refactor(agent): extract tool execution boundary (#5569)

* refactor(agent): extract tool execution boundary

* test(agent): use extracted tool execution boundary
This commit is contained in:
chengyongru
2026-08-28 13:52:03 +08:00
committed by GitHub
parent cace42af14
commit e73cce706c
6 changed files with 390 additions and 356 deletions
+9 -282
View File
@@ -19,7 +19,8 @@ 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, is_tool_error_result from nanobot.agent.tools.execution import execute_tool_calls
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.llm_usage.context import ( from nanobot.llm_usage.context import (
LLMUsageSource, LLMUsageSource,
bind_llm_usage_source, bind_llm_usage_source,
@@ -32,7 +33,6 @@ from nanobot.providers.base import (
LLMUsage, LLMUsage,
ProviderCallContext, ProviderCallContext,
ProviderConversationState, ProviderConversationState,
ToolCallRequest,
) )
from nanobot.providers.conversation_state import ( from nanobot.providers.conversation_state import (
ProviderConversationStateController, ProviderConversationStateController,
@@ -60,8 +60,6 @@ from nanobot.utils.runtime import (
build_finalization_retry_message, build_finalization_retry_message,
build_length_recovery_message, build_length_recovery_message,
is_blank_text, is_blank_text,
repeated_external_lookup_error,
repeated_workspace_violation_error,
) )
ContinuationCallback = Callable[[], str | None] ContinuationCallback = Callable[[], str | None]
@@ -586,13 +584,14 @@ class AgentRunner:
await hook.before_execute_tools(context) await hook.before_execute_tools(context)
results, new_events = await self._execute_tools( results, new_events = await execute_tool_calls(
spec, spec.tools,
response.tool_calls, response.tool_calls,
external_lookup_counts, concurrent=spec.concurrent_tools,
workspace_violation_counts, external_lookup_counts=external_lookup_counts,
hook, workspace_violation_counts=workspace_violation_counts,
context, hook=hook,
context=context,
) )
tool_events.extend(new_events) tool_events.extend(new_events)
tools_used.extend( tools_used.extend(
@@ -1385,253 +1384,6 @@ class AgentRunner:
return left return left
return left + right return left + right
async def _execute_tools(
self,
spec: AgentRunSpec,
tool_calls: list[ToolCallRequest],
external_lookup_counts: dict[str, int],
workspace_violation_counts: dict[str, int],
hook: AgentHook | None = None,
context: AgentHookContext | None = None,
) -> tuple[list[Any], list[dict[str, str]]]:
hook = hook or AgentHook()
context = context or AgentHookContext(iteration=0, messages=[])
batches = self._partition_tool_batches(spec, tool_calls)
tool_results: list[tuple[Any, dict[str, str]]] = []
for batch in batches:
if spec.concurrent_tools and len(batch) > 1:
batch_results = await asyncio.gather(*(
self._run_tool(
spec,
tool_call,
external_lookup_counts,
workspace_violation_counts,
hook,
context,
)
for tool_call in batch
))
tool_results.extend(batch_results)
else:
batch_results: list[tuple[Any, dict[str, str]]] = []
for tool_call in batch:
result = await self._run_tool(
spec,
tool_call,
external_lookup_counts,
workspace_violation_counts,
hook,
context,
)
tool_results.append(result)
batch_results.append(result)
results: list[Any] = []
events: list[dict[str, str]] = []
for result, event in tool_results:
results.append(result)
events.append(event)
return results, events
async def _run_tool(
self,
spec: AgentRunSpec,
tool_call: ToolCallRequest,
external_lookup_counts: dict[str, int],
workspace_violation_counts: dict[str, int],
hook: AgentHook | None = None,
context: AgentHookContext | None = None,
) -> tuple[Any, dict[str, str]]:
hook = hook or AgentHook()
context = context or AgentHookContext(iteration=0, messages=[])
hint = "\n\n[Analyze the error above and try a different approach.]"
lookup_error = repeated_external_lookup_error(
tool_call.name,
tool_call.arguments,
external_lookup_counts,
)
if lookup_error:
event = {
"name": tool_call.name,
"status": "error",
"detail": "repeated external lookup blocked",
}
return lookup_error + hint, event
prepare_call = cast(
Callable[[str, Any], object] | None,
getattr(spec.tools, "prepare_call", None),
)
tool, params, prep_error = None, tool_call.arguments, None
if callable(prepare_call):
prepared = prepare_call(tool_call.name, tool_call.arguments)
if isinstance(prepared, tuple):
prepared_tuple = cast(tuple[object, ...], prepared)
if len(prepared_tuple) == 3:
tool, params, prep_error = cast(tuple[Any, Any, str | None], prepared_tuple)
if prep_error:
event = {
"name": tool_call.name,
"status": "error",
"detail": prep_error.split(": ", 1)[-1][:120],
}
handled = self._classify_violation(
raw_text=prep_error,
soft_payload=prep_error + hint,
event=event,
tool_call=tool_call,
workspace_violation_counts=workspace_violation_counts,
)
if handled is not None:
return handled
return prep_error + hint, event
await hook.before_execute_tool(context, tool_call, tool, params)
try:
if tool is not None:
result = await tool.execute(**params)
else:
result = await spec.tools.execute(tool_call.name, params)
except asyncio.CancelledError:
raise
except Exception as exc:
await hook.on_execute_tool_error(context, tool_call, tool, params, exc)
event = {
"name": tool_call.name,
"status": "error",
"detail": str(exc),
}
payload = f"Error: {type(exc).__name__}: {exc}"
handled = self._classify_violation(
raw_text=str(exc),
# Preserve legacy exception payloads without the retry hint.
soft_payload=payload,
event=event,
tool_call=tool_call,
workspace_violation_counts=workspace_violation_counts,
)
if handled is not None:
return handled
return payload, event
if is_tool_error_result(result):
await hook.on_execute_tool_error(context, tool_call, tool, params, result)
event = {
"name": tool_call.name,
"status": "error",
"detail": result.replace("\n", " ").strip()[:120],
}
handled = self._classify_violation(
raw_text=result,
soft_payload=result + hint,
event=event,
tool_call=tool_call,
workspace_violation_counts=workspace_violation_counts,
)
if handled is not None:
return handled
return result + hint, event
await hook.after_execute_tool(context, tool_call, tool, params, result)
detail = "" if result is None else str(result)
detail = detail.replace("\n", " ").strip()
if not detail:
detail = "(empty)"
elif len(detail) > 120:
detail = detail[:120] + "..."
return result, {"name": tool_call.name, "status": "ok", "detail": detail}
# SSRF is a hard security block at the tool boundary, but the agent turn
# should recover conversationally instead of aborting the runtime.
_SSRF_MARKERS: tuple[str, ...] = (
"internal/private url detected",
"private/internal address",
"private address",
)
_SSRF_BOUNDARY_NOTE: str = (
"This is a non-bypassable security boundary. Stop trying to access "
"private/internal URLs. Do not retry with curl, wget, encoded IPs, "
"alternate DNS, redirects, proxies, or another tool. Ask the user for "
"local files, logs, screenshots, or an explicit safe public URL instead. "
"If the user explicitly trusts this private URL, ask them to whitelist "
"the exact IP/CIDR via tools.ssrfWhitelist."
)
# Non-SSRF boundary markers returned to the LLM as recoverable tool errors.
_WORKSPACE_VIOLATION_MARKERS: tuple[str, ...] = (
"outside the configured workspace",
"outside allowed directory",
"working_dir is outside",
"working_dir could not be resolved",
"path outside working dir",
"path traversal detected",
)
@classmethod
def _is_ssrf_violation(cls, text: str) -> bool:
if not text:
return False
lowered = text.lower()
return any(marker in lowered for marker in cls._SSRF_MARKERS)
@classmethod
def _is_workspace_violation(cls, text: str) -> bool:
"""True when *text* looks like any policy boundary rejection."""
if not text:
return False
lowered = text.lower()
if cls._is_ssrf_violation(lowered):
return True
return any(marker in lowered for marker in cls._WORKSPACE_VIOLATION_MARKERS)
def _classify_violation(
self,
*,
raw_text: str,
soft_payload: str,
event: dict[str, str],
tool_call: ToolCallRequest,
workspace_violation_counts: dict[str, int],
) -> tuple[Any, dict[str, str]] | None:
"""Classify safety-boundary failures, or return ``None`` to pass through."""
if self._is_ssrf_violation(raw_text):
logger.warning(
"Tool {} blocked by SSRF guard; returning non-retryable tool error: {}",
tool_call.name,
raw_text.replace("\n", " ").strip()[:200],
)
event["detail"] = self._event_detail("ssrf_violation: ", raw_text)
return self._ssrf_soft_payload(raw_text), event
if self._is_workspace_violation(raw_text):
escalation = repeated_workspace_violation_error(
tool_call.name,
tool_call.arguments,
workspace_violation_counts,
)
event["detail"] = self._event_detail("workspace_violation: ", raw_text)
if escalation is not None:
logger.warning(
"Tool {} hit workspace boundary repeatedly; escalating hint",
tool_call.name,
)
event["detail"] = self._event_detail(
"workspace_violation_escalated: ",
raw_text,
)
return escalation, event
return soft_payload, event
return None
@classmethod
def _ssrf_soft_payload(cls, raw_text: str) -> str:
text = raw_text.strip() or "Error: request blocked by SSRF guard"
return f"{text}\n\n{cls._SSRF_BOUNDARY_NOTE}"
@staticmethod
def _event_detail(prefix: str, text: str, limit: int = 160) -> str:
return (prefix + text.replace("\n", " ").strip())[:limit]
async def _emit_checkpoint( async def _emit_checkpoint(
self, self,
spec: AgentRunSpec, spec: AgentRunSpec,
@@ -1661,28 +1413,3 @@ class AgentRunner:
if messages and messages[-1].get("role") == "assistant" and not messages[-1].get("tool_calls"): if messages and messages[-1].get("role") == "assistant" and not messages[-1].get("tool_calls"):
return return
messages.append(build_assistant_message(_PERSISTED_MODEL_ERROR_PLACEHOLDER)) messages.append(build_assistant_message(_PERSISTED_MODEL_ERROR_PLACEHOLDER))
def _partition_tool_batches(
self,
spec: AgentRunSpec,
tool_calls: list[ToolCallRequest],
) -> list[list[ToolCallRequest]]:
if not spec.concurrent_tools:
return [[tool_call] for tool_call in tool_calls]
batches: list[list[ToolCallRequest]] = []
current: list[ToolCallRequest] = []
for tool_call in tool_calls:
get_tool = cast(Callable[[str], Any] | None, getattr(spec.tools, "get", None))
tool = get_tool(tool_call.name) if callable(get_tool) else None
can_batch = bool(tool and tool.concurrency_safe)
if can_batch:
current.append(tool_call)
continue
if current:
batches.append(current)
current = []
batches.append([tool_call])
if current:
batches.append(current)
return batches
+285
View File
@@ -0,0 +1,285 @@
"""Execute tool calls and turn their outcomes into model observations."""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from typing import Any, cast
from loguru import logger
from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.agent.tools.registry import ToolRegistry, is_tool_error_result
from nanobot.providers.base import ToolCallRequest
from nanobot.utils.runtime import (
repeated_external_lookup_error,
repeated_workspace_violation_error,
)
_RETRY_HINT = "\n\n[Analyze the error above and try a different approach.]"
# SSRF is a hard security block at the tool boundary, but the agent turn
# should recover conversationally instead of aborting the runtime.
_SSRF_MARKERS: tuple[str, ...] = (
"internal/private url detected",
"private/internal address",
"private address",
)
_SSRF_BOUNDARY_NOTE = (
"This is a non-bypassable security boundary. Stop trying to access "
"private/internal URLs. Do not retry with curl, wget, encoded IPs, "
"alternate DNS, redirects, proxies, or another tool. Ask the user for "
"local files, logs, screenshots, or an explicit safe public URL instead. "
"If the user explicitly trusts this private URL, ask them to whitelist "
"the exact IP/CIDR via tools.ssrfWhitelist."
)
# Non-SSRF boundary markers returned to the model as recoverable tool errors.
_WORKSPACE_VIOLATION_MARKERS: tuple[str, ...] = (
"outside the configured workspace",
"outside allowed directory",
"working_dir is outside",
"working_dir could not be resolved",
"path outside working dir",
"path traversal detected",
)
async def execute_tool_calls(
tools: ToolRegistry,
tool_calls: list[ToolCallRequest],
*,
concurrent: bool,
external_lookup_counts: dict[str, int],
workspace_violation_counts: dict[str, int],
hook: AgentHook,
context: AgentHookContext,
) -> tuple[list[Any], list[dict[str, str]]]:
"""Execute one model response's tool calls in stable result order."""
tool_results: list[tuple[Any, dict[str, str]]] = []
for batch in _partition_tool_batches(tools, tool_calls, concurrent=concurrent):
if concurrent and len(batch) > 1:
batch_results = await asyncio.gather(*(
_execute_tool_call(
tools,
tool_call,
external_lookup_counts,
workspace_violation_counts,
hook,
context,
)
for tool_call in batch
))
tool_results.extend(batch_results)
else:
for tool_call in batch:
result = await _execute_tool_call(
tools,
tool_call,
external_lookup_counts,
workspace_violation_counts,
hook,
context,
)
tool_results.append(result)
results = [result for result, _event in tool_results]
events = [event for _result, event in tool_results]
return results, events
async def _execute_tool_call(
tools: ToolRegistry,
tool_call: ToolCallRequest,
external_lookup_counts: dict[str, int],
workspace_violation_counts: dict[str, int],
hook: AgentHook,
context: AgentHookContext,
) -> tuple[Any, dict[str, str]]:
lookup_error = repeated_external_lookup_error(
tool_call.name,
tool_call.arguments,
external_lookup_counts,
)
if lookup_error:
event = {
"name": tool_call.name,
"status": "error",
"detail": "repeated external lookup blocked",
}
return lookup_error + _RETRY_HINT, event
prepare_call = cast(
Callable[[str, Any], object] | None,
getattr(tools, "prepare_call", None),
)
tool, params, prep_error = None, tool_call.arguments, None
if callable(prepare_call):
prepared = prepare_call(tool_call.name, tool_call.arguments)
if isinstance(prepared, tuple):
prepared_tuple = cast(tuple[object, ...], prepared)
if len(prepared_tuple) == 3:
tool, params, prep_error = cast(tuple[Any, Any, str | None], prepared_tuple)
if prep_error:
event = {
"name": tool_call.name,
"status": "error",
"detail": prep_error.split(": ", 1)[-1][:120],
}
handled = _classify_violation(
raw_text=prep_error,
soft_payload=prep_error + _RETRY_HINT,
event=event,
tool_call=tool_call,
workspace_violation_counts=workspace_violation_counts,
)
if handled is not None:
return handled
return prep_error + _RETRY_HINT, event
await hook.before_execute_tool(context, tool_call, tool, params)
try:
if tool is not None:
result = await tool.execute(**params)
else:
result = await tools.execute(tool_call.name, params)
except asyncio.CancelledError:
raise
except Exception as exc:
await hook.on_execute_tool_error(context, tool_call, tool, params, exc)
event = {
"name": tool_call.name,
"status": "error",
"detail": str(exc),
}
payload = f"Error: {type(exc).__name__}: {exc}"
handled = _classify_violation(
raw_text=str(exc),
# Preserve legacy exception payloads without the retry hint.
soft_payload=payload,
event=event,
tool_call=tool_call,
workspace_violation_counts=workspace_violation_counts,
)
if handled is not None:
return handled
return payload, event
if is_tool_error_result(result):
await hook.on_execute_tool_error(context, tool_call, tool, params, result)
event = {
"name": tool_call.name,
"status": "error",
"detail": result.replace("\n", " ").strip()[:120],
}
handled = _classify_violation(
raw_text=result,
soft_payload=result + _RETRY_HINT,
event=event,
tool_call=tool_call,
workspace_violation_counts=workspace_violation_counts,
)
if handled is not None:
return handled
return result + _RETRY_HINT, event
await hook.after_execute_tool(context, tool_call, tool, params, result)
detail = "" if result is None else str(result)
detail = detail.replace("\n", " ").strip()
if not detail:
detail = "(empty)"
elif len(detail) > 120:
detail = detail[:120] + "..."
return result, {"name": tool_call.name, "status": "ok", "detail": detail}
def is_ssrf_violation(text: str) -> bool:
"""Return whether a tool error describes a blocked private-network request."""
if not text:
return False
lowered = text.lower()
return any(marker in lowered for marker in _SSRF_MARKERS)
def _is_workspace_violation(text: str) -> bool:
"""Return whether text describes any workspace or network boundary rejection."""
if not text:
return False
lowered = text.lower()
if is_ssrf_violation(lowered):
return True
return any(marker in lowered for marker in _WORKSPACE_VIOLATION_MARKERS)
def _classify_violation(
*,
raw_text: str,
soft_payload: str,
event: dict[str, str],
tool_call: ToolCallRequest,
workspace_violation_counts: dict[str, int],
) -> tuple[Any, dict[str, str]] | None:
if is_ssrf_violation(raw_text):
logger.warning(
"Tool {} blocked by SSRF guard; returning non-retryable tool error: {}",
tool_call.name,
raw_text.replace("\n", " ").strip()[:200],
)
event["detail"] = _event_detail("ssrf_violation: ", raw_text)
return _ssrf_soft_payload(raw_text), event
if _is_workspace_violation(raw_text):
escalation = repeated_workspace_violation_error(
tool_call.name,
tool_call.arguments,
workspace_violation_counts,
)
event["detail"] = _event_detail("workspace_violation: ", raw_text)
if escalation is not None:
logger.warning(
"Tool {} hit workspace boundary repeatedly; escalating hint",
tool_call.name,
)
event["detail"] = _event_detail(
"workspace_violation_escalated: ",
raw_text,
)
return escalation, event
return soft_payload, event
return None
def _ssrf_soft_payload(raw_text: str) -> str:
text = raw_text.strip() or "Error: request blocked by SSRF guard"
return f"{text}\n\n{_SSRF_BOUNDARY_NOTE}"
def _event_detail(prefix: str, text: str, limit: int = 160) -> str:
return (prefix + text.replace("\n", " ").strip())[:limit]
def _partition_tool_batches(
tools: ToolRegistry,
tool_calls: list[ToolCallRequest],
*,
concurrent: bool,
) -> list[list[ToolCallRequest]]:
if not concurrent:
return [[tool_call] for tool_call in tool_calls]
batches: list[list[ToolCallRequest]] = []
current: list[ToolCallRequest] = []
for tool_call in tool_calls:
get_tool = cast(Callable[[str], Any] | None, getattr(tools, "get", None))
tool = get_tool(tool_call.name) if callable(get_tool) else None
can_batch = bool(tool and tool.concurrency_safe)
if can_batch:
current.append(tool_call)
continue
if current:
batches.append(current)
current = []
batches.append([tool_call])
if current:
batches.append(current)
return batches
+9 -18
View File
@@ -9,7 +9,9 @@ from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from agent.runner_helpers import make_run_spec from agent.runner_helpers import make_run_spec
from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.agent.tools import ToolResult from nanobot.agent.tools import ToolResult
from nanobot.agent.tools.execution import execute_tool_calls
from nanobot.config.schema import AgentDefaults from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
@@ -55,11 +57,7 @@ async def test_runner_returns_tool_exception_to_model_for_recovery():
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize("control_error", [KeyboardInterrupt, SystemExit]) @pytest.mark.parametrize("control_error", [KeyboardInterrupt, SystemExit])
async def test_runner_propagates_tool_control_flow_exceptions(control_error: type[BaseException]): async def test_tool_execution_propagates_control_flow_exceptions(control_error: type[BaseException]):
from nanobot.agent.runner import AgentRunner
provider = MagicMock(spec=LLMProvider)
async def execute(_name, _args): async def execute(_name, _args):
raise control_error("stop") raise control_error("stop")
@@ -67,22 +65,15 @@ async def test_runner_propagates_tool_control_flow_exceptions(control_error: typ
get_definitions=lambda: [], get_definitions=lambda: [],
execute=execute, execute=execute,
) )
runner = AgentRunner()
spec = make_run_spec(
provider,
initial_messages=[],
tools=tools,
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
)
with pytest.raises(control_error): with pytest.raises(control_error):
await runner._run_tool( await execute_tool_calls(
spec, tools,
ToolCallRequest(id="call_1", name="list_dir", arguments={}), [ToolCallRequest(id="call_1", name="list_dir", arguments={})],
concurrent=False,
external_lookup_counts={}, external_lookup_counts={},
workspace_violation_counts={}, workspace_violation_counts={},
hook=AgentHook(),
context=AgentHookContext(iteration=0, messages=[]),
) )
+6 -5
View File
@@ -9,6 +9,7 @@ import pytest
from agent.runner_helpers import make_run_spec from agent.runner_helpers import make_run_spec
from nanobot.agent.runner import AgentRunner from nanobot.agent.runner import AgentRunner
from nanobot.agent.tools import ToolResult from nanobot.agent.tools import ToolResult
from nanobot.agent.tools.execution import is_ssrf_violation
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
@@ -66,20 +67,20 @@ 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."""
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 is_ssrf_violation(ssrf_msg) is True
assert AgentRunner._is_ssrf_violation( assert is_ssrf_violation(
"URL validation failed: Blocked: host resolves to private/internal address 192.168.1.2" "URL validation failed: Blocked: host resolves to private/internal address 192.168.1.2"
) is True ) is True
# Workspace-bound markers are NOT classified as SSRF. # Workspace-bound markers are NOT classified as SSRF.
assert AgentRunner._is_ssrf_violation( assert is_ssrf_violation(
"Error: Command blocked by safety guard (path outside working dir)" "Error: Command blocked by safety guard (path outside working dir)"
) is False ) is False
assert AgentRunner._is_ssrf_violation( assert is_ssrf_violation(
"Path /tmp/x is outside allowed directory /ws" "Path /tmp/x is outside allowed directory /ws"
) is False ) is False
# Deny / allowlist filter messages stay non-fatal too. # Deny / allowlist filter messages stay non-fatal too.
assert AgentRunner._is_ssrf_violation( assert is_ssrf_violation(
"Error: Command blocked by deny pattern filter" "Error: Command blocked by deny pattern filter"
) is False ) is False
+70 -41
View File
@@ -3,14 +3,17 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from agent.runner_helpers import make_run_spec from agent.runner_helpers import make_run_spec
from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.agent.runner import AgentRunner from nanobot.agent.runner import AgentRunner
from nanobot.agent.tools.base import Tool, ToolResult from nanobot.agent.tools.base import Tool, ToolResult
from nanobot.agent.tools.context import ToolContext from nanobot.agent.tools.context import ToolContext
from nanobot.agent.tools.execution import execute_tool_calls
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
from nanobot.config.schema import AgentDefaults from nanobot.config.schema import AgentDefaults
@@ -150,31 +153,69 @@ def _tool_message(result, tool_call_id: str) -> dict:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_propagates_tool_preparation_failure(): async def test_tool_execution_propagates_preparation_failure():
tools = MagicMock() tools = MagicMock()
tools.prepare_call.side_effect = RuntimeError("tool preparation failed") tools.prepare_call.side_effect = RuntimeError("tool preparation failed")
tools.execute = AsyncMock() tools.execute = AsyncMock()
with pytest.raises(RuntimeError, match="tool preparation failed"): with pytest.raises(RuntimeError, match="tool preparation failed"):
await AgentRunner()._run_tool( await execute_tool_calls(
make_run_spec( tools,
MagicMock(), [ToolCallRequest(id="call-1", name="demo", arguments={})],
initial_messages=[], concurrent=False,
tools=tools, external_lookup_counts={},
model="test-model", workspace_violation_counts={},
max_iterations=1, hook=AgentHook(),
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, context=AgentHookContext(iteration=0, messages=[]),
),
ToolCallRequest(id="call-1", name="demo", arguments={}),
{},
{},
) )
tools.execute.assert_not_awaited() tools.execute.assert_not_awaited()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_batches_read_only_tools_before_exclusive_work(): async def test_tool_execution_propagates_cancellation_without_error_hook():
tools = MagicMock()
tools.prepare_call.return_value = (None, {}, None)
tools.execute = AsyncMock(side_effect=asyncio.CancelledError)
events: list[str] = []
class RecordingHook(AgentHook):
async def before_execute_tool(
self,
context: AgentHookContext,
tool_call: ToolCallRequest,
tool: Any,
params: Any,
) -> None:
events.append("before")
async def on_execute_tool_error(
self,
context: AgentHookContext,
tool_call: ToolCallRequest,
tool: Any,
params: Any,
error: Any,
) -> None:
events.append("error")
with pytest.raises(asyncio.CancelledError):
await execute_tool_calls(
tools,
[ToolCallRequest(id="call-1", name="demo", arguments={})],
concurrent=False,
external_lookup_counts={},
workspace_violation_counts={},
hook=RecordingHook(),
context=AgentHookContext(iteration=0, messages=[]),
)
assert events == ["before"]
@pytest.mark.asyncio
async def test_tool_execution_batches_read_only_tools_before_exclusive_work():
tools = ToolRegistry() tools = ToolRegistry()
shared_events: list[str] = [] shared_events: list[str] = []
read_a = _DelayTool("read_a", delay=0.05, read_only=True, shared_events=shared_events) read_a = _DelayTool("read_a", delay=0.05, read_only=True, shared_events=shared_events)
@@ -184,24 +225,18 @@ async def test_runner_batches_read_only_tools_before_exclusive_work():
tools.register(read_b) tools.register(read_b)
tools.register(write_a) tools.register(write_a)
provider = MagicMock() await execute_tool_calls(
runner = AgentRunner() tools,
await runner._execute_tools(
make_run_spec(provider,
initial_messages=[],
tools=tools,
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
concurrent_tools=True,
),
[ [
ToolCallRequest(id="ro1", name="read_a", arguments={}), ToolCallRequest(id="ro1", name="read_a", arguments={}),
ToolCallRequest(id="ro2", name="read_b", arguments={}), ToolCallRequest(id="ro2", name="read_b", arguments={}),
ToolCallRequest(id="rw1", name="write_a", arguments={}), ToolCallRequest(id="rw1", name="write_a", arguments={}),
], ],
{}, concurrent=True,
{}, external_lookup_counts={},
workspace_violation_counts={},
hook=AgentHook(),
context=AgentHookContext(iteration=0, messages=[]),
) )
assert shared_events[0:2] == ["start:read_a", "start:read_b"] assert shared_events[0:2] == ["start:read_a", "start:read_b"]
@@ -212,7 +247,7 @@ async def test_runner_batches_read_only_tools_before_exclusive_work():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_does_not_batch_exclusive_read_only_tools(): async def test_tool_execution_does_not_batch_exclusive_read_only_tools():
tools = ToolRegistry() tools = ToolRegistry()
shared_events: list[str] = [] shared_events: list[str] = []
read_a = _DelayTool("read_a", delay=0.03, read_only=True, shared_events=shared_events) read_a = _DelayTool("read_a", delay=0.03, read_only=True, shared_events=shared_events)
@@ -228,24 +263,18 @@ async def test_runner_does_not_batch_exclusive_read_only_tools():
tools.register(ddg_like) tools.register(ddg_like)
tools.register(read_b) tools.register(read_b)
provider = MagicMock() await execute_tool_calls(
runner = AgentRunner() tools,
await runner._execute_tools(
make_run_spec(provider,
initial_messages=[],
tools=tools,
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
concurrent_tools=True,
),
[ [
ToolCallRequest(id="ro1", name="read_a", arguments={}), ToolCallRequest(id="ro1", name="read_a", arguments={}),
ToolCallRequest(id="ddg1", name="ddg_like", arguments={}), ToolCallRequest(id="ddg1", name="ddg_like", arguments={}),
ToolCallRequest(id="ro2", name="read_b", arguments={}), ToolCallRequest(id="ro2", name="read_b", arguments={}),
], ],
{}, concurrent=True,
{}, external_lookup_counts={},
workspace_violation_counts={},
hook=AgentHook(),
context=AgentHookContext(iteration=0, messages=[]),
) )
assert shared_events[0] == "start:read_a" assert shared_events[0] == "start:read_a"
+11 -10
View File
@@ -376,9 +376,10 @@ async def test_inline_spawn_waits_for_concurrency_capacity(tmp_path):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_executes_inline_spawn_batch_concurrently(tmp_path): async def test_runner_executes_inline_spawn_batch_concurrently(tmp_path):
"""Adjacent blocking consultations should share one concurrent tool batch.""" """Adjacent blocking consultations should share one concurrent tool batch."""
from nanobot.agent.runner import AgentRunner, AgentRunSpec from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.agent.subagent import SubagentManager from nanobot.agent.subagent import SubagentManager
from nanobot.agent.tools.context import RequestContext, request_context from nanobot.agent.tools.context import RequestContext, request_context
from nanobot.agent.tools.execution import execute_tool_calls
from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.spawn import SpawnTool from nanobot.agent.tools.spawn import SpawnTool
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
@@ -410,14 +411,6 @@ async def test_runner_executes_inline_spawn_batch_concurrently(tmp_path):
tools = ToolRegistry() tools = ToolRegistry()
tools.register(SpawnTool(manager)) tools.register(SpawnTool(manager))
runtime = _runtime(MagicMock()) runtime = _runtime(MagicMock())
spec = AgentRunSpec(
initial_messages=[],
tools=tools,
runtime=runtime,
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
concurrent_tools=True,
)
calls = [ calls = [
ToolCallRequest( ToolCallRequest(
id="spawn-1", id="spawn-1",
@@ -437,7 +430,15 @@ async def test_runner_executes_inline_spawn_batch_concurrently(tmp_path):
session_key="test:c1", session_key="test:c1",
runtime=runtime, runtime=runtime,
)): )):
execution = asyncio.create_task(AgentRunner()._execute_tools(spec, calls, {}, {})) execution = asyncio.create_task(execute_tool_calls(
tools,
calls,
concurrent=True,
external_lookup_counts={},
workspace_violation_counts={},
hook=AgentHook(),
context=AgentHookContext(iteration=0, messages=[], session_key="test:c1"),
))
await asyncio.wait_for(both_entered.wait(), timeout=1.0) await asyncio.wait_for(both_entered.wait(), timeout=1.0)
release.set() release.set()
results, events = await execution results, events = await execution