返回 JoyAI-Echo
tool_hints.py
1 """Tool hint formatting for concise, human-readable tool call display."""
2
3 from __future__ import annotations
4
5 import re
6
7 from nanobot.utils.path import abbreviate_path
8
9 # Registry: tool_name -> (key_args, template, is_path, is_command)
10 _TOOL_FORMATS: dict[str, tuple[list[str], str, bool, bool]] = {
11 "read_file": (["path", "file_path"], "read {}", True, False),
12 "write_file": (["path", "file_path"], "write {}", True, False),
13 "edit": (["file_path", "path"], "edit {}", True, False),
14 "glob": (["pattern"], 'glob "{}"', False, False),
15 "grep": (["pattern"], 'grep "{}"', False, False),
16 "exec": (["command"], "$ {}", False, True),
17 "web_search": (["query"], 'search "{}"', False, False),
18 "web_fetch": (["url"], "fetch {}", True, False),
19 "list_dir": (["path"], "ls {}", True, False),
20 }
21
22 # Matches file paths embedded in shell commands, including quoted paths with spaces.
23 _PATH_IN_CMD_RE = re.compile(
24 r'"(?P<double>(?:[A-Za-z]:[/\\]|~/|/)[^"]+)"'
25 r"|'(?P<single>(?:[A-Za-z]:[/\\]|~/|/)[^']+)'"
26 r"|(?P<bare>(?:[A-Za-z]:[/\\]|~/|(?<=\s)/)[^\s;&|<>\"']+)"
27 )
28
29
30 def format_tool_hints(tool_calls: list) -> str:
31 """Format tool calls as concise hints with smart abbreviation."""
32 if not tool_calls:
33 return ""
34
35 formatted = []
36 for tc in tool_calls:
37 fmt = _TOOL_FORMATS.get(tc.name)
38 if fmt:
39 formatted.append(_fmt_known(tc, fmt))
40 elif tc.name.startswith("mcp_"):
41 formatted.append(_fmt_mcp(tc))
42 else:
43 formatted.append(_fmt_fallback(tc))
44
45 hints = []
46 for hint in formatted:
47 if hints and hints[-1][0] == hint:
48 hints[-1] = (hint, hints[-1][1] + 1)
49 else:
50 hints.append((hint, 1))
51
52 return ", ".join(
53 f"{h} \u00d7 {c}" if c > 1 else h for h, c in hints
54 )
55
56
57 def _get_args(tc) -> dict:
58 """Extract args dict from tc.arguments, handling list/dict/None/empty."""
59 if tc.arguments is None:
60 return {}
61 if isinstance(tc.arguments, list):
62 return tc.arguments[0] if tc.arguments else {}
63 if isinstance(tc.arguments, dict):
64 return tc.arguments
65 return {}
66
67
68 def _extract_arg(tc, key_args: list[str]) -> str | None:
69 """Extract the first available value from preferred key names."""
70 args = _get_args(tc)
71 if not isinstance(args, dict):
72 return None
73 for key in key_args:
74 val = args.get(key)
75 if isinstance(val, str) and val:
76 return val
77 for val in args.values():
78 if isinstance(val, str) and val:
79 return val
80 return None
81
82
83 def _fmt_known(tc, fmt: tuple) -> str:
84 """Format a registered tool using its template."""
85 val = _extract_arg(tc, fmt[0])
86 if val is None:
87 return tc.name
88 if fmt[2]: # is_path
89 val = abbreviate_path(val)
90 elif fmt[3]: # is_command
91 val = _abbreviate_command(val)
92 return fmt[1].format(val)
93
94
95 def _abbreviate_command(cmd: str, max_len: int = 40) -> str:
96 """Abbreviate paths in a command string, then truncate."""
97 def _replace_path(match: re.Match[str]) -> str:
98 if match.group("double") is not None:
99 return f'"{abbreviate_path(match.group("double"), max_len=25)}"'
100 if match.group("single") is not None:
101 return f"'{abbreviate_path(match.group('single'), max_len=25)}'"
102 return abbreviate_path(match.group("bare"), max_len=25)
103
104 abbreviated = _PATH_IN_CMD_RE.sub(_replace_path, cmd)
105 if len(abbreviated) <= max_len:
106 return abbreviated
107 return abbreviated[:max_len - 1] + "\u2026"
108
109
110 def _fmt_mcp(tc) -> str:
111 """Format MCP tool as server::tool."""
112 name = tc.name
113 if "__" in name:
114 parts = name.split("__", 1)
115 server = parts[0].removeprefix("mcp_")
116 tool = parts[1]
117 else:
118 rest = name.removeprefix("mcp_")
119 parts = rest.split("_", 1)
120 server = parts[0] if parts else rest
121 tool = parts[1] if len(parts) > 1 else ""
122 if not tool:
123 return name
124 args = _get_args(tc)
125 val = next((v for v in args.values() if isinstance(v, str) and v), None)
126 if val is None:
127 return f"{server}::{tool}"
128 return f'{server}::{tool}("{abbreviate_path(val, 40)}")'
129
130
131 def _fmt_fallback(tc) -> str:
132 """Original formatting logic for unregistered tools."""
133 args = _get_args(tc)
134 val = next(iter(args.values()), None) if isinstance(args, dict) else None
135 if not isinstance(val, str):
136 return tc.name
137 return f'{tc.name}("{abbreviate_path(val, 40)}")' if len(val) > 40 else f'{tc.name}("{val}")'
138
138 lines PYTHON