Improve tool call validation strictness (#4190)
* Improve tool call validation strictness Reject near-miss tool names without executing suggested tools. Require object-shaped tool parameters while preserving only lossless JSON wire-shape normalization. * Tighten tool call argument validation * Simplify tool argument validation tests * Improve tool name suggestions * Simplify tool suggestion helpers * Limit tool suggestions to canonical matches * Allow repair only for tool history replay * Clarify non-object tool argument errors * Inline replay tool argument normalization * Track only successful tool executions * Reject JSON null tool arguments
This commit is contained in:
@@ -399,7 +399,6 @@ class AgentRunner:
|
||||
thinking_blocks=response.thinking_blocks,
|
||||
)
|
||||
messages.append(assistant_message)
|
||||
tools_used.extend(tc.name for tc in response.tool_calls)
|
||||
await self._emit_checkpoint(
|
||||
spec,
|
||||
{
|
||||
@@ -421,6 +420,11 @@ class AgentRunner:
|
||||
workspace_violation_counts,
|
||||
)
|
||||
tool_events.extend(new_events)
|
||||
tools_used.extend(
|
||||
tool_call.name
|
||||
for tool_call, event in zip(response.tool_calls, new_events)
|
||||
if event.get("status") == "ok"
|
||||
)
|
||||
context.tool_results = list(results)
|
||||
context.tool_events = list(new_events)
|
||||
completed_tool_results: list[dict[str, Any]] = []
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tool registry for dynamic tool management."""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from nanobot.agent.tools.base import Tool
|
||||
@@ -30,6 +31,24 @@ class ToolRegistry:
|
||||
"""Get a tool by name."""
|
||||
return self._tools.get(name)
|
||||
|
||||
@staticmethod
|
||||
def _lookup_key(name: str) -> str:
|
||||
"""Normalize names for suggestions only; never for execution."""
|
||||
return "".join(ch.lower() for ch in name if ch.isalnum())
|
||||
|
||||
def _suggest_name(self, name: str) -> str | None:
|
||||
key = self._lookup_key(str(name or ""))
|
||||
if not key:
|
||||
return None
|
||||
matches = [
|
||||
registered
|
||||
for registered in self._tools
|
||||
if self._lookup_key(registered) == key
|
||||
]
|
||||
if len(matches) == 1:
|
||||
return matches[0]
|
||||
return None
|
||||
|
||||
def has(self, name: str) -> bool:
|
||||
"""Check if a tool is registered."""
|
||||
return name in self._tools
|
||||
@@ -73,20 +92,23 @@ class ToolRegistry:
|
||||
def prepare_call(
|
||||
self,
|
||||
name: str,
|
||||
params: dict[str, Any],
|
||||
) -> tuple[Tool | None, dict[str, Any], str | None]:
|
||||
params: Any,
|
||||
) -> tuple[Tool | None, Any, str | None]:
|
||||
"""Resolve, cast, and validate one tool call."""
|
||||
# Guard against invalid parameter types (e.g., list instead of dict)
|
||||
if not isinstance(params, dict) and name in ('write_file', 'read_file'):
|
||||
return None, params, (
|
||||
f"Error: Tool '{name}' parameters must be a JSON object, got {type(params).__name__}. "
|
||||
"Use named parameters: tool_name(param1=\"value1\", param2=\"value2\")"
|
||||
)
|
||||
|
||||
tool = self._tools.get(name)
|
||||
if not tool:
|
||||
suggestion = self._suggest_name(str(name))
|
||||
hint = f" Did you mean '{suggestion}'? Tool names must match exactly." if suggestion else ""
|
||||
return None, params, (
|
||||
f"Error: Tool '{name}' not found. Available: {', '.join(self.tool_names)}"
|
||||
f"Error: Tool '{name}' not found.{hint} Available: {', '.join(self.tool_names)}"
|
||||
)
|
||||
|
||||
params = self._coerce_params(tool, params)
|
||||
if not isinstance(params, dict):
|
||||
return tool, params, (
|
||||
f"Error: Tool '{name}' parameters must be a JSON object, got "
|
||||
f"{type(params).__name__}. Use named parameters like "
|
||||
'tool_name(param1="value1", param2="value2") matching the tool schema.'
|
||||
)
|
||||
|
||||
cast_params = tool.cast_params(params)
|
||||
@@ -97,21 +119,56 @@ class ToolRegistry:
|
||||
)
|
||||
return tool, cast_params, None
|
||||
|
||||
async def execute(self, name: str, params: dict[str, Any]) -> Any:
|
||||
@classmethod
|
||||
def _coerce_argument_value(cls, value: Any) -> Any:
|
||||
if value is None:
|
||||
return {}
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
return {}
|
||||
|
||||
if not stripped.startswith(("{", "[")):
|
||||
return value
|
||||
|
||||
try:
|
||||
parsed = json.loads(stripped)
|
||||
except Exception:
|
||||
return value
|
||||
|
||||
return parsed
|
||||
|
||||
@classmethod
|
||||
def _coerce_params(cls, tool: Tool, params: Any) -> Any:
|
||||
params = cls._coerce_argument_value(params)
|
||||
return cls._unwrap_arguments_payload(tool, params)
|
||||
|
||||
@classmethod
|
||||
def _unwrap_arguments_payload(cls, tool: Tool, params: Any) -> Any:
|
||||
if not isinstance(params, dict) or set(params) != {"arguments"}:
|
||||
return params
|
||||
properties = (tool.parameters or {}).get("properties", {})
|
||||
if isinstance(properties, dict) and "arguments" in properties:
|
||||
return params
|
||||
return cls._coerce_argument_value(params.get("arguments"))
|
||||
|
||||
async def execute(self, name: str, params: Any) -> Any:
|
||||
"""Execute a tool by name with given parameters."""
|
||||
_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)
|
||||
if error:
|
||||
return error + _HINT
|
||||
return error + hint
|
||||
|
||||
try:
|
||||
assert tool is not None # guarded by prepare_call()
|
||||
result = await tool.execute(**params)
|
||||
if isinstance(result, str) and result.startswith("Error"):
|
||||
return result + _HINT
|
||||
return result + hint
|
||||
return result
|
||||
except Exception as e:
|
||||
return f"Error executing {name}: {str(e)}" + _HINT
|
||||
return f"Error executing {name}: {str(e)}" + hint
|
||||
|
||||
@property
|
||||
def tool_names(self) -> list[str]:
|
||||
|
||||
@@ -10,9 +10,12 @@ import string
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
import json_repair
|
||||
|
||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||
from nanobot.providers.base import (
|
||||
LLMProvider,
|
||||
LLMResponse,
|
||||
ToolCallRequest,
|
||||
tool_arguments_object_for_replay,
|
||||
)
|
||||
|
||||
_ALNUM = string.ascii_letters + string.digits
|
||||
|
||||
@@ -207,13 +210,11 @@ class AnthropicProvider(LLMProvider):
|
||||
continue
|
||||
func = tc.get("function", {})
|
||||
args = func.get("arguments", "{}")
|
||||
if isinstance(args, str):
|
||||
args = json_repair.loads(args)
|
||||
blocks.append({
|
||||
"type": "tool_use",
|
||||
"id": tc.get("id") or _gen_tool_id(),
|
||||
"name": func.get("name", ""),
|
||||
"input": args,
|
||||
"input": tool_arguments_object_for_replay(args),
|
||||
})
|
||||
|
||||
return blocks or [{"type": "text", "text": ""}]
|
||||
@@ -509,7 +510,7 @@ class AnthropicProvider(LLMProvider):
|
||||
tool_calls.append(ToolCallRequest(
|
||||
id=block.id,
|
||||
name=block.name,
|
||||
arguments=block.input if isinstance(block.input, dict) else {},
|
||||
arguments=block.input,
|
||||
))
|
||||
elif block.type == "thinking":
|
||||
thinking_blocks.append({
|
||||
|
||||
@@ -11,6 +11,7 @@ from datetime import datetime, timezone
|
||||
from email.utils import parsedate_to_datetime
|
||||
from typing import Any
|
||||
|
||||
import json_repair
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.utils.helpers import image_placeholder_text
|
||||
@@ -21,19 +22,24 @@ class ToolCallRequest:
|
||||
"""A tool call request from the LLM."""
|
||||
id: str
|
||||
name: str
|
||||
arguments: dict[str, Any]
|
||||
arguments: Any
|
||||
extra_content: dict[str, Any] | None = None
|
||||
provider_specific_fields: dict[str, Any] | None = None
|
||||
function_provider_specific_fields: dict[str, Any] | None = None
|
||||
|
||||
def to_openai_tool_call(self) -> dict[str, Any]:
|
||||
"""Serialize to an OpenAI-style tool_call payload."""
|
||||
arguments = (
|
||||
self.arguments
|
||||
if isinstance(self.arguments, str)
|
||||
else json.dumps(self.arguments, ensure_ascii=False)
|
||||
)
|
||||
tool_call = {
|
||||
"id": self.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": self.name,
|
||||
"arguments": json.dumps(self.arguments, ensure_ascii=False),
|
||||
"arguments": arguments,
|
||||
},
|
||||
}
|
||||
if self.extra_content:
|
||||
@@ -45,6 +51,62 @@ class ToolCallRequest:
|
||||
return tool_call
|
||||
|
||||
|
||||
def parse_tool_arguments(arguments: Any) -> Any:
|
||||
"""Parse provider tool arguments without guessing executable parameters.
|
||||
|
||||
Valid JSON object strings become dicts. Empty strings become no-arg calls.
|
||||
Malformed JSON and JSON array/scalar values are preserved so ToolRegistry
|
||||
can reject them before execution.
|
||||
"""
|
||||
if arguments is None:
|
||||
return {}
|
||||
if not isinstance(arguments, str):
|
||||
return arguments
|
||||
|
||||
stripped = arguments.strip()
|
||||
if not stripped:
|
||||
return {}
|
||||
|
||||
try:
|
||||
parsed = json.loads(stripped)
|
||||
except Exception:
|
||||
return arguments
|
||||
return arguments if parsed is None else parsed
|
||||
|
||||
|
||||
def tool_arguments_object_for_replay(arguments: Any) -> dict[str, Any]:
|
||||
"""Return object-shaped arguments for provider history replay only.
|
||||
|
||||
This compatibility path may repair malformed JSON because it only shapes
|
||||
existing conversation history for provider protocols. Do not use it for
|
||||
newly generated tool calls that are about to execute.
|
||||
"""
|
||||
if arguments is None:
|
||||
return {}
|
||||
if isinstance(arguments, dict):
|
||||
return arguments
|
||||
if not isinstance(arguments, str):
|
||||
return {}
|
||||
|
||||
stripped = arguments.strip()
|
||||
if not stripped:
|
||||
return {}
|
||||
|
||||
try:
|
||||
parsed = json.loads(stripped)
|
||||
except Exception:
|
||||
try:
|
||||
parsed = json_repair.loads(stripped)
|
||||
except Exception:
|
||||
return {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
|
||||
|
||||
def tool_arguments_json_for_replay(arguments: Any) -> str:
|
||||
"""Return JSON object string arguments for provider history replay only."""
|
||||
return json.dumps(tool_arguments_object_for_replay(arguments), ensure_ascii=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMResponse:
|
||||
"""Response from an LLM provider."""
|
||||
|
||||
@@ -10,9 +10,13 @@ import re
|
||||
from collections.abc import Awaitable, Callable, Iterator
|
||||
from typing import Any
|
||||
|
||||
import json_repair
|
||||
|
||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||
from nanobot.providers.base import (
|
||||
LLMProvider,
|
||||
LLMResponse,
|
||||
ToolCallRequest,
|
||||
parse_tool_arguments,
|
||||
tool_arguments_object_for_replay,
|
||||
)
|
||||
|
||||
_IMAGE_DATA_URL = re.compile(r"^data:image/([a-zA-Z0-9.+-]+);base64,(.*)$", re.DOTALL)
|
||||
_TEXT_BLOCK_TYPES = {"text", "input_text", "output_text"}
|
||||
@@ -176,14 +180,7 @@ class BedrockProvider(LLMProvider):
|
||||
function = tool_call.get("function")
|
||||
if not isinstance(function, dict):
|
||||
return None
|
||||
args = function.get("arguments", {})
|
||||
if isinstance(args, str):
|
||||
try:
|
||||
args = json_repair.loads(args) if args.strip() else {}
|
||||
except Exception:
|
||||
args = {}
|
||||
if not isinstance(args, dict):
|
||||
args = {}
|
||||
args = tool_arguments_object_for_replay(function.get("arguments", {}))
|
||||
return {
|
||||
"toolUse": {
|
||||
"toolUseId": str(tool_call.get("id") or ""),
|
||||
@@ -491,7 +488,7 @@ class BedrockProvider(LLMProvider):
|
||||
content_parts.append(block["text"])
|
||||
tool_use = block.get("toolUse")
|
||||
if isinstance(tool_use, dict):
|
||||
arguments = tool_use.get("input") if isinstance(tool_use.get("input"), dict) else {}
|
||||
arguments = tool_use.get("input", {})
|
||||
tool_calls.append(ToolCallRequest(
|
||||
id=str(tool_use.get("toolUseId") or ""),
|
||||
name=str(tool_use.get("name") or ""),
|
||||
@@ -616,14 +613,11 @@ class BedrockProvider(LLMProvider):
|
||||
for buf in tool_buffers.values():
|
||||
args: Any = {}
|
||||
if buf.get("input"):
|
||||
try:
|
||||
args = json_repair.loads(buf["input"])
|
||||
except Exception:
|
||||
args = {}
|
||||
args = parse_tool_arguments(buf["input"])
|
||||
tool_calls.append(ToolCallRequest(
|
||||
id=buf.get("id") or "",
|
||||
name=buf.get("name") or "",
|
||||
arguments=args if isinstance(args, dict) else {},
|
||||
arguments=args,
|
||||
))
|
||||
return LLMResponse(
|
||||
content="".join(content_parts) or None,
|
||||
|
||||
@@ -17,10 +17,15 @@ from ipaddress import ip_address
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import json_repair
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||
from nanobot.providers.base import (
|
||||
LLMProvider,
|
||||
LLMResponse,
|
||||
ToolCallRequest,
|
||||
parse_tool_arguments,
|
||||
tool_arguments_json_for_replay,
|
||||
)
|
||||
from nanobot.providers.openai_responses import (
|
||||
consume_sdk_stream,
|
||||
convert_messages,
|
||||
@@ -478,24 +483,6 @@ class OpenAICompatProvider(LLMProvider):
|
||||
"""Return True for providers that reject normal OpenAI tool call IDs."""
|
||||
return bool(self._spec and self._spec.name == "mistral")
|
||||
|
||||
@staticmethod
|
||||
def _normalize_tool_call_arguments(arguments: Any) -> str:
|
||||
"""Force function.arguments into a valid JSON object string."""
|
||||
if isinstance(arguments, str):
|
||||
stripped = arguments.strip()
|
||||
if not stripped:
|
||||
return "{}"
|
||||
try:
|
||||
parsed = json_repair.loads(stripped)
|
||||
except Exception:
|
||||
return "{}"
|
||||
if isinstance(parsed, dict):
|
||||
return json.dumps(parsed, ensure_ascii=False)
|
||||
return "{}"
|
||||
if isinstance(arguments, dict):
|
||||
return json.dumps(arguments, ensure_ascii=False)
|
||||
return "{}"
|
||||
|
||||
@staticmethod
|
||||
def _coerce_content_to_string(content: Any) -> str | None:
|
||||
"""Coerce block/list content into plain text for strict string-only APIs."""
|
||||
@@ -572,7 +559,7 @@ class OpenAICompatProvider(LLMProvider):
|
||||
if isinstance(function, dict):
|
||||
function_clean = dict(function)
|
||||
if "arguments" in function_clean:
|
||||
function_clean["arguments"] = self._normalize_tool_call_arguments(
|
||||
function_clean["arguments"] = tool_arguments_json_for_replay(
|
||||
function_clean.get("arguments")
|
||||
)
|
||||
else:
|
||||
@@ -1021,14 +1008,12 @@ class OpenAICompatProvider(LLMProvider):
|
||||
for tc in raw_tool_calls:
|
||||
tc_map = self._maybe_mapping(tc) or {}
|
||||
fn = self._maybe_mapping(tc_map.get("function")) or {}
|
||||
args = fn.get("arguments", {})
|
||||
if isinstance(args, str):
|
||||
args = json_repair.loads(args)
|
||||
args = parse_tool_arguments(fn.get("arguments", {}))
|
||||
ec, prov, fn_prov = _extract_tc_extras(tc)
|
||||
parsed_tool_calls.append(ToolCallRequest(
|
||||
id=str(tc_map.get("id") or _short_tool_id()),
|
||||
name=str(fn.get("name") or ""),
|
||||
arguments=args if isinstance(args, dict) else {},
|
||||
arguments=args,
|
||||
extra_content=ec,
|
||||
provider_specific_fields=prov,
|
||||
function_provider_specific_fields=fn_prov,
|
||||
@@ -1064,9 +1049,7 @@ class OpenAICompatProvider(LLMProvider):
|
||||
|
||||
tool_calls = []
|
||||
for tc in raw_tool_calls:
|
||||
args = tc.function.arguments
|
||||
if isinstance(args, str):
|
||||
args = json_repair.loads(args)
|
||||
args = parse_tool_arguments(tc.function.arguments)
|
||||
ec, prov, fn_prov = _extract_tc_extras(tc)
|
||||
tool_calls.append(ToolCallRequest(
|
||||
id=str(getattr(tc, "id", None) or _short_tool_id()),
|
||||
@@ -1207,7 +1190,7 @@ class OpenAICompatProvider(LLMProvider):
|
||||
ToolCallRequest(
|
||||
id=b["id"] or _short_tool_id(),
|
||||
name=b["name"],
|
||||
arguments=json_repair.loads(b["arguments"]) if b["arguments"] else {},
|
||||
arguments=parse_tool_arguments(b["arguments"]),
|
||||
extra_content=b.get("extra_content"),
|
||||
provider_specific_fields=b.get("prov"),
|
||||
function_provider_specific_fields=b.get("fn_prov"),
|
||||
|
||||
@@ -5,6 +5,8 @@ from __future__ import annotations
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from nanobot.providers.base import tool_arguments_json_for_replay
|
||||
|
||||
|
||||
def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
|
||||
"""Convert Chat Completions messages to Responses API input items.
|
||||
@@ -46,7 +48,7 @@ def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str
|
||||
"id": response_item_id,
|
||||
"call_id": call_id or f"call_{idx}",
|
||||
"name": fn.get("name"),
|
||||
"arguments": fn.get("arguments") or "{}",
|
||||
"arguments": tool_arguments_json_for_replay(fn.get("arguments")),
|
||||
})
|
||||
continue
|
||||
|
||||
|
||||
@@ -7,10 +7,9 @@ from collections.abc import Awaitable, Callable
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
import httpx
|
||||
import json_repair
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||
from nanobot.providers.base import LLMResponse, ToolCallRequest, parse_tool_arguments
|
||||
|
||||
FINISH_REASON_MAP = {
|
||||
"completed": "stop",
|
||||
@@ -44,6 +43,27 @@ def _usage_from_response_obj(response: Any) -> dict[str, int]:
|
||||
}
|
||||
|
||||
|
||||
def _parse_tool_call_arguments(args_raw: Any, name: str | None) -> Any:
|
||||
parsed = parse_tool_arguments(args_raw)
|
||||
if parsed == args_raw and isinstance(args_raw, str) and args_raw.strip():
|
||||
logger.warning(
|
||||
"Failed to parse tool call arguments for '{}': {}",
|
||||
name,
|
||||
args_raw[:200],
|
||||
)
|
||||
return parsed
|
||||
|
||||
|
||||
def _tool_arguments_source(*values: Any) -> Any:
|
||||
for value in values:
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, str) and not value.strip():
|
||||
continue
|
||||
return value
|
||||
return "{}"
|
||||
|
||||
|
||||
async def iter_sse(response: httpx.Response) -> AsyncGenerator[dict[str, Any], None]:
|
||||
"""Yield parsed JSON events from a Responses API SSE stream."""
|
||||
buffer: list[str] = []
|
||||
@@ -116,10 +136,11 @@ async def consume_sse_with_reasoning(
|
||||
call_id = item.get("call_id")
|
||||
if not call_id:
|
||||
continue
|
||||
arguments = item.get("arguments")
|
||||
tool_call_buffers[call_id] = {
|
||||
"id": item.get("id") or "fc_0",
|
||||
"name": item.get("name"),
|
||||
"arguments": item.get("arguments") or "",
|
||||
"arguments": "" if arguments is None else arguments,
|
||||
}
|
||||
if on_tool_call_delta:
|
||||
await on_tool_call_delta({
|
||||
@@ -156,7 +177,10 @@ async def consume_sse_with_reasoning(
|
||||
call_id = event.get("call_id")
|
||||
if call_id and call_id in tool_call_buffers:
|
||||
delta = event.get("delta") or ""
|
||||
tool_call_buffers[call_id]["arguments"] += delta
|
||||
current = tool_call_buffers[call_id].get("arguments")
|
||||
if not isinstance(current, str):
|
||||
current = ""
|
||||
tool_call_buffers[call_id]["arguments"] = current + delta
|
||||
if on_tool_call_delta and delta:
|
||||
await on_tool_call_delta({
|
||||
"call_id": str(call_id),
|
||||
@@ -166,14 +190,14 @@ async def consume_sse_with_reasoning(
|
||||
elif event_type == "response.function_call_arguments.done":
|
||||
call_id = event.get("call_id")
|
||||
if call_id and call_id in tool_call_buffers:
|
||||
arguments = event.get("arguments") or ""
|
||||
arguments = event.get("arguments")
|
||||
tool_call_buffers[call_id]["arguments"] = arguments
|
||||
if on_tool_call_delta:
|
||||
tool_call_args_emitted.add(str(call_id))
|
||||
await on_tool_call_delta({
|
||||
"call_id": str(call_id),
|
||||
"name": str(tool_call_buffers[call_id].get("name") or ""),
|
||||
"arguments": str(arguments),
|
||||
"arguments": "" if arguments is None else str(arguments),
|
||||
})
|
||||
elif event_type == "response.output_item.done":
|
||||
item = event.get("item") or {}
|
||||
@@ -182,7 +206,7 @@ async def consume_sse_with_reasoning(
|
||||
if not call_id:
|
||||
continue
|
||||
buf = tool_call_buffers.get(call_id) or {}
|
||||
args_raw = buf.get("arguments") or item.get("arguments") or "{}"
|
||||
args_raw = _tool_arguments_source(buf.get("arguments"), item.get("arguments"))
|
||||
if on_tool_call_delta and str(call_id) not in tool_call_args_emitted:
|
||||
tool_call_args_emitted.add(str(call_id))
|
||||
await on_tool_call_delta({
|
||||
@@ -190,17 +214,10 @@ async def consume_sse_with_reasoning(
|
||||
"name": str(buf.get("name") or item.get("name") or ""),
|
||||
"arguments": str(args_raw),
|
||||
})
|
||||
try:
|
||||
args = json.loads(args_raw)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to parse tool call arguments for '{}': {}",
|
||||
buf.get("name") or item.get("name"),
|
||||
args_raw[:200],
|
||||
)
|
||||
args = json_repair.loads(args_raw)
|
||||
if not isinstance(args, dict):
|
||||
args = {"raw": args_raw}
|
||||
args = _parse_tool_call_arguments(
|
||||
args_raw,
|
||||
buf.get("name") or item.get("name"),
|
||||
)
|
||||
tool_calls.append(
|
||||
ToolCallRequest(
|
||||
id=f"{call_id}|{buf.get('id') or item.get('id') or 'fc_0'}",
|
||||
@@ -283,22 +300,12 @@ def parse_response_output(response: Any) -> LLMResponse:
|
||||
elif item_type == "function_call":
|
||||
call_id = item.get("call_id") or ""
|
||||
item_id = item.get("id") or "fc_0"
|
||||
args_raw = item.get("arguments") or "{}"
|
||||
try:
|
||||
args = json.loads(args_raw) if isinstance(args_raw, str) else args_raw
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to parse tool call arguments for '{}': {}",
|
||||
item.get("name"),
|
||||
str(args_raw)[:200],
|
||||
)
|
||||
args = json_repair.loads(args_raw) if isinstance(args_raw, str) else args_raw
|
||||
if not isinstance(args, dict):
|
||||
args = {"raw": args_raw}
|
||||
args_raw = _tool_arguments_source(item.get("arguments"))
|
||||
args = _parse_tool_call_arguments(args_raw, item.get("name"))
|
||||
tool_calls.append(ToolCallRequest(
|
||||
id=f"{call_id}|{item_id}",
|
||||
name=item.get("name") or "",
|
||||
arguments=args if isinstance(args, dict) else {},
|
||||
arguments=args,
|
||||
))
|
||||
|
||||
usage = _usage_from_response_obj(response)
|
||||
@@ -337,10 +344,11 @@ async def consume_sdk_stream(
|
||||
call_id = getattr(item, "call_id", None)
|
||||
if not call_id:
|
||||
continue
|
||||
arguments = getattr(item, "arguments", None)
|
||||
tool_call_buffers[call_id] = {
|
||||
"id": getattr(item, "id", None) or "fc_0",
|
||||
"name": getattr(item, "name", None),
|
||||
"arguments": getattr(item, "arguments", None) or "",
|
||||
"arguments": "" if arguments is None else arguments,
|
||||
}
|
||||
if on_tool_call_delta:
|
||||
await on_tool_call_delta({
|
||||
@@ -357,7 +365,10 @@ async def consume_sdk_stream(
|
||||
call_id = getattr(event, "call_id", None)
|
||||
if call_id and call_id in tool_call_buffers:
|
||||
delta = getattr(event, "delta", "") or ""
|
||||
tool_call_buffers[call_id]["arguments"] += delta
|
||||
current = tool_call_buffers[call_id].get("arguments")
|
||||
if not isinstance(current, str):
|
||||
current = ""
|
||||
tool_call_buffers[call_id]["arguments"] = current + delta
|
||||
if on_tool_call_delta and delta:
|
||||
await on_tool_call_delta({
|
||||
"call_id": str(call_id),
|
||||
@@ -367,14 +378,14 @@ async def consume_sdk_stream(
|
||||
elif event_type == "response.function_call_arguments.done":
|
||||
call_id = getattr(event, "call_id", None)
|
||||
if call_id and call_id in tool_call_buffers:
|
||||
arguments = getattr(event, "arguments", "") or ""
|
||||
arguments = getattr(event, "arguments", None)
|
||||
tool_call_buffers[call_id]["arguments"] = arguments
|
||||
if on_tool_call_delta:
|
||||
tool_call_args_emitted.add(str(call_id))
|
||||
await on_tool_call_delta({
|
||||
"call_id": str(call_id),
|
||||
"name": str(tool_call_buffers[call_id].get("name") or ""),
|
||||
"arguments": str(arguments),
|
||||
"arguments": "" if arguments is None else str(arguments),
|
||||
})
|
||||
elif event_type == "response.output_item.done":
|
||||
item = getattr(event, "item", None)
|
||||
@@ -383,7 +394,10 @@ async def consume_sdk_stream(
|
||||
if not call_id:
|
||||
continue
|
||||
buf = tool_call_buffers.get(call_id) or {}
|
||||
args_raw = buf.get("arguments") or getattr(item, "arguments", None) or "{}"
|
||||
args_raw = _tool_arguments_source(
|
||||
buf.get("arguments"),
|
||||
getattr(item, "arguments", None),
|
||||
)
|
||||
if on_tool_call_delta and str(call_id) not in tool_call_args_emitted:
|
||||
tool_call_args_emitted.add(str(call_id))
|
||||
await on_tool_call_delta({
|
||||
@@ -391,17 +405,10 @@ async def consume_sdk_stream(
|
||||
"name": str(buf.get("name") or getattr(item, "name", None) or ""),
|
||||
"arguments": str(args_raw),
|
||||
})
|
||||
try:
|
||||
args = json.loads(args_raw)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to parse tool call arguments for '{}': {}",
|
||||
buf.get("name") or getattr(item, "name", None),
|
||||
str(args_raw)[:200],
|
||||
)
|
||||
args = json_repair.loads(args_raw)
|
||||
if not isinstance(args, dict):
|
||||
args = {"raw": args_raw}
|
||||
args = _parse_tool_call_arguments(
|
||||
args_raw,
|
||||
buf.get("name") or getattr(item, "name", None),
|
||||
)
|
||||
tool_calls.append(
|
||||
ToolCallRequest(
|
||||
id=f"{call_id}|{buf.get('id') or getattr(item, 'id', None) or 'fc_0'}",
|
||||
|
||||
@@ -49,13 +49,18 @@ async def invoke_file_edit_progress(
|
||||
await on_progress("", file_edit_events=file_edit_events)
|
||||
|
||||
|
||||
def _tool_event_arguments(tool_call: Any) -> dict[str, Any]:
|
||||
arguments = getattr(tool_call, "arguments", {}) or {}
|
||||
return arguments if isinstance(arguments, dict) else {}
|
||||
|
||||
|
||||
def build_tool_event_start_payload(tool_call: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"version": 1,
|
||||
"phase": "start",
|
||||
"call_id": str(getattr(tool_call, "id", "") or ""),
|
||||
"name": getattr(tool_call, "name", ""),
|
||||
"arguments": getattr(tool_call, "arguments", {}) or {},
|
||||
"arguments": _tool_event_arguments(tool_call),
|
||||
"result": None,
|
||||
"error": None,
|
||||
"files": [],
|
||||
@@ -86,7 +91,7 @@ def build_tool_event_finish_payloads(context: AgentHookContext) -> list[dict[str
|
||||
"phase": phase,
|
||||
"call_id": str(getattr(tool_call, "id", "") or ""),
|
||||
"name": getattr(tool_call, "name", ""),
|
||||
"arguments": getattr(tool_call, "arguments", {}) or {},
|
||||
"arguments": _tool_event_arguments(tool_call),
|
||||
"result": result if phase == "end" else None,
|
||||
"error": None,
|
||||
"files": files,
|
||||
|
||||
@@ -75,8 +75,10 @@ def build_goal_continue_message(custom: str | None = None) -> dict[str, str]:
|
||||
return {"role": "user", "content": custom or SUSTAINED_GOAL_CONTINUE_PROMPT}
|
||||
|
||||
|
||||
def external_lookup_signature(tool_name: str, arguments: dict[str, Any]) -> str | None:
|
||||
def external_lookup_signature(tool_name: str, arguments: Any) -> str | None:
|
||||
"""Stable signature for repeated external lookups we want to throttle."""
|
||||
if not isinstance(arguments, dict):
|
||||
return None
|
||||
if tool_name == "web_fetch":
|
||||
url = str(arguments.get("url") or "").strip()
|
||||
if url:
|
||||
@@ -90,7 +92,7 @@ def external_lookup_signature(tool_name: str, arguments: dict[str, Any]) -> str
|
||||
|
||||
def repeated_external_lookup_error(
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any],
|
||||
arguments: Any,
|
||||
seen_counts: dict[str, int],
|
||||
) -> str | None:
|
||||
"""Block repeated external lookups after a small retry budget."""
|
||||
@@ -119,9 +121,11 @@ _OUTSIDE_PATH_PATTERN = re.compile(r"(?:^|[\s|>'\"])((?:/[^\s\"'>;|<]+)|(?:~[^\s
|
||||
|
||||
def workspace_violation_signature(
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any],
|
||||
arguments: Any,
|
||||
) -> str | None:
|
||||
"""Return a stable cross-tool signature for the outside-workspace target."""
|
||||
if not isinstance(arguments, dict):
|
||||
return None
|
||||
for key in ("path", "file_path", "target", "source", "destination"):
|
||||
val = arguments.get(key)
|
||||
if isinstance(val, str) and val.strip():
|
||||
@@ -151,7 +155,7 @@ def _normalize_violation_target(raw: str) -> str:
|
||||
|
||||
def repeated_workspace_violation_error(
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any],
|
||||
arguments: Any,
|
||||
seen_counts: dict[str, int],
|
||||
) -> str | None:
|
||||
"""Return an escalated error after repeated bypass attempts."""
|
||||
|
||||
Reference in New Issue
Block a user