@@ -3,9 +3,11 @@ |
| 3 | from __future__ import annotations | 3 | from __future__ import annotations |
| 4 | | 4 | |
| 5 | import inspect | 5 | import inspect |
| | 6 | +import shlex |
| 6 | from collections.abc import Awaitable, Callable | 7 | from collections.abc import Awaitable, Callable |
| 7 | from dataclasses import dataclass | 8 | from dataclasses import dataclass |
| 8 | from enum import StrEnum | 9 | from enum import StrEnum |
| | 10 | +from pathlib import Path |
| 9 | from typing import Any | 11 | from typing import Any |
| 10 | | 12 | |
| 11 | from ..llm.base import Message, ToolCall | 13 | from ..llm.base import Message, ToolCall |
@@ -542,6 +544,10 @@ class ToolExecutor: |
| 542 | def _normalize_tool_call_arguments(tool_call: ToolCall) -> ToolCall: | 544 | def _normalize_tool_call_arguments(tool_call: ToolCall) -> ToolCall: |
| 543 | """Accept narrow, unambiguous argument aliases from local models.""" | 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 | if tool_call.name != "edit": | 551 | if tool_call.name != "edit": |
| 546 | return tool_call | 552 | return tool_call |
| 547 | | 553 | |
@@ -554,3 +560,92 @@ def _normalize_tool_call_arguments(tool_call: ToolCall) -> ToolCall: |
| 554 | arguments=arguments, | 560 | arguments=arguments, |
| 555 | ) | 561 | ) |
| 556 | return tool_call | 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 |