vectara-agentic 0.2.13__py3-none-any.whl → 0.2.15__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.

Potentially problematic release.


This version of vectara-agentic might be problematic. Click here for more details.

@@ -0,0 +1,536 @@
1
+ """
2
+ This module contains the ToolsFactory class for creating agent tools.
3
+ """
4
+
5
+ import inspect
6
+ import re
7
+
8
+ from typing import (
9
+ Callable, List, Dict, Any, Optional, Union, Type, Tuple,
10
+ Sequence
11
+ )
12
+ from pydantic import BaseModel, create_model
13
+ from pydantic_core import PydanticUndefined
14
+
15
+ from llama_index.core.tools import FunctionTool
16
+ from llama_index.core.tools.function_tool import AsyncCallable
17
+ from llama_index.core.tools.types import ToolMetadata, ToolOutput
18
+ from llama_index.core.workflow.context import Context
19
+
20
+ from llama_index.core.tools.types import BaseTool
21
+ from llama_index.core.base.llms.types import ChatMessage, MessageRole
22
+ from llama_index.llms.openai.utils import resolve_tool_choice
23
+
24
+ from .types import ToolType
25
+ from .utils import is_float
26
+
27
+
28
+ def _updated_openai_prepare_chat_with_tools(
29
+ self,
30
+ tools: Sequence["BaseTool"],
31
+ user_msg: Optional[Union[str, ChatMessage]] = None,
32
+ chat_history: Optional[List[ChatMessage]] = None,
33
+ verbose: bool = False,
34
+ allow_parallel_tool_calls: bool = False,
35
+ tool_choice: Union[str, dict] = "auto",
36
+ strict: Optional[bool] = None,
37
+ **kwargs: Any,
38
+ ) -> Dict[str, Any]:
39
+ """Predict and call the tool."""
40
+ tool_specs = [tool.metadata.to_openai_tool(skip_length_check=True) for tool in tools]
41
+
42
+ # if strict is passed in, use, else default to the class-level attribute, else default to True`
43
+ strict = strict if strict is not None else self.strict
44
+
45
+ if self.metadata.is_function_calling_model:
46
+ for tool_spec in tool_specs:
47
+ if tool_spec["type"] == "function":
48
+ tool_spec["function"]["strict"] = strict
49
+ # in current openai 1.40.0 it is always false.
50
+ tool_spec["function"]["parameters"]["additionalProperties"] = False
51
+
52
+ if isinstance(user_msg, str):
53
+ user_msg = ChatMessage(role=MessageRole.USER, content=user_msg)
54
+
55
+ messages = chat_history or []
56
+ if user_msg:
57
+ messages.append(user_msg)
58
+
59
+ return {
60
+ "messages": messages,
61
+ "tools": tool_specs or None,
62
+ "tool_choice": resolve_tool_choice(tool_choice) if tool_specs else None,
63
+ **kwargs,
64
+ }
65
+
66
+ class VectaraToolMetadata(ToolMetadata):
67
+ """
68
+ A subclass of ToolMetadata adding the tool_type attribute.
69
+ """
70
+
71
+ tool_type: ToolType
72
+
73
+ def __init__(self, tool_type: ToolType, **kwargs):
74
+ super().__init__(**kwargs)
75
+ self.tool_type = tool_type
76
+
77
+ def __repr__(self) -> str:
78
+ """
79
+ Returns a string representation of the VectaraToolMetadata object, including the tool_type attribute.
80
+ """
81
+ base_repr = super().__repr__()
82
+ return f"{base_repr}, tool_type={self.tool_type}"
83
+
84
+
85
+ class VectaraTool(FunctionTool):
86
+ """
87
+ A subclass of FunctionTool adding the tool_type attribute.
88
+ """
89
+
90
+ def __init__(
91
+ self,
92
+ tool_type: ToolType,
93
+ metadata: ToolMetadata,
94
+ fn: Optional[Callable[..., Any]] = None,
95
+ async_fn: Optional[AsyncCallable] = None,
96
+ ) -> None:
97
+ metadata_dict = (
98
+ metadata.dict() if hasattr(metadata, "dict") else metadata.__dict__
99
+ )
100
+ vm = VectaraToolMetadata(tool_type=tool_type, **metadata_dict)
101
+ super().__init__(fn, vm, async_fn)
102
+
103
+ @classmethod
104
+ def from_defaults(
105
+ cls,
106
+ fn: Optional[Callable[..., Any]] = None,
107
+ name: Optional[str] = None,
108
+ description: Optional[str] = None,
109
+ return_direct: bool = False,
110
+ fn_schema: Optional[Type[BaseModel]] = None,
111
+ async_fn: Optional[AsyncCallable] = None,
112
+ tool_metadata: Optional[ToolMetadata] = None,
113
+ callback: Optional[Callable[[Any], Any]] = None,
114
+ async_callback: Optional[AsyncCallable] = None,
115
+ tool_type: ToolType = ToolType.QUERY,
116
+ ) -> "VectaraTool":
117
+ tool = FunctionTool.from_defaults(
118
+ fn,
119
+ name,
120
+ description,
121
+ return_direct,
122
+ fn_schema,
123
+ async_fn,
124
+ tool_metadata,
125
+ callback,
126
+ async_callback,
127
+ )
128
+ vectara_tool = cls(
129
+ tool_type=tool_type,
130
+ fn=tool.fn,
131
+ metadata=tool.metadata,
132
+ async_fn=tool.async_fn,
133
+ )
134
+ return vectara_tool
135
+
136
+ def __str__(self) -> str:
137
+ return f"Tool(name={self.metadata.name}, " f"Tool metadata={self.metadata})"
138
+
139
+ def __repr__(self) -> str:
140
+ return str(self)
141
+
142
+ def __eq__(self, other):
143
+ if not isinstance(other, VectaraTool):
144
+ return False
145
+
146
+ if self.metadata.tool_type != other.metadata.tool_type:
147
+ return False
148
+
149
+ if self.metadata.name != other.metadata.name:
150
+ return False
151
+
152
+ # If schema is a dict-like object, compare the dict representation
153
+ try:
154
+ # Try to get schema as dict if possible
155
+ if hasattr(self.metadata.fn_schema, "schema"):
156
+ self_schema = self.metadata.fn_schema.schema
157
+ other_schema = other.metadata.fn_schema.schema
158
+
159
+ # Compare only properties and required fields
160
+ self_props = self_schema.get("properties", {})
161
+ other_props = other_schema.get("properties", {})
162
+
163
+ self_required = self_schema.get("required", [])
164
+ other_required = other_schema.get("required", [])
165
+
166
+ return self_props.keys() == other_props.keys() and set(
167
+ self_required
168
+ ) == set(other_required)
169
+ except Exception:
170
+ # If any exception occurs during schema comparison, fall back to name comparison
171
+ pass
172
+
173
+ return True
174
+
175
+ def call(
176
+ self, *args: Any, ctx: Optional[Context] = None, **kwargs: Any
177
+ ) -> ToolOutput:
178
+ try:
179
+ return super().call(*args, ctx=ctx, **kwargs)
180
+ except TypeError as e:
181
+ sig = inspect.signature(self.metadata.fn_schema)
182
+ valid_parameters = list(sig.parameters.keys())
183
+ params_str = ", ".join(valid_parameters)
184
+
185
+ err_output = ToolOutput(
186
+ tool_name=self.metadata.name,
187
+ content=(
188
+ f"Wrong argument used when calling {self.metadata.name}: {str(e)}. "
189
+ f"Valid arguments: {params_str}. please call the tool again with the correct arguments."
190
+ ),
191
+ raw_input={"args": args, "kwargs": kwargs},
192
+ raw_output={"response": str(e)},
193
+ )
194
+ return err_output
195
+ except Exception as e:
196
+ err_output = ToolOutput(
197
+ tool_name=self.metadata.name,
198
+ content=f"Tool {self.metadata.name} Malfunction: {str(e)}",
199
+ raw_input={"args": args, "kwargs": kwargs},
200
+ raw_output={"response": str(e)},
201
+ )
202
+ return err_output
203
+
204
+ async def acall(
205
+ self, *args: Any, ctx: Optional[Context] = None, **kwargs: Any
206
+ ) -> ToolOutput:
207
+ try:
208
+ return await super().acall(*args, ctx=ctx, **kwargs)
209
+ except TypeError as e:
210
+ sig = inspect.signature(self.metadata.fn_schema)
211
+ valid_parameters = list(sig.parameters.keys())
212
+ params_str = ", ".join(valid_parameters)
213
+
214
+ err_output = ToolOutput(
215
+ tool_name=self.metadata.name,
216
+ content=(
217
+ f"Wrong argument used when calling {self.metadata.name}: {str(e)}. "
218
+ f"Valid arguments: {params_str}. please call the tool again with the correct arguments."
219
+ ),
220
+ raw_input={"args": args, "kwargs": kwargs},
221
+ raw_output={"response": str(e)},
222
+ )
223
+ return err_output
224
+ except Exception as e:
225
+ err_output = ToolOutput(
226
+ tool_name=self.metadata.name,
227
+ content=f"Tool {self.metadata.name} Malfunction: {str(e)}",
228
+ raw_input={"args": args, "kwargs": kwargs},
229
+ raw_output={"response": str(e)},
230
+ )
231
+ return err_output
232
+
233
+
234
+ class EmptyBaseModel(BaseModel):
235
+ """empty base model"""
236
+
237
+ def _unwrap_default(default):
238
+ # PydanticUndefined means “no default — required”
239
+ return default if default is not PydanticUndefined else inspect.Parameter.empty
240
+
241
+ def _schema_default(default):
242
+ # PydanticUndefined ⇒ Ellipsis (required)
243
+ return default if default is not PydanticUndefined else ...
244
+
245
+ def _make_docstring(
246
+ function: Callable[..., ToolOutput],
247
+ tool_name: str,
248
+ tool_description: str,
249
+ fn_schema: Type[BaseModel],
250
+ all_params: List[inspect.Parameter],
251
+ compact_docstring: bool,
252
+ ) -> str:
253
+ params_str = ", ".join(
254
+ f"{p.name}: {p.annotation.__name__ if hasattr(p.annotation, '__name__') else p.annotation}"
255
+ for p in all_params
256
+ )
257
+ signature_line = f"{tool_name}({params_str}) -> dict[str, Any]"
258
+ if compact_docstring:
259
+ doc_lines = [tool_description.strip()]
260
+ else:
261
+ doc_lines = [signature_line, "", tool_description.strip()]
262
+ doc_lines += [
263
+ "",
264
+ "Args:",
265
+ ]
266
+
267
+ full_schema = fn_schema.model_json_schema()
268
+ props = full_schema.get("properties", {})
269
+ for prop_name, schema_prop in props.items():
270
+ desc = schema_prop.get("description", "")
271
+
272
+ # pick up any examples you declared on the Field or via schema_extra
273
+ examples = schema_prop.get("examples", [])
274
+ default = schema_prop.get("default", PydanticUndefined)
275
+
276
+ # format the type, default, description, examples
277
+ # find the matching inspect.Parameter so you get its annotation
278
+ param = next((p for p in all_params if p.name == prop_name), None)
279
+ if param and hasattr(param.annotation, "__name__"):
280
+ ty = param.annotation.__name__
281
+ else:
282
+ ty = schema_prop.get("type", "")
283
+
284
+ # inline default if present
285
+ default_txt = f", default={default!r}" if default is not PydanticUndefined else ""
286
+
287
+ # inline examples if any
288
+ if examples:
289
+ examples_txt = ", ".join(repr(e) for e in examples)
290
+ desc = f"{desc} (e.g., {examples_txt})"
291
+
292
+ doc_lines.append(f" - {prop_name} ({ty}{default_txt}): {desc}")
293
+
294
+ doc_lines.append("")
295
+ doc_lines.append("Returns:")
296
+ return_desc = getattr(
297
+ function, "__return_description__", "A dictionary containing the result data."
298
+ )
299
+ doc_lines.append(f" dict[str, Any]: {return_desc}")
300
+
301
+ initial_docstring = "\n".join(doc_lines)
302
+ collapsed_spaces = re.sub(r' {2,}', ' ', initial_docstring)
303
+ final_docstring = re.sub(r'\n{2,}', '\n', collapsed_spaces).strip()
304
+ return final_docstring
305
+
306
+
307
+ def create_tool_from_dynamic_function(
308
+ function: Callable[..., ToolOutput],
309
+ tool_name: str,
310
+ tool_description: str,
311
+ base_params_model: Type[BaseModel],
312
+ tool_args_schema: Type[BaseModel],
313
+ compact_docstring: bool = False,
314
+ return_direct: bool = False,
315
+ ) -> VectaraTool:
316
+ """
317
+ Create a VectaraTool from a dynamic function.
318
+ Args:
319
+ function (Callable[..., ToolOutput]): The function to wrap as a tool.
320
+ tool_name (str): The name of the tool.
321
+ tool_description (str): The description of the tool.
322
+ base_params_model (Type[BaseModel]): The Pydantic model for the base parameters.
323
+ tool_args_schema (Type[BaseModel]): The Pydantic model for the tool arguments.
324
+ compact_docstring (bool): Whether to use a compact docstring format.
325
+ Returns:
326
+ VectaraTool: The created VectaraTool.
327
+ """
328
+ if tool_args_schema is None:
329
+ tool_args_schema = EmptyBaseModel
330
+
331
+ if not isinstance(tool_args_schema, type) or not issubclass(tool_args_schema, BaseModel):
332
+ raise TypeError("tool_args_schema must be a Pydantic BaseModel subclass")
333
+
334
+ fields = {}
335
+ base_params = []
336
+ for field_name, field_info in base_params_model.model_fields.items():
337
+ field_type = field_info.annotation
338
+ default_value = _unwrap_default(field_info.default)
339
+ param = inspect.Parameter(
340
+ field_name,
341
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
342
+ default=default_value,
343
+ annotation=field_type,
344
+ )
345
+ base_params.append(param)
346
+ fields[field_name] = (field_type, _schema_default(field_info.default))
347
+
348
+ # Add tool_args_schema fields to the fields dict if not already included.
349
+ # Also add them to the function signature by creating new inspect.Parameter objects.
350
+ for field_name, field_info in tool_args_schema.model_fields.items():
351
+ if field_name in fields:
352
+ continue
353
+
354
+ field_type = field_info.annotation
355
+ default_value = _unwrap_default(field_info.default)
356
+ param = inspect.Parameter(
357
+ field_name,
358
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
359
+ default=default_value,
360
+ annotation=field_type,
361
+ )
362
+ base_params.append(param)
363
+ fields[field_name] = (field_type, _schema_default(field_info.default))
364
+
365
+ # Create the dynamic schema with both base_params_model and tool_args_schema fields.
366
+ fn_schema = create_model(f"{tool_name}_schema", **fields)
367
+
368
+ # Combine parameters into a function signature.
369
+ all_params = base_params[:] # Now all_params contains parameters from both models.
370
+ required_params = [p for p in all_params if p.default is inspect.Parameter.empty]
371
+ optional_params = [
372
+ p for p in all_params if p.default is not inspect.Parameter.empty
373
+ ]
374
+ function.__signature__ = inspect.Signature(required_params + optional_params)
375
+ function.__annotations__["return"] = dict[str, Any]
376
+ function.__name__ = re.sub(r"[^A-Za-z0-9_]", "_", tool_name)
377
+
378
+ function.__doc__ = _make_docstring(
379
+ function,
380
+ tool_name, tool_description, fn_schema,
381
+ all_params, compact_docstring
382
+ )
383
+ tool = VectaraTool.from_defaults(
384
+ fn=function,
385
+ name=tool_name,
386
+ description=function.__doc__,
387
+ fn_schema=fn_schema,
388
+ tool_type=ToolType.QUERY,
389
+ return_direct=return_direct,
390
+ )
391
+ return tool
392
+
393
+
394
+ _PARSE_RANGE_REGEX = re.compile(
395
+ r"""
396
+ ^([\[\(])\s* # opening bracket
397
+ ([+-]?\d+(\.\d*)?)\s*, # first number
398
+ \s*([+-]?\d+(\.\d*)?) # second number
399
+ \s*([\]\)])$ # closing bracket
400
+ """,
401
+ re.VERBOSE,
402
+ )
403
+
404
+
405
+ def _parse_range(val_str: str) -> Tuple[float, float, bool, bool]:
406
+ """
407
+ Parses '[1,10)' or '(0.5, 5]' etc.
408
+ Returns (start, end, start_incl, end_incl) or raises ValueError.
409
+ """
410
+ m = _PARSE_RANGE_REGEX.match(val_str)
411
+ if not m:
412
+ raise ValueError(f"Invalid range syntax: {val_str!r}")
413
+ start_inc = m.group(1) == "["
414
+ end_inc = m.group(7) == "]"
415
+ start = float(m.group(2))
416
+ end = float(m.group(4))
417
+ if start > end:
418
+ raise ValueError(f"Range lower bound greater than upper bound: {val_str!r}")
419
+ return start, end, start_inc, end_inc
420
+
421
+
422
+ def _parse_comparison(val_str: str) -> Tuple[str, Union[float, str, bool]]:
423
+ """
424
+ Parses '>10', '<=3.14', '!=foo', \"='bar'\" etc.
425
+ Returns (operator, rhs) or raises ValueError.
426
+ """
427
+ # pick off the operator
428
+ comparison_operators = [">=", "<=", "!=", ">", "<", "="]
429
+ numeric_only_operators = {">", "<", ">=", "<="}
430
+ for op in comparison_operators:
431
+ if val_str.startswith(op):
432
+ rhs = val_str[len(op) :].strip()
433
+ if op in numeric_only_operators:
434
+ try:
435
+ rhs_val = float(rhs)
436
+ except ValueError as e:
437
+ raise ValueError(
438
+ f"Numeric comparison {op!r} must have a number, got {rhs!r}"
439
+ ) from e
440
+ return op, rhs_val
441
+ # = and != can be bool, numeric, or string
442
+ low = rhs.lower()
443
+ if low in ("true", "false"):
444
+ return op, (low == "true")
445
+ try:
446
+ return op, float(rhs)
447
+ except ValueError:
448
+ return op, rhs
449
+ raise ValueError(f"No valid comparison operator at start of {val_str!r}")
450
+
451
+
452
+ def build_filter_string(
453
+ kwargs: Dict[str, Any], tool_args_type: Dict[str, dict], fixed_filter: str
454
+ ) -> str:
455
+ """
456
+ Build filter string for Vectara from kwargs
457
+ """
458
+ filter_parts = []
459
+ for key, raw in kwargs.items():
460
+ if raw is None or raw == "":
461
+ continue
462
+
463
+ if raw is PydanticUndefined:
464
+ raise ValueError(
465
+ f"Value of argument {key!r} is undefined, and this is invalid. "
466
+ "Please form proper arguments and try again."
467
+ )
468
+
469
+ tool_args_dict = tool_args_type.get(key, {"type": "doc", "is_list": False})
470
+ prefix = tool_args_dict.get("type", "doc")
471
+ is_list = tool_args_dict.get("is_list", False)
472
+
473
+ if prefix not in ("doc", "part"):
474
+ raise ValueError(
475
+ f'Unrecognized prefix {prefix!r}. Please make sure to use either "doc" or "part" for the prefix.'
476
+ )
477
+
478
+ # 1) native numeric
479
+ if isinstance(raw, (int, float)) or is_float(str(raw)):
480
+ val = str(raw)
481
+ if is_list:
482
+ filter_parts.append(f"({val} IN {prefix}.{key})")
483
+ else:
484
+ filter_parts.append(f"{prefix}.{key}={val}")
485
+ continue
486
+
487
+ # 2) native boolean
488
+ if isinstance(raw, bool):
489
+ val = "true" if raw else "false"
490
+ if is_list:
491
+ filter_parts.append(f"({val} IN {prefix}.{key})")
492
+ else:
493
+ filter_parts.append(f"{prefix}.{key}={val}")
494
+ continue
495
+
496
+ if not isinstance(raw, str):
497
+ raise ValueError(f"Unsupported type for {key!r}: {type(raw).__name__}")
498
+
499
+ val_str = raw.strip()
500
+
501
+ # 3) Range operator
502
+ if (val_str.startswith("[") or val_str.startswith("(")) and (
503
+ val_str.endswith("]") or val_str.endswith(")")
504
+ ):
505
+ start, end, start_incl, end_incl = _parse_range(val_str)
506
+ conds = []
507
+ op1 = ">=" if start_incl else ">"
508
+ op2 = "<=" if end_incl else "<"
509
+ conds.append(f"{prefix}.{key} {op1} {start}")
510
+ conds.append(f"{prefix}.{key} {op2} {end}")
511
+ filter_parts.append("(" + " AND ".join(conds) + ")")
512
+ continue
513
+
514
+ # 4) comparison operator
515
+ try:
516
+ op, rhs = _parse_comparison(val_str)
517
+ except ValueError:
518
+ # no operator → treat as membership or equality-on-string
519
+ if is_list:
520
+ filter_parts.append(f"('{val_str}' IN {prefix}.{key})")
521
+ else:
522
+ filter_parts.append(f"{prefix}.{key}='{val_str}'")
523
+ else:
524
+ # normal comparison always binds to the field
525
+ if isinstance(rhs, bool):
526
+ rhs_sql = "true" if rhs else "false"
527
+ elif isinstance(rhs, (int, float)):
528
+ rhs_sql = str(rhs)
529
+ else:
530
+ rhs_sql = f"'{rhs}'"
531
+ filter_parts.append(f"{prefix}.{key}{op}{rhs_sql}")
532
+
533
+ joined = " AND ".join(filter_parts)
534
+ if fixed_filter and joined:
535
+ return f"({fixed_filter}) AND ({joined})"
536
+ return fixed_filter or joined