feat(tool_search): probe-validate blind tool_call args against the deferred schema

Port from nearai/ironclaw#5149 (the describe-first live-hardening fix in
their progressive tool disclosure work): when a model invokes a deferred
tool through the tool_call bridge without the schema-required arguments,
return the tool's parameter schema instead of dispatching blind.

Pre-fix, a blind call produced an opaque downstream failure
("[TOOL_ERROR] Tool execution failed: KeyError: 'document_id'") that
teaches the model nothing about what the tool expects — IronClaw observed
cheap models looping ~30 identical invalid calls until the iteration
budget died. Post-fix, the model repairs the call in one round-trip.

- tools/tool_search.py: new validate_deferred_call_args() — key-absence
  check of schema 'required' fields only; no type checking (coerce_tool_args
  already repairs types downstream); fails open on any validator error so
  it can never block a legitimate dispatch.
- model_tools.py: probe after the scope gate in the bridge dispatch.
- agent/tool_executor.py: probe in both unwrap sites (concurrent +
  sequential) before the underlying tool replaces the bridge; sequential
  path flattens the payload to match its {"error": str} wrapping.
- tests: TestDeferredCallSchemaProbe — blind call returns schema (not
  KeyError), valid/optional calls dispatch, unvalidatable tools fail open,
  out-of-scope rejection unchanged.
This commit is contained in:
teknium1 2026-07-05 17:30:17 -07:00 committed by Teknium
parent 9b97dea1e6
commit 8fbe2e388f
4 changed files with 199 additions and 4 deletions

View file

@ -431,8 +431,15 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
_underlying, _underlying_args, _err = _ts.resolve_underlying_call(function_args)
if not _err and _underlying:
if _underlying in _tool_search_scoped_names(agent):
function_name = _underlying
function_args = _underlying_args
# Probe-validate before unwrapping (ironclaw#5149):
# missing required args return the parameter schema
# instead of dispatching into an opaque failure.
_probe_err = _ts.validate_deferred_call_args(_underlying, _underlying_args)
if _probe_err is not None:
_ts_scope_block = _probe_err
else:
function_name = _underlying
function_args = _underlying_args
else:
_ts_scope_block = json.dumps({
"error": (
@ -1113,8 +1120,25 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
_underlying, _underlying_args, _err = _ts.resolve_underlying_call(function_args)
if not _err and _underlying:
if _underlying in _tool_search_scoped_names(agent):
function_name = _underlying
function_args = _underlying_args
# Probe-validate before unwrapping (ironclaw#5149):
# missing required args return the parameter schema
# instead of dispatching into an opaque failure.
_probe_err = _ts.validate_deferred_call_args(_underlying, _underlying_args)
if _probe_err is not None:
# This path wraps _block_msg in {"error": ...} —
# flatten the probe payload to one plain string.
try:
_probe = json.loads(_probe_err)
_ts_scope_block = (
f"{_probe.get('error', '')} Parameters schema: "
f"{json.dumps(_probe.get('parameters', {}), ensure_ascii=False)}. "
f"{_probe.get('hint', '')}"
).strip()
except Exception:
_ts_scope_block = _probe_err
else:
function_name = _underlying
function_args = _underlying_args
else:
_ts_scope_block = (
f"'{_underlying}' is not available in this session. "