optimizely-opal.opal-tools-sdk 0.1.21.dev0__tar.gz → 0.1.23.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.
- {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/PKG-INFO +1 -1
- {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/opal_tools_sdk/__init__.py +4 -2
- {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/opal_tools_sdk/decorators.py +131 -0
- {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/opal_tools_sdk/models.py +7 -0
- {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/opal_tools_sdk/proteus.py +70 -46
- {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/opal_tools_sdk/service.py +106 -1
- {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/optimizely_opal.opal_tools_sdk.egg-info/PKG-INFO +1 -1
- {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/pyproject.toml +1 -1
- {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/setup.py +1 -1
- optimizely_opal_opal_tools_sdk-0.1.23.dev0/tests/test_integration.py +749 -0
- optimizely_opal_opal_tools_sdk-0.1.21.dev0/tests/test_integration.py +0 -352
- {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/README.md +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/opal_tools_sdk/_registry.py +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/opal_tools_sdk/auth.py +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/opal_tools_sdk/logging.py +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/opal_tools_sdk/ui.py +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/optimizely_opal.opal_tools_sdk.egg-info/SOURCES.txt +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/optimizely_opal.opal_tools_sdk.egg-info/dependency_links.txt +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/optimizely_opal.opal_tools_sdk.egg-info/requires.txt +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/optimizely_opal.opal_tools_sdk.egg-info/top_level.txt +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/setup.cfg +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/tests/test_nested_schema.py +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/tests/test_proteus.py +0 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
from .service import ToolsService
|
|
2
|
-
from .decorators import tool, resource
|
|
2
|
+
from .decorators import tool, resource, interaction
|
|
3
3
|
from .auth import requires_auth
|
|
4
4
|
from .logging import register_logger_factory
|
|
5
|
-
from .models import AuthData, AuthRequirement, Credentials, Environment, IslandConfig, IslandResponse
|
|
5
|
+
from .models import AuthData, AuthRequirement, Credentials, Environment, InteractionContext, IslandConfig, IslandResponse
|
|
6
6
|
from .proteus import UI
|
|
7
7
|
|
|
8
8
|
__version__ = "0.1.14-dev"
|
|
@@ -10,6 +10,7 @@ __all__ = [
|
|
|
10
10
|
"ToolsService",
|
|
11
11
|
"tool",
|
|
12
12
|
"resource",
|
|
13
|
+
"interaction",
|
|
13
14
|
"requires_auth",
|
|
14
15
|
"register_logger_factory",
|
|
15
16
|
# Models
|
|
@@ -17,6 +18,7 @@ __all__ = [
|
|
|
17
18
|
"AuthRequirement",
|
|
18
19
|
"Credentials",
|
|
19
20
|
"Environment",
|
|
21
|
+
"InteractionContext",
|
|
20
22
|
"IslandConfig",
|
|
21
23
|
"IslandResponse",
|
|
22
24
|
# UI
|
|
@@ -279,6 +279,137 @@ def tool(
|
|
|
279
279
|
return decorator
|
|
280
280
|
|
|
281
281
|
|
|
282
|
+
def interaction(
|
|
283
|
+
name: str,
|
|
284
|
+
description: str = "",
|
|
285
|
+
):
|
|
286
|
+
"""Decorator to register a function as an interaction handler.
|
|
287
|
+
|
|
288
|
+
Interactions are app-only handlers — actions callable by the UI but hidden from the model.
|
|
289
|
+
They follow the MCP app-only tools pattern (visibility: ["app"]).
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
name: Name of the interaction. Must be unique across both tools and interactions
|
|
293
|
+
within the same service, since both may be exported as MCP tools.
|
|
294
|
+
description: Description of the interaction
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
Decorator function
|
|
298
|
+
|
|
299
|
+
Note:
|
|
300
|
+
The handler's first parameter can be a Pydantic model defining the interaction's
|
|
301
|
+
input schema — the same pattern as @tool. The decorator introspects the model fields
|
|
302
|
+
to extract parameter definitions (type, description, required). If the first parameter
|
|
303
|
+
is typed as `dict`, no schema extraction is performed.
|
|
304
|
+
|
|
305
|
+
Example:
|
|
306
|
+
class TaskFormInput(BaseModel):
|
|
307
|
+
title: str = Field(description="Task title")
|
|
308
|
+
priority: str = Field(default="medium", description="Task priority")
|
|
309
|
+
assignee: Optional[str] = Field(default=None, description="Assignee email")
|
|
310
|
+
|
|
311
|
+
@interaction(
|
|
312
|
+
name="submit_task_form",
|
|
313
|
+
description="Handle task form submission"
|
|
314
|
+
)
|
|
315
|
+
async def handle_task_submission(parameters: TaskFormInput, context: InteractionContext):
|
|
316
|
+
# parameters is already validated and typed
|
|
317
|
+
task_id = await create_task(parameters.title, parameters.priority)
|
|
318
|
+
return {"task_id": task_id}
|
|
319
|
+
"""
|
|
320
|
+
def decorator(func: Callable):
|
|
321
|
+
from . import _registry
|
|
322
|
+
|
|
323
|
+
logger.info(f"Registering interaction {name}")
|
|
324
|
+
|
|
325
|
+
# Extract parameters from Pydantic model in function signature (same as @tool)
|
|
326
|
+
sig = inspect.signature(func)
|
|
327
|
+
type_hints = get_type_hints(func)
|
|
328
|
+
|
|
329
|
+
parameters: List[Parameter] = []
|
|
330
|
+
param_model = None
|
|
331
|
+
|
|
332
|
+
for param_name, param in sig.parameters.items():
|
|
333
|
+
if param_name in type_hints:
|
|
334
|
+
param_type = type_hints[param_name]
|
|
335
|
+
if hasattr(param_type, '__fields__') or hasattr(param_type, 'model_fields'):
|
|
336
|
+
param_model = param_type
|
|
337
|
+
break
|
|
338
|
+
|
|
339
|
+
if param_model:
|
|
340
|
+
model_fields = getattr(param_model, 'model_fields', getattr(param_model, '__fields__', {}))
|
|
341
|
+
for field_name, field in model_fields.items():
|
|
342
|
+
field_info = field.field_info if hasattr(field, 'field_info') else field
|
|
343
|
+
|
|
344
|
+
if hasattr(field, 'outer_type_'):
|
|
345
|
+
field_type = field.outer_type_
|
|
346
|
+
elif hasattr(field, 'annotation'):
|
|
347
|
+
field_type = field.annotation
|
|
348
|
+
else:
|
|
349
|
+
field_type = str
|
|
350
|
+
|
|
351
|
+
type_args = getattr(field_type, '__args__', ())
|
|
352
|
+
is_optional = get_origin(field_type) is Union and type(None) in type_args
|
|
353
|
+
|
|
354
|
+
if is_optional and type_args:
|
|
355
|
+
field_type = next(
|
|
356
|
+
(arg for arg in type_args if arg is not type(None)),
|
|
357
|
+
field_type,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
param_type = ParameterType.string
|
|
361
|
+
if field_type is int:
|
|
362
|
+
param_type = ParameterType.integer
|
|
363
|
+
elif field_type is float:
|
|
364
|
+
param_type = ParameterType.number
|
|
365
|
+
elif field_type is bool:
|
|
366
|
+
param_type = ParameterType.boolean
|
|
367
|
+
elif field_type is list or get_origin(field_type) is list:
|
|
368
|
+
param_type = ParameterType.list
|
|
369
|
+
elif field_type is dict or get_origin(field_type) is dict:
|
|
370
|
+
param_type = ParameterType.dictionary
|
|
371
|
+
|
|
372
|
+
field_info_extra = getattr(field_info, "json_schema_extra") or {}
|
|
373
|
+
if "required" in field_info_extra:
|
|
374
|
+
required = field_info_extra["required"]
|
|
375
|
+
elif is_optional:
|
|
376
|
+
required = False
|
|
377
|
+
elif hasattr(field_info, 'is_required'):
|
|
378
|
+
required = field_info.is_required()
|
|
379
|
+
elif hasattr(field_info, 'default'):
|
|
380
|
+
required = field_info.default is ...
|
|
381
|
+
else:
|
|
382
|
+
required = True
|
|
383
|
+
|
|
384
|
+
description_text = ""
|
|
385
|
+
if hasattr(field_info, 'description'):
|
|
386
|
+
description_text = field_info.description
|
|
387
|
+
elif hasattr(field, 'description'):
|
|
388
|
+
description_text = field.description
|
|
389
|
+
|
|
390
|
+
parameters.append(Parameter(
|
|
391
|
+
name=field_name,
|
|
392
|
+
param_type=param_type,
|
|
393
|
+
description=description_text,
|
|
394
|
+
required=required,
|
|
395
|
+
))
|
|
396
|
+
|
|
397
|
+
if not _registry.services:
|
|
398
|
+
logger.warning("No services registered in registry! Make sure to create ToolsService before decorating functions.")
|
|
399
|
+
|
|
400
|
+
for service in _registry.services:
|
|
401
|
+
service.register_interaction(
|
|
402
|
+
name=name,
|
|
403
|
+
description=description,
|
|
404
|
+
handler=func,
|
|
405
|
+
parameters=parameters,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
return func
|
|
409
|
+
|
|
410
|
+
return decorator
|
|
411
|
+
|
|
412
|
+
|
|
282
413
|
def resource(
|
|
283
414
|
uri: str,
|
|
284
415
|
name: str,
|
|
@@ -71,6 +71,13 @@ class AuthData(TypedDict):
|
|
|
71
71
|
credentials: Credentials
|
|
72
72
|
|
|
73
73
|
|
|
74
|
+
@dataclass
|
|
75
|
+
class InteractionContext:
|
|
76
|
+
"""Context object passed to interaction handlers."""
|
|
77
|
+
|
|
78
|
+
auth_data: Optional[AuthData] = None
|
|
79
|
+
|
|
80
|
+
|
|
74
81
|
class Environment(TypedDict):
|
|
75
82
|
"""Execution environment for an Opal tool. Interactive will provide interaction islands, while headless will not."""
|
|
76
83
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# generated by datamodel-codegen:
|
|
2
2
|
# filename: proteus-document-spec.json
|
|
3
|
-
# timestamp: 2026-
|
|
3
|
+
# timestamp: 2026-04-01T20:49:32+00:00
|
|
4
4
|
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
@@ -15,38 +15,13 @@ class OpalProteusDocumentSpecification(RootModel[Any]):
|
|
|
15
15
|
|
|
16
16
|
class ProteusEventHandler1(BaseModel):
|
|
17
17
|
"""
|
|
18
|
-
Server-side
|
|
18
|
+
Server-side interaction call
|
|
19
19
|
"""
|
|
20
20
|
|
|
21
21
|
model_config = ConfigDict(
|
|
22
22
|
extra='forbid',
|
|
23
23
|
)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
class ProteusEventHandler2(BaseModel):
|
|
28
|
-
"""
|
|
29
|
-
Client-side message action
|
|
30
|
-
"""
|
|
31
|
-
|
|
32
|
-
model_config = ConfigDict(
|
|
33
|
-
extra='forbid',
|
|
34
|
-
)
|
|
35
|
-
message: str = Field(..., description='Message to send to LLM via sendNewMessage()')
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
class ProteusEventHandler4(BaseModel):
|
|
39
|
-
"""
|
|
40
|
-
Client-side component action - collects name/value pairs from a data array and sends as a message
|
|
41
|
-
"""
|
|
42
|
-
|
|
43
|
-
model_config = ConfigDict(
|
|
44
|
-
extra='forbid',
|
|
45
|
-
)
|
|
46
|
-
action: Literal['message-from'] = Field(default='message-from', description='The action type')
|
|
47
|
-
path: str = Field(
|
|
48
|
-
..., description='JSON pointer to an array of objects with name and value fields'
|
|
49
|
-
)
|
|
24
|
+
interaction: str = Field(..., description='Name of registered interaction to call')
|
|
50
25
|
|
|
51
26
|
|
|
52
27
|
class Series(BaseModel):
|
|
@@ -1800,28 +1775,18 @@ class ProteusCondition7(BaseModel):
|
|
|
1800
1775
|
)
|
|
1801
1776
|
|
|
1802
1777
|
|
|
1803
|
-
class
|
|
1778
|
+
class ProteusCondition8(BaseModel):
|
|
1804
1779
|
"""
|
|
1805
|
-
|
|
1780
|
+
Condition for Show component. Can be a comparison operator, logical AND, or logical OR. Supports nesting.
|
|
1806
1781
|
"""
|
|
1807
1782
|
|
|
1808
1783
|
model_config = ConfigDict(
|
|
1809
1784
|
extra='forbid',
|
|
1810
1785
|
)
|
|
1811
|
-
|
|
1812
|
-
url: ProteusValue | str = Field(..., description='URL to download')
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
class ProteusEventHandler(
|
|
1816
|
-
RootModel[
|
|
1817
|
-
ProteusEventHandler1 | ProteusEventHandler2 | ProteusEventHandler3 | ProteusEventHandler4
|
|
1818
|
-
]
|
|
1819
|
-
):
|
|
1820
|
-
root: (
|
|
1821
|
-
ProteusEventHandler1 | ProteusEventHandler2 | ProteusEventHandler3 | ProteusEventHandler4
|
|
1822
|
-
) = Field(
|
|
1786
|
+
field_: str | float | bool | ProteusValue | None = Field(
|
|
1823
1787
|
...,
|
|
1824
|
-
|
|
1788
|
+
alias='!',
|
|
1789
|
+
description='Falsy check - returns true if value is falsy (null, undefined, false, 0, or empty string)',
|
|
1825
1790
|
)
|
|
1826
1791
|
|
|
1827
1792
|
|
|
@@ -2368,7 +2333,7 @@ class ProteusTime(BaseModel):
|
|
|
2368
2333
|
z: SprinklePropZ | None = None
|
|
2369
2334
|
|
|
2370
2335
|
|
|
2371
|
-
class
|
|
2336
|
+
class ProteusCondition9(BaseModel):
|
|
2372
2337
|
"""
|
|
2373
2338
|
Condition for Show component. Can be a comparison operator, logical AND, or logical OR. Supports nesting.
|
|
2374
2339
|
"""
|
|
@@ -2384,7 +2349,7 @@ class ProteusCondition8(BaseModel):
|
|
|
2384
2349
|
)
|
|
2385
2350
|
|
|
2386
2351
|
|
|
2387
|
-
class
|
|
2352
|
+
class ProteusCondition10(BaseModel):
|
|
2388
2353
|
"""
|
|
2389
2354
|
Condition for Show component. Can be a comparison operator, logical AND, or logical OR. Supports nesting.
|
|
2390
2355
|
"""
|
|
@@ -2432,6 +2397,34 @@ class ProteusDocument(BaseModel):
|
|
|
2432
2397
|
)
|
|
2433
2398
|
|
|
2434
2399
|
|
|
2400
|
+
class ProteusEventHandler2(BaseModel):
|
|
2401
|
+
"""
|
|
2402
|
+
Client-side message action
|
|
2403
|
+
"""
|
|
2404
|
+
|
|
2405
|
+
model_config = ConfigDict(
|
|
2406
|
+
extra='forbid',
|
|
2407
|
+
)
|
|
2408
|
+
message: ProteusMap | ProteusValue | str = Field(
|
|
2409
|
+
...,
|
|
2410
|
+
description='Message to send to LLM via sendNewMessage(). Can be a string, a Value reference, or a Map expression.',
|
|
2411
|
+
)
|
|
2412
|
+
|
|
2413
|
+
|
|
2414
|
+
class ProteusEventHandler3(BaseModel):
|
|
2415
|
+
"""
|
|
2416
|
+
Client-side component action - for downloading a URL
|
|
2417
|
+
"""
|
|
2418
|
+
|
|
2419
|
+
model_config = ConfigDict(
|
|
2420
|
+
extra='forbid',
|
|
2421
|
+
)
|
|
2422
|
+
action: Literal['download'] = Field(default='download', description='The action type')
|
|
2423
|
+
url: ProteusMap | ProteusValue | str = Field(
|
|
2424
|
+
..., description='URL to download, or a Map expression resolving to multiple URLs'
|
|
2425
|
+
)
|
|
2426
|
+
|
|
2427
|
+
|
|
2435
2428
|
class ProteusAction(BaseModel):
|
|
2436
2429
|
model_config = ConfigDict(
|
|
2437
2430
|
extra='forbid',
|
|
@@ -2507,6 +2500,9 @@ class ProteusAction(BaseModel):
|
|
|
2507
2500
|
textAlign: SprinklePropTextAlign | None = None
|
|
2508
2501
|
textTransform: SprinklePropTextTransform | None = None
|
|
2509
2502
|
transition: SprinklePropTransition | None = None
|
|
2503
|
+
type: Literal['button'] | Literal['reset'] | Literal['submit'] | ProteusValue | None = Field(
|
|
2504
|
+
default=None, description='The default behavior of the button.'
|
|
2505
|
+
)
|
|
2510
2506
|
w: SprinklePropW | None = None
|
|
2511
2507
|
whiteSpace: SprinklePropWhiteSpace | None = None
|
|
2512
2508
|
z: SprinklePropZ | None = None
|
|
@@ -2958,6 +2954,17 @@ class ProteusCardLink(BaseModel):
|
|
|
2958
2954
|
z: SprinklePropZ | None = None
|
|
2959
2955
|
|
|
2960
2956
|
|
|
2957
|
+
class ProteusConcat(BaseModel):
|
|
2958
|
+
model_config = ConfigDict(
|
|
2959
|
+
extra='forbid',
|
|
2960
|
+
)
|
|
2961
|
+
field_type: Literal['Concat'] = Field(default='Concat', alias='$type')
|
|
2962
|
+
children: list[ProteusNode | str | None] | None = Field(
|
|
2963
|
+
default=None,
|
|
2964
|
+
description='Array of values to concatenate into a single string. Each item is resolved and joined together.',
|
|
2965
|
+
)
|
|
2966
|
+
|
|
2967
|
+
|
|
2961
2968
|
class ProteusField(BaseModel):
|
|
2962
2969
|
model_config = ConfigDict(
|
|
2963
2970
|
extra='forbid',
|
|
@@ -3636,6 +3643,7 @@ class ProteusCondition(
|
|
|
3636
3643
|
| ProteusCondition7
|
|
3637
3644
|
| ProteusCondition8
|
|
3638
3645
|
| ProteusCondition9
|
|
3646
|
+
| ProteusCondition10
|
|
3639
3647
|
]
|
|
3640
3648
|
):
|
|
3641
3649
|
root: (
|
|
@@ -3648,12 +3656,22 @@ class ProteusCondition(
|
|
|
3648
3656
|
| ProteusCondition7
|
|
3649
3657
|
| ProteusCondition8
|
|
3650
3658
|
| ProteusCondition9
|
|
3659
|
+
| ProteusCondition10
|
|
3651
3660
|
) = Field(
|
|
3652
3661
|
...,
|
|
3653
3662
|
description='Condition for Show component. Can be a comparison operator, logical AND, or logical OR. Supports nesting.',
|
|
3654
3663
|
)
|
|
3655
3664
|
|
|
3656
3665
|
|
|
3666
|
+
class ProteusEventHandler(
|
|
3667
|
+
RootModel[ProteusEventHandler1 | ProteusEventHandler2 | ProteusEventHandler3]
|
|
3668
|
+
):
|
|
3669
|
+
root: ProteusEventHandler1 | ProteusEventHandler2 | ProteusEventHandler3 = Field(
|
|
3670
|
+
...,
|
|
3671
|
+
description='Handler for user interactions - a server-side interaction call, client-side message, or client-side component action',
|
|
3672
|
+
)
|
|
3673
|
+
|
|
3674
|
+
|
|
3657
3675
|
class ProteusElement(
|
|
3658
3676
|
RootModel[
|
|
3659
3677
|
ProteusAction
|
|
@@ -3664,6 +3682,7 @@ class ProteusElement(
|
|
|
3664
3682
|
| ProteusCardHeader
|
|
3665
3683
|
| ProteusCardLink
|
|
3666
3684
|
| ProteusChart
|
|
3685
|
+
| ProteusConcat
|
|
3667
3686
|
| ProteusDataTable
|
|
3668
3687
|
| ProteusField
|
|
3669
3688
|
| ProteusGroup
|
|
@@ -3696,6 +3715,7 @@ class ProteusElement(
|
|
|
3696
3715
|
| ProteusCardHeader
|
|
3697
3716
|
| ProteusCardLink
|
|
3698
3717
|
| ProteusChart
|
|
3718
|
+
| ProteusConcat
|
|
3699
3719
|
| ProteusDataTable
|
|
3700
3720
|
| ProteusField
|
|
3701
3721
|
| ProteusGroup
|
|
@@ -3746,9 +3766,11 @@ class ProteusNode(
|
|
|
3746
3766
|
)
|
|
3747
3767
|
|
|
3748
3768
|
|
|
3749
|
-
ProteusCondition8.model_rebuild()
|
|
3750
3769
|
ProteusCondition9.model_rebuild()
|
|
3770
|
+
ProteusCondition10.model_rebuild()
|
|
3751
3771
|
ProteusDocument.model_rebuild()
|
|
3772
|
+
ProteusEventHandler2.model_rebuild()
|
|
3773
|
+
ProteusEventHandler3.model_rebuild()
|
|
3752
3774
|
ProteusAction.model_rebuild()
|
|
3753
3775
|
ProteusAvatar.model_rebuild()
|
|
3754
3776
|
ProteusBadge.model_rebuild()
|
|
@@ -3756,6 +3778,7 @@ ProteusCancelAction.model_rebuild()
|
|
|
3756
3778
|
ProteusCard.model_rebuild()
|
|
3757
3779
|
ProteusCardHeader.model_rebuild()
|
|
3758
3780
|
ProteusCardLink.model_rebuild()
|
|
3781
|
+
ProteusConcat.model_rebuild()
|
|
3759
3782
|
ProteusField.model_rebuild()
|
|
3760
3783
|
ProteusGroup.model_rebuild()
|
|
3761
3784
|
ProteusHeading.model_rebuild()
|
|
@@ -3789,6 +3812,7 @@ class UI:
|
|
|
3789
3812
|
CardHeader = ProteusCardHeader
|
|
3790
3813
|
CardLink = ProteusCardLink
|
|
3791
3814
|
Chart = ProteusChart
|
|
3815
|
+
Concat = ProteusConcat
|
|
3792
3816
|
DataTable = ProteusDataTable
|
|
3793
3817
|
Document = ProteusDocument
|
|
3794
3818
|
Field = ProteusField
|
|
@@ -6,7 +6,7 @@ from fastapi import FastAPI, APIRouter, HTTPException, Request
|
|
|
6
6
|
from fastapi.routing import APIRoute
|
|
7
7
|
from pydantic import BaseModel, ValidationError
|
|
8
8
|
|
|
9
|
-
from .models import Function, Parameter, AuthRequirement
|
|
9
|
+
from .models import Function, Parameter, AuthRequirement, InteractionContext
|
|
10
10
|
from .proteus import ProteusDocument, UI
|
|
11
11
|
from . import _registry
|
|
12
12
|
|
|
@@ -25,6 +25,7 @@ class ToolsService:
|
|
|
25
25
|
self.app = app
|
|
26
26
|
self.router = APIRouter()
|
|
27
27
|
self.functions: List[Function] = []
|
|
28
|
+
self.interactions: Dict[str, Dict[str, Any]] = {} # name -> {"handler": fn, "description": str, "parameters": List[Parameter]}
|
|
28
29
|
self.resource_handlers: Dict[str, Tuple[Callable, Optional[str]]] = {} # URI -> (handler, mime_type)
|
|
29
30
|
self._init_routes()
|
|
30
31
|
|
|
@@ -98,6 +99,67 @@ class ToolsService:
|
|
|
98
99
|
logger.error(traceback.format_exc())
|
|
99
100
|
raise HTTPException(status_code=500, detail=str(e))
|
|
100
101
|
|
|
102
|
+
@self.router.post("/interactions/execute")
|
|
103
|
+
async def execute_interaction(request: Request):
|
|
104
|
+
"""Execute an interaction by name with parameters."""
|
|
105
|
+
try:
|
|
106
|
+
body = await request.json()
|
|
107
|
+
name = body.get("name")
|
|
108
|
+
parameters = body.get("parameters", {})
|
|
109
|
+
|
|
110
|
+
if not name:
|
|
111
|
+
raise HTTPException(status_code=400, detail="Missing 'name' field in request body")
|
|
112
|
+
|
|
113
|
+
if name not in self.interactions:
|
|
114
|
+
raise HTTPException(status_code=404, detail=f"Interaction '{name}' not found")
|
|
115
|
+
|
|
116
|
+
interaction_info = self.interactions[name]
|
|
117
|
+
handler = interaction_info["handler"]
|
|
118
|
+
|
|
119
|
+
# Extract auth data if available
|
|
120
|
+
auth_data = body.get("auth")
|
|
121
|
+
|
|
122
|
+
# Build context object
|
|
123
|
+
context = InteractionContext(
|
|
124
|
+
auth_data=auth_data,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Introspect handler signature for Pydantic model (same pattern as tool endpoint)
|
|
128
|
+
sig = inspect.signature(handler)
|
|
129
|
+
type_hints = get_type_hints(handler)
|
|
130
|
+
|
|
131
|
+
param_model = None
|
|
132
|
+
for param_name, param in sig.parameters.items():
|
|
133
|
+
if param_name in type_hints:
|
|
134
|
+
param_type = type_hints[param_name]
|
|
135
|
+
if hasattr(param_type, '__fields__') or hasattr(param_type, 'model_fields'):
|
|
136
|
+
param_model = param_type
|
|
137
|
+
break
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
if param_model:
|
|
141
|
+
# Validate and deserialize parameters using the Pydantic model
|
|
142
|
+
model_instance = param_model(**parameters)
|
|
143
|
+
result = await handler(model_instance, context)
|
|
144
|
+
else:
|
|
145
|
+
# Fall back to raw dict
|
|
146
|
+
result = await handler(parameters, context)
|
|
147
|
+
return result
|
|
148
|
+
except ValidationError as e:
|
|
149
|
+
logger.warning(f"Invalid parameters for interaction '{name}': {str(e)}")
|
|
150
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
151
|
+
except Exception as e:
|
|
152
|
+
logger.error(f"Interaction '{name}' failed: {e}", exc_info=True)
|
|
153
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
154
|
+
|
|
155
|
+
except HTTPException:
|
|
156
|
+
raise
|
|
157
|
+
except Exception as e:
|
|
158
|
+
import traceback
|
|
159
|
+
logger.error(f"Error executing interaction: {str(e)}")
|
|
160
|
+
logger.error(traceback.format_exc())
|
|
161
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
162
|
+
|
|
101
163
|
# Include router in app
|
|
102
164
|
self.app.include_router(self.router)
|
|
103
165
|
|
|
@@ -148,6 +210,13 @@ class ToolsService:
|
|
|
148
210
|
"""
|
|
149
211
|
logger.info(f"Registering tool: {name} with endpoint: {endpoint}")
|
|
150
212
|
|
|
213
|
+
# Validate name uniqueness across tools and interactions
|
|
214
|
+
if name in self.interactions:
|
|
215
|
+
raise ValueError(
|
|
216
|
+
f"Tool name '{name}' conflicts with an existing interaction name. "
|
|
217
|
+
"Tool and interaction names must be unique because both are exported as MCP tools."
|
|
218
|
+
)
|
|
219
|
+
|
|
151
220
|
# Extract auth requirements from handler if decorated with @requires_auth
|
|
152
221
|
handler_auth_requirements = self._extract_auth_requirements(handler)
|
|
153
222
|
|
|
@@ -263,3 +332,39 @@ class ToolsService:
|
|
|
263
332
|
|
|
264
333
|
# Store the handler and mime_type as a tuple
|
|
265
334
|
self.resource_handlers[uri] = (handler, mime_type)
|
|
335
|
+
|
|
336
|
+
def register_interaction(
|
|
337
|
+
self,
|
|
338
|
+
name: str,
|
|
339
|
+
description: str,
|
|
340
|
+
handler: Callable,
|
|
341
|
+
parameters: Optional[List[Parameter]] = None,
|
|
342
|
+
) -> None:
|
|
343
|
+
"""Register an interaction handler.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
name: Name of the interaction. Must be unique across both tools and interactions.
|
|
347
|
+
description: Description of the interaction
|
|
348
|
+
handler: Async function that handles the interaction.
|
|
349
|
+
Signature: async def handler(parameters: Model|dict, context: InteractionContext) -> dict
|
|
350
|
+
parameters: Parameter definitions extracted from the handler's Pydantic model
|
|
351
|
+
|
|
352
|
+
Raises:
|
|
353
|
+
ValueError: If the name conflicts with an existing tool or interaction name.
|
|
354
|
+
"""
|
|
355
|
+
# Validate name uniqueness across tools and interactions
|
|
356
|
+
tool_names = {f.name for f in self.functions}
|
|
357
|
+
if name in tool_names:
|
|
358
|
+
raise ValueError(
|
|
359
|
+
f"Interaction name '{name}' conflicts with an existing tool name. "
|
|
360
|
+
"Tool and interaction names must be unique because both are exported as MCP tools."
|
|
361
|
+
)
|
|
362
|
+
if name in self.interactions:
|
|
363
|
+
raise ValueError(f"Interaction '{name}' is already registered.")
|
|
364
|
+
|
|
365
|
+
self.interactions[name] = {
|
|
366
|
+
"handler": handler,
|
|
367
|
+
"description": description,
|
|
368
|
+
"parameters": parameters or [],
|
|
369
|
+
}
|
|
370
|
+
logger.info(f"Registered interaction: {name}")
|
|
@@ -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.
|
|
7
|
+
version = "0.1.23-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"
|