janito 3.9.0__py3-none-any.whl → 3.11.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- janito/agent_events.py +75 -0
- janito/cli/chat_mode/session.py +1 -0
- janito/cli/chat_mode/shell/commands/__init__.py +2 -0
- janito/cli/chat_mode/shell/commands/interactive.py +33 -0
- janito/cli/chat_mode/toolbar.py +16 -1
- janito/cli/core/runner.py +33 -0
- janito/cli/main_cli.py +9 -0
- janito/cli/prompt_core.py +67 -33
- janito/cli/rich_terminal_reporter.py +170 -179
- janito/cli/single_shot_mode/handler.py +19 -0
- janito/llm/agent.py +59 -0
- janito/plugins/tools/local/__init__.py +7 -0
- janito/plugins/tools/local/create_directory.py +44 -1
- janito/tests/test_tool_adapter_case_insensitive.py +112 -0
- janito/tools/tools_adapter.py +514 -510
- {janito-3.9.0.dist-info → janito-3.11.0.dist-info}/METADATA +1 -1
- {janito-3.9.0.dist-info → janito-3.11.0.dist-info}/RECORD +21 -18
- {janito-3.9.0.dist-info → janito-3.11.0.dist-info}/WHEEL +0 -0
- {janito-3.9.0.dist-info → janito-3.11.0.dist-info}/entry_points.txt +0 -0
- {janito-3.9.0.dist-info → janito-3.11.0.dist-info}/licenses/LICENSE +0 -0
- {janito-3.9.0.dist-info → janito-3.11.0.dist-info}/top_level.txt +0 -0
janito/tools/tools_adapter.py
CHANGED
@@ -1,510 +1,514 @@
|
|
1
|
-
from janito.tools.tool_base import ToolBase
|
2
|
-
from janito.tools.tool_events import ToolCallStarted, ToolCallFinished, ToolCallError
|
3
|
-
from janito.exceptions import ToolCallException
|
4
|
-
from janito.tools.tool_base import ToolPermissions
|
5
|
-
|
6
|
-
|
7
|
-
class ToolsAdapterBase:
|
8
|
-
"""
|
9
|
-
Composable entry point for tools management and provisioning in LLM pipelines.
|
10
|
-
This class represents an external or plugin-based provider of tool definitions.
|
11
|
-
Extend and customize this to load, register, or serve tool implementations dynamically.
|
12
|
-
After refactor, also responsible for tool execution.
|
13
|
-
"""
|
14
|
-
|
15
|
-
def __init__(self, tools=None, event_bus=None):
|
16
|
-
self._tools = tools or []
|
17
|
-
self._event_bus = event_bus # event bus can be set on all adapters
|
18
|
-
self.verbose_tools = False
|
19
|
-
|
20
|
-
def set_verbose_tools(self, value: bool):
|
21
|
-
self.verbose_tools = value
|
22
|
-
|
23
|
-
@property
|
24
|
-
def event_bus(self):
|
25
|
-
return self._event_bus
|
26
|
-
|
27
|
-
@event_bus.setter
|
28
|
-
def event_bus(self, bus):
|
29
|
-
self._event_bus = bus
|
30
|
-
|
31
|
-
def is_tool_allowed(self, tool):
|
32
|
-
"""Check if a tool is allowed based on current global AllowedPermissionsState."""
|
33
|
-
from janito.tools.permissions import get_global_allowed_permissions
|
34
|
-
|
35
|
-
allowed_permissions = get_global_allowed_permissions()
|
36
|
-
perms = tool.permissions # permissions are mandatory and type-checked
|
37
|
-
# If all permissions are False, block all tools
|
38
|
-
if not (
|
39
|
-
allowed_permissions.read
|
40
|
-
or allowed_permissions.write
|
41
|
-
or allowed_permissions.execute
|
42
|
-
):
|
43
|
-
return False
|
44
|
-
for perm in ["read", "write", "execute"]:
|
45
|
-
if getattr(perms, perm) and not getattr(allowed_permissions, perm):
|
46
|
-
return False
|
47
|
-
return True
|
48
|
-
|
49
|
-
def get_tools(self):
|
50
|
-
"""Return the list of enabled tools managed by this provider, filtered by allowed permissions and disabled tools."""
|
51
|
-
from janito.tools.disabled_tools import is_tool_disabled
|
52
|
-
|
53
|
-
tools = [
|
54
|
-
tool
|
55
|
-
for tool in self._tools
|
56
|
-
if self.is_tool_allowed(tool)
|
57
|
-
and not is_tool_disabled(getattr(tool, "tool_name", str(tool)))
|
58
|
-
]
|
59
|
-
return tools
|
60
|
-
|
61
|
-
def set_allowed_permissions(self, allowed_permissions):
|
62
|
-
"""Set the allowed permissions at runtime. This now updates the global AllowedPermissionsState only."""
|
63
|
-
from janito.tools.permissions import set_global_allowed_permissions
|
64
|
-
|
65
|
-
set_global_allowed_permissions(allowed_permissions)
|
66
|
-
|
67
|
-
def add_tool(self, tool):
|
68
|
-
self._tools.append(tool)
|
69
|
-
|
70
|
-
def _validate_arguments_against_schema(self, arguments: dict, schema: dict):
|
71
|
-
properties = schema.get("properties", {})
|
72
|
-
required = schema.get("required", [])
|
73
|
-
missing = [field for field in required if field not in arguments]
|
74
|
-
if missing:
|
75
|
-
return f"Missing required argument(s): {', '.join(missing)}"
|
76
|
-
type_map = {
|
77
|
-
"string": str,
|
78
|
-
"integer": int,
|
79
|
-
"number": (int, float),
|
80
|
-
"boolean": bool,
|
81
|
-
"array": list,
|
82
|
-
"object": dict,
|
83
|
-
}
|
84
|
-
for key, value in arguments.items():
|
85
|
-
if key not in properties:
|
86
|
-
continue
|
87
|
-
expected_type = properties[key].get("type")
|
88
|
-
if expected_type and expected_type in type_map:
|
89
|
-
if not isinstance(value, type_map[expected_type]):
|
90
|
-
return f"Argument '{key}' should be of type '{expected_type}', got '{type(value).__name__}'"
|
91
|
-
return None
|
92
|
-
|
93
|
-
def execute(self, tool, *args, **kwargs):
|
94
|
-
|
95
|
-
if self.verbose_tools:
|
96
|
-
print(
|
97
|
-
f"[tools-adapter] [execute] Executing tool: {getattr(tool, 'tool_name', repr(tool))} with args: {args}, kwargs: {kwargs}"
|
98
|
-
)
|
99
|
-
if isinstance(tool, ToolBase):
|
100
|
-
tool.event_bus = self._event_bus
|
101
|
-
result = None
|
102
|
-
if callable(tool):
|
103
|
-
result = tool(*args, **kwargs)
|
104
|
-
elif hasattr(tool, "execute") and callable(getattr(tool, "execute")):
|
105
|
-
result = tool.execute(*args, **kwargs)
|
106
|
-
elif hasattr(tool, "run") and callable(getattr(tool, "run")):
|
107
|
-
result = tool.run(*args, **kwargs)
|
108
|
-
else:
|
109
|
-
raise ValueError("Provided tool is not executable.")
|
110
|
-
|
111
|
-
return result
|
112
|
-
|
113
|
-
def _get_tool_callable(self, tool):
|
114
|
-
"""Helper to retrieve the primary callable of a tool instance."""
|
115
|
-
if callable(tool):
|
116
|
-
return tool
|
117
|
-
if hasattr(tool, "execute") and callable(getattr(tool, "execute")):
|
118
|
-
return getattr(tool, "execute")
|
119
|
-
if hasattr(tool, "run") and callable(getattr(tool, "run")):
|
120
|
-
return getattr(tool, "run")
|
121
|
-
raise ValueError("Provided tool is not executable.")
|
122
|
-
|
123
|
-
def _validate_arguments_against_signature(self, func, arguments: dict):
|
124
|
-
"""Validate provided arguments against a callable signature.
|
125
|
-
|
126
|
-
Returns an error string if validation fails, otherwise ``None``.
|
127
|
-
"""
|
128
|
-
import inspect
|
129
|
-
|
130
|
-
if arguments is None:
|
131
|
-
arguments = {}
|
132
|
-
# If arguments are provided as a non-dict (e.g. a list or a scalar)
|
133
|
-
# we skip signature *keyword* validation completely and defer the
|
134
|
-
# decision to Python's own call mechanics when the function is
|
135
|
-
# eventually invoked. This allows positional / variadic arguments to
|
136
|
-
# be supplied by callers that intentionally bypass the structured
|
137
|
-
# JSON-schema style interface.
|
138
|
-
if not isinstance(arguments, dict):
|
139
|
-
# Nothing to validate at this stage – treat as OK.
|
140
|
-
return None
|
141
|
-
|
142
|
-
sig = inspect.signature(func)
|
143
|
-
params = sig.parameters
|
144
|
-
|
145
|
-
# Check for unexpected arguments (unless **kwargs is accepted)
|
146
|
-
accepts_kwargs = any(
|
147
|
-
p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()
|
148
|
-
)
|
149
|
-
if not accepts_kwargs:
|
150
|
-
unexpected = [k for k in arguments.keys() if k not in params]
|
151
|
-
if unexpected:
|
152
|
-
# Build detailed error message with received arguments
|
153
|
-
error_parts = [
|
154
|
-
"Unexpected argument(s): " + ", ".join(sorted(unexpected))
|
155
|
-
]
|
156
|
-
error_parts.append(
|
157
|
-
"Valid parameters: " + ", ".join(sorted(params.keys()))
|
158
|
-
)
|
159
|
-
error_parts.append("Arguments received:")
|
160
|
-
for key, value in arguments.items():
|
161
|
-
error_parts.append(
|
162
|
-
f" {key}: {repr(value)} ({type(value).__name__})"
|
163
|
-
)
|
164
|
-
return "\n".join(error_parts)
|
165
|
-
|
166
|
-
# Check for missing required arguments (ignoring *args / **kwargs / self)
|
167
|
-
required_params = [
|
168
|
-
name
|
169
|
-
for name, p in params.items()
|
170
|
-
if p.kind
|
171
|
-
in (
|
172
|
-
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
173
|
-
inspect.Parameter.KEYWORD_ONLY,
|
174
|
-
)
|
175
|
-
and p.default is inspect._empty
|
176
|
-
and name != "self"
|
177
|
-
]
|
178
|
-
missing = [name for name in required_params if name not in arguments]
|
179
|
-
if missing:
|
180
|
-
# Build detailed error message with received arguments
|
181
|
-
error_parts = [
|
182
|
-
"Missing required argument(s): " + ", ".join(sorted(missing))
|
183
|
-
]
|
184
|
-
error_parts.append("Arguments received:")
|
185
|
-
if isinstance(arguments, dict):
|
186
|
-
for key, value in arguments.items():
|
187
|
-
error_parts.append(
|
188
|
-
f" {key}: {repr(value)} ({type(value).__name__})"
|
189
|
-
)
|
190
|
-
elif arguments is not None:
|
191
|
-
error_parts.append(f" {repr(arguments)} ({type(arguments).__name__})")
|
192
|
-
else:
|
193
|
-
error_parts.append(" None")
|
194
|
-
return "\n".join(error_parts)
|
195
|
-
|
196
|
-
return None
|
197
|
-
|
198
|
-
def execute_by_name(
|
199
|
-
self, tool_name: str, *args, request_id=None, arguments=None, **kwargs
|
200
|
-
):
|
201
|
-
self._check_tool_permissions(tool_name, request_id, arguments)
|
202
|
-
tool = self.get_tool(tool_name)
|
203
|
-
self._ensure_tool_exists(tool, tool_name, request_id, arguments)
|
204
|
-
func = self._get_tool_callable(tool)
|
205
|
-
|
206
|
-
validation_error = self._validate_tool_arguments(
|
207
|
-
tool, func, arguments, tool_name, request_id
|
208
|
-
)
|
209
|
-
if validation_error:
|
210
|
-
return validation_error
|
211
|
-
|
212
|
-
# --- SECURITY: Path restriction enforcement ---
|
213
|
-
if not getattr(self, "unrestricted_paths", False):
|
214
|
-
workdir = getattr(self, "workdir", None)
|
215
|
-
# Ensure workdir is always set; default to current working directory.
|
216
|
-
if not workdir:
|
217
|
-
import os
|
218
|
-
|
219
|
-
workdir = os.getcwd()
|
220
|
-
from janito.tools.path_security import (
|
221
|
-
validate_paths_in_arguments,
|
222
|
-
PathSecurityError,
|
223
|
-
)
|
224
|
-
|
225
|
-
schema = getattr(tool, "schema", None)
|
226
|
-
# Only validate paths for dictionary-style arguments
|
227
|
-
if isinstance(arguments, dict):
|
228
|
-
try:
|
229
|
-
validate_paths_in_arguments(arguments, workdir, schema=schema)
|
230
|
-
except PathSecurityError as sec_err:
|
231
|
-
# Publish both a ToolCallError and a user-facing ReportEvent for path security errors
|
232
|
-
self._publish_tool_call_error(
|
233
|
-
tool_name, request_id, str(sec_err), arguments
|
234
|
-
)
|
235
|
-
if self._event_bus:
|
236
|
-
from janito.report_events import (
|
237
|
-
ReportEvent,
|
238
|
-
ReportSubtype,
|
239
|
-
ReportAction,
|
240
|
-
)
|
241
|
-
|
242
|
-
self._event_bus.publish(
|
243
|
-
ReportEvent(
|
244
|
-
subtype=ReportSubtype.ERROR,
|
245
|
-
message=f"[SECURITY] Path access denied: {sec_err}",
|
246
|
-
action=ReportAction.EXECUTE,
|
247
|
-
tool=tool_name,
|
248
|
-
context={
|
249
|
-
"arguments": arguments,
|
250
|
-
"request_id": request_id,
|
251
|
-
},
|
252
|
-
)
|
253
|
-
)
|
254
|
-
return f"Security error: {sec_err}"
|
255
|
-
# --- END SECURITY ---
|
256
|
-
|
257
|
-
self._publish_tool_call_started(tool_name, request_id, arguments)
|
258
|
-
self._print_verbose(
|
259
|
-
f"[tools-adapter] Executing tool: {tool_name} with arguments: {arguments}"
|
260
|
-
)
|
261
|
-
try:
|
262
|
-
# Normalize arguments to ensure proper type handling
|
263
|
-
normalized_args = self._normalize_arguments(arguments, tool, func)
|
264
|
-
|
265
|
-
if isinstance(normalized_args, (list, tuple)):
|
266
|
-
# Positional arguments supplied as an array → expand as *args
|
267
|
-
result = self.execute(tool, *normalized_args, **kwargs)
|
268
|
-
elif isinstance(normalized_args, dict) or normalized_args is None:
|
269
|
-
# Keyword-style arguments (the default) – pass as **kwargs
|
270
|
-
result = self.execute(tool, **(normalized_args or {}), **kwargs)
|
271
|
-
else:
|
272
|
-
# Single positional argument (scalar/str/int/…)
|
273
|
-
result = self.execute(tool, normalized_args, **kwargs)
|
274
|
-
except Exception as e:
|
275
|
-
# Handle exception and return error message instead of raising
|
276
|
-
error_result = self._handle_execution_error(
|
277
|
-
tool_name, request_id, e, arguments
|
278
|
-
)
|
279
|
-
if error_result is not None:
|
280
|
-
return error_result
|
281
|
-
# If _handle_execution_error returns None, re-raise
|
282
|
-
raise
|
283
|
-
self._print_verbose(
|
284
|
-
f"[tools-adapter] Tool execution finished: {tool_name} -> {result}"
|
285
|
-
)
|
286
|
-
self._publish_tool_call_finished(tool_name, request_id, result)
|
287
|
-
return result
|
288
|
-
|
289
|
-
def _validate_tool_arguments(self, tool, func, arguments, tool_name, request_id):
|
290
|
-
sig_error = self._validate_arguments_against_signature(func, arguments)
|
291
|
-
if sig_error:
|
292
|
-
self._publish_tool_call_error(tool_name, request_id, sig_error, arguments)
|
293
|
-
return sig_error
|
294
|
-
schema = getattr(tool, "schema", None)
|
295
|
-
if schema and arguments is not None:
|
296
|
-
validation_error = self._validate_arguments_against_schema(
|
297
|
-
arguments, schema
|
298
|
-
)
|
299
|
-
if validation_error:
|
300
|
-
self._publish_tool_call_error(
|
301
|
-
tool_name, request_id, validation_error, arguments
|
302
|
-
)
|
303
|
-
return validation_error
|
304
|
-
return None
|
305
|
-
|
306
|
-
def _publish_tool_call_error(self, tool_name, request_id, error, arguments):
|
307
|
-
if self._event_bus:
|
308
|
-
self._event_bus.publish(
|
309
|
-
ToolCallError(
|
310
|
-
tool_name=tool_name,
|
311
|
-
request_id=request_id,
|
312
|
-
error=error,
|
313
|
-
arguments=arguments,
|
314
|
-
)
|
315
|
-
)
|
316
|
-
|
317
|
-
def _publish_tool_call_started(self, tool_name, request_id, arguments):
|
318
|
-
if self._event_bus:
|
319
|
-
self._event_bus.publish(
|
320
|
-
ToolCallStarted(
|
321
|
-
tool_name=tool_name, request_id=request_id, arguments=arguments
|
322
|
-
)
|
323
|
-
)
|
324
|
-
|
325
|
-
def _publish_tool_call_finished(self, tool_name, request_id, result):
|
326
|
-
if self._event_bus:
|
327
|
-
self._event_bus.publish(
|
328
|
-
ToolCallFinished(
|
329
|
-
tool_name=tool_name, request_id=request_id, result=result
|
330
|
-
)
|
331
|
-
)
|
332
|
-
|
333
|
-
def _print_verbose(self, message):
|
334
|
-
if self.verbose_tools:
|
335
|
-
print(message)
|
336
|
-
|
337
|
-
def _normalize_arguments(self, arguments, tool, func):
|
338
|
-
"""
|
339
|
-
Normalize arguments to ensure proper type handling at the adapter level.
|
340
|
-
|
341
|
-
This handles cases where:
|
342
|
-
1. String is passed instead of list for array parameters
|
343
|
-
2. JSON string parsing issues
|
344
|
-
3. Other type mismatches that can be automatically resolved
|
345
|
-
"""
|
346
|
-
import inspect
|
347
|
-
import json
|
348
|
-
|
349
|
-
# If arguments is already a dict or None, return as-is
|
350
|
-
if isinstance(arguments, dict) or arguments is None:
|
351
|
-
return arguments
|
352
|
-
|
353
|
-
# If arguments is a list/tuple, return as-is (positional args)
|
354
|
-
if isinstance(arguments, (list, tuple)):
|
355
|
-
return arguments
|
356
|
-
|
357
|
-
# Handle string arguments
|
358
|
-
if isinstance(arguments, str):
|
359
|
-
# Try to parse as JSON if it looks like JSON
|
360
|
-
stripped = arguments.strip()
|
361
|
-
if (stripped.startswith("{") and stripped.endswith("}")) or (
|
362
|
-
stripped.startswith("[") and stripped.endswith("]")
|
363
|
-
):
|
364
|
-
try:
|
365
|
-
parsed = json.loads(arguments)
|
366
|
-
return parsed
|
367
|
-
except json.JSONDecodeError:
|
368
|
-
# If it looks like JSON but failed, try to handle common issues
|
369
|
-
pass
|
370
|
-
|
371
|
-
# Check if the function expects a list parameter
|
372
|
-
try:
|
373
|
-
sig = inspect.signature(func)
|
374
|
-
params = list(sig.parameters.values())
|
375
|
-
|
376
|
-
# Skip 'self' parameter for methods
|
377
|
-
if len(params) > 0 and params[0].name == "self":
|
378
|
-
params = params[1:]
|
379
|
-
|
380
|
-
# If there's exactly one parameter that expects a list, wrap string in list
|
381
|
-
if len(params) == 1:
|
382
|
-
param = params[0]
|
383
|
-
annotation = param.annotation
|
384
|
-
|
385
|
-
# Check if annotation is list[str] or similar
|
386
|
-
if (
|
387
|
-
hasattr(annotation, "__origin__")
|
388
|
-
and annotation.__origin__ is list
|
389
|
-
):
|
390
|
-
return [arguments]
|
391
|
-
elif (
|
392
|
-
str(annotation).startswith("list[") or str(annotation) == "list"
|
393
|
-
):
|
394
|
-
return [arguments]
|
395
|
-
|
396
|
-
except (ValueError, TypeError):
|
397
|
-
pass
|
398
|
-
|
399
|
-
# Return original arguments for other cases
|
400
|
-
return arguments
|
401
|
-
|
402
|
-
def execute_function_call_message_part(self, function_call_message_part):
|
403
|
-
"""
|
404
|
-
Execute a FunctionCallMessagePart by extracting the tool name and arguments and dispatching to execute_by_name.
|
405
|
-
"""
|
406
|
-
import json
|
407
|
-
|
408
|
-
function = getattr(function_call_message_part, "function", None)
|
409
|
-
tool_call_id = getattr(function_call_message_part, "tool_call_id", None)
|
410
|
-
if function is None or not hasattr(function, "name"):
|
411
|
-
raise ValueError(
|
412
|
-
"FunctionCallMessagePart does not contain a valid function object."
|
413
|
-
)
|
414
|
-
tool_name = function.name
|
415
|
-
arguments = function.arguments
|
416
|
-
# Parse arguments if they are a JSON string
|
417
|
-
if isinstance(arguments, str):
|
418
|
-
try:
|
419
|
-
# Try to parse as JSON first
|
420
|
-
arguments = json.loads(arguments)
|
421
|
-
except json.JSONDecodeError:
|
422
|
-
# Handle single quotes in JSON strings
|
423
|
-
try:
|
424
|
-
# Replace single quotes with double quotes for JSON compatibility
|
425
|
-
fixed_json = arguments.replace("'", '"')
|
426
|
-
arguments = json.loads(fixed_json)
|
427
|
-
except (json.JSONDecodeError, ValueError):
|
428
|
-
# If it's a string that looks like it might be a single path parameter,
|
429
|
-
# try to handle it gracefully
|
430
|
-
if arguments.startswith("{") and arguments.endswith("}"):
|
431
|
-
# Looks like JSON but failed to parse - this is likely an error
|
432
|
-
pass
|
433
|
-
else:
|
434
|
-
# Single string argument - let the normalization handle it
|
435
|
-
pass
|
436
|
-
|
437
|
-
|
438
|
-
|
439
|
-
)
|
440
|
-
|
441
|
-
|
442
|
-
|
443
|
-
|
444
|
-
|
445
|
-
|
446
|
-
|
447
|
-
|
448
|
-
def
|
449
|
-
|
450
|
-
|
451
|
-
|
452
|
-
|
453
|
-
|
454
|
-
|
455
|
-
|
456
|
-
|
457
|
-
|
458
|
-
|
459
|
-
|
460
|
-
|
461
|
-
|
462
|
-
|
463
|
-
|
464
|
-
|
465
|
-
|
466
|
-
|
467
|
-
|
468
|
-
|
469
|
-
|
470
|
-
|
471
|
-
|
472
|
-
|
473
|
-
|
474
|
-
|
475
|
-
|
476
|
-
|
477
|
-
|
478
|
-
|
479
|
-
|
480
|
-
|
481
|
-
|
482
|
-
|
483
|
-
|
484
|
-
|
485
|
-
|
486
|
-
|
487
|
-
|
488
|
-
|
489
|
-
|
490
|
-
|
491
|
-
|
492
|
-
|
493
|
-
|
494
|
-
|
495
|
-
|
496
|
-
|
497
|
-
|
498
|
-
|
499
|
-
|
500
|
-
|
501
|
-
|
502
|
-
|
503
|
-
|
504
|
-
|
505
|
-
|
506
|
-
|
507
|
-
|
508
|
-
|
509
|
-
|
510
|
-
|
1
|
+
from janito.tools.tool_base import ToolBase
|
2
|
+
from janito.tools.tool_events import ToolCallStarted, ToolCallFinished, ToolCallError
|
3
|
+
from janito.exceptions import ToolCallException
|
4
|
+
from janito.tools.tool_base import ToolPermissions
|
5
|
+
|
6
|
+
|
7
|
+
class ToolsAdapterBase:
|
8
|
+
"""
|
9
|
+
Composable entry point for tools management and provisioning in LLM pipelines.
|
10
|
+
This class represents an external or plugin-based provider of tool definitions.
|
11
|
+
Extend and customize this to load, register, or serve tool implementations dynamically.
|
12
|
+
After refactor, also responsible for tool execution.
|
13
|
+
"""
|
14
|
+
|
15
|
+
def __init__(self, tools=None, event_bus=None):
|
16
|
+
self._tools = tools or []
|
17
|
+
self._event_bus = event_bus # event bus can be set on all adapters
|
18
|
+
self.verbose_tools = False
|
19
|
+
|
20
|
+
def set_verbose_tools(self, value: bool):
|
21
|
+
self.verbose_tools = value
|
22
|
+
|
23
|
+
@property
|
24
|
+
def event_bus(self):
|
25
|
+
return self._event_bus
|
26
|
+
|
27
|
+
@event_bus.setter
|
28
|
+
def event_bus(self, bus):
|
29
|
+
self._event_bus = bus
|
30
|
+
|
31
|
+
def is_tool_allowed(self, tool):
|
32
|
+
"""Check if a tool is allowed based on current global AllowedPermissionsState."""
|
33
|
+
from janito.tools.permissions import get_global_allowed_permissions
|
34
|
+
|
35
|
+
allowed_permissions = get_global_allowed_permissions()
|
36
|
+
perms = tool.permissions # permissions are mandatory and type-checked
|
37
|
+
# If all permissions are False, block all tools
|
38
|
+
if not (
|
39
|
+
allowed_permissions.read
|
40
|
+
or allowed_permissions.write
|
41
|
+
or allowed_permissions.execute
|
42
|
+
):
|
43
|
+
return False
|
44
|
+
for perm in ["read", "write", "execute"]:
|
45
|
+
if getattr(perms, perm) and not getattr(allowed_permissions, perm):
|
46
|
+
return False
|
47
|
+
return True
|
48
|
+
|
49
|
+
def get_tools(self):
|
50
|
+
"""Return the list of enabled tools managed by this provider, filtered by allowed permissions and disabled tools."""
|
51
|
+
from janito.tools.disabled_tools import is_tool_disabled
|
52
|
+
|
53
|
+
tools = [
|
54
|
+
tool
|
55
|
+
for tool in self._tools
|
56
|
+
if self.is_tool_allowed(tool)
|
57
|
+
and not is_tool_disabled(getattr(tool, "tool_name", str(tool)))
|
58
|
+
]
|
59
|
+
return tools
|
60
|
+
|
61
|
+
def set_allowed_permissions(self, allowed_permissions):
|
62
|
+
"""Set the allowed permissions at runtime. This now updates the global AllowedPermissionsState only."""
|
63
|
+
from janito.tools.permissions import set_global_allowed_permissions
|
64
|
+
|
65
|
+
set_global_allowed_permissions(allowed_permissions)
|
66
|
+
|
67
|
+
def add_tool(self, tool):
|
68
|
+
self._tools.append(tool)
|
69
|
+
|
70
|
+
def _validate_arguments_against_schema(self, arguments: dict, schema: dict):
|
71
|
+
properties = schema.get("properties", {})
|
72
|
+
required = schema.get("required", [])
|
73
|
+
missing = [field for field in required if field not in arguments]
|
74
|
+
if missing:
|
75
|
+
return f"Missing required argument(s): {', '.join(missing)}"
|
76
|
+
type_map = {
|
77
|
+
"string": str,
|
78
|
+
"integer": int,
|
79
|
+
"number": (int, float),
|
80
|
+
"boolean": bool,
|
81
|
+
"array": list,
|
82
|
+
"object": dict,
|
83
|
+
}
|
84
|
+
for key, value in arguments.items():
|
85
|
+
if key not in properties:
|
86
|
+
continue
|
87
|
+
expected_type = properties[key].get("type")
|
88
|
+
if expected_type and expected_type in type_map:
|
89
|
+
if not isinstance(value, type_map[expected_type]):
|
90
|
+
return f"Argument '{key}' should be of type '{expected_type}', got '{type(value).__name__}'"
|
91
|
+
return None
|
92
|
+
|
93
|
+
def execute(self, tool, *args, **kwargs):
|
94
|
+
|
95
|
+
if self.verbose_tools:
|
96
|
+
print(
|
97
|
+
f"[tools-adapter] [execute] Executing tool: {getattr(tool, 'tool_name', repr(tool))} with args: {args}, kwargs: {kwargs}"
|
98
|
+
)
|
99
|
+
if isinstance(tool, ToolBase):
|
100
|
+
tool.event_bus = self._event_bus
|
101
|
+
result = None
|
102
|
+
if callable(tool):
|
103
|
+
result = tool(*args, **kwargs)
|
104
|
+
elif hasattr(tool, "execute") and callable(getattr(tool, "execute")):
|
105
|
+
result = tool.execute(*args, **kwargs)
|
106
|
+
elif hasattr(tool, "run") and callable(getattr(tool, "run")):
|
107
|
+
result = tool.run(*args, **kwargs)
|
108
|
+
else:
|
109
|
+
raise ValueError("Provided tool is not executable.")
|
110
|
+
|
111
|
+
return result
|
112
|
+
|
113
|
+
def _get_tool_callable(self, tool):
|
114
|
+
"""Helper to retrieve the primary callable of a tool instance."""
|
115
|
+
if callable(tool):
|
116
|
+
return tool
|
117
|
+
if hasattr(tool, "execute") and callable(getattr(tool, "execute")):
|
118
|
+
return getattr(tool, "execute")
|
119
|
+
if hasattr(tool, "run") and callable(getattr(tool, "run")):
|
120
|
+
return getattr(tool, "run")
|
121
|
+
raise ValueError("Provided tool is not executable.")
|
122
|
+
|
123
|
+
def _validate_arguments_against_signature(self, func, arguments: dict):
|
124
|
+
"""Validate provided arguments against a callable signature.
|
125
|
+
|
126
|
+
Returns an error string if validation fails, otherwise ``None``.
|
127
|
+
"""
|
128
|
+
import inspect
|
129
|
+
|
130
|
+
if arguments is None:
|
131
|
+
arguments = {}
|
132
|
+
# If arguments are provided as a non-dict (e.g. a list or a scalar)
|
133
|
+
# we skip signature *keyword* validation completely and defer the
|
134
|
+
# decision to Python's own call mechanics when the function is
|
135
|
+
# eventually invoked. This allows positional / variadic arguments to
|
136
|
+
# be supplied by callers that intentionally bypass the structured
|
137
|
+
# JSON-schema style interface.
|
138
|
+
if not isinstance(arguments, dict):
|
139
|
+
# Nothing to validate at this stage – treat as OK.
|
140
|
+
return None
|
141
|
+
|
142
|
+
sig = inspect.signature(func)
|
143
|
+
params = sig.parameters
|
144
|
+
|
145
|
+
# Check for unexpected arguments (unless **kwargs is accepted)
|
146
|
+
accepts_kwargs = any(
|
147
|
+
p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()
|
148
|
+
)
|
149
|
+
if not accepts_kwargs:
|
150
|
+
unexpected = [k for k in arguments.keys() if k not in params]
|
151
|
+
if unexpected:
|
152
|
+
# Build detailed error message with received arguments
|
153
|
+
error_parts = [
|
154
|
+
"Unexpected argument(s): " + ", ".join(sorted(unexpected))
|
155
|
+
]
|
156
|
+
error_parts.append(
|
157
|
+
"Valid parameters: " + ", ".join(sorted(params.keys()))
|
158
|
+
)
|
159
|
+
error_parts.append("Arguments received:")
|
160
|
+
for key, value in arguments.items():
|
161
|
+
error_parts.append(
|
162
|
+
f" {key}: {repr(value)} ({type(value).__name__})"
|
163
|
+
)
|
164
|
+
return "\n".join(error_parts)
|
165
|
+
|
166
|
+
# Check for missing required arguments (ignoring *args / **kwargs / self)
|
167
|
+
required_params = [
|
168
|
+
name
|
169
|
+
for name, p in params.items()
|
170
|
+
if p.kind
|
171
|
+
in (
|
172
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
173
|
+
inspect.Parameter.KEYWORD_ONLY,
|
174
|
+
)
|
175
|
+
and p.default is inspect._empty
|
176
|
+
and name != "self"
|
177
|
+
]
|
178
|
+
missing = [name for name in required_params if name not in arguments]
|
179
|
+
if missing:
|
180
|
+
# Build detailed error message with received arguments
|
181
|
+
error_parts = [
|
182
|
+
"Missing required argument(s): " + ", ".join(sorted(missing))
|
183
|
+
]
|
184
|
+
error_parts.append("Arguments received:")
|
185
|
+
if isinstance(arguments, dict):
|
186
|
+
for key, value in arguments.items():
|
187
|
+
error_parts.append(
|
188
|
+
f" {key}: {repr(value)} ({type(value).__name__})"
|
189
|
+
)
|
190
|
+
elif arguments is not None:
|
191
|
+
error_parts.append(f" {repr(arguments)} ({type(arguments).__name__})")
|
192
|
+
else:
|
193
|
+
error_parts.append(" None")
|
194
|
+
return "\n".join(error_parts)
|
195
|
+
|
196
|
+
return None
|
197
|
+
|
198
|
+
def execute_by_name(
|
199
|
+
self, tool_name: str, *args, request_id=None, arguments=None, **kwargs
|
200
|
+
):
|
201
|
+
self._check_tool_permissions(tool_name, request_id, arguments)
|
202
|
+
tool = self.get_tool(tool_name)
|
203
|
+
self._ensure_tool_exists(tool, tool_name, request_id, arguments)
|
204
|
+
func = self._get_tool_callable(tool)
|
205
|
+
|
206
|
+
validation_error = self._validate_tool_arguments(
|
207
|
+
tool, func, arguments, tool_name, request_id
|
208
|
+
)
|
209
|
+
if validation_error:
|
210
|
+
return validation_error
|
211
|
+
|
212
|
+
# --- SECURITY: Path restriction enforcement ---
|
213
|
+
if not getattr(self, "unrestricted_paths", False):
|
214
|
+
workdir = getattr(self, "workdir", None)
|
215
|
+
# Ensure workdir is always set; default to current working directory.
|
216
|
+
if not workdir:
|
217
|
+
import os
|
218
|
+
|
219
|
+
workdir = os.getcwd()
|
220
|
+
from janito.tools.path_security import (
|
221
|
+
validate_paths_in_arguments,
|
222
|
+
PathSecurityError,
|
223
|
+
)
|
224
|
+
|
225
|
+
schema = getattr(tool, "schema", None)
|
226
|
+
# Only validate paths for dictionary-style arguments
|
227
|
+
if isinstance(arguments, dict):
|
228
|
+
try:
|
229
|
+
validate_paths_in_arguments(arguments, workdir, schema=schema)
|
230
|
+
except PathSecurityError as sec_err:
|
231
|
+
# Publish both a ToolCallError and a user-facing ReportEvent for path security errors
|
232
|
+
self._publish_tool_call_error(
|
233
|
+
tool_name, request_id, str(sec_err), arguments
|
234
|
+
)
|
235
|
+
if self._event_bus:
|
236
|
+
from janito.report_events import (
|
237
|
+
ReportEvent,
|
238
|
+
ReportSubtype,
|
239
|
+
ReportAction,
|
240
|
+
)
|
241
|
+
|
242
|
+
self._event_bus.publish(
|
243
|
+
ReportEvent(
|
244
|
+
subtype=ReportSubtype.ERROR,
|
245
|
+
message=f"[SECURITY] Path access denied: {sec_err}",
|
246
|
+
action=ReportAction.EXECUTE,
|
247
|
+
tool=tool_name,
|
248
|
+
context={
|
249
|
+
"arguments": arguments,
|
250
|
+
"request_id": request_id,
|
251
|
+
},
|
252
|
+
)
|
253
|
+
)
|
254
|
+
return f"Security error: {sec_err}"
|
255
|
+
# --- END SECURITY ---
|
256
|
+
|
257
|
+
self._publish_tool_call_started(tool_name, request_id, arguments)
|
258
|
+
self._print_verbose(
|
259
|
+
f"[tools-adapter] Executing tool: {tool_name} with arguments: {arguments}"
|
260
|
+
)
|
261
|
+
try:
|
262
|
+
# Normalize arguments to ensure proper type handling
|
263
|
+
normalized_args = self._normalize_arguments(arguments, tool, func)
|
264
|
+
|
265
|
+
if isinstance(normalized_args, (list, tuple)):
|
266
|
+
# Positional arguments supplied as an array → expand as *args
|
267
|
+
result = self.execute(tool, *normalized_args, **kwargs)
|
268
|
+
elif isinstance(normalized_args, dict) or normalized_args is None:
|
269
|
+
# Keyword-style arguments (the default) – pass as **kwargs
|
270
|
+
result = self.execute(tool, **(normalized_args or {}), **kwargs)
|
271
|
+
else:
|
272
|
+
# Single positional argument (scalar/str/int/…)
|
273
|
+
result = self.execute(tool, normalized_args, **kwargs)
|
274
|
+
except Exception as e:
|
275
|
+
# Handle exception and return error message instead of raising
|
276
|
+
error_result = self._handle_execution_error(
|
277
|
+
tool_name, request_id, e, arguments
|
278
|
+
)
|
279
|
+
if error_result is not None:
|
280
|
+
return error_result
|
281
|
+
# If _handle_execution_error returns None, re-raise
|
282
|
+
raise
|
283
|
+
self._print_verbose(
|
284
|
+
f"[tools-adapter] Tool execution finished: {tool_name} -> {result}"
|
285
|
+
)
|
286
|
+
self._publish_tool_call_finished(tool_name, request_id, result)
|
287
|
+
return result
|
288
|
+
|
289
|
+
def _validate_tool_arguments(self, tool, func, arguments, tool_name, request_id):
|
290
|
+
sig_error = self._validate_arguments_against_signature(func, arguments)
|
291
|
+
if sig_error:
|
292
|
+
self._publish_tool_call_error(tool_name, request_id, sig_error, arguments)
|
293
|
+
return sig_error
|
294
|
+
schema = getattr(tool, "schema", None)
|
295
|
+
if schema and arguments is not None:
|
296
|
+
validation_error = self._validate_arguments_against_schema(
|
297
|
+
arguments, schema
|
298
|
+
)
|
299
|
+
if validation_error:
|
300
|
+
self._publish_tool_call_error(
|
301
|
+
tool_name, request_id, validation_error, arguments
|
302
|
+
)
|
303
|
+
return validation_error
|
304
|
+
return None
|
305
|
+
|
306
|
+
def _publish_tool_call_error(self, tool_name, request_id, error, arguments):
|
307
|
+
if self._event_bus:
|
308
|
+
self._event_bus.publish(
|
309
|
+
ToolCallError(
|
310
|
+
tool_name=tool_name,
|
311
|
+
request_id=request_id,
|
312
|
+
error=error,
|
313
|
+
arguments=arguments,
|
314
|
+
)
|
315
|
+
)
|
316
|
+
|
317
|
+
def _publish_tool_call_started(self, tool_name, request_id, arguments):
|
318
|
+
if self._event_bus:
|
319
|
+
self._event_bus.publish(
|
320
|
+
ToolCallStarted(
|
321
|
+
tool_name=tool_name, request_id=request_id, arguments=arguments
|
322
|
+
)
|
323
|
+
)
|
324
|
+
|
325
|
+
def _publish_tool_call_finished(self, tool_name, request_id, result):
|
326
|
+
if self._event_bus:
|
327
|
+
self._event_bus.publish(
|
328
|
+
ToolCallFinished(
|
329
|
+
tool_name=tool_name, request_id=request_id, result=result
|
330
|
+
)
|
331
|
+
)
|
332
|
+
|
333
|
+
def _print_verbose(self, message):
|
334
|
+
if self.verbose_tools:
|
335
|
+
print(message)
|
336
|
+
|
337
|
+
def _normalize_arguments(self, arguments, tool, func):
|
338
|
+
"""
|
339
|
+
Normalize arguments to ensure proper type handling at the adapter level.
|
340
|
+
|
341
|
+
This handles cases where:
|
342
|
+
1. String is passed instead of list for array parameters
|
343
|
+
2. JSON string parsing issues
|
344
|
+
3. Other type mismatches that can be automatically resolved
|
345
|
+
"""
|
346
|
+
import inspect
|
347
|
+
import json
|
348
|
+
|
349
|
+
# If arguments is already a dict or None, return as-is
|
350
|
+
if isinstance(arguments, dict) or arguments is None:
|
351
|
+
return arguments
|
352
|
+
|
353
|
+
# If arguments is a list/tuple, return as-is (positional args)
|
354
|
+
if isinstance(arguments, (list, tuple)):
|
355
|
+
return arguments
|
356
|
+
|
357
|
+
# Handle string arguments
|
358
|
+
if isinstance(arguments, str):
|
359
|
+
# Try to parse as JSON if it looks like JSON
|
360
|
+
stripped = arguments.strip()
|
361
|
+
if (stripped.startswith("{") and stripped.endswith("}")) or (
|
362
|
+
stripped.startswith("[") and stripped.endswith("]")
|
363
|
+
):
|
364
|
+
try:
|
365
|
+
parsed = json.loads(arguments)
|
366
|
+
return parsed
|
367
|
+
except json.JSONDecodeError:
|
368
|
+
# If it looks like JSON but failed, try to handle common issues
|
369
|
+
pass
|
370
|
+
|
371
|
+
# Check if the function expects a list parameter
|
372
|
+
try:
|
373
|
+
sig = inspect.signature(func)
|
374
|
+
params = list(sig.parameters.values())
|
375
|
+
|
376
|
+
# Skip 'self' parameter for methods
|
377
|
+
if len(params) > 0 and params[0].name == "self":
|
378
|
+
params = params[1:]
|
379
|
+
|
380
|
+
# If there's exactly one parameter that expects a list, wrap string in list
|
381
|
+
if len(params) == 1:
|
382
|
+
param = params[0]
|
383
|
+
annotation = param.annotation
|
384
|
+
|
385
|
+
# Check if annotation is list[str] or similar
|
386
|
+
if (
|
387
|
+
hasattr(annotation, "__origin__")
|
388
|
+
and annotation.__origin__ is list
|
389
|
+
):
|
390
|
+
return [arguments]
|
391
|
+
elif (
|
392
|
+
str(annotation).startswith("list[") or str(annotation) == "list"
|
393
|
+
):
|
394
|
+
return [arguments]
|
395
|
+
|
396
|
+
except (ValueError, TypeError):
|
397
|
+
pass
|
398
|
+
|
399
|
+
# Return original arguments for other cases
|
400
|
+
return arguments
|
401
|
+
|
402
|
+
def execute_function_call_message_part(self, function_call_message_part):
|
403
|
+
"""
|
404
|
+
Execute a FunctionCallMessagePart by extracting the tool name and arguments and dispatching to execute_by_name.
|
405
|
+
"""
|
406
|
+
import json
|
407
|
+
|
408
|
+
function = getattr(function_call_message_part, "function", None)
|
409
|
+
tool_call_id = getattr(function_call_message_part, "tool_call_id", None)
|
410
|
+
if function is None or not hasattr(function, "name"):
|
411
|
+
raise ValueError(
|
412
|
+
"FunctionCallMessagePart does not contain a valid function object."
|
413
|
+
)
|
414
|
+
tool_name = function.name
|
415
|
+
arguments = function.arguments
|
416
|
+
# Parse arguments if they are a JSON string
|
417
|
+
if isinstance(arguments, str):
|
418
|
+
try:
|
419
|
+
# Try to parse as JSON first
|
420
|
+
arguments = json.loads(arguments)
|
421
|
+
except json.JSONDecodeError:
|
422
|
+
# Handle single quotes in JSON strings
|
423
|
+
try:
|
424
|
+
# Replace single quotes with double quotes for JSON compatibility
|
425
|
+
fixed_json = arguments.replace("'", '"')
|
426
|
+
arguments = json.loads(fixed_json)
|
427
|
+
except (json.JSONDecodeError, ValueError):
|
428
|
+
# If it's a string that looks like it might be a single path parameter,
|
429
|
+
# try to handle it gracefully
|
430
|
+
if arguments.startswith("{") and arguments.endswith("}"):
|
431
|
+
# Looks like JSON but failed to parse - this is likely an error
|
432
|
+
pass
|
433
|
+
else:
|
434
|
+
# Single string argument - let the normalization handle it
|
435
|
+
pass
|
436
|
+
|
437
|
+
# Convert argument names to lowercase for better matching
|
438
|
+
if isinstance(arguments, dict):
|
439
|
+
arguments = {k.lower(): v for k, v in arguments.items()}
|
440
|
+
if self.verbose_tools:
|
441
|
+
print(
|
442
|
+
f"[tools-adapter] Executing FunctionCallMessagePart: tool={tool_name}, arguments={arguments}, tool_call_id={tool_call_id}"
|
443
|
+
)
|
444
|
+
return self.execute_by_name(
|
445
|
+
tool_name, request_id=tool_call_id, arguments=arguments
|
446
|
+
)
|
447
|
+
|
448
|
+
def _check_tool_permissions(self, tool_name, request_id, arguments):
|
449
|
+
# No enabled_tools check anymore; permission checks are handled by is_tool_allowed
|
450
|
+
pass
|
451
|
+
|
452
|
+
def _ensure_tool_exists(self, tool, tool_name, request_id, arguments):
|
453
|
+
if tool is None:
|
454
|
+
error_msg = f"Tool '{tool_name}' not found in registry."
|
455
|
+
if self._event_bus:
|
456
|
+
self._event_bus.publish(
|
457
|
+
ToolCallError(
|
458
|
+
tool_name=tool_name,
|
459
|
+
request_id=request_id,
|
460
|
+
error=error_msg,
|
461
|
+
arguments=arguments,
|
462
|
+
)
|
463
|
+
)
|
464
|
+
raise ToolCallException(tool_name, error_msg, arguments=arguments)
|
465
|
+
|
466
|
+
def _handle_execution_error(self, tool_name, request_id, exception, arguments):
|
467
|
+
# Check if this is a loop protection error that should trigger a new strategy
|
468
|
+
if isinstance(exception, RuntimeError) and "Loop protection:" in str(exception):
|
469
|
+
error_msg = str(exception)
|
470
|
+
if self._event_bus:
|
471
|
+
self._event_bus.publish(
|
472
|
+
ToolCallError(
|
473
|
+
tool_name=tool_name,
|
474
|
+
request_id=request_id,
|
475
|
+
error=error_msg,
|
476
|
+
exception=exception,
|
477
|
+
arguments=arguments,
|
478
|
+
)
|
479
|
+
)
|
480
|
+
# Return the loop protection message as string to trigger new strategy
|
481
|
+
return f"Loop protection triggered - requesting new strategy: {error_msg}"
|
482
|
+
|
483
|
+
# Check if this is a string return from loop protection (new behavior)
|
484
|
+
if isinstance(exception, str) and "Loop protection:" in exception:
|
485
|
+
error_msg = str(exception)
|
486
|
+
if self._event_bus:
|
487
|
+
self._event_bus.publish(
|
488
|
+
ToolCallError(
|
489
|
+
tool_name=tool_name,
|
490
|
+
request_id=request_id,
|
491
|
+
error=error_msg,
|
492
|
+
arguments=arguments,
|
493
|
+
)
|
494
|
+
)
|
495
|
+
return f"Loop protection triggered - requesting new strategy: {error_msg}"
|
496
|
+
|
497
|
+
error_msg = f"Exception during execution of tool '{tool_name}': {exception}"
|
498
|
+
if self._event_bus:
|
499
|
+
self._event_bus.publish(
|
500
|
+
ToolCallError(
|
501
|
+
tool_name=tool_name,
|
502
|
+
request_id=request_id,
|
503
|
+
error=error_msg,
|
504
|
+
exception=exception,
|
505
|
+
arguments=arguments,
|
506
|
+
)
|
507
|
+
)
|
508
|
+
raise ToolCallException(
|
509
|
+
tool_name, error_msg, arguments=arguments, exception=exception
|
510
|
+
)
|
511
|
+
|
512
|
+
def get_tool(self, tool_name):
|
513
|
+
"""Abstract method: implement in subclass to return tool instance by name"""
|
514
|
+
raise NotImplementedError()
|