1414- Special types ([`TableInput`][], [`AnyArrow`][], varargs) use field metadata markers
1515"""
1616
17+ import json
1718import warnings
1819from collections .abc import Sequence
1920from dataclasses import dataclass
2021from typing import Annotated , Any , get_args , get_origin , get_type_hints
2122
2223import pyarrow as pa
2324
24- from vgi .arguments import PYTHON_TO_ARROW , AnyArrow , AnyArrowValue , Arg , TableInput
25+ from vgi .arguments import _MISSING , PYTHON_TO_ARROW , AnyArrow , AnyArrowValue , Arg , TableInput
2526
2627__all__ = [
2728 "ArgumentSpec" ,
4142 "VGI_CONST_KEY" ,
4243 "VGI_CONST_TRUE" ,
4344 "VGI_DOC_KEY" ,
45+ "VGI_DEFAULT_KEY" ,
46+ "VGI_CHOICES_KEY" ,
47+ "VGI_RANGE_KEY" ,
48+ "VGI_PATTERN_KEY" ,
4449]
4550
4651# =============================================================================
6974# ``vgi_doc_*`` prefix is reserved for future per-argument doc variants.
7075VGI_DOC_KEY = b"vgi_doc"
7176
77+ # Keys carrying per-argument constraint metadata for agent discovery. All are
78+ # presence-only (omitted when the constraint is absent) and value-encoded as
79+ # UTF-8 so the column type stays uniform regardless of the arg's value type:
80+ # vgi_default — JSON scalar (the arg's default value; named/optional args only)
81+ # vgi_choices — JSON array (the closed set of allowed values)
82+ # vgi_range — interval notation built from ge/le/gt/lt (e.g. "[0, 100]",
83+ # "(0, +inf)", "[1, 10)"); a discovery surface, not the raw bounds
84+ # vgi_pattern — raw regex the value must match (open set)
85+ # Kept byte-for-byte in sync with the C++ reader in the vgi extension
86+ # (src/vgi_function_arguments_function.cpp / src/include/vgi_protocol_constants.hpp).
87+ VGI_DEFAULT_KEY = b"vgi_default"
88+ VGI_CHOICES_KEY = b"vgi_choices"
89+ VGI_RANGE_KEY = b"vgi_range"
90+ VGI_PATTERN_KEY = b"vgi_pattern"
91+
7292
7393def _argument_spec_sort_key (spec : "ArgumentSpec" ) -> tuple [int , int | str ]:
7494 """Sort key: positional first (by index), then named (alphabetically)."""
@@ -109,6 +129,15 @@ class ArgumentSpec:
109129 doc: Optional human/agent-facing description of the argument. Surfaced
110130 through the catalog as the ``vgi_doc`` Arrow field metadata key
111131 (UTF-8); empty string means undocumented.
132+ default_json: Optional JSON-encoded default value (``vgi_default``);
133+ None when the argument is required.
134+ choices_json: Optional JSON array of allowed values (``vgi_choices``);
135+ None when the argument is not restricted to a closed set.
136+ range_notation: Optional interval-notation string built from the arg's
137+ numeric bounds (``vgi_range``, e.g. ``"[0, 100]"``); None when
138+ unbounded.
139+ pattern: Optional regex the value must match (``vgi_pattern``); None
140+ when there is no pattern constraint.
112141
113142 Note:
114143 For named arguments, the Python attribute name (``name``) and the SQL
@@ -131,6 +160,10 @@ class ArgumentSpec:
131160 is_varargs : bool = False
132161 is_const : bool = False
133162 doc : str = ""
163+ default_json : str | None = None
164+ choices_json : str | None = None
165+ range_notation : str | None = None
166+ pattern : str | None = None
134167
135168 def __repr__ (self ) -> str :
136169 """Return concise repr showing key attributes."""
@@ -213,6 +246,16 @@ def argument_specs_to_schema(specs: Sequence[ArgumentSpec]) -> pa.Schema:
213246 if spec .doc :
214247 metadata [VGI_DOC_KEY ] = spec .doc .encode ("utf-8" )
215248
249+ # Per-argument constraint metadata (presence-only; already value-encoded)
250+ if spec .default_json is not None :
251+ metadata [VGI_DEFAULT_KEY ] = spec .default_json .encode ("utf-8" )
252+ if spec .choices_json is not None :
253+ metadata [VGI_CHOICES_KEY ] = spec .choices_json .encode ("utf-8" )
254+ if spec .range_notation is not None :
255+ metadata [VGI_RANGE_KEY ] = spec .range_notation .encode ("utf-8" )
256+ if spec .pattern is not None :
257+ metadata [VGI_PATTERN_KEY ] = spec .pattern .encode ("utf-8" )
258+
216259 # Create field with or without metadata
217260 field = pa .field (
218261 spec .name ,
@@ -266,6 +309,12 @@ def schema_to_argument_specs(schema: pa.Schema) -> list[ArgumentSpec]:
266309 doc_bytes = metadata .get (VGI_DOC_KEY )
267310 doc = doc_bytes .decode ("utf-8" ) if doc_bytes else ""
268311
312+ # Per-argument constraint metadata (absent = unconstrained)
313+ default_bytes = metadata .get (VGI_DEFAULT_KEY )
314+ choices_bytes = metadata .get (VGI_CHOICES_KEY )
315+ range_bytes = metadata .get (VGI_RANGE_KEY )
316+ pattern_bytes = metadata .get (VGI_PATTERN_KEY )
317+
269318 specs .append (
270319 ArgumentSpec (
271320 name = field .name ,
@@ -276,6 +325,10 @@ def schema_to_argument_specs(schema: pa.Schema) -> list[ArgumentSpec]:
276325 is_varargs = is_varargs ,
277326 is_const = is_const ,
278327 doc = doc ,
328+ default_json = default_bytes .decode ("utf-8" ) if default_bytes else None ,
329+ choices_json = choices_bytes .decode ("utf-8" ) if choices_bytes else None ,
330+ range_notation = range_bytes .decode ("utf-8" ) if range_bytes else None ,
331+ pattern = pattern_bytes .decode ("utf-8" ) if pattern_bytes else None ,
279332 )
280333 )
281334
@@ -367,6 +420,94 @@ def macro_parameter_docs_from_schema(schema: pa.Schema) -> dict[str, str]:
367420# =============================================================================
368421
369422
423+ def _format_range (
424+ ge : float | int | None ,
425+ le : float | int | None ,
426+ gt : float | int | None ,
427+ lt : float | int | None ,
428+ ) -> str | None :
429+ """Build interval notation from an argument's numeric bounds.
430+
431+ Inclusive bounds (``ge``/``le``) render as square brackets, exclusive bounds
432+ (``gt``/``lt``) as parentheses, and an open side as ``-inf``/``+inf``. Returns
433+ None when the argument has no numeric bound at all.
434+
435+ Args:
436+ ge: Inclusive lower bound (value >= ge).
437+ le: Inclusive upper bound (value <= le).
438+ gt: Exclusive lower bound (value > gt).
439+ lt: Exclusive upper bound (value < lt).
440+
441+ Returns:
442+ Interval string such as ``"[0, 100]"``, ``"(0, +inf)"``, ``"[1, 10)"``,
443+ or None when unbounded.
444+
445+ """
446+ if ge is None and le is None and gt is None and lt is None :
447+ return None
448+ if gt is not None :
449+ low = f"({ gt } "
450+ elif ge is not None :
451+ low = f"[{ ge } "
452+ else :
453+ low = "(-inf"
454+ if lt is not None :
455+ high = f"{ lt } )"
456+ elif le is not None :
457+ high = f"{ le } ]"
458+ else :
459+ high = "+inf)"
460+ return f"{ low } , { high } "
461+
462+
463+ def _constraint_kwargs (arg : Arg [Any ]) -> dict [str , Any ]:
464+ """Extract discovery-facing constraint metadata from an [`Arg`][] descriptor.
465+
466+ Returns keyword arguments for [`ArgumentSpec`][] carrying only the constraints
467+ that are present, value-encoded for uniform surfacing: ``default``/``choices``
468+ as JSON, numeric bounds collapsed into a single ``range_notation`` interval
469+ string, ``pattern`` as-is. A defensively unserializable default falls back to
470+ its ``repr`` rather than dropping the whole registration.
471+
472+ Args:
473+ arg: The `Arg` descriptor to read constraints from.
474+
475+ Returns:
476+ A dict of `ArgumentSpec` keyword arguments (possibly empty).
477+
478+ """
479+ kwargs : dict [str , Any ] = {}
480+
481+ default = getattr (arg , "default" , _MISSING )
482+ if default is not _MISSING :
483+ try :
484+ kwargs ["default_json" ] = json .dumps (default )
485+ except (TypeError , ValueError ):
486+ kwargs ["default_json" ] = json .dumps (repr (default ))
487+
488+ choices = getattr (arg , "choices" , None )
489+ if choices is not None :
490+ try :
491+ kwargs ["choices_json" ] = json .dumps (list (choices ))
492+ except (TypeError , ValueError ):
493+ kwargs ["choices_json" ] = json .dumps ([repr (c ) for c in choices ])
494+
495+ range_notation = _format_range (
496+ getattr (arg , "ge" , None ),
497+ getattr (arg , "le" , None ),
498+ getattr (arg , "gt" , None ),
499+ getattr (arg , "lt" , None ),
500+ )
501+ if range_notation is not None :
502+ kwargs ["range_notation" ] = range_notation
503+
504+ pattern = getattr (arg , "pattern" , None )
505+ if pattern is not None :
506+ kwargs ["pattern" ] = pattern
507+
508+ return kwargs
509+
510+
370511def extract_argument_specs (
371512 cls : type ,
372513) -> list [ArgumentSpec ]:
@@ -414,6 +555,7 @@ def extract_argument_specs(
414555 is_varargs = param_arg .varargs ,
415556 is_const = False ,
416557 doc = param_arg .doc or "" ,
558+ ** _constraint_kwargs (param_arg ),
417559 )
418560 )
419561
@@ -431,6 +573,7 @@ def extract_argument_specs(
431573 is_varargs = const_arg .varargs ,
432574 is_const = True ,
433575 doc = const_arg .doc or "" ,
576+ ** _constraint_kwargs (const_arg ),
434577 )
435578 )
436579
@@ -496,6 +639,7 @@ def extract_argument_specs(
496639 is_varargs = arg_instance .varargs ,
497640 is_const = getattr (arg_instance , "const" , False ),
498641 doc = getattr (arg_instance , "doc" , "" ) or "" ,
642+ ** _constraint_kwargs (arg_instance ),
499643 )
500644 )
501645
@@ -571,6 +715,7 @@ def extract_argument_specs(
571715 is_varargs = is_varargs ,
572716 is_const = is_const ,
573717 doc = getattr (arg_legacy , "doc" , "" ) or "" ,
718+ ** _constraint_kwargs (arg_legacy ),
574719 )
575720 )
576721
0 commit comments