optimizely-opal.opal-tools-sdk 0.1.18.dev0__tar.gz → 0.1.20.dev0__tar.gz

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.
Files changed (22) hide show
  1. {optimizely_opal_opal_tools_sdk-0.1.18.dev0 → optimizely_opal_opal_tools_sdk-0.1.20.dev0}/PKG-INFO +1 -1
  2. {optimizely_opal_opal_tools_sdk-0.1.18.dev0 → optimizely_opal_opal_tools_sdk-0.1.20.dev0}/opal_tools_sdk/decorators.py +110 -2
  3. {optimizely_opal_opal_tools_sdk-0.1.18.dev0 → optimizely_opal_opal_tools_sdk-0.1.20.dev0}/opal_tools_sdk/models.py +6 -2
  4. {optimizely_opal_opal_tools_sdk-0.1.18.dev0 → optimizely_opal_opal_tools_sdk-0.1.20.dev0}/opal_tools_sdk/proteus.py +12 -39
  5. {optimizely_opal_opal_tools_sdk-0.1.18.dev0 → optimizely_opal_opal_tools_sdk-0.1.20.dev0}/optimizely_opal.opal_tools_sdk.egg-info/PKG-INFO +1 -1
  6. {optimizely_opal_opal_tools_sdk-0.1.18.dev0 → optimizely_opal_opal_tools_sdk-0.1.20.dev0}/optimizely_opal.opal_tools_sdk.egg-info/SOURCES.txt +1 -0
  7. {optimizely_opal_opal_tools_sdk-0.1.18.dev0 → optimizely_opal_opal_tools_sdk-0.1.20.dev0}/pyproject.toml +1 -1
  8. {optimizely_opal_opal_tools_sdk-0.1.18.dev0 → optimizely_opal_opal_tools_sdk-0.1.20.dev0}/setup.py +1 -1
  9. optimizely_opal_opal_tools_sdk-0.1.20.dev0/tests/test_nested_schema.py +235 -0
  10. {optimizely_opal_opal_tools_sdk-0.1.18.dev0 → optimizely_opal_opal_tools_sdk-0.1.20.dev0}/README.md +0 -0
  11. {optimizely_opal_opal_tools_sdk-0.1.18.dev0 → optimizely_opal_opal_tools_sdk-0.1.20.dev0}/opal_tools_sdk/__init__.py +0 -0
  12. {optimizely_opal_opal_tools_sdk-0.1.18.dev0 → optimizely_opal_opal_tools_sdk-0.1.20.dev0}/opal_tools_sdk/_registry.py +0 -0
  13. {optimizely_opal_opal_tools_sdk-0.1.18.dev0 → optimizely_opal_opal_tools_sdk-0.1.20.dev0}/opal_tools_sdk/auth.py +0 -0
  14. {optimizely_opal_opal_tools_sdk-0.1.18.dev0 → optimizely_opal_opal_tools_sdk-0.1.20.dev0}/opal_tools_sdk/logging.py +0 -0
  15. {optimizely_opal_opal_tools_sdk-0.1.18.dev0 → optimizely_opal_opal_tools_sdk-0.1.20.dev0}/opal_tools_sdk/service.py +0 -0
  16. {optimizely_opal_opal_tools_sdk-0.1.18.dev0 → optimizely_opal_opal_tools_sdk-0.1.20.dev0}/opal_tools_sdk/ui.py +0 -0
  17. {optimizely_opal_opal_tools_sdk-0.1.18.dev0 → optimizely_opal_opal_tools_sdk-0.1.20.dev0}/optimizely_opal.opal_tools_sdk.egg-info/dependency_links.txt +0 -0
  18. {optimizely_opal_opal_tools_sdk-0.1.18.dev0 → optimizely_opal_opal_tools_sdk-0.1.20.dev0}/optimizely_opal.opal_tools_sdk.egg-info/requires.txt +0 -0
  19. {optimizely_opal_opal_tools_sdk-0.1.18.dev0 → optimizely_opal_opal_tools_sdk-0.1.20.dev0}/optimizely_opal.opal_tools_sdk.egg-info/top_level.txt +0 -0
  20. {optimizely_opal_opal_tools_sdk-0.1.18.dev0 → optimizely_opal_opal_tools_sdk-0.1.20.dev0}/setup.cfg +0 -0
  21. {optimizely_opal_opal_tools_sdk-0.1.18.dev0 → optimizely_opal_opal_tools_sdk-0.1.20.dev0}/tests/test_integration.py +0 -0
  22. {optimizely_opal_opal_tools_sdk-0.1.18.dev0 → optimizely_opal_opal_tools_sdk-0.1.20.dev0}/tests/test_proteus.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optimizely-opal.opal-tools-sdk
3
- Version: 0.1.18.dev0
3
+ Version: 0.1.20.dev0
4
4
  Summary: SDK for creating Opal-compatible tools services
5
5
  Home-page: https://github.com/optimizely/opal-tools-sdk
6
6
  Author: Optimizely
@@ -1,7 +1,7 @@
1
1
  import inspect
2
2
  import re
3
3
  import logging
4
- from typing import Callable, Any, List, Dict, Type, get_origin, get_type_hints, Optional, Union
4
+ from typing import Callable, Any, List, Dict, Type, get_args, get_origin, get_type_hints, Optional, Union
5
5
  from fastapi import APIRouter, Depends, Header, HTTPException
6
6
  from pydantic import BaseModel
7
7
 
@@ -10,6 +10,81 @@ from .logging import get_logger
10
10
 
11
11
  logger = get_logger(__name__)
12
12
 
13
+
14
+ def _resolve_json_schema(schema: dict, defs: dict, _seen: Optional[set] = None) -> dict:
15
+ """Resolve $ref references and simplify Pydantic v2 JSON schema output.
16
+
17
+ - Resolves ``$ref`` pointers by looking up ``$defs``
18
+ - Simplifies ``anyOf: [{type: X}, {type: "null"}]`` to ``{type: X}`` (Optional encoding)
19
+ - Recursively resolves nested ``properties`` and ``items``
20
+ - Strips Pydantic metadata (``title``, ``$defs`` at root)
21
+ - Guards against circular ``$ref`` via a ``_seen`` set
22
+ """
23
+ if _seen is None:
24
+ _seen = set()
25
+
26
+ # Resolve $ref
27
+ if "$ref" in schema:
28
+ ref_path = schema["$ref"] # e.g. "#/$defs/OutputFormat"
29
+ ref_name = ref_path.rsplit("/", 1)[-1]
30
+ if ref_name in _seen:
31
+ return {"type": "object"} # break circular ref
32
+ if ref_name in defs:
33
+ resolved = _resolve_json_schema(defs[ref_name], defs, _seen | {ref_name})
34
+ # Preserve sibling description from the outer schema
35
+ if "description" in schema and "description" not in resolved:
36
+ resolved["description"] = schema["description"]
37
+ return resolved
38
+ return {"type": "string"}
39
+
40
+ # Simplify anyOf (Pydantic Optional encoding)
41
+ if "anyOf" in schema:
42
+ non_null = [
43
+ s for s in schema["anyOf"]
44
+ if (s.get("type") != "null" and "$ref" not in s) or "$ref" in s
45
+ ]
46
+ if len(non_null) == 1:
47
+ resolved = _resolve_json_schema(non_null[0], defs, _seen)
48
+ # Preserve description from the outer schema
49
+ if "description" in schema and "description" not in resolved:
50
+ resolved["description"] = schema["description"]
51
+ return resolved
52
+ # If multiple non-null types, fall back to string
53
+ result: dict = {"type": "string"}
54
+ if "description" in schema:
55
+ result["description"] = schema["description"]
56
+ return result
57
+
58
+ result = {}
59
+
60
+ # Copy type
61
+ if "type" in schema:
62
+ result["type"] = schema["type"]
63
+
64
+ # Copy description
65
+ if "description" in schema:
66
+ result["description"] = schema["description"]
67
+
68
+ # Copy enum
69
+ if "enum" in schema:
70
+ result["enum"] = schema["enum"]
71
+
72
+ # Recursively resolve properties
73
+ if "properties" in schema:
74
+ result["properties"] = {}
75
+ for prop_name, prop_schema in schema["properties"].items():
76
+ result["properties"][prop_name] = _resolve_json_schema(prop_schema, defs, _seen)
77
+
78
+ # Copy required
79
+ if "required" in schema:
80
+ result["required"] = schema["required"]
81
+
82
+ # Recursively resolve items
83
+ if "items" in schema:
84
+ result["items"] = _resolve_json_schema(schema["items"], defs, _seen)
85
+
86
+ return result
87
+
13
88
  def tool(
14
89
  name: str,
15
90
  description: str,
@@ -87,6 +162,8 @@ def tool(
87
162
 
88
163
  # Map Python type to Parameter type
89
164
  param_type = ParameterType.string
165
+ full_schema = None
166
+
90
167
  if field_type is int:
91
168
  param_type = ParameterType.integer
92
169
  elif field_type is float:
@@ -95,8 +172,33 @@ def tool(
95
172
  param_type = ParameterType.boolean
96
173
  elif field_type is list or get_origin(field_type) is list:
97
174
  param_type = ParameterType.list
175
+ # Extract item type from List[T] and build complete schema
176
+ type_args_inner = get_args(field_type)
177
+ items_schema = None
178
+ if type_args_inner:
179
+ item_type = type_args_inner[0]
180
+ if hasattr(item_type, 'model_json_schema'):
181
+ raw = item_type.model_json_schema()
182
+ raw_defs = raw.pop('$defs', {})
183
+ items_schema = _resolve_json_schema(raw, raw_defs)
184
+ elif item_type is str:
185
+ items_schema = {"type": "string"}
186
+ elif item_type is int:
187
+ items_schema = {"type": "integer"}
188
+ elif item_type is float:
189
+ items_schema = {"type": "number"}
190
+ elif item_type is bool:
191
+ items_schema = {"type": "boolean"}
192
+ if items_schema is not None:
193
+ full_schema = {"type": "array", "items": items_schema}
98
194
  elif field_type is dict or get_origin(field_type) is dict:
99
195
  param_type = ParameterType.dictionary
196
+ elif hasattr(field_type, 'model_json_schema'):
197
+ param_type = ParameterType.dictionary
198
+ raw = field_type.model_json_schema()
199
+ raw_defs = raw.pop('$defs', {})
200
+ resolved = _resolve_json_schema(raw, raw_defs)
201
+ full_schema = resolved
100
202
 
101
203
  # Determine if required
102
204
  field_info_extra = getattr(field_info, "json_schema_extra") or {}
@@ -125,12 +227,18 @@ def tool(
125
227
  elif hasattr(field, 'description'):
126
228
  description_text = field.description
127
229
 
230
+ # Build the complete schema with description included
231
+ if full_schema is not None:
232
+ if "description" not in full_schema:
233
+ full_schema["description"] = description_text
234
+
128
235
  parameters.append(Parameter(
129
236
  name=field_name,
130
237
  param_type=param_type,
131
238
  description=description_text,
132
239
  required=required,
133
- in_context=in_context
240
+ in_context=in_context,
241
+ schema=full_schema,
134
242
  ))
135
243
 
136
244
  logger.info(f"Registered parameter: {field_name} of type {param_type.value}, required: {required}")
@@ -1,6 +1,6 @@
1
1
  from enum import Enum
2
2
  from typing import List, Dict, Any, Optional, Literal, TypedDict
3
- from dataclasses import dataclass
3
+ from dataclasses import dataclass, field
4
4
 
5
5
  from pydantic import BaseModel, Field
6
6
 
@@ -25,16 +25,20 @@ class Parameter:
25
25
  description: str
26
26
  required: bool
27
27
  in_context: bool = False
28
+ schema: Optional[Dict[str, Any]] = field(default=None)
28
29
 
29
30
  def to_dict(self) -> Dict[str, Any]:
30
31
  """Convert to dictionary for the discovery endpoint."""
31
- return {
32
+ result = {
32
33
  "name": self.name,
33
34
  "type": self.param_type.value,
34
35
  "description": self.description,
35
36
  "required": self.required,
36
37
  "in_context": self.in_context,
37
38
  }
39
+ if self.schema is not None:
40
+ result["schema"] = self.schema
41
+ return result
38
42
 
39
43
 
40
44
  @dataclass
@@ -1,6 +1,6 @@
1
1
  # generated by datamodel-codegen:
2
2
  # filename: proteus-document-spec.json
3
- # timestamp: 2026-03-12T17:59:00+00:00
3
+ # timestamp: 2026-03-17T21:59:22+00:00
4
4
 
5
5
  from __future__ import annotations
6
6
 
@@ -1971,6 +1971,14 @@ class ProteusImage(BaseModel):
1971
1971
  src: ProteusValue | str | None = Field(default=None, description='The image source URL')
1972
1972
 
1973
1973
 
1974
+ class ProteusQuestion(BaseModel):
1975
+ model_config = ConfigDict(
1976
+ extra='forbid',
1977
+ )
1978
+ field_type: Literal['Question'] = Field(default='Question', alias='$type')
1979
+ questions: ProteusValue = Field(..., description='Array of questions data')
1980
+
1981
+
1974
1982
  class ProteusRange(BaseModel):
1975
1983
  model_config = ConfigDict(
1976
1984
  extra='forbid',
@@ -2908,36 +2916,6 @@ class ProteusCardLink(BaseModel):
2908
2916
  z: SprinklePropZ | None = None
2909
2917
 
2910
2918
 
2911
- class ProteusChoice(BaseModel):
2912
- model_config = ConfigDict(
2913
- extra='forbid',
2914
- )
2915
- field_type: Literal['Choice'] = Field(default='Choice', alias='$type')
2916
- addonBefore: ProteusNode | None = Field(
2917
- default=None, description='Content to display before the choice text (e.g., numbered badge)'
2918
- )
2919
- children: ProteusNode | None = Field(default=None, description='Title/label of the choice')
2920
- description: ProteusNode | None = Field(
2921
- default=None, description='Secondary description text shown below the title'
2922
- )
2923
- onClick: ProteusEventHandler | None = Field(
2924
- default=None, description='Action triggered when choice is selected'
2925
- )
2926
- required: bool | None = Field(
2927
- default=None, description='Whether selecting this choice is required to proceed'
2928
- )
2929
- value: str | None = Field(default=None, description='Value associated with this choice')
2930
-
2931
-
2932
- class ProteusChoiceGroup(BaseModel):
2933
- model_config = ConfigDict(
2934
- extra='forbid',
2935
- )
2936
- field_type: Literal['ChoiceGroup'] = Field(default='ChoiceGroup', alias='$type')
2937
- children: ProteusNode | None = Field(default=None, description='Choice elements to render')
2938
- name: str | None = Field(default=None, description='Data field name for the selected value')
2939
-
2940
-
2941
2919
  class ProteusField(BaseModel):
2942
2920
  model_config = ConfigDict(
2943
2921
  extra='forbid',
@@ -3563,8 +3541,6 @@ class ProteusElement(
3563
3541
  | ProteusCardHeader
3564
3542
  | ProteusCardLink
3565
3543
  | ProteusChart
3566
- | ProteusChoice
3567
- | ProteusChoiceGroup
3568
3544
  | ProteusDataTable
3569
3545
  | ProteusField
3570
3546
  | ProteusGroup
@@ -3574,6 +3550,7 @@ class ProteusElement(
3574
3550
  | ProteusInput
3575
3551
  | ProteusLink
3576
3552
  | ProteusMap
3553
+ | ProteusQuestion
3577
3554
  | ProteusRange
3578
3555
  | ProteusSelect
3579
3556
  | ProteusSelectContent
@@ -3595,8 +3572,6 @@ class ProteusElement(
3595
3572
  | ProteusCardHeader
3596
3573
  | ProteusCardLink
3597
3574
  | ProteusChart
3598
- | ProteusChoice
3599
- | ProteusChoiceGroup
3600
3575
  | ProteusDataTable
3601
3576
  | ProteusField
3602
3577
  | ProteusGroup
@@ -3606,6 +3581,7 @@ class ProteusElement(
3606
3581
  | ProteusInput
3607
3582
  | ProteusLink
3608
3583
  | ProteusMap
3584
+ | ProteusQuestion
3609
3585
  | ProteusRange
3610
3586
  | ProteusSelect
3611
3587
  | ProteusSelectContent
@@ -3655,8 +3631,6 @@ ProteusCancelAction.model_rebuild()
3655
3631
  ProteusCard.model_rebuild()
3656
3632
  ProteusCardHeader.model_rebuild()
3657
3633
  ProteusCardLink.model_rebuild()
3658
- ProteusChoice.model_rebuild()
3659
- ProteusChoiceGroup.model_rebuild()
3660
3634
  ProteusField.model_rebuild()
3661
3635
  ProteusGroup.model_rebuild()
3662
3636
  ProteusHeading.model_rebuild()
@@ -3689,8 +3663,6 @@ class UI:
3689
3663
  CardHeader = ProteusCardHeader
3690
3664
  CardLink = ProteusCardLink
3691
3665
  Chart = ProteusChart
3692
- Choice = ProteusChoice
3693
- ChoiceGroup = ProteusChoiceGroup
3694
3666
  DataTable = ProteusDataTable
3695
3667
  Document = ProteusDocument
3696
3668
  Field = ProteusField
@@ -3701,6 +3673,7 @@ class UI:
3701
3673
  Input = ProteusInput
3702
3674
  Link = ProteusLink
3703
3675
  Map = ProteusMap
3676
+ Question = ProteusQuestion
3704
3677
  Range = ProteusRange
3705
3678
  Select = ProteusSelect
3706
3679
  SelectContent = ProteusSelectContent
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optimizely-opal.opal-tools-sdk
3
- Version: 0.1.18.dev0
3
+ Version: 0.1.20.dev0
4
4
  Summary: SDK for creating Opal-compatible tools services
5
5
  Home-page: https://github.com/optimizely/opal-tools-sdk
6
6
  Author: Optimizely
@@ -16,4 +16,5 @@ optimizely_opal.opal_tools_sdk.egg-info/dependency_links.txt
16
16
  optimizely_opal.opal_tools_sdk.egg-info/requires.txt
17
17
  optimizely_opal.opal_tools_sdk.egg-info/top_level.txt
18
18
  tests/test_integration.py
19
+ tests/test_nested_schema.py
19
20
  tests/test_proteus.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "optimizely-opal.opal-tools-sdk"
7
- version = "0.1.18-dev"
7
+ version = "0.1.20-dev"
8
8
  description = "SDK for creating Opal-compatible tools services"
9
9
  authors = [{ name = "Optimizely", email = "opal-team@optimizely.com" }]
10
10
  readme = "README.md"
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="optimizely-opal.opal-tools-sdk",
5
- version="0.1.18-dev",
5
+ version="0.1.19-dev",
6
6
  packages=find_packages(),
7
7
  install_requires=[
8
8
  "fastapi>=0.100.0",
@@ -0,0 +1,235 @@
1
+ """Tests for nested schema extraction from Pydantic models in the @tool decorator."""
2
+
3
+ from enum import Enum
4
+ from typing import List, Optional
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+ from opal_tools_sdk.decorators import _resolve_json_schema
9
+ from opal_tools_sdk.models import Parameter, ParameterType
10
+
11
+
12
+ class OutputFormat(str, Enum):
13
+ markdown = "markdown"
14
+ raw = "raw"
15
+
16
+
17
+ class BrowseWebUrlItem(BaseModel):
18
+ url: str = Field(description="The webpage URL to browse.")
19
+ information_needed: Optional[str] = Field(
20
+ default=None, description="What information to extract"
21
+ )
22
+ output_format: Optional[OutputFormat] = Field(
23
+ default=None, description="Output format"
24
+ )
25
+
26
+
27
+ class SimpleItem(BaseModel):
28
+ name: str = Field(description="Item name")
29
+ count: int = Field(description="Item count")
30
+
31
+
32
+ class TestResolveJsonSchema:
33
+ """Tests for _resolve_json_schema helper."""
34
+
35
+ def test_simple_object(self):
36
+ schema = SimpleItem.model_json_schema()
37
+ defs = schema.pop("$defs", {})
38
+ resolved = _resolve_json_schema(schema, defs)
39
+ assert resolved["type"] == "object"
40
+ assert resolved["properties"]["name"] == {
41
+ "type": "string",
42
+ "description": "Item name",
43
+ }
44
+ assert resolved["properties"]["count"] == {
45
+ "type": "integer",
46
+ "description": "Item count",
47
+ }
48
+ assert resolved["required"] == ["name", "count"]
49
+
50
+ def test_optional_field_simplified(self):
51
+ schema = BrowseWebUrlItem.model_json_schema()
52
+ defs = schema.pop("$defs", {})
53
+ resolved = _resolve_json_schema(schema, defs)
54
+ info = resolved["properties"]["information_needed"]
55
+ assert info["type"] == "string"
56
+ assert "description" in info
57
+
58
+ def test_enum_ref_resolved(self):
59
+ schema = BrowseWebUrlItem.model_json_schema()
60
+ defs = schema.pop("$defs", {})
61
+ resolved = _resolve_json_schema(schema, defs)
62
+ fmt = resolved["properties"]["output_format"]
63
+ assert fmt["type"] == "string"
64
+ assert fmt["enum"] == ["markdown", "raw"]
65
+
66
+ def test_required_fields(self):
67
+ schema = BrowseWebUrlItem.model_json_schema()
68
+ defs = schema.pop("$defs", {})
69
+ resolved = _resolve_json_schema(schema, defs)
70
+ assert resolved["required"] == ["url"]
71
+
72
+ def test_ref_resolution(self):
73
+ defs = {"MyType": {"type": "string", "enum": ["a", "b"]}}
74
+ schema = {"$ref": "#/$defs/MyType"}
75
+ resolved = _resolve_json_schema(schema, defs)
76
+ assert resolved == {"type": "string", "enum": ["a", "b"]}
77
+
78
+ def test_strips_title(self):
79
+ schema = {"type": "object", "title": "SomeModel", "properties": {}}
80
+ resolved = _resolve_json_schema(schema, {})
81
+ assert "title" not in resolved
82
+
83
+ def test_ref_preserves_sibling_description(self):
84
+ """$ref with sibling description should preserve the description."""
85
+ defs = {"Inner": {"type": "object", "properties": {"x": {"type": "string"}}}}
86
+ schema = {"$ref": "#/$defs/Inner", "description": "My inner object"}
87
+ resolved = _resolve_json_schema(schema, defs)
88
+ assert resolved["description"] == "My inner object"
89
+ assert resolved["type"] == "object"
90
+
91
+ def test_circular_ref_does_not_crash(self):
92
+ """Self-referential $ref should not cause infinite recursion."""
93
+ defs = {
94
+ "TreeNode": {
95
+ "type": "object",
96
+ "properties": {
97
+ "name": {"type": "string"},
98
+ "children": {"type": "array", "items": {"$ref": "#/$defs/TreeNode"}},
99
+ },
100
+ }
101
+ }
102
+ schema = {"$ref": "#/$defs/TreeNode"}
103
+ resolved = _resolve_json_schema(schema, defs)
104
+ assert resolved["type"] == "object"
105
+ # The circular ref should be broken with {type: object}
106
+ assert resolved["properties"]["children"]["items"]["type"] == "object"
107
+
108
+ def test_anyof_with_multiple_non_null_types(self):
109
+ """Union[str, int] should fall back to string."""
110
+ schema = {
111
+ "anyOf": [{"type": "string"}, {"type": "integer"}],
112
+ "description": "A union field",
113
+ }
114
+ resolved = _resolve_json_schema(schema, {})
115
+ assert resolved == {"type": "string", "description": "A union field"}
116
+
117
+
118
+ class TestParameterToDict:
119
+ """Tests for Parameter.to_dict with schema field."""
120
+
121
+ def test_to_dict_with_array_schema(self):
122
+ param = Parameter(
123
+ name="urls",
124
+ param_type=ParameterType.list,
125
+ description="URLs",
126
+ required=True,
127
+ schema={"type": "array", "description": "URLs", "items": {"type": "object", "properties": {"url": {"type": "string"}}}},
128
+ )
129
+ d = param.to_dict()
130
+ assert d["schema"]["type"] == "array"
131
+ assert d["schema"]["items"]["properties"]["url"] == {"type": "string"}
132
+
133
+ def test_to_dict_with_object_schema(self):
134
+ param = Parameter(
135
+ name="config",
136
+ param_type=ParameterType.dictionary,
137
+ description="Config",
138
+ required=True,
139
+ schema={"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]},
140
+ )
141
+ d = param.to_dict()
142
+ assert d["schema"]["properties"]["name"] == {"type": "string"}
143
+ assert d["schema"]["required"] == ["name"]
144
+
145
+ def test_to_dict_without_schema(self):
146
+ """Backward compat: no schema key when None."""
147
+ param = Parameter(
148
+ name="query",
149
+ param_type=ParameterType.string,
150
+ description="Search",
151
+ required=True,
152
+ )
153
+ d = param.to_dict()
154
+ assert "schema" not in d
155
+
156
+ def test_schema_is_complete_json_schema(self):
157
+ """schema field must be a complete, self-contained JSON Schema."""
158
+ param = Parameter(
159
+ name="urls",
160
+ param_type=ParameterType.list,
161
+ description="URLs to browse",
162
+ required=True,
163
+ schema={
164
+ "type": "array",
165
+ "description": "URLs to browse",
166
+ "items": {
167
+ "type": "object",
168
+ "properties": {
169
+ "url": {"type": "string", "description": "The URL"},
170
+ },
171
+ "required": ["url"],
172
+ },
173
+ },
174
+ )
175
+ d = param.to_dict()
176
+ schema = d["schema"]
177
+ # Schema is self-contained: includes type and description
178
+ assert schema["type"] == "array"
179
+ assert schema["description"] == "URLs to browse"
180
+ assert schema["items"]["type"] == "object"
181
+ assert schema["items"]["required"] == ["url"]
182
+
183
+
184
+ class TestToolDecoratorNestedExtraction:
185
+ """Tests for the @tool decorator extracting nested schemas from Pydantic models."""
186
+
187
+ def test_list_of_pydantic_model(self):
188
+ """Simulates what the decorator does for List[BaseModel] fields."""
189
+ raw = SimpleItem.model_json_schema()
190
+ defs = raw.pop("$defs", {})
191
+ items_schema = _resolve_json_schema(raw, defs)
192
+ assert items_schema["type"] == "object"
193
+ assert "name" in items_schema["properties"]
194
+ assert "count" in items_schema["properties"]
195
+
196
+ def test_list_of_str_schema(self):
197
+ """List[str] should produce a complete array schema."""
198
+ param = Parameter(
199
+ name="tags",
200
+ param_type=ParameterType.list,
201
+ description="Tags",
202
+ required=False,
203
+ schema={"type": "array", "description": "Tags", "items": {"type": "string"}},
204
+ )
205
+ d = param.to_dict()
206
+ assert d["schema"]["type"] == "array"
207
+ assert d["schema"]["items"] == {"type": "string"}
208
+
209
+ def test_browse_web_url_item_full(self):
210
+ """Full integration test with BrowseWebUrlItem model."""
211
+ raw = BrowseWebUrlItem.model_json_schema()
212
+ defs = raw.pop("$defs", {})
213
+ items_schema = _resolve_json_schema(raw, defs)
214
+
215
+ assert items_schema["type"] == "object"
216
+ assert items_schema["properties"]["url"]["type"] == "string"
217
+ assert items_schema["properties"]["information_needed"]["type"] == "string"
218
+ assert items_schema["properties"]["output_format"]["enum"] == ["markdown", "raw"]
219
+ assert items_schema["required"] == ["url"]
220
+
221
+ # Build the complete schema as the decorator would
222
+ full_schema = {"type": "array", "description": "URLs to browse", "items": items_schema}
223
+ param = Parameter(
224
+ name="urls",
225
+ param_type=ParameterType.list,
226
+ description="URLs to browse",
227
+ required=True,
228
+ schema=full_schema,
229
+ )
230
+ d = param.to_dict()
231
+ assert d["schema"]["type"] == "array"
232
+ assert d["schema"]["items"]["type"] == "object"
233
+ assert "url" in d["schema"]["items"]["properties"]
234
+ # Uses standard "required" key, not "required_properties"
235
+ assert d["schema"]["items"]["required"] == ["url"]