|
22 | 22 | from . import __version__ |
23 | 23 | from . import config as config_module |
24 | 24 | from . import runtime_identity |
| 25 | +from .tools import _arg_contract |
25 | 26 | # Tool modules are imported lazily inside each call_tool() dispatch branch. |
26 | 27 | # This defers loading heavy dependencies (tree-sitter, httpx, pathspec) until |
27 | 28 | # the first actual call to a tool that needs them, reducing cold-start latency |
@@ -330,6 +331,28 @@ def _raw_catalog_tools() -> list: |
330 | 331 | return _RAW_CATALOG or [] |
331 | 332 |
|
332 | 333 |
|
| 334 | +_DECLARED_ARG_KEYS: "Optional[dict]" = None |
| 335 | + |
| 336 | + |
| 337 | +def _declared_arg_keys(name: str): |
| 338 | + """Declared inputSchema property names for a tool, or None if unknown. |
| 339 | +
|
| 340 | + Built once from the same catalog `list_tools` publishes, so the contract can |
| 341 | + never drift from what the agent was shown. None (not an empty set) when the |
| 342 | + tool or its schema is missing: an absent declaration is not evidence that a |
| 343 | + caller's key is wrong. |
| 344 | + """ |
| 345 | + global _DECLARED_ARG_KEYS |
| 346 | + if _DECLARED_ARG_KEYS is None: |
| 347 | + built = {} |
| 348 | + for t in _raw_catalog_tools(): |
| 349 | + props = (t.inputSchema or {}).get("properties") |
| 350 | + if isinstance(props, dict) and props: |
| 351 | + built[t.name] = frozenset(props) |
| 352 | + _DECLARED_ARG_KEYS = built |
| 353 | + return _DECLARED_ARG_KEYS.get(name) |
| 354 | + |
| 355 | + |
333 | 356 | def _catalog_rows() -> "list[dict]": |
334 | 357 | """Menu-shaped rows for every real action (front door excluded).""" |
335 | 358 | rows = [] |
@@ -6353,6 +6376,22 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent] | CallToolR |
6353 | 6376 | except Exception: |
6354 | 6377 | logger.debug("Steering attach failed", exc_info=True) |
6355 | 6378 |
|
| 6379 | + # Argument contract (v1.108.175): every tool reads its arguments |
| 6380 | + # key-by-key, so a misspelled parameter is dropped in silence and the |
| 6381 | + # call that runs is not the call that was asked for. Disclose the |
| 6382 | + # ignored keys on every state, and downgrade `absent` to `degraded` so |
| 6383 | + # the absence-refusal rule below does the refusing — MUST run before the |
| 6384 | + # absence-evidence block for that to hold. |
| 6385 | + try: |
| 6386 | + _ignored = _arg_contract.unrecognized_keys( |
| 6387 | + arguments, _declared_arg_keys(name) |
| 6388 | + ) |
| 6389 | + if _ignored: |
| 6390 | + _arg_contract.apply_argument_contract(result, _ignored) |
| 6391 | + logger.debug("Ignored unknown arguments for %s: %s", name, _ignored) |
| 6392 | + except Exception: |
| 6393 | + logger.debug("Argument-contract check failed", exc_info=True) |
| 6394 | + |
6356 | 6395 | # Absence evidence (#377 phase 3): record every absence-shaped verdict |
6357 | 6396 | # so a handoff claim can cite the SCAN when nothing was served, and |
6358 | 6397 | # hand the caller the citable ref in-band. A ref is only surfaced when |
|
0 commit comments