Files
hermes-agent/tools/connectors/dispatch.py
teknium1 13fc2e1044 fix(tools): tool_call rejections restate the single-entry shape and stop misdirecting deferred names
Two rejections from the tool_call bridge fuelled identical-retry loops on
small models because they stated a constraint without a correction:

* A multi-entry batch naming a local tool got "Local tools require one entry
  per tool_call; mixed and multi-local batches are not supported." A 9B model
  re-sent the same two-entry array until identical_call_streak_halt. The
  error now restates the valid shape with the caller's OWN first entry
  ("you sent 2. Retry with only: {"calls":[<first entry>]} then issue the
  remaining 1 call(s) as separate tool_call invocations"), which is the
  hand-written correction that unstuck the reporter's session. The refusal
  itself stays: b4d04eb8fd keeps local entries out of batches deliberately
  (a batch bypasses per-tool admission, path-overlap serialization and the
  per-server MCP parallel opt-in). Shared helper local_batch_error() feeds
  both resolve_underlying_call and the connector dispatcher's guard.

* "'<name>' is not a deferrable tool. If it appears in the model-facing
  tools list already, call it directly" fired for two opposite mistakes.
  For a directly-listed tool the advice is right; for an unknown name —
  typically a deferred MCP tool cited by its bare suffix
  ('mempalace_search' for 'mcp__mempalace__mempalace_search') — "call it
  directly" is the opposite of what the model must do. not_deferrable_error()
  now distinguishes them, suggests the registered full name when one ends
  with '__<name>', and points at tool_search otherwise. tool_describe's
  wrong-door error uses the same helper.

Also ports one parametrize row from #114488 (a stringified single object
without a name flows into the legacy single-shape tolerance) and adds two
invariant tests; docs updated.

Co-authored-by: Konstantin Khlopkov <konstantin.khlopkov93@gmail.com>
Co-authored-by: KoNit-K <konit.block@protonmail.com>
2026-09-18 09:24:54 -07:00

61 lines
2.9 KiB
Python

"""Route connector calls through the normal dispatch policy pipeline."""
import json
from dataclasses import asdict
from tools.registry import tool_error
from tools.connectors.gateway.config import MAX_CALLS_PER_DISPATCH
from tools.connectors.gateway.merge import assemble_results, fill_remote_failure, partition_calls
def dispatch_connector_call(name, arguments, tool_call_id):
from tools.connectors.gateway.bridge import run_remote
partition = partition_calls([{"name": name, "arguments": arguments}])
entries = run_remote(partition.remote, tool_call_id, availability=None, client_factory=None)
entry = entries[0]
return json.dumps({key: value for key, value in entry.items() if key in {"response", "error"}},
ensure_ascii=False)
def dispatch_connector_batch(calls, ids, *, user_task, enabled_tools,
middleware_trace, enabled_toolsets, disabled_toolsets):
from model_tools import handle_function_call
from tools.interrupt import is_interrupted
if len(calls) > MAX_CALLS_PER_DISPATCH:
return tool_error(f"too many calls: {len(calls)} > max {MAX_CALLS_PER_DISPATCH}. "
"Retry with fewer calls per batch.")
partition = partition_calls(calls)
if partition.local:
from tools.tool_search_validation import local_batch_error
return tool_error(local_batch_error(calls))
entries = list(partition.errors)
for offset, plan in enumerate(partition.remote):
if is_interrupted():
# Check before every entry so /stop prevents unstarted remote side effects.
entries.extend(fill_remote_failure(
partition.remote[offset:], "Stopped by the user before this call was made.",
code="INTERRUPTED"))
break
# Each entry must run its own policy and middleware.
payload = handle_function_call(
plan.name, plan.arguments, **asdict(ids), user_task=user_task,
enabled_tools=enabled_tools, tool_request_middleware_trace=list(middleware_trace),
skip_pre_tool_call_hook=False, skip_tool_request_middleware=False,
skip_tool_execution_middleware=False,
enabled_toolsets=enabled_toolsets, disabled_toolsets=disabled_toolsets,
)
try:
value = json.loads(payload) if isinstance(payload, str) else payload
except ValueError:
value = payload
entry = {"index": plan.position, "name": plan.name}
if isinstance(value, dict) and "error" in value:
error = value["error"]
entry["error"] = error if isinstance(error, dict) else {"code": "TOOL_ERROR", "message": str(error)}
else:
entry["response"] = value.get("response", value) if isinstance(value, dict) else value
entries.append(entry)
return json.dumps(assemble_results(len(calls), entries), ensure_ascii=False)