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.
@@ -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
- if self.verbose_tools:
437
- print(
438
- f"[tools-adapter] Executing FunctionCallMessagePart: tool={tool_name}, arguments={arguments}, tool_call_id={tool_call_id}"
439
- )
440
- return self.execute_by_name(
441
- tool_name, request_id=tool_call_id, arguments=arguments
442
- )
443
-
444
- def _check_tool_permissions(self, tool_name, request_id, arguments):
445
- # No enabled_tools check anymore; permission checks are handled by is_tool_allowed
446
- pass
447
-
448
- def _ensure_tool_exists(self, tool, tool_name, request_id, arguments):
449
- if tool is None:
450
- error_msg = f"Tool '{tool_name}' not found in registry."
451
- if self._event_bus:
452
- self._event_bus.publish(
453
- ToolCallError(
454
- tool_name=tool_name,
455
- request_id=request_id,
456
- error=error_msg,
457
- arguments=arguments,
458
- )
459
- )
460
- raise ToolCallException(tool_name, error_msg, arguments=arguments)
461
-
462
- def _handle_execution_error(self, tool_name, request_id, exception, arguments):
463
- # Check if this is a loop protection error that should trigger a new strategy
464
- if isinstance(exception, RuntimeError) and "Loop protection:" in str(exception):
465
- error_msg = str(exception)
466
- if self._event_bus:
467
- self._event_bus.publish(
468
- ToolCallError(
469
- tool_name=tool_name,
470
- request_id=request_id,
471
- error=error_msg,
472
- exception=exception,
473
- arguments=arguments,
474
- )
475
- )
476
- # Return the loop protection message as string to trigger new strategy
477
- return f"Loop protection triggered - requesting new strategy: {error_msg}"
478
-
479
- # Check if this is a string return from loop protection (new behavior)
480
- if isinstance(exception, str) and "Loop protection:" in exception:
481
- error_msg = str(exception)
482
- if self._event_bus:
483
- self._event_bus.publish(
484
- ToolCallError(
485
- tool_name=tool_name,
486
- request_id=request_id,
487
- error=error_msg,
488
- arguments=arguments,
489
- )
490
- )
491
- return f"Loop protection triggered - requesting new strategy: {error_msg}"
492
-
493
- error_msg = f"Exception during execution of tool '{tool_name}': {exception}"
494
- if self._event_bus:
495
- self._event_bus.publish(
496
- ToolCallError(
497
- tool_name=tool_name,
498
- request_id=request_id,
499
- error=error_msg,
500
- exception=exception,
501
- arguments=arguments,
502
- )
503
- )
504
- raise ToolCallException(
505
- tool_name, error_msg, arguments=arguments, exception=exception
506
- )
507
-
508
- def get_tool(self, tool_name):
509
- """Abstract method: implement in subclass to return tool instance by name"""
510
- raise NotImplementedError()
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()