refactor(tools): use structured tool error results

This commit is contained in:
chengyongru
2026-07-01 13:03:47 +08:00
committed by Xubin Ren
parent 8d2c31eb6a
commit 8493560976
20 changed files with 294 additions and 188 deletions
+2 -2
View File
@@ -18,7 +18,7 @@ from nanobot.agent.context_governance import (
ContextGovernor, ContextGovernor,
) )
from nanobot.agent.hook import AgentHook, AgentHookContext, AgentRunHookContext from nanobot.agent.hook import AgentHook, AgentHookContext, AgentRunHookContext
from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.registry import ToolRegistry, is_tool_error_result
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from nanobot.utils.file_edit_events import ( from nanobot.utils.file_edit_events import (
StreamingFileEditTracker, StreamingFileEditTracker,
@@ -1266,7 +1266,7 @@ class AgentRunner:
return payload, event, exc return payload, event, exc
return payload, event, None return payload, event, None
if isinstance(result, str) and result.startswith("Error"): if is_tool_error_result(tool_call.name, result):
if file_edit_trackers and progress_callback is not None: if file_edit_trackers and progress_callback is not None:
await invoke_file_edit_progress( await invoke_file_edit_progress(
progress_callback, progress_callback,
+2 -1
View File
@@ -1,6 +1,6 @@
"""Agent tools module.""" """Agent tools module."""
from nanobot.agent.tools.base import Schema, Tool, tool_parameters from nanobot.agent.tools.base import Schema, Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import ToolContext from nanobot.agent.tools.context import ToolContext
from nanobot.agent.tools.loader import ToolLoader from nanobot.agent.tools.loader import ToolLoader
from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.registry import ToolRegistry
@@ -25,6 +25,7 @@ __all__ = [
"Tool", "Tool",
"ToolContext", "ToolContext",
"ToolLoader", "ToolLoader",
"ToolResult",
"ToolRegistry", "ToolRegistry",
"tool_parameters", "tool_parameters",
"tool_parameters_schema", "tool_parameters_schema",
+4 -4
View File
@@ -7,7 +7,7 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from nanobot.agent.tools.base import tool_parameters from nanobot.agent.tools.base import ToolResult, tool_parameters
from nanobot.agent.tools.filesystem import _FsTool from nanobot.agent.tools.filesystem import _FsTool
from nanobot.agent.tools.schema import ( from nanobot.agent.tools.schema import (
ArraySchema, ArraySchema,
@@ -289,8 +289,8 @@ class ApplyPatchTool(_FsTool):
_format_summary(summary) for summary in summaries _format_summary(summary) for summary in summaries
) )
except PermissionError as exc: except PermissionError as exc:
return f"Error: {exc}" return ToolResult.error(f"Error: {exc}")
except _PatchError as exc: except _PatchError as exc:
return f"Error applying patch: {exc}" return ToolResult.error(f"Error applying patch: {exc}")
except Exception as exc: except Exception as exc:
return f"Error applying patch: {exc}" return ToolResult.error(f"Error applying patch: {exc}")
+20 -1
View File
@@ -128,6 +128,21 @@ class Schema(ABC):
return Schema.validate_json_schema_value(value, self.to_json_schema(), path) return Schema.validate_json_schema_value(value, self.to_json_schema(), path)
class ToolResult(str):
"""String-compatible tool output with structured status."""
is_error: bool
def __new__(cls, content: str, *, is_error: bool = False) -> ToolResult:
obj = str.__new__(cls, content)
obj.is_error = is_error
return obj
@classmethod
def error(cls, content: str) -> ToolResult:
return cls(content, is_error=True)
class Tool(ABC): class Tool(ABC):
"""Agent capability: read files, run commands, etc.""" """Agent capability: read files, run commands, etc."""
@@ -193,9 +208,13 @@ class Tool(ABC):
@abstractmethod @abstractmethod
async def execute(self, **kwargs: Any) -> Any: async def execute(self, **kwargs: Any) -> Any:
"""Run the tool; returns a string or list of content blocks.""" """Run the tool; return content, or ``ToolResult.error(...)`` for failures."""
... ...
@staticmethod
def error(content: str) -> ToolResult:
return ToolResult.error(content)
def _cast_object(self, obj: Any, schema: dict[str, Any]) -> dict[str, Any]: def _cast_object(self, obj: Any, schema: dict[str, Any]) -> dict[str, Any]:
if not isinstance(obj, dict): if not isinstance(obj, dict):
return obj return obj
+2 -2
View File
@@ -7,7 +7,7 @@ from typing import Any
from pydantic import Field from pydantic import Field
from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.schema import ( from nanobot.agent.tools.schema import (
ArraySchema, ArraySchema,
BooleanSchema, BooleanSchema,
@@ -136,4 +136,4 @@ class CliAppsTool(Tool):
restrict_to_workspace=access.restrict_to_workspace, restrict_to_workspace=access.restrict_to_workspace,
) )
except CliAppError as exc: except CliAppError as exc:
return f"Error: {exc.message}" return ToolResult.error(f"Error: {exc.message}")
+10 -10
View File
@@ -6,7 +6,7 @@ from contextvars import ContextVar
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import ContextAware, RequestContext from nanobot.agent.tools.context import ContextAware, RequestContext
from nanobot.agent.tools.schema import ( from nanobot.agent.tools.schema import (
IntegerSchema, IntegerSchema,
@@ -99,7 +99,7 @@ class CronTool(Tool, ContextAware):
try: try:
ZoneInfo(tz) ZoneInfo(tz)
except (KeyError, Exception): except (KeyError, Exception):
return f"Error: unknown timezone '{tz}'" return ToolResult.error(f"Error: unknown timezone '{tz}'")
return None return None
def _display_timezone(self, schedule: CronSchedule) -> str: def _display_timezone(self, schedule: CronSchedule) -> str:
@@ -148,7 +148,7 @@ class CronTool(Tool, ContextAware):
) -> str: ) -> str:
if action == "add": if action == "add":
if self._in_cron_context.get(): if self._in_cron_context.get():
return "Error: cannot schedule new jobs from within a cron job execution" return ToolResult.error("Error: cannot schedule new jobs from within a cron job execution")
return self._add_job(name, message, every_seconds, cron_expr, tz, at) return self._add_job(name, message, every_seconds, cron_expr, tz, at)
elif action == "list": elif action == "list":
return self._list_jobs() return self._list_jobs()
@@ -166,20 +166,20 @@ class CronTool(Tool, ContextAware):
at: str | None, at: str | None,
) -> str: ) -> str:
if not message: if not message:
return ( return ToolResult.error(
"Error: cron action='add' requires a non-empty 'message' parameter " "Error: cron action='add' requires a non-empty 'message' parameter "
"describing what to do when the job triggers " "describing what to do when the job triggers "
"(e.g. the reminder text). Retry including message=\"...\"." "(e.g. the reminder text). Retry including message=\"...\"."
) )
session_key = self._session_key.get() session_key = self._session_key.get()
if not session_key: if not session_key:
return "Error: scheduled cron jobs must be created from a chat session" return ToolResult.error("Error: scheduled cron jobs must be created from a chat session")
origin_channel = self._origin_channel.get() origin_channel = self._origin_channel.get()
origin_chat_id = self._origin_chat_id.get() origin_chat_id = self._origin_chat_id.get()
if not origin_channel or not origin_chat_id: if not origin_channel or not origin_chat_id:
return "Error: scheduled cron jobs must be created from a chat session" return ToolResult.error("Error: scheduled cron jobs must be created from a chat session")
if tz and not cron_expr: if tz and not cron_expr:
return "Error: tz can only be used with cron_expr" return ToolResult.error("Error: tz can only be used with cron_expr")
if tz: if tz:
if err := self._validate_timezone(tz): if err := self._validate_timezone(tz):
return err return err
@@ -199,7 +199,7 @@ class CronTool(Tool, ContextAware):
try: try:
dt = datetime.fromisoformat(at) dt = datetime.fromisoformat(at)
except ValueError: except ValueError:
return f"Error: invalid ISO datetime format '{at}'. Expected format: YYYY-MM-DDTHH:MM:SS" return ToolResult.error(f"Error: invalid ISO datetime format '{at}'. Expected format: YYYY-MM-DDTHH:MM:SS")
if dt.tzinfo is None: if dt.tzinfo is None:
if err := self._validate_timezone(self._default_timezone): if err := self._validate_timezone(self._default_timezone):
return err return err
@@ -208,7 +208,7 @@ class CronTool(Tool, ContextAware):
schedule = CronSchedule(kind="at", at_ms=at_ms) schedule = CronSchedule(kind="at", at_ms=at_ms)
delete_after = True delete_after = True
else: else:
return "Error: either every_seconds, cron_expr, or at is required" return ToolResult.error("Error: either every_seconds, cron_expr, or at is required")
job = self._cron.add_job( job = self._cron.add_job(
name=name or message[:30], name=name or message[:30],
@@ -279,7 +279,7 @@ class CronTool(Tool, ContextAware):
def _remove_job(self, job_id: str | None) -> str: def _remove_job(self, job_id: str | None) -> str:
if not job_id: if not job_id:
return "Error: job_id is required for remove" return ToolResult.error("Error: job_id is required for remove")
result = self._cron.remove_job(job_id) result = self._cron.remove_job(job_id)
if result == "removed": if result == "removed":
return f"Removed job {job_id}" return f"Removed job {job_id}"
+9 -7
View File
@@ -9,7 +9,7 @@ from contextlib import suppress
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import current_request_session_key from nanobot.agent.tools.context import current_request_session_key
from nanobot.agent.tools.schema import ( from nanobot.agent.tools.schema import (
BooleanSchema, BooleanSchema,
@@ -492,11 +492,12 @@ class WriteStdinTool(Tool):
max_output_chars=output_limit, max_output_chars=output_limit,
owner_session_key=current_request_session_key(), owner_session_key=current_request_session_key(),
) )
return format_session_poll(session_id, poll) result = format_session_poll(session_id, poll)
return ToolResult.error(result) if poll.timed_out else result
except KeyError: except KeyError:
return f"Error: exec session not found: {session_id}" return ToolResult.error(f"Error: exec session not found: {session_id!r}")
except Exception as exc: except Exception as exc:
return f"Error writing to exec session: {exc}" return ToolResult.error(f"Error writing to exec session: {exc}")
async def _wait_for_output( async def _wait_for_output(
self, self,
@@ -532,13 +533,14 @@ class WriteStdinTool(Tool):
joined = "".join(aggregate) joined = "".join(aggregate)
if wait_for in joined: if wait_for in joined:
poll.output = joined poll.output = joined
return format_session_poll(session_id, poll) result = format_session_poll(session_id, poll)
return ToolResult.error(result) if poll.timed_out else result
if poll.done or remaining_ms <= 0: if poll.done or remaining_ms <= 0:
poll.output = "".join(aggregate) poll.output = "".join(aggregate)
result = format_session_poll(session_id, poll) result = format_session_poll(session_id, poll)
if wait_for not in poll.output: if wait_for not in poll.output:
result += f"\nWait target not observed: {wait_for!r}" result += f"\nWait target not observed: {wait_for!r}"
return result return ToolResult.error(result) if poll.timed_out else result
@tool_parameters(tool_parameters_schema()) @tool_parameters(tool_parameters_schema())
@@ -606,4 +608,4 @@ class ListExecSessionsTool(Tool):
) )
return "\n".join(lines) return "\n".join(lines)
except Exception as exc: except Exception as exc:
return f"Error listing exec sessions: {exc}" return ToolResult.error(f"Error listing exec sessions: {exc}")
+40 -40
View File
@@ -7,7 +7,7 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.file_state import FileStates, _hash_file, current_file_states from nanobot.agent.tools.file_state import FileStates, _hash_file, current_file_states
from nanobot.agent.tools.path_utils import resolve_workspace_path from nanobot.agent.tools.path_utils import resolve_workspace_path
from nanobot.agent.tools.schema import ( from nanobot.agent.tools.schema import (
@@ -268,19 +268,19 @@ class ReadFileTool(_FsTool):
) -> Any: ) -> Any:
try: try:
if not path: if not path:
return "Error reading file: Unknown path" return ToolResult.error("Error reading file: Unknown path")
# Device path blacklist # Device path blacklist
if _is_blocked_device(path): if _is_blocked_device(path):
return f"Error: Reading {path} is blocked (device path that could hang or produce infinite output)." return ToolResult.error(f"Error: Reading {path} is blocked (device path that could hang or produce infinite output).")
fp = self._resolve_read(path) fp = self._resolve_read(path)
if _is_blocked_device(fp): if _is_blocked_device(fp):
return f"Error: Reading {fp} is blocked (device path that could hang or produce infinite output)." return ToolResult.error(f"Error: Reading {fp} is blocked (device path that could hang or produce infinite output).")
if not fp.exists(): if not fp.exists():
return f"Error: File not found: {path}" return ToolResult.error(f"Error: File not found: {path}")
if not fp.is_file(): if not fp.is_file():
return f"Error: Not a file: {path}" return ToolResult.error(f"Error: Not a file: {path}")
# PDF support # PDF support
if fp.suffix.lower() == ".pdf": if fp.suffix.lower() == ".pdf":
@@ -343,7 +343,7 @@ class ReadFileTool(_FsTool):
mime = detect_image_mime(raw) or mimetypes.guess_type(path)[0] mime = detect_image_mime(raw) or mimetypes.guess_type(path)[0]
if mime and mime.startswith("image/"): if mime and mime.startswith("image/"):
return build_image_content_blocks(raw, mime, str(fp), f"(Image file: {path})") return build_image_content_blocks(raw, mime, str(fp), f"(Image file: {path})")
return f"Error: Cannot read binary file {path} (MIME: {mime or 'unknown'}). Only UTF-8 text and images are supported." return ToolResult.error(f"Error: Cannot read binary file {path} (MIME: {mime or 'unknown'}). Only UTF-8 text and images are supported.")
# Normalize CRLF -> LF before line-splitting. Primarily a Windows # Normalize CRLF -> LF before line-splitting. Primarily a Windows
# concern (git checkouts with autocrlf, editors saving CRLF) but # concern (git checkouts with autocrlf, editors saving CRLF) but
@@ -357,7 +357,7 @@ class ReadFileTool(_FsTool):
if offset < 1: if offset < 1:
offset = 1 offset = 1
if offset > total: if offset > total:
return f"Error: offset {offset} is beyond end of file ({total} lines)" return ToolResult.error(f"Error: offset {offset} is beyond end of file ({total} lines)")
start = offset - 1 start = offset - 1
end = min(start + (limit or self._DEFAULT_LIMIT), total) end = min(start + (limit or self._DEFAULT_LIMIT), total)
@@ -381,20 +381,20 @@ class ReadFileTool(_FsTool):
self._file_states.record_read(fp, offset=offset, limit=limit) self._file_states.record_read(fp, offset=offset, limit=limit)
return result return result
except PermissionError as e: except PermissionError as e:
return f"Error: {e}" return ToolResult.error(f"Error: {e}")
except Exception as e: except Exception as e:
return f"Error reading file: {e}" return ToolResult.error(f"Error reading file: {e}")
def _read_pdf(self, fp: Path, pages: str | None) -> str: def _read_pdf(self, fp: Path, pages: str | None) -> str:
try: try:
import fitz # pymupdf import fitz # pymupdf
except ImportError: except ImportError:
return "Error: PDF reading requires pymupdf. Install with: pip install pymupdf" return ToolResult.error("Error: PDF reading requires pymupdf. Install with: pip install pymupdf")
try: try:
doc = fitz.open(str(fp)) doc = fitz.open(str(fp))
except Exception as e: except Exception as e:
return f"Error reading PDF: {e}" return ToolResult.error(f"Error reading PDF: {e}")
total_pages = len(doc) total_pages = len(doc)
if pages: if pages:
@@ -402,10 +402,10 @@ class ReadFileTool(_FsTool):
start, end = _parse_page_range(pages, total_pages) start, end = _parse_page_range(pages, total_pages)
except (ValueError, IndexError): except (ValueError, IndexError):
doc.close() doc.close()
return f"Error: Invalid page range '{pages}'. Use format like '1-5'." return ToolResult.error(f"Error: Invalid page range '{pages}'. Use format like '1-5'.")
if start > end or start >= total_pages: if start > end or start >= total_pages:
doc.close() doc.close()
return f"Error: Page range '{pages}' is out of bounds (document has {total_pages} pages)." return ToolResult.error(f"Error: Page range '{pages}' is out of bounds (document has {total_pages} pages).")
else: else:
start = 0 start = 0
end = min(total_pages - 1, self._MAX_PDF_PAGES - 1) end = min(total_pages - 1, self._MAX_PDF_PAGES - 1)
@@ -437,10 +437,10 @@ class ReadFileTool(_FsTool):
result = extract_text(fp) result = extract_text(fp)
if result is None: if result is None:
return f"Error: Unsupported file format: {fp.suffix}" return ToolResult.error(f"Error: Unsupported file format: {fp.suffix}")
if result.startswith("[error:"): if result.startswith("[error:"):
return f"Error reading {fp.suffix.upper()} file: {result}" return ToolResult.error(f"Error reading {fp.suffix.upper()} file: {result}")
if not result: if not result:
return f"({fp.suffix.upper().lstrip('.')} has no extractable text: {fp})" return f"({fp.suffix.upper().lstrip('.')} has no extractable text: {fp})"
@@ -492,9 +492,9 @@ class WriteFileTool(_FsTool):
self._file_states.record_write(fp) self._file_states.record_write(fp)
return f"Successfully wrote {len(content)} characters to {fp}" return f"Successfully wrote {len(content)} characters to {fp}"
except PermissionError as e: except PermissionError as e:
return f"Error: {e}" return ToolResult.error(f"Error: {e}")
except Exception as e: except Exception as e:
return f"Error writing file: {e}" return ToolResult.error(f"Error writing file: {e}")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -830,11 +830,11 @@ class EditFileTool(_FsTool):
if new_text is None: if new_text is None:
raise ValueError("Unknown new_text") raise ValueError("Unknown new_text")
if occurrence is not None and occurrence < 1: if occurrence is not None and occurrence < 1:
return "Error: occurrence must be >= 1." return ToolResult.error("Error: occurrence must be >= 1.")
if line_hint is not None and line_hint < 1: if line_hint is not None and line_hint < 1:
return "Error: line_hint must be >= 1." return ToolResult.error("Error: line_hint must be >= 1.")
if expected_replacements is not None and expected_replacements < 1: if expected_replacements is not None and expected_replacements < 1:
return "Error: expected_replacements must be >= 1." return ToolResult.error("Error: expected_replacements must be >= 1.")
fp = self._resolve_write(path) fp = self._resolve_write(path)
@@ -853,14 +853,14 @@ class EditFileTool(_FsTool):
except OSError: except OSError:
fsize = 0 fsize = 0
if fsize > self._MAX_EDIT_FILE_SIZE: if fsize > self._MAX_EDIT_FILE_SIZE:
return f"Error: File too large to edit ({fsize / (1024**3):.1f} GiB). Maximum is 1 GiB." return ToolResult.error(f"Error: File too large to edit ({fsize / (1024**3):.1f} GiB). Maximum is 1 GiB.")
# Create-file: old_text='' but file exists and not empty → reject # Create-file: old_text='' but file exists and not empty → reject
if old_text == "": if old_text == "":
raw = fp.read_bytes() raw = fp.read_bytes()
content = raw.decode("utf-8") content = raw.decode("utf-8")
if content.strip(): if content.strip():
return f"Error: Cannot create file — {path} already exists and is not empty." return ToolResult.error(f"Error: Cannot create file — {path} already exists and is not empty.")
fp.write_text(new_text, encoding="utf-8") fp.write_text(new_text, encoding="utf-8")
self._file_states.record_write(fp) self._file_states.record_write(fp)
return f"Successfully edited {fp}" return f"Successfully edited {fp}"
@@ -878,15 +878,15 @@ class EditFileTool(_FsTool):
return self._not_found_msg(old_text, content, path) return self._not_found_msg(old_text, content, path)
count = len(matches) count = len(matches)
if replace_all and occurrence is not None: if replace_all and occurrence is not None:
return "Error: occurrence cannot be used with replace_all=true." return ToolResult.error("Error: occurrence cannot be used with replace_all=true.")
if replace_all and line_hint is not None: if replace_all and line_hint is not None:
return "Error: line_hint cannot be used with replace_all=true." return ToolResult.error("Error: line_hint cannot be used with replace_all=true.")
if occurrence is not None and line_hint is not None: if occurrence is not None and line_hint is not None:
return "Error: line_hint cannot be used with occurrence." return ToolResult.error("Error: line_hint cannot be used with occurrence.")
if count > 1 and not replace_all: if count > 1 and not replace_all:
if occurrence is not None: if occurrence is not None:
if occurrence > count: if occurrence > count:
return ( return ToolResult.error(
f"Error: occurrence {occurrence} is out of range; " f"Error: occurrence {occurrence} is out of range; "
f"old_text appears {count} times." f"old_text appears {count} times."
) )
@@ -894,7 +894,7 @@ class EditFileTool(_FsTool):
nearest = min(matches, key=lambda match: abs(match.line - line_hint)) nearest = min(matches, key=lambda match: abs(match.line - line_hint))
distance = abs(nearest.line - line_hint) distance = abs(nearest.line - line_hint)
if sum(1 for match in matches if abs(match.line - line_hint) == distance) > 1: if sum(1 for match in matches if abs(match.line - line_hint) == distance) > 1:
return ( return ToolResult.error(
f"Error: line_hint {line_hint} is ambiguous; " f"Error: line_hint {line_hint} is ambiguous; "
f"old_text appears {count} times." f"old_text appears {count} times."
) )
@@ -910,7 +910,7 @@ class EditFileTool(_FsTool):
"or set replace_all=true." "or set replace_all=true."
) )
elif occurrence is not None and occurrence > count: elif occurrence is not None and occurrence > count:
return ( return ToolResult.error(
f"Error: occurrence {occurrence} is out of range; " f"Error: occurrence {occurrence} is out of range; "
f"old_text appears {count} time." f"old_text appears {count} time."
) )
@@ -928,7 +928,7 @@ class EditFileTool(_FsTool):
else: else:
selected = [matches[occurrence - 1 if occurrence else 0]] selected = [matches[occurrence - 1 if occurrence else 0]]
if expected_replacements is not None and len(selected) != expected_replacements: if expected_replacements is not None and len(selected) != expected_replacements:
return ( return ToolResult.error(
f"Error: expected {expected_replacements} replacements but " f"Error: expected {expected_replacements} replacements but "
f"would make {len(selected)}." f"would make {len(selected)}."
) )
@@ -954,9 +954,9 @@ class EditFileTool(_FsTool):
msg = f"{warning}\n{msg}" msg = f"{warning}\n{msg}"
return msg return msg
except PermissionError as e: except PermissionError as e:
return f"Error: {e}" return ToolResult.error(f"Error: {e}")
except Exception as e: except Exception as e:
return f"Error editing file: {e}" return ToolResult.error(f"Error editing file: {e}")
def _file_not_found_msg(self, path: str, fp: Path) -> str: def _file_not_found_msg(self, path: str, fp: Path) -> str:
"""Build an error message with 'Did you mean ...?' suggestions.""" """Build an error message with 'Did you mean ...?' suggestions."""
@@ -969,7 +969,7 @@ class EditFileTool(_FsTool):
parts = [f"Error: File not found: {path}"] parts = [f"Error: File not found: {path}"]
if suggestions: if suggestions:
parts.append("Did you mean: " + ", ".join(suggestions) + "?") parts.append("Did you mean: " + ", ".join(suggestions) + "?")
return "\n".join(parts) return ToolResult.error("\n".join(parts))
@staticmethod @staticmethod
def _not_found_msg(old_text: str, content: str, path: str) -> str: def _not_found_msg(old_text: str, content: str, path: str) -> str:
@@ -985,18 +985,18 @@ class EditFileTool(_FsTool):
hint_text = "" hint_text = ""
if hints: if hints:
hint_text = "\nPossible cause: " + ", ".join(hints) + "." hint_text = "\nPossible cause: " + ", ".join(hints) + "."
return ( return ToolResult.error(
f"Error: old_text not found in {path}." f"Error: old_text not found in {path}."
f"{hint_text}\nBest match ({best_ratio:.0%} similar) at line {best_start + 1}:\n{diff}" f"{hint_text}\nBest match ({best_ratio:.0%} similar) at line {best_start + 1}:\n{diff}"
) )
if hints: if hints:
return ( return ToolResult.error(
f"Error: old_text not found in {path}. " f"Error: old_text not found in {path}. "
f"Possible cause: {', '.join(hints)}. " f"Possible cause: {', '.join(hints)}. "
"Copy the exact text from read_file and try again." "Copy the exact text from read_file and try again."
) )
return f"Error: old_text not found in {path}. No similar text found. Verify the file content." return ToolResult.error(f"Error: old_text not found in {path}. No similar text found. Verify the file content.")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1051,9 +1051,9 @@ class ListDirTool(_FsTool):
raise ValueError("Unknown path") raise ValueError("Unknown path")
dp = self._resolve(path) dp = self._resolve(path)
if not dp.exists(): if not dp.exists():
return f"Error: Directory not found: {path}" return ToolResult.error(f"Error: Directory not found: {path}")
if not dp.is_dir(): if not dp.is_dir():
return f"Error: Not a directory: {path}" return ToolResult.error(f"Error: Not a directory: {path}")
cap = max_entries or self._DEFAULT_MAX cap = max_entries or self._DEFAULT_MAX
items: list[str] = [] items: list[str] = []
@@ -1084,6 +1084,6 @@ class ListDirTool(_FsTool):
result += f"\n\n(truncated, showing first {cap} of {total} entries)" result += f"\n\n(truncated, showing first {cap} of {total} entries)"
return result return result
except PermissionError as e: except PermissionError as e:
return f"Error: {e}" return ToolResult.error(f"Error: {e}")
except Exception as e: except Exception as e:
return f"Error listing directory: {e}" return ToolResult.error(f"Error listing directory: {e}")
+4 -4
View File
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any
from pydantic import Field from pydantic import Field
from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.schema import ( from nanobot.agent.tools.schema import (
ArraySchema, ArraySchema,
IntegerSchema, IntegerSchema,
@@ -172,11 +172,11 @@ class ImageGenerationTool(Tool):
) -> str: ) -> str:
client = self._provider_client() client = self._provider_client()
if client is None: if client is None:
return f"Error: unsupported image generation provider '{self.config.provider}'" return ToolResult.error(f"Error: unsupported image generation provider '{self.config.provider}'")
requested = count or 1 requested = count or 1
if requested > self.config.max_images_per_turn: if requested > self.config.max_images_per_turn:
return ( return ToolResult.error(
"Error: count exceeds tools.imageGeneration.maxImagesPerTurn " "Error: count exceeds tools.imageGeneration.maxImagesPerTurn "
f"({self.config.max_images_per_turn})" f"({self.config.max_images_per_turn})"
) )
@@ -206,4 +206,4 @@ class ImageGenerationTool(Tool):
break break
return generated_image_tool_result(artifacts) return generated_image_tool_result(artifacts)
except (ArtifactError, ImageGenerationError, OSError) as exc: except (ArtifactError, ImageGenerationError, OSError) as exc:
return f"Error: {exc}" return ToolResult.error(f"Error: {exc}")
+4 -4
View File
@@ -20,7 +20,7 @@ from contextvars import ContextVar
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import ContextAware, RequestContext from nanobot.agent.tools.context import ContextAware, RequestContext
from nanobot.agent.tools.schema import StringSchema, tool_parameters_schema from nanobot.agent.tools.schema import StringSchema, tool_parameters_schema
from nanobot.bus.runtime_events import GoalStateChanged, RuntimeEventBus, RuntimeEventContext from nanobot.bus.runtime_events import GoalStateChanged, RuntimeEventBus, RuntimeEventContext
@@ -150,12 +150,12 @@ class LongTaskTool(Tool, _GoalToolsMixin):
async def execute(self, goal: str, ui_summary: str | None = None, **kwargs: Any) -> str: async def execute(self, goal: str, ui_summary: str | None = None, **kwargs: Any) -> str:
sess = self._session() sess = self._session()
if sess is None: if sess is None:
return ( return ToolResult.error(
"Error: long_task requires an active chat session (missing routing context)." "Error: long_task requires an active chat session (missing routing context)."
) )
prior = parse_goal_state(goal_state_raw(sess.metadata)) prior = parse_goal_state(goal_state_raw(sess.metadata))
if isinstance(prior, dict) and prior.get("status") == "active": if isinstance(prior, dict) and prior.get("status") == "active":
return ( return ToolResult.error(
"Error: a sustained goal is already active. " "Error: a sustained goal is already active. "
"Use complete_goal when finished, or ask the user before replacing it." "Use complete_goal when finished, or ask the user before replacing it."
) )
@@ -230,7 +230,7 @@ class CompleteGoalTool(Tool, _GoalToolsMixin):
async def execute(self, recap: str | None = None, **kwargs: Any) -> str: async def execute(self, recap: str | None = None, **kwargs: Any) -> str:
sess = self._session() sess = self._session()
if sess is None: if sess is None:
return "Error: complete_goal requires an active chat session." return ToolResult.error("Error: complete_goal requires an active chat session.")
prior = parse_goal_state(goal_state_raw(sess.metadata)) prior = parse_goal_state(goal_state_raw(sess.metadata))
if not isinstance(prior, dict) or prior.get("status") != "active": if not isinstance(prior, dict) or prior.get("status") != "active":
return "No active goal to complete." return "No active goal to complete."
+7 -7
View File
@@ -6,7 +6,7 @@ from typing import Any, Awaitable, Callable
from loguru import logger from loguru import logger
from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import ContextAware, RequestContext from nanobot.agent.tools.context import ContextAware, RequestContext
from nanobot.agent.tools.path_utils import resolve_workspace_path from nanobot.agent.tools.path_utils import resolve_workspace_path
from nanobot.agent.tools.schema import ArraySchema, StringSchema, tool_parameters_schema from nanobot.agent.tools.schema import ArraySchema, StringSchema, tool_parameters_schema
@@ -198,7 +198,7 @@ class MessageTool(Tool, ContextAware):
not isinstance(row, list) or any(not isinstance(label, str) for label in row) not isinstance(row, list) or any(not isinstance(label, str) for label in row)
for row in buttons for row in buttons
): ):
return "Error: buttons must be a list of list of strings" return ToolResult.error("Error: buttons must be a list of list of strings")
default_channel = self._default_channel.get() default_channel = self._default_channel.get()
default_chat_id = self._default_chat_id.get() default_chat_id = self._default_chat_id.get()
channel = channel or default_channel channel = channel or default_channel
@@ -210,7 +210,7 @@ class MessageTool(Tool, ContextAware):
and str(explicit_chat_id).strip() != "" and str(explicit_chat_id).strip() != ""
and str(explicit_chat_id).strip() != str(default_chat_id).strip() and str(explicit_chat_id).strip() != str(default_chat_id).strip()
): ):
return ( return ToolResult.error(
"Error: chat_id does not match the active WebSocket conversation. " "Error: chat_id does not match the active WebSocket conversation. "
"Omit chat_id (and usually channel) so delivery uses the current " "Omit chat_id (and usually channel) so delivery uses the current "
"conversation id from context — WebSocket client_id strings " "conversation id from context — WebSocket client_id strings "
@@ -229,16 +229,16 @@ class MessageTool(Tool, ContextAware):
message_id = None message_id = None
if not channel or not chat_id: if not channel or not chat_id:
return "Error: No target channel/chat specified" return ToolResult.error("Error: No target channel/chat specified")
if not self._send_callback: if not self._send_callback:
return "Error: Message sending not configured" return ToolResult.error("Error: Message sending not configured")
if media: if media:
try: try:
media = self._resolve_media(media) media = self._resolve_media(media)
except (OSError, PermissionError, ValueError) as e: except (OSError, PermissionError, ValueError) as e:
return f"Error: media path is not allowed: {str(e)}" return ToolResult.error(f"Error: media path is not allowed: {str(e)}")
metadata = dict(self._default_metadata.get()) if same_target else {} metadata = dict(self._default_metadata.get()) if same_target else {}
if message_id: if message_id:
@@ -270,4 +270,4 @@ class MessageTool(Tool, ContextAware):
button_info = f" with {sum(len(row) for row in buttons)} button(s)" if buttons else "" button_info = f" with {sum(len(row) for row in buttons)} button(s)" if buttons else ""
return f"Message sent to {channel}:{chat_id}{media_info}{button_info}" return f"Message sent to {channel}:{chat_id}{media_info}{button_info}"
except Exception as e: except Exception as e:
return f"Error sending message: {str(e)}" return ToolResult.error(f"Error sending message: {str(e)}")
+18 -10
View File
@@ -3,7 +3,11 @@
import json import json
from typing import Any from typing import Any
from nanobot.agent.tools.base import Tool from nanobot.agent.tools.base import Tool, ToolResult
def is_tool_error_result(name: str, result: Any) -> bool:
return isinstance(result, ToolResult) and result.is_error
class ToolRegistry: class ToolRegistry:
@@ -100,22 +104,26 @@ class ToolRegistry:
suggestion = self._suggest_name(str(name)) suggestion = self._suggest_name(str(name))
hint = f" Did you mean '{suggestion}'? Tool names must match exactly." if suggestion else "" hint = f" Did you mean '{suggestion}'? Tool names must match exactly." if suggestion else ""
return None, params, ( return None, params, (
f"Error: Tool '{name}' not found.{hint} Available: {', '.join(self.tool_names)}" ToolResult.error(
f"Error: Tool '{name}' not found.{hint} Available: {', '.join(self.tool_names)}"
)
) )
params = self._coerce_params(tool, params) params = self._coerce_params(tool, params)
if not isinstance(params, dict): if not isinstance(params, dict):
return tool, params, ( return tool, params, (
f"Error: Tool '{name}' parameters must be a JSON object, got " ToolResult.error(
f"{type(params).__name__}. Use named parameters like " f"Error: Tool '{name}' parameters must be a JSON object, got "
'tool_name(param1="value1", param2="value2") matching the tool schema.' f"{type(params).__name__}. Use named parameters like "
'tool_name(param1="value1", param2="value2") matching the tool schema.'
)
) )
cast_params = tool.cast_params(params) cast_params = tool.cast_params(params)
errors = tool.validate_params(cast_params) errors = tool.validate_params(cast_params)
if errors: if errors:
return tool, cast_params, ( return tool, cast_params, (
f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors) ToolResult.error(f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors))
) )
return tool, cast_params, None return tool, cast_params, None
@@ -159,16 +167,16 @@ class ToolRegistry:
hint = "\n\n[Analyze the error above and try a different approach.]" hint = "\n\n[Analyze the error above and try a different approach.]"
tool, params, error = self.prepare_call(name, params) tool, params, error = self.prepare_call(name, params)
if error: if error:
return error + hint return ToolResult.error(str(error) + hint)
try: try:
assert tool is not None # guarded by prepare_call() assert tool is not None # guarded by prepare_call()
result = await tool.execute(**params) result = await tool.execute(**params)
if isinstance(result, str) and result.startswith("Error"): if is_tool_error_result(name, result):
return result + hint return ToolResult.error(str(result) + hint)
return result return result
except Exception as e: except Exception as e:
return f"Error executing {name}: {str(e)}" + hint return ToolResult.error(f"Error executing {name}: {str(e)}" + hint)
@property @property
def tool_names(self) -> list[str]: def tool_names(self) -> list[str]:
+11 -10
View File
@@ -9,6 +9,7 @@ from contextlib import suppress
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
from typing import Any, Iterable, TypeVar from typing import Any, Iterable, TypeVar
from nanobot.agent.tools.base import ToolResult
from nanobot.agent.tools.filesystem import ListDirTool, _FsTool from nanobot.agent.tools.filesystem import ListDirTool, _FsTool
_DEFAULT_HEAD_LIMIT = 250 _DEFAULT_HEAD_LIMIT = 250
@@ -218,12 +219,12 @@ class FindFilesTool(_SearchTool):
try: try:
target = self._resolve(path or ".") target = self._resolve(path or ".")
if not target.exists(): if not target.exists():
return f"Error: Path not found: {path}" return ToolResult.error(f"Error: Path not found: {path}")
if not (target.is_dir() or target.is_file()): if not (target.is_dir() or target.is_file()):
return f"Error: Unsupported path: {path}" return ToolResult.error(f"Error: Unsupported path: {path}")
if sort not in {"path", "modified"}: if sort not in {"path", "modified"}:
return "Error: sort must be 'path' or 'modified'" return ToolResult.error("Error: sort must be 'path' or 'modified'")
limit = ( limit = (
_DEFAULT_FILE_HEAD_LIMIT _DEFAULT_FILE_HEAD_LIMIT
@@ -271,9 +272,9 @@ class FindFilesTool(_SearchTool):
result += "\n\n" + note result += "\n\n" + note
return result return result
except PermissionError as e: except PermissionError as e:
return f"Error: {e}" return ToolResult.error(f"Error: {e}")
except Exception as e: except Exception as e:
return f"Error finding files: {e}" return ToolResult.error(f"Error finding files: {e}")
class GrepTool(_SearchTool): class GrepTool(_SearchTool):
@@ -425,16 +426,16 @@ class GrepTool(_SearchTool):
try: try:
target = self._resolve(path or ".") target = self._resolve(path or ".")
if not target.exists(): if not target.exists():
return f"Error: Path not found: {path}" return ToolResult.error(f"Error: Path not found: {path}")
if not (target.is_dir() or target.is_file()): if not (target.is_dir() or target.is_file()):
return f"Error: Unsupported path: {path}" return ToolResult.error(f"Error: Unsupported path: {path}")
flags = re.IGNORECASE if case_insensitive else 0 flags = re.IGNORECASE if case_insensitive else 0
try: try:
needle = re.escape(pattern) if fixed_strings else pattern needle = re.escape(pattern) if fixed_strings else pattern
regex = re.compile(needle, flags) regex = re.compile(needle, flags)
except re.error as e: except re.error as e:
return f"Error: invalid regex pattern: {e}" return ToolResult.error(f"Error: invalid regex pattern: {e}")
if head_limit is not None: if head_limit is not None:
limit = None if head_limit == 0 else head_limit limit = None if head_limit == 0 else head_limit
@@ -579,6 +580,6 @@ class GrepTool(_SearchTool):
result += "\n\n" + "\n".join(notes) result += "\n\n" + "\n".join(notes)
return result return result
except PermissionError as e: except PermissionError as e:
return f"Error: {e}" return ToolResult.error(f"Error: {e}")
except Exception as e: except Exception as e:
return f"Error searching files: {e}" return ToolResult.error(f"Error searching files: {e}")
+24 -24
View File
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any
from loguru import logger from loguru import logger
from nanobot.agent.tools.base import Tool from nanobot.agent.tools.base import Tool, ToolResult
from nanobot.agent.tools.context import ContextAware, RequestContext from nanobot.agent.tools.context import ContextAware, RequestContext
from nanobot.agent.tools.runtime_state import RuntimeState from nanobot.agent.tools.runtime_state import RuntimeState
from nanobot.config_base import Base from nanobot.config_base import Base
@@ -216,7 +216,7 @@ class MyTool(Tool, ContextAware):
@staticmethod @staticmethod
def _validate_key(key: str | None, label: str = "key") -> str | None: def _validate_key(key: str | None, label: str = "key") -> str | None:
if not key or not key.strip(): if not key or not key.strip():
return f"Error: '{label}' cannot be empty or whitespace" return ToolResult.error(f"Error: '{label}' cannot be empty or whitespace")
return None return None
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -321,7 +321,7 @@ class MyTool(Tool, ContextAware):
if action in ("inspect", "check"): if action in ("inspect", "check"):
return self._inspect(key) return self._inspect(key)
if not self._modify_allowed: if not self._modify_allowed:
return "Error: set is disabled (tools.my.allow_set is false)" return ToolResult.error("Error: set is disabled (tools.my.allow_set is false)")
if action in ("modify", "set"): if action in ("modify", "set"):
return self._modify(key, value) return self._modify(key, value)
return f"Unknown action: {action}" return f"Unknown action: {action}"
@@ -333,7 +333,7 @@ class MyTool(Tool, ContextAware):
return self._inspect_all() return self._inspect_all()
top = key.split(".")[0] top = key.split(".")[0]
if top in self._DENIED_ATTRS or top.startswith("__"): if top in self._DENIED_ATTRS or top.startswith("__"):
return f"Error: '{top}' is not accessible" return ToolResult.error(f"Error: '{top}' is not accessible")
obj, err = self._resolve_path(key) obj, err = self._resolve_path(key)
if err: if err:
# "scratchpad" alias for _runtime_vars # "scratchpad" alias for _runtime_vars
@@ -343,12 +343,12 @@ class MyTool(Tool, ContextAware):
# Fallback: check _runtime_vars for simple keys stored by modify # Fallback: check _runtime_vars for simple keys stored by modify
if "." not in key and key in self._runtime_state._runtime_vars: if "." not in key and key in self._runtime_state._runtime_vars:
return self._format_value(self._runtime_state._runtime_vars[key], key) return self._format_value(self._runtime_state._runtime_vars[key], key)
return f"Error: {err}" return ToolResult.error(f"Error: {err}")
# Guard against mock auto-generated attributes # Guard against mock auto-generated attributes
if "." not in key and not _has_real_attr(self._runtime_state, key): if "." not in key and not _has_real_attr(self._runtime_state, key):
if key in self._runtime_state._runtime_vars: if key in self._runtime_state._runtime_vars:
return self._format_value(self._runtime_state._runtime_vars[key], key) return self._format_value(self._runtime_state._runtime_vars[key], key)
return f"Error: '{key}' not found" return ToolResult.error(f"Error: '{key}' not found")
return self._format_value(obj, key) return self._format_value(obj, key)
def _inspect_all(self) -> str: def _inspect_all(self) -> str:
@@ -379,21 +379,21 @@ class MyTool(Tool, ContextAware):
top = key.split(".")[0] top = key.split(".")[0]
if top in self.BLOCKED or top in self._DENIED_ATTRS or top.startswith("__") or top.lower() in self._SENSITIVE_NAMES: if top in self.BLOCKED or top in self._DENIED_ATTRS or top.startswith("__") or top.lower() in self._SENSITIVE_NAMES:
self._audit("modify", f"BLOCKED {key}") self._audit("modify", f"BLOCKED {key}")
return f"Error: '{key}' is protected and cannot be modified" return ToolResult.error(f"Error: '{key}' is protected and cannot be modified")
if top in self.READ_ONLY: if top in self.READ_ONLY:
self._audit("modify", f"READ_ONLY {key}") self._audit("modify", f"READ_ONLY {key}")
return f"Error: '{key}' is read-only and cannot be modified" return ToolResult.error(f"Error: '{key}' is read-only and cannot be modified")
if "." in key: if "." in key:
parent_path, leaf = key.rsplit(".", 1) parent_path, leaf = key.rsplit(".", 1)
if leaf in self._DENIED_ATTRS or leaf.startswith("__"): if leaf in self._DENIED_ATTRS or leaf.startswith("__"):
self._audit("modify", f"BLOCKED leaf '{leaf}'") self._audit("modify", f"BLOCKED leaf '{leaf}'")
return f"Error: '{leaf}' is not accessible" return ToolResult.error(f"Error: '{leaf}' is not accessible")
if leaf.lower() in self._SENSITIVE_NAMES: if leaf.lower() in self._SENSITIVE_NAMES:
self._audit("modify", f"BLOCKED sensitive leaf '{leaf}'") self._audit("modify", f"BLOCKED sensitive leaf '{leaf}'")
return f"Error: '{leaf}' is not accessible" return ToolResult.error(f"Error: '{leaf}' is not accessible")
parent, err = self._resolve_path(parent_path) parent, err = self._resolve_path(parent_path)
if err: if err:
return f"Error: {err}" return ToolResult.error(f"Error: {err}")
if isinstance(parent, dict): if isinstance(parent, dict):
parent[leaf] = value parent[leaf] = value
else: else:
@@ -408,11 +408,11 @@ class MyTool(Tool, ContextAware):
def _modify_model_preset(self, value: Any) -> str: def _modify_model_preset(self, value: Any) -> str:
if not isinstance(value, str) or not value.strip(): if not isinstance(value, str) or not value.strip():
return "Error: 'model_preset' must be a non-empty string" return ToolResult.error("Error: 'model_preset' must be a non-empty string")
name = value.strip() name = value.strip()
result = self._modify_free("model_preset", name) result = self._modify_free("model_preset", name)
if result.startswith("Error:"): if isinstance(result, ToolResult) and result.is_error:
return result if result.endswith((".", "!", "?")) else f"{result}." return result if result.endswith((".", "!", "?")) else ToolResult.error(f"{result}.")
return ( return (
f"{result}; model is now {self._runtime_state.model!r}; " f"{result}; model is now {self._runtime_state.model!r}; "
f"context_window_tokens is now {self._runtime_state.context_window_tokens!r}" f"context_window_tokens is now {self._runtime_state.context_window_tokens!r}"
@@ -422,19 +422,19 @@ class MyTool(Tool, ContextAware):
spec = self.RESTRICTED[key] spec = self.RESTRICTED[key]
expected = spec["type"] expected = spec["type"]
if expected is int and isinstance(value, bool): if expected is int and isinstance(value, bool):
return f"Error: '{key}' must be {expected.__name__}, got bool" return ToolResult.error(f"Error: '{key}' must be {expected.__name__}, got bool")
if not isinstance(value, expected): if not isinstance(value, expected):
try: try:
value = expected(value) value = expected(value)
except (ValueError, TypeError): except (ValueError, TypeError):
return f"Error: '{key}' must be {expected.__name__}, got {type(value).__name__}" return ToolResult.error(f"Error: '{key}' must be {expected.__name__}, got {type(value).__name__}")
old = getattr(self._runtime_state, key) old = getattr(self._runtime_state, key)
if "min" in spec and value < spec["min"]: if "min" in spec and value < spec["min"]:
return f"Error: '{key}' must be >= {spec['min']}" return ToolResult.error(f"Error: '{key}' must be >= {spec['min']}")
if "max" in spec and value > spec["max"]: if "max" in spec and value > spec["max"]:
return f"Error: '{key}' must be <= {spec['max']}" return ToolResult.error(f"Error: '{key}' must be <= {spec['max']}")
if "min_len" in spec and len(str(value)) < spec["min_len"]: if "min_len" in spec and len(str(value)) < spec["min_len"]:
return f"Error: '{key}' must be at least {spec['min_len']} characters" return ToolResult.error(f"Error: '{key}' must be at least {spec['min_len']} characters")
setattr(self._runtime_state, key, value) setattr(self._runtime_state, key, value)
if key == "model": if key == "model":
self._runtime_state._active_preset = None self._runtime_state._active_preset = None
@@ -458,25 +458,25 @@ class MyTool(Tool, ContextAware):
"modify", "modify",
f"REJECTED type mismatch {key}: expects {old_t.__name__}, got {new_t.__name__}", f"REJECTED type mismatch {key}: expects {old_t.__name__}, got {new_t.__name__}",
) )
return f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}" return ToolResult.error(f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}")
try: try:
setattr(self._runtime_state, key, value) setattr(self._runtime_state, key, value)
except (ValueError, KeyError) as e: except (ValueError, KeyError) as e:
message = str(e.args[0] if isinstance(e, KeyError) and e.args else e).strip('"') message = str(e.args[0] if isinstance(e, KeyError) and e.args else e).strip('"')
self._audit("modify", f"REJECTED {key}: {message}") self._audit("modify", f"REJECTED {key}: {message}")
return f"Error: {message}" return ToolResult.error(f"Error: {message}")
self._audit("modify", f"{key}: {old!r} -> {value!r}") self._audit("modify", f"{key}: {old!r} -> {value!r}")
return f"Set {key} = {value!r} (was {old!r})" return f"Set {key} = {value!r} (was {old!r})"
if callable(value): if callable(value):
self._audit("modify", f"REJECTED callable {key}") self._audit("modify", f"REJECTED callable {key}")
return "Error: cannot store callable values" return ToolResult.error("Error: cannot store callable values")
err = self._validate_json_safe(value) err = self._validate_json_safe(value)
if err: if err:
self._audit("modify", f"REJECTED {key}: {err}") self._audit("modify", f"REJECTED {key}: {err}")
return f"Error: {err}" return ToolResult.error(f"Error: {err}")
if key not in self._runtime_state._runtime_vars and len(self._runtime_state._runtime_vars) >= self._MAX_RUNTIME_KEYS: if key not in self._runtime_state._runtime_vars and len(self._runtime_state._runtime_vars) >= self._MAX_RUNTIME_KEYS:
self._audit("modify", f"REJECTED {key}: max keys ({self._MAX_RUNTIME_KEYS}) reached") self._audit("modify", f"REJECTED {key}: max keys ({self._MAX_RUNTIME_KEYS}) reached")
return f"Error: scratchpad is full (max {self._MAX_RUNTIME_KEYS} keys). Remove unused keys first." return ToolResult.error(f"Error: scratchpad is full (max {self._MAX_RUNTIME_KEYS} keys). Remove unused keys first.")
old = self._runtime_state._runtime_vars.get(key) old = self._runtime_state._runtime_vars.get(key)
self._runtime_state._runtime_vars[key] = value self._runtime_state._runtime_vars[key] = value
self._audit("modify", f"scratchpad.{key}: {old!r} -> {value!r}") self._audit("modify", f"scratchpad.{key}: {old!r} -> {value!r}")
+21 -20
View File
@@ -15,7 +15,7 @@ from typing import Any
from loguru import logger from loguru import logger
from pydantic import Field from pydantic import Field
from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import current_request_session_key from nanobot.agent.tools.context import current_request_session_key
from nanobot.agent.tools.exec_session import ( from nanobot.agent.tools.exec_session import (
DEFAULT_EXEC_SESSION_MANAGER, DEFAULT_EXEC_SESSION_MANAGER,
@@ -256,7 +256,7 @@ class ExecTool(Tool):
command = command or cmd command = command or cmd
working_dir = working_dir or workdir working_dir = working_dir or workdir
if not command: if not command:
return "Error: Missing command. Provide command or cmd." return ToolResult.error("Error: Missing command. Provide command or cmd.")
if max_output_chars is None: if max_output_chars is None:
max_output_chars = max_output_tokens max_output_chars = max_output_tokens
@@ -283,7 +283,7 @@ class ExecTool(Tool):
) )
except asyncio.TimeoutError: except asyncio.TimeoutError:
await self._kill_process(process) await self._kill_process(process)
return f"Error: Command timed out after {prepared.timeout} seconds" return ToolResult.error(f"Error: Command timed out after {prepared.timeout} seconds")
except asyncio.CancelledError: except asyncio.CancelledError:
await self._kill_process(process) await self._kill_process(process)
raise raise
@@ -314,7 +314,7 @@ class ExecTool(Tool):
return result return result
except Exception as e: except Exception as e:
return f"Error executing command: {str(e)}" return ToolResult.error(f"Error executing command: {str(e)}")
async def _execute_session( async def _execute_session(
self, self,
@@ -339,9 +339,10 @@ class ExecTool(Tool):
MAX_OUTPUT_CHARS, MAX_OUTPUT_CHARS,
), ),
) )
return format_session_poll(session_id, poll) result = format_session_poll(session_id, poll)
return ToolResult.error(result) if poll.timed_out else result
except Exception as exc: except Exception as exc:
return f"Error executing command: {exc}" return ToolResult.error(f"Error executing command: {exc}")
def _resolve_timeout(self, timeout: int | None) -> int | None: def _resolve_timeout(self, timeout: int | None) -> int | None:
"""Resolve the effective hard timeout in seconds (None = no limit). """Resolve the effective hard timeout in seconds (None = no limit).
@@ -383,12 +384,12 @@ class ExecTool(Tool):
requested = Path(cwd).expanduser().resolve() requested = Path(cwd).expanduser().resolve()
resolved_root = Path(workspace_root).expanduser().resolve() resolved_root = Path(workspace_root).expanduser().resolve()
except Exception: except Exception:
return ( return ToolResult.error(
"Error: working_dir could not be resolved" "Error: working_dir could not be resolved"
+ _WORKSPACE_BOUNDARY_NOTE + _WORKSPACE_BOUNDARY_NOTE
) )
if not is_path_within(requested, resolved_root): if not is_path_within(requested, resolved_root):
return ( return ToolResult.error(
"Error: working_dir is outside the configured workspace" "Error: working_dir is outside the configured workspace"
+ _WORKSPACE_BOUNDARY_NOTE + _WORKSPACE_BOUNDARY_NOTE
) )
@@ -504,24 +505,24 @@ class ExecTool(Tool):
if not shell: if not shell:
return None, None return None, None
if _IS_WINDOWS: if _IS_WINDOWS:
return None, "Error: shell parameter is not supported on Windows" return None, ToolResult.error("Error: shell parameter is not supported on Windows")
if "\0" in shell or "\n" in shell or "\r" in shell: if "\0" in shell or "\n" in shell or "\r" in shell:
return None, "Error: shell contains invalid characters" return None, ToolResult.error("Error: shell contains invalid characters")
allowed = {"sh", "bash", "zsh"} allowed = {"sh", "bash", "zsh"}
path = Path(shell).expanduser() path = Path(shell).expanduser()
if path.is_absolute(): if path.is_absolute():
if path.name not in allowed: if path.name not in allowed:
return None, f"Error: unsupported shell {shell!r}. Allowed: bash, sh, zsh" return None, ToolResult.error(f"Error: unsupported shell {shell!r}. Allowed: bash, sh, zsh")
if not path.is_file() or not os.access(path, os.X_OK): if not path.is_file() or not os.access(path, os.X_OK):
return None, f"Error: shell is not executable: {shell}" return None, ToolResult.error(f"Error: shell is not executable: {shell}")
return str(path), None return str(path), None
if "/" in shell or "\\" in shell: if "/" in shell or "\\" in shell:
return None, "Error: shell must be a shell name or absolute path" return None, ToolResult.error("Error: shell must be a shell name or absolute path")
if shell not in allowed: if shell not in allowed:
return None, f"Error: unsupported shell {shell!r}. Allowed: bash, sh, zsh" return None, ToolResult.error(f"Error: unsupported shell {shell!r}. Allowed: bash, sh, zsh")
resolved = shutil.which(shell) resolved = shutil.which(shell)
if not resolved: if not resolved:
return None, f"Error: shell not found: {shell}" return None, ToolResult.error(f"Error: shell not found: {shell}")
return resolved, None return resolved, None
@staticmethod @staticmethod
@@ -608,10 +609,10 @@ class ExecTool(Tool):
if not explicitly_allowed: if not explicitly_allowed:
for pattern in self.deny_patterns: for pattern in self.deny_patterns:
if re.search(pattern, lower): if re.search(pattern, lower):
return "Error: Command blocked by deny pattern filter" return ToolResult.error("Error: Command blocked by deny pattern filter")
if self.allow_patterns: if self.allow_patterns:
return "Error: Command blocked by allowlist filter (not in allowlist)" return ToolResult.error("Error: Command blocked by allowlist filter (not in allowlist)")
from nanobot.security.network import contains_internal_url from nanobot.security.network import contains_internal_url
if contains_internal_url( if contains_internal_url(
@@ -621,12 +622,12 @@ class ExecTool(Tool):
), ),
): ):
# The runner turns this marker into a non-retryable security hint. # The runner turns this marker into a non-retryable security hint.
return "Error: Command blocked by safety guard (internal/private URL detected)" return ToolResult.error("Error: Command blocked by safety guard (internal/private URL detected)")
should_restrict = self.restrict_to_workspace if restrict_to_workspace is None else restrict_to_workspace should_restrict = self.restrict_to_workspace if restrict_to_workspace is None else restrict_to_workspace
if should_restrict: if should_restrict:
if "..\\" in cmd or "../" in cmd: if "..\\" in cmd or "../" in cmd:
return ( return ToolResult.error(
"Error: Command blocked by safety guard (path traversal detected)" "Error: Command blocked by safety guard (path traversal detected)"
+ _WORKSPACE_BOUNDARY_NOTE + _WORKSPACE_BOUNDARY_NOTE
) )
@@ -661,7 +662,7 @@ class ExecTool(Tool):
if not allowed and resolved_workspace is not None: if not allowed and resolved_workspace is not None:
allowed = is_path_within(p, resolved_workspace) allowed = is_path_within(p, resolved_workspace)
if p.is_absolute() and not allowed: if p.is_absolute() and not allowed:
return ( return ToolResult.error(
"Error: Command blocked by safety guard (path outside working dir)" "Error: Command blocked by safety guard (path outside working dir)"
+ _WORKSPACE_BOUNDARY_NOTE + _WORKSPACE_BOUNDARY_NOTE
) )
+26 -26
View File
@@ -14,7 +14,7 @@ import httpx
from loguru import logger from loguru import logger
from pydantic import Field from pydantic import Field
from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.schema import ( from nanobot.agent.tools.schema import (
BooleanSchema, BooleanSchema,
IntegerSchema, IntegerSchema,
@@ -395,13 +395,13 @@ class WebSearchTool(Tool):
elif provider == "keenable": elif provider == "keenable":
return await self._search_keenable(query, n) return await self._search_keenable(query, n)
else: else:
return f"Error: unknown search provider '{provider}'" return ToolResult.error(f"Error: unknown search provider '{provider}'")
async def _search_olostep(self, query: str, n: int) -> str: async def _search_olostep(self, query: str, n: int) -> str:
try: try:
from olostep import AsyncOlostep, Olostep_BaseError from olostep import AsyncOlostep, Olostep_BaseError
except ImportError: except ImportError:
return "Error: olostep package not installed. Run: pip install olostep" return ToolResult.error("Error: olostep package not installed. Run: pip install olostep")
api_key = self.config.api_key or os.environ.get("OLOSTEP_API_KEY", "") api_key = self.config.api_key or os.environ.get("OLOSTEP_API_KEY", "")
if not api_key: if not api_key:
logger.warning("OLOSTEP_API_KEY not set, falling back to DuckDuckGo") logger.warning("OLOSTEP_API_KEY not set, falling back to DuckDuckGo")
@@ -481,13 +481,13 @@ class WebSearchTool(Tool):
return _format_results(query, items, n) return _format_results(query, items, n)
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
if e.response.status_code == 429: if e.response.status_code == 429:
return ( return ToolResult.error(
"Error: Brave search rate limited after retry. " "Error: Brave search rate limited after retry. "
"Retry later or reduce consecutive web_search calls." "Retry later or reduce consecutive web_search calls."
) )
return f"Error: {e}" return ToolResult.error(f"Error: {e}")
except Exception as e: except Exception as e:
return f"Error: {e}" return ToolResult.error(f"Error: {e}")
async def _search_tavily(self, query: str, n: int) -> str: async def _search_tavily(self, query: str, n: int) -> str:
api_key = self.config.api_key or os.environ.get("TAVILY_API_KEY", "") api_key = self.config.api_key or os.environ.get("TAVILY_API_KEY", "")
@@ -505,7 +505,7 @@ class WebSearchTool(Tool):
r.raise_for_status() r.raise_for_status()
return _format_results(query, r.json().get("results", []), n) return _format_results(query, r.json().get("results", []), n)
except Exception as e: except Exception as e:
return f"Error: {e}" return ToolResult.error(f"Error: {e}")
async def _search_keenable(self, query: str, n: int) -> str: async def _search_keenable(self, query: str, n: int) -> str:
api_key = self.config.api_key or os.environ.get("KEENABLE_API_KEY", "") api_key = self.config.api_key or os.environ.get("KEENABLE_API_KEY", "")
@@ -540,10 +540,10 @@ class WebSearchTool(Tool):
return _format_results(query, items, n) return _format_results(query, items, n)
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
if e.response.status_code == 429: if e.response.status_code == 429:
return "Error: Keenable search rate limited. Try again later or reduce search frequency." return ToolResult.error("Error: Keenable search rate limited. Try again later or reduce search frequency.")
return f"Error: Keenable search failed ({e.response.status_code}): {e}" return ToolResult.error(f"Error: Keenable search failed ({e.response.status_code}): {e}")
except Exception as e: except Exception as e:
return f"Error: Keenable search failed: {e}" return ToolResult.error(f"Error: Keenable search failed: {e}")
async def _search_searxng(self, query: str, n: int) -> str: async def _search_searxng(self, query: str, n: int) -> str:
base_url = (self.config.base_url or os.environ.get("SEARXNG_BASE_URL", "")).strip() base_url = (self.config.base_url or os.environ.get("SEARXNG_BASE_URL", "")).strip()
@@ -553,7 +553,7 @@ class WebSearchTool(Tool):
endpoint = f"{base_url.rstrip('/')}/search" endpoint = f"{base_url.rstrip('/')}/search"
is_valid, error_msg = _validate_url(endpoint) is_valid, error_msg = _validate_url(endpoint)
if not is_valid: if not is_valid:
return f"Error: invalid SearXNG URL: {error_msg}" return ToolResult.error(f"Error: invalid SearXNG URL: {error_msg}")
try: try:
async with httpx.AsyncClient(proxy=self.proxy) as client: async with httpx.AsyncClient(proxy=self.proxy) as client:
r = await client.get( r = await client.get(
@@ -565,7 +565,7 @@ class WebSearchTool(Tool):
r.raise_for_status() r.raise_for_status()
return _format_results(query, r.json().get("results", []), n) return _format_results(query, r.json().get("results", []), n)
except Exception as e: except Exception as e:
return f"Error: {e}" return ToolResult.error(f"Error: {e}")
async def _search_jina(self, query: str, n: int) -> str: async def _search_jina(self, query: str, n: int) -> str:
api_key = self.config.api_key or os.environ.get("JINA_API_KEY", "") api_key = self.config.api_key or os.environ.get("JINA_API_KEY", "")
@@ -616,7 +616,7 @@ class WebSearchTool(Tool):
] ]
return _format_results(query, items, n) return _format_results(query, items, n)
except Exception as e: except Exception as e:
return f"Error: {e}" return ToolResult.error(f"Error: {e}")
async def _search_exa(self, query: str, n: int) -> str: async def _search_exa(self, query: str, n: int) -> str:
api_key = self.config.api_key or os.environ.get("EXA_API_KEY", "") api_key = self.config.api_key or os.environ.get("EXA_API_KEY", "")
@@ -663,10 +663,10 @@ class WebSearchTool(Tool):
return _format_results(query, items, n) return _format_results(query, items, n)
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
if e.response.status_code == 429: if e.response.status_code == 429:
return "Error: Exa search rate limited. Try again later or reduce search frequency." return ToolResult.error("Error: Exa search rate limited. Try again later or reduce search frequency.")
return f"Error: Exa search failed ({e.response.status_code}): {e}" return ToolResult.error(f"Error: Exa search failed ({e.response.status_code}): {e}")
except Exception as e: except Exception as e:
return f"Error: Exa search failed: {e}" return ToolResult.error(f"Error: Exa search failed: {e}")
async def _search_volcengine( async def _search_volcengine(
self, self,
@@ -690,7 +690,7 @@ class WebSearchTool(Tool):
normalized_time_range = _normalize_volcengine_time_range(time_range) if time_range else None normalized_time_range = _normalize_volcengine_time_range(time_range) if time_range else None
normalized_auth_level = _normalize_volcengine_auth_level(auth_level) if auth_level is not None else None normalized_auth_level = _normalize_volcengine_auth_level(auth_level) if auth_level is not None else None
except ValueError as e: except ValueError as e:
return f"Error: {e}" return ToolResult.error(f"Error: {e}")
body: dict[str, Any] = { body: dict[str, Any] = {
"Query": query, "Query": query,
@@ -723,18 +723,18 @@ class WebSearchTool(Tool):
data = r.json() data = r.json()
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
if e.response.status_code == 429: if e.response.status_code == 429:
return "Error: Volcengine search rate limited. Try again later or reduce search frequency." return ToolResult.error("Error: Volcengine search rate limited. Try again later or reduce search frequency.")
return f"Error: Volcengine search failed ({e.response.status_code}): {e}" return ToolResult.error(f"Error: Volcengine search failed ({e.response.status_code}): {e}")
except Exception as e: except Exception as e:
return f"Error: Volcengine search failed: {e}" return ToolResult.error(f"Error: Volcengine search failed: {e}")
error = (data.get("ResponseMetadata") or {}).get("Error") or data.get("Error") or data.get("error") error = (data.get("ResponseMetadata") or {}).get("Error") or data.get("Error") or data.get("error")
if error: if error:
if isinstance(error, dict): if isinstance(error, dict):
code = error.get("Code") or error.get("code") or "unknown" code = error.get("Code") or error.get("code") or "unknown"
message = error.get("Message") or error.get("message") or error message = error.get("Message") or error.get("message") or error
return f"Error: Volcengine search error {code}: {message}" return ToolResult.error(f"Error: Volcengine search error {code}: {message}")
return f"Error: Volcengine search error: {error}" return ToolResult.error(f"Error: Volcengine search error: {error}")
result = data.get("Result") or data result = data.get("Result") or data
web_results = result.get("WebResults") or result.get("webResults") or result.get("results") or [] web_results = result.get("WebResults") or result.get("webResults") or result.get("results") or []
@@ -791,7 +791,7 @@ class WebSearchTool(Tool):
return _format_results(query, items, n) return _format_results(query, items, n)
except Exception as e: except Exception as e:
logger.warning("DuckDuckGo search failed: {}", e) logger.warning("DuckDuckGo search failed: {}", e)
return f"Error: DuckDuckGo search failed ({e})" return ToolResult.error(f"Error: DuckDuckGo search failed ({e})")
async def _search_bocha(self, query: str, n: int, freshness: str = "noLimit") -> str: async def _search_bocha(self, query: str, n: int, freshness: str = "noLimit") -> str:
api_key = self.config.api_key or os.environ.get("BOCHA_API_KEY", "") api_key = self.config.api_key or os.environ.get("BOCHA_API_KEY", "")
@@ -819,7 +819,7 @@ class WebSearchTool(Tool):
timeout=self.config.timeout, timeout=self.config.timeout,
) )
if r.status_code == 429: if r.status_code == 429:
return "Error: Bocha search rate-limited (HTTP 429). Wait and retry." return ToolResult.error("Error: Bocha search rate-limited (HTTP 429). Wait and retry.")
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
wrapped_data = data.get("data") if isinstance(data, dict) else None wrapped_data = data.get("data") if isinstance(data, dict) else None
@@ -839,9 +839,9 @@ class WebSearchTool(Tool):
] ]
return _format_results(query, items, n) return _format_results(query, items, n)
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
return f"Error: Bocha search HTTP {e.response.status_code}: {e.response.text[:200]}" return ToolResult.error(f"Error: Bocha search HTTP {e.response.status_code}: {e.response.text[:200]}")
except Exception as e: except Exception as e:
return f"Error: {e}" return ToolResult.error(f"Error: {e}")
@tool_parameters( @tool_parameters(
+40
View File
@@ -135,6 +135,46 @@ async def test_runner_tool_error_sets_final_content():
assert result.stop_reason == "tool_error" assert result.stop_reason == "tool_error"
@pytest.mark.asyncio
async def test_runner_preserves_successful_exec_output_that_starts_with_error():
from nanobot.agent.runner import AgentRunSpec, AgentRunner
provider = MagicMock(spec=LLMProvider)
async def chat_with_retry(*, messages, **kwargs):
if not any(msg.get("role") == "tool" for msg in messages):
return LLMResponse(
content="working",
tool_calls=[
ToolCallRequest(id="call_1", name="exec", arguments={"command": "report"})
],
usage={},
)
return LLMResponse(content="done", usage={})
provider.chat_with_retry = chat_with_retry
output = "Error: generated report successfully\n\nExit code: 0"
tools = MagicMock()
tools.get_definitions.return_value = []
tools.execute = AsyncMock(return_value=output)
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
initial_messages=[{"role": "user", "content": "run report"}],
tools=tools,
model="test-model",
max_iterations=2,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
fail_on_tool_error=True,
))
assert result.final_content == "done"
assert result.stop_reason == "completed"
assert result.tool_events == [
{"name": "exec", "status": "ok", "detail": "Error: generated report successfully Exit code: 0"}
]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_tool_error_preserves_tool_results_in_messages(): async def test_runner_tool_error_preserves_tool_results_in_messages():
"""When a tool raises a fatal error, its results must still be appended """When a tool raises a fatal error, its results must still be appended
+9 -13
View File
@@ -6,6 +6,8 @@ from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from nanobot.agent.runner import AgentRunner, AgentRunSpec
from nanobot.agent.tools import ToolResult
from nanobot.config.schema import AgentDefaults from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMResponse, ToolCallRequest from nanobot.providers.base import LLMResponse, ToolCallRequest
@@ -20,8 +22,6 @@ async def test_runner_does_not_abort_on_workspace_violation_anymore():
we now hand the error back to the LLM as a recoverable tool result and we now hand the error back to the LLM as a recoverable tool result and
rely on ``repeated_workspace_violation_error`` to throttle bypass loops. rely on ``repeated_workspace_violation_error`` to throttle bypass loops.
""" """
from nanobot.agent.runner import AgentRunSpec, AgentRunner
provider = MagicMock() provider = MagicMock()
provider.chat_with_retry = AsyncMock(side_effect=[ provider.chat_with_retry = AsyncMock(side_effect=[
LLMResponse( LLMResponse(
@@ -64,8 +64,6 @@ async def test_runner_does_not_abort_on_workspace_violation_anymore():
def test_is_ssrf_violation_recognizes_private_url_blocks(): def test_is_ssrf_violation_recognizes_private_url_blocks():
"""SSRF rejections are classified separately from workspace boundaries.""" """SSRF rejections are classified separately from workspace boundaries."""
from nanobot.agent.runner import AgentRunner
ssrf_msg = "Error: Command blocked by safety guard (internal/private URL detected)" ssrf_msg = "Error: Command blocked by safety guard (internal/private URL detected)"
assert AgentRunner._is_ssrf_violation(ssrf_msg) is True assert AgentRunner._is_ssrf_violation(ssrf_msg) is True
assert AgentRunner._is_ssrf_violation( assert AgentRunner._is_ssrf_violation(
@@ -88,8 +86,6 @@ def test_is_ssrf_violation_recognizes_private_url_blocks():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_returns_non_retryable_hint_on_ssrf_violation(): async def test_runner_returns_non_retryable_hint_on_ssrf_violation():
"""SSRF stays blocked, but the runtime gives the LLM a final chance to recover.""" """SSRF stays blocked, but the runtime gives the LLM a final chance to recover."""
from nanobot.agent.runner import AgentRunSpec, AgentRunner
provider = MagicMock() provider = MagicMock()
provider.chat_with_retry = AsyncMock(side_effect=[ provider.chat_with_retry = AsyncMock(side_effect=[
LLMResponse( LLMResponse(
@@ -107,7 +103,7 @@ async def test_runner_returns_non_retryable_hint_on_ssrf_violation():
]) ])
tools = MagicMock() tools = MagicMock()
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
tools.execute = AsyncMock(return_value=( tools.execute = AsyncMock(return_value=ToolResult.error(
"Error: Command blocked by safety guard (internal/private URL detected)" "Error: Command blocked by safety guard (internal/private URL detected)"
)) ))
@@ -141,8 +137,6 @@ async def test_runner_lets_llm_recover_from_shell_guard_path_outside():
turn (silent hang on Telegram per #3605); now the LLM gets the soft turn (silent hang on Telegram per #3605); now the LLM gets the soft
error back and can finalize on the next iteration. error back and can finalize on the next iteration.
""" """
from nanobot.agent.runner import AgentRunSpec, AgentRunner
provider = MagicMock() provider = MagicMock()
captured_second_call: list[dict] = [] captured_second_call: list[dict] = []
@@ -163,7 +157,9 @@ async def test_runner_lets_llm_recover_from_shell_guard_path_outside():
tools = MagicMock() tools = MagicMock()
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
tools.execute = AsyncMock( tools.execute = AsyncMock(
return_value="Error: Command blocked by safety guard (path outside working dir)" return_value=ToolResult.error(
"Error: Command blocked by safety guard (path outside working dir)"
)
) )
runner = AgentRunner(provider) runner = AgentRunner(provider)
@@ -195,8 +191,6 @@ async def test_runner_throttles_repeated_workspace_bypass_attempts():
the runner replaces the tool result with a hard "stop trying" message the runner replaces the tool result with a hard "stop trying" message
so the model finally gives up and surfaces the boundary to the user. so the model finally gives up and surfaces the boundary to the user.
""" """
from nanobot.agent.runner import AgentRunSpec, AgentRunner
bypass_attempts = [ bypass_attempts = [
ToolCallRequest( ToolCallRequest(
id=f"a{i}", name="exec", id=f"a{i}", name="exec",
@@ -215,7 +209,9 @@ async def test_runner_throttles_repeated_workspace_bypass_attempts():
tools = MagicMock() tools = MagicMock()
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
tools.execute = AsyncMock( tools.execute = AsyncMock(
return_value="Error: Command blocked by safety guard (path outside working dir)" return_value=ToolResult.error(
"Error: Command blocked by safety guard (path outside working dir)"
)
) )
runner = AgentRunner(provider) runner = AgentRunner(provider)
+4 -2
View File
@@ -11,6 +11,7 @@ from nanobot.agent.tools.exec_session import (
ListExecSessionsTool, ListExecSessionsTool,
WriteStdinTool, WriteStdinTool,
) )
from nanobot.agent.tools.registry import is_tool_error_result
from nanobot.agent.tools.shell import ExecTool from nanobot.agent.tools.shell import ExecTool
@@ -334,9 +335,10 @@ def test_write_stdin_reports_missing_session(tmp_path):
manager = ExecSessionManager() manager = ExecSessionManager()
tool = WriteStdinTool(manager=manager) tool = WriteStdinTool(manager=manager)
result = asyncio.run(tool.execute(session_id="missing", chars="")) result = asyncio.run(tool.execute(session_id="missing\nExit code: 0", chars=""))
assert "exec session not found" in result assert result == "Error: exec session not found: 'missing\\nExit code: 0'"
assert is_tool_error_result("write_stdin", result)
def test_list_exec_sessions_reports_running_commands(tmp_path): def test_list_exec_sessions_reports_running_commands(tmp_path):
+37 -1
View File
@@ -1,8 +1,9 @@
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any
from unittest.mock import AsyncMock
from nanobot.agent.tools.base import Tool from nanobot.agent.tools.base import Tool, ToolResult
from nanobot.agent.tools.filesystem import ReadFileTool from nanobot.agent.tools.filesystem import ReadFileTool
from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.registry import ToolRegistry
@@ -257,6 +258,41 @@ async def test_registry_rejects_unknown_builtin_tool_parameters(tmp_path) -> Non
assert "one" not in result assert "one" not in result
async def test_registry_preserves_successful_exec_output_that_starts_with_error() -> None:
registry = ToolRegistry()
output = "Error: generated report successfully\n\nExit code: 0"
tool = _FakeTool("exec")
tool.execute = AsyncMock(return_value=output)
registry.register(tool)
result = await registry.execute("exec", {})
assert result == output
async def test_registry_uses_structured_tool_result_for_errors() -> None:
registry = ToolRegistry()
output = "Error: plain tool output, not a structured failure"
raw_tool = _FakeTool("raw_output")
raw_tool.execute = AsyncMock(return_value=output)
registry.register(raw_tool)
raw_result = await registry.execute("raw_output", {})
assert raw_result == output
failing_tool = _FakeTool("failing_tool")
failing_tool.execute = AsyncMock(return_value=ToolResult.error("Error: real failure"))
registry.register(failing_tool)
error_result = await registry.execute("failing_tool", {})
assert isinstance(error_result, ToolResult)
assert error_result.is_error
assert error_result.startswith("Error: real failure")
assert "[Analyze the error above" in error_result
def test_get_definitions_returns_cached_result() -> None: def test_get_definitions_returns_cached_result() -> None:
registry = ToolRegistry() registry = ToolRegistry()
registry.register(_FakeTool("read_file")) registry.register(_FakeTool("read_file"))