vectara-agentic 0.2.12__py3-none-any.whl → 0.2.14__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,174 @@
1
+ """
2
+ Utilities for the Vectara agentic.
3
+ """
4
+ from types import MethodType
5
+ from typing import Tuple, Callable, Optional
6
+ from functools import lru_cache
7
+ import tiktoken
8
+
9
+ from llama_index.core.llms import LLM
10
+ from llama_index.llms.openai import OpenAI
11
+ from llama_index.llms.anthropic import Anthropic
12
+
13
+ from .types import LLMRole, AgentType, ModelProvider
14
+ from .agent_config import AgentConfig
15
+ from .tool_utils import _updated_openai_prepare_chat_with_tools
16
+
17
+ provider_to_default_model_name = {
18
+ ModelProvider.OPENAI: "gpt-4o",
19
+ ModelProvider.ANTHROPIC: "claude-3-7-sonnet-latest",
20
+ ModelProvider.TOGETHER: "Qwen/Qwen2.5-72B-Instruct-Turbo",
21
+ ModelProvider.GROQ: "meta-llama/llama-4-scout-17b-16e-instruct",
22
+ ModelProvider.FIREWORKS: "accounts/fireworks/models/firefunction-v2",
23
+ ModelProvider.BEDROCK: "anthropic.claude-3-7-sonnet-20250219-v1:0",
24
+ ModelProvider.COHERE: "command-a-03-2025",
25
+ ModelProvider.GEMINI: "models/gemini-2.0-flash",
26
+ }
27
+
28
+ DEFAULT_MODEL_PROVIDER = ModelProvider.OPENAI
29
+
30
+
31
+ @lru_cache(maxsize=None)
32
+ def _get_llm_params_for_role(
33
+ role: LLMRole, config: Optional[AgentConfig] = None
34
+ ) -> Tuple[ModelProvider, str]:
35
+ """
36
+ Get the model provider and model name for the specified role.
37
+
38
+ If config is None, a new AgentConfig() is instantiated using environment defaults.
39
+ """
40
+ config = config or AgentConfig() # fallback to default config
41
+
42
+ if role == LLMRole.TOOL:
43
+ model_provider = ModelProvider(config.tool_llm_provider)
44
+ # If the user hasn’t explicitly set a tool_llm_model_name,
45
+ # fallback to provider default from provider_to_default_model_name
46
+ model_name = config.tool_llm_model_name or provider_to_default_model_name.get(
47
+ model_provider
48
+ )
49
+ else:
50
+ model_provider = ModelProvider(config.main_llm_provider)
51
+ model_name = config.main_llm_model_name or provider_to_default_model_name.get(
52
+ model_provider
53
+ )
54
+
55
+ # If the agent type is OpenAI, check that the main LLM provider is also OpenAI.
56
+ if role == LLMRole.MAIN and config.agent_type == AgentType.OPENAI:
57
+ if model_provider != ModelProvider.OPENAI:
58
+ raise ValueError(
59
+ "OpenAI agent requested but main model provider is not OpenAI."
60
+ )
61
+
62
+ return model_provider, model_name
63
+
64
+
65
+ @lru_cache(maxsize=None)
66
+ def get_tokenizer_for_model(
67
+ role: LLMRole, config: Optional[AgentConfig] = None
68
+ ) -> Optional[Callable]:
69
+ """
70
+ Get the tokenizer for the specified model, as determined by the role & config.
71
+ """
72
+ model_provider, model_name = _get_llm_params_for_role(role, config)
73
+ if model_provider == ModelProvider.OPENAI:
74
+ # This might raise an exception if the model_name is unknown to tiktoken
75
+ return tiktoken.encoding_for_model(model_name).encode
76
+ if model_provider == ModelProvider.ANTHROPIC:
77
+ return Anthropic().tokenizer
78
+ return None
79
+
80
+
81
+ @lru_cache(maxsize=None)
82
+ def get_llm(role: LLMRole, config: Optional[AgentConfig] = None) -> LLM:
83
+ """
84
+ Get the LLM for the specified role, using the provided config
85
+ or a default if none is provided.
86
+ """
87
+ max_tokens = 8192
88
+ model_provider, model_name = _get_llm_params_for_role(role, config)
89
+ if model_provider == ModelProvider.OPENAI:
90
+ llm = OpenAI(
91
+ model=model_name,
92
+ temperature=0,
93
+ is_function_calling_model=True,
94
+ strict=True,
95
+ max_tokens=max_tokens,
96
+ pydantic_program_mode="openai",
97
+ )
98
+ elif model_provider == ModelProvider.ANTHROPIC:
99
+ llm = Anthropic(
100
+ model=model_name,
101
+ temperature=0,
102
+ max_tokens=max_tokens,
103
+ )
104
+ elif model_provider == ModelProvider.GEMINI:
105
+ from llama_index.llms.google_genai import GoogleGenAI
106
+
107
+ llm = GoogleGenAI(
108
+ model=model_name,
109
+ temperature=0,
110
+ is_function_calling_model=True,
111
+ allow_parallel_tool_calls=True,
112
+ max_tokens=max_tokens,
113
+ )
114
+ elif model_provider == ModelProvider.TOGETHER:
115
+ from llama_index.llms.together import TogetherLLM
116
+
117
+ llm = TogetherLLM(
118
+ model=model_name,
119
+ temperature=0,
120
+ is_function_calling_model=True,
121
+ max_tokens=max_tokens,
122
+ )
123
+ # pylint: disable=protected-access
124
+ llm._prepare_chat_with_tools = MethodType(
125
+ _updated_openai_prepare_chat_with_tools,
126
+ llm,
127
+ )
128
+ elif model_provider == ModelProvider.GROQ:
129
+ from llama_index.llms.groq import Groq
130
+
131
+ llm = Groq(
132
+ model=model_name,
133
+ temperature=0,
134
+ is_function_calling_model=True,
135
+ max_tokens=max_tokens,
136
+ )
137
+ # pylint: disable=protected-access
138
+ llm._prepare_chat_with_tools = MethodType(
139
+ _updated_openai_prepare_chat_with_tools,
140
+ llm,
141
+ )
142
+ elif model_provider == ModelProvider.FIREWORKS:
143
+ from llama_index.llms.fireworks import Fireworks
144
+
145
+ llm = Fireworks(model=model_name, temperature=0, max_tokens=max_tokens)
146
+ elif model_provider == ModelProvider.BEDROCK:
147
+ from llama_index.llms.bedrock import Bedrock
148
+
149
+ llm = Bedrock(model=model_name, temperature=0, max_tokens=max_tokens)
150
+ elif model_provider == ModelProvider.COHERE:
151
+ from llama_index.llms.cohere import Cohere
152
+
153
+ llm = Cohere(model=model_name, temperature=0, max_tokens=max_tokens)
154
+ elif model_provider == ModelProvider.PRIVATE:
155
+ from llama_index.llms.openai_like import OpenAILike
156
+
157
+ llm = OpenAILike(
158
+ model=model_name,
159
+ temperature=0,
160
+ is_function_calling_model=True,
161
+ is_chat_model=True,
162
+ api_base=config.private_llm_api_base,
163
+ api_key=config.private_llm_api_key,
164
+ max_tokens=max_tokens,
165
+ )
166
+ # pylint: disable=protected-access
167
+ llm._prepare_chat_with_tools = MethodType(
168
+ _updated_openai_prepare_chat_with_tools,
169
+ llm,
170
+ )
171
+
172
+ else:
173
+ raise ValueError(f"Unknown LLM provider: {model_provider}")
174
+ return llm
@@ -0,0 +1,513 @@
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
+ def _create_tool_from_dynamic_function(
235
+ function: Callable[..., ToolOutput],
236
+ tool_name: str,
237
+ tool_description: str,
238
+ base_params_model: Type[BaseModel],
239
+ tool_args_schema: Type[BaseModel],
240
+ compact_docstring: bool = False,
241
+ ) -> VectaraTool:
242
+ base_params = []
243
+
244
+ if tool_args_schema is None:
245
+
246
+ class EmptyBaseModel(BaseModel):
247
+ """empty base model"""
248
+
249
+ tool_args_schema = EmptyBaseModel
250
+
251
+ fields = {}
252
+ for param_name, model_field in base_params_model.model_fields.items():
253
+ field_type = base_params_model.__annotations__.get(
254
+ param_name, str
255
+ )
256
+ default_value = (
257
+ model_field.default
258
+ if model_field.default is not None
259
+ else inspect.Parameter.empty
260
+ )
261
+ param = inspect.Parameter(
262
+ param_name,
263
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
264
+ default=default_value,
265
+ annotation=field_type,
266
+ )
267
+ base_params.append(param)
268
+ fields[param_name] = (
269
+ field_type,
270
+ model_field.default if model_field.default is not None else ...,
271
+ )
272
+
273
+ # Add tool_args_schema fields to the fields dict if not already included.
274
+ # Also add them to the function signature by creating new inspect.Parameter objects.
275
+ for field_name, field_info in tool_args_schema.model_fields.items():
276
+ if field_name in fields:
277
+ continue
278
+
279
+ default_value = field_info.default if field_info.default is not None else ...
280
+ field_type = tool_args_schema.__annotations__.get(field_name, None)
281
+ fields[field_name] = (field_type, default_value)
282
+ param = inspect.Parameter(
283
+ field_name,
284
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
285
+ default=(
286
+ default_value
287
+ if default_value is not ...
288
+ else inspect.Parameter.empty
289
+ ),
290
+ annotation=field_type,
291
+ )
292
+ base_params.append(param)
293
+
294
+ # Create the dynamic schema with both base_params_model and tool_args_schema fields.
295
+ fn_schema = create_model(f"{tool_name}_schema", **fields)
296
+
297
+ # Combine parameters into a function signature.
298
+ all_params = base_params[:] # Now all_params contains parameters from both models.
299
+ required_params = [p for p in all_params if p.default is inspect.Parameter.empty]
300
+ optional_params = [
301
+ p for p in all_params if p.default is not inspect.Parameter.empty
302
+ ]
303
+ function.__signature__ = inspect.Signature(required_params + optional_params)
304
+ function.__annotations__["return"] = dict[str, Any]
305
+ function.__name__ = re.sub(r"[^A-Za-z0-9_]", "_", tool_name)
306
+
307
+ # Build a docstring using parameter descriptions from the BaseModels.
308
+ params_str = ", ".join(
309
+ f"{p.name}: {p.annotation.__name__ if hasattr(p.annotation, '__name__') else p.annotation}"
310
+ for p in all_params
311
+ )
312
+ signature_line = f"{tool_name}({params_str}) -> dict[str, Any]"
313
+ if compact_docstring:
314
+ doc_lines = [
315
+ tool_description.strip(),
316
+ ]
317
+ else:
318
+ doc_lines = [
319
+ signature_line,
320
+ "",
321
+ tool_description.strip(),
322
+ ]
323
+ doc_lines += [
324
+ "",
325
+ "Args:",
326
+ ]
327
+ for param in all_params:
328
+ description = ""
329
+ if param.name in base_params_model.model_fields:
330
+ description = base_params_model.model_fields[param.name].description
331
+ elif param.name in tool_args_schema.model_fields:
332
+ description = tool_args_schema.model_fields[param.name].description
333
+ if not description:
334
+ description = ""
335
+ type_name = (
336
+ param.annotation.__name__
337
+ if hasattr(param.annotation, "__name__")
338
+ else str(param.annotation)
339
+ )
340
+ if (
341
+ param.default is not inspect.Parameter.empty
342
+ and param.default is not PydanticUndefined
343
+ ):
344
+ default_text = f", default={param.default!r}"
345
+ else:
346
+ default_text = ""
347
+ doc_lines.append(f" - {param.name} ({type_name}){default_text}: {description}")
348
+ doc_lines.append("")
349
+ doc_lines.append("Returns:")
350
+ return_desc = getattr(
351
+ function, "__return_description__", "A dictionary containing the result data."
352
+ )
353
+ doc_lines.append(f" dict[str, Any]: {return_desc}")
354
+
355
+ initial_docstring = "\n".join(doc_lines)
356
+ collapsed_spaces = re.sub(r' {2,}', ' ', initial_docstring)
357
+ final_docstring = re.sub(r'\n{2,}', '\n', collapsed_spaces).strip()
358
+ function.__doc__ = final_docstring
359
+
360
+ tool = VectaraTool.from_defaults(
361
+ fn=function,
362
+ name=tool_name,
363
+ description=function.__doc__,
364
+ fn_schema=fn_schema,
365
+ tool_type=ToolType.QUERY,
366
+ )
367
+ return tool
368
+
369
+
370
+ Range = Tuple[float, float, bool, bool] # (min, max, min_inclusive, max_inclusive)
371
+
372
+
373
+ def _parse_range(val_str: str) -> Range:
374
+ """
375
+ Parses '[1,10)' or '(0.5, 5]' etc.
376
+ Returns (start, end, start_incl, end_incl) or raises ValueError.
377
+ """
378
+ m = re.match(
379
+ r"""
380
+ ^([\[\(])\s* # opening bracket
381
+ ([+-]?\d+(\.\d*)?)\s*, # first number
382
+ \s*([+-]?\d+(\.\d*)?) # second number
383
+ \s*([\]\)])$ # closing bracket
384
+ """,
385
+ val_str,
386
+ re.VERBOSE,
387
+ )
388
+ if not m:
389
+ raise ValueError(f"Invalid range syntax: {val_str!r}")
390
+ start_inc = m.group(1) == "["
391
+ end_inc = m.group(7) == "]"
392
+ start = float(m.group(2))
393
+ end = float(m.group(4))
394
+ if start > end:
395
+ raise ValueError(f"Range lower bound greater than upper bound: {val_str!r}")
396
+ return start, end, start_inc, end_inc
397
+
398
+
399
+ def _parse_comparison(val_str: str) -> Tuple[str, Union[float, str, bool]]:
400
+ """
401
+ Parses '>10', '<=3.14', '!=foo', \"='bar'\" etc.
402
+ Returns (operator, rhs) or raises ValueError.
403
+ """
404
+ # pick off the operator
405
+ comparison_operators = [">=", "<=", "!=", ">", "<", "="]
406
+ numeric_only_operators = {">", "<", ">=", "<="}
407
+ for op in comparison_operators:
408
+ if val_str.startswith(op):
409
+ rhs = val_str[len(op) :].strip()
410
+ if op in numeric_only_operators:
411
+ try:
412
+ rhs_val = float(rhs)
413
+ except ValueError as e:
414
+ raise ValueError(
415
+ f"Numeric comparison {op!r} must have a number, got {rhs!r}"
416
+ ) from e
417
+ return op, rhs_val
418
+ # = and != can be bool, numeric, or string
419
+ low = rhs.lower()
420
+ if low in ("true", "false"):
421
+ return op, (low == "true")
422
+ try:
423
+ return op, float(rhs)
424
+ except ValueError:
425
+ return op, rhs
426
+ raise ValueError(f"No valid comparison operator at start of {val_str!r}")
427
+
428
+
429
+ def _build_filter_string(
430
+ kwargs: Dict[str, Any], tool_args_type: Dict[str, dict], fixed_filter: str
431
+ ) -> str:
432
+ """
433
+ Build filter string for Vectara from kwargs
434
+ """
435
+ filter_parts = []
436
+ for key, raw in kwargs.items():
437
+ if raw is None or raw == "":
438
+ continue
439
+
440
+ if raw is PydanticUndefined:
441
+ raise ValueError(
442
+ f"Value of argument {key!r} is undefined, and this is invalid. "
443
+ "Please form proper arguments and try again."
444
+ )
445
+
446
+ tool_args_dict = tool_args_type.get(key, {"type": "doc", "is_list": False})
447
+ prefix = tool_args_dict.get("type", "doc")
448
+ is_list = tool_args_dict.get("is_list", False)
449
+
450
+ if prefix not in ("doc", "part"):
451
+ raise ValueError(
452
+ f'Unrecognized prefix {prefix!r}. Please make sure to use either "doc" or "part" for the prefix.'
453
+ )
454
+
455
+ # 1) native numeric
456
+ if isinstance(raw, (int, float)) or is_float(str(raw)):
457
+ val = str(raw)
458
+ if is_list:
459
+ filter_parts.append(f"({val} IN {prefix}.{key})")
460
+ else:
461
+ filter_parts.append(f"{prefix}.{key}={val}")
462
+ continue
463
+
464
+ # 2) native boolean
465
+ if isinstance(raw, bool):
466
+ val = "true" if raw else "false"
467
+ if is_list:
468
+ filter_parts.append(f"({val} IN {prefix}.{key})")
469
+ else:
470
+ filter_parts.append(f"{prefix}.{key}={val}")
471
+ continue
472
+
473
+ if not isinstance(raw, str):
474
+ raise ValueError(f"Unsupported type for {key!r}: {type(raw).__name__}")
475
+
476
+ val_str = raw.strip()
477
+
478
+ # 3) Range operator
479
+ if (val_str.startswith("[") or val_str.startswith("(")) and (
480
+ val_str.endswith("]") or val_str.endswith(")")
481
+ ):
482
+ start, end, start_incl, end_incl = _parse_range(val_str)
483
+ conds = []
484
+ op1 = ">=" if start_incl else ">"
485
+ op2 = "<=" if end_incl else "<"
486
+ conds.append(f"{prefix}.{key} {op1} {start}")
487
+ conds.append(f"{prefix}.{key} {op2} {end}")
488
+ filter_parts.append("(" + " AND ".join(conds) + ")")
489
+ continue
490
+
491
+ # 4) comparison operator
492
+ try:
493
+ op, rhs = _parse_comparison(val_str)
494
+ except ValueError:
495
+ # no operator → treat as membership or equality-on-string
496
+ if is_list:
497
+ filter_parts.append(f"('{val_str}' IN {prefix}.{key})")
498
+ else:
499
+ filter_parts.append(f"{prefix}.{key}='{val_str}'")
500
+ else:
501
+ # normal comparison always binds to the field
502
+ if isinstance(rhs, bool):
503
+ rhs_sql = "true" if rhs else "false"
504
+ elif isinstance(rhs, (int, float)):
505
+ rhs_sql = str(rhs)
506
+ else:
507
+ rhs_sql = f"'{rhs}'"
508
+ filter_parts.append(f"{prefix}.{key}{op}{rhs_sql}")
509
+
510
+ joined = " AND ".join(filter_parts)
511
+ if fixed_filter and joined:
512
+ return f"({fixed_filter}) AND ({joined})"
513
+ return fixed_filter or joined