@@ -3,9 +3,11 @@ |
| 3 | 3 | from __future__ import annotations |
| 4 | 4 | |
| 5 | 5 | import inspect |
| 6 | +import shlex |
| 6 | 7 | from collections.abc import Awaitable, Callable |
| 7 | 8 | from dataclasses import dataclass |
| 8 | 9 | from enum import StrEnum |
| 10 | +from pathlib import Path |
| 9 | 11 | from typing import Any |
| 10 | 12 | |
| 11 | 13 | from ..llm.base import Message, ToolCall |
@@ -542,6 +544,10 @@ class ToolExecutor: |
| 542 | 544 | def _normalize_tool_call_arguments(tool_call: ToolCall) -> ToolCall: |
| 543 | 545 | """Accept narrow, unambiguous argument aliases from local models.""" |
| 544 | 546 | |
| 547 | + shell_alias = _normalize_shell_probe_tool_call(tool_call) |
| 548 | + if shell_alias is not None: |
| 549 | + return shell_alias |
| 550 | + |
| 545 | 551 | if tool_call.name != "edit": |
| 546 | 552 | return tool_call |
| 547 | 553 | |
@@ -554,3 +560,92 @@ def _normalize_tool_call_arguments(tool_call: ToolCall) -> ToolCall: |
| 554 | 560 | arguments=arguments, |
| 555 | 561 | ) |
| 556 | 562 | return tool_call |
| 563 | + |
| 564 | + |
| 565 | +_SHELL_PROBE_TOOL_ALIASES = frozenset( |
| 566 | + {"cat", "find", "head", "ls", "pwd", "stat", "tail"} |
| 567 | +) |
| 568 | +_SHELL_PROBE_TARGET_KEYS = ( |
| 569 | + "path", |
| 570 | + "directory", |
| 571 | + "dir", |
| 572 | + "folder", |
| 573 | + "file_path", |
| 574 | + "file", |
| 575 | + "target", |
| 576 | +) |
| 577 | +_SHELL_PROBE_OPTION_KEYS = ("flags", "options", "args") |
| 578 | +_SHELL_PROBE_PASSTHROUGH_KEYS = ("cwd", "timeout", "background") |
| 579 | + |
| 580 | + |
| 581 | +def _normalize_shell_probe_tool_call(tool_call: ToolCall) -> ToolCall | None: |
| 582 | + """Map hallucinated native shell-probe tools to the real bash tool safely.""" |
| 583 | + |
| 584 | + command_name = tool_call.name.strip().lower() |
| 585 | + if command_name not in _SHELL_PROBE_TOOL_ALIASES: |
| 586 | + return None |
| 587 | + |
| 588 | + arguments = dict(tool_call.arguments) |
| 589 | + parts = [command_name] |
| 590 | + parts.extend(_quote_option_tokens(arguments)) |
| 591 | + parts.extend(_quote_target_tokens(arguments)) |
| 592 | + |
| 593 | + bash_arguments: dict[str, Any] = {"command": " ".join(parts)} |
| 594 | + for key in _SHELL_PROBE_PASSTHROUGH_KEYS: |
| 595 | + if key in arguments: |
| 596 | + bash_arguments[key] = arguments[key] |
| 597 | + |
| 598 | + return ToolCall( |
| 599 | + id=tool_call.id, |
| 600 | + name="bash", |
| 601 | + arguments=bash_arguments, |
| 602 | + ) |
| 603 | + |
| 604 | + |
| 605 | +def _quote_option_tokens(arguments: dict[str, Any]) -> list[str]: |
| 606 | + quoted: list[str] = [] |
| 607 | + for key in _SHELL_PROBE_OPTION_KEYS: |
| 608 | + value = arguments.get(key) |
| 609 | + if value is None: |
| 610 | + continue |
| 611 | + quoted.extend(shlex.quote(token) for token in _split_shell_probe_options(value)) |
| 612 | + return quoted |
| 613 | + |
| 614 | + |
| 615 | +def _quote_target_tokens(arguments: dict[str, Any]) -> list[str]: |
| 616 | + quoted: list[str] = [] |
| 617 | + for key in _SHELL_PROBE_TARGET_KEYS: |
| 618 | + value = arguments.get(key) |
| 619 | + if value is None: |
| 620 | + continue |
| 621 | + quoted.extend( |
| 622 | + shlex.quote(_expand_shell_probe_target(token)) |
| 623 | + for token in _as_tokens(value) |
| 624 | + ) |
| 625 | + break |
| 626 | + return quoted |
| 627 | + |
| 628 | + |
| 629 | +def _split_shell_probe_options(value: Any) -> list[str]: |
| 630 | + if isinstance(value, (list, tuple)): |
| 631 | + return [str(item).strip() for item in value if str(item).strip()] |
| 632 | + text = str(value).strip() |
| 633 | + if not text: |
| 634 | + return [] |
| 635 | + try: |
| 636 | + return [token for token in shlex.split(text) if token.strip()] |
| 637 | + except ValueError: |
| 638 | + return [text] |
| 639 | + |
| 640 | + |
| 641 | +def _as_tokens(value: Any) -> list[str]: |
| 642 | + if isinstance(value, (list, tuple)): |
| 643 | + return [str(item).strip() for item in value if str(item).strip()] |
| 644 | + text = str(value).strip() |
| 645 | + return [text] if text else [] |
| 646 | + |
| 647 | + |
| 648 | +def _expand_shell_probe_target(value: str) -> str: |
| 649 | + if value == "~" or value.startswith("~/"): |
| 650 | + return str(Path(value).expanduser()) |
| 651 | + return value |