From efd34f8ffde966e1c109826f0e4bf9fc05a74ef1 Mon Sep 17 00:00:00 2001 From: Elena Kolevska Date: Tue, 12 Aug 2025 23:33:18 +0100 Subject: [PATCH 01/10] 1.16.0-rc1 Signed-off-by: Elena Kolevska --- README.md | 2 +- dapr/version/version.py | 2 +- examples/demo_actor/demo_actor/requirements.txt | 2 +- examples/demo_workflow/demo_workflow/requirements.txt | 2 +- examples/invoke-simple/requirements.txt | 4 ++-- examples/w3c-tracing/requirements.txt | 4 ++-- examples/workflow/requirements.txt | 4 ++-- ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py | 2 +- ext/dapr-ext-fastapi/setup.cfg | 2 +- ext/dapr-ext-grpc/dapr/ext/grpc/version.py | 2 +- ext/dapr-ext-grpc/setup.cfg | 2 +- ext/dapr-ext-workflow/dapr/ext/workflow/version.py | 2 +- ext/dapr-ext-workflow/setup.cfg | 2 +- ext/flask_dapr/flask_dapr/version.py | 2 +- ext/flask_dapr/setup.cfg | 2 +- 15 files changed, 18 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 73fae5b9..05720eb9 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ pip3 install dapr-ext-fastapi ```sh # Install Dapr client sdk -pip3 install dapr-dev +pip3 install dapr # Install Dapr gRPC AppCallback service extension pip3 install dapr-ext-grpc-dev diff --git a/dapr/version/version.py b/dapr/version/version.py index 112a2520..95693e39 100644 --- a/dapr/version/version.py +++ b/dapr/version/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.15.0.dev' +__version__ = '1.16.0rc1' diff --git a/examples/demo_actor/demo_actor/requirements.txt b/examples/demo_actor/demo_actor/requirements.txt index 4c2215b5..36548818 100644 --- a/examples/demo_actor/demo_actor/requirements.txt +++ b/examples/demo_actor/demo_actor/requirements.txt @@ -1 +1 @@ -dapr-ext-fastapi-dev>=1.15.0.dev +dapr-ext-fastapi>=1.16.0rc1 diff --git a/examples/demo_workflow/demo_workflow/requirements.txt b/examples/demo_workflow/demo_workflow/requirements.txt index 7f7a666d..76d2a673 100644 --- a/examples/demo_workflow/demo_workflow/requirements.txt +++ b/examples/demo_workflow/demo_workflow/requirements.txt @@ -1 +1 @@ -dapr-ext-workflow-dev>=1.15.0.dev \ No newline at end of file +dapr-ext-workflow>=1.16.0rc1 \ No newline at end of file diff --git a/examples/invoke-simple/requirements.txt b/examples/invoke-simple/requirements.txt index ee0ce707..1481c0c9 100644 --- a/examples/invoke-simple/requirements.txt +++ b/examples/invoke-simple/requirements.txt @@ -1,2 +1,2 @@ -dapr-ext-grpc-dev >= 1.15.0.dev -dapr-dev >= 1.15.0.dev +dapr-ext-grpc >= 1.16.0rc1 +dapr >= 1.16.0rc1 diff --git a/examples/w3c-tracing/requirements.txt b/examples/w3c-tracing/requirements.txt index cd15885b..042e1b7f 100644 --- a/examples/w3c-tracing/requirements.txt +++ b/examples/w3c-tracing/requirements.txt @@ -1,5 +1,5 @@ -dapr-ext-grpc-dev >= 1.15.0.dev -dapr-dev >= 1.15.0.dev +dapr-ext-grpc >= 1.16.0rc1 +dapr >= 1.16.0rc1 opentelemetry-sdk opentelemetry-instrumentation-grpc opentelemetry-exporter-zipkin diff --git a/examples/workflow/requirements.txt b/examples/workflow/requirements.txt index e220036d..85763263 100644 --- a/examples/workflow/requirements.txt +++ b/examples/workflow/requirements.txt @@ -1,2 +1,2 @@ -dapr-ext-workflow-dev>=1.15.0.dev -dapr-dev>=1.15.0.dev +dapr-ext-workflow>=1.16.0rc1 +dapr>=1.16.0rc1 diff --git a/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py b/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py index 112a2520..95693e39 100644 --- a/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py +++ b/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.15.0.dev' +__version__ = '1.16.0rc1' diff --git a/ext/dapr-ext-fastapi/setup.cfg b/ext/dapr-ext-fastapi/setup.cfg index 560a795f..4d1c4d61 100644 --- a/ext/dapr-ext-fastapi/setup.cfg +++ b/ext/dapr-ext-fastapi/setup.cfg @@ -24,7 +24,7 @@ python_requires = >=3.9 packages = find_namespace: include_package_data = True install_requires = - dapr-dev >= 1.15.0.dev + dapr >= 1.16.0rc1 uvicorn >= 0.11.6 fastapi >= 0.60.1 diff --git a/ext/dapr-ext-grpc/dapr/ext/grpc/version.py b/ext/dapr-ext-grpc/dapr/ext/grpc/version.py index 112a2520..95693e39 100644 --- a/ext/dapr-ext-grpc/dapr/ext/grpc/version.py +++ b/ext/dapr-ext-grpc/dapr/ext/grpc/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.15.0.dev' +__version__ = '1.16.0rc1' diff --git a/ext/dapr-ext-grpc/setup.cfg b/ext/dapr-ext-grpc/setup.cfg index caf84a2e..c998af5d 100644 --- a/ext/dapr-ext-grpc/setup.cfg +++ b/ext/dapr-ext-grpc/setup.cfg @@ -24,7 +24,7 @@ python_requires = >=3.9 packages = find_namespace: include_package_data = True install_requires = - dapr-dev >= 1.15.0.dev + dapr >= 1.16.0rc1 cloudevents >= 1.0.0 [options.packages.find] diff --git a/ext/dapr-ext-workflow/dapr/ext/workflow/version.py b/ext/dapr-ext-workflow/dapr/ext/workflow/version.py index 112a2520..95693e39 100644 --- a/ext/dapr-ext-workflow/dapr/ext/workflow/version.py +++ b/ext/dapr-ext-workflow/dapr/ext/workflow/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.15.0.dev' +__version__ = '1.16.0rc1' diff --git a/ext/dapr-ext-workflow/setup.cfg b/ext/dapr-ext-workflow/setup.cfg index 3776ec89..df21ebc2 100644 --- a/ext/dapr-ext-workflow/setup.cfg +++ b/ext/dapr-ext-workflow/setup.cfg @@ -24,7 +24,7 @@ python_requires = >=3.9 packages = find_namespace: include_package_data = True install_requires = - dapr-dev >= 1.15.0.dev + dapr >= 1.16.0rc1 durabletask-dapr >= 0.2.0a7 [options.packages.find] diff --git a/ext/flask_dapr/flask_dapr/version.py b/ext/flask_dapr/flask_dapr/version.py index 112a2520..95693e39 100644 --- a/ext/flask_dapr/flask_dapr/version.py +++ b/ext/flask_dapr/flask_dapr/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.15.0.dev' +__version__ = '1.16.0rc1' diff --git a/ext/flask_dapr/setup.cfg b/ext/flask_dapr/setup.cfg index 64d15941..d3019d27 100644 --- a/ext/flask_dapr/setup.cfg +++ b/ext/flask_dapr/setup.cfg @@ -26,4 +26,4 @@ include_package_data = true zip_safe = false install_requires = Flask >= 1.1 - dapr-dev >= 1.15.0.dev + dapr >= 1.16.0rc1 From d62dd5ee3066c00a955c1c998592d7393b7230f2 Mon Sep 17 00:00:00 2001 From: Filinto Duran <1373693+filintod@users.noreply.github.com> Date: Sun, 7 Sep 2025 06:49:33 -0500 Subject: [PATCH 02/10] [Conversation API - Alpha2] Add new tool calling capability (#822) (#832) * initial * fixes after proto change upstream * minor name changes and cleanup unused function * refactors, updates to readme, linting * feedback * feedback, updates * fix import in examples * cleanup, import, lint, more conversation helpers * clarify README, minor test import changes, copyright * feedback DRY test_conversation file * lint * move conversation classes in _response module to conversation module. Some example README refactor/lint * minor readme change * Update daprdocs/content/en/python-sdk-docs/python-client.md * lint * updates to fix issue with tool calling helper when dealing with classes instead of dataclasses, and also with serializatin output of the tool back to the LLM * coalesce conv helper tests, fix typing lint * make indent line method doc more dev friendly * tackle some feedback, still missing unit tests * add unit test to convert_value_to_struct * more unit tests per feedback * make async version of unit test conversation * add some information how to run markdown tests with a different runtime * ran tox -e ruff, even though tox -e flake8 was fine * add tests to increase coverage in conversation and conversation_helpers that codecov pointed out * add more information on execute registered tools, also added more tests for them to validate * fix test failing on py 1.13. Merge two unit test files per feedback * Linter * fix typing issue with UnionType in py3.9 --------- Signed-off-by: Filinto Duran <1373693+filintod@users.noreply.github.com> Signed-off-by: Elena Kolevska Co-authored-by: Albert Callarisa Co-authored-by: Elena Kolevska Co-authored-by: Elena Kolevska --- README.md | 9 + dapr/aio/clients/grpc/client.py | 121 +- dapr/clients/_constants.py | 25 + dapr/clients/base.py | 4 - dapr/clients/grpc/_conversation_helpers.py | 1045 ++++++++ dapr/clients/grpc/_helpers.py | 135 +- dapr/clients/grpc/_request.py | 18 +- dapr/clients/grpc/_response.py | 20 +- dapr/clients/grpc/client.py | 124 +- dapr/clients/grpc/conversation.py | 662 +++++ dapr/clients/http/client.py | 2 +- dapr/conf/global_settings.py | 8 + .../en/python-sdk-docs/python-client.md | 2 +- dev-requirements.txt | 6 + examples/conversation/.env.example | 20 + examples/conversation/README.md | 591 ++++- examples/conversation/TOOL-CALL-QUICKSTART.md | 165 ++ ...conversation.py => conversation_alpha1.py} | 5 +- examples/conversation/conversation_alpha2.py | 39 + .../real_llm_providers_example.py | 1265 ++++++++++ ext/dapr-ext-grpc/dapr/ext/grpc/_servicer.py | 2 +- tests/clients/fake_dapr_server.py | 82 + tests/clients/test_conversation.py | 1227 ++++++++++ tests/clients/test_conversation_helpers.py | 2153 +++++++++++++++++ tests/clients/test_dapr_grpc_client.py | 315 ++- tests/clients/test_dapr_grpc_client_async.py | 298 ++- tests/clients/test_dapr_grpc_helpers.py | 189 ++ tox.ini | 19 + 28 files changed, 8456 insertions(+), 95 deletions(-) create mode 100644 dapr/clients/_constants.py create mode 100644 dapr/clients/grpc/_conversation_helpers.py create mode 100644 dapr/clients/grpc/conversation.py create mode 100644 examples/conversation/.env.example create mode 100644 examples/conversation/TOOL-CALL-QUICKSTART.md rename examples/conversation/{conversation.py => conversation_alpha1.py} (91%) create mode 100644 examples/conversation/conversation_alpha2.py create mode 100644 examples/conversation/real_llm_providers_example.py create mode 100644 tests/clients/test_conversation.py create mode 100644 tests/clients/test_conversation_helpers.py create mode 100644 tests/clients/test_dapr_grpc_helpers.py diff --git a/README.md b/README.md index 05720eb9..c5cdda81 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,15 @@ tox -e type tox -e examples ``` +[Dapr Mechanical Markdown](https://github.com/dapr/mechanical-markdown) is used to test the examples. + +If you need to run the examples against a pre-released version of the runtime, you can use the following command: +- Get your daprd runtime binary from [here](https://github.com/dapr/dapr/releases) for your platform. +- Copy the binary to your dapr home folder at $HOME/.dapr/bin/daprd. +Or using dapr cli directly: `dapr init --runtime-version ` +- Now you can run the example with `tox -e examples`. + + ## Documentation Documentation is generated using Sphinx. Extensions used are mainly Napoleon (To process the Google Comment Style) and Autodocs (For automatically generating documentation). The `.rst` files are generated using Sphinx-Apidocs. diff --git a/dapr/aio/clients/grpc/client.py b/dapr/aio/clients/grpc/client.py index be7c7c10..995b8268 100644 --- a/dapr/aio/clients/grpc/client.py +++ b/dapr/aio/clients/grpc/client.py @@ -63,6 +63,8 @@ to_bytes, validateNotNone, validateNotBlankString, + convert_dict_to_grpc_dict_of_any, + convert_value_to_struct, ) from dapr.aio.clients.grpc._request import ( EncryptRequestIterator, @@ -76,13 +78,12 @@ InvokeMethodRequest, BindingRequest, TransactionalStateOperation, - ConversationInput, ) +from dapr.clients.grpc import conversation + from dapr.clients.grpc._jobs import Job from dapr.clients.grpc._response import ( BindingResponse, - ConversationResponse, - ConversationResult, DaprResponse, GetSecretResponse, GetBulkSecretResponse, @@ -1722,21 +1723,21 @@ async def purge_workflow(self, instance_id: str, workflow_component: str) -> Dap async def converse_alpha1( self, name: str, - inputs: List[ConversationInput], + inputs: List[conversation.ConversationInput], *, context_id: Optional[str] = None, parameters: Optional[Dict[str, GrpcAny]] = None, metadata: Optional[Dict[str, str]] = None, scrub_pii: Optional[bool] = None, temperature: Optional[float] = None, - ) -> ConversationResponse: + ) -> conversation.ConversationResponseAlpha1: """Invoke an LLM using the conversation API (Alpha). Args: name: Name of the LLM component to invoke inputs: List of conversation inputs context_id: Optional ID for continuing an existing chat - parameters: Optional custom parameters for the request + parameters: Optional custom parameters for the request (raw Python values or GrpcAny objects) metadata: Optional metadata for the component scrub_pii: Optional flag to scrub PII from inputs and outputs temperature: Optional temperature setting for the LLM to optimize for creativity or predictability @@ -1752,11 +1753,14 @@ async def converse_alpha1( for inp in inputs ] + # Convert raw Python parameters to GrpcAny objects + converted_parameters = convert_dict_to_grpc_dict_of_any(parameters) + request = api_v1.ConversationRequest( name=name, inputs=inputs_pb, contextID=context_id, - parameters=parameters or {}, + parameters=converted_parameters, metadata=metadata or {}, scrubPII=scrub_pii, temperature=temperature, @@ -1766,11 +1770,110 @@ async def converse_alpha1( response = await self._stub.ConverseAlpha1(request) outputs = [ - ConversationResult(result=output.result, parameters=output.parameters) + conversation.ConversationResultAlpha1( + result=output.result, parameters=output.parameters + ) for output in response.outputs ] - return ConversationResponse(context_id=response.contextID, outputs=outputs) + return conversation.ConversationResponseAlpha1( + context_id=response.contextID, outputs=outputs + ) + + except grpc.aio.AioRpcError as err: + raise DaprGrpcError(err) from err + + async def converse_alpha2( + self, + name: str, + inputs: List[conversation.ConversationInputAlpha2], + *, + context_id: Optional[str] = None, + parameters: Optional[Dict[str, Union[GrpcAny, Any]]] = None, + metadata: Optional[Dict[str, str]] = None, + scrub_pii: Optional[bool] = None, + temperature: Optional[float] = None, + tools: Optional[List[conversation.ConversationTools]] = None, + tool_choice: Optional[str] = None, + ) -> conversation.ConversationResponseAlpha2: + """Invoke an LLM using the conversation API (Alpha2) with tool calling support. + + Args: + name: Name of the LLM component to invoke + inputs: List of Alpha2 conversation inputs with sophisticated message types + context_id: Optional ID for continuing an existing chat + parameters: Optional custom parameters for the request (raw Python values or GrpcAny objects) + metadata: Optional metadata for the component + scrub_pii: Optional flag to scrub PII from inputs and outputs + temperature: Optional temperature setting for the LLM to optimize for creativity or predictability + tools: Optional list of tools available for the LLM to call + tool_choice: Optional control over which tools can be called ('none', 'auto', 'required', or specific tool name) + + Returns: + ConversationResponseAlpha2 containing the conversation results with choices and tool calls + + Raises: + DaprGrpcError: If the Dapr runtime returns an error + """ + + # Convert inputs to proto format + inputs_pb = [] + for inp in inputs: + proto_input = api_v1.ConversationInputAlpha2() + if inp.scrub_pii is not None: + proto_input.scrub_pii = inp.scrub_pii + + for message in inp.messages: + proto_input.messages.append(message.to_proto()) + + inputs_pb.append(proto_input) + + # Convert tools to proto format + tools_pb = [] + if tools: + for tool in tools: + proto_tool = api_v1.ConversationTools() + if tool.function: + proto_tool.function.name = tool.function.name + if tool.function.description: + proto_tool.function.description = tool.function.description + if tool.function.parameters: + proto_tool.function.parameters.CopyFrom( + convert_value_to_struct(tool.function.parameters) + ) + tools_pb.append(proto_tool) + + # Convert raw Python parameters to GrpcAny objects + converted_parameters = convert_dict_to_grpc_dict_of_any(parameters) + + # Build the request + request = api_v1.ConversationRequestAlpha2( + name=name, + inputs=inputs_pb, + parameters=converted_parameters, + metadata=metadata or {}, + tools=tools_pb, + ) + + if context_id is not None: + request.context_id = context_id + if scrub_pii is not None: + request.scrub_pii = scrub_pii + if temperature is not None: + request.temperature = temperature + if tool_choice is not None: + request.tool_choice = tool_choice + + try: + response, call = await self.retry_policy.run_rpc_async( + self._stub.ConverseAlpha2, request + ) + + outputs = conversation._get_outputs_from_grpc_response(response) + + return conversation.ConversationResponseAlpha2( + context_id=response.context_id, outputs=outputs + ) except grpc.aio.AioRpcError as err: raise DaprGrpcError(err) from err diff --git a/dapr/clients/_constants.py b/dapr/clients/_constants.py new file mode 100644 index 00000000..31f6c4db --- /dev/null +++ b/dapr/clients/_constants.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- + +""" +Copyright 2025 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +""" +Internal constants for the Dapr clients package. + +This module contains shared constants that can be imported by various +client modules without creating circular dependencies. +""" + +# Encoding and content type constants +DEFAULT_ENCODING = 'utf-8' +DEFAULT_JSON_CONTENT_TYPE = f'application/json; charset={DEFAULT_ENCODING}' diff --git a/dapr/clients/base.py b/dapr/clients/base.py index dccb9624..d2b97224 100644 --- a/dapr/clients/base.py +++ b/dapr/clients/base.py @@ -17,10 +17,6 @@ from typing import Optional -DEFAULT_ENCODING = 'utf-8' -DEFAULT_JSON_CONTENT_TYPE = f'application/json; charset={DEFAULT_ENCODING}' - - class DaprActorClientBase(ABC): """A base class that represents Dapr Actor Client.""" diff --git a/dapr/clients/grpc/_conversation_helpers.py b/dapr/clients/grpc/_conversation_helpers.py new file mode 100644 index 00000000..37bb81c1 --- /dev/null +++ b/dapr/clients/grpc/_conversation_helpers.py @@ -0,0 +1,1045 @@ +# -*- coding: utf-8 -*- + +""" +Copyright 2025 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import inspect +import random +import string +from dataclasses import fields, is_dataclass +from enum import Enum +from typing import ( + Any, + Callable, + Dict, + List, + Mapping, + Optional, + Sequence, + Union, + Literal, + get_args, + get_origin, + get_type_hints, + cast, +) + +from dapr.conf import settings + +import types + +# Make mypy happy. Runtime handle: real class on 3.10+, else None. +# TODO: Python 3.9 is about to be end-of-life, so we can drop this at some point next year (2026) +UnionType: Any = getattr(types, 'UnionType', None) + +# duplicated from conversation to avoid circular import +Params = Union[Mapping[str, Any], Sequence[Any], None] + +""" +Tool Calling Helpers for Dapr Conversation API. + +This module provides function-to-JSON-schema helpers that automatically +convert typed Python functions to tools for the Conversation API. + +These makes it easy to create tools for the Conversation API without +having to manually define the JSON schema for each tool. +""" + + +def _python_type_to_json_schema(python_type: Any, field_name: str = '') -> Dict[str, Any]: + """Convert a Python type hint to JSON schema format. + + Args: + python_type: The Python type to convert + field_name: The name of the field (for better error messages) + + Returns: + Dict representing the JSON schema for this type + + Examples: + >>> _python_type_to_json_schema(str) + {"type": "string"} + >>> _python_type_to_json_schema(Optional[int]) + {"type": "integer"} + >>> _python_type_to_json_schema(List[str]) + {"type": "array", "items": {"type": "string"}} + """ + # Handle None type + if python_type is type(None): + return {'type': 'null'} + + # Get the origin type for generic types (List, Dict, Union, etc.) + origin = get_origin(python_type) + args = get_args(python_type) + + # Handle Union types (including Optional which is Union[T, None]) + if origin is Union: + # Check if this is Optional[T] (Union[T, None]) + non_none_args = [arg for arg in args if arg is not type(None)] + if len(non_none_args) == 1 and type(None) in args: + # This is Optional[T], convert T + return _python_type_to_json_schema(non_none_args[0], field_name) + else: + # This is a true Union, use anyOf + return {'anyOf': [_python_type_to_json_schema(arg, field_name) for arg in args]} + + # Handle Literal types -> map to enum + if origin is Literal: + # Normalize literal values (convert Enum members to their value) + literal_values: List[Any] = [] + for val in args: + try: + from enum import Enum as _Enum + + if isinstance(val, _Enum): + literal_values.append(val.value) + else: + literal_values.append(val) + except Exception: + literal_values.append(val) + + # Determine JSON Schema primitive types for provided literals + def _json_primitive_type(v: Any) -> str: + if v is None: + return 'null' + if isinstance(v, bool): + return 'boolean' + if isinstance(v, int) and not isinstance(v, bool): + return 'integer' + if isinstance(v, float): + return 'number' + if isinstance(v, (bytes, bytearray)): + return 'string' + if isinstance(v, str): + return 'string' + # Fallback: let enum carry through without explicit type + return 'string' + + types = {_json_primitive_type(v) for v in literal_values} + schema: Dict[str, Any] = {'enum': literal_values} + # If all non-null literals share same type, include it + non_null_types = {t for t in types if t != 'null'} + if len(non_null_types) == 1 and (len(types) == 1 or len(types) == 2 and 'null' in types): + only_type = next(iter(non_null_types)) if non_null_types else 'null' + if only_type == 'string' and any( + isinstance(v, (bytes, bytearray)) for v in literal_values + ): + schema['type'] = 'string' + # Note: bytes literals represented as raw bytes are unusual; keeping enum as-is. + else: + schema['type'] = only_type + elif types == {'null'}: + schema['type'] = 'null' + return schema + + # Handle List types + if origin is list or python_type is list: + if args: + return { + 'type': 'array', + 'items': _python_type_to_json_schema(args[0], f'{field_name}[]'), + } + else: + return {'type': 'array'} + + # Handle Dict types + if origin is dict or python_type is dict: + schema = {'type': 'object'} + if args and len(args) == 2: + # Dict[str, ValueType] - add additionalProperties + key_type, value_type = args + if key_type is str: + schema['additionalProperties'] = _python_type_to_json_schema( + value_type, f'{field_name}.*' + ) + return schema + + # Handle basic types + if python_type is str: + return {'type': 'string'} + elif python_type is int: + return {'type': 'integer'} + elif python_type is float: + return {'type': 'number'} + elif python_type is bool: + return {'type': 'boolean'} + elif python_type is bytes: + return {'type': 'string', 'format': 'byte'} + + # Handle Enum types + if inspect.isclass(python_type) and issubclass(python_type, Enum): + try: + members = list(python_type) + except Exception: + members = [] + count = len(members) + # If enum is small enough, include full enum list (current behavior) + if count <= settings.DAPR_CONVERSATION_TOOLS_MAX_ENUM_ITEMS: + return {'type': 'string', 'enum': [item.value for item in members]} + # Large enum handling + if settings.DAPR_CONVERSATION_TOOLS_LARGE_ENUM_BEHAVIOR == 'error': + raise ValueError( + f"Enum '{getattr(python_type, '__name__', str(python_type))}' has {count} members, " + f"exceeding DAPR_CONVERSATION_MAX_ENUM_ITEMS={settings.DAPR_CONVERSATION_TOOLS_MAX_ENUM_ITEMS}. " + f"Either reduce the enum size or set DAPR_CONVERSATION_LARGE_ENUM_BEHAVIOR=string to allow compact schema." + ) + # Default behavior: compact schema as a string with helpful context and a few examples + example_values = [item.value for item in members[:5]] if members else [] + desc = ( + f"{getattr(python_type, '__name__', 'Enum')} (enum with {count} values). " + f"Provide a valid value. Schema compacted to avoid oversized enum listing." + ) + schema = {'type': 'string', 'description': desc} + if example_values: + schema['examples'] = example_values + return schema + + # Handle Pydantic models (if available) + if hasattr(python_type, 'model_json_schema'): + try: + return python_type.model_json_schema() + except Exception: + pass + elif hasattr(python_type, 'schema'): + try: + return python_type.schema() + except Exception: + pass + + # Handle dataclasses + if is_dataclass(python_type): + from dataclasses import MISSING + + dataclass_schema: Dict[str, Any] = {'type': 'object', 'properties': {}, 'required': []} + + for field in fields(python_type): + field_schema = _python_type_to_json_schema(field.type, field.name) + dataclass_schema['properties'][field.name] = field_schema + + # Check if field has no default (required) - use MISSING for dataclasses + if field.default is MISSING: + dataclass_schema['required'].append(field.name) + + return dataclass_schema + + # Handle plain classes (non-dataclass) using __init__ signature and annotations + if inspect.isclass(python_type) and python_type is not Any: + try: + # Gather type hints from __init__ if available; fall back to class annotations + init = getattr(python_type, '__init__', None) + init_hints = get_type_hints(init) if init else {} + class_hints = get_type_hints(python_type) + except Exception: + init_hints = {} + class_hints = {} + + # Build properties from __init__ parameters (excluding self) + properties: Dict[str, Any] = {} + required: List[str] = [] + + try: + sig = inspect.signature(python_type) + except Exception: + sig = None # type: ignore + + check_slots = True + if sig is not None: + check_slots = False + for pname, param in sig.parameters.items(): + if pname == 'self': + continue + # Determine type for this parameter + ptype = init_hints.get(pname) or class_hints.get(pname) or Any + properties[pname] = _python_type_to_json_schema(ptype, pname) + # Required if no default provided and not VAR_KEYWORD/POSITIONAL + if param.default is inspect._empty and param.kind in ( + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY, + ): + required.append(pname) + else: + check_slots = True + if check_slots: + # Fall back to __slots__ if present + slots = getattr(python_type, '__slots__', None) + if isinstance(slots, (list, tuple)): + for pname in slots: + ptype = class_hints.get(pname, Any) + properties[pname] = _python_type_to_json_schema(ptype, pname) + if not (get_origin(ptype) is Union and type(None) in get_args(ptype)): + required.append(pname) + else: # use class_hints + for pname, ptype in class_hints.items(): + properties[pname] = _python_type_to_json_schema(ptype, pname) + if not (get_origin(ptype) is Union and type(None) in get_args(ptype)): + required.append(pname) + + # If we found nothing, return a generic object + if not properties: + return {'type': 'object'} + + schema = {'type': 'object', 'properties': properties} + if required: + schema['required'] = required + return schema + + # Fallback for unknown/unsupported types + raise TypeError( + f"Unsupported type in JSON schema conversion for field '{field_name}': {python_type}. " + f'Please use supported typing annotations (e.g., str, int, float, bool, bytes, List[T], Dict[str, V], Union, Optional, Literal, Enum, dataclass, or plain classes).' + f'You can report this issue for future support of this type. You can always create the json schema manually.' + ) + + +def _extract_docstring_args(func) -> Dict[str, str]: + """Extract parameter descriptions from function docstring. + + Supports Google-style, NumPy-style, and Sphinx-style docstrings. + + Args: + func: The function to analyze + + Returns: + Dict mapping parameter names to their descriptions + + Raises: + ValueError: If docstring contains parameter info but doesn't match supported formats + """ + docstring = inspect.getdoc(func) + if not docstring: + return {} + + param_descriptions = {} + lines = docstring.split('\n') + + # First, try to extract Sphinx-style parameters (:param name: description) + param_descriptions.update(_extract_sphinx_params(lines)) + + # If no Sphinx-style params found, try Google/NumPy style + if not param_descriptions: + param_descriptions.update(_extract_google_numpy_params(lines)) + + # If still no parameters found, check if docstring might have parameter info + # in an unsupported format + if not param_descriptions and _has_potential_param_info(lines): + func_name = getattr(func, '__name__', 'unknown') + import warnings + + warnings.warn( + f"Function '{func_name}' has a docstring that appears to contain parameter " + f"information, but it doesn't match any supported format (Google, NumPy, or Sphinx style). " + f'Consider reformatting the docstring to use one of the supported styles for ' + f'automatic parameter extraction or create the tool manually.', + UserWarning, + stacklevel=2, + ) + + return param_descriptions + + +def _has_potential_param_info(lines: List[str]) -> bool: + """Check if docstring lines might contain parameter information in unsupported format. + + This is a heuristic to detect when a docstring might have parameter info + but doesn't match our supported formats (Google, NumPy, Sphinx). + """ + text = ' '.join(line.strip().lower() for line in lines) + + # Look for specific parameter documentation patterns that suggest + # an attempt to document parameters in an unsupported format + import re + + # Look for informal parameter descriptions like: + # "The filename parameter should be..." or "parameter_name is used for..." + has_param_descriptions = bool( + re.search(r'the\s+\w+\s+parameter\s+(should|is|controls|specifies)', text) + ) + + # Look for patterns where parameters are mentioned with descriptions + # "filename parameter", "mode parameter", etc. + has_param_mentions = bool( + re.search(r'\w+\s+parameter\s+(should|is|controls|specifies|contains)', text) + ) + + # Look for informal patterns like "takes param1 which is", "param2 is an integer" + has_informal_param_descriptions = bool( + re.search(r'takes\s+\w+\s+which\s+(is|are)', text) + or re.search(r'\w+\s+(is|are)\s+(a|an)\s+\w+\s+(input|argument)', text) + ) + + # Look for multiple parameter mentions suggesting documentation attempt + param_count = text.count(' parameter ') + has_multiple_param_mentions = param_count >= 2 + + # Exclude common phrases that don't indicate parameter documentation attempts + exclude_phrases = [ + 'no parameters', + 'without parameters', + 'parameters documented', + 'parameter information', + 'parameter extraction', + 'function parameter', + 'optional parameter', + 'required parameter', # These are often in general descriptions + ] + has_excluded_phrases = any(phrase in text for phrase in exclude_phrases) + + return ( + has_param_descriptions + or has_param_mentions + or has_informal_param_descriptions + or has_multiple_param_mentions + ) and not has_excluded_phrases + + +def _extract_sphinx_params(lines: List[str]) -> Dict[str, str]: + """Extract parameters from Sphinx-style docstring. + + Looks for patterns like: + :param name: description + :parameter name: description + """ + import re + + param_descriptions = {} + + for original_line in lines: + line = original_line.strip() + + # Match Sphinx-style parameter documentation + # Patterns: :param name: description or :parameter name: description + param_match = re.match(r':param(?:eter)?\s+(\w+)\s*:\s*(.*)', line) + if param_match: + param_name = param_match.group(1) + description = param_match.group(2).strip() + param_descriptions[param_name] = description + continue + + # Handle multi-line descriptions for Sphinx style + # If line is indented and we have existing params, it might be a continuation + if ( + original_line.startswith(' ') or original_line.startswith('\t') + ) and param_descriptions: + # Check if this could be a continuation of the last parameter + last_param = list(param_descriptions.keys())[-1] + # Don't treat section headers or other directive-like content as continuations + # Also don't treat content that looks like parameter definitions from other styles + if ( + param_descriptions[last_param] + and not any( + line.startswith(prefix) for prefix in [':param', ':type', ':return', ':raises'] + ) + and not line.lower().endswith(':') + and not line.lower() in ('args', 'arguments', 'parameters', 'params') + and ':' not in line + ): # Avoid treating "param1: description" as continuation + param_descriptions[last_param] += ' ' + line.strip() + + return param_descriptions + + +def _extract_google_numpy_params(lines: List[str]) -> Dict[str, str]: + """Extract parameters from Google/NumPy-style docstring.""" + param_descriptions = {} + in_args_section = False + current_param = None + + for i, original_line in enumerate(lines): + line = original_line.strip() + + # Detect Args/Parameters section + if line.lower() in ('args:', 'arguments:', 'parameters:', 'params:'): + in_args_section = True + continue + + # Handle NumPy style section headers with dashes + if line.lower() in ('parameters', 'arguments') and in_args_section is False: + in_args_section = True + continue + + # Skip NumPy-style separator lines (dashes) but also check if this signals section end + if in_args_section and line and all(c in '-=' for c in line): + # Check if next line starts a new section + next_line_idx = i + 1 + if next_line_idx < len(lines): + next_line = lines[next_line_idx].strip().lower() + if next_line in ( + 'returns', + 'return', + 'yields', + 'yield', + 'raises', + 'raise', + 'notes', + 'note', + 'examples', + 'example', + ): + in_args_section = False + continue + + # Exit args section on new section + if in_args_section and (line.endswith(':') and not line.startswith(' ')): + in_args_section = False + continue + + # Also exit on direct section headers without separators + if in_args_section and line.lower() in ( + 'returns', + 'return', + 'yields', + 'yield', + 'raises', + 'raise', + 'notes', + 'note', + 'examples', + 'example', + ): + in_args_section = False + continue + + if in_args_section and line: + # Look for parameter definitions (contains colon) + if ':' in line: + parts = line.split(':', 1) + if len(parts) == 2: + param_name = parts[0].strip() + description = parts[1].strip() + + # Handle type annotations like "param_name (type): description" + if '(' in param_name and ')' in param_name: + param_name = param_name.split('(')[0].strip() + # Handle NumPy style "param_name : type" format where description is on next line + if ' ' in param_name: + param_name = param_name.split()[0] + + # Check if this looks like a real description vs just a type annotation + # For NumPy style: "param : type" vs Google style: "param: description" + # Type annotations are usually single words like "str", "int", "float" + # Descriptions have multiple words or punctuation + if description: + if description.replace(' ', '').isalnum() and len(description.split()) == 1: + # Likely just a type annotation (single alphanumeric word), wait for real description + param_descriptions[param_name] = '' + else: + # Contains multiple words or punctuation, likely a real description + param_descriptions[param_name] = description + else: + param_descriptions[param_name] = '' + current_param = param_name + elif ( + current_param + and (original_line.startswith(' ') or original_line.startswith('\t')) + and in_args_section + ): + # Indented continuation line for current parameter (only if still in args section) + if not param_descriptions[current_param]: + # First description line for this parameter (for cases where description is on next line) + param_descriptions[current_param] = line + else: + # Additional description lines + param_descriptions[current_param] += ' ' + line + + return param_descriptions + + +def extract_docstring_summary(func) -> Optional[str]: + """Extract only the summary from a function's docstring. + + Args: + func: The function to extract the summary from + + Returns: + The summary portion of the docstring, or None if no docstring exists + """ + docstring = inspect.getdoc(func) + if not docstring: + return None + + lines = docstring.strip().split('\n') + if not lines: + return None + + # Extract all lines before the first section header + summary_lines = [] + + for line in lines: + line = line.strip() + + # Skip empty lines + if not line: + continue + + # Check if this line starts a Google/NumPy-style section + google_numpy_headers = ( + 'args:', + 'arguments:', + 'parameters:', + 'params:', + 'returns:', + 'return:', + 'yields:', + 'yield:', + 'raises:', + 'raise:', + 'note:', + 'notes:', + 'example:', + 'examples:', + 'see also:', + 'references:', + 'attributes:', + ) + if line.lower().endswith(':') and line.lower() in google_numpy_headers: + break + + # Check if this line starts a Sphinx-style section + # Look for patterns like :param name:, :returns:, :raises:, etc. + import re + + sphinx_pattern = r'^:(?:param|parameter|type|returns?|return|yields?|yield|raises?|raise|note|notes|example|examples|see|seealso|references?|attributes?)(?:\s+\w+)?:' + if re.match(sphinx_pattern, line.lower()): + break + + summary_lines.append(line) + + return ' '.join(summary_lines) if summary_lines else None + + +def function_to_json_schema( + func, name: Optional[str] = None, description: Optional[str] = None +) -> Dict[str, Any]: + """Convert a Python function to a JSON schema for tool calling. + All parameters without default values are set as required. + + Args: + func: The Python function to convert + name: Override the function name (defaults to func.__name__) + description: Override the function description (defaults to first line of docstring) + + Returns: + Complete JSON schema with properties and required fields + + Examples: + >>> def get_weather(location: str, unit: str = "fahrenheit") -> str: + ... '''Get weather for a location. + ... + ... Args: + ... location: The city name + ... unit: Temperature unit (celsius or fahrenheit) + ... ''' + ... pass + >>> schema = function_to_json_schema(get_weather) + >>> schema["properties"]["location"]["type"] + 'string' + """ + # Get function signature and type hints + sig = inspect.signature(func) + type_hints = get_type_hints(func) + + # Extract parameter descriptions from docstring + param_descriptions = _extract_docstring_args(func) + + # Build JSON schema + schema: Dict[str, Any] = {'type': 'object', 'properties': {}, 'required': []} + + for param_name, param in sig.parameters.items(): + # Skip *args and **kwargs + if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD): + continue + + # Get type hint + param_type = type_hints.get(param_name, str) + + # Convert to JSON schema + param_schema = _python_type_to_json_schema(param_type, param_name) + + # Add description if available + if param_name in param_descriptions: + param_schema['description'] = param_descriptions[param_name] + + schema['properties'][param_name] = param_schema + + # Check if parameter is required (no default value) + if param.default is param.empty: + schema['required'].append(param_name) + + return schema + + +def _generate_unique_tool_call_id(): + """Generate a unique ID for a tool call. Mainly used if the LLM provider is not able to generate one itself.""" + return ''.join(random.choices(string.ascii_letters + string.digits, k=9)) + + +def stringify_tool_output(value: Any) -> str: + """Convert arbitrary tool return values into a serializable string. + + Rules: + - If value is already a string, return as-is. + - For bytes/bytearray, return a base64-encoded string with 'base64:' prefix (not JSON). + - Otherwise, attempt to JSON-serialize the value and return the JSON string. + Uses a conservative default encoder that supports only: + * Enum -> enum.value (fallback to name) + * dataclass -> asdict + If JSON serialization still fails, fallback to str(value). If that fails, return ''. + """ + import json as _json + import base64 as _b64 + from dataclasses import asdict as _asdict + + if isinstance(value, str): + return value + + # bytes/bytearray -> base64 string (raw, not JSON-quoted) + if isinstance(value, (bytes, bytearray)): + try: + return 'base64:' + _b64.b64encode(bytes(value)).decode('ascii') + except Exception: + try: + return str(value) + except Exception: + return '' + + def _default(o: Any): + # Enum handling + try: + from enum import Enum as _Enum + + if isinstance(o, _Enum): + try: + return o.value + except Exception: + return getattr(o, 'name', str(o)) + except Exception: + pass + + # dataclass handling + try: + if is_dataclass(o): + # mypy: asdict expects a DataclassInstance; after the runtime guard, this cast is safe + return _asdict(cast(Any, o)) + except Exception: + pass + + # Plain Python objects with __dict__: return a dict filtered for non-callable attributes + try: + d = getattr(o, '__dict__', None) + if isinstance(d, dict): + return {k: v for k, v in d.items() if not callable(v)} + except Exception: + pass + + # Fallback: cause JSON to fail for unsupported types + raise TypeError(f'Object of type {type(o).__name__} is not JSON serializable') + + try: + return _json.dumps(value, default=_default, ensure_ascii=False) + except Exception: + try: + # Last resort: convert to string + return str(value) + except Exception: + return '' + + +# --- Tool Function Executor Backend + +# --- Errors ---- + + +class ToolError(RuntimeError): + ... + + +class ToolNotFoundError(ToolError): + ... + + +class ToolExecutionError(ToolError): + ... + + +class ToolArgumentError(ToolError): + ... + + +def _coerce_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, (int,)): + return bool(value) + if isinstance(value, str): + v = value.strip().lower() + if v in {'true', '1', 'yes', 'y', 'on'}: + return True + if v in {'false', '0', 'no', 'n', 'off'}: + return False + raise ValueError(f'Cannot coerce to bool: {value!r}') + + +def _coerce_scalar(value: Any, expected_type: Any) -> Any: + # Basic scalar coercions + if expected_type is str: + return value if isinstance(value, str) else str(value) + if expected_type is int: + if isinstance(value, bool): # avoid True->1 surprises + return int(value) + if isinstance(value, int): + return value + if isinstance(value, (float,)) and value.is_integer(): + return int(value) + if isinstance(value, str): + return int(value.strip()) + raise ValueError + if expected_type is float: + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + return float(value.strip()) + raise ValueError + if expected_type is bool: + return _coerce_bool(value) + return value + + +def _coerce_enum(value: Any, enum_type: Any) -> Any: + # Accept enum instance, name, or value + if isinstance(value, enum_type): + return value + try: + # match by value + for member in enum_type: + if member.value == value: + return member + if isinstance(value, str): + name = value.strip() + try: + return enum_type[name] + except Exception: + # try case-insensitive + for member in enum_type: + if member.name.lower() == name.lower(): + return member + except Exception: + pass + raise ValueError(f'Cannot coerce {value!r} to {enum_type.__name__}') + + +def _coerce_literal(value: Any, lit_args: List[Any]) -> Any: + # Try exact match first + if value in lit_args: + return value + # Try string-to-number coercions if literal set is homogeneous numeric + try_coerced: List[Any] = [] + for target in lit_args: + try: + if isinstance(target, int) and not isinstance(target, bool) and isinstance(value, str): + try_coerced.append(int(value)) + elif isinstance(target, float) and isinstance(value, str): + try_coerced.append(float(value)) + else: + try_coerced.append(value) + except Exception: + try_coerced.append(value) + for coerced in try_coerced: + if coerced in lit_args: + return coerced + raise ValueError(f'{value!r} not in allowed literals {lit_args!r}') + + +def _is_union(t) -> bool: + origin = get_origin(t) + if origin is Union: + return True + return UnionType is not None and origin is UnionType + + +def _coerce_and_validate(value: Any, expected_type: Any) -> Any: + args = get_args(expected_type) + + if expected_type is Any: + raise TypeError('We cannot handle parameters with type Any') + + # Optional[T] -> Union[T, None] + if _is_union(expected_type): + # try each option + last_err: Optional[Exception] = None + for opt in args: + if opt is type(None): + if value is None: + return None + continue + try: + return _coerce_and_validate(value, opt) + except Exception as e: + last_err = e + continue + raise ValueError( + str(last_err) if last_err else f'Cannot coerce {value!r} to {expected_type}' + ) + + origin = get_origin(expected_type) + + # Literal + if origin is Literal: + return _coerce_literal(value, list(args)) + + # List[T] + if origin is list or expected_type is list: + item_type = args[0] if args else Any + if not isinstance(value, list): + raise ValueError(f'Expected list, got {type(value).__name__}') + return [_coerce_and_validate(v, item_type) for v in value] + + # Dict[K, V] + if origin is dict or expected_type is dict: + key_t = args[0] if len(args) > 0 else Any + val_t = args[1] if len(args) > 1 else Any + if not isinstance(value, dict): + raise ValueError(f'Expected dict, got {type(value).__name__}') + coerced: Dict[Any, Any] = {} + for k, v in value.items(): + ck = _coerce_and_validate(k, key_t) + cv = _coerce_and_validate(v, val_t) + coerced[ck] = cv + return coerced + + # Enums + if inspect.isclass(expected_type) and issubclass(expected_type, Enum): + return _coerce_enum(value, expected_type) + + # Dataclasses + if inspect.isclass(expected_type) and is_dataclass(expected_type): + if isinstance(value, expected_type) or value is None: + return value + raise ValueError( + f'Expected {expected_type.__name__} dataclass instance, got {type(value).__name__}' + ) + + # Plain classes (construct from dict using __init__ where possible) + if inspect.isclass(expected_type): + if isinstance(value, expected_type): + return value + if isinstance(value, dict): + try: + sig = inspect.signature(expected_type) + except Exception as e: + raise ValueError(f'Cannot inspect constructor for {expected_type.__name__}: {e}') + + # type hints from __init__ + try: + init_hints = get_type_hints(getattr(expected_type, '__init__', None)) + except Exception: + init_hints = {} + + kwargs: Dict[str, Any] = {} + missing: List[str] = [] + for pname, param in sig.parameters.items(): + if pname == 'self': + continue + if pname in value: + et = init_hints.get(pname, Any) + kwargs[pname] = _coerce_and_validate(value[pname], et) + else: + if param.default is inspect._empty and param.kind in ( + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY, + ): + missing.append(pname) + if missing: + raise ValueError( + f"Missing required constructor arg(s) for {expected_type.__name__}: {', '.join(missing)}" + ) + try: + return expected_type(**kwargs) + except Exception as e: + raise ValueError(f'Failed constructing {expected_type.__name__} with {kwargs}: {e}') + # Not a dict or instance: fall through to isinstance check + + # Basic primitives + try: + return _coerce_scalar(value, expected_type) + except Exception: + # Fallback to isinstance check + if expected_type is Any or isinstance(value, expected_type): + return value + raise ValueError( + f"Expected {getattr(expected_type, '__name__', str(expected_type))}, got {type(value).__name__}" + ) + + +def bind_params_to_func(fn: Callable[..., Any], params: Params): + """Bind parameters to a function in the correct order. + + Args: + fn: The function to bind parameters to + params: The parameters to bind + + Returns: + The bound parameters + """ + sig = inspect.signature(fn) + if params is None: + bound = sig.bind() + bound.apply_defaults() + return bound + + if isinstance(params, Mapping): + bound = sig.bind_partial(**params) + # missing required parameters + missing = [ + p.name + for p in sig.parameters.values() + if p.default is inspect._empty + and p.kind + in ( + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY, + ) + and p.name not in bound.arguments + ] + if missing: + raise ToolArgumentError(f"Missing required parameter(s): {', '.join(missing)}") + # unexpected kwargs unless **kwargs present + if not any(p.kind is inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()): + extra = set(params) - set(sig.parameters) + if extra: + raise ToolArgumentError(f"Unexpected parameter(s): {', '.join(sorted(extra))}") + elif isinstance(params, Sequence): + bound = sig.bind(*params) + else: + raise ToolArgumentError('params must be a mapping (kwargs), sequence (positional), or None') + + bound.apply_defaults() + + # Coerce and validate according to type hints + try: + type_hints = get_type_hints(fn) + except Exception: + type_hints = {} + for name, value in list(bound.arguments.items()): + if name in type_hints: + expected = type_hints[name] + try: + bound.arguments[name] = _coerce_and_validate(value, expected) + except Exception as e: + raise ToolArgumentError( + f"Invalid value for parameter '{name}': expected {getattr(get_origin(expected) or expected, '__name__', str(expected))}, got {type(value).__name__} ({value!r}). Details: {e}" + ) from e + + return bound diff --git a/dapr/clients/grpc/_helpers.py b/dapr/clients/grpc/_helpers.py index 7da35dc2..c68b0f56 100644 --- a/dapr/clients/grpc/_helpers.py +++ b/dapr/clients/grpc/_helpers.py @@ -12,10 +12,22 @@ See the License for the specific language governing permissions and limitations under the License. """ -from typing import Dict, List, Union, Tuple, Optional from enum import Enum +from typing import Any, Dict, List, Optional, Union, Tuple + + from google.protobuf.any_pb2 import Any as GrpcAny from google.protobuf.message import Message as GrpcMessage +from google.protobuf.wrappers_pb2 import ( + BoolValue, + StringValue, + Int32Value, + Int64Value, + DoubleValue, + BytesValue, +) +from google.protobuf.struct_pb2 import Struct +from google.protobuf import json_format MetadataDict = Dict[str, List[Union[bytes, str]]] MetadataTuple = Tuple[Tuple[str, Union[bytes, str]], ...] @@ -105,3 +117,124 @@ def getWorkflowRuntimeStatus(inputString): return WorkflowRuntimeStatus[inputString].value except KeyError: return WorkflowRuntimeStatus.UNKNOWN + + +def convert_value_to_struct(value: Dict[str, Any]) -> Struct: + """Convert a raw Python value to a protobuf Struct message. + + This function converts Python values to a protobuf Struct, which is designed + to represent JSON-like dynamic data structures. + + Args: + value: Raw Python value (str, int, float, bool, None, dict, list, or already Struct) + + Returns: + Struct: The value converted to a protobuf Struct message + + Raises: + ValueError: If the value type is not supported or cannot be serialized + + Examples: + >>> convert_value_to_struct("hello") # -> Struct with string value + >>> convert_value_to_struct(42) # -> Struct with number value + >>> convert_value_to_struct(True) # -> Struct with bool value + >>> convert_value_to_struct({"key": "value"}) # -> Struct with nested structure + """ + # If it's already a Struct, return as-is (backward compatibility) + if isinstance(value, Struct): + return value + + # raise an error if the value is not a dictionary + if not isinstance(value, dict) and not isinstance(value, bytes): + raise ValueError(f'Value must be a dictionary, got {type(value)}') + + # Convert the value to a JSON-serializable format first + # Handle bytes by converting to base64 string for JSON compatibility + if isinstance(value, bytes): + import base64 + + json_value = base64.b64encode(value).decode('utf-8') + else: + json_value = value + + try: + # For dict values, use ParseDict directly + struct = Struct() + json_format.ParseDict(json_value, struct) + return struct + + except (TypeError, ValueError) as e: + raise ValueError( + f'Unsupported parameter type or value: {type(value)} = {repr(value)}. ' + f'Must be JSON-serializable. Error: {e}' + ) from e + + +def convert_value_to_grpc_any(value: Any) -> GrpcAny: + """Convert a raw Python value to a GrpcAny protobuf message. + This function automatically detects the type of the input value and wraps it + in the appropriate protobuf wrapper type before packing it into GrpcAny. + Args: + value: Raw Python value (str, int, float, bool, bytes, or already GrpcAny) + Returns: + GrpcAny: The value wrapped in a GrpcAny protobuf message + Raises: + ValueError: If the value type is not supported + Examples: + >>> convert_value_to_grpc_any("hello") # -> GrpcAny containing StringValue + >>> convert_value_to_grpc_any(42) # -> GrpcAny containing Int64Value + >>> convert_value_to_grpc_any(3.14) # -> GrpcAny containing DoubleValue + >>> convert_value_to_grpc_any(True) # -> GrpcAny containing BoolValue + """ + # If it's already a GrpcAny, return as-is (backward compatibility) + if isinstance(value, GrpcAny): + return value + + # Create the GrpcAny wrapper + any_pb = GrpcAny() + + # Convert based on Python type + if isinstance(value, bool): + # Note: bool check must come before int since bool is a subclass of int in Python + any_pb.Pack(BoolValue(value=value)) + elif isinstance(value, str): + any_pb.Pack(StringValue(value=value)) + elif isinstance(value, int): + # Use Int64Value to handle larger integers, but Int32Value for smaller ones + if -2147483648 <= value <= 2147483647: + any_pb.Pack(Int32Value(value=value)) + else: + any_pb.Pack(Int64Value(value=value)) + elif isinstance(value, float): + any_pb.Pack(DoubleValue(value=value)) + elif isinstance(value, bytes): + any_pb.Pack(BytesValue(value=value)) + else: + raise ValueError( + f'Unsupported parameter type: {type(value)}. ' + f'Supported types: str, int, float, bool, bytes, GrpcAny' + ) + + return any_pb + + +def convert_dict_to_grpc_dict_of_any(parameters: Optional[Dict[str, Any]]) -> Dict[str, GrpcAny]: + """Convert a dictionary of raw Python values to GrpcAny parameters. + This function takes a dictionary with raw Python values and converts each + value to the appropriate GrpcAny protobuf message for use in Dapr API calls. + Args: + parameters: Optional dictionary of parameter names to raw Python values + Returns: + Dictionary of parameter names to GrpcAny values + Examples: + >>> convert_dict_to_grpc_dict_of_any({"temperature": 0.7, "max_tokens": 1000, "stream": False}) + >>> # Returns: {"temperature": GrpcAny, "max_tokens": GrpcAny, "stream": GrpcAny} + """ + if not parameters: + return {} + + converted = {} + for key, value in parameters.items(): + converted[key] = convert_value_to_grpc_any(value) + + return converted diff --git a/dapr/clients/grpc/_request.py b/dapr/clients/grpc/_request.py index c914a9d5..0ac1ef2f 100644 --- a/dapr/clients/grpc/_request.py +++ b/dapr/clients/grpc/_request.py @@ -15,23 +15,22 @@ import io from enum import Enum -from dataclasses import dataclass from typing import Dict, Optional, Union from google.protobuf.any_pb2 import Any as GrpcAny from google.protobuf.message import Message as GrpcMessage -from dapr.proto import api_v1, common_v1 -from dapr.clients.base import DEFAULT_JSON_CONTENT_TYPE -from dapr.clients.grpc._crypto import EncryptOptions, DecryptOptions +from dapr.clients._constants import DEFAULT_JSON_CONTENT_TYPE +from dapr.clients.grpc._crypto import DecryptOptions, EncryptOptions from dapr.clients.grpc._helpers import ( MetadataDict, MetadataTuple, - tuple_to_dict, to_bytes, to_str, + tuple_to_dict, unpack, ) +from dapr.proto import api_v1, common_v1 class DaprRequest: @@ -428,15 +427,6 @@ def __next__(self): return request_proto -@dataclass -class ConversationInput: - """A single input message for the conversation.""" - - content: str - role: Optional[str] = None - scrub_pii: Optional[bool] = None - - class JobEvent: """Represents a job event received from Dapr runtime. diff --git a/dapr/clients/grpc/_response.py b/dapr/clients/grpc/_response.py index 6d6ee92a..fff511ff 100644 --- a/dapr/clients/grpc/_response.py +++ b/dapr/clients/grpc/_response.py @@ -18,7 +18,6 @@ import contextlib import json import threading -from dataclasses import dataclass, field from datetime import datetime from enum import Enum from typing import ( @@ -40,7 +39,7 @@ from google.protobuf.any_pb2 import Any as GrpcAny from google.protobuf.message import Message as GrpcMessage -from dapr.clients.base import DEFAULT_JSON_CONTENT_TYPE +from dapr.clients._constants import DEFAULT_JSON_CONTENT_TYPE from dapr.clients.grpc._helpers import ( MetadataDict, MetadataTuple, @@ -57,6 +56,7 @@ if TYPE_CHECKING: from dapr.clients.grpc.client import DaprGrpcClient + TCryptoResponse = TypeVar( 'TCryptoResponse', bound=Union[api_v1.EncryptResponse, api_v1.DecryptResponse] ) @@ -1071,19 +1071,3 @@ class EncryptResponse(CryptoResponse[TCryptoResponse]): class DecryptResponse(CryptoResponse[TCryptoResponse]): ... - - -@dataclass -class ConversationResult: - """Result from a single conversation input.""" - - result: str - parameters: Dict[str, GrpcAny] = field(default_factory=dict) - - -@dataclass -class ConversationResponse: - """Response from the conversation API.""" - - context_id: Optional[str] - outputs: List[ConversationResult] diff --git a/dapr/clients/grpc/client.py b/dapr/clients/grpc/client.py index 0e446016..e4ffb264 100644 --- a/dapr/clients/grpc/client.py +++ b/dapr/clients/grpc/client.py @@ -23,6 +23,7 @@ from warnings import warn from typing import Callable, Dict, Optional, Text, Union, Sequence, List, Any + from typing_extensions import Self from datetime import datetime from google.protobuf.message import Message as GrpcMessage @@ -40,7 +41,6 @@ from dapr.clients.exceptions import DaprInternalError, DaprGrpcError from dapr.clients.grpc._state import StateOptions, StateItem -from dapr.clients.grpc._helpers import getWorkflowRuntimeStatus from dapr.clients.grpc._crypto import EncryptOptions, DecryptOptions from dapr.clients.grpc.subscription import Subscription, StreamInactiveError from dapr.clients.grpc.interceptors import DaprClientInterceptor, DaprClientTimeoutInterceptor @@ -53,10 +53,13 @@ from dapr.version import __version__ from dapr.clients.grpc._helpers import ( + getWorkflowRuntimeStatus, MetadataTuple, to_bytes, validateNotNone, validateNotBlankString, + convert_dict_to_grpc_dict_of_any, + convert_value_to_struct, ) from dapr.conf.helpers import GrpcEndpoint from dapr.clients.grpc._request import ( @@ -65,8 +68,8 @@ TransactionalStateOperation, EncryptRequestIterator, DecryptRequestIterator, - ConversationInput, ) +from dapr.clients.grpc import conversation from dapr.clients.grpc._jobs import Job from dapr.clients.grpc._response import ( BindingResponse, @@ -91,8 +94,6 @@ EncryptResponse, DecryptResponse, TopicEventResponse, - ConversationResponse, - ConversationResult, ) @@ -1725,21 +1726,21 @@ def purge_workflow(self, instance_id: str, workflow_component: str) -> DaprRespo def converse_alpha1( self, name: str, - inputs: List[ConversationInput], + inputs: List[conversation.ConversationInput], *, context_id: Optional[str] = None, - parameters: Optional[Dict[str, GrpcAny]] = None, + parameters: Optional[Dict[str, Any]] = None, metadata: Optional[Dict[str, str]] = None, scrub_pii: Optional[bool] = None, temperature: Optional[float] = None, - ) -> ConversationResponse: + ) -> conversation.ConversationResponseAlpha1: """Invoke an LLM using the conversation API (Alpha). Args: name: Name of the LLM component to invoke inputs: List of conversation inputs context_id: Optional ID for continuing an existing chat - parameters: Optional custom parameters for the request + parameters: Optional custom parameters for the request (raw Python values or GrpcAny objects) metadata: Optional metadata for the component scrub_pii: Optional flag to scrub PII from inputs and outputs temperature: Optional temperature setting for the LLM to optimize for creativity or predictability @@ -1756,11 +1757,14 @@ def converse_alpha1( for inp in inputs ] + # Convert raw Python parameters to GrpcAny objects + converted_parameters = convert_dict_to_grpc_dict_of_any(parameters) + request = api_v1.ConversationRequest( name=name, inputs=inputs_pb, contextID=context_id, - parameters=parameters or {}, + parameters=converted_parameters, metadata=metadata or {}, scrubPII=scrub_pii, temperature=temperature, @@ -1770,11 +1774,109 @@ def converse_alpha1( response, call = self.retry_policy.run_rpc(self._stub.ConverseAlpha1.with_call, request) outputs = [ - ConversationResult(result=output.result, parameters=output.parameters) + conversation.ConversationResultAlpha1( + result=output.result, parameters=output.parameters + ) for output in response.outputs ] - return ConversationResponse(context_id=response.contextID, outputs=outputs) + return conversation.ConversationResponseAlpha1( + context_id=response.contextID, outputs=outputs + ) + except RpcError as err: + raise DaprGrpcError(err) from err + + def converse_alpha2( + self, + name: str, + inputs: List[conversation.ConversationInputAlpha2], + *, + context_id: Optional[str] = None, + parameters: Optional[Dict[str, Union[GrpcAny, Any]]] = None, + metadata: Optional[Dict[str, str]] = None, + scrub_pii: Optional[bool] = None, + temperature: Optional[float] = None, + tools: Optional[List[conversation.ConversationTools]] = None, + tool_choice: Optional[str] = None, + ) -> conversation.ConversationResponseAlpha2: + """Invoke an LLM using the conversation API (Alpha2) with tool calling support. + + Args: + name: Name of the LLM component to invoke + inputs: List of Alpha2 conversation inputs with sophisticated message types + context_id: Optional ID for continuing an existing chat + parameters: Optional custom parameters for the request (raw Python values or GrpcAny objects) + metadata: Optional metadata for the component + scrub_pii: Optional flag to scrub PII from inputs and outputs + temperature: Optional temperature setting for the LLM to optimize for creativity or predictability + tools: Optional list of tools available for the LLM to call + tool_choice: Optional control over which tools can be called ('none', 'auto', 'required', or specific tool name) + + Returns: + ConversationResponseAlpha2 containing the conversation results with choices and tool calls + + Raises: + DaprGrpcError: If the Dapr runtime returns an error + """ + + # Convert inputs to proto format + inputs_pb = [] + for inp in inputs: + proto_input = api_v1.ConversationInputAlpha2() + if inp.scrub_pii is not None: + proto_input.scrub_pii = inp.scrub_pii + + for message in inp.messages: + proto_input.messages.append(message.to_proto()) + + inputs_pb.append(proto_input) + + # Convert tools to proto format + tools_pb = [] + if tools: + for tool in tools: + proto_tool = api_v1.ConversationTools() + if tool.function: + proto_tool.function.name = tool.function.name + if tool.function.description: + proto_tool.function.description = tool.function.description + if tool.function.parameters: + # we only keep type, properties and required + proto_tool.function.parameters.CopyFrom( + convert_value_to_struct(tool.function.parameters) + ) + tools_pb.append(proto_tool) + + # Convert raw Python parameters to GrpcAny objects + converted_parameters = convert_dict_to_grpc_dict_of_any(parameters) + + # Build the request + request = api_v1.ConversationRequestAlpha2( + name=name, + inputs=inputs_pb, + parameters=converted_parameters, + metadata=metadata or {}, + tools=tools_pb, + ) + + if context_id is not None: + request.context_id = context_id + if scrub_pii is not None: + request.scrub_pii = scrub_pii + if temperature is not None: + request.temperature = temperature + if tool_choice is not None: + request.tool_choice = tool_choice + + try: + response, call = self.retry_policy.run_rpc(self._stub.ConverseAlpha2.with_call, request) + + # Convert response to our format + outputs = conversation._get_outputs_from_grpc_response(response) + + return conversation.ConversationResponseAlpha2( + context_id=response.context_id, outputs=outputs + ) except RpcError as err: raise DaprGrpcError(err) from err diff --git a/dapr/clients/grpc/conversation.py b/dapr/clients/grpc/conversation.py new file mode 100644 index 00000000..1da02dac --- /dev/null +++ b/dapr/clients/grpc/conversation.py @@ -0,0 +1,662 @@ +# -*- coding: utf-8 -*- + +""" +Copyright 2025 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" +from __future__ import annotations + +import asyncio +import inspect +import json +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Mapping, Optional, Protocol, Sequence, Union, cast + +from google.protobuf.any_pb2 import Any as GrpcAny + +from dapr.clients.grpc import _conversation_helpers as conv_helpers +from dapr.clients.grpc._conversation_helpers import _generate_unique_tool_call_id +from dapr.proto import api_v1 + +Params = Union[Mapping[str, Any], Sequence[Any], None] + +# ------------------------------------------------------------------------------------------------ +# Request Classes +# ------------------------------------------------------------------------------------------------ + + +@dataclass +class ConversationInput: + """A single input message for the conversation.""" + + content: str + role: Optional[str] = None + scrub_pii: Optional[bool] = None + + +@dataclass +class ConversationMessageContent: + """Content for conversation messages.""" + + text: str + + +def _indent_lines(title: str, text: str, indent: int) -> str: + """ + Indent lines of text. + Example: + >>> print("foo") + foo + >>> print(_indent_lines("Description", "This is a long\nmultiline\ntext block", 4)) + Description: This is a long + multiline + text block + """ + indent_after_first_line = indent + len(title) + 2 + lines = text.splitlines() if text is not None else [''] + first = lines[0] if lines else '' + rest = '' + for line in lines[1:]: + rest += f'\n{indent_after_first_line * " "}{line}' + return f'{indent * " "}{title}: {first}{rest}' + + +class HasNameAndContent: + """Mixin Protocol for name and content typing.""" + + name: Optional[str] = None + content: List[ConversationMessageContent] = field(default_factory=list) + + +class UserTracePrintMixin(HasNameAndContent): + """Mixin for trace_print for text based message content from user to LLM.""" + + def trace_print(self, indent: int = 0) -> None: + base = ' ' * indent + if self.name: + print(f'{base}name: {self.name}') + for i, c in enumerate(self.content): + print(_indent_lines(f'content[{i}]', c.text, indent)) + + +@dataclass +class ConversationMessageOfDeveloper(UserTracePrintMixin): + """Developer message content.""" + + name: Optional[str] = None + content: List[ConversationMessageContent] = field(default_factory=list) + + +@dataclass +class ConversationMessageOfSystem(UserTracePrintMixin): + """System message content.""" + + name: Optional[str] = None + content: List[ConversationMessageContent] = field(default_factory=list) + + +@dataclass +class ConversationMessageOfUser(UserTracePrintMixin): + """User message content.""" + + name: Optional[str] = None + content: List[ConversationMessageContent] = field(default_factory=list) + + +@dataclass +class ConversationToolCallsOfFunction: + """Function call details within a tool call.""" + + name: str + arguments: str + + +@dataclass +class ConversationToolCalls: + """Tool calls generated by the model.""" + + id: Optional[str] = None + function: Optional[ConversationToolCallsOfFunction] = None + + +@dataclass +class ConversationMessageOfAssistant: + """Assistant message content.""" + + name: Optional[str] = None + content: List[ConversationMessageContent] = field(default_factory=list) + tool_calls: List[ConversationToolCalls] = field(default_factory=list) + + def trace_print(self, indent: int = 0) -> None: + base = ' ' * indent + if self.name: + print(f'{base}name: {self.name}') + for i, c in enumerate(self.content): + print(_indent_lines(f'content[{i}]', c.text, indent)) + if self.tool_calls: + print(f'{base}tool_calls: {len(self.tool_calls)}') + for idx, tc in enumerate(self.tool_calls): + tc_id = tc.id or '' + fn = tc.function.name if tc.function else '' + args = tc.function.arguments if tc.function else '' + print(f'{base} [{idx}] id={tc_id} function={fn}({args})') + + +@dataclass +class ConversationMessageOfTool: + """Tool message content.""" + + tool_id: Optional[str] = None + name: str = '' + content: List[ConversationMessageContent] = field(default_factory=list) + + def trace_print(self, indent: int = 0) -> None: + base = ' ' * indent + if self.tool_id: + print(f'{base}tool_id: {self.tool_id}') + if self.name: + print(f'{base}name: {self.name}') + for i, c in enumerate(self.content): + lines = c.text.splitlines() if c.text is not None else [''] + first = lines[0] if lines else '' + print(f'{base}content[{i}]: {first}') + for extra in lines[1:]: + print(extra) + + +@dataclass +class ConversationMessage: + """Conversation message with different role types.""" + + of_developer: Optional[ConversationMessageOfDeveloper] = None + of_system: Optional[ConversationMessageOfSystem] = None + of_user: Optional[ConversationMessageOfUser] = None + of_assistant: Optional[ConversationMessageOfAssistant] = None + of_tool: Optional[ConversationMessageOfTool] = None + + def trace_print(self, indent: int = 0): + print() + """Print the conversation message with indentation and direction arrows.""" + if self.of_developer: + print(f'{" " * indent}client[devel] --------------> LLM[assistant]:') + self.of_developer.trace_print(indent + 2) + if self.of_system: + print(f'{" " * indent}client[system] --------------> LLM[assistant]:') + self.of_system.trace_print(indent + 2) + if self.of_user: + print(f'{" " * indent}client[user] --------------> LLM[assistant]:') + self.of_user.trace_print(indent + 2) + if self.of_assistant: + print(f'{" " * indent}client <------------- LLM[assistant]:') + self.of_assistant.trace_print(indent + 2) + if self.of_tool: + print(f'{" " * indent}client[tool] -------------> LLM[assistant]:') + self.of_tool.trace_print(indent + 2) + + def to_proto(self) -> api_v1.ConversationMessage: + """Convert a conversation message to proto format.""" + + def _convert_message_content_to_proto( + content_list: List[ConversationMessageContent], + ): + """Convert message content list to proto format.""" + if not content_list: + return [] + return [ + api_v1.ConversationMessageContent(text=content.text) for content in content_list + ] + + def _convert_tool_calls_to_proto(tool_calls: List[ConversationToolCalls]): + """Convert tool calls to proto format.""" + if not tool_calls: + return [] + proto_calls = [] + for call in tool_calls: + proto_call = api_v1.ConversationToolCalls() + if call.id: + proto_call.id = call.id + if call.function: + proto_call.function.name = call.function.name + proto_call.function.arguments = call.function.arguments + proto_calls.append(proto_call) + return proto_calls + + proto_message = api_v1.ConversationMessage() + + if self.of_developer: + proto_message.of_developer.name = self.of_developer.name or '' + proto_message.of_developer.content.extend( + _convert_message_content_to_proto(self.of_developer.content or []) + ) + elif self.of_system: + proto_message.of_system.name = self.of_system.name or '' + proto_message.of_system.content.extend( + _convert_message_content_to_proto(self.of_system.content or []) + ) + elif self.of_user: + proto_message.of_user.name = self.of_user.name or '' + proto_message.of_user.content.extend( + _convert_message_content_to_proto(self.of_user.content or []) + ) + elif self.of_assistant: + proto_message.of_assistant.name = self.of_assistant.name or '' + proto_message.of_assistant.content.extend( + _convert_message_content_to_proto(self.of_assistant.content or []) + ) + proto_message.of_assistant.tool_calls.extend( + _convert_tool_calls_to_proto(self.of_assistant.tool_calls or []) + ) + elif self.of_tool: + if self.of_tool.tool_id: + proto_message.of_tool.tool_id = self.of_tool.tool_id + proto_message.of_tool.name = self.of_tool.name + proto_message.of_tool.content.extend( + _convert_message_content_to_proto(self.of_tool.content or []) + ) + + return proto_message + + +@dataclass +class ConversationInputAlpha2: + """Alpha2 input message for conversation API.""" + + messages: List[ConversationMessage] + scrub_pii: Optional[bool] = None + + +@dataclass +class ConversationToolsFunction: + """Function definition for conversation tools.""" + + name: str + description: Optional[str] = None + parameters: Optional[Dict] = None + + def schema_as_dict(self) -> Dict: + """Return the function's schema as a dictionary. + + Returns: + Dict: The JSON schema for the function parameters. + """ + return self.parameters or {} + + @classmethod + def from_function(cls, func: Callable, register: bool = True) -> 'ConversationToolsFunction': + """Create a ConversationToolsFunction from a function. + + Args: + func: The function to extract the schema from. + register: Whether to register the function in the tool registry. + """ + c = cls( + name=func.__name__, + description=conv_helpers.extract_docstring_summary(func), + parameters=conv_helpers.function_to_json_schema(func), + ) + if register: + register_tool(c.name, ConversationTools(function=c, backend=FunctionBackend(func))) + return c + + +# ------------------------------------------------------------------------------------------------ +# Response Classes +# ------------------------------------------------------------------------------------------------ + + +@dataclass +class ConversationResultAlpha1: + """One of the outputs to a request to the conversation API.""" + + result: str + parameters: Dict[str, GrpcAny] = field(default_factory=dict) + + +@dataclass +class ConversationResultAlpha2Message: + """Message content in one conversation result choice.""" + + content: str + tool_calls: List[ConversationToolCalls] = field(default_factory=list) + + +@dataclass +class ConversationResultAlpha2Choices: + """Choice in one Alpha2 conversation result output.""" + + finish_reason: str + index: int + message: ConversationResultAlpha2Message + + +@dataclass +class ConversationResultAlpha2: + """One of the outputs in Alpha2 response from conversation input.""" + + choices: List[ConversationResultAlpha2Choices] = field(default_factory=list) + + +@dataclass +class ConversationResponseAlpha1: + """Response to a request from the conversation API.""" + + context_id: Optional[str] + outputs: List[ConversationResultAlpha1] + + +@dataclass +class ConversationResponseAlpha2: + """Alpha2 response to a request from the conversation API.""" + + context_id: Optional[str] + outputs: List[ConversationResultAlpha2] + + def to_assistant_messages(self) -> List[ConversationMessage]: + """Helper to convert to Assistant messages and makes it easy to use in multi-turn conversations.""" + + def convert_llm_response_to_conversation_input( + result_message: ConversationResultAlpha2Message, + ) -> ConversationMessage: + """Convert ConversationResultMessage (from LLM response) to ConversationMessage.""" + + # Convert content string to ConversationMessageContent list + content = [] + if result_message.content: + content = [ConversationMessageContent(text=(result_message.content))] + + # Convert tool_calls if present (they're already the right type) + tool_calls = result_message.tool_calls or [] + + # Create an assistant message (since LLM responses are always assistant messages) + return ConversationMessage( + of_assistant=ConversationMessageOfAssistant(content=content, tool_calls=tool_calls) + ) + + """Convert the outputs to a list of ConversationInput.""" + assistant_messages = [] + + for output in self.outputs or []: + for choice in output.choices or []: + # Convert and collect all assistant messages + assistant_message = convert_llm_response_to_conversation_input(choice.message) + assistant_messages.append(assistant_message) + + return assistant_messages + + +# ------------------------------------------------------------------------------------------------ +# Tool Helpers +# ------------------------------------------------------------------------------------------------ + + +class ToolBackend(Protocol): + """Interface for executors that knows how to execute a tool call.""" + + def invoke(self, spec: ConversationToolsFunction, params: Params) -> Any: + ... + + async def ainvoke( + self, spec: ConversationToolsFunction, params: Params, *, timeout: Union[float, None] = None + ) -> Any: + ... + + +@dataclass +class FunctionBackend: + """A backend that executes a local function.""" + + func: Callable[..., Any] = field(repr=False) + + def invoke(self, spec: ConversationToolsFunction, params: Params) -> Any: + bound = conv_helpers.bind_params_to_func(self.func, params) + if inspect.iscoroutinefunction(self.func): + raise conv_helpers.ToolExecutionError( + "This tool is async; use 'await tool.ainvoke(...)'." + ) + try: + return self.func(*bound.args, **bound.kwargs) + except Exception as e: + raise conv_helpers.ToolExecutionError(f'Tool raised: {e}') from e + + async def ainvoke( + self, spec: ConversationToolsFunction, params: Params, *, timeout: Union[float, None] = None + ) -> Any: + bound = conv_helpers.bind_params_to_func(self.func, params) + try: + if inspect.iscoroutinefunction(self.func): + coro = self.func(*bound.args, **bound.kwargs) + return await (asyncio.wait_for(coro, timeout) if timeout else coro) + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, lambda: self.func(*bound.args, **bound.kwargs)) + except asyncio.TimeoutError as err: + raise conv_helpers.ToolExecutionError(f'Timed out after {timeout} seconds') from err + except Exception as e: + raise conv_helpers.ToolExecutionError(f'Tool raised: {e}') from e + + +def tool( + func: Optional[Callable] = None, + *, + name: Optional[str] = None, + description: Optional[str] = None, + namespace: Optional[str] = None, + register: bool = True, +): + """ + Decorate a callable as a conversation tool. + + Security note: + - Register only trusted functions. Tool calls may be triggered from LLM outputs and receive + untrusted parameters. + - Use precise type annotations and docstrings for your function; we derive a JSON schema used by + the binder to coerce types and reject unexpected/invalid arguments. + - Add your own guardrails if the tool can perform side effects (filesystem, network, subprocess). + - You can set register=False and call register_tool later to control registration explicitly. + """ + + def _decorate(f: Callable): + ctf = ConversationToolsFunction.from_function(f, register=False) + + # Prefix name with namespace/module if not provided explicitly + ns = namespace or '' + if ns: + ns += '.' + ctf.name = name or f'{ns}{ctf.name}' + + if description: + ctf.description = description + + ct = ConversationTools(function=ctf, backend=FunctionBackend(f)) + + # Store the tool in the function for later retrieval (mypy-safe without setattr) + cast(Any, f).__dapr_conversation_tool__ = ct + + if register: + register_tool(ctf.name, ct) + + return f + + return _decorate if func is None else _decorate(func) + + +@dataclass +class ConversationTools: + """Tools available for conversation. + + Notes on safety and validation: + - Tools execute arbitrary Python callables. Register only trusted functions and be mindful of + side effects (filesystem, network, subprocesses). + - Parameters provided by an LLM are untrusted. The invocation path uses bind_params_to_func to + coerce types based on your function annotations and to reject unexpected/invalid arguments. + - Consider adding your own validation/guardrails in your tool implementation. + """ + + # currently only function is supported + function: ConversationToolsFunction + backend: Optional[ToolBackend] = None + + def invoke(self, params: Params = None) -> Any: + """Execute the tool with params (synchronous). + + params may be: + - Mapping[str, Any]: passed as keyword arguments + - Sequence[Any]: passed as positional arguments + - None: no arguments + Detailed validation and coercion are performed by the backend via bind_params_to_func. + """ + if not self.backend: + raise conv_helpers.ToolExecutionError('Tool backend not set') + return self.backend.invoke(self.function, params) + + async def ainvoke(self, params: Params = None, *, timeout: Union[float, None] = None) -> Any: + """Execute the tool asynchronously. See invoke() for parameter shape and safety notes.""" + if not self.backend: + raise conv_helpers.ToolExecutionError('Tool backend not set') + return await self.backend.ainvoke(self.function, params, timeout=timeout) + + +# registry of tools +_TOOL_REGISTRY: Dict[str, ConversationTools] = {} + + +def register_tool(name: str, t: ConversationTools): + if name in _TOOL_REGISTRY: + raise ValueError(f"Tool '{name}' already registered") + _TOOL_REGISTRY[name] = t + + +def unregister_tool(name: str): + """Unregister a tool. Good for cleanup and avoid collisions.""" + if name in _TOOL_REGISTRY: + del _TOOL_REGISTRY[name] + + +def get_registered_tools() -> List[ConversationTools]: + """Get a list of all registered tools. This can be pass as tools in the ConversationInput.""" + return list(_TOOL_REGISTRY.values()) + + +def _get_tool(name: str) -> ConversationTools: + try: + return _TOOL_REGISTRY[name] + except KeyError as err: + raise conv_helpers.ToolNotFoundError(f"Tool '{name}' is not registered") from err + + +def execute_registered_tool(name: str, params: Union[Params, str] = None) -> Any: + """Execute a registered tool. + + Security considerations: + - A registered tool typically executes user-defined code (or code imported from libraries). Only + register and execute tools you trust. Treat model-provided params as untrusted input. + - Prefer defining a JSON schema for your tool function parameters (ConversationToolsFunction + is created from your function’s signature and annotations). The internal binder performs + type coercion and rejects unexpected/invalid arguments. + - Add your own guardrails if the tool can perform side effects (filesystem, network, subprocess, etc.). + """ + if isinstance(params, str): + params = json.loads(params) + # Minimal upfront shape check; detailed validation happens in bind_params_to_func + if params is not None and not isinstance(params, (Mapping, Sequence)): + raise conv_helpers.ToolArgumentError( + 'params must be a mapping (kwargs), a sequence (args), or None' + ) + return _get_tool(name).invoke(params) + + +async def execute_registered_tool_async( + name: str, params: Union[Params, str] = None, *, timeout: Union[float, None] = None +) -> Any: + """Execute a registered tool asynchronously. + + Security considerations: + - Only execute trusted tools; treat model-provided params as untrusted input. + - Prefer well-typed function signatures and schemas for parameter validation. The binder will + coerce and validate, rejecting unexpected arguments. + - For async tools, consider timeouts and guardrails to limit side effects. + """ + if isinstance(params, str): + params = json.loads(params) + if params is not None and not isinstance(params, (Mapping, Sequence)): + raise conv_helpers.ToolArgumentError( + 'params must be a mapping (kwargs), a sequence (args), or None' + ) + return await _get_tool(name).ainvoke(params, timeout=timeout) + + +# ------------------------------------------------------------------------------------------------ +# Helpers to create messages for Alpha2 inputs +# ------------------------------------------------------------------------------------------------ + + +def create_user_message(text: str) -> ConversationMessage: + """Helper to create a user message for Alpha2.""" + return ConversationMessage( + of_user=ConversationMessageOfUser(content=[ConversationMessageContent(text=text)]) + ) + + +def create_system_message(text: str) -> ConversationMessage: + """Helper to create a system message for Alpha2.""" + return ConversationMessage( + of_system=ConversationMessageOfSystem(content=[ConversationMessageContent(text=text)]) + ) + + +def create_assistant_message(text: str) -> ConversationMessage: + """Helper to create an assistant message for Alpha2.""" + return ConversationMessage( + of_assistant=ConversationMessageOfAssistant(content=[ConversationMessageContent(text=text)]) + ) + + +def create_tool_message(tool_id: str, name: str, content: Any) -> ConversationMessage: + """Helper to create a tool message for Alpha2 responses (from client to LLM).""" + content = conv_helpers.stringify_tool_output(content) + return ConversationMessage( + of_tool=ConversationMessageOfTool( + tool_id=tool_id, name=name, content=[ConversationMessageContent(text=content)] + ) + ) + + +def _get_outputs_from_grpc_response( + response: api_v1.ConversationResponseAlpha2, +) -> List[ConversationResultAlpha2]: + """Helper to get outputs from a Converse gRPC response from dapr sidecar.""" + outputs: List[ConversationResultAlpha2] = [] + for output in response.outputs: + choices = [] + for choice in output.choices: + # Convert tool calls from response + tool_calls = [] + for tool_call in choice.message.tool_calls: + function_call = ConversationToolCallsOfFunction( + name=tool_call.function.name, arguments=tool_call.function.arguments + ) + if not tool_call.id: + tool_call.id = _generate_unique_tool_call_id() + tool_calls.append(ConversationToolCalls(id=tool_call.id, function=function_call)) + + result_message = ConversationResultAlpha2Message( + content=choice.message.content, tool_calls=tool_calls + ) + + choices.append( + ConversationResultAlpha2Choices( + finish_reason=choice.finish_reason, + index=choice.index, + message=result_message, + ) + ) + + outputs.append(ConversationResultAlpha2(choices=choices)) + return outputs diff --git a/dapr/clients/http/client.py b/dapr/clients/http/client.py index 5944e278..86e9ab6f 100644 --- a/dapr/clients/http/client.py +++ b/dapr/clients/http/client.py @@ -30,7 +30,7 @@ from dapr.serializers import Serializer from dapr.conf import settings -from dapr.clients.base import DEFAULT_JSON_CONTENT_TYPE +from dapr.clients._constants import DEFAULT_JSON_CONTENT_TYPE from dapr.clients.exceptions import DaprHttpError, DaprInternalError diff --git a/dapr/conf/global_settings.py b/dapr/conf/global_settings.py index 43bb51f6..5a64e5d4 100644 --- a/dapr/conf/global_settings.py +++ b/dapr/conf/global_settings.py @@ -33,3 +33,11 @@ DAPR_API_METHOD_INVOCATION_PROTOCOL = 'http' DAPR_HTTP_TIMEOUT_SECONDS = 60 + +# ----- Conversation API settings ------ + +# Configuration for handling large enums to avoid massive JSON schemas that can exceed LLM token limits +DAPR_CONVERSATION_TOOLS_MAX_ENUM_ITEMS = 100 +# What to do when an enum has more than DAPR_CONVERSATION_TOOLS_MAX_ENUM_ITEMS items. Convert to String message or raise an exception +# possible values: 'string' (default), 'error' +DAPR_CONVERSATION_TOOLS_LARGE_ENUM_BEHAVIOR = 'string' diff --git a/daprdocs/content/en/python-sdk-docs/python-client.md b/daprdocs/content/en/python-sdk-docs/python-client.md index b2689971..f03a6a74 100644 --- a/daprdocs/content/en/python-sdk-docs/python-client.md +++ b/daprdocs/content/en/python-sdk-docs/python-client.md @@ -441,7 +441,7 @@ Since version 1.15 Dapr offers developers the capability to securely and reliabl ```python from dapr.clients import DaprClient -from dapr.clients.grpc._request import ConversationInput +from dapr.clients.grpc.conversation import ConversationInput with DaprClient() as d: inputs = [ diff --git a/dev-requirements.txt b/dev-requirements.txt index cec56fb2..cbd71985 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -16,3 +16,9 @@ Flask>=1.1 ruff===0.2.2 # needed for dapr-ext-workflow durabletask-dapr >= 0.2.0a7 +# needed for .env file loading in examples +python-dotenv>=1.0.0 +# needed for enhanced schema generation from function features +pydantic>=2.0.0 +# needed for yaml file generation in examples +PyYAML>=6.0.2 diff --git a/examples/conversation/.env.example b/examples/conversation/.env.example new file mode 100644 index 00000000..97956ab0 --- /dev/null +++ b/examples/conversation/.env.example @@ -0,0 +1,20 @@ +# LLM Provider API Keys +# Add your API keys for the providers you want to test + +# OpenAI +OPENAI_API_KEY=your_openai_api_key_here + +# Anthropic +ANTHROPIC_API_KEY=your_anthropic_api_key_here + +# Mistral AI +MISTRAL_API_KEY=your_mistral_api_key_here + +# DeepSeek +DEEPSEEK_API_KEY=your_deepseek_api_key_here + +# Google AI (Gemini/Vertex) +GOOGLE_API_KEY=your_google_api_key_here + +# Optional: Default component to use if not specified +DAPR_LLM_COMPONENT_DEFAULT=openai diff --git a/examples/conversation/README.md b/examples/conversation/README.md index c793dd4b..1d7789b3 100644 --- a/examples/conversation/README.md +++ b/examples/conversation/README.md @@ -1,34 +1,579 @@ -# Example - Conversation API +# Dapr Python SDK - Conversation API Examples -## Step +This directory contains examples demonstrating how to use the Dapr Conversation API with the Python SDK, including real LLM provider integrations and advanced Alpha2 features. -### Prepare +## Real LLM Providers Support -- Dapr installed +The Conversation API supports real LLM providers including: -### Run Conversation Example +- **OpenAI** (GPT-4o-mini, GPT-4, etc.) +- **Anthropic** (Claude Sonnet 4, Claude Haiku, etc.) +- **Mistral** (Mistral Large, etc.) +- **DeepSeek** (DeepSeek V3, etc.) +- **Google AI** (Gemini 2.5 Flash, etc.) - +### Environment Setup -```bash -dapr run --app-id conversation \ - --log-level debug \ - --resources-path ./config \ - -- python3 conversation.py +1. **Install dependencies:** + ```bash + pip install python-dotenv # For .env file support + ``` + +2. **Run the simple conversation on the Alpha V1 version (dapr 1.15)** + + This is a basic example that uses the Conversation API to get a response from a bot. + It also uses the `echo` provider that just echoes back the message. + In the echo provider, a multi-input message is returned as a single output separated by newlines. + + + + ```bash + dapr run --app-id conversation-alpha1 \ + --log-level debug \ + --resources-path ./config \ + -- python3 conversation_alpha1.py + ``` + + + +3. **Run the simple conversation on the Alpha V2 version (dapr 1.16)** + + + ```bash + dapr run --app-id conversation-alpha2 \ + --log-level debug \ + --resources-path ./config \ + -- python3 conversation_alpha2.py + ``` + + + +4. **Run the comprehensive example with real LLM providers (This requires LLM API Keys)** + + You need to have at least one of the following LLM providers API keys: + + - OpenAI + - Anthropic + - Mistral + - Deepseek + - Google AI + + **Create .env file:** + + We use the python-dotenv package to load environment variables from a .env file, so we need to create one first. + If you don't have an .env file, you can copy the .env.example file and rename it to .env: + ```bash + cp .env.example .env + ``` + + **Add your API keys to .env:** + + Open the .env file and add your API keys for the providers you want to use. For example: + ```bash + OPENAI_API_KEY=your_openai_key_here + ANTHROPIC_API_KEY=your_anthropic_key_here + MISTRAL_API_KEY=your_mistral_key_here + DEEPSEEK_API_KEY=your_deepseek_key_here + GOOGLE_API_KEY=your_google_ai_key_here + ``` + Run the example: + + ```bash + python examples/conversation/real_llm_providers_example.py + ``` + + Depending on what API key you have, this will run and print the result of each test function in the example file. + + Before running the example, you need to start the Dapr sidecar with the component configurations as shown below in our run output. + Here we have a temporary directory with the component configurations with the API keys setup in the .env file. + + ```bash + dapr run --app-id test-app --dapr-http-port 3500 --dapr-grpc-port 50001 --resources-path + ``` + + For example if we have openai, anthropic, mistral, deepseek and google ai, we will have a temporary directory with the component configurations for each provider: + + The example will run and print the result of each test function in the example file. + ```bash + 🚀 Real LLM Providers Example for Dapr Conversation API Alpha2 + ============================================================ + 📁 Loaded environment from /Users/filinto/diagrid/python-sdk/examples/conversation/.env + + 🔍 Detecting available LLM providers... + + ✅ Found 5 configured provider(s) + 📝 Created component: /var/folders/3t/b6jkjnv970l6dd1sp81b19hw0000gn/T/dapr-llm-components-9mcpb1a3/openai.yaml + 📝 Created component: /var/folders/3t/b6jkjnv970l6dd1sp81b19hw0000gn/T/dapr-llm-components-9mcpb1a3/anthropic.yaml + 📝 Created component: /var/folders/3t/b6jkjnv970l6dd1sp81b19hw0000gn/T/dapr-llm-components-9mcpb1a3/mistral.yaml + 📝 Created component: /var/folders/3t/b6jkjnv970l6dd1sp81b19hw0000gn/T/dapr-llm-components-9mcpb1a3/deepseek.yaml + 📝 Created component: /var/folders/3t/b6jkjnv970l6dd1sp81b19hw0000gn/T/dapr-llm-components-9mcpb1a3/google.yaml + + ⚠️ IMPORTANT: Make sure Dapr sidecar is running with components from: + /var/folders/3t/b6jkjnv970l6dd1sp81b19hw0000gn/T/dapr-llm-components-9mcpb1a3 + + To start the sidecar with these components: + dapr run --app-id test-app --dapr-http-port 3500 --dapr-grpc-port 50001 --resources-path /var/folders/3t/b6jkjnv970l6dd1sp81b19hw0000gn/T/dapr-llm-components-9mcpb1a3 + + Press Enter when Dapr sidecar is running with the component configurations... + ``` + + At this point, you can press Enter to continue if you have the Dapr sidecar running with the component configurations. + +## Alpha2 API Features + +The Alpha2 API introduces sophisticated features: + +- **Advanced Message Types**: user, system, assistant, developer, tool messages +- **Automatic Parameter Conversion**: Raw Python values → GrpcAny +- **Tool Calling**: Function calling with JSON schema definition +- **Function-to-Schema**: Ultimate DevEx for tool creation +- **Multi-turn Conversations**: Context accumulation across turns +- **Async Support**: Full async/await implementation + +## Current Limitations + +- **Streaming**: Response streaming is not yet supported in Alpha2. All responses are returned as complete messages. + +## Tool Creation (Alpha2) + +Recommended order of approaches: +- Decorator-based definition (best ergonomics) +- Function-to-Schema (automatic schema from typed function) +- JSON schema variants (fallbacks for dynamic/manual cases) + +When using the Decorator or Function-to-Schema approach, you get the following benefits: + +- ✅ **Type Safety**: Full Python type hint support (str, int, List, Optional, Enum, etc.) +- ✅ **Auto-Documentation**: Docstring parsing for parameter descriptions +- ✅ **Ultimate DevEx**: Define functions, get tools automatically +- ✅ **90%+ less boilerplate** compared to manual schema creation +- ✅ **Automatic Tool Registration** this comes handy when you want to execute the tool when called by the LLM + + +### Decorator-based Tool Definition (Recommended) +```python +from dapr.clients.grpc import conversation + +@conversation.tool +def get_weather(location: str, unit: str = 'fahrenheit') -> str: + """Get current weather for a location.""" + # Implementation or placeholder + return f"Weather in {location} (unit={unit})" + +# Tools registered via @conversation.tool can be retrieved with: +tools = conversation.get_registered_tools() +``` + +### Function-to-Schema (from_function) + +Automatically generate the tool schema from a typed Python function. + +```python +from enum import Enum +from dapr.clients.grpc import conversation + + +class Units(Enum): + CELSIUS = 'celsius' + FAHRENHEIT = 'fahrenheit' + + +def get_weather(location: str, unit: Units = Units.FAHRENHEIT) -> str: + """Get current weather for a location.""" + return f"Weather in {location}" + + +# Use the from_function class method for automatic schema generation +function = conversation.ConversationToolsFunction.from_function(get_weather) +weather_tool = conversation.ConversationTools(function=function) +``` + +### JSON Schema Variants (fallbacks) + +Use when you can't decorate or need to build tools dynamically. + +#### Complete JSON Schema (e.g., calculator) +```python +from dapr.clients.grpc import conversation + +function = conversation.ConversationToolsFunction( + name="calculate", + description="Perform calculations", + parameters={ + "type": "object", + "properties": { + "expression": {"type": "string", "description": "Math expression"} + }, + "required": ["expression"] + } +) +calc_tool = conversation.ConversationTools(function=function) +``` + +#### No Parameters +```python +from dapr.clients.grpc import conversation + +function = conversation.ConversationToolsFunction( + name="get_time", + description="Get current time", + parameters={"type": "object", "properties": {}, "required": []} +) +time_tool = conversation.ConversationTools(function=function) +``` + +#### Complex Schema with Arrays +```python +from dapr.clients.grpc import conversation + +function = conversation.ConversationToolsFunction( + name="search", + description="Search the web", + parameters={ + "type": "object", + "properties": { + "query": {"type": "string"}, + "domains": { + "type": "array", + "items": {"type": "string"} + } + }, + "required": ["query"] + } +) +search_tool = conversation.ConversationTools(function=function) +``` + +## Advanced Message Types (Alpha2) + +Alpha2 supports sophisticated message structures for complex conversations: + +### User Messages + +```python + +from dapr.clients.grpc._conversation import ConversationMessageContent, ConversationMessageOfDeveloper, + +ConversationMessage +ConversationMessageOfTool +ConversationMessageOfAssistant +ConversationMessageOfUser +ConversationMessageOfSystem + +user_message = ConversationMessage( + of_user=ConversationMessageOfUser( + content=[ConversationMessageContent(text="What's the weather in Paris?")] + ) +) +``` + +### System Messages +```python +system_message = ConversationMessage( + of_system=ConversationMessageOfSystem( + content=[ConversationMessageContent(text="You are a helpful AI assistant.")] + ) +) +``` + +### Developer Messages +```python +developer_message = ConversationMessage( + of_developer=ConversationMessageOfDeveloper( + name="developer", + content=[ConversationMessageContent(text="System configuration update.")] + ) +) +``` + +### Assistant Messages +```python +assistant_message = ConversationMessage( + of_assistant=ConversationMessageOfAssistant( + content=[ConversationMessageContent(text="I can help you with that!")], + tool_calls=[...] # Optional tool calls + ) +) ``` - +### Tool Messages (for tool responses) +```python +tool_message = ConversationMessage( + of_tool=ConversationMessageOfTool( + tool_id="call_123", + name="get_weather", + content=[ConversationMessageContent(text="Weather: 72°F, sunny")] + ) +) +``` + +### Convenience message helpers + +You can create the same messages more concisely using helpers from `conversation`: + +```python +from dapr.clients.grpc import conversation + +user = conversation.create_user_message("What's the weather in Paris?") +system = conversation.create_system_message("You are a helpful AI assistant.") +assistant = conversation.create_assistant_message("I can help you with that!") +tool_result = conversation.create_tool_message( + tool_id="call_123", name="get_weather", content="Weather: 72°F, sunny" +) +``` + +## Multi-turn Conversations + +Alpha2 excels at multi-turn conversations with proper context accumulation: + +```python +from dapr.clients.grpc import conversation + +conversation_history: list[conversation.ConversationMessage] = [ + conversation.create_user_message("What's the weather in SF?")] + +# Turn 1: User asks a question + +response1: conversation.ConversationResponseAlpha2 = client.converse_alpha2( + name="openai", + inputs=[conversation.ConversationInputAlpha2(messages=conversation_history)], + tools=conversation.get_registered_tools(), + tool_choice='auto', +) + +# Append assistant messages directly using the helper +for msg in response1.to_assistant_messages(): + conversation_history.append(msg) + # If tool calls were returned, you can execute and append a tool message + for tc in msg.of_assistant.tool_calls: + tool_output = conversation.execute_registered_tool(tc.function.name, tc.function.arguments) + conversation_history.append( + conversation.create_tool_message(tool_id=tc.id, name=tc.function.name, content=str(tool_output)) + ) + +# Turn 2 with accumulated context +conversation_history.append(conversation.create_user_message("Should I bring an umbrella?")) +response2 = client.converse_alpha2( + name="openai", + inputs=[conversation.ConversationInputAlpha2(messages=conversation_history)], + tools=conversation.get_registered_tools(), +) +``` + +### Trace print + +We have added a trace print method to the ConversationMessage class that will print the conversation history with the direction arrows and the content of the messages that is good for debugging. + +For example in the real_llm_providers_example.py file, we have the following code in a multi-turn conversation: + +```python + + for msg in conversation_history: + msg.trace_print(2) +``` + +That will print the conversation history with the following output (might vary depending on the LLM provider): + +``` +Full conversation history trace: + + client[user] --------------> LLM[assistant]: + content[0]: What's the weather like in San Francisco? Use one of the tools available. + + client <------------- LLM[assistant]: + content[0]: I'll check the current weather in San Francisco for you. + + client <------------- LLM[assistant]: + tool_calls: 1 + [0] id=toolu_01TJSATPrtE4uL9GcpJJDKEY function=get_weather({"location":"San Francisco"}) + + client[tool] -------------> LLM[assistant]: + tool_id: toolu_01TJSATPrtE4uL9GcpJJDKEY + name: get_weather + content[0]: The weather in San Francisco is sunny with a temperature of 72°F. + + client <------------- LLM[assistant]: + content[0]: The weather in San Francisco is currently sunny with a temperature of 72°F. It's a beautiful day there! + + client[user] --------------> LLM[assistant]: + content[0]: Should I bring an umbrella? Also, what about the weather in New York? + + client <------------- LLM[assistant]: + content[0]: Let me check the weather in New York for you to help answer both questions. + + client <------------- LLM[assistant]: + tool_calls: 1 + [0] id=toolu_01DqngeKSXhgqbn128NC4J1o function=get_weather({"location":"New York"}) + + client[tool] -------------> LLM[assistant]: + tool_id: toolu_01DqngeKSXhgqbn128NC4J1o + name: get_weather + content[0]: The weather in New York is sunny with a temperature of 72°F. + + client <------------- LLM[assistant]: + content[0]: Based on the weather information: + + **San Francisco**: Sunny, 72°F - No need for an umbrella there! + + **New York**: Also sunny, 72°F - No umbrella needed here either. + + Both cities are having beautiful, sunny weather today, so you shouldn't need an umbrella for either location. Perfect weather for being outdoors! +``` + + +### How context accumulation works + +- Context is not automatic. Each turn you must pass the entire `messages` history you want the LLM to see. +- Append assistant responses using `response.to_assistant_messages()` before the next turn. +- If the LLM makes tool calls, execute them locally and append a tool result message via `conversation.create_tool_message(...)`. +- Re-send available tools on every turn (e.g., `tools=conversation.get_registered_tools()`), especially when the provider requires tools to be present to call them. +- Keep history as a list of `ConversationMessage` objects; add new user/assistant/tool messages as the dialog progresses. +- Context Engineering is a key skill for multi-turn conversations and you will need to experiment with different approaches to get the best results as you cannot keep accumulating context forever. + +## Async Support -## Result +Full async/await support for non-blocking operations: +```python +from dapr.aio.clients import DaprClient as AsyncDaprClient + +async def async_conversation(): + async with AsyncDaprClient() as client: + user_message = create_user_message("Tell me a joke about async programming.") + input_alpha2 = ConversationInputAlpha2(messages=[user_message]) + + response = await client.converse_alpha2( + name="openai", + inputs=[input_alpha2], + parameters={'temperature': 0.7} + ) + + return response.outputs[0].choices[0].message.content + +# Run async function +result = asyncio.run(async_conversation()) ``` - - '== APP == Result: What's Dapr?' - - '== APP == Result: Give a brief overview.' -``` \ No newline at end of file + +## Benefits + +- ✅ **Clean JSON schema definition** +- ✅ **Automatic type conversion** +- ✅ **Multiple input formats supported** +- ✅ **Direct Python dict to protobuf Struct conversion** +- ✅ **Clean, readable code** +- ✅ **Supports complex nested structures** +- ✅ **Real LLM provider integration** +- ✅ **Multi-turn conversation support** +- ✅ **Function-to-schema automation** +- ✅ **Full async/await support** + +## Dapr Component Configuration + +For real LLM providers, you need Dapr component configurations. The example real_llm_providers_example.py automatically creates these for each provider you have configured in the .env file: + +### OpenAI Component Example +```yaml +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: openai +spec: + type: conversation.openai + version: v1 + metadata: + - name: key + value: "your_openai_api_key" + - name: model + value: "gpt-4o-mini" +``` + +### Running with Dapr Sidecar +```bash +# The example creates temporary component configs and shows you the command: +dapr run --app-id test-app --dapr-http-port 3500 --dapr-grpc-port 50001 --resources-path /tmp/dapr-llm-components-xyz/ +``` + + +## Examples in This Directory + +- **`real_llm_providers_example.py`** - Comprehensive Alpha2 examples with real providers + - Real LLM provider setup (OpenAI, Anthropic, Mistral, DeepSeek, Google AI) + - Basic conversation testing (Alpha2) + - Multi-turn conversation testing + - Tool calling with real LLMs + - Parameter conversion demonstration + - Multi-turn tool calling with context accumulation + - Function-to-schema automatic tool generation + - Decorator-based tool definition + - Async conversation and tool calling support + - Backward compatibility with Alpha1 + +- **`conversation_alpha1.py`** - Basic conversation examples (Alpha1) + - Simple Alpha1 conversation flow +- **`conversation_alpha2.py`** - Basic conversation examples (Alpha2) + - Simple Alpha2 conversation flow + +- **Configuration files:** + - `.env.example` - Environment variables template + - `config/` directory - Provider-specific component configurations + + + +## Troubleshooting + +### Common Issues + +1. **No LLM providers configured** + - Ensure API keys are set in environment variables or `.env` file + - Check that component configurations are correctly formatted + +2. **Tool calls not working** + - Verify tool schema is properly formatted (use examples as reference) + - Check that `tool_choice` is set to `'auto'` or specific tool name + - Ensure LLM provider supports function calling + +3. **Multi-turn context issues** + - Use `to_assistant_message()` helper function + - Maintain conversation history across turns + - Include all previous messages in subsequent requests + +4. **Parameter conversion errors** + - Alpha2 automatically converts raw Python values to GrpcAny + - No need to manually create GrpcAny objects for parameters + - Supported types: int, float, bool, str, dict, list + +5. **Streaming not available** + - Response streaming is not yet supported in Alpha2 + - Set `stream: False` in parameters (this is the default) + - All responses are returned as complete, non-streaming messages + + +## Features Overview + +| Feature | Alpha1 | Alpha2 | +|---------------------------|--------|--------| +| Basic Conversations | ✅ | ✅ | +| Tool Calling | ❌ | ✅ | +| Multi-turn Context | ❌ | ✅ | +| Advanced Message Types | ❌ | ✅ | +| Parameter Auto-conversion | ❌ | ✅ | +| Function-to-Schema | ❌ | ✅ | +| Async Support | ✅ | ✅ | +| Real LLM Providers | ✅ | ✅ | +| Streaming | ❌ | ❌ | + +**Recommendation:** Use Alpha2 for new projects and consider migrating existing Alpha1 code to benefit from enhanced features and improved developer experience. \ No newline at end of file diff --git a/examples/conversation/TOOL-CALL-QUICKSTART.md b/examples/conversation/TOOL-CALL-QUICKSTART.md new file mode 100644 index 00000000..4d0ab88e --- /dev/null +++ b/examples/conversation/TOOL-CALL-QUICKSTART.md @@ -0,0 +1,165 @@ +# Conversation API Tool Calling Quickstart (Alpha2) + +This guide shows the cleanest, most ergonomic way to use the Conversation API with tools and multi‑turn flows. + +## Recommended: Decorator‑based Tools + +```python +from dapr.clients import DaprClient +from dapr.clients.grpc import conversation + + +@conversation.tool +def get_weather(location: str, unit: str = 'fahrenheit') -> str: + """Get current weather for a location.""" + return f"Weather in {location} (unit={unit})" + + +user_msg = conversation.create_user_message("What's the weather in Paris?") +input_alpha2 = conversation.ConversationInputAlpha2(messages=[user_msg]) + +with DaprClient() as client: + response = client.converse_alpha2( + name="openai", + inputs=[input_alpha2], + tools=conversation.get_registered_tools(), # tools registered by @conversation.tool + tool_choice='auto', + parameters={'temperature': 0.2, 'max_tokens': 200}, # raw values auto-converted + ) + + for msg in response.to_assistant_messages(): + if msg.of_assistant.tool_calls: + for tc in msg.of_assistant.tool_calls: + print(f"Tool call: {tc.function.name} args={tc.function.arguments}") + else: + print(msg.of_assistant.content[0].text) +``` + +## Minimal Multi‑turn Pattern with Tools + +```python +from dapr.clients import DaprClient +from dapr.clients.grpc import conversation + + +@conversation.tool +def get_weather(location: str, unit: str = 'fahrenheit') -> str: + return f"Weather in {location} (unit={unit})" + + +history: list[conversation.ConversationMessage] = [] +history.append(conversation.create_user_message("What's the weather in San Francisco?")) + +with DaprClient() as client: + # Turn 1 + resp1 = client.converse_alpha2( + name="openai", + inputs=[conversation.ConversationInputAlpha2(messages=history)], + tools=conversation.get_registered_tools(), + tool_choice='auto', + parameters={'temperature': 0.2}, + ) + + # Append assistant messages; execute any tool calls and append tool results + for msg in resp1.to_assistant_messages(): + history.append(msg) + for tc in msg.of_assistant.tool_calls: + # Execute (we suggest validating inputs before execution in production) + tool_output = conversation.execute_registered_tool(tc.function.name, tc.function.arguments) + history.append( + conversation.create_tool_message(tool_id=tc.id, name=tc.function.name, content=str(tool_output)) + ) + + # Turn 2 (LLM sees tool result) + history.append(conversation.create_user_message("Should I bring an umbrella?")) + resp2 = client.converse_alpha2( + name="openai", + inputs=[conversation.ConversationInputAlpha2(messages=history)], + tools=conversation.get_registered_tools(), + parameters={'temperature': 0.2}, + ) + + for msg in resp2.to_assistant_messages(): + history.append(msg) + if not msg.of_assistant.tool_calls: + print(msg.of_assistant.content[0].text) +``` + + + +## Alternative: Function‑to‑Schema (from_function) + +```python +from enum import Enum +from dapr.clients.grpc import conversation + + +class Units(Enum): + CELSIUS = 'celsius' + FAHRENHEIT = 'fahrenheit' + + +def get_weather(location: str, unit: Units = Units.FAHRENHEIT) -> str: + return f"Weather in {location}" + + +fn = conversation.ConversationToolsFunction.from_function(get_weather) +weather_tool = conversation.ConversationTools(function=fn) +``` + +## JSON Schema Variants (fallbacks) + +```python +from dapr.clients.grpc import conversation + + +# Simple schema +fn = conversation.ConversationToolsFunction( + name='get_weather', + description='Get current weather', + parameters={ + 'type': 'object', + 'properties': { + 'location': {'type': 'string'}, + 'unit': {'type': 'string', 'enum': ['celsius', 'fahrenheit']}, + }, + 'required': ['location'], + }, +) +weather_tool = conversation.ConversationTools(function=fn) +``` + +## Async Variant + +```python +import asyncio +from dapr.aio.clients import DaprClient as AsyncDaprClient +from dapr.clients.grpc import conversation + + +@conversation.tool +def get_time() -> str: + return '2025-01-01T12:00:00Z' + + +async def main(): + async with AsyncDaprClient() as client: + msg = conversation.create_user_message('What time is it?') + inp = conversation.ConversationInputAlpha2(messages=[msg]) + resp = await client.converse_alpha2( + name='openai', inputs=[inp], tools=conversation.get_registered_tools() + ) + for m in resp.to_assistant_messages(): + if m.of_assistant.content: + print(m.of_assistant.content[0].text) + + +asyncio.run(main()) +``` + +## See also + +- `examples/conversation/real_llm_providers_example.py` — end‑to‑end multi‑turn and tool calling flows with real providers +- Main README in this folder for provider setup and additional examples + + diff --git a/examples/conversation/conversation.py b/examples/conversation/conversation_alpha1.py similarity index 91% rename from examples/conversation/conversation.py rename to examples/conversation/conversation_alpha1.py index 6b39e37c..42ba94c9 100644 --- a/examples/conversation/conversation.py +++ b/examples/conversation/conversation_alpha1.py @@ -11,7 +11,7 @@ # limitations under the License. # ------------------------------------------------------------ from dapr.clients import DaprClient -from dapr.clients.grpc._request import ConversationInput +from dapr.clients.grpc.conversation import ConversationInput with DaprClient() as d: inputs = [ @@ -29,5 +29,6 @@ name='echo', inputs=inputs, temperature=0.7, context_id='chat-123', metadata=metadata ) + print('Result: ', end='') for output in response.outputs: - print(f'Result: {output.result}') + print(output.result) diff --git a/examples/conversation/conversation_alpha2.py b/examples/conversation/conversation_alpha2.py new file mode 100644 index 00000000..b96f7f96 --- /dev/null +++ b/examples/conversation/conversation_alpha2.py @@ -0,0 +1,39 @@ +# ------------------------------------------------------------ +# Copyright 2025 The Dapr Authors +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------ +from dapr.clients import DaprClient +from dapr.clients.grpc.conversation import ( + ConversationInputAlpha2, + create_user_message, +) + +with DaprClient() as d: + inputs = [ + ConversationInputAlpha2(messages=[create_user_message("What's Dapr?")], scrub_pii=True), + ConversationInputAlpha2( + messages=[create_user_message('Give a brief overview.')], scrub_pii=True + ), + ] + + metadata = { + 'model': 'foo', + 'key': 'authKey', + 'cacheTTL': '10m', + } + + response = d.converse_alpha2( + name='echo', inputs=inputs, temperature=0.7, context_id='chat-123', metadata=metadata + ) + + print('Result: ', end='') + for output in response.outputs: + print(output.choices[0].message.content) diff --git a/examples/conversation/real_llm_providers_example.py b/examples/conversation/real_llm_providers_example.py new file mode 100644 index 00000000..c103007e --- /dev/null +++ b/examples/conversation/real_llm_providers_example.py @@ -0,0 +1,1265 @@ +# ------------------------------------------------------------ +# Copyright 2025 The Dapr Authors +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------ + +""" +Real LLM Providers Example for Dapr Conversation API (Alpha2) + +This example demonstrates how to use real LLM providers (OpenAI, Anthropic, etc.) +with the Dapr Conversation API Alpha2. It showcases the latest features including: +- Advanced message types (user, system, assistant, developer, tool) +- Automatic parameter conversion (raw Python values) +- Enhanced tool calling capabilities +- Multi-turn conversations +- Decorator-based tool definition +- Both sync and async implementations + +Prerequisites: +1. Set up API keys in .env file (copy from .env.example) +2. For manual mode: Start Dapr sidecar manually + +Usage: + # requires manual Dapr sidecar setup + python examples/conversation/real_llm_providers_example.py + + # Show help + python examples/conversation/real_llm_providers_example.py --help + +Environment Variables: + OPENAI_API_KEY: OpenAI API key + ANTHROPIC_API_KEY: Anthropic API key + MISTRAL_API_KEY: Mistral API key + DEEPSEEK_API_KEY: DeepSeek API key + GOOGLE_API_KEY: Google AI (Gemini) API key +""" + +import asyncio +import json +import os +import sys +import tempfile +from pathlib import Path +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +import yaml + +# Add the parent directory to the path so we can import local dapr sdk +# uncomment if running from development version +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +# Load environment variables from .env file if available +try: + from dotenv import load_dotenv + + DOTENV_AVAILABLE = True +except ImportError: + DOTENV_AVAILABLE = False + print('⚠️ python-dotenv not installed. Install with: pip install python-dotenv') + +from dapr.aio.clients import DaprClient as AsyncDaprClient +from dapr.clients import DaprClient +from dapr.clients.grpc import conversation + + +def create_weather_tool() -> conversation.ConversationTools: + """Create a weather tool for testing Alpha2 tool calling using full JSON schema in parameters approach.""" + conversation.unregister_tool('get_weather') + function = conversation.ConversationToolsFunction( + name='get_weather', + description='Get the current weather for a location', + parameters={ + 'type': 'object', + 'properties': { + 'location': {'type': 'string', 'description': 'The city and state or country'}, + 'unit': { + 'type': 'string', + 'enum': ['celsius', 'fahrenheit'], + 'description': 'Temperature unit', + }, + }, + 'required': ['location'], + }, + ) + return conversation.ConversationTools(function=function) + + +def create_calculator_tool() -> conversation.ConversationTools: + """Create a calculator tool using full JSON schema in parameters approach.""" + conversation.unregister_tool('calculate') # cleanup + function = conversation.ConversationToolsFunction( + name='calculate', + description='Perform mathematical calculations', + parameters={ + 'type': 'object', + 'properties': { + 'expression': { + 'type': 'string', + 'description': "Mathematical expression to evaluate (e.g., '2+2', 'sqrt(16)')", + } + }, + 'required': ['expression'], + }, + ) + return conversation.ConversationTools(function=function) + + +def create_time_tool() -> conversation.ConversationTools: + """Create a simple tool with no parameters using full JSON schema in parameters approach.""" + conversation.unregister_tool('get_current_time') + function = conversation.ConversationToolsFunction( + name='get_current_time', + description='Get the current date and time', + parameters={'type': 'object', 'properties': {}, 'required': []}, + ) + return conversation.ConversationTools(function=function) + + +def create_search_tool() -> conversation.ConversationTools: + """Create a more complex tool with multiple parameter types and constraints using full JSON schema in parameters approach.""" + conversation.unregister_tool('web_search') + function = conversation.ConversationToolsFunction( + name='web_search', + description='Search the web for information', + parameters={ + 'type': 'object', + 'properties': { + 'query': {'type': 'string', 'description': 'Search query'}, + 'limit': { + 'type': 'integer', + 'description': 'Maximum number of results', + 'minimum': 1, + 'maximum': 10, + 'default': 5, + }, + 'include_images': { + 'type': 'boolean', + 'description': 'Whether to include image results', + 'default': False, + }, + 'domains': { + 'type': 'array', + 'items': {'type': 'string'}, + # 'description': 'Limit search to specific domains', + }, + }, + 'required': ['query'], + }, + ) + return conversation.ConversationTools(function=function) + + +def create_tool_from_typed_function_example() -> conversation.ConversationTools: + """Demonstrate creating tools from typed Python functions - Best DevEx for most cases. + + This shows the most advanced approach: define a typed function and automatically + generate the complete tool schema from type hints and docstrings. + """ + from typing import Optional, List + from enum import Enum + + conversation.unregister_tool('find_restaurants') + + # Define the tool behavior as a regular Python function with type hints + class PriceRange(Enum): + BUDGET = 'budget' + MODERATE = 'moderate' + EXPENSIVE = 'expensive' + + def find_restaurants( + location: str, + cuisine: str = 'any', + price_range: PriceRange = PriceRange.MODERATE, + max_results: int = 5, + dietary_restrictions: Optional[List[str]] = None, + ) -> str: + """Find restaurants in a specific location. + + Args: + location: The city or neighborhood to search + cuisine: Type of cuisine (italian, chinese, mexican, etc.) + price_range: Budget preference for dining + max_results: Maximum number of restaurant recommendations + dietary_restrictions: Special dietary needs (vegetarian, gluten-free, etc.) + """ + # This would contain actual implementation + return f'Found restaurants in {location} serving {cuisine} food' + + # Create the tool using the from_function class method + function = conversation.ConversationToolsFunction.from_function(find_restaurants) + + return conversation.ConversationTools(function=function) + + +def create_tool_from_tool_decorator_example() -> conversation.ConversationTools: + """Demonstrate creating tools from typed Python functions - Best DevEx for most cases. + + This shows the most advanced approach: define a typed function and automatically + generate the complete tool schema from type hints and docstrings. + """ + from typing import Optional, List + from enum import Enum + + conversation.unregister_tool('find_restaurants') + + # Define the tool behavior as a regular Python function with type hints + class PriceRange(Enum): + MODERATE = 'moderate' + EXPENSIVE = 'expensive' + + @conversation.tool + def find_restaurants( + location: str, + cuisine: str = 'any', + price_range: PriceRange = PriceRange.MODERATE, + max_results: int = 5, + dietary_restrictions: Optional[List[str]] = None, + ) -> str: + """Find restaurants in a specific location. + + Args: + location: The city or neighborhood to search + cuisine: Type of cuisine (italian, chinese, mexican, etc.) + price_range: Budget preference for dining + max_results: Maximum number of restaurant recommendations + dietary_restrictions: Special dietary needs (vegetarian, gluten-free, etc.) + """ + # This would contain actual implementation + return f'Found restaurants in {location} serving {cuisine} food' + + return conversation.ConversationTools(function=find_restaurants) + + +def execute_weather_tool(location: str, unit: str = 'fahrenheit') -> str: + """Simulate weather tool execution.""" + temp = '72°F' if unit == 'fahrenheit' else '22°C' + return f'The weather in {location} is sunny with a temperature of {temp}.' + + +def convert_llm_response_to_conversation_input( + result_message: conversation.ConversationResultAlpha2Message, +) -> conversation.ConversationMessage: + """Convert ConversationResultMessage (from LLM response) to ConversationMessage (for conversation input). + + This standalone utility function makes it easy to append LLM responses to conversation history + and reuse them as input for subsequent conversation turns in multi-turn scenarios. + + Args: + result_message: ConversationResultMessage from LLM response (choice.message) + + Returns: + ConversationMessage suitable for input to next conversation turn + + Example: + >>> import dapr.clients.grpc.conversation + >>> client = DaprClient() + >>> response = client.converse_alpha2(name="openai", inputs=[input_alpha2], tools=[tool]) + >>> choice = response.outputs[0].choices[0] + >>> + >>> # Convert LLM response to conversation message + >>> conversation_history = [] + >>> assistant_message = convert_llm_response_to_conversation_input(choice.message) + >>> conversation_history.append(assistant_message) + >>> + >>> # Use in next turn + >>> next_input = conversation.ConversationInputAlpha2(messages=conversation_history) + >>> next_response = client.converse_alpha2(name="openai", inputs=[next_input]) + """ + # Convert content string to ConversationMessageContent list + content = [] + if result_message.content: + content = [conversation.ConversationMessageContent(text=result_message.content)] + + # Convert tool_calls if present (they're already the right type) + tool_calls = result_message.tool_calls or [] + + # Create assistant message (since LLM responses are always assistant messages) + return conversation.ConversationMessage( + of_assistant=conversation.ConversationMessageOfAssistant( + content=content, tool_calls=tool_calls + ) + ) + + +class RealLLMProviderTester: + """Test real LLM providers with Dapr Conversation API Alpha2.""" + + def __init__(self): + self.available_providers = {} + self.component_configs = {} + self.components_dir = None + + def load_environment(self) -> None: + """Load environment variables from .env file if available.""" + if DOTENV_AVAILABLE: + env_file = Path(__file__).parent / '.env' + if env_file.exists(): + load_dotenv(env_file) + print(f'📁 Loaded environment from {env_file}') + else: + print(f'⚠️ No .env file found at {env_file}') + print(' Copy .env.example to .env and add your API keys') + else: + print('⚠️ python-dotenv not available, using system environment variables') + + def detect_available_providers(self) -> Dict[str, Dict[str, Any]]: + """Detect which LLM providers are available based on API keys.""" + providers = {} + + # OpenAI + if os.getenv('OPENAI_API_KEY'): + providers['openai'] = { + 'display_name': 'OpenAI GPT-5-mini', + 'component_type': 'conversation.openai', + 'api_key_env': 'OPENAI_API_KEY', + 'metadata': [ + {'name': 'key', 'value': os.getenv('OPENAI_API_KEY')}, + {'name': 'model', 'value': 'gpt-5-mini-2025-08-07'}, + ], + } + + # Anthropic + if os.getenv('ANTHROPIC_API_KEY'): + providers['anthropic'] = { + 'display_name': 'Anthropic Claude Sonnet 4', + 'component_type': 'conversation.anthropic', + 'api_key_env': 'ANTHROPIC_API_KEY', + 'metadata': [ + {'name': 'key', 'value': os.getenv('ANTHROPIC_API_KEY')}, + {'name': 'model', 'value': 'claude-sonnet-4-20250514'}, + ], + } + + # Mistral + if os.getenv('MISTRAL_API_KEY'): + providers['mistral'] = { + 'display_name': 'Mistral Large', + 'component_type': 'conversation.mistral', + 'api_key_env': 'MISTRAL_API_KEY', + 'metadata': [ + {'name': 'key', 'value': os.getenv('MISTRAL_API_KEY')}, + {'name': 'model', 'value': 'mistral-large-latest'}, + ], + } + + # DeepSeek + if os.getenv('DEEPSEEK_API_KEY'): + providers['deepseek'] = { + 'display_name': 'DeepSeek V3', + 'component_type': 'conversation.deepseek', + 'api_key_env': 'DEEPSEEK_API_KEY', + 'metadata': [ + {'name': 'key', 'value': os.getenv('DEEPSEEK_API_KEY')}, + {'name': 'model', 'value': 'deepseek-chat'}, + ], + } + + # Google AI (Gemini) + if os.getenv('GOOGLE_API_KEY'): + providers['google'] = { + 'display_name': 'Google Gemini 2.5 Flash', + 'component_type': 'conversation.googleai', + 'api_key_env': 'GOOGLE_API_KEY', + 'metadata': [ + {'name': 'key', 'value': os.getenv('GOOGLE_API_KEY')}, + {'name': 'model', 'value': 'gemini-2.5-flash'}, + ], + } + + return providers + + def create_component_configs(self, selected_providers: Optional[List[str]] = None) -> str: + """Create Dapr component configurations for available providers (those with API keys exposed).""" + # Create temporary directory for components + self.components_dir = tempfile.mkdtemp(prefix='dapr-llm-components-') + + # If no specific providers selected, use OpenAI as default (most reliable) + if not selected_providers: + selected_providers = ( + ['openai'] + if 'openai' in self.available_providers + else list(self.available_providers.keys())[:1] + ) + + for provider_id in selected_providers: + if provider_id not in self.available_providers: + continue + + config = self.available_providers[provider_id] + component_config = { + 'apiVersion': 'dapr.io/v1alpha1', + 'kind': 'Component', + 'metadata': {'name': provider_id}, + 'spec': { + 'type': config['component_type'], + 'version': 'v1', + 'metadata': config['metadata'], + }, + } + + # Write component file + component_file = Path(self.components_dir) / f'{provider_id}.yaml' + with open(component_file, 'w') as f: + yaml.dump(component_config, f, default_flow_style=False) + + print(f'📝 Created component: {component_file}') + + return self.components_dir + + def test_basic_conversation_alpha2(self, provider_id: str) -> None: + """Test basic Alpha2 conversation with a provider.""" + print( + f"\n💬 Testing Alpha2 basic conversation with {self.available_providers[provider_id]['display_name']}" + ) + + try: + with DaprClient() as client: + # Create Alpha2 conversation input with sophisticated message structure + user_message = conversation.create_user_message( + "Hello! Please respond with exactly: 'Hello from Dapr Alpha2!'" + ) + input_alpha2 = conversation.ConversationInputAlpha2(messages=[user_message]) + + # Use new parameter conversion (raw Python values automatically converted) + response = client.converse_alpha2( + name=provider_id, + inputs=[input_alpha2], + temperature=1, + parameters={ + 'temperature': 0.7, + 'max_tokens': 100, + 'top_p': 0.9, + }, + ) + + if response.outputs and response.outputs[0].choices: + choice = response.outputs[0].choices[0] + print(f'✅ Alpha2 Response: {choice.message.content}') + print(f'📊 Finish reason: {choice.finish_reason}') + else: + print('❌ No Alpha2 response received') + + except Exception as e: + print(f'❌ Alpha2 basic conversation error: {e}') + + def test_multi_turn_conversation_alpha2(self, provider_id: str) -> None: + """Test multi-turn Alpha2 conversation with different message types.""" + print( + f"\n🔄 Testing Alpha2 multi-turn conversation with {self.available_providers[provider_id]['display_name']}" + ) + + try: + with DaprClient() as client: + # Create a multi-turn conversation with system, user, and assistant messages + system_message = conversation.create_system_message( + 'You are a helpful AI assistant. Be concise.' + ) + user_message1 = conversation.create_user_message('What is 2+2?') + assistant_message = conversation.create_assistant_message('2+2 equals 4.') + user_message2 = conversation.create_user_message('What about 3+3?') + + input_alpha2 = conversation.ConversationInputAlpha2( + messages=[system_message, user_message1, assistant_message, user_message2] + ) + + response = client.converse_alpha2( + name=provider_id, + inputs=[input_alpha2], + temperature=1, + parameters={ + 'max_tokens': 150, + }, + ) + + if response.outputs and response.outputs[0].choices: + print( + f'✅ Multi-turn conversation processed {len(response.outputs[0].choices)} message(s)' + ) + for i, choice in enumerate(response.outputs[0].choices): + print(f' Response {i+1}: {choice.message.content[:100]}...') + else: + print('❌ No multi-turn response received') + + except Exception as e: + print(f'❌ Multi-turn conversation error: {e}') + + def test_tool_calling_alpha2(self, provider_id: str) -> None: + """Test Alpha2 tool calling with a provider.""" + print( + f"\n🔧 Testing Alpha2 tool calling with {self.available_providers[provider_id]['display_name']}" + ) + + try: + with DaprClient() as client: + weather_tool = create_weather_tool() + user_message = conversation.create_user_message( + "What's the weather like in San Francisco?" + ) + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[user_message]) + + response = client.converse_alpha2( + name=provider_id, + inputs=[input_alpha2], + tools=[weather_tool], + tool_choice='auto', + temperature=1, + parameters={ + 'max_tokens': 500, + }, + ) + + if response.outputs and response.outputs[0].choices: + choice = response.outputs[0].choices[0] + print(f'📊 Finish reason: {choice.finish_reason}') + + if choice.finish_reason == 'tool_calls' and choice.message.tool_calls: + print(f'🔧 Tool calls made: {len(choice.message.tool_calls)}') + for tool_call in choice.message.tool_calls: + print(f' Tool: {tool_call.function.name}') + print(f' Arguments: {tool_call.function.arguments}') + + # Execute the tool to show the workflow + try: + args = json.loads(tool_call.function.arguments) + weather_result = execute_weather_tool( + args.get('location', 'San Francisco'), + args.get('unit', 'fahrenheit'), + ) + print(f'🌤️ Tool executed: {weather_result}') + + # Demonstrate tool result message (for multi-turn tool workflows) + tool_result_message = conversation.create_tool_message( + tool_id=tool_call.id, + name=tool_call.function.name, + content=weather_result, + ) + print( + '✅ Alpha2 tool calling demonstration completed! Tool Result Message:' + ) + print(tool_result_message) + + except json.JSONDecodeError: + print('⚠️ Could not parse tool arguments') + else: + print(f'💬 Regular response: {choice.message.content}') + else: + print('❌ No tool calling response received') + + except Exception as e: + print(f'❌ Alpha2 tool calling error: {e}') + + def test_parameter_conversion(self, provider_id: str) -> None: + """Test the new parameter conversion feature.""" + print( + f"\n🔄 Testing parameter conversion with {self.available_providers[provider_id]['display_name']}" + ) + + try: + with DaprClient() as client: + user_message = conversation.create_user_message( + 'Tell me about the different tool creation approaches available.' + ) + input_alpha2 = conversation.ConversationInputAlpha2(messages=[user_message]) + + # Demonstrate different tool creation approaches + weather_tool = create_weather_tool() # Simple properties approach + calc_tool = create_calculator_tool() # Full JSON schema approach + time_tool = create_time_tool() # No parameters approach + search_tool = create_search_tool() # Complex schema with arrays, etc. + + print( + f'✅ Created {len([weather_tool, calc_tool, time_tool, search_tool])} tools with different approaches!' + ) + + # Test various parameter types that are automatically converted + response = client.converse_alpha2( + name=provider_id, + inputs=[input_alpha2], + tools=[weather_tool, calc_tool, time_tool, search_tool], + temperature=1, + parameters={ + # Raw Python values - automatically converted to GrpcAny + 'max_tokens': 200, # int + 'top_p': 1.0, # float + 'frequency_penalty': 0.0, # float + 'presence_penalty': 0.0, # float + 'stream': False, # bool + 'tool_choice': 'none', # string + 'model': 'gpt-4o-mini', # string (provider-specific) + }, + ) + + if response.outputs and response.outputs[0].choices: + choice = response.outputs[0].choices[0] + print(f'✅ Parameter conversion successful!') + print(f'✅ Tool creation helpers working perfectly!') + print(f' Response: {choice.message.content[:100]}...') + else: + print('❌ Parameter conversion test failed') + + except Exception as e: + print(f'❌ Parameter conversion error: {e}') + + def test_multi_turn_tool_calling_alpha2(self, provider_id: str) -> None: + """Test multi-turn Alpha2 tool calling with proper context accumulation.""" + print( + f"\n🔄🔧 Testing multi-turn tool calling with {self.available_providers[provider_id]['display_name']}" + ) + + try: + with DaprClient() as client: + weather_tool = create_weather_tool() + conversation_history = [] + + # Turn 1: User asks about weather (include tools) + print('\n--- Turn 1: Initial weather query ---') + user_message1 = conversation.create_user_message( + "What's the weather like in San Francisco? Use one of the tools available." + ) + conversation_history.append(user_message1) + + print(f'📝 Request 1 context: {len(conversation_history)} messages + tools') + input_alpha2_turn1 = conversation.ConversationInputAlpha2( + messages=conversation_history + ) + + response1 = client.converse_alpha2( + name=provider_id, + inputs=[input_alpha2_turn1], + tools=[weather_tool], # Tools included in turn 1 + tool_choice='auto', + temperature=1, + parameters={ + 'max_tokens': 500, + }, + ) + + # Check all outputs and choices for tool calls + tool_calls_found = [] + assistant_messages = [] + + for output_idx, output in enumerate(response1.outputs or []): + for choice_idx, choice in enumerate(output.choices or []): + print( + f'📋 Checking output {output_idx}, choice {choice_idx}: finish_reason={choice.finish_reason}, choice: {choice}' + ) + + # Convert and collect all assistant messages + assistant_message = convert_llm_response_to_conversation_input( + choice.message + ) + assistant_messages.append(assistant_message) + + # Check for tool calls in this choice + if choice.message.tool_calls: + # if not choice.message.tool_calls[0].id: + # choice.message.tool_calls[0].id = "1" + tool_calls_found.extend(choice.message.tool_calls) + print( + f'🔧 Found {len(choice.message.tool_calls)} tool call(s) in output {output_idx}, choice {choice_idx}' + ) + + # Use the first assistant message for conversation history (most providers return one) + if assistant_messages: + for assistant_message in assistant_messages: + conversation_history.append(assistant_message) + print( + f'✅ Added assistant message to history (from {len(assistant_messages)} total messages)' + ) + + if tool_calls_found: + # Use the first tool call for demonstration + tool_call = tool_calls_found[0] + print( + f'🔧 Processing tool call: {tool_call.function.name} (found {len(tool_calls_found)} total tool calls)' + ) + + # Execute the tool + args = json.loads(tool_call.function.arguments) + weather_result = execute_weather_tool( + args.get('location', 'San Francisco'), args.get('unit', 'fahrenheit') + ) + print(f'🌤️ Tool result: {weather_result}') + + # Add tool result to conversation history + tool_result_message = conversation.create_tool_message( + tool_id=tool_call.id, name=tool_call.function.name, content=weather_result + ) + conversation_history.append(tool_result_message) + + # Turn 2: LLM processes tool result (accumulate context + tools) + print('\n--- Turn 2: LLM processes tool result ---') + print(f'📝 Request 2 context: {len(conversation_history)} messages + tools') + input_alpha2_turn2 = conversation.ConversationInputAlpha2( + messages=conversation_history + ) + + response2 = client.converse_alpha2( + name=provider_id, + inputs=[input_alpha2_turn2], + tools=[weather_tool], # Tools carried forward to turn 2 + temperature=1, + parameters={ + 'max_tokens': 500, + }, + ) + + if response2.outputs and response2.outputs[0].choices: + choice2 = response2.outputs[0].choices[0] + print(f'🤖 LLM response with tool context: {choice2.message.content}') + + # Add LLM's response to accumulated history using utility + assistant_message2 = convert_llm_response_to_conversation_input( + choice2.message + ) + conversation_history.append(assistant_message2) + + # Turn 3: Follow-up question (full context + tools) + print('\n--- Turn 3: Follow-up question using accumulated context ---') + user_message2 = conversation.create_user_message( + 'Should I bring an umbrella? Also, what about the weather in New York?' + ) + conversation_history.append(user_message2) + + print(f'📝 Request 3 context: {len(conversation_history)} messages + tools') + print('📋 Accumulated context includes:') + print(' • Original user query about San Francisco') + print(" • Assistant's tool call intention") + print(' • Weather tool execution result') + print(" • Assistant's weather summary") + print(' • New user follow-up question') + + input_alpha2_turn3 = conversation.ConversationInputAlpha2( + messages=conversation_history + ) + + response3 = client.converse_alpha2( + name=provider_id, + inputs=[input_alpha2_turn3], + tools=[weather_tool], # Tools still available in turn 3 + tool_choice='auto', + temperature=1, + parameters={ + 'max_tokens': 500, + }, + ) + + if response3.outputs and response3.outputs[0].choices: + choice3 = response3.outputs[0].choices[0] + + if choice3.finish_reason == 'tool_calls' and choice3.message.tool_calls: + print( + f'🔧 Follow-up tool call: {choice3.message.tool_calls[0].function.name}' + ) + + # Execute second tool call + tool_call3 = choice3.message.tool_calls[0] + # if not tool_call3.id: + # tool_call3.id = "2" + args3 = json.loads(tool_call3.function.arguments) + weather_result3 = execute_weather_tool( + args3.get('location', 'New York'), + args3.get('unit', 'fahrenheit'), + ) + print(f'🌤️ Second tool result: {weather_result3}') + + # Could continue accumulating context for turn 4... + print( + '✅ Multi-turn tool calling with proper context accumulation successful!' + ) + print( + f'📊 Final context: {len(conversation_history)} messages + tools available for next turn' + ) + else: + print( + f'💬 Follow-up response using accumulated context: {choice3.message.content}' + ) + print( + '✅ Multi-turn conversation with proper context accumulation successful!' + ) + print(f'📊 Final context: {len(conversation_history)} messages') + else: + print( + '⚠️ No tool calls found in any output/choice - continuing with regular conversation flow' + ) + # Could continue with regular multi-turn conversation without tools + + if not assistant_messages: + print('❌ No assistant messages received in first turn') + + except Exception as e: + print(f'❌ Multi-turn tool calling error: {e}') + + def test_multi_turn_tool_calling_alpha2_tool_helpers(self, provider_id: str) -> None: + """Test multi-turn Alpha2 tool calling with proper context accumulation using higher level abstractions.""" + print( + f"\n🔄🔧 Testing multi-turn tool calling with {self.available_providers[provider_id]['display_name']}" + ) + + # using decorator + + @conversation.tool + def get_weather(location: str, unit: str = 'fahrenheit') -> str: + """Get the current weather for a location.""" + # This is a mock implementation. Replace with actual weather API call. + temp = '72°F' if unit == 'fahrenheit' else '22°C' + return f'The weather in {location} is sunny with a temperature of {temp}.' + + try: + with DaprClient() as client: + conversation_history = [] # our context to pass to the LLM on each turn + + # Turn 1: User asks about weather (include tools) + print('\n--- Turn 1: Initial weather query ---') + user_message1 = conversation.create_user_message( + "What's the weather like in San Francisco? Use one of the tools available." + ) + conversation_history.append(user_message1) + + print(f'📝 Request 1 context: {len(conversation_history)} messages + tools') + input_alpha2_turn1 = conversation.ConversationInputAlpha2( + messages=conversation_history + ) + + response1 = client.converse_alpha2( + name=provider_id, + inputs=[input_alpha2_turn1], + tools=conversation.get_registered_tools(), # using registered tools (automatically registered by the decorator) + tool_choice='auto', + temperature=1, + parameters={ + 'max_tokens': 500, + }, + ) + + def append_response_to_history( + response: conversation.ConversationResponseAlpha2, skip_execution: bool = False + ): + """Helper to append response to history and execute tool calls.""" + for msg in response.to_assistant_messages(): + conversation_history.append(msg) + if not msg.of_assistant.tool_calls: + continue + for _tool_call in msg.of_assistant.tool_calls: + print(f'Executing tool call: {_tool_call.function.name}') + + # execute the tool called by the LLM + if not skip_execution: + output = conversation.execute_registered_tool( + _tool_call.function.name, _tool_call.function.arguments + ) + print(f'Tool output: {output}') + else: + output = 'tool execution skipped' + + # append a result to history + conversation_history.append( + conversation.create_tool_message( + tool_id=_tool_call.id, + name=_tool_call.function.name, + content=output, + ) + ) + + append_response_to_history(response1) + + # Turn 2: LLM processes tool result (accumulate context + tools) + print('\n--- Turn 2: LLM processes tool result ---') + print(f'📝 Request 2 context: {len(conversation_history)} messages + tools') + input_alpha2_turn2 = conversation.ConversationInputAlpha2( + messages=conversation_history + ) + + response2 = client.converse_alpha2( + name=provider_id, + inputs=[input_alpha2_turn2], + tools=conversation.get_registered_tools(), + temperature=1, + parameters={ + 'max_tokens': 500, + }, + ) + + # Turn 3: Follow-up question (full context + tools) + + append_response_to_history(response2) + + print('\n--- Turn 3: Follow-up question using accumulated context ---') + user_message2 = conversation.create_user_message( + 'Should I bring an umbrella? Also, what about the weather in New York?' + ) + conversation_history.append(user_message2) + + print(f'📝 Request 3 context: {len(conversation_history)} messages + tools') + print('📋 Accumulated context includes:') + + input_alpha2_turn3 = conversation.ConversationInputAlpha2( + messages=conversation_history + ) + + response3 = client.converse_alpha2( + name=provider_id, + inputs=[input_alpha2_turn3], + tools=conversation.get_registered_tools(), + tool_choice='auto', + temperature=1, + parameters={ + 'max_tokens': 500, + }, + ) + + append_response_to_history(response3) + + print(f'📝 Request 4 context: {len(conversation_history)} messages + tools') + print('📋 Expect response about the umbrella:') + + input_alpha2_turn4 = conversation.ConversationInputAlpha2( + messages=conversation_history + ) + + response4 = client.converse_alpha2( + name=provider_id, + inputs=[input_alpha2_turn4], + tools=conversation.get_registered_tools(), + tool_choice='auto', + temperature=1, + parameters={ + 'max_tokens': 500, + }, + ) + + append_response_to_history(response4, skip_execution=False) + + print('Full conversation history trace:') + print(' Tools available:') + for tool in conversation.get_registered_tools(): + print(f' - {tool.function.name}({tool.function.parameters["properties"]})') + for msg in conversation_history: + msg.trace_print(2) + + except Exception as e: + print(f'❌ Multi-turn tool calling error: {e}') + finally: + conversation.unregister_tool('get_weather') + + def test_function_to_schema_approach(self, provider_id: str) -> None: + """Test the best DevEx for most cases: function-to-JSON-schema automatic tool creation.""" + print( + f"\n🎯 Testing function-to-schema approach with {self.available_providers[provider_id]['display_name']}" + ) + + try: + with DaprClient() as client: + # Create a tool using the typed function approach + restaurant_tool = create_tool_from_typed_function_example() + print(restaurant_tool) + + user_message = conversation.create_user_message( + 'I want to find Italian restaurants in San Francisco with a moderate price range.' + ) + input_alpha2 = conversation.ConversationInputAlpha2(messages=[user_message]) + + response = client.converse_alpha2( + name=provider_id, + inputs=[input_alpha2], + tools=[restaurant_tool], + tool_choice='auto', + temperature=1, + parameters={ + 'max_tokens': 500, + }, + ) + + if response.outputs and response.outputs[0].choices: + choice = response.outputs[0].choices[0] + print(f'📊 Finish reason: {choice.finish_reason}') + + if choice.finish_reason == 'tool_calls' and choice.message.tool_calls: + print('🎯 Function-to-schema tool calling successful!') + for tool_call in choice.message.tool_calls: + print(f' Tool: {tool_call.function.name}') + print(f' Arguments: {tool_call.function.arguments}') + + # This demonstrates the complete workflow + print('✅ Auto-generated schema worked perfectly with real LLM!') + else: + print(f'💬 Response: {choice.message.content}') + else: + print('❌ No function-to-schema response received') + + except Exception as e: + print(f'❌ Function-to-schema approach error: {e}') + + def test_tool_decorated_function_to_schema_approach(self, provider_id: str) -> None: + """Test the best DevEx for most cases: function-to-JSON-schema automatic tool creation.""" + print( + f"\n🎯 Testing decorator tool function-to-schema approach with {self.available_providers[provider_id]['display_name']}" + ) + + try: + with DaprClient() as client: + # Create a tool using the typed function approach + create_tool_from_tool_decorator_example() + + # we can get tools registered from different places in our repo + print(conversation.get_registered_tools()) + + user_message = conversation.create_user_message( + 'I want to find Italian restaurants in San Francisco with a moderate price range.' + ) + input_alpha2 = conversation.ConversationInputAlpha2(messages=[user_message]) + + response = client.converse_alpha2( + name=provider_id, + inputs=[input_alpha2], + tools=conversation.get_registered_tools(), + tool_choice='auto', + temperature=1, + parameters={ + 'max_tokens': 500, + }, + ) + + if response.outputs and response.outputs[0].choices: + choice = response.outputs[0].choices[0] + print(f'📊 Finish reason: {choice.finish_reason}') + + if choice.finish_reason == 'tool_calls' and choice.message.tool_calls: + print('🎯 Function-to-schema tool calling successful!') + for tool_call in choice.message.tool_calls: + print(f' Tool: {tool_call.function.name}') + print(f' Arguments: {tool_call.function.arguments}') + + # This demonstrates the complete workflow + print('✅ Auto-generated schema worked perfectly with real LLM!') + else: + print(f'💬 Response: {choice.message.content}') + else: + print('❌ No function-to-schema response received') + + except Exception as e: + print(f'❌ Function-to-schema approach error: {e}') + + async def test_async_conversation_alpha2(self, provider_id: str) -> None: + """Test async Alpha2 conversation with a provider.""" + print( + f"\n⚡ Testing async Alpha2 conversation with {self.available_providers[provider_id]['display_name']}" + ) + + try: + async with AsyncDaprClient() as client: + user_message = conversation.create_user_message( + 'Tell me a very short joke about async programming.' + ) + input_alpha2 = conversation.ConversationInputAlpha2(messages=[user_message]) + + response = await client.converse_alpha2( + name=provider_id, + inputs=[input_alpha2], + temperature=1, + parameters={ + 'max_tokens': 500, + }, + ) + + if response.outputs and response.outputs[0].choices: + choice = response.outputs[0].choices[0] + print(f'✅ Async Alpha2 response: {choice.message.content}') + else: + print('❌ No async Alpha2 response received') + + except Exception as e: + print(f'❌ Async Alpha2 error: {e}') + + async def test_async_tool_calling_alpha2(self, provider_id: str) -> None: + """Test async Alpha2 tool calling with a provider.""" + print( + f"\n🔧⚡ Testing async Alpha2 tool calling with {self.available_providers[provider_id]['display_name']}" + ) + + try: + async with AsyncDaprClient() as client: + weather_tool = create_weather_tool() + user_message = conversation.create_user_message("What's the weather in Tokyo?") + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[user_message]) + + response = await client.converse_alpha2( + name=provider_id, + inputs=[input_alpha2], + tools=[weather_tool], + temperature=1, + parameters={ + 'max_tokens': 500, + }, + ) + + if response.outputs and response.outputs[0].choices: + choice = response.outputs[0].choices[0] + if choice.finish_reason == 'tool_calls' and choice.message.tool_calls: + print('✅ Async tool calling successful!') + for tool_call in choice.message.tool_calls: + print(f' Tool: {tool_call.function.name}') + args = json.loads(tool_call.function.arguments) + weather_result = execute_weather_tool( + args.get('location', 'Tokyo'), args.get('unit', 'fahrenheit') + ) + print(f' Result: {weather_result}') + else: + print(f'💬 Async response: {choice.message.content}') + else: + print('❌ No async tool calling response received') + + except Exception as e: + print(f'❌ Async tool calling error: {e}') + + def run_comprehensive_test(self, provider_id: str) -> None: + """Run comprehensive Alpha2 tests for a provider.""" + provider_name = self.available_providers[provider_id]['display_name'] + print(f"\n{'='*60}") + print(f'🧪 Testing {provider_name} with Alpha2 API') + print(f"{'='*60}") + + # Alpha2 Sync tests + self.test_basic_conversation_alpha2(provider_id) + self.test_multi_turn_conversation_alpha2(provider_id) + self.test_tool_calling_alpha2(provider_id) + self.test_parameter_conversion(provider_id) + self.test_function_to_schema_approach(provider_id) + self.test_tool_decorated_function_to_schema_approach(provider_id) + self.test_multi_turn_tool_calling_alpha2(provider_id) + self.test_multi_turn_tool_calling_alpha2_tool_helpers(provider_id) + + # Alpha2 Async tests + asyncio.run(self.test_async_conversation_alpha2(provider_id)) + asyncio.run(self.test_async_tool_calling_alpha2(provider_id)) + + # Legacy Alpha1 test for comparison + self.test_basic_conversation_alpha1_legacy(provider_id) + + def test_basic_conversation_alpha1_legacy(self, provider_id: str) -> None: + """Test legacy Alpha1 conversation for comparison.""" + print( + f"\n📚 Testing legacy Alpha1 for comparison with {self.available_providers[provider_id]['display_name']}" + ) + + try: + with DaprClient() as client: + inputs = [ + conversation.ConversationInput( + content="Hello! Please respond with: 'Hello from Dapr Alpha1!'", role='user' + ) + ] + + response = client.converse_alpha1( + name=provider_id, + inputs=inputs, + temperature=1, + parameters={ + 'max_tokens': 100, + }, + ) + + if response.outputs: + result = response.outputs[0].result + print(f'✅ Alpha1 Response: {result}') + else: + print('❌ No Alpha1 response received') + + except Exception as e: + print(f'❌ Alpha1 legacy conversation error: {e}') + + def cleanup(self) -> None: + # Clean up temporary components directory + if self.components_dir and Path(self.components_dir).exists(): + import shutil + + shutil.rmtree(self.components_dir) + print(f'🧹 Cleaned up components directory: {self.components_dir}') + + +def main(): + """Main function to run the real LLM providers test with Alpha2 API.""" + print('🚀 Real LLM Providers Example for Dapr Conversation API Alpha2') + print('=' * 60) + + # Check for help flag + if '--help' in sys.argv or '-h' in sys.argv: + print(__doc__) + return + + tester = RealLLMProviderTester() + + try: + # Load environment variables + tester.load_environment() + + # Detect available providers + print('\n🔍 Detecting available LLM providers...') + tester.available_providers = tester.detect_available_providers() + + if not tester.available_providers: + print('\n❌ No LLM providers configured!') + print('Please set up API keys in .env file (copy from .env.example)') + print('Available providers: OpenAI, Anthropic, Mistral, DeepSeek, Google AI') + return + + print(f'\n✅ Found {len(tester.available_providers)} configured provider(s)') + + # Create component configurations for all available providers + selected_providers = list(tester.available_providers.keys()) + components_dir = tester.create_component_configs(selected_providers) + + # Manual sidecar setup + print('\n⚠️ IMPORTANT: Make sure Dapr sidecar is running with components from:') + print(f' {components_dir}') + print('\nTo start the sidecar with these components:') + print( + f' dapr run --app-id test-app --dapr-http-port 3500 --dapr-grpc-port 50001 --resources-path {components_dir}' + ) + + # Wait for user to confirm + input('\nPress Enter when Dapr sidecar is running with the component configurations...') + + # Test only the providers we created components for + for provider_id in selected_providers: + if provider_id in tester.available_providers: + tester.run_comprehensive_test(provider_id) + + print(f"\n{'='*60}") + print('🎉 All Alpha2 tests completed!') + print('✅ Real LLM provider integration with Alpha2 API is working correctly') + print('🔧 Features demonstrated:') + print(' • Alpha2 conversation API with sophisticated message types') + print(' • Automatic parameter conversion (raw Python values)') + print(' • Enhanced tool calling capabilities') + print(' • Multi-turn conversations') + print(' • Multi-turn tool calling with context expansion') + print(' • Function-to-schema automatic tool generation') + print(' • Function-to-schema using @tool decorator for automatic tool generation') + print(' • Both sync and async implementations') + print(' • Backward compatibility with Alpha1') + print(f"{'='*60}") + + except KeyboardInterrupt: + print('\n\n⏹️ Tests interrupted by user') + except Exception as e: + print(f'\n❌ Unexpected error: {e}') + import traceback + + traceback.print_exc() + finally: + tester.cleanup() + + +if __name__ == '__main__': + main() diff --git a/ext/dapr-ext-grpc/dapr/ext/grpc/_servicer.py b/ext/dapr-ext-grpc/dapr/ext/grpc/_servicer.py index c51df48b..996267fd 100644 --- a/ext/dapr-ext-grpc/dapr/ext/grpc/_servicer.py +++ b/ext/dapr-ext-grpc/dapr/ext/grpc/_servicer.py @@ -28,7 +28,7 @@ JobEventRequest, ) from dapr.proto.common.v1.common_pb2 import InvokeRequest -from dapr.clients.base import DEFAULT_JSON_CONTENT_TYPE +from dapr.clients._constants import DEFAULT_JSON_CONTENT_TYPE from dapr.clients.grpc._request import InvokeMethodRequest, BindingRequest, JobEvent from dapr.clients.grpc._response import InvokeMethodResponse, TopicEventResponse diff --git a/tests/clients/fake_dapr_server.py b/tests/clients/fake_dapr_server.py index d530b838..a1cbeb4b 100644 --- a/tests/clients/fake_dapr_server.py +++ b/tests/clients/fake_dapr_server.py @@ -34,6 +34,12 @@ EncryptResponse, DecryptRequest, DecryptResponse, + ConversationResultAlpha2, + ConversationResultChoices, + ConversationResultMessage, + ConversationResponseAlpha2, + ConversationToolCalls, + ConversationToolCallsOfFunction, ) from typing import Dict @@ -538,6 +544,82 @@ def ConverseAlpha1(self, request, context): return api_v1.ConversationResponse(contextID=request.contextID, outputs=outputs) + def ConverseAlpha2(self, request, context): + """Mock implementation of the ConverseAlpha2 endpoint.""" + self.check_for_exception(context) + + # Process inputs and create responses with choices structure + outputs = [] + for input_idx, input in enumerate(request.inputs): + choices = [] + + # Process each message in the input + for msg_idx, message in enumerate(input.messages): + response_content = '' + tool_calls = [] + + # Extract content based on message type + if message.HasField('of_user'): + if message.of_user.content: + response_content = f'Response to user: {message.of_user.content[0].text}' + elif message.HasField('of_system'): + if message.of_system.content: + response_content = ( + f'System acknowledged: {message.of_system.content[0].text}' + ) + elif message.HasField('of_assistant'): + if message.of_assistant.content: + response_content = ( + f'Assistant continued: {message.of_assistant.content[0].text}' + ) + elif message.HasField('of_developer'): + if message.of_developer.content: + response_content = ( + f'Developer note processed: {message.of_developer.content[0].text}' + ) + elif message.HasField('of_tool'): + if message.of_tool.content: + response_content = ( + f'Tool result processed: {message.of_tool.content[0].text}' + ) + + # Check if tools are available and simulate tool calling + if request.tools and response_content and 'weather' in response_content.lower(): + # Simulate a tool call for weather requests + for tool in request.tools: + if tool.function and 'weather' in tool.function.name.lower(): + tool_call = ConversationToolCalls( + id=f'call_{input_idx}_{msg_idx}', + function=ConversationToolCallsOfFunction( + name=tool.function.name, + arguments='{"location": "San Francisco", "unit": "celsius"}', + ), + ) + tool_calls.append(tool_call) + response_content = "I'll check the weather for you." + break + + # Create result message + result_message = ConversationResultMessage( + content=response_content, tool_calls=tool_calls + ) + + # Create choice + finish_reason = 'tool_calls' if tool_calls else 'stop' + choice = ConversationResultChoices( + finish_reason=finish_reason, index=msg_idx, message=result_message + ) + choices.append(choice) + + # Create result for this input + result = ConversationResultAlpha2(choices=choices) + outputs.append(result) + + return ConversationResponseAlpha2( + context_id=request.context_id if request.HasField('context_id') else None, + outputs=outputs, + ) + def ScheduleJobAlpha1(self, request, context): self.check_for_exception(context) diff --git a/tests/clients/test_conversation.py b/tests/clients/test_conversation.py new file mode 100644 index 00000000..8a6cc697 --- /dev/null +++ b/tests/clients/test_conversation.py @@ -0,0 +1,1227 @@ +# -*- coding: utf-8 -*- + +""" +Copyright 2025 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + + +import asyncio +import json +import unittest +import uuid + +from google.rpc import code_pb2, status_pb2 + +from dapr.aio.clients import DaprClient as AsyncDaprClient +from dapr.clients import DaprClient +from dapr.clients.exceptions import DaprGrpcError +from dapr.clients.grpc import conversation +from dapr.clients.grpc._conversation_helpers import ( + ToolArgumentError, + ToolExecutionError, + ToolNotFoundError, +) +from dapr.clients.grpc.conversation import ( + ConversationInput, + ConversationInputAlpha2, + ConversationResponseAlpha2, + ConversationTools, + ConversationToolsFunction, + FunctionBackend, + create_assistant_message, + create_system_message, + create_tool_message, + create_user_message, + execute_registered_tool_async, + get_registered_tools, + register_tool, + unregister_tool, + ConversationResultAlpha2Message, + ConversationResultAlpha2Choices, + ConversationResultAlpha2, + ConversationMessage, + ConversationMessageOfAssistant, + ConversationToolCalls, + ConversationToolCallsOfFunction, + execute_registered_tool, +) +from dapr.clients.grpc.conversation import ( + tool as tool_decorator, +) +from dapr.conf import settings +from tests.clients.fake_dapr_server import FakeDaprSidecar + +""" +Comprehensive tests for Dapr conversation API functionality. + +This test suite covers: +- Basic conversation API (Alpha1) +- Advanced conversation API (Alpha2) with tool calling +- Multi-turn conversations +- Different message types (user, system, assistant, developer, tool) +- Error handling +- Both sync and async implementations +- Parameter conversion and validation +""" + + +def create_weather_tool(): + """Create a weather tool for testing.""" + return ConversationTools( + function=ConversationToolsFunction( + name='get_weather', + description='Get weather information for a location', + parameters={ + 'type': 'object', + 'properties': { + 'location': { + 'type': 'string', + 'description': 'The city and state, e.g. San Francisco, CA', + }, + 'unit': { + 'type': 'string', + 'enum': ['celsius', 'fahrenheit'], + 'description': 'Temperature unit', + }, + }, + 'required': ['location'], + }, + ) + ) + + +def create_calculate_tool(): + """Create a calculate tool for testing.""" + return ConversationTools( + function=ConversationToolsFunction( + name='calculate', + description='Perform mathematical calculations', + parameters={ + 'type': 'object', + 'properties': { + 'expression': { + 'type': 'string', + 'description': 'Mathematical expression to evaluate', + } + }, + 'required': ['expression'], + }, + ) + ) + + +class ConversationTestBase: + """Base class for conversation tests with common setup.""" + + grpc_port = 50011 + http_port = 3510 + scheme = '' + + @classmethod + def setUpClass(cls): + cls._fake_dapr_server = FakeDaprSidecar(grpc_port=cls.grpc_port, http_port=cls.http_port) + cls._fake_dapr_server.start() + # Configure health check to use fake server's HTTP port + settings.DAPR_HTTP_PORT = cls.http_port + settings.DAPR_HTTP_ENDPOINT = f'http://127.0.0.1:{cls.http_port}' + + @classmethod + def tearDownClass(cls): + cls._fake_dapr_server.stop() + + +class ConversationTestBaseSync(ConversationTestBase, unittest.TestCase): + """Base class for conversation tests with common setup.""" + + def setUp(self): + super().setUp() + self.client = DaprClient(f'{self.scheme}localhost:{self.grpc_port}') + + def tearDown(self): + super().tearDown() + self.client.close() + + +class ConversationTestBaseAsync(ConversationTestBase, unittest.IsolatedAsyncioTestCase): + """Base class for conversation tests with common setup.""" + + async def asyncSetUp(self): + await super().asyncSetUp() + self.client = AsyncDaprClient(f'{self.scheme}localhost:{self.grpc_port}') + + async def asyncTearDown(self): + await super().asyncTearDown() + await self.client.close() + + +class ConversationAlpha1SyncTests(ConversationTestBaseSync): + """Synchronous Alpha1 conversation API tests.""" + + def test_basic_conversation_alpha1(self): + """Test basic Alpha1 conversation functionality.""" + inputs = [ + ConversationInput(content='Hello', role='user'), + ConversationInput(content='How are you?', role='user'), + ] + + response = self.client.converse_alpha1(name='test-llm', inputs=inputs) + + self.assertIsNotNone(response) + self.assertEqual(len(response.outputs), 2) + self.assertIn('Hello', response.outputs[0].result) + self.assertIn('How are you?', response.outputs[1].result) + + def test_conversation_alpha1_with_options(self): + """Test Alpha1 conversation with various options.""" + inputs = [ConversationInput(content='Hello with options', role='user', scrub_pii=True)] + + response = self.client.converse_alpha1( + name='test-llm', + inputs=inputs, + context_id='test-context-123', + temperature=0.7, + scrub_pii=True, + metadata={'test_key': 'test_value'}, + ) + + self.assertIsNotNone(response) + self.assertEqual(len(response.outputs), 1) + self.assertEqual(response.context_id, 'test-context-123') + + def test_alpha1_parameter_conversion(self): + """Test Alpha1 parameter conversion with raw Python values.""" + inputs = [ConversationInput(content='Test with parameters', role='user')] + + # Test with raw Python parameters - these should be automatically converted + response = self.client.converse_alpha1( + name='test-llm', + inputs=inputs, + parameters={ + 'temperature': 0.7, + 'max_tokens': 1000, + 'top_p': 0.9, + 'frequency_penalty': 0.0, + 'presence_penalty': 0.0, + }, + ) + + self.assertIsNotNone(response) + self.assertEqual(len(response.outputs), 1) + + def test_alpha1_error_handling(self): + """Test Alpha1 conversation error handling.""" + # Setup server to raise an exception + self._fake_dapr_server.raise_exception_on_next_call( + status_pb2.Status(code=code_pb2.INVALID_ARGUMENT, message='Alpha1 test error') + ) + + inputs = [ConversationInput(content='Error test', role='user')] + + with self.assertRaises(DaprGrpcError) as context: + self.client.converse_alpha1(name='test-llm', inputs=inputs) + self.assertIn('Alpha1 test error', str(context.exception)) + + +class ConversationAlpha2SyncTests(ConversationTestBaseSync): + """Synchronous Alpha2 conversation API tests.""" + + def test_basic_conversation_alpha2(self): + """Test basic Alpha2 conversation functionality.""" + user_message = create_user_message('Hello Alpha2!') + input_alpha2 = ConversationInputAlpha2(messages=[user_message]) + + response = self.client.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + + self.assertIsNotNone(response) + self.assertEqual(len(response.outputs), 1) + self.assertEqual(len(response.outputs[0].choices), 1) + + choice = response.outputs[0].choices[0] + self.assertEqual(choice.finish_reason, 'stop') + self.assertIn('Hello Alpha2!', choice.message.content) + + def test_conversation_alpha2_with_system_message(self): + """Test Alpha2 conversation with system message.""" + system_message = create_system_message('You are a helpful assistant.') + user_message = create_user_message('Hello!') + + input_alpha2 = ConversationInputAlpha2( + messages=[system_message, user_message], scrub_pii=False + ) + + response = self.client.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + + self.assertIsNotNone(response) + self.assertEqual(len(response.outputs[0].choices), 2) + + # Check system message response + system_choice = response.outputs[0].choices[0] + self.assertIn('System acknowledged', system_choice.message.content) + + # Check user message response + user_choice = response.outputs[0].choices[1] + self.assertIn('Response to user', user_choice.message.content) + + def test_conversation_alpha2_with_options(self): + """Test Alpha2 conversation with various options.""" + user_message = create_user_message('Alpha2 with options') + input_alpha2 = ConversationInputAlpha2(messages=[user_message], scrub_pii=True) + + response = self.client.converse_alpha2( + name='test-llm', + inputs=[input_alpha2], + context_id='alpha2-context-123', + temperature=0.8, + scrub_pii=True, + metadata={'alpha2_test': 'true'}, + tool_choice='none', + ) + + self.assertIsNotNone(response) + self.assertEqual(response.context_id, 'alpha2-context-123') + + def test_alpha2_parameter_conversion(self): + """Test Alpha2 parameter conversion with various types.""" + user_message = create_user_message('Parameter conversion test') + input_alpha2 = ConversationInputAlpha2(messages=[user_message]) + + response = self.client.converse_alpha2( + name='test-llm', + inputs=[input_alpha2], + parameters={ + 'model': 'gpt-4o-mini', + 'temperature': 0.7, + 'max_tokens': 1000, + 'top_p': 1.0, + 'frequency_penalty': 0.0, + 'presence_penalty': 0.0, + 'stream': False, + }, + ) + + self.assertIsNotNone(response) + + def test_alpha2_error_handling(self): + """Test Alpha2 conversation error handling.""" + self._fake_dapr_server.raise_exception_on_next_call( + status_pb2.Status(code=code_pb2.INVALID_ARGUMENT, message='Alpha2 test error') + ) + + user_message = create_user_message('Error test') + input_alpha2 = ConversationInputAlpha2(messages=[user_message]) + + with self.assertRaises(DaprGrpcError) as context: + self.client.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + self.assertIn('Alpha2 test error', str(context.exception)) + + +class ConversationToolCallingSyncTests(ConversationTestBaseSync): + """Synchronous tool calling tests for Alpha2.""" + + def test_tool_calling_weather(self): + """Test tool calling with weather tool.""" + weather_tool = create_weather_tool() + user_message = create_user_message('What is the weather in San Francisco?') + + input_alpha2 = ConversationInputAlpha2(messages=[user_message]) + + response = self.client.converse_alpha2( + name='test-llm', inputs=[input_alpha2], tools=[weather_tool], tool_choice='auto' + ) + + self.assertIsNotNone(response) + choice = response.outputs[0].choices[0] + self.assertEqual(choice.finish_reason, 'tool_calls') + self.assertEqual(len(choice.message.tool_calls), 1) + + tool_call = choice.message.tool_calls[0] + self.assertEqual(tool_call.function.name, 'get_weather') + self.assertIn('San Francisco', tool_call.function.arguments) + + def test_tool_calling_calculate(self): + """Test tool calling with calculate tool.""" + calc_tool = create_calculate_tool() + user_message = create_user_message('Calculate 15 * 23') + + input_alpha2 = ConversationInputAlpha2(messages=[user_message]) + + response = self.client.converse_alpha2( + name='test-llm', inputs=[input_alpha2], tools=[calc_tool] + ) + + # Note: Our fake server only triggers weather tools, so this won't return tool calls + # but it tests that the API works with different tools + self.assertIsNotNone(response) + choice = response.outputs[0].choices[0] + self.assertIn('Calculate', choice.message.content) + + def test_multiple_tools(self): + """Test conversation with multiple tools.""" + weather_tool = create_weather_tool() + calc_tool = create_calculate_tool() + + user_message = create_user_message('I need weather and calculation help') + input_alpha2 = ConversationInputAlpha2(messages=[user_message]) + + response = self.client.converse_alpha2( + name='test-llm', + inputs=[input_alpha2], + tools=[weather_tool, calc_tool], + tool_choice='auto', + ) + + self.assertIsNotNone(response) + # The fake server will call weather tool if "weather" is in the message + choice = response.outputs[0].choices[0] + self.assertEqual(choice.finish_reason, 'tool_calls') + + def test_tool_choice_none(self): + """Test tool choice set to 'none'.""" + + weather_tool = create_weather_tool() + user_message = create_user_message('What is the weather today?') + + input_alpha2 = ConversationInputAlpha2(messages=[user_message]) + + response = self.client.converse_alpha2( + name='test-llm', inputs=[input_alpha2], tools=[weather_tool], tool_choice='none' + ) + + self.assertIsNotNone(response) + choice = response.outputs[0].choices[0] + # With tool_choice='none', should not make tool calls even if weather is mentioned + # (though our fake server may still trigger based on content) + self.assertIsNotNone(choice.message.content) + + def test_tool_choice_specific(self): + """Test tool choice set to specific tool name.""" + weather_tool = create_weather_tool() + calc_tool = create_calculate_tool() + + user_message = create_user_message('What is the weather like?') + input_alpha2 = ConversationInputAlpha2(messages=[user_message]) + + response = self.client.converse_alpha2( + name='test-llm', + inputs=[input_alpha2], + tools=[weather_tool, calc_tool], + tool_choice='get_weather', + ) + + self.assertIsNotNone(response) + choice = response.outputs[0].choices[0] + if choice.finish_reason == 'tool_calls': + tool_call = choice.message.tool_calls[0] + self.assertEqual(tool_call.function.name, 'get_weather') + + +class ConversationMultiTurnSyncTests(ConversationTestBaseSync): + """Multi-turn conversation tests for Alpha2.""" + + def test_multi_turn_conversation(self): + """Test multi-turn conversation with different message types.""" + with DaprClient(f'{self.scheme}localhost:{self.grpc_port}') as client: + # Create a conversation with system, user, and assistant messages + system_message = create_system_message('You are a helpful AI assistant.') + user_message1 = create_user_message('Hello, how are you?') + assistant_message = create_assistant_message('I am doing well, thank you!') + user_message2 = create_user_message('What can you help me with?') + + input_alpha2 = ConversationInputAlpha2( + messages=[system_message, user_message1, assistant_message, user_message2] + ) + + response = client.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + + self.assertIsNotNone(response) + self.assertEqual(len(response.outputs[0].choices), 4) + + # Check each response type + choices = response.outputs[0].choices + self.assertIn('System acknowledged', choices[0].message.content) + self.assertIn('Response to user', choices[1].message.content) + self.assertIn('Assistant continued', choices[2].message.content) + self.assertIn('Response to user', choices[3].message.content) + + def test_tool_calling_workflow(self): + """Test complete tool calling workflow.""" + with DaprClient(f'{self.scheme}localhost:{self.grpc_port}') as client: + # Step 1: User asks for weather + weather_tool = create_weather_tool() + user_message = create_user_message('What is the weather in Tokyo?') + + input_alpha2 = ConversationInputAlpha2(messages=[user_message]) + + response1 = client.converse_alpha2( + name='test-llm', inputs=[input_alpha2], tools=[weather_tool] + ) + + # Should get tool call + self.assertIsNotNone(response1) + choice = response1.outputs[0].choices[0] + self.assertEqual(choice.finish_reason, 'tool_calls') + tool_call = choice.message.tool_calls[0] + + # Step 2: Send tool result back + tool_result_message = create_tool_message( + tool_id=tool_call.id, + name='get_weather', + content='{"temperature": 18, "condition": "cloudy", "humidity": 75}', + ) + + result_input = ConversationInputAlpha2(messages=[tool_result_message]) + + response2 = client.converse_alpha2(name='test-llm', inputs=[result_input]) + + # Should get processed tool result + self.assertIsNotNone(response2) + result_choice = response2.outputs[0].choices[0] + self.assertIn('Tool result processed', result_choice.message.content) + + def test_conversation_context_continuity(self): + """Test conversation context continuity with context_id.""" + with DaprClient(f'{self.scheme}localhost:{self.grpc_port}') as client: + context_id = 'multi-turn-test-123' + + # First turn + user_message1 = create_user_message('My name is Alice.') + input1 = ConversationInputAlpha2(messages=[user_message1]) + + response1 = client.converse_alpha2( + name='test-llm', inputs=[input1], context_id=context_id + ) + + self.assertEqual(response1.context_id, context_id) + + # Second turn with same context + user_message2 = create_user_message('What is my name?') + input2 = ConversationInputAlpha2(messages=[user_message2]) + + response2 = client.converse_alpha2( + name='test-llm', inputs=[input2], context_id=context_id + ) + + self.assertEqual(response2.context_id, context_id) + self.assertIsNotNone(response2.outputs[0].choices[0].message.content) + + +class ConversationAsyncTests(ConversationTestBaseAsync): + """Asynchronous conversation API tests.""" + + async def test_basic_async_conversation_alpha1(self): + """Test basic async Alpha1 conversation.""" + inputs = [ + ConversationInput(content='Hello async', role='user'), + ConversationInput(content='How are you async?', role='user'), + ] + + response = await self.client.converse_alpha1(name='test-llm', inputs=inputs) + + self.assertIsNotNone(response) + self.assertEqual(len(response.outputs), 2) + self.assertIn('Hello async', response.outputs[0].result) + + async def test_basic_async_conversation_alpha2(self): + """Test basic async Alpha2 conversation.""" + user_message = create_user_message('Hello async Alpha2!') + input_alpha2 = ConversationInputAlpha2(messages=[user_message]) + + response = await self.client.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + + self.assertIsNotNone(response) + choice = response.outputs[0].choices[0] + self.assertIn('Hello async Alpha2!', choice.message.content) + + async def test_async_tool_calling(self): + """Test async tool calling.""" + weather_tool = create_weather_tool() + user_message = create_user_message('Async weather request for London') + + input_alpha2 = ConversationInputAlpha2(messages=[user_message]) + + response = await self.client.converse_alpha2( + name='test-llm', inputs=[input_alpha2], tools=[weather_tool] + ) + + self.assertIsNotNone(response) + choice = response.outputs[0].choices[0] + self.assertEqual(choice.finish_reason, 'tool_calls') + tool_call = choice.message.tool_calls[0] + self.assertEqual(tool_call.function.name, 'get_weather') + + async def test_concurrent_async_conversations(self): + """Test multiple concurrent async conversations.""" + + async def run_alpha1_conversation(message, session_id): + inputs = [ConversationInput(content=message, role='user')] + response = await self.client.converse_alpha1( + name='test-llm', inputs=inputs, context_id=session_id + ) + return response.outputs[0].result + + async def run_alpha2_conversation(message, session_id): + user_message = create_user_message(message) + input_alpha2 = ConversationInputAlpha2(messages=[user_message]) + response = await self.client.converse_alpha2( + name='test-llm', inputs=[input_alpha2], context_id=session_id + ) + return response.outputs[0].choices[0].message.content + + # Run concurrent conversations with both Alpha1 and Alpha2 + tasks = [ + run_alpha1_conversation('First Alpha1 message', 'concurrent-alpha1'), + run_alpha2_conversation('First Alpha2 message', 'concurrent-alpha2'), + run_alpha1_conversation('Second Alpha1 message', 'concurrent-alpha1-2'), + run_alpha2_conversation('Second Alpha2 message', 'concurrent-alpha2-2'), + ] + + results = await asyncio.gather(*tasks) + + self.assertEqual(len(results), 4) + for result in results: + self.assertIsNotNone(result) + self.assertIsInstance(result, str) + + async def test_async_multi_turn_with_tools(self): + """Test async multi-turn conversation with tool calling.""" + # First turn: user asks for weather + weather_tool = create_weather_tool() + user_message = create_user_message('Async weather for Paris') + input1 = ConversationInputAlpha2(messages=[user_message]) + + response1 = await self.client.converse_alpha2( + name='test-llm', + inputs=[input1], + tools=[weather_tool], + context_id='async-multi-turn', + ) + + # Should get tool call + self.assertEqual(response1.outputs[0].choices[0].finish_reason, 'tool_calls') + tool_call = response1.outputs[0].choices[0].message.tool_calls[0] + + # Second turn: provide tool result + tool_result_message = create_tool_message( + tool_id=tool_call.id, + name='get_weather', + content='{"temperature": 22, "condition": "sunny"}', + ) + input2 = ConversationInputAlpha2(messages=[tool_result_message]) + + response2 = await self.client.converse_alpha2( + name='test-llm', inputs=[input2], context_id='async-multi-turn' + ) + + self.assertIsNotNone(response2) + self.assertIn('Tool result processed', response2.outputs[0].choices[0].message.content) + + async def test_async_error_handling(self): + """Test async conversation error handling.""" + self._fake_dapr_server.raise_exception_on_next_call( + status_pb2.Status(code=code_pb2.INVALID_ARGUMENT, message='Async test error') + ) + + inputs = [ConversationInput(content='Async error test', role='user')] + + with self.assertRaises(DaprGrpcError) as context: + await self.client.converse_alpha1(name='test-llm', inputs=inputs) + self.assertIn('Async test error', str(context.exception)) + + +class ConversationParameterTests(ConversationTestBaseSync): + """Tests for parameter handling and conversion.""" + + def test_parameter_edge_cases(self): + """Test parameter conversion with edge cases.""" + user_message = create_user_message('Edge cases test') + input_alpha2 = ConversationInputAlpha2(messages=[user_message]) + + response = self.client.converse_alpha2( + name='test-llm', + inputs=[input_alpha2], + parameters={ + 'int32_max': 2147483647, # Int32 maximum + 'int64_large': 9999999999, # Requires Int64 + 'negative_temp': -0.5, # Negative float + 'zero_value': 0, # Zero integer + 'false_flag': False, # Boolean false + 'true_flag': True, # Boolean true + 'empty_string': '', # Empty string + }, + ) + + self.assertIsNotNone(response) + + def test_realistic_provider_parameters(self): + """Test with realistic LLM provider parameters.""" + user_message = create_user_message('Provider parameters test') + input_alpha2 = ConversationInputAlpha2(messages=[user_message]) + + # OpenAI-style parameters + response1 = self.client.converse_alpha2( + name='test-llm', + inputs=[input_alpha2], + parameters={ + 'model': 'gpt-4o-mini', + 'temperature': 0.7, + 'max_tokens': 1000, + 'top_p': 1.0, + 'frequency_penalty': 0.0, + 'presence_penalty': 0.0, + 'stream': False, + 'tool_choice': 'auto', + }, + ) + + # Anthropic-style parameters + response2 = self.client.converse_alpha2( + name='test-llm', + inputs=[input_alpha2], + parameters={ + 'model': 'claude-3-5-sonnet-20241022', + 'max_tokens': 4096, + 'temperature': 0.8, + 'top_p': 0.9, + 'top_k': 250, + 'stream': False, + }, + ) + + self.assertIsNotNone(response1) + self.assertIsNotNone(response2) + + +class ConversationValidationTests(ConversationTestBaseSync): + """Tests for input validation and edge cases.""" + + def test_empty_inputs_alpha1(self): + """Test Alpha1 with empty inputs.""" + response = self.client.converse_alpha1(name='test-llm', inputs=[]) + self.assertIsNotNone(response) + + def test_empty_inputs_alpha2(self): + """Test Alpha2 with empty inputs.""" + response = self.client.converse_alpha2(name='test-llm', inputs=[]) + self.assertIsNotNone(response) + + def test_empty_messages_alpha2(self): + """Test Alpha2 with empty messages in input.""" + input_alpha2 = ConversationInputAlpha2(messages=[]) + response = self.client.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + self.assertIsNotNone(response) + + def test_mixed_alpha1_alpha2_compatibility(self): + """Test that Alpha1 and Alpha2 can be used in the same session.""" + # Alpha1 call + alpha1_inputs = [ConversationInput(content='Alpha1 call', role='user')] + alpha1_response = self.client.converse_alpha1(name='test-llm', inputs=alpha1_inputs) + + # Alpha2 call + user_message = create_user_message('Alpha2 call') + alpha2_input = ConversationInputAlpha2(messages=[user_message]) + alpha2_response = self.client.converse_alpha2(name='test-llm', inputs=[alpha2_input]) + + # Both should work + self.assertIsNotNone(alpha1_response) + self.assertIsNotNone(alpha2_response) + + # Check response structures are different but valid + self.assertTrue(hasattr(alpha1_response, 'outputs')) + self.assertTrue(hasattr(alpha2_response, 'outputs')) + self.assertTrue(hasattr(alpha2_response.outputs[0], 'choices')) + + +class ConversationToolHelpersSyncTests(ConversationTestBaseSync): + """Tests for conversation tool helpers, registry, and backends (sync).""" + + def tearDown(self): + # Cleanup tools with known prefixes + for t in list(get_registered_tools()): + try: + name = t.function.name + if name.startswith('test_') or name.startswith('ns_') or name.startswith('dup_'): + unregister_tool(name) + except Exception: + continue + + def test_tool_decorator_namespace_and_name_override(self): + ns_unique = uuid.uuid4().hex[:6] + name_override = f'test_sum_{ns_unique}' + + @tool_decorator(namespace=f'ns.{ns_unique}', name=name_override) + def foo(x: int, y: int) -> int: + return x + y + + names = {t.function.name for t in get_registered_tools()} + self.assertIn(name_override, names) + unregister_tool(name_override) + + ns_tool = f'ns.{ns_unique}.bar' + + @tool_decorator(namespace=f'ns.{ns_unique}') + def bar(q: int) -> int: + return q * 2 + + names = {t.function.name for t in get_registered_tools()} + self.assertIn(ns_tool, names) + unregister_tool(ns_tool) + + def test_register_tool_duplicate_raises(self): + dup_name = f'dup_tool_{uuid.uuid4().hex[:6]}' + ct = ConversationTools( + function=ConversationToolsFunction(name=dup_name, parameters={'type': 'object'}), + backend=FunctionBackend(lambda: None), + ) + register_tool(dup_name, ct) + try: + with self.assertRaises(ValueError): + register_tool(dup_name, ct) + finally: + unregister_tool(dup_name) + + def test_conversationtools_invoke_without_backend_raises(self): + ct = ConversationTools( + function=ConversationToolsFunction( + name='test_no_backend', parameters={'type': 'object'} + ), + backend=None, + ) + with self.assertRaises(ToolExecutionError): + ct.invoke({'a': 1}) + + async def run(): + with self.assertRaises(ToolExecutionError): + await ct.ainvoke({'a': 1}) + + asyncio.run(run()) + + def test_functionbackend_sync_and_async_and_timeout(self): + def mul(a: int, b: int) -> int: + return a * b + + fb_sync = FunctionBackend(mul) + self.assertEqual( + fb_sync.invoke(ConversationToolsFunction(name='mul'), {'a': 3, 'b': 5}), + 15, + ) + + async def run_sync_via_async(): + res = await fb_sync.ainvoke(ConversationToolsFunction(name='mul'), {'a': 2, 'b': 7}) + self.assertEqual(res, 14) + + asyncio.run(run_sync_via_async()) + + async def wait_and_return(x: int, delay: float = 0.01) -> int: + await asyncio.sleep(delay) + return x + + fb_async = FunctionBackend(wait_and_return) + with self.assertRaises(ToolExecutionError): + fb_async.invoke(ConversationToolsFunction(name='wait'), {'x': 1}) + + async def run_async_ok(): + res = await fb_async.ainvoke(ConversationToolsFunction(name='wait'), {'x': 42}) + self.assertEqual(res, 42) + + asyncio.run(run_async_ok()) + + async def run_async_timeout(): + with self.assertRaises(ToolExecutionError): + await fb_async.ainvoke( + ConversationToolsFunction(name='wait'), + {'x': 1, 'delay': 0.2}, + timeout=0.01, + ) + + asyncio.run(run_async_timeout()) + + with self.assertRaises(ToolArgumentError): + fb_sync.invoke(ConversationToolsFunction(name='mul'), {'a': 1}) + + async def run_missing_arg_async(): + with self.assertRaises(ToolArgumentError): + await fb_sync.ainvoke(ConversationToolsFunction(name='mul'), {'a': 1}) + + asyncio.run(run_missing_arg_async()) + + def test_conversationtoolsfunction_from_function_and_schema(self): + def greet(name: str, punctuation: str = '!') -> str: + """Say hello. + + Args: + name: Person to greet + punctuation: Trailing punctuation + """ + + return f'Hello, {name}{punctuation}' + + spec = ConversationToolsFunction.from_function(greet, register=False) + schema = spec.schema_as_dict() + self.assertIn('name', schema.get('properties', {})) + self.assertIn('name', schema.get('required', [])) + self.assertIn('punctuation', schema.get('properties', {})) + + spec2 = ConversationToolsFunction.from_function(greet, register=True) + try: + names = {t.function.name for t in get_registered_tools()} + self.assertIn(spec2.name, names) + finally: + unregister_tool(spec2.name) + + def test_message_helpers_and_to_proto(self): + user_msg = conversation.create_user_message('hi') + self.assertIsNotNone(user_msg.of_user) + self.assertEqual(user_msg.of_user.content[0].text, 'hi') + proto_user = user_msg.to_proto() + self.assertEqual(proto_user.of_user.content[0].text, 'hi') + + sys_msg = conversation.create_system_message('sys') + proto_sys = sys_msg.to_proto() + self.assertEqual(proto_sys.of_system.content[0].text, 'sys') + + tc = conversation.ConversationToolCalls( + id='abc123', + function=conversation.ConversationToolCallsOfFunction(name='fn', arguments='{}'), + ) + asst_msg = conversation.ConversationMessage( + of_assistant=conversation.ConversationMessageOfAssistant( + content=[conversation.ConversationMessageContent(text='ok')], + tool_calls=[tc], + ) + ) + proto_asst = asst_msg.to_proto() + self.assertEqual(proto_asst.of_assistant.content[0].text, 'ok') + self.assertEqual(proto_asst.of_assistant.tool_calls[0].function.name, 'fn') + + tool_msg = conversation.create_tool_message('tid1', 'get_weather', 'cloudy') + proto_tool = tool_msg.to_proto() + self.assertEqual(proto_tool.of_tool.tool_id, 'tid1') + self.assertEqual(proto_tool.of_tool.name, 'get_weather') + self.assertEqual(proto_tool.of_tool.content[0].text, 'cloudy') + + +class ConversationToolHelpersAsyncTests(ConversationTestBaseAsync): + async def asyncTearDown(self): + for t in list(get_registered_tools()): + try: + name = t.function.name + if name.startswith('test_'): + unregister_tool(name) + except Exception: + continue + + async def test_execute_registered_tool_async(self): + unique = uuid.uuid4().hex[:8] + tool_name = f'test_async_{unique}' + + @tool_decorator(name=tool_name) + async def echo(value: str, delay: float = 0.0) -> str: + await asyncio.sleep(delay) + return value + + out = await execute_registered_tool_async(tool_name, {'value': 'hello'}) + self.assertEqual(out, 'hello') + + with self.assertRaises(ToolExecutionError): + await execute_registered_tool_async( + tool_name, {'value': 'slow', 'delay': 0.2}, timeout=0.01 + ) + unregister_tool(tool_name) + + +class TestStringifyToolOutputIntegration(unittest.TestCase): + def test_create_tool_message_with_bytes_and_bytearray(self): + import base64 + + # bytes + raw = bytes([0, 1, 2, 250, 255]) + msg = create_tool_message('tidb', 'bin', raw) + self.assertTrue(msg.of_tool.content[0].text.startswith('base64:')) + self.assertEqual( + msg.of_tool.content[0].text, + 'base64:' + base64.b64encode(raw).decode('ascii'), + ) + # bytearray + ba = bytearray(raw) + msg2 = create_tool_message('tidb2', 'bin', ba) + self.assertEqual( + msg2.of_tool.content[0].text, + 'base64:' + base64.b64encode(bytes(ba)).decode('ascii'), + ) + + def test_create_tool_message_with_dataclass_and_plain_object(self): + import json + from dataclasses import dataclass + + @dataclass + class P: + x: int + y: str + + p = P(3, 'z') + msg = create_tool_message('tiddc', 'dc', p) + self.assertEqual(json.loads(msg.of_tool.content[0].text), {'x': 3, 'y': 'z'}) + + class Plain: + def __init__(self): + self.a = 1 + self.b = 'b' + self.fn = lambda: 42 # filtered out + + obj = Plain() + msg2 = create_tool_message('tidobj', 'plain', obj) + self.assertEqual(json.loads(msg2.of_tool.content[0].text), {'a': 1, 'b': 'b'}) + + def test_create_tool_message_json_failure_falls_back_to_str(self): + class Bad: + def __init__(self): + self.s = {1, 2, 3} # set not JSON serializable + + def __str__(self): + return 'badobj' + + m = create_tool_message('tidbad', 'bad', Bad()) + self.assertEqual(m.of_tool.content[0].text, 'badobj') + + +class TestIndentLines(unittest.TestCase): + def test_single_line_with_indent(self): + result = conversation._indent_lines('Note', 'Hello', 2) + self.assertEqual(result, ' Note: Hello') + + def test_multiline_example(self): + text = 'This is a long\nmultiline\ntext block' + result = conversation._indent_lines('Description', text, 4) + expected = ( + ' Description: This is a long\n' + ' multiline\n' + ' text block' + ) + self.assertEqual(result, expected) + + def test_zero_indent(self): + result = conversation._indent_lines('Title', 'Line one\nLine two', 0) + expected = 'Title: Line one\n' ' Line two' + self.assertEqual(result, expected) + + def test_empty_string(self): + result = conversation._indent_lines('Empty', '', 3) + # Should end with a space after colon + self.assertEqual(result, ' Empty: ') + + def test_none_text(self): + result = conversation._indent_lines('NoneCase', None, 1) + self.assertEqual(result, ' NoneCase: ') + + def test_title_length_affects_indent(self): + # Title length is 1, indent_after_first_line should be indent + len(title) + 2 + # indent=2, len(title)=1 => 2 + 1 + 2 = 5 spaces on continuation lines + result = conversation._indent_lines('T', 'a\nb', 2) + expected = ' T: a\n' ' b' + self.assertEqual(result, expected) + + +class TestToAssistantMessages(unittest.TestCase): + def test_single_choice_content_only(self): + # Prepare a response with a single output and single choice, content only + msg = ConversationResultAlpha2Message(content='Hello from assistant!', tool_calls=[]) + choice = ConversationResultAlpha2Choices(finish_reason='stop', index=0, message=msg) + response = ConversationResponseAlpha2( + context_id='ctx1', outputs=[ConversationResultAlpha2(choices=[choice])] + ) + + out = response.to_assistant_messages() + + self.assertIsInstance(out, list) + self.assertEqual(len(out), 1) + self.assertIsInstance(out[0], ConversationMessage) + self.assertIsNotNone(out[0].of_assistant) + self.assertIsInstance(out[0].of_assistant, ConversationMessageOfAssistant) + self.assertEqual(len(out[0].of_assistant.content), 1) + self.assertEqual(out[0].of_assistant.content[0].text, 'Hello from assistant!') + self.assertEqual(len(out[0].of_assistant.tool_calls), 0) + + def test_multiple_outputs_and_choices(self): + # Prepare response with 2 outputs, each with 2 choices + def make_choice(idx: int, text: str) -> ConversationResultAlpha2Choices: + return ConversationResultAlpha2Choices( + finish_reason='stop', + index=idx, + message=ConversationResultAlpha2Message(content=text, tool_calls=[]), + ) + + outputs = [ + ConversationResultAlpha2(choices=[make_choice(0, 'A1'), make_choice(1, 'A2')]), + ConversationResultAlpha2(choices=[make_choice(0, 'B1'), make_choice(1, 'B2')]), + ] + + response = ConversationResponseAlpha2(context_id=None, outputs=outputs) + out = response.to_assistant_messages() + + # Expect 4 assistant messages in order + self.assertEqual(len(out), 4) + texts = [m.of_assistant.content[0].text for m in out] + self.assertEqual(texts, ['A1', 'A2', 'B1', 'B2']) + + def test_choice_with_tool_calls_preserved(self): + tool_call = ConversationToolCalls( + id='call-123', + function=ConversationToolCallsOfFunction( + name='get_weather', arguments='{"location":"Paris","unit":"celsius"}' + ), + ) + msg = ConversationResultAlpha2Message(content='', tool_calls=[tool_call]) + choice = ConversationResultAlpha2Choices(finish_reason='tool_calls', index=0, message=msg) + response = ConversationResponseAlpha2( + context_id='ctx2', outputs=[ConversationResultAlpha2(choices=[choice])] + ) + + out = response.to_assistant_messages() + + self.assertEqual(len(out), 1) + asst = out[0].of_assistant + self.assertIsNotNone(asst) + self.assertEqual(len(asst.content), 0) + self.assertEqual(len(asst.tool_calls), 1) + tc = asst.tool_calls[0] + self.assertEqual(tc.id, 'call-123') + self.assertIsNotNone(tc.function) + self.assertEqual(tc.function.name, 'get_weather') + self.assertEqual(tc.function.arguments, '{"location":"Paris","unit":"celsius"}') + + def test_empty_and_none_outputs(self): + # Empty list outputs + response_empty = ConversationResponseAlpha2(context_id=None, outputs=[]) + self.assertEqual(response_empty.to_assistant_messages(), []) + + # None outputs (even though type says List, code handles None via `or []`) + response_none = ConversationResponseAlpha2(context_id=None, outputs=None) # type: ignore[arg-type] + self.assertEqual(response_none.to_assistant_messages(), []) + + +class ExecuteRegisteredToolSyncTests(unittest.TestCase): + def tearDown(self): + # Cleanup all tools we may have registered by name prefix + # (names are randomized per test to avoid collisions) + pass # Names are unique per test; we explicitly unregister in tests + + def test_sync_success_with_kwargs_and_sequence_and_json(self): + name = f'test_add_{uuid.uuid4().hex[:8]}' + + @tool_decorator(name=name) + def add(a: int, b: int) -> int: + return a + b + + try: + # kwargs mapping + out = execute_registered_tool(name, {'a': 2, 'b': 3}) + self.assertEqual(out, 5) + + # sequence args + out2 = execute_registered_tool(name, [10, 5]) + self.assertEqual(out2, 15) + + # JSON string params + out3 = execute_registered_tool(name, json.dumps({'a': '7', 'b': '8'})) + self.assertEqual(out3, 15) + finally: + unregister_tool(name) + + def test_sync_invalid_params_type_raises(self): + name = f'test_echo_{uuid.uuid4().hex[:8]}' + + @tool_decorator(name=name) + def echo(x: str) -> str: + return x + + try: + with self.assertRaises(ToolArgumentError): + execute_registered_tool(name, 123) # not Mapping/Sequence/None + finally: + unregister_tool(name) + + def test_sync_unregistered_tool_raises(self): + name = f'does_not_exist_{uuid.uuid4().hex[:8]}' + with self.assertRaises(ToolNotFoundError): + execute_registered_tool(name, {'a': 1}) + + def test_sync_tool_exception_wrapped(self): + name = f'test_fail_{uuid.uuid4().hex[:8]}' + + @tool_decorator(name=name) + def fail() -> None: + raise ValueError('boom') + + try: + with self.assertRaises(ToolExecutionError): + execute_registered_tool(name) + finally: + unregister_tool(name) + + +class ExecuteRegisteredToolAsyncTests(unittest.IsolatedAsyncioTestCase): + async def asyncTearDown(self): + # Nothing persistent; individual tests unregister. + pass + + async def test_async_success_and_json_params(self): + name = f'test_async_echo_{uuid.uuid4().hex[:8]}' + + @tool_decorator(name=name) + async def echo(value: str) -> str: + await asyncio.sleep(0) + return value + + try: + out = await execute_registered_tool_async(name, {'value': 'hi'}) + self.assertEqual(out, 'hi') + + out2 = await execute_registered_tool_async(name, json.dumps({'value': 'ok'})) + self.assertEqual(out2, 'ok') + finally: + unregister_tool(name) + + async def test_async_invalid_params_type_raises(self): + name = f'test_async_inv_{uuid.uuid4().hex[:8]}' + + @tool_decorator(name=name) + async def one(x: int) -> int: + return x + + try: + with self.assertRaises(ToolArgumentError): + await execute_registered_tool_async(name, 3.14) # invalid type + finally: + unregister_tool(name) + + async def test_async_unregistered_tool_raises(self): + name = f'does_not_exist_{uuid.uuid4().hex[:8]}' + with self.assertRaises(ToolNotFoundError): + await execute_registered_tool_async(name, None) + + async def test_async_tool_exception_wrapped(self): + name = f'test_async_fail_{uuid.uuid4().hex[:8]}' + + @tool_decorator(name=name) + async def fail_async() -> None: + await asyncio.sleep(0) + raise RuntimeError('nope') + + try: + with self.assertRaises(ToolExecutionError): + await execute_registered_tool_async(name) + finally: + unregister_tool(name) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/clients/test_conversation_helpers.py b/tests/clients/test_conversation_helpers.py new file mode 100644 index 00000000..62f2f69a --- /dev/null +++ b/tests/clients/test_conversation_helpers.py @@ -0,0 +1,2153 @@ +# -*- coding: utf-8 -*- + +""" +Copyright 2025 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" +import io +import json +import base64 +import unittest +import warnings +from contextlib import redirect_stdout +from dataclasses import dataclass +from enum import Enum +from typing import Any, Dict, List, Literal, Optional, Union, Set +from dapr.conf import settings +from dapr.clients.grpc._conversation_helpers import ( + stringify_tool_output, + bind_params_to_func, + function_to_json_schema, + _extract_docstring_args, + _python_type_to_json_schema, + extract_docstring_summary, + ToolArgumentError, +) +from dapr.clients.grpc.conversation import ( + ConversationToolsFunction, + ConversationMessageOfUser, + ConversationMessageContent, + ConversationToolCalls, + ConversationToolCallsOfFunction, + ConversationMessageOfAssistant, + ConversationMessageOfTool, + ConversationMessage, + ConversationMessageOfDeveloper, + ConversationMessageOfSystem, +) + + +def test_string_passthrough(): + assert stringify_tool_output('hello') == 'hello' + + +def test_json_serialization_collections(): + data = {'a': 1, 'b': [2, 'x'], 'c': {'k': True}} + out = stringify_tool_output(data) + # Must be a JSON string we can parse back to the same structure + parsed = json.loads(out) + assert parsed == data + + +class Color(Enum): + RED = 'red' + BLUE = 'blue' + + +def test_enum_serialization_uses_value_and_is_json_string(): + out = stringify_tool_output(Color.RED) + # json.dumps on a string value yields a quoted JSON string + assert out == json.dumps('red', ensure_ascii=False) + + +@dataclass +class Point: + x: int + y: int + + +def test_dataclass_serialization_to_json_dict(): + p = Point(1, 2) + out = stringify_tool_output(p) + parsed = json.loads(out) + assert parsed == {'x': 1, 'y': 2} + + +def test_bytes_and_bytearray_to_base64_prefixed(): + b = bytes([0, 1, 2, 250, 255]) + expected = 'base64:' + base64.b64encode(b).decode('ascii') + assert stringify_tool_output(b) == expected + + ba = bytearray(b) + expected_ba = 'base64:' + base64.b64encode(bytes(ba)).decode('ascii') + assert stringify_tool_output(ba) == expected_ba + + +class WithDict: + def __init__(self): + self.x = 1 + self.y = 'y' + self.fn = lambda: 42 # callable should be filtered out + + +def test_object_with___dict___becomes_dict_without_callables(): + obj = WithDict() + out = stringify_tool_output(obj) + parsed = json.loads(out) + assert parsed == {'x': 1, 'y': 'y'} + + +class UnserializableButStr: + def __init__(self): + self.bad = {1, 2, 3} # set is not JSON serializable + + def __str__(self): + return 'myobj' + + +def test_fallback_to_str_when_json_fails(): + obj = UnserializableButStr() + out = stringify_tool_output(obj) + assert out == 'myobj' + + +class BadStr: + def __init__(self): + self.bad = {1, 2, 3} + + def __str__(self): + raise RuntimeError('boom') + + +def test_last_resort_unserializable_marker_when_str_raises(): + obj = BadStr() + out = stringify_tool_output(obj) + assert out == '' + + +def _example_get_flights( + *, + flight_data: List[str], + trip: Literal['round-trip', 'one-way', 'multi-city'], + passengers: int, + seat: Literal['economy', 'premium-economy', 'business', 'first'], + fetch_mode: Literal['common', 'fallback', 'force-fallback', 'local'] = 'common', + max_stops: Optional[int] = None, +): + return { + 'flight_data': flight_data, + 'trip': trip, + 'passengers': passengers, + 'seat': seat, + 'fetch_mode': fetch_mode, + 'max_stops': max_stops, + } + + +def test_bind_params_basic_coercion_from_examples(): + params = { + 'flight_data': ['AUS', 'OPO'], + 'trip': 'one-way', + 'passengers': '1', # should coerce to int + 'seat': 'economy', + 'fetch_mode': 'common', + 'max_stops': 0, + } + bound = bind_params_to_func(_example_get_flights, params) + # Ensure type coercion happened + assert isinstance(bound.kwargs['passengers'], int) + assert bound.kwargs['passengers'] == 1 + assert isinstance(bound.kwargs['max_stops'], int) + # Function should still run with coerced params + result = _example_get_flights(*bound.args, **bound.kwargs) + assert result['passengers'] == 1 + assert result['trip'] == 'one-way' + assert result['seat'] == 'economy' + + +def test_literal_schema_generation_from_examples(): + schema = function_to_json_schema(_example_get_flights) + props = schema['properties'] + + # flight_data -> array of strings + assert props['flight_data']['type'] == 'array' + assert props['flight_data']['items']['type'] == 'string' + + # trip -> enum of strings + assert props['trip']['type'] == 'string' + assert set(props['trip']['enum']) == {'round-trip', 'one-way', 'multi-city'} + + # passengers -> integer + assert props['passengers']['type'] == 'integer' + + # seat -> enum of strings + assert props['seat']['type'] == 'string' + assert set(props['seat']['enum']) == {'economy', 'premium-economy', 'business', 'first'} + + # fetch_mode -> enum with default provided in function (not necessarily in schema, but not required) + assert props['fetch_mode']['type'] == 'string' + assert set(props['fetch_mode']['enum']) == {'common', 'fallback', 'force-fallback', 'local'} + + # max_stops -> optional int (not required) + assert props['max_stops']['type'] == 'integer' + + # Required fields reflect parameters without defaults + # Note: order not guaranteed + required = set(schema['required']) + assert {'flight_data', 'trip', 'passengers', 'seat'}.issubset(required) + assert 'fetch_mode' not in required + assert 'max_stops' not in required + + +# Define minimal stand-in classes to test class coercion behavior +class FlightData: + def __init__( + self, date: str, from_airport: str, to_airport: str, max_stops: Optional[int] = None + ): + self.date = date + self.from_airport = from_airport + self.to_airport = to_airport + self.max_stops = max_stops + + +class Passengers: + def __init__(self, adults: int, children: int, infants_in_seat: int, infants_on_lap: int): + self.adults = adults + self.children = children + self.infants_in_seat = infants_in_seat + self.infants_on_lap = infants_on_lap + + +def _example_get_flights_with_classes( + *, + flight_data: List[FlightData], + trip: Literal['round-trip', 'one-way', 'multi-city'], + passengers: Passengers, + seat: Literal['economy', 'premium-economy', 'business', 'first'], + fetch_mode: Literal['common', 'fallback', 'force-fallback', 'local'] = 'common', + max_stops: Optional[int] = None, +): + return { + 'flight_data': flight_data, + 'trip': trip, + 'passengers': passengers, + 'seat': seat, + 'fetch_mode': fetch_mode, + 'max_stops': max_stops, + } + + +def test_class_coercion_and_schema_from_examples(): + # Verify schema generation includes class fields + schema = function_to_json_schema(_example_get_flights_with_classes) + props = schema['properties'] + + # flight_data is array of objects with class fields + fd_schema = props['flight_data']['items'] + assert fd_schema['type'] == 'object' + for key in ['date', 'from_airport', 'to_airport']: + assert key in fd_schema['properties'] + assert fd_schema['properties'][key]['type'] == 'string' + # Optional int field + assert fd_schema['properties']['max_stops']['type'] == 'integer' + + # passengers object has proper fields + p_schema = props['passengers'] + assert p_schema['type'] == 'object' + for key in ['adults', 'children', 'infants_in_seat', 'infants_on_lap']: + assert p_schema['properties'][key]['type'] == 'integer' + + # Provide dicts to be coerced into class instances + params = { + 'flight_data': [ + {'date': '2025-09-01', 'from_airport': 'AUS', 'to_airport': 'OPO', 'max_stops': '1'}, + {'date': '2025-09-10', 'from_airport': 'OPO', 'to_airport': 'AUS'}, + ], + 'trip': 'round-trip', + 'passengers': {'adults': 1, 'children': 0, 'infants_in_seat': 0, 'infants_on_lap': 0}, + 'seat': 'economy', + 'fetch_mode': 'common', + 'max_stops': 1, + } + + bound = bind_params_to_func(_example_get_flights_with_classes, params) + result = _example_get_flights_with_classes(*bound.args, **bound.kwargs) + + # Ensure coerced instances + assert all(isinstance(fd, FlightData) for fd in result['flight_data']) + assert isinstance(result['passengers'], Passengers) + # Ensure coercion of max_stops inside FlightData + assert result['flight_data'][0].max_stops == 1 + + +# ---- Additional function_to_json_schema tests for dataclass and other types ---- + + +@dataclass +class Person: + name: str + age: int = 0 # default -> not required in schema + + +def _fn_with_dataclass(user: Person, teammates: Optional[List[Person]] = None): + return True + + +def test_function_to_json_schema_with_dataclass_param(): + schema = function_to_json_schema(_fn_with_dataclass) + props = schema['properties'] + + # user -> dataclass object + assert props['user']['type'] == 'object' + assert set(props['user']['properties'].keys()) == {'name', 'age'} + assert props['user']['properties']['name']['type'] == 'string' + assert props['user']['properties']['age']['type'] == 'integer' + # required should include 'user' (function param) and within dataclass, field default logic is internal; + # for function level required, user has no default -> required + assert 'user' in schema['required'] + + # teammates -> Optional[List[Person]] + assert props['teammates']['type'] == 'array' + assert props['teammates']['items']['type'] == 'object' + assert set(props['teammates']['items']['properties'].keys()) == {'name', 'age'} + # teammates is Optional -> not required at top level + assert 'teammates' not in schema['required'] + + +class Pet(Enum): + DOG = 'dog' + CAT = 'cat' + + +def _fn_with_enum(pet: Pet): + return True + + +def test_function_to_json_schema_with_enum_param(): + schema = function_to_json_schema(_fn_with_enum) + pet_schema = schema['properties']['pet'] + assert pet_schema['type'] == 'string' + assert set(pet_schema['enum']) == {'dog', 'cat'} + + +def _fn_with_dict(meta: Dict[str, int]): + return True + + +def test_function_to_json_schema_with_dict_str_int(): + schema = function_to_json_schema(_fn_with_dict) + meta_schema = schema['properties']['meta'] + assert meta_schema['type'] == 'object' + assert meta_schema['additionalProperties']['type'] == 'integer' + + +def _fn_with_bytes(data: bytes): + return True + + +def test_function_to_json_schema_with_bytes(): + schema = function_to_json_schema(_fn_with_bytes) + data_schema = schema['properties']['data'] + assert data_schema['type'] == 'string' + assert data_schema.get('format') == 'byte' + + +def _fn_with_union(identifier: Union[int, str]): + return True + + +def test_function_to_json_schema_with_true_union_anyof(): + schema = function_to_json_schema(_fn_with_union) + id_schema = schema['properties']['identifier'] + assert 'anyOf' in id_schema + types = {opt.get('type') for opt in id_schema['anyOf']} + assert types == {'integer', 'string'} + + +def _fn_with_unsupported_type(s: Set[int]): + return True + + +def test_function_to_json_schema_unsupported_type_raises(): + try: + function_to_json_schema(_fn_with_unsupported_type) + assert False, 'Expected TypeError or ValueError for unsupported type' + except (TypeError, ValueError): + pass + + +class TestPythonTypeToJsonSchema(unittest.TestCase): + """Test the _python_type_to_json_schema function.""" + + def test_basic_types(self): + """Test conversion of basic Python types.""" + test_cases = [ + (str, {'type': 'string'}), + (int, {'type': 'integer'}), + (float, {'type': 'number'}), + (bool, {'type': 'boolean'}), + (bytes, {'type': 'string', 'format': 'byte'}), + ] + + for python_type, expected in test_cases: + with self.subTest(python_type=python_type): + result = _python_type_to_json_schema(python_type) + self.assertEqual(result['type'], expected['type']) + if 'format' in expected: + self.assertEqual(result['format'], expected['format']) + + def test_optional_types(self): + """Test Optional[T] types (Union[T, None]).""" + # Optional[str] should resolve to string + result = _python_type_to_json_schema(Optional[str]) + self.assertEqual(result['type'], 'string') + + # Optional[int] should resolve to integer + result = _python_type_to_json_schema(Optional[int]) + self.assertEqual(result['type'], 'integer') + + def test_list_types(self): + """Test List[T] types.""" + # List[str] + result = _python_type_to_json_schema(List[str]) + expected = {'type': 'array', 'items': {'type': 'string'}} + self.assertEqual(result, expected) + + # List[int] + result = _python_type_to_json_schema(List[int]) + expected = {'type': 'array', 'items': {'type': 'integer'}} + self.assertEqual(result, expected) + + def test_dict_types(self): + """Test Dict[str, T] types.""" + result = _python_type_to_json_schema(Dict[str, int]) + expected = {'type': 'object', 'additionalProperties': {'type': 'integer'}} + self.assertEqual(result, expected) + + def test_enum_types(self): + """Test Enum types.""" + + class Color(Enum): + RED = 'red' + GREEN = 'green' + BLUE = 'blue' + + result = _python_type_to_json_schema(Color) + expected = {'type': 'string', 'enum': ['red', 'green', 'blue']} + self.assertEqual(result['type'], expected['type']) + self.assertEqual(set(result['enum']), set(expected['enum'])) + + def test_union_types(self): + """Test Union types.""" + result = _python_type_to_json_schema(Union[str, int]) + self.assertIn('anyOf', result) + self.assertEqual(len(result['anyOf']), 2) + + # Should contain both string and integer schemas + types = [schema['type'] for schema in result['anyOf']] + self.assertIn('string', types) + self.assertIn('integer', types) + + def test_dataclass_types(self): + """Test dataclass types.""" + + @dataclass + class Person: + name: str + age: int = 25 + + result = _python_type_to_json_schema(Person) + + self.assertEqual(result['type'], 'object') + self.assertIn('properties', result) + self.assertIn('required', result) + + # Check properties + self.assertIn('name', result['properties']) + self.assertIn('age', result['properties']) + self.assertEqual(result['properties']['name']['type'], 'string') + self.assertEqual(result['properties']['age']['type'], 'integer') + + # Check required fields (name is required, age has default) + self.assertIn('name', result['required']) + self.assertNotIn('age', result['required']) + + def test_pydantic_models(self): + """Test Pydantic model types.""" + try: + from pydantic import BaseModel + + class SearchParams(BaseModel): + query: str + limit: int = 10 + include_images: bool = False + tags: Optional[List[str]] = None + + result = _python_type_to_json_schema(SearchParams) + + # Pydantic models should generate their own schema + self.assertIn('type', result) + # The exact structure depends on Pydantic version, but it should have properties + if 'properties' in result: + self.assertIn('query', result['properties']) + except ImportError: + self.skipTest('Pydantic not available for testing') + + def test_nested_types(self): + """Test complex nested type combinations.""" + # Optional[List[str]] + result = _python_type_to_json_schema(Optional[List[str]]) + self.assertEqual(result['type'], 'array') + self.assertEqual(result['items']['type'], 'string') + + # List[Optional[int]] + result = _python_type_to_json_schema(List[Optional[int]]) + self.assertEqual(result['type'], 'array') + self.assertEqual(result['items']['type'], 'integer') + + # Dict[str, List[int]] + result = _python_type_to_json_schema(Dict[str, List[int]]) + self.assertEqual(result['type'], 'object') + self.assertEqual(result['additionalProperties']['type'], 'array') + self.assertEqual(result['additionalProperties']['items']['type'], 'integer') + + def test_complex_dataclass_with_nested_types(self): + """Test dataclass with complex nested types.""" + + @dataclass + class Address: + street: str + city: str + zipcode: Optional[str] = None + + @dataclass + class Person: + name: str + addresses: List[Address] + metadata: Dict[str, str] + tags: Optional[List[str]] = None + + result = _python_type_to_json_schema(Person) + + self.assertEqual(result['type'], 'object') + self.assertIn('name', result['properties']) + self.assertIn('addresses', result['properties']) + self.assertIn('metadata', result['properties']) + self.assertIn('tags', result['properties']) + + # Check nested structures + self.assertEqual(result['properties']['addresses']['type'], 'array') + self.assertEqual(result['properties']['metadata']['type'], 'object') + self.assertEqual(result['properties']['tags']['type'], 'array') + + # Required fields + self.assertIn('name', result['required']) + self.assertIn('addresses', result['required']) + self.assertIn('metadata', result['required']) + self.assertNotIn('tags', result['required']) + + def test_enum_with_different_types(self): + """Test enums with different value types.""" + + class Status(Enum): + ACTIVE = 1 + INACTIVE = 0 + PENDING = 2 + + class Priority(Enum): + LOW = 'low' + MEDIUM = 'medium' + HIGH = 'high' + + # String enum + result = _python_type_to_json_schema(Priority) + self.assertEqual(result['type'], 'string') + self.assertEqual(set(result['enum']), {'low', 'medium', 'high'}) + + # Integer enum + result = _python_type_to_json_schema(Status) + self.assertEqual(result['type'], 'string') + self.assertEqual(set(result['enum']), {1, 0, 2}) + + def test_none_type(self): + """Test None type handling.""" + result = _python_type_to_json_schema(type(None)) + self.assertEqual(result['type'], 'null') + + def test_realistic_function_types(self): + """Test types from realistic function signatures.""" + # Weather function parameters + result = _python_type_to_json_schema(str) # location + self.assertEqual(result['type'], 'string') + + # Optional unit with enum + class TemperatureUnit(Enum): + CELSIUS = 'celsius' + FAHRENHEIT = 'fahrenheit' + + result = _python_type_to_json_schema(Optional[TemperatureUnit]) + self.assertEqual(result['type'], 'string') + self.assertEqual(set(result['enum']), {'celsius', 'fahrenheit'}) + + # Search function with complex params + @dataclass + class SearchOptions: + max_results: int = 10 + include_metadata: bool = True + filters: Optional[Dict[str, str]] = None + + result = _python_type_to_json_schema(SearchOptions) + self.assertEqual(result['type'], 'object') + self.assertIn('max_results', result['properties']) + self.assertIn('include_metadata', result['properties']) + self.assertIn('filters', result['properties']) + + def test_list_without_type_args(self): + """Test bare List type without type arguments.""" + result = _python_type_to_json_schema(list) + self.assertEqual(result['type'], 'array') + self.assertNotIn('items', result) + + def test_dict_without_type_args(self): + """Test bare Dict type without type arguments.""" + result = _python_type_to_json_schema(dict) + self.assertEqual(result['type'], 'object') + self.assertNotIn('additionalProperties', result) + + +class TestExtractDocstringInfo(unittest.TestCase): + """Test the extract_docstring_info function.""" + + def test_google_style_docstring(self): + """Test Google-style docstring parsing.""" + + def sample_function(name: str, age: int) -> str: + """A sample function. + + Args: + name: The person's name + age: The person's age in years + """ + return f'{name} is {age}' + + result = _extract_docstring_args(sample_function) + expected = {'name': "The person's name", 'age': "The person's age in years"} + self.assertEqual(result, expected) + + def test_no_docstring(self): + """Test function with no docstring.""" + + def no_doc_function(param): + pass + + result = _extract_docstring_args(no_doc_function) + self.assertEqual(result, {}) + + def test_docstring_without_args(self): + """Test docstring without Args section.""" + + def simple_function(param): + """Just a simple function.""" + pass + + result = _extract_docstring_args(simple_function) + self.assertEqual(result, {}) + + def test_multiline_param_description(self): + """Test parameter descriptions that span multiple lines.""" + + def complex_function(param1: str) -> str: + """A complex function. + + Args: + param1: This is a long description + that spans multiple lines + for testing purposes + """ + return param1 + + result = _extract_docstring_args(complex_function) + expected = { + 'param1': 'This is a long description that spans multiple lines for testing purposes' + } + self.assertEqual(result, expected) + + def test_sphinx_style_docstring(self): + """Test Sphinx-style docstring parsing.""" + + def sphinx_function(location: str, unit: str) -> str: + """Get weather information. + + :param location: The city or location name + :param unit: Temperature unit (celsius or fahrenheit) + :type location: str + :type unit: str + :returns: Weather information string + :rtype: str + """ + return f'Weather in {location}' + + result = _extract_docstring_args(sphinx_function) + expected = { + 'location': 'The city or location name', + 'unit': 'Temperature unit (celsius or fahrenheit)', + } + self.assertEqual(result, expected) + + def test_sphinx_style_with_parameter_keyword(self): + """Test Sphinx-style with :parameter: instead of :param:.""" + + def sphinx_function2(query: str, limit: int) -> str: + """Search for data. + + :parameter query: The search query string + :parameter limit: Maximum number of results + """ + return f'Results for {query}' + + result = _extract_docstring_args(sphinx_function2) + expected = {'query': 'The search query string', 'limit': 'Maximum number of results'} + self.assertEqual(result, expected) + + def test_sphinx_style_multiline_descriptions(self): + """Test Sphinx-style with multi-line parameter descriptions.""" + + def sphinx_multiline_function(data: str) -> str: + """Process complex data. + + :param data: The input data to process, which can be + quite complex and may require special handling + for optimal results + :returns: Processed data + """ + return data + + result = _extract_docstring_args(sphinx_multiline_function) + expected = { + 'data': 'The input data to process, which can be quite complex and may require special handling for optimal results' + } + self.assertEqual(result, expected) + + def test_numpy_style_docstring(self): + """Test NumPy-style docstring parsing.""" + + def numpy_function(x: float, y: float) -> float: + """Calculate distance. + + Parameters + ---------- + x : float + The x coordinate + y : float + The y coordinate + + Returns + ------- + float + The calculated distance + """ + return (x**2 + y**2) ** 0.5 + + result = _extract_docstring_args(numpy_function) + expected = {'x': 'The x coordinate', 'y': 'The y coordinate'} + self.assertEqual(result, expected) + + def test_mixed_style_preference(self): + """Test that Sphinx-style takes precedence when both styles are present.""" + + def mixed_function(param1: str, param2: int) -> str: + """Function with mixed documentation styles. + + :param param1: Sphinx-style description for param1 + :param param2: Sphinx-style description for param2 + + Args: + param1: Google-style description for param1 + param2: Google-style description for param2 + """ + return f'{param1}: {param2}' + + result = _extract_docstring_args(mixed_function) + expected = { + 'param1': 'Sphinx-style description for param1', + 'param2': 'Sphinx-style description for param2', + } + self.assertEqual(result, expected) + + def test_unsupported_format_warning(self): + """Test that unsupported docstring formats trigger a warning.""" + + def unsupported_function(param1: str, param2: int) -> str: + """Function with unsupported parameter documentation format. + + This function takes param1 which is a string input, + and param2 which is an integer argument. + """ + return f'{param1}: {param2}' + + with self.assertWarns(UserWarning) as warning_context: + result = _extract_docstring_args(unsupported_function) + + # Should return empty dict since no supported format found + self.assertEqual(result, {}) + + # Check warning message content + warning_message = str(warning_context.warning) + self.assertIn('unsupported_function', warning_message) + self.assertIn('supported format', warning_message) + self.assertIn('Google, NumPy, or Sphinx style', warning_message) + + def test_informal_style_warning(self): + """Test that informal parameter documentation triggers a warning.""" + + def informal_function(filename: str, mode: str) -> str: + """Open and read a file. + + The filename parameter should be the path to the file. + The mode parameter controls how the file is opened. + """ + return f'Reading {filename} in {mode} mode' + + with self.assertWarns(UserWarning): + result = _extract_docstring_args(informal_function) + + self.assertEqual(result, {}) + + def test_no_warning_for_no_params(self): + """Test that functions without parameter docs don't trigger warnings.""" + + def simple_function() -> str: + """Simple function with no parameters documented.""" + return 'hello' + + # Should not raise any warnings + with warnings.catch_warnings(): + warnings.simplefilter('error') # Turn warnings into errors + result = _extract_docstring_args(simple_function) + + self.assertEqual(result, {}) + + def test_no_warning_for_valid_formats(self): + """Test that valid formats don't trigger warnings.""" + + def google_function(param: str) -> str: + """Function with Google-style docs. + + Args: + param: A parameter description + """ + return param + + # Should not raise any warnings + with warnings.catch_warnings(): + warnings.simplefilter('error') # Turn warnings into errors + result = _extract_docstring_args(google_function) + + self.assertEqual(result, {'param': 'A parameter description'}) + + +class TestFunctionToJsonSchema(unittest.TestCase): + """Test the function_to_json_schema function.""" + + def test_simple_function(self): + """Test a simple function with basic types.""" + + def get_weather(location: str, unit: str = 'fahrenheit') -> str: + """Get weather for a location. + + Args: + location: The city name + unit: Temperature unit + """ + return f'Weather in {location}' + + result = function_to_json_schema(get_weather) + + # Check structure + self.assertEqual(result['type'], 'object') + self.assertIn('properties', result) + self.assertIn('required', result) + + # Check properties + self.assertIn('location', result['properties']) + self.assertIn('unit', result['properties']) + self.assertEqual(result['properties']['location']['type'], 'string') + self.assertEqual(result['properties']['unit']['type'], 'string') + + # Check descriptions + self.assertEqual(result['properties']['location']['description'], 'The city name') + self.assertEqual(result['properties']['unit']['description'], 'Temperature unit') + + # Check required (location is required, unit has default) + self.assertIn('location', result['required']) + self.assertNotIn('unit', result['required']) + + def test_function_with_complex_types(self): + """Test function with complex type hints.""" + + def search_data( + query: str, + limit: int = 10, + filters: Optional[List[str]] = None, + metadata: Dict[str, str] = None, + ) -> Dict[str, any]: + """Search for data. + + Args: + query: Search query + limit: Maximum results + filters: Optional search filters + metadata: Additional metadata + """ + return {} + + result = function_to_json_schema(search_data) + + # Check all parameters are present + props = result['properties'] + self.assertIn('query', props) + self.assertIn('limit', props) + self.assertIn('filters', props) + self.assertIn('metadata', props) + + # Check types + self.assertEqual(props['query']['type'], 'string') + self.assertEqual(props['limit']['type'], 'integer') + self.assertEqual(props['filters']['type'], 'array') + self.assertEqual(props['filters']['items']['type'], 'string') + self.assertEqual(props['metadata']['type'], 'object') + + # Check required (only query is required) + self.assertEqual(result['required'], ['query']) + + def test_function_with_enum(self): + """Test function with Enum parameter.""" + + class Priority(Enum): + LOW = 'low' + HIGH = 'high' + + def create_task(name: str, priority: Priority = Priority.LOW) -> str: + """Create a task. + + Args: + name: Task name + priority: Task priority level + """ + return f'Task: {name}' + + result = function_to_json_schema(create_task) + + # Check enum handling + priority_prop = result['properties']['priority'] + self.assertEqual(priority_prop['type'], 'string') + self.assertIn('enum', priority_prop) + self.assertEqual(set(priority_prop['enum']), {'low', 'high'}) + + def test_function_no_parameters(self): + """Test function with no parameters.""" + + def get_time() -> str: + """Get current time.""" + return '12:00' + + result = function_to_json_schema(get_time) + + self.assertEqual(result['type'], 'object') + self.assertEqual(result['properties'], {}) + self.assertEqual(result['required'], []) + + def test_function_with_args_kwargs(self): + """Test function with *args and **kwargs (should be ignored).""" + + def flexible_function(name: str, *args, **kwargs) -> str: + """A flexible function.""" + return name + + result = function_to_json_schema(flexible_function) + + # Should only include 'name', not *args or **kwargs + self.assertEqual(list(result['properties'].keys()), ['name']) + self.assertEqual(result['required'], ['name']) + + def test_realistic_weather_function(self): + """Test realistic weather API function.""" + + class Units(Enum): + CELSIUS = 'celsius' + FAHRENHEIT = 'fahrenheit' + + def get_weather( + location: str, unit: Units = Units.FAHRENHEIT, include_forecast: bool = False + ) -> str: + """Get current weather for a location. + + Args: + location: The city and state or country + unit: Temperature unit preference + include_forecast: Whether to include 5-day forecast + """ + return f'Weather in {location}' + + result = function_to_json_schema(get_weather) + + # Check structure + self.assertEqual(result['type'], 'object') + props = result['properties'] + + # Check location (required string) + self.assertEqual(props['location']['type'], 'string') + self.assertEqual(props['location']['description'], 'The city and state or country') + self.assertIn('location', result['required']) + + # Check unit (optional enum) + self.assertEqual(props['unit']['type'], 'string') + self.assertEqual(set(props['unit']['enum']), {'celsius', 'fahrenheit'}) + self.assertNotIn('unit', result['required']) + + # Check forecast flag (optional boolean) + self.assertEqual(props['include_forecast']['type'], 'boolean') + self.assertNotIn('include_forecast', result['required']) + + def test_realistic_search_function(self): + """Test realistic search function with complex parameters.""" + + @dataclass + class SearchFilters: + category: Optional[str] = None + price_min: Optional[float] = None + price_max: Optional[float] = None + + def search_products( + query: str, + max_results: int = 20, + sort_by: str = 'relevance', + filters: Optional[SearchFilters] = None, + include_metadata: bool = True, + ) -> List[Dict[str, str]]: + """Search for products in catalog. + + Args: + query: Search query string + max_results: Maximum number of results to return + sort_by: Sort order (relevance, price, rating) + filters: Optional search filters + include_metadata: Whether to include product metadata + """ + return [] + + result = function_to_json_schema(search_products) + + props = result['properties'] + + # Check required query + self.assertEqual(props['query']['type'], 'string') + self.assertIn('query', result['required']) + + # Check optional integer with default + self.assertEqual(props['max_results']['type'], 'integer') + self.assertNotIn('max_results', result['required']) + + # Check string with default + self.assertEqual(props['sort_by']['type'], 'string') + self.assertNotIn('sort_by', result['required']) + + # Check optional dataclass + self.assertEqual(props['filters']['type'], 'object') + self.assertNotIn('filters', result['required']) + + # Check boolean with default + self.assertEqual(props['include_metadata']['type'], 'boolean') + self.assertNotIn('include_metadata', result['required']) + + def test_realistic_database_function(self): + """Test realistic database query function.""" + + def query_users( + filter_conditions: Dict[str, str], + limit: int = 100, + offset: int = 0, + order_by: Optional[str] = None, + include_inactive: bool = False, + ) -> List[Dict[str, any]]: + """Query users from database. + + Args: + filter_conditions: Key-value pairs for filtering + limit: Maximum number of users to return + offset: Number of records to skip + order_by: Field to sort by + include_inactive: Whether to include inactive users + """ + return [] + + result = function_to_json_schema(query_users) + + props = result['properties'] + + # Check required dict parameter + self.assertEqual(props['filter_conditions']['type'], 'object') + self.assertIn('filter_conditions', result['required']) + + # Check integer parameters with defaults + for param in ['limit', 'offset']: + self.assertEqual(props[param]['type'], 'integer') + self.assertNotIn(param, result['required']) + + # Check optional string + self.assertEqual(props['order_by']['type'], 'string') + self.assertNotIn('order_by', result['required']) + + # Check boolean flag + self.assertEqual(props['include_inactive']['type'], 'boolean') + self.assertNotIn('include_inactive', result['required']) + + def test_pydantic_function_parameter(self): + """Test function with Pydantic model parameter.""" + try: + from pydantic import BaseModel + + class UserProfile(BaseModel): + name: str + email: str + age: Optional[int] = None + preferences: Dict[str, bool] = {} + + def update_user(user_id: str, profile: UserProfile, notify: bool = True) -> str: + """Update user profile. + + Args: + user_id: Unique user identifier + profile: User profile data + notify: Whether to send notification + """ + return 'updated' + + result = function_to_json_schema(update_user) + + props = result['properties'] + + # Check required string + self.assertEqual(props['user_id']['type'], 'string') + self.assertIn('user_id', result['required']) + + # Check Pydantic model (should have proper schema) + self.assertIn('profile', props) + self.assertIn('profile', result['required']) + + # Check boolean flag + self.assertEqual(props['notify']['type'], 'boolean') + self.assertNotIn('notify', result['required']) + + except ImportError: + self.skipTest('Pydantic not available for testing') + + +class TestExtractDocstringSummary(unittest.TestCase): + """Test the extract_docstring_summary function.""" + + def test_simple_docstring(self): + """Test function with simple one-line docstring.""" + + def simple_function(): + """Simple one-line description.""" + pass + + result = extract_docstring_summary(simple_function) + self.assertEqual(result, 'Simple one-line description.') + + def test_full_docstring_with_extended_summary(self): + """Test function with full docstring including extended summary.""" + + def complex_function(): + """Get weather information for a specific location. + + This function retrieves current weather data including temperature, + humidity, and precipitation for the given location. + + Args: + location: The city or location to get weather for + unit: Temperature unit (celsius or fahrenheit) + + Returns: + Weather information as a string + + Raises: + ValueError: If location is invalid + """ + pass + + result = extract_docstring_summary(complex_function) + expected = ( + 'Get weather information for a specific location. ' + 'This function retrieves current weather data including temperature, ' + 'humidity, and precipitation for the given location.' + ) + self.assertEqual(result, expected) + + def test_multiline_summary_before_args(self): + """Test function with multiline summary that stops at Args section.""" + + def multiline_summary_function(): + """Complex function that does many things. + + This is an extended description that spans multiple lines + and provides more context about what the function does. + + Args: + param1: First parameter + """ + pass + + result = extract_docstring_summary(multiline_summary_function) + expected = ( + 'Complex function that does many things. ' + 'This is an extended description that spans multiple lines ' + 'and provides more context about what the function does.' + ) + self.assertEqual(result, expected) + + def test_no_docstring(self): + """Test function without docstring.""" + + def no_docstring_function(): + pass + + result = extract_docstring_summary(no_docstring_function) + self.assertIsNone(result) + + def test_empty_docstring(self): + """Test function with empty docstring.""" + + def empty_docstring_function(): + """""" + pass + + result = extract_docstring_summary(empty_docstring_function) + self.assertIsNone(result) + + def test_docstring_with_only_whitespace(self): + """Test function with docstring containing only whitespace.""" + + def whitespace_docstring_function(): + """ """ + pass + + result = extract_docstring_summary(whitespace_docstring_function) + self.assertIsNone(result) + + def test_docstring_stops_at_various_sections(self): + """Test that summary extraction stops at various section headers.""" + + def function_with_returns(): + """Function description. + + Returns: + Something useful + """ + pass + + def function_with_raises(): + """Function description. + + Raises: + ValueError: If something goes wrong + """ + pass + + def function_with_note(): + """Function description. + + Note: + This is important to remember + """ + pass + + # Test each section header + for func in [function_with_returns, function_with_raises, function_with_note]: + result = extract_docstring_summary(func) + self.assertEqual(result, 'Function description.') + + def test_docstring_with_parameters_section(self): + """Test docstring with Parameters section (alternative to Args).""" + + def function_with_parameters(): + """Process data efficiently. + + Parameters: + data: Input data to process + options: Processing options + """ + pass + + result = extract_docstring_summary(function_with_parameters) + self.assertEqual(result, 'Process data efficiently.') + + def test_docstring_with_example_section(self): + """Test docstring with Example section.""" + + def function_with_example(): + """Calculate the area of a circle. + + Example: + >>> calculate_area(5) + 78.54 + """ + pass + + result = extract_docstring_summary(function_with_example) + self.assertEqual(result, 'Calculate the area of a circle.') + + def test_case_insensitive_section_headers(self): + """Test that section header matching is case insensitive.""" + + def function_with_uppercase_args(): + """Function with uppercase section. + + ARGS: + param: A parameter + """ + pass + + result = extract_docstring_summary(function_with_uppercase_args) + self.assertEqual(result, 'Function with uppercase section.') + + def test_sphinx_style_summary_extraction(self): + """Test that Sphinx-style docstrings stop at :param: sections.""" + + def sphinx_function(): + """Calculate mathematical operations. + + This function performs various mathematical calculations + with high precision and error handling. + + :param x: First number + :param y: Second number + :returns: Calculation result + """ + pass + + result = extract_docstring_summary(sphinx_function) + expected = ( + 'Calculate mathematical operations. ' + 'This function performs various mathematical calculations ' + 'with high precision and error handling.' + ) + self.assertEqual(result, expected) + + def test_mixed_sphinx_google_summary(self): + """Test summary extraction stops at first section marker (Sphinx or Google).""" + + def mixed_function(): + """Process data with multiple algorithms. + + This is an extended description that provides + more context about the processing methods. + + :param data: Input data + + Args: + additional: More parameters + """ + pass + + result = extract_docstring_summary(mixed_function) + expected = ( + 'Process data with multiple algorithms. ' + 'This is an extended description that provides ' + 'more context about the processing methods.' + ) + self.assertEqual(result, expected) + + +class TestConversationToolsFunctionFromFunction(unittest.TestCase): + """Test the ConversationToolsFunction.from_function method.""" + + def test_from_function_basic(self): + """Test creating ConversationToolsFunction from a basic function.""" + + def test_function(param1: str, param2: int = 10): + """Test function for conversion. + + Args: + param1: First parameter + param2: Second parameter with default + """ + return f'{param1}: {param2}' + + result = ConversationToolsFunction.from_function(test_function) + + # Check basic properties + self.assertEqual(result.name, 'test_function') + self.assertEqual(result.description, 'Test function for conversion.') + self.assertIsInstance(result.parameters, dict) + + # Check that parameters schema was generated + self.assertEqual(result.parameters['type'], 'object') + self.assertIn('properties', result.parameters) + self.assertIn('required', result.parameters) + + def test_from_function_with_complex_docstring(self): + """Test from_function with complex docstring extracts only summary.""" + + def complex_function(location: str): + """Get weather information for a location. + + This function provides comprehensive weather data including + current conditions and forecasts. + + Args: + location: The location to get weather for + + Returns: + str: Weather information + + Raises: + ValueError: If location is invalid + + Example: + >>> get_weather("New York") + "Sunny, 72°F" + """ + return f'Weather for {location}' + + result = ConversationToolsFunction.from_function(complex_function) + + expected_description = ( + 'Get weather information for a location. ' + 'This function provides comprehensive weather data including ' + 'current conditions and forecasts.' + ) + self.assertEqual(result.description, expected_description) + + def test_from_function_no_docstring(self): + """Test from_function with function that has no docstring.""" + + def no_doc_function(param): + return param + + result = ConversationToolsFunction.from_function(no_doc_function) + + self.assertEqual(result.name, 'no_doc_function') + self.assertIsNone(result.description) + self.assertIsInstance(result.parameters, dict) + + def test_from_function_simple_docstring(self): + """Test from_function with simple one-line docstring.""" + + def simple_function(): + """Simple function description.""" + pass + + result = ConversationToolsFunction.from_function(simple_function) + + self.assertEqual(result.name, 'simple_function') + self.assertEqual(result.description, 'Simple function description.') + + def test_from_function_sphinx_style_summary(self): + """Test from_function extracts only summary from Sphinx-style docstring.""" + + def sphinx_function(location: str): + """Get weather information for a location. + + This function provides comprehensive weather data including + current conditions and forecasts using various APIs. + + :param location: The location to get weather for + :type location: str + :returns: Weather information string + :rtype: str + :raises ValueError: If location is invalid + """ + return f'Weather for {location}' + + result = ConversationToolsFunction.from_function(sphinx_function) + + expected_description = ( + 'Get weather information for a location. ' + 'This function provides comprehensive weather data including ' + 'current conditions and forecasts using various APIs.' + ) + self.assertEqual(result.description, expected_description) + + def test_from_function_google_style_summary(self): + """Test from_function extracts only summary from Google-style docstring.""" + + def google_function(data: str): + """Process input data efficiently. + + This function handles various data formats and applies + multiple processing algorithms for optimal results. + + Args: + data: The input data to process + + Returns: + str: Processed data string + + Raises: + ValueError: If data format is invalid + """ + return f'Processed {data}' + + result = ConversationToolsFunction.from_function(google_function) + + expected_description = ( + 'Process input data efficiently. ' + 'This function handles various data formats and applies ' + 'multiple processing algorithms for optimal results.' + ) + self.assertEqual(result.description, expected_description) + + +class TestIntegrationScenarios(unittest.TestCase): + """Test real-world integration scenarios.""" + + def test_restaurant_finder_scenario(self): + """Test the restaurant finder example from the documentation.""" + from enum import Enum + from typing import List, Optional + + class PriceRange(Enum): + BUDGET = 'budget' + MODERATE = 'moderate' + EXPENSIVE = 'expensive' + + def find_restaurants( + location: str, + cuisine: str = 'any', + price_range: PriceRange = PriceRange.MODERATE, + max_results: int = 5, + dietary_restrictions: Optional[List[str]] = None, + ) -> str: + """Find restaurants in a specific location. + + Args: + location: The city or neighborhood to search + cuisine: Type of cuisine (italian, chinese, mexican, etc.) + price_range: Budget preference for dining + max_results: Maximum number of restaurant recommendations + dietary_restrictions: Special dietary needs (vegetarian, gluten-free, etc.) + """ + return f'Found restaurants in {location}' + + schema = function_to_json_schema(find_restaurants) + + # Comprehensive validation + self.assertEqual(schema['type'], 'object') + + # Check all properties exist + props = schema['properties'] + self.assertIn('location', props) + self.assertIn('cuisine', props) + self.assertIn('price_range', props) + self.assertIn('max_results', props) + self.assertIn('dietary_restrictions', props) + + # Check types + self.assertEqual(props['location']['type'], 'string') + self.assertEqual(props['cuisine']['type'], 'string') + self.assertEqual(props['price_range']['type'], 'string') + self.assertEqual(props['max_results']['type'], 'integer') + self.assertEqual(props['dietary_restrictions']['type'], 'array') + self.assertEqual(props['dietary_restrictions']['items']['type'], 'string') + + # Check enum values + self.assertEqual(set(props['price_range']['enum']), {'budget', 'moderate', 'expensive'}) + + # Check descriptions + self.assertIn('description', props['location']) + self.assertIn('description', props['cuisine']) + self.assertIn('description', props['price_range']) + + # Check required (only location is required) + self.assertEqual(schema['required'], ['location']) + + def test_weather_api_scenario(self): + """Test a weather API scenario with validation.""" + from enum import Enum + from typing import Optional + + class Units(Enum): + CELSIUS = 'celsius' + FAHRENHEIT = 'fahrenheit' + KELVIN = 'kelvin' + + def get_weather_forecast( + latitude: float, + longitude: float, + units: Units = Units.CELSIUS, + days: int = 7, + include_hourly: bool = False, + api_key: Optional[str] = None, + ) -> Dict[str, any]: + """Get weather forecast for coordinates. + + Args: + latitude: Latitude coordinate + longitude: Longitude coordinate + units: Temperature units for response + days: Number of forecast days + include_hourly: Whether to include hourly forecasts + api_key: Optional API key override + """ + return {'forecast': []} + + schema = function_to_json_schema(get_weather_forecast) + + # Check numeric types + self.assertEqual(schema['properties']['latitude']['type'], 'number') + self.assertEqual(schema['properties']['longitude']['type'], 'number') + self.assertEqual(schema['properties']['days']['type'], 'integer') + self.assertEqual(schema['properties']['include_hourly']['type'], 'boolean') + + # Check enum + self.assertEqual(schema['properties']['units']['type'], 'string') + self.assertEqual( + set(schema['properties']['units']['enum']), {'celsius', 'fahrenheit', 'kelvin'} + ) + + # Check required fields + self.assertEqual(set(schema['required']), {'latitude', 'longitude'}) + + +class TestTracePrintUserMixin(unittest.TestCase): + def test_user_trace_print_with_name_and_multiple_contents(self): + msg = ConversationMessageOfUser( + name='alice', + content=[ + ConversationMessageContent(text='hello'), + ConversationMessageContent(text='how are you?'), + ], + ) + buf = io.StringIO() + with redirect_stdout(buf): + msg.trace_print(indent=2) + out = buf.getvalue().splitlines() + # Name line with indent + self.assertEqual(' name: alice', out[0]) + # Content lines with computed indentation + self.assertEqual(' content[0]: hello', out[1]) + self.assertEqual(' content[1]: how are you?', out[2]) + + +class TestTracePrintAssistant(unittest.TestCase): + def test_assistant_trace_print_with_tool_calls(self): + tool_calls = [ + ConversationToolCalls( + id='id1', + function=ConversationToolCallsOfFunction( + name='get_weather', arguments='{"location":"Paris"}' + ), + ) + ] + msg = ConversationMessageOfAssistant( + name='helper', + content=[ConversationMessageContent(text='checking weather')], + tool_calls=tool_calls, + ) + buf = io.StringIO() + with redirect_stdout(buf): + msg.trace_print(indent=0) + lines = buf.getvalue().strip().splitlines() + # Name line + self.assertEqual(lines[0], 'name: helper') + # Content line + self.assertEqual(lines[1], 'content[0]: checking weather') + # Tool calls header and entry + self.assertEqual(lines[2], 'tool_calls: 1') + self.assertEqual(lines[3], ' [0] id=id1 function=get_weather({"location":"Paris"})') + + +class TestTracePrintTool(unittest.TestCase): + def test_tool_trace_print_multiline_content(self): + msg = ConversationMessageOfTool( + tool_id='tid-123', + name='get_weather', + content=[ + ConversationMessageContent(text='line1\nline2\nline3'), + ], + ) + buf = io.StringIO() + with redirect_stdout(buf): + msg.trace_print(indent=2) + lines = buf.getvalue().splitlines() + # tool_id and name printed with indent + self.assertEqual(lines[0], ' tool_id: tid-123') + self.assertEqual(lines[1], ' name: get_weather') + # First line has the content[0] prefix with indent + self.assertEqual(lines[2], ' content[0]: line1') + # Subsequent lines are printed as-is per implementation + self.assertEqual(lines[3], 'line2') + self.assertEqual(lines[4], 'line3') + + +class TestTracePrintConversationMessage(unittest.TestCase): + def test_conversation_message_headers_for_all_roles(self): + msg = ConversationMessage( + of_user=ConversationMessageOfUser( + name='bob', content=[ConversationMessageContent(text='hi')] + ), + of_assistant=ConversationMessageOfAssistant( + content=[ConversationMessageContent(text='hello')] + ), + of_tool=ConversationMessageOfTool( + tool_id='t1', + name='tool.fn', + content=[ConversationMessageContent(text='ok')], + ), + of_developer=ConversationMessageOfDeveloper( + name='dev', content=[ConversationMessageContent(text='turn on feature x')] + ), + of_system=ConversationMessageOfSystem( + name='policy', content=[ConversationMessageContent(text='Follow company policy.')] + ), + ) + buf = io.StringIO() + with redirect_stdout(buf): + msg.trace_print(indent=0) + out = buf.getvalue().splitlines() + # First line is an empty line due to initial print() + self.assertEqual(out[0], '') + # Headers for each role appear + # Developer header and content + self.assertEqual(out[1], 'client[devel] --------------> LLM[assistant]:') + self.assertEqual(out[2], ' name: dev') + self.assertEqual(out[3], ' content[0]: turn on feature x') + # System header and content + self.assertEqual(out[4], 'client[system] --------------> LLM[assistant]:') + self.assertEqual(out[5], ' name: policy') + self.assertEqual(out[6], ' content[0]: Follow company policy.') + # Delegated lines for user (name first, then content) + self.assertEqual(out[7], 'client[user] --------------> LLM[assistant]:') + self.assertEqual(out[8], ' name: bob') + self.assertEqual(out[9], ' content[0]: hi') + # Assistant header and content + self.assertEqual(out[10], 'client <------------- LLM[assistant]:') + self.assertIn(' content[0]: hello', out[11]) + # Tool header and content + self.assertEqual(out[12], 'client[tool] -------------> LLM[assistant]:') + self.assertEqual(out[13], ' tool_id: t1') + self.assertEqual(out[14], ' name: tool.fn') + self.assertEqual(out[15], ' content[0]: ok') + + +class TestLargeEnumBehavior(unittest.TestCase): + def setUp(self): + # Save originals + self._orig_max = settings.DAPR_CONVERSATION_TOOLS_MAX_ENUM_ITEMS + self._orig_beh = settings.DAPR_CONVERSATION_TOOLS_LARGE_ENUM_BEHAVIOR + + def tearDown(self): + # Restore + settings.DAPR_CONVERSATION_TOOLS_MAX_ENUM_ITEMS = self._orig_max + settings.DAPR_CONVERSATION_TOOLS_LARGE_ENUM_BEHAVIOR = self._orig_beh + + def test_large_enum_compacted_to_string(self): + # Make threshold tiny to trigger large-enum path + settings.DAPR_CONVERSATION_TOOLS_MAX_ENUM_ITEMS = 2 + settings.DAPR_CONVERSATION_TOOLS_LARGE_ENUM_BEHAVIOR = 'string' + + class BigEnum(Enum): + A = 'a' + B = 'b' + C = 'c' + D = 'd' + + schema = _python_type_to_json_schema(BigEnum) + # Should be compacted to string with description and examples + self.assertEqual(schema.get('type'), 'string') + self.assertIn('description', schema) + self.assertIn('examples', schema) + self.assertTrue(len(schema['examples']) > 0) + + def test_large_enum_error_mode(self): + settings.DAPR_CONVERSATION_TOOLS_MAX_ENUM_ITEMS = 1 + settings.DAPR_CONVERSATION_TOOLS_LARGE_ENUM_BEHAVIOR = 'error' + + from enum import Enum + + class BigEnum(Enum): + A = 'a' + B = 'b' + + with self.assertRaises(ValueError): + _python_type_to_json_schema(BigEnum) + + +class TestCoercionsAndBinding(unittest.TestCase): + def test_coerce_bool_variants(self): + def f(flag: bool) -> bool: + return flag + + # True-ish variants + for v in ['true', 'True', 'YES', '1', 'on', ' y ']: + bound = bind_params_to_func(f, {'flag': v}) + self.assertIs(f(*bound.args, **bound.kwargs), True) + + # False-ish variants + for v in ['false', 'False', 'NO', '0', 'off', ' n ']: + bound = bind_params_to_func(f, {'flag': v}) + self.assertIs(f(*bound.args, **bound.kwargs), False) + + # Invalid + with self.assertRaises(ToolArgumentError): + bind_params_to_func(f, {'flag': 'maybe'}) + + def test_literal_numeric_from_string(self): + def g(x: Literal[1, 2, 3]) -> int: + return x # type: ignore[return-value] + + bound = bind_params_to_func(g, {'x': '2'}) + self.assertEqual(g(*bound.args, **bound.kwargs), 2) + + def test_unexpected_kwarg_is_rejected(self): + def h(a: int) -> int: + return a + + with self.assertRaises(Exception): + bind_params_to_func(h, {'a': 1, 'extra': 2}) + + def test_dataclass_arg_validation(self): + @dataclass + class P: + x: int + y: str + + def k(p: P) -> str: + return p.y + + # Passing an instance is fine + p = P(1, 'ok') + bound = bind_params_to_func(k, {'p': p}) + self.assertEqual(k(*bound.args, **bound.kwargs), 'ok') + + # Passing a dict should fail for dataclass per implementation + with self.assertRaises(ToolArgumentError): + bind_params_to_func(k, {'p': {'x': 1, 'y': 'nope'}}) + + +class TestPlainClassSchema(unittest.TestCase): + def test_plain_class_init_signature(self): + class C: + def __init__(self, a: int, b: str = 'x'): + self.a = a + self.b = b + + schema = _python_type_to_json_schema(C) + self.assertEqual(schema['type'], 'object') + props = schema['properties'] + self.assertIn('a', props) + self.assertIn('b', props) + # Only 'a' is required + self.assertIn('required', schema) + self.assertEqual(schema['required'], ['a']) + + def test_plain_class_slots_fallback(self): + class D: + __slots__ = ('m', 'n') + m: int + n: Optional[str] + + schema = _python_type_to_json_schema(D) + # Implementation builds properties from __slots__ with required for non-optional + self.assertEqual(schema['type'], 'object') + self.assertIn('properties', schema) + self.assertIn('m', schema['properties']) + self.assertIn('n', schema['properties']) + self.assertEqual(schema['properties']['m']['type'], 'integer') + self.assertEqual(schema['properties']['n']['type'], 'string') + self.assertIn('required', schema) + self.assertEqual(schema['required'], ['m']) + + +class TestDocstringUnsupportedWarning(unittest.TestCase): + def test_informal_param_info_warning(self): + def unsupported(x: int, y: str): + """Do something. + + The x parameter should be an integer indicating repetitions. The y parameter is used for labeling. + """ + return x, y + + # _extract_docstring_args is used via function_to_json_schema or directly. Use direct import path + from dapr.clients.grpc._conversation_helpers import _extract_docstring_args + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always') + res = _extract_docstring_args(unsupported) + self.assertEqual(res, {}) + self.assertTrue( + any('appears to contain parameter information' in str(wi.message) for wi in w) + ) + + +class TestLiteralSchemaMapping(unittest.TestCase): + def test_literal_strings_schema(self): + T = Literal['a', 'b', 'c'] + schema = _python_type_to_json_schema(T) + self.assertEqual(schema.get('type'), 'string') + self.assertEqual(set(schema['enum']), {'a', 'b', 'c'}) + + def test_literal_ints_schema(self): + T = Literal[1, 2, 3] + schema = _python_type_to_json_schema(T) + self.assertEqual(schema.get('type'), 'integer') + self.assertEqual(set(schema['enum']), {1, 2, 3}) + + def test_literal_nullable_string_schema(self): + T = Literal[None, 'x', 'y'] + schema = _python_type_to_json_schema(T) + # non-null types only string, should set 'type' to 'string' and include None in enum + self.assertEqual(schema.get('type'), 'string') + self.assertIn(None, schema['enum']) + self.assertIn('x', schema['enum']) + self.assertIn('y', schema['enum']) + + def test_literal_mixed_types_no_unified_type(self): + T = Literal['x', 1] + schema = _python_type_to_json_schema(T) + # Mixed non-null types -> no unified 'type' should be set + self.assertNotIn('type', schema) + self.assertEqual(set(schema['enum']), {'x', 1}) + + def test_literal_enum_members_normalized(self): + from enum import Enum + + class Mode(Enum): + FAST = 'fast' + SLOW = 'slow' + + T = Literal[Mode.FAST, Mode.SLOW] + schema = _python_type_to_json_schema(T) + self.assertEqual(schema.get('type'), 'string') + self.assertEqual(set(schema['enum']), {'fast', 'slow'}) + + def test_literal_bytes_and_bytearray_schema(self): + T = Literal[b'a', bytearray(b'b')] + schema = _python_type_to_json_schema(T) + # bytes/bytearray are coerced to string type for schema typing + self.assertEqual(schema.get('type'), 'string') + # The enum preserves the literal values as provided + self.assertIn(b'a', schema['enum']) + self.assertIn(bytearray(b'b'), schema['enum']) + + +# --- Helpers for Coercion tests + + +class Mode(Enum): + RED = 'red' + BLUE = 'blue' + + +@dataclass +class DC: + x: int + y: str + + +class Plain: + def __init__(self, a: int, b: str = 'x') -> None: + self.a = a + self.b = b + + +class TestScalarCoercions(unittest.TestCase): + def test_int_from_str_and_float_and_invalid(self): + def f(a: int) -> int: + return a + + # str -> int + bound = bind_params_to_func(f, {'a': ' 42 '}) + self.assertEqual(f(*bound.args, **bound.kwargs), 42) + + # float integral -> int + bound = bind_params_to_func(f, {'a': 3.0}) + self.assertEqual(f(*bound.args, **bound.kwargs), 3) + + # float non-integral -> error + with self.assertRaises(ToolArgumentError): + bind_params_to_func(f, {'a': 3.14}) + + def test_float_from_int_and_str(self): + def g(x: float) -> float: + return x + + bound = bind_params_to_func(g, {'x': 2}) + self.assertEqual(g(*bound.args, **bound.kwargs), 2.0) + + bound = bind_params_to_func(g, {'x': ' 3.5 '}) + self.assertEqual(g(*bound.args, **bound.kwargs), 3.5) + + def test_str_from_non_str(self): + def h(s: str) -> str: + return s + + bound = bind_params_to_func(h, {'s': 123}) + self.assertEqual(h(*bound.args, **bound.kwargs), '123') + + def test_bool_variants_and_invalid(self): + def b(flag: bool) -> bool: + return flag + + for v in ['true', 'False', 'YES', 'no', '1', '0', 'on', 'off']: + bound = bind_params_to_func(b, {'flag': v}) + # Ensure conversion yields actual bool + self.assertIsInstance(b(*bound.args, **bound.kwargs), bool) + + with self.assertRaises(ToolArgumentError): + bind_params_to_func(b, {'flag': 'maybe'}) + + +class TestEnumCoercions(unittest.TestCase): + def test_enum_by_value_and_name_and_case_insensitive(self): + def f(m: Mode) -> Mode: + return m + + # by value + bound = bind_params_to_func(f, {'m': 'red'}) + self.assertEqual(f(*bound.args, **bound.kwargs), Mode.RED) + + # by exact name + bound = bind_params_to_func(f, {'m': 'BLUE'}) + self.assertEqual(f(*bound.args, **bound.kwargs), Mode.BLUE) + + # by case-insensitive name + bound = bind_params_to_func(f, {'m': 'red'}) # value already tested; use name lower + self.assertEqual(f(*bound.args, **bound.kwargs), Mode.RED) + + # invalid + with self.assertRaises(ToolArgumentError): + bind_params_to_func(f, {'m': 'green'}) + + +class TestCoerceAndValidateBranches(unittest.TestCase): + def test_optional_and_union(self): + def f(a: Optional[int], b: Union[str, int]) -> tuple: + return a, b + + bound = bind_params_to_func(f, {'a': '2', 'b': 5}) + # Union[str, int] tries str first; 5 is coerced to '5' + self.assertEqual(f(*bound.args, **bound.kwargs), (2, '5')) + + bound = bind_params_to_func(f, {'a': None, 'b': 'hello'}) + self.assertEqual(f(*bound.args, **bound.kwargs), (None, 'hello')) + + def test_list_and_dict_coercion(self): + def g(xs: List[int], mapping: Dict[int, float]) -> tuple: + return xs, mapping + + bound = bind_params_to_func(g, {'xs': ['1', '2', '3'], 'mapping': {'1': '2.5', 3: 4}}) + xs, mapping = g(*bound.args, **bound.kwargs) + self.assertEqual(xs, [1, 2, 3]) + self.assertEqual(mapping, {1: 2.5, 3: 4.0}) + + # Wrong type for list + with self.assertRaises(ToolArgumentError): + bind_params_to_func(g, {'xs': 'not-a-list', 'mapping': {}}) + + # Wrong type for dict + with self.assertRaises(ToolArgumentError): + bind_params_to_func(g, {'xs': [1], 'mapping': 'not-a-dict'}) + + def test_dataclass_optional_and_rejection_of_dict(self): + def f(p: Optional[DC]) -> Optional[str]: + return None if p is None else p.y + + # inst = DC(1, 'ok') + # bound = bind_params_to_func(f, {'p': inst}) + # self.assertEqual(f(*bound.args, **bound.kwargs), 'ok') + # + # bound = bind_params_to_func(f, {'p': None}) + # self.assertIsNone(f(*bound.args, **bound.kwargs)) + + with self.assertRaises(ToolArgumentError): + bind_params_to_func(f, {'p': {'x': 1, 'y': 'no'}}) + + def test_plain_class_construction_from_dict_and_missing_arg(self): + def f(p: Plain) -> int: + return p.a + + # Construct from dict with coercion + bound = bind_params_to_func(f, {'p': {'a': '3'}}) + res = f(*bound.args, **bound.kwargs) + self.assertEqual(res, 3) + self.assertIsInstance(bound.arguments['p'], Plain) + self.assertEqual(bound.arguments['p'].b, 'x') # default applied + + # Missing required arg + with self.assertRaises(ToolArgumentError): + bind_params_to_func(f, {'p': {}}) + + def test_any_and_isinstance_fallback(self): + class C: + ... + + def f(a: Any, c: C) -> tuple: + return a, c + + c = C() + with self.assertRaises(ToolArgumentError) as ctx: + bind_params_to_func(f, {'a': object(), 'c': c}) + # _coerce_and_validate raises TypeError for Any; bind wraps it in ToolArgumentError + self.assertIsInstance(ctx.exception.__cause__, TypeError) + + +# ---- Helpers for test stringify + + +class Shade(Enum): + LIGHT = 'light' + DARK = 'dark' + + +@dataclass +class Pair: + a: int + b: str + + +class PlainWithDict: + def __init__(self): + self.x = 10 + self.y = 'y' + self.fn = lambda: 1 # callable should be filtered out + + +class TestStringifyToolOutputMore(unittest.TestCase): + def test_bytes_and_bytearray_branch(self): + raw = bytes([1, 2, 3, 254, 255]) + expected = 'base64:' + base64.b64encode(raw).decode('ascii') + self.assertEqual(stringify_tool_output(raw), expected) + + ba = bytearray(raw) + expected_ba = 'base64:' + base64.b64encode(bytes(ba)).decode('ascii') + self.assertEqual(stringify_tool_output(ba), expected_ba) + + def test_default_encoder_enum_dataclass_and___dict__(self): + # Enum -> value via default encoder (JSON string) + out_enum = stringify_tool_output(Shade.DARK) + self.assertEqual(out_enum, json.dumps('dark', ensure_ascii=False)) + + # Dataclass -> asdict via default encoder + p = Pair(3, 'z') + out_dc = stringify_tool_output(p) + self.assertEqual(json.loads(out_dc), {'a': 3, 'b': 'z'}) + + # __dict__ plain object -> filtered dict via default encoder + obj = PlainWithDict() + out_obj = stringify_tool_output(obj) + self.assertEqual(json.loads(out_obj), {'x': 10, 'y': 'y'}) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/clients/test_dapr_grpc_client.py b/tests/clients/test_dapr_grpc_client.py index d1884147..e0713f70 100644 --- a/tests/clients/test_dapr_grpc_client.py +++ b/tests/clients/test_dapr_grpc_client.py @@ -36,7 +36,6 @@ from dapr.clients.grpc._request import ( TransactionalStateOperation, TransactionOperationType, - ConversationInput, ) from dapr.clients.grpc._jobs import Job from dapr.clients.grpc._state import StateOptions, Consistency, Concurrency, StateItem @@ -50,6 +49,7 @@ WorkflowRuntimeStatus, TopicEventResponse, ) +from dapr.clients.grpc import conversation class DaprGrpcClientTests(unittest.TestCase): @@ -1191,8 +1191,8 @@ def test_converse_alpha1_basic(self): dapr = DaprGrpcClient(f'{self.scheme}localhost:{self.grpc_port}') inputs = [ - ConversationInput(content='Hello', role='user'), - ConversationInput(content='How are you?', role='user'), + conversation.ConversationInput(content='Hello', role='user'), + conversation.ConversationInput(content='How are you?', role='user'), ] response = dapr.converse_alpha1(name='test-llm', inputs=inputs) @@ -1206,7 +1206,7 @@ def test_converse_alpha1_basic(self): def test_converse_alpha1_with_options(self): dapr = DaprGrpcClient(f'{self.scheme}localhost:{self.grpc_port}') - inputs = [ConversationInput(content='Hello', role='user', scrub_pii=True)] + inputs = [conversation.ConversationInput(content='Hello', role='user', scrub_pii=True)] response = dapr.converse_alpha1( name='test-llm', @@ -1229,12 +1229,317 @@ def test_converse_alpha1_error_handling(self): status_pb2.Status(code=code_pb2.INVALID_ARGUMENT, message='Invalid argument') ) - inputs = [ConversationInput(content='Hello', role='user')] + inputs = [conversation.ConversationInput(content='Hello', role='user')] with self.assertRaises(DaprGrpcError) as context: dapr.converse_alpha1(name='test-llm', inputs=inputs) self.assertTrue('Invalid argument' in str(context.exception)) + def test_converse_alpha2_basic_user_message(self): + """Test basic Alpha2 conversation with user messages.""" + dapr = DaprGrpcClient(f'{self.scheme}localhost:{self.grpc_port}') + + # Create user message + user_message = conversation.ConversationMessage( + of_user=conversation.ConversationMessageOfUser( + name='TestUser', + content=[conversation.ConversationMessageContent(text='Hello, how are you?')], + ) + ) + + # Create Alpha2 input + input_alpha2 = conversation.ConversationInputAlpha2( + messages=[user_message], scrub_pii=False + ) + + response = dapr.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + + # Check response structure + self.assertIsNotNone(response) + self.assertEqual(len(response.outputs), 1) + self.assertEqual(len(response.outputs[0].choices), 1) + + choice = response.outputs[0].choices[0] + self.assertEqual(choice.finish_reason, 'stop') + self.assertEqual(choice.index, 0) + self.assertEqual(choice.message.content, 'Response to user: Hello, how are you?') + self.assertEqual(len(choice.message.tool_calls), 0) + + def test_converse_alpha2_with_tools_weather_request(self): + """Test Alpha2 conversation with tool calling for weather requests.""" + dapr = DaprGrpcClient(f'{self.scheme}localhost:{self.grpc_port}') + + # Create weather tool + weather_tool = conversation.ConversationTools( + function=conversation.ConversationToolsFunction( + name='get_weather', + description='Get current weather information', + parameters={ + 'type': 'object', + 'properties': { + 'location': {'type': 'string', 'description': 'Location for weather info'} + }, + 'required': ['location'], + }, + ) + ) + + # Create user message asking for weather + user_message = conversation.ConversationMessage( + of_user=conversation.ConversationMessageOfUser( + content=[conversation.ConversationMessageContent(text="What's the weather like?")] + ) + ) + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[user_message]) + + response = dapr.converse_alpha2( + name='test-llm', inputs=[input_alpha2], tools=[weather_tool], tool_choice='auto' + ) + + # Check response structure with tool call + self.assertIsNotNone(response) + self.assertEqual(len(response.outputs), 1) + self.assertEqual(len(response.outputs[0].choices), 1) + + choice = response.outputs[0].choices[0] + self.assertEqual(choice.finish_reason, 'tool_calls') + self.assertEqual(choice.index, 0) + self.assertEqual(choice.message.content, "I'll check the weather for you.") + self.assertEqual(len(choice.message.tool_calls), 1) + + tool_call = choice.message.tool_calls[0] + self.assertEqual(tool_call.function.name, 'get_weather') + self.assertEqual( + tool_call.function.arguments, '{"location": "San Francisco", "unit": "celsius"}' + ) + self.assertTrue(tool_call.id.startswith('call_')) + + def test_converse_alpha2_system_message(self): + """Test Alpha2 conversation with system messages.""" + dapr = DaprGrpcClient(f'{self.scheme}localhost:{self.grpc_port}') + + # Create system message + system_message = conversation.ConversationMessage( + of_system=conversation.ConversationMessageOfSystem( + content=[ + conversation.ConversationMessageContent(text='You are a helpful assistant.') + ] + ) + ) + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[system_message]) + + response = dapr.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + + # Check response + self.assertIsNotNone(response) + choice = response.outputs[0].choices[0] + self.assertEqual( + choice.message.content, 'System acknowledged: You are a helpful assistant.' + ) + + def test_converse_alpha2_developer_message(self): + """Test Alpha2 conversation with developer messages.""" + dapr = DaprGrpcClient(f'{self.scheme}localhost:{self.grpc_port}') + + # Create developer message + developer_message = conversation.ConversationMessage( + of_developer=conversation.ConversationMessageOfDeveloper( + name='DevTeam', + content=[ + conversation.ConversationMessageContent(text='Debug: Processing user input') + ], + ) + ) + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[developer_message]) + + response = dapr.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + + # Check response + self.assertIsNotNone(response) + choice = response.outputs[0].choices[0] + self.assertEqual( + choice.message.content, 'Developer note processed: Debug: Processing user input' + ) + + def test_converse_alpha2_tool_message(self): + """Test Alpha2 conversation with tool messages.""" + dapr = DaprGrpcClient(f'{self.scheme}localhost:{self.grpc_port}') + + # Create tool message + tool_message = conversation.ConversationMessage( + of_tool=conversation.ConversationMessageOfTool( + tool_id='call_123', + name='get_weather', + content=[ + conversation.ConversationMessageContent( + text='{"temperature": 22, "condition": "sunny"}' + ) + ], + ) + ) + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[tool_message]) + + response = dapr.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + + # Check response + self.assertIsNotNone(response) + choice = response.outputs[0].choices[0] + self.assertEqual( + choice.message.content, + 'Tool result processed: {"temperature": 22, "condition": "sunny"}', + ) + + def test_converse_alpha2_assistant_message(self): + """Test Alpha2 conversation with assistant messages.""" + dapr = DaprGrpcClient(f'{self.scheme}localhost:{self.grpc_port}') + + # Create assistant message + assistant_message = conversation.ConversationMessage( + of_assistant=conversation.ConversationMessageOfAssistant( + content=[conversation.ConversationMessageContent(text='I understand your request.')] + ) + ) + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[assistant_message]) + + response = dapr.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + + # Check response + self.assertIsNotNone(response) + choice = response.outputs[0].choices[0] + self.assertEqual(choice.message.content, 'Assistant continued: I understand your request.') + + def test_converse_alpha2_multiple_messages(self): + """Test Alpha2 conversation with multiple messages in one input.""" + dapr = DaprGrpcClient(f'{self.scheme}localhost:{self.grpc_port}') + + # Create multiple messages + system_message = conversation.ConversationMessage( + of_system=conversation.ConversationMessageOfSystem( + content=[conversation.ConversationMessageContent(text='You are helpful.')] + ) + ) + + user_message = conversation.ConversationMessage( + of_user=conversation.ConversationMessageOfUser( + content=[conversation.ConversationMessageContent(text='Hello!')] + ) + ) + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[system_message, user_message]) + + response = dapr.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + + # Check response has choices for both messages + self.assertIsNotNone(response) + self.assertEqual(len(response.outputs), 1) + self.assertEqual(len(response.outputs[0].choices), 2) + + # Check individual responses + self.assertEqual( + response.outputs[0].choices[0].message.content, 'System acknowledged: You are helpful.' + ) + self.assertEqual(response.outputs[0].choices[1].message.content, 'Response to user: Hello!') + + def test_converse_alpha2_with_context_and_options(self): + """Test Alpha2 conversation with context ID and various options.""" + from google.protobuf.any_pb2 import Any as GrpcAny + + dapr = DaprGrpcClient(f'{self.scheme}localhost:{self.grpc_port}') + + user_message = conversation.ConversationMessage( + of_user=conversation.ConversationMessageOfUser( + content=[conversation.ConversationMessageContent(text='Continue our conversation')] + ) + ) + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[user_message], scrub_pii=True) + + # Create custom parameters + params = {'custom_param': GrpcAny(value=b'{"setting": "value"}')} + + response = dapr.converse_alpha2( + name='test-llm', + inputs=[input_alpha2], + context_id='chat-session-123', + parameters=params, + metadata={'env': 'test'}, + scrub_pii=True, + temperature=0.7, + tool_choice='none', + ) + + # Check response + self.assertIsNotNone(response) + self.assertEqual(response.context_id, 'chat-session-123') + choice = response.outputs[0].choices[0] + self.assertEqual(choice.message.content, 'Response to user: Continue our conversation') + + def test_converse_alpha2_error_handling(self): + """Test Alpha2 conversation error handling.""" + dapr = DaprGrpcClient(f'{self.scheme}localhost:{self.grpc_port}') + + # Setup server to raise an exception + self._fake_dapr_server.raise_exception_on_next_call( + status_pb2.Status(code=code_pb2.INVALID_ARGUMENT, message='Alpha2 Invalid argument') + ) + + user_message = conversation.ConversationMessage( + of_user=conversation.ConversationMessageOfUser( + content=[conversation.ConversationMessageContent(text='Test error')] + ) + ) + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[user_message]) + + with self.assertRaises(DaprGrpcError) as context: + dapr.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + self.assertTrue('Alpha2 Invalid argument' in str(context.exception)) + + def test_converse_alpha2_tool_choice_specific(self): + """Test Alpha2 conversation with specific tool choice.""" + + dapr = DaprGrpcClient(f'{self.scheme}localhost:{self.grpc_port}') + + # Create multiple tools + weather_tool = conversation.ConversationTools( + function=conversation.ConversationToolsFunction( + name='get_weather', description='Get weather information' + ) + ) + + calculator_tool = conversation.ConversationTools( + function=conversation.ConversationToolsFunction( + name='calculate', description='Perform calculations' + ) + ) + + user_message = conversation.ConversationMessage( + of_user=conversation.ConversationMessageOfUser( + content=[conversation.ConversationMessageContent(text="What's the weather today?")] + ) + ) + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[user_message]) + + response = dapr.converse_alpha2( + name='test-llm', + inputs=[input_alpha2], + tools=[weather_tool, calculator_tool], + tool_choice='get_weather', # Force specific tool + ) + + # Even though we specified a specific tool, our mock will still trigger + # based on content matching "weather" + self.assertIsNotNone(response) + choice = response.outputs[0].choices[0] + if 'weather' in choice.message.content.lower(): + self.assertEqual(choice.finish_reason, 'tool_calls') + # # Tests for Jobs API (Alpha) # diff --git a/tests/clients/test_dapr_grpc_client_async.py b/tests/clients/test_dapr_grpc_client_async.py index 1e3210b2..50043912 100644 --- a/tests/clients/test_dapr_grpc_client_async.py +++ b/tests/clients/test_dapr_grpc_client_async.py @@ -29,7 +29,8 @@ from .fake_dapr_server import FakeDaprSidecar from dapr.conf import settings from dapr.clients.grpc._helpers import to_bytes -from dapr.clients.grpc._request import TransactionalStateOperation, ConversationInput +from dapr.clients.grpc._request import TransactionalStateOperation +from dapr.clients.grpc import conversation from dapr.clients.grpc._jobs import Job from dapr.clients.grpc._state import StateOptions, Consistency, Concurrency, StateItem from dapr.clients.grpc._crypto import EncryptOptions, DecryptOptions @@ -1119,8 +1120,8 @@ async def test_converse_alpha1_basic(self): dapr = DaprGrpcClientAsync(f'{self.scheme}localhost:{self.grpc_port}') inputs = [ - ConversationInput(content='Hello', role='user'), - ConversationInput(content='How are you?', role='user'), + conversation.ConversationInput(content='Hello', role='user'), + conversation.ConversationInput(content='How are you?', role='user'), ] response = await dapr.converse_alpha1(name='test-llm', inputs=inputs) @@ -1135,7 +1136,7 @@ async def test_converse_alpha1_basic(self): async def test_converse_alpha1_with_options(self): dapr = DaprGrpcClientAsync(f'{self.scheme}localhost:{self.grpc_port}') - inputs = [ConversationInput(content='Hello', role='user', scrub_pii=True)] + inputs = [conversation.ConversationInput(content='Hello', role='user', scrub_pii=True)] response = await dapr.converse_alpha1( name='test-llm', @@ -1159,13 +1160,300 @@ async def test_converse_alpha1_error_handling(self): status_pb2.Status(code=code_pb2.INVALID_ARGUMENT, message='Invalid argument') ) - inputs = [ConversationInput(content='Hello', role='user')] + inputs = [conversation.ConversationInput(content='Hello', role='user')] with self.assertRaises(DaprGrpcError) as context: await dapr.converse_alpha1(name='test-llm', inputs=inputs) self.assertTrue('Invalid argument' in str(context.exception)) await dapr.close() + async def test_converse_alpha2_basic_user_message(self): + """Test basic Alpha2 conversation with user messages (async).""" + dapr = DaprGrpcClientAsync(f'{self.scheme}localhost:{self.grpc_port}') + + user_message = conversation.ConversationMessage( + of_user=conversation.ConversationMessageOfUser( + name='TestUser', + content=[conversation.ConversationMessageContent(text='Hello, how are you?')], + ) + ) + + input_alpha2 = conversation.ConversationInputAlpha2( + messages=[user_message], scrub_pii=False + ) + + response = await dapr.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + + self.assertIsNotNone(response) + self.assertEqual(len(response.outputs), 1) + self.assertEqual(len(response.outputs[0].choices), 1) + choice = response.outputs[0].choices[0] + self.assertEqual(choice.finish_reason, 'stop') + self.assertEqual(choice.index, 0) + self.assertEqual(choice.message.content, 'Response to user: Hello, how are you?') + self.assertEqual(len(choice.message.tool_calls), 0) + await dapr.close() + + async def test_converse_alpha2_with_tools_weather_request(self): + """Test Alpha2 conversation with tool calling for weather requests (async).""" + dapr = DaprGrpcClientAsync(f'{self.scheme}localhost:{self.grpc_port}') + + weather_tool = conversation.ConversationTools( + function=conversation.ConversationToolsFunction( + name='get_weather', + description='Get current weather information', + parameters={ + 'type': 'object', + 'properties': { + 'location': {'type': 'string', 'description': 'Location for weather info'} + }, + 'required': ['location'], + }, + ) + ) + + user_message = conversation.ConversationMessage( + of_user=conversation.ConversationMessageOfUser( + content=[conversation.ConversationMessageContent(text="What's the weather like?")] + ) + ) + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[user_message]) + + response = await dapr.converse_alpha2( + name='test-llm', inputs=[input_alpha2], tools=[weather_tool], tool_choice='auto' + ) + + self.assertIsNotNone(response) + self.assertEqual(len(response.outputs), 1) + self.assertEqual(len(response.outputs[0].choices), 1) + choice = response.outputs[0].choices[0] + self.assertEqual(choice.finish_reason, 'tool_calls') + self.assertEqual(choice.index, 0) + self.assertEqual(choice.message.content, "I'll check the weather for you.") + self.assertEqual(len(choice.message.tool_calls), 1) + tool_call = choice.message.tool_calls[0] + self.assertEqual(tool_call.function.name, 'get_weather') + self.assertEqual( + tool_call.function.arguments, '{"location": "San Francisco", "unit": "celsius"}' + ) + self.assertTrue(tool_call.id.startswith('call_')) + await dapr.close() + + async def test_converse_alpha2_system_message(self): + """Test Alpha2 conversation with system messages (async).""" + dapr = DaprGrpcClientAsync(f'{self.scheme}localhost:{self.grpc_port}') + + system_message = conversation.ConversationMessage( + of_system=conversation.ConversationMessageOfSystem( + content=[ + conversation.ConversationMessageContent(text='You are a helpful assistant.') + ] + ) + ) + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[system_message]) + + response = await dapr.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + + self.assertIsNotNone(response) + choice = response.outputs[0].choices[0] + self.assertEqual( + choice.message.content, 'System acknowledged: You are a helpful assistant.' + ) + await dapr.close() + + async def test_converse_alpha2_developer_message(self): + """Test Alpha2 conversation with developer messages (async).""" + dapr = DaprGrpcClientAsync(f'{self.scheme}localhost:{self.grpc_port}') + + developer_message = conversation.ConversationMessage( + of_developer=conversation.ConversationMessageOfDeveloper( + name='DevTeam', + content=[ + conversation.ConversationMessageContent(text='Debug: Processing user input') + ], + ) + ) + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[developer_message]) + + response = await dapr.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + + self.assertIsNotNone(response) + choice = response.outputs[0].choices[0] + self.assertEqual( + choice.message.content, 'Developer note processed: Debug: Processing user input' + ) + await dapr.close() + + async def test_converse_alpha2_tool_message(self): + """Test Alpha2 conversation with tool messages (async).""" + dapr = DaprGrpcClientAsync(f'{self.scheme}localhost:{self.grpc_port}') + + tool_message = conversation.ConversationMessage( + of_tool=conversation.ConversationMessageOfTool( + tool_id='call_123', + name='get_weather', + content=[ + conversation.ConversationMessageContent( + text='{"temperature": 22, "condition": "sunny"}' + ) + ], + ) + ) + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[tool_message]) + + response = await dapr.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + + self.assertIsNotNone(response) + choice = response.outputs[0].choices[0] + self.assertEqual( + choice.message.content, + 'Tool result processed: {"temperature": 22, "condition": "sunny"}', + ) + await dapr.close() + + async def test_converse_alpha2_assistant_message(self): + """Test Alpha2 conversation with assistant messages (async).""" + dapr = DaprGrpcClientAsync(f'{self.scheme}localhost:{self.grpc_port}') + + assistant_message = conversation.ConversationMessage( + of_assistant=conversation.ConversationMessageOfAssistant( + content=[conversation.ConversationMessageContent(text='I understand your request.')] + ) + ) + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[assistant_message]) + + response = await dapr.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + + self.assertIsNotNone(response) + choice = response.outputs[0].choices[0] + self.assertEqual(choice.message.content, 'Assistant continued: I understand your request.') + await dapr.close() + + async def test_converse_alpha2_multiple_messages(self): + """Test Alpha2 conversation with multiple messages in one input (async).""" + dapr = DaprGrpcClientAsync(f'{self.scheme}localhost:{self.grpc_port}') + + system_message = conversation.ConversationMessage( + of_system=conversation.ConversationMessageOfSystem( + content=[conversation.ConversationMessageContent(text='You are helpful.')] + ) + ) + + user_message = conversation.ConversationMessage( + of_user=conversation.ConversationMessageOfUser( + content=[conversation.ConversationMessageContent(text='Hello!')] + ) + ) + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[system_message, user_message]) + + response = await dapr.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + + self.assertIsNotNone(response) + self.assertEqual(len(response.outputs), 1) + self.assertEqual(len(response.outputs[0].choices), 2) + self.assertEqual( + response.outputs[0].choices[0].message.content, + 'System acknowledged: You are helpful.', + ) + self.assertEqual(response.outputs[0].choices[1].message.content, 'Response to user: Hello!') + await dapr.close() + + async def test_converse_alpha2_with_context_and_options(self): + """Test Alpha2 conversation with context ID and various options (async).""" + from google.protobuf.any_pb2 import Any as GrpcAny + + dapr = DaprGrpcClientAsync(f'{self.scheme}localhost:{self.grpc_port}') + + user_message = conversation.ConversationMessage( + of_user=conversation.ConversationMessageOfUser( + content=[conversation.ConversationMessageContent(text='Continue our conversation')] + ) + ) + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[user_message], scrub_pii=True) + + params = {'custom_param': GrpcAny(value=b'{"setting": "value"}')} + + response = await dapr.converse_alpha2( + name='test-llm', + inputs=[input_alpha2], + context_id='chat-session-123', + parameters=params, + metadata={'env': 'test'}, + scrub_pii=True, + temperature=0.7, + tool_choice='none', + ) + + self.assertIsNotNone(response) + self.assertEqual(response.context_id, 'chat-session-123') + choice = response.outputs[0].choices[0] + self.assertEqual(choice.message.content, 'Response to user: Continue our conversation') + await dapr.close() + + async def test_converse_alpha2_error_handling(self): + """Test Alpha2 conversation error handling (async).""" + dapr = DaprGrpcClientAsync(f'{self.scheme}localhost:{self.grpc_port}') + + self._fake_dapr_server.raise_exception_on_next_call( + status_pb2.Status(code=code_pb2.INVALID_ARGUMENT, message='Alpha2 Invalid argument') + ) + + user_message = conversation.ConversationMessage( + of_user=conversation.ConversationMessageOfUser( + content=[conversation.ConversationMessageContent(text='Test error')] + ) + ) + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[user_message]) + + with self.assertRaises(DaprGrpcError) as context: + await dapr.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + self.assertTrue('Alpha2 Invalid argument' in str(context.exception)) + await dapr.close() + + async def test_converse_alpha2_tool_choice_specific(self): + """Test Alpha2 conversation with specific tool choice (async).""" + dapr = DaprGrpcClientAsync(f'{self.scheme}localhost:{self.grpc_port}') + + weather_tool = conversation.ConversationTools( + function=conversation.ConversationToolsFunction( + name='get_weather', description='Get weather information' + ) + ) + calculator_tool = conversation.ConversationTools( + function=conversation.ConversationToolsFunction( + name='calculate', description='Perform calculations' + ) + ) + + user_message = conversation.ConversationMessage( + of_user=conversation.ConversationMessageOfUser( + content=[conversation.ConversationMessageContent(text="What's the weather today?")] + ) + ) + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[user_message]) + + response = await dapr.converse_alpha2( + name='test-llm', + inputs=[input_alpha2], + tools=[weather_tool, calculator_tool], + tool_choice='get_weather', + ) + + self.assertIsNotNone(response) + choice = response.outputs[0].choices[0] + if 'weather' in choice.message.content.lower(): + self.assertEqual(choice.finish_reason, 'tool_calls') + await dapr.close() + # # Tests for Jobs API (Alpha) - Async # diff --git a/tests/clients/test_dapr_grpc_helpers.py b/tests/clients/test_dapr_grpc_helpers.py new file mode 100644 index 00000000..9e794aab --- /dev/null +++ b/tests/clients/test_dapr_grpc_helpers.py @@ -0,0 +1,189 @@ +import base64 +import unittest + +from google.protobuf.struct_pb2 import Struct +from google.protobuf import json_format +from google.protobuf.json_format import ParseError +from google.protobuf.any_pb2 import Any as GrpcAny +from google.protobuf.wrappers_pb2 import ( + BoolValue, + StringValue, + Int32Value, + Int64Value, + DoubleValue, + BytesValue, +) + +from dapr.clients.grpc._helpers import ( + convert_value_to_struct, + convert_dict_to_grpc_dict_of_any, +) + + +class TestConvertValueToStruct(unittest.TestCase): + def test_struct_passthrough_same_instance(self): + # Prepare a Struct + original = Struct() + json_format.ParseDict({'a': 1, 'b': 'x'}, original) + + # It should return the exact same instance + result = convert_value_to_struct(original) + self.assertIs(result, original) + + def test_simple_and_nested_dict_conversion(self): + payload = { + 'a': 'b', + 'n': 3, + 't': True, + 'f': 1.5, + 'none': None, + 'list': [1, 'x', False, None], + 'obj': {'k': 'v', 'inner': {'i': 2, 'j': None}}, + } + struct = convert_value_to_struct(payload) + + # Convert back to dict to assert equivalence + back = json_format.MessageToDict(struct, preserving_proto_field_name=True) + self.assertEqual( + back, + { + 'a': 'b', + 'n': 3, + 't': True, + 'f': 1.5, + 'none': None, + 'list': [1, 'x', False, None], + 'obj': {'k': 'v', 'inner': {'i': 2, 'j': None}}, + }, + ) + + def test_invalid_non_dict_non_bytes_types_raise(self): + for bad in [ + 'str', + 42, + 3.14, + True, + None, + [1, 2, 3], + ]: + with self.subTest(value=bad): + with self.assertRaises(ValueError) as ctx: + convert_value_to_struct(bad) # type: ignore[arg-type] + self.assertIn('Value must be a dictionary, got', str(ctx.exception)) + + def test_bytes_input_raises_parse_error(self): + data = b'hello world' + # The implementation base64-encodes bytes then attempts to ParseDict a string, + # which results in a ParseError from protobuf's json_format. + with self.assertRaises(ParseError) as ctx: + convert_value_to_struct(data) + msg = str(ctx.exception) + # Ensure the base64 string is what would have been produced (implementation detail) + expected_b64 = base64.b64encode(data).decode('utf-8') + self.assertIn(expected_b64, msg) + + def test_dict_with_non_string_key_raises_wrapped_value_error(self): + # Struct JSON object keys must be strings; non-string key should cause parse error + bad_dict = {1: 'a', 'ok': 2} # type: ignore[dict-item] + with self.assertRaises(ValueError) as ctx: + convert_value_to_struct(bad_dict) # type: ignore[arg-type] + self.assertIn('Unsupported parameter type or value', str(ctx.exception)) + + def test_json_roundtrip_struct_to_dict_to_json(self): + import json + + # Start with a JSON string (could come from any external source) + original_json = json.dumps( + { + 'a': 'b', + 'n': 3, + 't': True, + 'f': 1.5, + 'none': None, + 'list': [1, 'x', False, None], + 'obj': {'k': 'v', 'inner': {'i': 2, 'j': None}}, + } + ) + + # JSON -> dict + original_dict = json.loads(original_json) + + # dict -> Struct + struct = convert_value_to_struct(original_dict) + + # Struct -> dict + back_to_dict = json_format.MessageToDict(struct, preserving_proto_field_name=True) + + # dict -> JSON + final_json = json.dumps(back_to_dict, separators=(',', ':'), sort_keys=True) + + # Validate: parsing final_json should yield the original_dict structure + # Note: We compare dicts to avoid key-order issues and formatting differences + self.assertEqual(json.loads(final_json), original_dict) + + +class TestConvertDictToGrpcDictOfAny(unittest.TestCase): + def test_none_and_empty_return_empty_dict(self): + self.assertEqual(convert_dict_to_grpc_dict_of_any(None), {}) + self.assertEqual(convert_dict_to_grpc_dict_of_any({}), {}) + + def test_basic_types_conversion(self): + params = { + 's': 'hello', + 'b': True, + 'i32': 123, + 'i64': 2**40, + 'f': 3.14, + 'bytes': b'abc', + } + result = convert_dict_to_grpc_dict_of_any(params) + + # Ensure all keys present + self.assertEqual(set(result.keys()), set(params.keys())) + + # Check each Any contains the proper wrapper with correct value + sv = StringValue() + self.assertTrue(result['s'].Unpack(sv)) + self.assertEqual(sv.value, 'hello') + + bv = BoolValue() + self.assertTrue(result['b'].Unpack(bv)) + self.assertEqual(bv.value, True) + + i32v = Int32Value() + self.assertTrue(result['i32'].Unpack(i32v)) + self.assertEqual(i32v.value, 123) + + i64v = Int64Value() + self.assertTrue(result['i64'].Unpack(i64v)) + self.assertEqual(i64v.value, 2**40) + + dv = DoubleValue() + self.assertTrue(result['f'].Unpack(dv)) + self.assertAlmostEqual(dv.value, 3.14) + + byv = BytesValue() + self.assertTrue(result['bytes'].Unpack(byv)) + self.assertEqual(byv.value, b'abc') + + def test_pass_through_existing_any_instances(self): + # Prepare Any values + any_s = GrpcAny() + any_s.Pack(StringValue(value='x')) + any_i = GrpcAny() + any_i.Pack(Int64Value(value=9999999999)) + + params = {'s': any_s, 'i': any_i} + result = convert_dict_to_grpc_dict_of_any(params) + + # Should be the exact same Any instances + self.assertIs(result['s'], any_s) + self.assertIs(result['i'], any_i) + + def test_unsupported_type_raises_value_error(self): + with self.assertRaises(ValueError): + convert_dict_to_grpc_dict_of_any({'bad': [1, 2, 3]}) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/tox.ini b/tox.ini index 0c9ebeab..ebd403c3 100644 --- a/tox.ini +++ b/tox.ini @@ -75,6 +75,25 @@ commands_pre = pip3 install -e {toxinidir}/ext/dapr-ext-fastapi/ allowlist_externals=* +[testenv:example-component] +; This environment is used to validate a specific example component. +; Usage: tox -e example-component -- component_name +; Example: tox -e example-component -- conversation +passenv = HOME +basepython = python3 +changedir = ./examples/ +deps = + mechanical-markdown +commands = + ./validate.sh {posargs} + +commands_pre = + pip3 install -e {toxinidir}/ + pip3 install -e {toxinidir}/ext/dapr-ext-workflow/ + pip3 install -e {toxinidir}/ext/dapr-ext-grpc/ + pip3 install -e {toxinidir}/ext/dapr-ext-fastapi/ +allowlist_externals=* + [testenv:type] basepython = python3 usedevelop = False From a06d4fcb509ac69d54725097dc45c2d205c39373 Mon Sep 17 00:00:00 2001 From: Filinto Duran <1373693+filintod@users.noreply.github.com> Date: Fri, 12 Sep 2025 12:38:47 -0500 Subject: [PATCH 03/10] update docs with tool calling helpers info (#838) Signed-off-by: Filinto Duran <1373693+filintod@users.noreply.github.com> --- daprdocs/content/en/python-sdk-docs/_index.md | 7 + .../en/python-sdk-docs/conversation.md | 295 ++++++++++++++++++ 2 files changed, 302 insertions(+) create mode 100644 daprdocs/content/en/python-sdk-docs/conversation.md diff --git a/daprdocs/content/en/python-sdk-docs/_index.md b/daprdocs/content/en/python-sdk-docs/_index.md index 454a5afb..b8689eb9 100644 --- a/daprdocs/content/en/python-sdk-docs/_index.md +++ b/daprdocs/content/en/python-sdk-docs/_index.md @@ -67,6 +67,13 @@ Python SDK imports are subpackages included with the main SDK install, but need +
+
+
Conversation
+

Use the Dapr Conversation API (Alpha) for LLM interactions, tools, and multi-turn flows.

+ +
+
Learn more about _all_ of the [available Dapr Python SDK imports](https://github.com/dapr/python-sdk/tree/master/dapr). diff --git a/daprdocs/content/en/python-sdk-docs/conversation.md b/daprdocs/content/en/python-sdk-docs/conversation.md new file mode 100644 index 00000000..db67a6c4 --- /dev/null +++ b/daprdocs/content/en/python-sdk-docs/conversation.md @@ -0,0 +1,295 @@ +title: "Conversation API (Python) – Recommended Usage" +linkTitle: "Conversation" +weight: 11000 +type: docs +description: Recommended patterns for using Dapr Conversation API in Python with and without tools, including multi‑turn flows and safety guidance. +--- + +The Dapr Conversation API is currently in alpha. This page presents the recommended, minimal patterns to use it effectively with the Python SDK: +- Plain requests (no tools) +- Requests with tools (functions as tools) +- Multi‑turn flows with tool execution +- Async variants +- Important safety notes for executing tool calls + +## Prerequisites + +- [Dapr CLI]({{% ref install-dapr-cli.md %}}) installed +- Initialized [Dapr environment]({{% ref install-dapr-selfhost.md %}}) +- [Python 3.9+](https://www.python.org/downloads/) installed +- [Dapr Python package]({{% ref "python#installation" %}}) installed +- A configured LLM component (for example, OpenAI or Azure OpenAI) in your Dapr environment + +For full, end‑to‑end flows and provider setup, see: +- The SDK examples under Conversation: + - [TOOL-CALL-QUICKSTART.md](https://github.com/dapr/python-sdk/blob/main/examples/conversation/TOOL-CALL-QUICKSTART.md) + - [real_llm_providers_example.py](https://github.com/dapr/python-sdk/blob/main/examples/conversation/real_llm_providers_example.py) + +## Plain conversation (no tools) + +```python +from dapr.clients import DaprClient +from dapr.clients.grpc import conversation + +# Build a single‑turn Alpha2 input +user_msg = conversation.create_user_message("What's Dapr?") +alpha2_input = conversation.ConversationInputAlpha2(messages=[user_msg]) + +with DaprClient() as client: + resp = client.converse_alpha2( + name="echo", # replace with your LLM component name + inputs=[alpha2_input], + temperature=1, + ) + + for msg in resp.to_assistant_messages(): + if msg.of_assistant.content: + print(msg.of_assistant.content[0].text) +``` + +Key points: +- Use `conversation.create_user_message` to build messages. +- Wrap into `ConversationInputAlpha2(messages=[...])` and pass to `converse_alpha2`. +- Use `response.to_assistant_messages()` to iterate assistant outputs. + +## Tools: decorator‑based (recommended) + +Decorator-based tools offer a clean, ergonomic approach. Define a function with clear type hints and detail docstring, this is important for the LLM to understand how or when to invoke the tool; +decorate it with `@conversation.tool`. Registered tools can be passed to the LLM and invoked via tool calls. + +```python +from dapr.clients import DaprClient +from dapr.clients.grpc import conversation + +@conversation.tool +def get_weather(location: str, unit: str = 'fahrenheit') -> str: + """Get current weather for a location.""" + # Replace with a real implementation + return f"Weather in {location} (unit={unit})" + +user_msg = conversation.create_user_message("What's the weather in Paris?") +alpha2_input = conversation.ConversationInputAlpha2(messages=[user_msg]) + +with DaprClient() as client: + response = client.converse_alpha2( + name="openai", # your LLM component + inputs=[alpha2_input], + tools=conversation.get_registered_tools(), # tools registered by @conversation.tool + tool_choice='auto', + temperature=1, + ) + + # Inspect assistant messages, including any tool calls + for msg in response.to_assistant_messages(): + if msg.of_assistant.tool_calls: + for tc in msg.of_assistant.tool_calls: + print(f"Tool call: {tc.function.name} args={tc.function.arguments}") + elif msg.of_assistant.content: + print(msg.of_assistant.content[0].text) +``` + +Notes: +- Use `conversation.get_registered_tools()` to collect all `@conversation.tool` decorated functions. +- The binder validates/coerces params using your function signature. Keep annotations accurate. + +## Minimal multi‑turn with tools + +This is the go‑to loop for tool‑using conversations: + +{{% alert title="Warning" color="warning" %}} +Do not blindly auto‑execute tool calls returned by the LLM unless you trust all tools registered. Treat tool names and arguments as untrusted input. +- Validate inputs and enforce guardrails (allow‑listed tools, argument schemas, side‑effect constraints). +- For async or I/O‑bound tools, prefer `conversation.execute_registered_tool_async(..., timeout=...)` and set conservative timeouts. +- Consider adding a policy layer or a user confirmation step before execution in sensitive contexts. +- Log and monitor tool usage; fail closed when validation fails. +{{% /alert %}} + +```python +from dapr.clients import DaprClient +from dapr.clients.grpc import conversation + +@conversation.tool +def get_weather(location: str, unit: str = 'fahrenheit') -> str: + return f"Weather in {location} (unit={unit})" + +history: list[conversation.ConversationMessage] = [ + conversation.create_user_message("What's the weather in San Francisco?")] + +with DaprClient() as client: + # Turn 1 + resp1 = client.converse_alpha2( + name="openai", + inputs=[conversation.ConversationInputAlpha2(messages=history)], + tools=conversation.get_registered_tools(), + tool_choice='auto', + temperature=1, + ) + + # Append assistant messages; execute tool calls; append tool results + for msg in resp1.to_assistant_messages(): + history.append(msg) + for tc in msg.of_assistant.tool_calls: + # IMPORTANT: validate inputs and enforce guardrails in production + tool_output = conversation.execute_registered_tool( + tc.function.name, tc.function.arguments + ) + history.append( + conversation.create_tool_message( + tool_id=tc.id, name=tc.function.name, content=str(tool_output) + ) + ) + + # Turn 2 (LLM sees tool result) + history.append(conversation.create_user_message("Should I bring an umbrella?")) + resp2 = client.converse_alpha2( + name="openai", + inputs=[conversation.ConversationInputAlpha2(messages=history)], + tools=conversation.get_registered_tools(), + temperature=1, + ) + + for msg in resp2.to_assistant_messages(): + history.append(msg) + if not msg.of_assistant.tool_calls and msg.of_assistant.content: + print(msg.of_assistant.content[0].text) +``` + +Tips: +- Always append assistant messages to history. +- Execute each tool call (with validation) and append a tool message with the tool output. +- The next turn includes these tool results so the LLM can reason with them. + +## Functions as tools: alternatives + +When decorators aren’t practical, two options exist. + +A) Automatic schema from a typed function: + +```python +from enum import Enum +from dapr.clients.grpc import conversation + +class Units(Enum): + CELSIUS = 'celsius' + FAHRENHEIT = 'fahrenheit' + +def get_weather(location: str, unit: Units = Units.FAHRENHEIT) -> str: + return f"Weather in {location}" + +fn = conversation.ConversationToolsFunction.from_function(get_weather) +weather_tool = conversation.ConversationTools(function=fn) +``` + +B) Manual JSON Schema (fallback): + +```python +from dapr.clients.grpc import conversation + +fn = conversation.ConversationToolsFunction( + name='get_weather', + description='Get current weather', + parameters={ + 'type': 'object', + 'properties': { + 'location': {'type': 'string'}, + 'unit': {'type': 'string', 'enum': ['celsius', 'fahrenheit']}, + }, + 'required': ['location'], + }, +) +weather_tool = conversation.ConversationTools(function=fn) +``` + +## Async variant + +Use the asynchronous client and async tool execution helpers as needed. + +```python +import asyncio +from dapr.aio.clients import DaprClient as AsyncDaprClient +from dapr.clients.grpc import conversation + +@conversation.tool +def get_time() -> str: + return '2025-01-01T12:00:00Z' + +async def main(): + async with AsyncDaprClient() as client: + msg = conversation.create_user_message('What time is it?') + inp = conversation.ConversationInputAlpha2(messages=[msg]) + resp = await client.converse_alpha2( + name='openai', inputs=[inp], tools=conversation.get_registered_tools() + ) + for m in resp.to_assistant_messages(): + if m.of_assistant.content: + print(m.of_assistant.content[0].text) + +asyncio.run(main()) +``` + +If you need to execute tools asynchronously (e.g., network I/O), implement async functions and use `conversation.execute_registered_tool_async` with timeouts. + +## Safety and validation (must‑read) + +An LLM may suggest tool calls. Treat all model‑provided parameters as untrusted input. + +Recommendations: +- Register only trusted functions as tools. Prefer the `@conversation.tool` decorator for clarity and automatic schema generation. +- Use precise type annotations and docstrings. The SDK converts function signatures to JSON schema and binds parameters with type coercion and rejection of unexpected/invalid fields. +- Add guardrails for tools that can cause side effects (filesystem, network, subprocess). Consider allow‑lists, sandboxing, and limits. +- Validate arguments before execution. For example, sanitize file paths or restrict URLs/domains. +- Consider timeouts and concurrency controls. For async tools, pass a timeout to `execute_registered_tool_async(..., timeout=...)`. +- Log and monitor tool usage. Fail closed: if validation fails, avoid executing the tool and inform the user safely. + +See also inline notes in `dapr/clients/grpc/conversation.py` (e.g., `tool()`, `ConversationTools`, `execute_registered_tool`) for parameter binding and error handling details. + + +## Key helper methods (quick reference) + +This section summarizes helper utilities available in dapr.clients.grpc.conversation used throughout the examples. + +- create_user_message(text: str) -> ConversationMessage + - Builds a user role message for Alpha2. Use in history lists. + - Example: `history.append(conversation.create_user_message("Hello"))` + +- create_system_message(text: str) -> ConversationMessage + - Builds a system message to steer the assistant’s behavior. + - Example: `history = [conversation.create_system_message("You are a concise assistant.")]` + +- create_assistant_message(text: str) -> ConversationMessage + - Useful for injecting assistant text in tests or controlled flows. + +- create_tool_message(tool_id: str, name: str, content: Any) -> ConversationMessage + - Converts a tool’s output into a tool message the LLM can read next turn. + - content can be any object; it is stringified safely by the SDK. + - Example: `history.append(conversation.create_tool_message(tool_id=tc.id, name=tc.function.name, content=conversation.execute_registered_tool(tc.function.name, tc.function.arguments)))` + +- get_registered_tools() -> list[ConversationTools] + - Returns all tools currently registered in the in-process registry. + - Includes tools created via: + - @conversation.tool decorator (auto-registered by default), and + - ConversationToolsFunction.from_function with register=True (default). + - Pass this list in converse_alpha2(..., tools=...). + +- register_tool(name: str, t: ConversationTools) / unregister_tool(name: str) + - Manually manage the tool registry (e.g., advanced scenarios, tests, cleanup). + - Names must be unique; unregister to avoid collisions in long-lived processes. + +- execute_registered_tool(name: str, params: Mapping|Sequence|str|None) -> Any + - Synchronously executes a registered tool by name. + - params accepts kwargs (mapping), args (sequence), JSON string, or None. If a JSON string is provided (as commonly returned by LLMs), it is parsed for you. + - Parameters are validated and coerced against the function signature/schema; unexpected or invalid fields raise errors. + - Security: treat params as untrusted; add guardrails for side effects. + +- execute_registered_tool_async(name: str, params: Mapping|Sequence|str|None, *, timeout: float|None=None) -> Any + - Async counterpart. Supports timeouts, which are recommended for I/O-bound tools. + - Prefer this for async tools or when using the aio client. + +- ConversationToolsFunction.from_function(func: Callable, register: bool = True) -> ConversationToolsFunction + - Derives a JSON schema from a typed Python function (annotations + optional docstring) and optionally registers a tool. + - Typical usage: `spec = conversation.ConversationToolsFunction.from_function(my_func)`; then either rely on auto-registration or wrap with `ConversationTools(function=spec)` and call `register_tool(spec.name, tool)` or pass `[tool]` directly to `tools=`. + +- ConversationResponseAlpha2.to_assistant_messages() -> list[ConversationMessage] + - Convenience to transform the response outputs into assistant ConversationMessage objects you can append to history directly (including tool_calls when present). + +Tip: The @conversation.tool decorator is the easiest way to create a tool. It auto-generates the schema from your function, allows an optional namespace/name override, and auto-registers the tool (you can set register=False to defer registration). From 1569fba0be30b2b8e4c978c86cad38218d4cff84 Mon Sep 17 00:00:00 2001 From: Elena Kolevska Date: Fri, 12 Sep 2025 18:51:08 +0100 Subject: [PATCH 04/10] 1.16.0rc2 Signed-off-by: Elena Kolevska --- dapr/version/version.py | 2 +- examples/demo_actor/demo_actor/requirements.txt | 2 +- examples/demo_workflow/demo_workflow/requirements.txt | 2 +- examples/invoke-simple/requirements.txt | 4 ++-- examples/w3c-tracing/requirements.txt | 4 ++-- examples/workflow/requirements.txt | 4 ++-- ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py | 2 +- ext/dapr-ext-fastapi/setup.cfg | 2 +- ext/dapr-ext-grpc/dapr/ext/grpc/version.py | 2 +- ext/dapr-ext-grpc/setup.cfg | 2 +- ext/dapr-ext-workflow/dapr/ext/workflow/version.py | 2 +- ext/dapr-ext-workflow/setup.cfg | 2 +- ext/flask_dapr/flask_dapr/version.py | 2 +- ext/flask_dapr/setup.cfg | 2 +- 14 files changed, 17 insertions(+), 17 deletions(-) diff --git a/dapr/version/version.py b/dapr/version/version.py index 95693e39..cc4914d1 100644 --- a/dapr/version/version.py +++ b/dapr/version/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.0rc1' +__version__ = '1.16.0rc2' diff --git a/examples/demo_actor/demo_actor/requirements.txt b/examples/demo_actor/demo_actor/requirements.txt index 36548818..bf327e43 100644 --- a/examples/demo_actor/demo_actor/requirements.txt +++ b/examples/demo_actor/demo_actor/requirements.txt @@ -1 +1 @@ -dapr-ext-fastapi>=1.16.0rc1 +dapr-ext-fastapi>=1.16.0rc2 diff --git a/examples/demo_workflow/demo_workflow/requirements.txt b/examples/demo_workflow/demo_workflow/requirements.txt index 76d2a673..6b3c699e 100644 --- a/examples/demo_workflow/demo_workflow/requirements.txt +++ b/examples/demo_workflow/demo_workflow/requirements.txt @@ -1 +1 @@ -dapr-ext-workflow>=1.16.0rc1 \ No newline at end of file +dapr-ext-workflow>=1.16.0rc2 \ No newline at end of file diff --git a/examples/invoke-simple/requirements.txt b/examples/invoke-simple/requirements.txt index 1481c0c9..03b31f71 100644 --- a/examples/invoke-simple/requirements.txt +++ b/examples/invoke-simple/requirements.txt @@ -1,2 +1,2 @@ -dapr-ext-grpc >= 1.16.0rc1 -dapr >= 1.16.0rc1 +dapr-ext-grpc >= 1.16.0rc2 +dapr >= 1.16.0rc2 diff --git a/examples/w3c-tracing/requirements.txt b/examples/w3c-tracing/requirements.txt index 042e1b7f..8fca8eda 100644 --- a/examples/w3c-tracing/requirements.txt +++ b/examples/w3c-tracing/requirements.txt @@ -1,5 +1,5 @@ -dapr-ext-grpc >= 1.16.0rc1 -dapr >= 1.16.0rc1 +dapr-ext-grpc >= 1.16.0rc2 +dapr >= 1.16.0rc2 opentelemetry-sdk opentelemetry-instrumentation-grpc opentelemetry-exporter-zipkin diff --git a/examples/workflow/requirements.txt b/examples/workflow/requirements.txt index 85763263..cdb6218d 100644 --- a/examples/workflow/requirements.txt +++ b/examples/workflow/requirements.txt @@ -1,2 +1,2 @@ -dapr-ext-workflow>=1.16.0rc1 -dapr>=1.16.0rc1 +dapr-ext-workflow>=1.16.0rc2 +dapr>=1.16.0rc2 diff --git a/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py b/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py index 95693e39..cc4914d1 100644 --- a/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py +++ b/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.0rc1' +__version__ = '1.16.0rc2' diff --git a/ext/dapr-ext-fastapi/setup.cfg b/ext/dapr-ext-fastapi/setup.cfg index 4d1c4d61..93dedbbe 100644 --- a/ext/dapr-ext-fastapi/setup.cfg +++ b/ext/dapr-ext-fastapi/setup.cfg @@ -24,7 +24,7 @@ python_requires = >=3.9 packages = find_namespace: include_package_data = True install_requires = - dapr >= 1.16.0rc1 + dapr >= 1.16.0rc2 uvicorn >= 0.11.6 fastapi >= 0.60.1 diff --git a/ext/dapr-ext-grpc/dapr/ext/grpc/version.py b/ext/dapr-ext-grpc/dapr/ext/grpc/version.py index 95693e39..cc4914d1 100644 --- a/ext/dapr-ext-grpc/dapr/ext/grpc/version.py +++ b/ext/dapr-ext-grpc/dapr/ext/grpc/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.0rc1' +__version__ = '1.16.0rc2' diff --git a/ext/dapr-ext-grpc/setup.cfg b/ext/dapr-ext-grpc/setup.cfg index c998af5d..43b8c05d 100644 --- a/ext/dapr-ext-grpc/setup.cfg +++ b/ext/dapr-ext-grpc/setup.cfg @@ -24,7 +24,7 @@ python_requires = >=3.9 packages = find_namespace: include_package_data = True install_requires = - dapr >= 1.16.0rc1 + dapr >= 1.16.0rc2 cloudevents >= 1.0.0 [options.packages.find] diff --git a/ext/dapr-ext-workflow/dapr/ext/workflow/version.py b/ext/dapr-ext-workflow/dapr/ext/workflow/version.py index 95693e39..cc4914d1 100644 --- a/ext/dapr-ext-workflow/dapr/ext/workflow/version.py +++ b/ext/dapr-ext-workflow/dapr/ext/workflow/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.0rc1' +__version__ = '1.16.0rc2' diff --git a/ext/dapr-ext-workflow/setup.cfg b/ext/dapr-ext-workflow/setup.cfg index df21ebc2..1ed25c2e 100644 --- a/ext/dapr-ext-workflow/setup.cfg +++ b/ext/dapr-ext-workflow/setup.cfg @@ -24,7 +24,7 @@ python_requires = >=3.9 packages = find_namespace: include_package_data = True install_requires = - dapr >= 1.16.0rc1 + dapr >= 1.16.0rc2 durabletask-dapr >= 0.2.0a7 [options.packages.find] diff --git a/ext/flask_dapr/flask_dapr/version.py b/ext/flask_dapr/flask_dapr/version.py index 95693e39..cc4914d1 100644 --- a/ext/flask_dapr/flask_dapr/version.py +++ b/ext/flask_dapr/flask_dapr/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.0rc1' +__version__ = '1.16.0rc2' diff --git a/ext/flask_dapr/setup.cfg b/ext/flask_dapr/setup.cfg index d3019d27..68fefcbe 100644 --- a/ext/flask_dapr/setup.cfg +++ b/ext/flask_dapr/setup.cfg @@ -26,4 +26,4 @@ include_package_data = true zip_safe = false install_requires = Flask >= 1.1 - dapr >= 1.16.0rc1 + dapr >= 1.16.0rc2 From a7414eb99f6bf4f5f6e55f106c948773d14f3b4e Mon Sep 17 00:00:00 2001 From: Cassie Coyle Date: Wed, 17 Sep 2025 05:27:37 -0500 Subject: [PATCH 05/10] use latest durabletask (#840) Signed-off-by: Cassandra Coyle --- dev-requirements.txt | 2 +- ext/dapr-ext-workflow/setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dev-requirements.txt b/dev-requirements.txt index cbd71985..3e7ca471 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -15,7 +15,7 @@ Flask>=1.1 # needed for auto fix ruff===0.2.2 # needed for dapr-ext-workflow -durabletask-dapr >= 0.2.0a7 +durabletask-dapr >= 0.2.0a8 # needed for .env file loading in examples python-dotenv>=1.0.0 # needed for enhanced schema generation from function features diff --git a/ext/dapr-ext-workflow/setup.cfg b/ext/dapr-ext-workflow/setup.cfg index 1ed25c2e..b7451642 100644 --- a/ext/dapr-ext-workflow/setup.cfg +++ b/ext/dapr-ext-workflow/setup.cfg @@ -25,7 +25,7 @@ packages = find_namespace: include_package_data = True install_requires = dapr >= 1.16.0rc2 - durabletask-dapr >= 0.2.0a7 + durabletask-dapr >= 0.2.0a8 [options.packages.find] include = From ae3e592f78a58c220504a0c507bbf49737f00b31 Mon Sep 17 00:00:00 2001 From: Elena Kolevska Date: Wed, 17 Sep 2025 11:53:47 +0100 Subject: [PATCH 06/10] 1.16.0 Signed-off-by: Elena Kolevska --- dapr/version/version.py | 2 +- examples/demo_actor/demo_actor/requirements.txt | 2 +- examples/demo_workflow/demo_workflow/requirements.txt | 2 +- examples/invoke-simple/requirements.txt | 4 ++-- examples/w3c-tracing/requirements.txt | 4 ++-- examples/workflow/requirements.txt | 4 ++-- ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py | 2 +- ext/dapr-ext-fastapi/setup.cfg | 2 +- ext/dapr-ext-grpc/dapr/ext/grpc/version.py | 2 +- ext/dapr-ext-grpc/setup.cfg | 2 +- ext/dapr-ext-workflow/dapr/ext/workflow/version.py | 2 +- ext/dapr-ext-workflow/setup.cfg | 2 +- ext/flask_dapr/flask_dapr/version.py | 2 +- ext/flask_dapr/setup.cfg | 2 +- 14 files changed, 17 insertions(+), 17 deletions(-) diff --git a/dapr/version/version.py b/dapr/version/version.py index cc4914d1..ff4ea95e 100644 --- a/dapr/version/version.py +++ b/dapr/version/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.0rc2' +__version__ = '1.16.0' diff --git a/examples/demo_actor/demo_actor/requirements.txt b/examples/demo_actor/demo_actor/requirements.txt index bf327e43..a5d6d966 100644 --- a/examples/demo_actor/demo_actor/requirements.txt +++ b/examples/demo_actor/demo_actor/requirements.txt @@ -1 +1 @@ -dapr-ext-fastapi>=1.16.0rc2 +dapr-ext-fastapi>=1.16.0 diff --git a/examples/demo_workflow/demo_workflow/requirements.txt b/examples/demo_workflow/demo_workflow/requirements.txt index 6b3c699e..c91800c6 100644 --- a/examples/demo_workflow/demo_workflow/requirements.txt +++ b/examples/demo_workflow/demo_workflow/requirements.txt @@ -1 +1 @@ -dapr-ext-workflow>=1.16.0rc2 \ No newline at end of file +dapr-ext-workflow>=1.16.0 \ No newline at end of file diff --git a/examples/invoke-simple/requirements.txt b/examples/invoke-simple/requirements.txt index 03b31f71..5d83c587 100644 --- a/examples/invoke-simple/requirements.txt +++ b/examples/invoke-simple/requirements.txt @@ -1,2 +1,2 @@ -dapr-ext-grpc >= 1.16.0rc2 -dapr >= 1.16.0rc2 +dapr-ext-grpc >= 1.16.0 +dapr >= 1.16.0 diff --git a/examples/w3c-tracing/requirements.txt b/examples/w3c-tracing/requirements.txt index 8fca8eda..ec17054a 100644 --- a/examples/w3c-tracing/requirements.txt +++ b/examples/w3c-tracing/requirements.txt @@ -1,5 +1,5 @@ -dapr-ext-grpc >= 1.16.0rc2 -dapr >= 1.16.0rc2 +dapr-ext-grpc >= 1.16.0 +dapr >= 1.16.0 opentelemetry-sdk opentelemetry-instrumentation-grpc opentelemetry-exporter-zipkin diff --git a/examples/workflow/requirements.txt b/examples/workflow/requirements.txt index cdb6218d..3be7c3c7 100644 --- a/examples/workflow/requirements.txt +++ b/examples/workflow/requirements.txt @@ -1,2 +1,2 @@ -dapr-ext-workflow>=1.16.0rc2 -dapr>=1.16.0rc2 +dapr-ext-workflow>=1.16.0 +dapr>=1.16.0 diff --git a/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py b/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py index cc4914d1..ff4ea95e 100644 --- a/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py +++ b/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.0rc2' +__version__ = '1.16.0' diff --git a/ext/dapr-ext-fastapi/setup.cfg b/ext/dapr-ext-fastapi/setup.cfg index 93dedbbe..ba467c42 100644 --- a/ext/dapr-ext-fastapi/setup.cfg +++ b/ext/dapr-ext-fastapi/setup.cfg @@ -24,7 +24,7 @@ python_requires = >=3.9 packages = find_namespace: include_package_data = True install_requires = - dapr >= 1.16.0rc2 + dapr >= 1.16.0 uvicorn >= 0.11.6 fastapi >= 0.60.1 diff --git a/ext/dapr-ext-grpc/dapr/ext/grpc/version.py b/ext/dapr-ext-grpc/dapr/ext/grpc/version.py index cc4914d1..ff4ea95e 100644 --- a/ext/dapr-ext-grpc/dapr/ext/grpc/version.py +++ b/ext/dapr-ext-grpc/dapr/ext/grpc/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.0rc2' +__version__ = '1.16.0' diff --git a/ext/dapr-ext-grpc/setup.cfg b/ext/dapr-ext-grpc/setup.cfg index 43b8c05d..b05472b1 100644 --- a/ext/dapr-ext-grpc/setup.cfg +++ b/ext/dapr-ext-grpc/setup.cfg @@ -24,7 +24,7 @@ python_requires = >=3.9 packages = find_namespace: include_package_data = True install_requires = - dapr >= 1.16.0rc2 + dapr >= 1.16.0 cloudevents >= 1.0.0 [options.packages.find] diff --git a/ext/dapr-ext-workflow/dapr/ext/workflow/version.py b/ext/dapr-ext-workflow/dapr/ext/workflow/version.py index cc4914d1..ff4ea95e 100644 --- a/ext/dapr-ext-workflow/dapr/ext/workflow/version.py +++ b/ext/dapr-ext-workflow/dapr/ext/workflow/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.0rc2' +__version__ = '1.16.0' diff --git a/ext/dapr-ext-workflow/setup.cfg b/ext/dapr-ext-workflow/setup.cfg index b7451642..edc914a1 100644 --- a/ext/dapr-ext-workflow/setup.cfg +++ b/ext/dapr-ext-workflow/setup.cfg @@ -24,7 +24,7 @@ python_requires = >=3.9 packages = find_namespace: include_package_data = True install_requires = - dapr >= 1.16.0rc2 + dapr >= 1.16.0 durabletask-dapr >= 0.2.0a8 [options.packages.find] diff --git a/ext/flask_dapr/flask_dapr/version.py b/ext/flask_dapr/flask_dapr/version.py index cc4914d1..ff4ea95e 100644 --- a/ext/flask_dapr/flask_dapr/version.py +++ b/ext/flask_dapr/flask_dapr/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.0rc2' +__version__ = '1.16.0' diff --git a/ext/flask_dapr/setup.cfg b/ext/flask_dapr/setup.cfg index 68fefcbe..b86bb284 100644 --- a/ext/flask_dapr/setup.cfg +++ b/ext/flask_dapr/setup.cfg @@ -26,4 +26,4 @@ include_package_data = true zip_safe = false install_requires = Flask >= 1.1 - dapr >= 1.16.0rc2 + dapr >= 1.16.0 From 99314a4575592311b0b4ec5a27409f5388f10e9f Mon Sep 17 00:00:00 2001 From: Albert Callarisa Date: Wed, 17 Sep 2025 17:51:19 +0200 Subject: [PATCH 07/10] Adds support for interceptors and concurrency_options arguments in the workflow engine (#841) Signed-off-by: Albert Callarisa --- .../dapr/ext/workflow/workflow_runtime.py | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/ext/dapr-ext-workflow/dapr/ext/workflow/workflow_runtime.py b/ext/dapr-ext-workflow/dapr/ext/workflow/workflow_runtime.py index d1f02b35..9f4be622 100644 --- a/ext/dapr-ext-workflow/dapr/ext/workflow/workflow_runtime.py +++ b/ext/dapr-ext-workflow/dapr/ext/workflow/workflow_runtime.py @@ -15,7 +15,8 @@ import inspect from functools import wraps -from typing import Optional, TypeVar +from typing import Optional, TypeVar, Union, Sequence +import grpc from durabletask import worker, task @@ -34,6 +35,13 @@ TInput = TypeVar('TInput') TOutput = TypeVar('TOutput') +ClientInterceptor = Union[ + grpc.UnaryUnaryClientInterceptor, + grpc.UnaryStreamClientInterceptor, + grpc.StreamUnaryClientInterceptor, + grpc.StreamStreamClientInterceptor, +] + class WorkflowRuntime: """WorkflowRuntime is the entry point for registering workflows and activities.""" @@ -43,6 +51,10 @@ def __init__( host: Optional[str] = None, port: Optional[str] = None, logger_options: Optional[LoggerOptions] = None, + interceptors: Optional[Sequence[ClientInterceptor]] = None, + maximum_concurrent_activity_work_items: Optional[int] = None, + maximum_concurrent_orchestration_work_items: Optional[int] = None, + maximum_thread_pool_workers: Optional[int] = None, ): self._logger = Logger('WorkflowRuntime', logger_options) metadata = tuple() @@ -62,6 +74,12 @@ def __init__( secure_channel=uri.tls, log_handler=options.log_handler, log_formatter=options.log_formatter, + interceptors=interceptors, + concurrency_options=worker.ConcurrencyOptions( + maximum_concurrent_activity_work_items=maximum_concurrent_activity_work_items, + maximum_concurrent_orchestration_work_items=maximum_concurrent_orchestration_work_items, + maximum_thread_pool_workers=maximum_thread_pool_workers, + ), ) def register_workflow(self, fn: Workflow, *, name: Optional[str] = None): From d005a152a7f94f89648d5d3b8ffb5c3bda25d2c2 Mon Sep 17 00:00:00 2001 From: Albert Callarisa Date: Tue, 7 Oct 2025 12:40:00 +0200 Subject: [PATCH 08/10] Implement multi-app workflows (#844) * feat: Adds support for cross-app calls. Signed-off-by: Albert Callarisa * Use durabletask alpha.9 Signed-off-by: Albert Callarisa * Added examples for error scenarios in multi-app workflow Signed-off-by: Albert Callarisa * Remove unnecessary hardcoded ports Signed-off-by: Albert Callarisa --------- Signed-off-by: Albert Callarisa --- dev-requirements.txt | 2 +- examples/workflow/README.md | 141 +++++++++++++++++- examples/workflow/cross-app1.py | 58 +++++++ examples/workflow/cross-app2.py | 50 +++++++ examples/workflow/cross-app3.py | 32 ++++ .../ext/workflow/dapr_workflow_context.py | 53 ++++++- .../dapr/ext/workflow/workflow_context.py | 23 ++- ext/dapr-ext-workflow/setup.cfg | 4 +- .../tests/test_dapr_workflow_context.py | 4 +- 9 files changed, 340 insertions(+), 27 deletions(-) create mode 100644 examples/workflow/cross-app1.py create mode 100644 examples/workflow/cross-app2.py create mode 100644 examples/workflow/cross-app3.py diff --git a/dev-requirements.txt b/dev-requirements.txt index 3e7ca471..461d9239 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -15,7 +15,7 @@ Flask>=1.1 # needed for auto fix ruff===0.2.2 # needed for dapr-ext-workflow -durabletask-dapr >= 0.2.0a8 +durabletask-dapr >= 0.2.0a9 # needed for .env file loading in examples python-dotenv>=1.0.0 # needed for enhanced schema generation from function features diff --git a/examples/workflow/README.md b/examples/workflow/README.md index f5b901d1..2e09eeef 100644 --- a/examples/workflow/README.md +++ b/examples/workflow/README.md @@ -20,7 +20,7 @@ pip3 install -r requirements.txt Each of the examples in this directory can be run directly from the command line. ### Simple Workflow -This example represents a workflow that manages counters through a series of activities and child workflows. +This example represents a workflow that manages counters through a series of activities and child workflows. It shows several Dapr Workflow features including: - Basic activity execution with counter increments - Retryable activities with configurable retry policies @@ -57,7 +57,7 @@ timeout_seconds: 30 --> ```sh -dapr run --app-id wf-simple-example --dapr-grpc-port 50001 -- python3 simple.py +dapr run --app-id wf-simple-example -- python3 simple.py ``` @@ -99,7 +99,7 @@ timeout_seconds: 30 --> ```sh -dapr run --app-id wfexample --dapr-grpc-port 50001 -- python3 task_chaining.py +dapr run --app-id wfexample -- python3 task_chaining.py ``` @@ -146,7 +146,7 @@ timeout_seconds: 30 --> ```sh -dapr run --app-id wfexample --dapr-grpc-port 50001 -- python3 fan_out_fan_in.py +dapr run --app-id wfexample -- python3 fan_out_fan_in.py ``` @@ -186,7 +186,7 @@ This example demonstrates how to use a workflow to interact with a human user. T The Dapr CLI can be started using the following command: ```sh -dapr run --app-id wfexample --dapr-grpc-port 50001 +dapr run --app-id wfexample ``` In a separate terminal window, run the following command to start the Python workflow app: @@ -222,7 +222,7 @@ This example demonstrates how to eternally running workflow that polls an endpoi The Dapr CLI can be started using the following command: ```sh -dapr run --app-id wfexample --dapr-grpc-port 50001 +dapr run --app-id wfexample ``` In a separate terminal window, run the following command to start the Python workflow app: @@ -254,7 +254,7 @@ This workflow runs forever or until you press `ENTER` to stop it. Starting the a This example demonstrates how to call a child workflow. The Dapr CLI can be started using the following command: ```sh -dapr run --app-id wfexample --dapr-grpc-port 50001 +dapr run --app-id wfexample ``` In a separate terminal window, run the following command to start the Python workflow app: @@ -269,4 +269,129 @@ When you run the example, you will see output like this: *** Calling child workflow 29a7592a1e874b07aad2bb58de309a51-child *** Child workflow 6feadc5370184b4998e50875b20084f6 called ... -``` \ No newline at end of file +``` + + +### Cross-app Workflow + +This example demonstrates how to call child workflows and activities in different apps. The multiple Dapr CLI instances can be started using the following commands: + + + +```sh +dapr run --app-id wfexample3 python3 cross-app3.py & +dapr run --app-id wfexample2 python3 cross-app2.py & +dapr run --app-id wfexample1 python3 cross-app1.py +``` + + +When you run the apps, you will see output like this: +``` +... +app1 - triggering app2 workflow +app2 - triggering app3 activity +... +``` +among others. This shows that the workflow calls are working as expected. + + +#### Error handling on activity calls + +This example demonstrates how the error handling works on activity calls across apps. + +Error handling on activity calls across apps works as normal workflow activity calls. + +In this example we run `app3` in failing mode, which makes the activity call return error constantly. The activity call from `app2` will fail after the retry policy is exhausted. + + + +```sh +export ERROR_ACTIVITY_MODE=true +dapr run --app-id wfexample3 python3 cross-app3.py & +dapr run --app-id wfexample2 python3 cross-app2.py & +dapr run --app-id wfexample1 python3 cross-app1.py +``` + + + +When you run the apps with the `ERROR_ACTIVITY_MODE` environment variable set, you will see output like this: +``` +... +app3 - received activity call +app3 - raising error in activity due to error mode being enabled +app2 - received activity error from app3 +... +``` +among others. This shows that the activity calls are failing as expected, and they are being handled as expected too. + + +#### Error handling on workflow calls + +This example demonstrates how the error handling works on workflow calls across apps. + +Error handling on workflow calls across apps works as normal workflow calls. + +In this example we run `app2` in failing mode, which makes the workflow call return error constantly. The workflow call from `app1` will fail after the retry policy is exhausted. + + + +```sh +export ERROR_WORKFLOW_MODE=true +dapr run --app-id wfexample3 python3 cross-app3.py & +dapr run --app-id wfexample2 python3 cross-app2.py & +dapr run --app-id wfexample1 python3 cross-app1.py +``` + + +When you run the apps with the `ERROR_WORKFLOW_MODE` environment variable set, you will see output like this: +``` +... +app2 - received workflow call +app2 - raising error in workflow due to error mode being enabled +app1 - received workflow error from app2 +... +``` +among others. This shows that the workflow calls are failing as expected, and they are being handled as expected too. + diff --git a/examples/workflow/cross-app1.py b/examples/workflow/cross-app1.py new file mode 100644 index 00000000..f84de662 --- /dev/null +++ b/examples/workflow/cross-app1.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +# Copyright 2025 The Dapr Authors +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from datetime import timedelta + +from durabletask.task import TaskFailedError +import dapr.ext.workflow as wf +import time + +wfr = wf.WorkflowRuntime() + + +@wfr.workflow +def app1_workflow(ctx: wf.DaprWorkflowContext): + print(f'app1 - received workflow call', flush=True) + print(f'app1 - triggering app2 workflow', flush=True) + + try: + retry_policy = wf.RetryPolicy( + max_number_of_attempts=2, + first_retry_interval=timedelta(milliseconds=100), + max_retry_interval=timedelta(seconds=3), + ) + yield ctx.call_child_workflow( + workflow='app2_workflow', + input=None, + app_id='wfexample2', + retry_policy=retry_policy, + ) + print(f'app1 - received workflow result', flush=True) + except TaskFailedError as e: + print(f'app1 - received workflow error from app2', flush=True) + + print(f'app1 - returning workflow result', flush=True) + return 1 + + +if __name__ == '__main__': + wfr.start() + time.sleep(10) # wait for workflow runtime to start + + wf_client = wf.DaprWorkflowClient() + print(f'app1 - triggering app1 workflow', flush=True) + instance_id = wf_client.schedule_new_workflow(workflow=app1_workflow) + + # Wait for the workflow to complete + time.sleep(7) + + wfr.shutdown() diff --git a/examples/workflow/cross-app2.py b/examples/workflow/cross-app2.py new file mode 100644 index 00000000..4cb30874 --- /dev/null +++ b/examples/workflow/cross-app2.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# Copyright 2025 The Dapr Authors +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from datetime import timedelta +import os + +from durabletask.task import TaskFailedError +import dapr.ext.workflow as wf +import time + +wfr = wf.WorkflowRuntime() + + +@wfr.workflow +def app2_workflow(ctx: wf.DaprWorkflowContext): + print(f'app2 - received workflow call', flush=True) + if os.getenv('ERROR_WORKFLOW_MODE', 'false') == 'true': + print(f'app2 - raising error in workflow due to error mode being enabled', flush=True) + raise ValueError('Error in workflow due to error mode being enabled') + print(f'app2 - triggering app3 activity', flush=True) + try: + retry_policy = wf.RetryPolicy( + max_number_of_attempts=2, + first_retry_interval=timedelta(milliseconds=100), + max_retry_interval=timedelta(seconds=3), + ) + result = yield ctx.call_activity( + 'app3_activity', input=None, app_id='wfexample3', retry_policy=retry_policy + ) + print(f'app2 - received activity result', flush=True) + except TaskFailedError as e: + print(f'app2 - received activity error from app3', flush=True) + + print(f'app2 - returning workflow result', flush=True) + return 2 + + +if __name__ == '__main__': + wfr.start() + time.sleep(15) # wait for workflow runtime to start + wfr.shutdown() diff --git a/examples/workflow/cross-app3.py b/examples/workflow/cross-app3.py new file mode 100644 index 00000000..ecc945ca --- /dev/null +++ b/examples/workflow/cross-app3.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Copyright 2025 The Dapr Authors +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import os +import dapr.ext.workflow as wf +import time + +wfr = wf.WorkflowRuntime() + + +@wfr.activity +def app3_activity(ctx: wf.DaprWorkflowContext) -> int: + print(f'app3 - received activity call', flush=True) + if os.getenv('ERROR_ACTIVITY_MODE', 'false') == 'true': + print(f'app3 - raising error in activity due to error mode being enabled', flush=True) + raise ValueError('Error in activity due to error mode being enabled') + print(f'app3 - returning activity result', flush=True) + return 3 + + +if __name__ == '__main__': + wfr.start() + time.sleep(15) # wait for workflow runtime to start + wfr.shutdown() diff --git a/ext/dapr-ext-workflow/dapr/ext/workflow/dapr_workflow_context.py b/ext/dapr-ext-workflow/dapr/ext/workflow/dapr_workflow_context.py index 2dee46fe..476ab765 100644 --- a/ext/dapr-ext-workflow/dapr/ext/workflow/dapr_workflow_context.py +++ b/ext/dapr-ext-workflow/dapr/ext/workflow/dapr_workflow_context.py @@ -63,11 +63,29 @@ def create_timer(self, fire_at: Union[datetime, timedelta]) -> task.Task: def call_activity( self, - activity: Callable[[WorkflowActivityContext, TInput], TOutput], + activity: Union[Callable[[WorkflowActivityContext, TInput], TOutput], str], *, input: TInput = None, retry_policy: Optional[RetryPolicy] = None, + app_id: Optional[str] = None, ) -> task.Task[TOutput]: + # Handle string activity names for cross-app scenarios + if isinstance(activity, str): + activity_name = activity + if app_id is not None: + self._logger.debug( + f'{self.instance_id}: Creating cross-app activity {activity_name} for app {app_id}' + ) + else: + self._logger.debug(f'{self.instance_id}: Creating activity {activity_name}') + + if retry_policy is None: + return self.__obj.call_activity(activity=activity_name, input=input, app_id=app_id) + return self.__obj.call_activity( + activity=activity_name, input=input, retry_policy=retry_policy.obj, app_id=app_id + ) + + # Handle function activity objects (original behavior) self._logger.debug(f'{self.instance_id}: Creating activity {activity.__name__}') if hasattr(activity, '_dapr_alternate_name'): act = activity.__dict__['_dapr_alternate_name'] @@ -75,17 +93,38 @@ def call_activity( # this case should ideally never happen act = activity.__name__ if retry_policy is None: - return self.__obj.call_activity(activity=act, input=input) - return self.__obj.call_activity(activity=act, input=input, retry_policy=retry_policy.obj) + return self.__obj.call_activity(activity=act, input=input, app_id=app_id) + return self.__obj.call_activity( + activity=act, input=input, retry_policy=retry_policy.obj, app_id=app_id + ) def call_child_workflow( self, - workflow: Workflow, + workflow: Union[Workflow, str], *, input: Optional[TInput] = None, instance_id: Optional[str] = None, retry_policy: Optional[RetryPolicy] = None, + app_id: Optional[str] = None, ) -> task.Task[TOutput]: + # Handle string workflow names for cross-app scenarios + if isinstance(workflow, str): + workflow_name = workflow + self._logger.debug(f'{self.instance_id}: Creating child workflow {workflow_name}') + + if retry_policy is None: + return self.__obj.call_sub_orchestrator( + workflow_name, input=input, instance_id=instance_id, app_id=app_id + ) + return self.__obj.call_sub_orchestrator( + workflow_name, + input=input, + instance_id=instance_id, + retry_policy=retry_policy.obj, + app_id=app_id, + ) + + # Handle function workflow objects (original behavior) self._logger.debug(f'{self.instance_id}: Creating child workflow {workflow.__name__}') def wf(ctx: task.OrchestrationContext, inp: TInput): @@ -100,9 +139,11 @@ def wf(ctx: task.OrchestrationContext, inp: TInput): # this case should ideally never happen wf.__name__ = workflow.__name__ if retry_policy is None: - return self.__obj.call_sub_orchestrator(wf, input=input, instance_id=instance_id) + return self.__obj.call_sub_orchestrator( + wf, input=input, instance_id=instance_id, app_id=app_id + ) return self.__obj.call_sub_orchestrator( - wf, input=input, instance_id=instance_id, retry_policy=retry_policy.obj + wf, input=input, instance_id=instance_id, retry_policy=retry_policy.obj, app_id=app_id ) def wait_for_external_event(self, name: str) -> task.Task: diff --git a/ext/dapr-ext-workflow/dapr/ext/workflow/workflow_context.py b/ext/dapr-ext-workflow/dapr/ext/workflow/workflow_context.py index b4c85f6a..d6e6ba07 100644 --- a/ext/dapr-ext-workflow/dapr/ext/workflow/workflow_context.py +++ b/ext/dapr-ext-workflow/dapr/ext/workflow/workflow_context.py @@ -107,18 +107,22 @@ def create_timer(self, fire_at: Union[datetime, timedelta]) -> task.Task: @abstractmethod def call_activity( - self, activity: Activity[TOutput], *, input: Optional[TInput] = None + self, + activity: Union[Activity[TOutput], str], + *, + input: Optional[TInput] = None, + app_id: Optional[str] = None, ) -> task.Task[TOutput]: """Schedule an activity for execution. Parameters ---------- - activity: Activity[TInput, TOutput] - A reference to the activity function to call. + activity: Activity[TInput, TOutput] | str + A reference to the activity function to call, or a string name for cross-app activities. input: TInput | None The JSON-serializable input (or None) to pass to the activity. - return_type: task.Task[TOutput] - The JSON-serializable output type to expect from the activity result. + app_id: str | None + The AppID that will execute the activity. Returns ------- @@ -130,22 +134,25 @@ def call_activity( @abstractmethod def call_child_workflow( self, - orchestrator: Workflow[TOutput], + orchestrator: Union[Workflow[TOutput], str], *, input: Optional[TInput] = None, instance_id: Optional[str] = None, + app_id: Optional[str] = None, ) -> task.Task[TOutput]: """Schedule child-workflow function for execution. Parameters ---------- - orchestrator: Orchestrator[TInput, TOutput] - A reference to the orchestrator function to call. + orchestrator: Orchestrator[TInput, TOutput] | str + A reference to the orchestrator function to call, or a string name for cross-app workflows. input: TInput The optional JSON-serializable input to pass to the orchestrator function. instance_id: str A unique ID to use for the sub-orchestration instance. If not specified, a random UUID will be used. + app_id: str + The AppID that will execute the workflow. Returns ------- diff --git a/ext/dapr-ext-workflow/setup.cfg b/ext/dapr-ext-workflow/setup.cfg index edc914a1..1d54cc08 100644 --- a/ext/dapr-ext-workflow/setup.cfg +++ b/ext/dapr-ext-workflow/setup.cfg @@ -25,11 +25,11 @@ packages = find_namespace: include_package_data = True install_requires = dapr >= 1.16.0 - durabletask-dapr >= 0.2.0a8 + durabletask-dapr >= 0.2.0a9 [options.packages.find] include = dapr.* -exclude = +exclude = tests diff --git a/ext/dapr-ext-workflow/tests/test_dapr_workflow_context.py b/ext/dapr-ext-workflow/tests/test_dapr_workflow_context.py index 9fdfe044..3ae5fdaf 100644 --- a/ext/dapr-ext-workflow/tests/test_dapr_workflow_context.py +++ b/ext/dapr-ext-workflow/tests/test_dapr_workflow_context.py @@ -36,10 +36,10 @@ def __init__(self): def create_timer(self, fire_at): return mock_create_timer - def call_activity(self, activity, input): + def call_activity(self, activity, input, app_id): return mock_call_activity - def call_sub_orchestrator(self, orchestrator, input, instance_id): + def call_sub_orchestrator(self, orchestrator, input, instance_id, app_id): return mock_call_sub_orchestrator def set_custom_status(self, custom_status): From d4c022734ea480751746e10c2fafa9b96f1d5c2e Mon Sep 17 00:00:00 2001 From: Albert Callarisa Date: Wed, 8 Oct 2025 11:11:03 +0200 Subject: [PATCH 09/10] chore: Rename wait_until_ready to wait_for_sidecar (#843) Signed-off-by: Albert Callarisa Co-authored-by: Elena Kolevska --- dapr/aio/clients/grpc/client.py | 2 +- dapr/aio/clients/grpc/subscription.py | 2 +- dapr/clients/grpc/client.py | 2 +- dapr/clients/grpc/subscription.py | 2 +- dapr/clients/health.py | 10 ++++++++++ dapr/clients/http/client.py | 2 +- tests/clients/test_heatlhcheck.py | 16 ++++++++-------- 7 files changed, 23 insertions(+), 13 deletions(-) diff --git a/dapr/aio/clients/grpc/client.py b/dapr/aio/clients/grpc/client.py index 995b8268..1b76dcb0 100644 --- a/dapr/aio/clients/grpc/client.py +++ b/dapr/aio/clients/grpc/client.py @@ -153,7 +153,7 @@ def __init__( max_grpc_message_length (int, optional): The maximum grpc send and receive message length in bytes. """ - DaprHealth.wait_until_ready() + DaprHealth.wait_for_sidecar() self.retry_policy = retry_policy or RetryPolicy() useragent = f'dapr-sdk-python/{__version__}' diff --git a/dapr/aio/clients/grpc/subscription.py b/dapr/aio/clients/grpc/subscription.py index 9aabf8b2..e0e380ca 100644 --- a/dapr/aio/clients/grpc/subscription.py +++ b/dapr/aio/clients/grpc/subscription.py @@ -51,7 +51,7 @@ async def outgoing_request_iterator(): async def reconnect_stream(self): await self.close() - DaprHealth.wait_until_ready() + DaprHealth.wait_for_sidecar() print('Attempting to reconnect...') await self.start() diff --git a/dapr/clients/grpc/client.py b/dapr/clients/grpc/client.py index e4ffb264..6c276dd3 100644 --- a/dapr/clients/grpc/client.py +++ b/dapr/clients/grpc/client.py @@ -145,7 +145,7 @@ def __init__( message length in bytes. retry_policy (RetryPolicy optional): Specifies retry behaviour """ - DaprHealth.wait_until_ready() + DaprHealth.wait_for_sidecar() self.retry_policy = retry_policy or RetryPolicy() useragent = f'dapr-sdk-python/{__version__}' diff --git a/dapr/clients/grpc/subscription.py b/dapr/clients/grpc/subscription.py index 111946b1..6dcfcb4d 100644 --- a/dapr/clients/grpc/subscription.py +++ b/dapr/clients/grpc/subscription.py @@ -65,7 +65,7 @@ def outgoing_request_iterator(): def reconnect_stream(self): self.close() - DaprHealth.wait_until_ready() + DaprHealth.wait_for_sidecar() print('Attempting to reconnect...') self.start() diff --git a/dapr/clients/health.py b/dapr/clients/health.py index e3daec79..37c42a87 100644 --- a/dapr/clients/health.py +++ b/dapr/clients/health.py @@ -15,6 +15,7 @@ import urllib.request import urllib.error import time +from warnings import warn from dapr.clients.http.conf import DAPR_API_TOKEN_HEADER, USER_AGENT_HEADER, DAPR_USER_AGENT from dapr.clients.http.helpers import get_api_url @@ -24,6 +25,15 @@ class DaprHealth: @staticmethod def wait_until_ready(): + warn( + 'This method is deprecated. Use DaprHealth.wait_for_sidecar instead.', + DeprecationWarning, + stacklevel=2, + ) + DaprHealth.wait_for_sidecar() + + @staticmethod + def wait_for_sidecar(): health_url = f'{get_api_url()}/healthz/outbound' headers = {USER_AGENT_HEADER: DAPR_USER_AGENT} if settings.DAPR_API_TOKEN is not None: diff --git a/dapr/clients/http/client.py b/dapr/clients/http/client.py index 86e9ab6f..f6f95aa7 100644 --- a/dapr/clients/http/client.py +++ b/dapr/clients/http/client.py @@ -51,7 +51,7 @@ def __init__( timeout (int, optional): Timeout in seconds, defaults to 60. headers_callback (lambda: Dict[str, str]], optional): Generates header for each request. """ - DaprHealth.wait_until_ready() + DaprHealth.wait_for_sidecar() self._timeout = aiohttp.ClientTimeout(total=timeout) self._serializer = message_serializer diff --git a/tests/clients/test_heatlhcheck.py b/tests/clients/test_heatlhcheck.py index f3be8a47..d447e072 100644 --- a/tests/clients/test_heatlhcheck.py +++ b/tests/clients/test_heatlhcheck.py @@ -24,13 +24,13 @@ class DaprHealthCheckTests(unittest.TestCase): @patch.object(settings, 'DAPR_HTTP_ENDPOINT', 'http://domain.com:3500') @patch('urllib.request.urlopen') - def test_wait_until_ready_success(self, mock_urlopen): + def test_wait_for_sidecar_success(self, mock_urlopen): mock_urlopen.return_value.__enter__.return_value = MagicMock(status=200) try: - DaprHealth.wait_until_ready() + DaprHealth.wait_for_sidecar() except Exception as e: - self.fail(f'wait_until_ready() raised an exception unexpectedly: {e}') + self.fail(f'wait_for_sidecar() raised an exception unexpectedly: {e}') mock_urlopen.assert_called_once() @@ -45,13 +45,13 @@ def test_wait_until_ready_success(self, mock_urlopen): @patch.object(settings, 'DAPR_HTTP_ENDPOINT', 'http://domain.com:3500') @patch.object(settings, 'DAPR_API_TOKEN', 'mytoken') @patch('urllib.request.urlopen') - def test_wait_until_ready_success_with_api_token(self, mock_urlopen): + def test_wait_for_sidecar_success_with_api_token(self, mock_urlopen): mock_urlopen.return_value.__enter__.return_value = MagicMock(status=200) try: - DaprHealth.wait_until_ready() + DaprHealth.wait_for_sidecar() except Exception as e: - self.fail(f'wait_until_ready() raised an exception unexpectedly: {e}') + self.fail(f'wait_for_sidecar() raised an exception unexpectedly: {e}') mock_urlopen.assert_called_once() @@ -64,13 +64,13 @@ def test_wait_until_ready_success_with_api_token(self, mock_urlopen): @patch.object(settings, 'DAPR_HEALTH_TIMEOUT', '2.5') @patch('urllib.request.urlopen') - def test_wait_until_ready_timeout(self, mock_urlopen): + def test_wait_for_sidecar_timeout(self, mock_urlopen): mock_urlopen.return_value.__enter__.return_value = MagicMock(status=500) start = time.time() with self.assertRaises(TimeoutError): - DaprHealth.wait_until_ready() + DaprHealth.wait_for_sidecar() self.assertGreaterEqual(time.time() - start, 2.5) self.assertGreater(mock_urlopen.call_count, 1) From 6745935c79a95abb089893e234069a9ccd42ff93 Mon Sep 17 00:00:00 2001 From: Albert Callarisa Date: Mon, 13 Oct 2025 13:47:02 +0200 Subject: [PATCH 10/10] 1.16.1rc1 (#846) Signed-off-by: Albert Callarisa --- dapr/version/version.py | 2 +- examples/demo_actor/demo_actor/requirements.txt | 2 +- examples/demo_workflow/demo_workflow/requirements.txt | 2 +- examples/invoke-simple/requirements.txt | 4 ++-- examples/w3c-tracing/requirements.txt | 4 ++-- examples/workflow/requirements.txt | 4 ++-- ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py | 2 +- ext/dapr-ext-fastapi/setup.cfg | 4 ++-- ext/dapr-ext-grpc/dapr/ext/grpc/version.py | 2 +- ext/dapr-ext-grpc/setup.cfg | 4 ++-- ext/dapr-ext-workflow/dapr/ext/workflow/version.py | 2 +- ext/dapr-ext-workflow/setup.cfg | 2 +- ext/flask_dapr/flask_dapr/version.py | 2 +- ext/flask_dapr/setup.cfg | 2 +- 14 files changed, 19 insertions(+), 19 deletions(-) diff --git a/dapr/version/version.py b/dapr/version/version.py index ff4ea95e..8c6c1296 100644 --- a/dapr/version/version.py +++ b/dapr/version/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.0' +__version__ = '1.16.1rc1' diff --git a/examples/demo_actor/demo_actor/requirements.txt b/examples/demo_actor/demo_actor/requirements.txt index a5d6d966..9496602e 100644 --- a/examples/demo_actor/demo_actor/requirements.txt +++ b/examples/demo_actor/demo_actor/requirements.txt @@ -1 +1 @@ -dapr-ext-fastapi>=1.16.0 +dapr-ext-fastapi>=1.16.1rc1 diff --git a/examples/demo_workflow/demo_workflow/requirements.txt b/examples/demo_workflow/demo_workflow/requirements.txt index c91800c6..a70b0269 100644 --- a/examples/demo_workflow/demo_workflow/requirements.txt +++ b/examples/demo_workflow/demo_workflow/requirements.txt @@ -1 +1 @@ -dapr-ext-workflow>=1.16.0 \ No newline at end of file +dapr-ext-workflow>=1.16.1rc1 diff --git a/examples/invoke-simple/requirements.txt b/examples/invoke-simple/requirements.txt index 5d83c587..e77f5d6e 100644 --- a/examples/invoke-simple/requirements.txt +++ b/examples/invoke-simple/requirements.txt @@ -1,2 +1,2 @@ -dapr-ext-grpc >= 1.16.0 -dapr >= 1.16.0 +dapr-ext-grpc >= 1.16.1rc1 +dapr >= 1.16.1rc1 diff --git a/examples/w3c-tracing/requirements.txt b/examples/w3c-tracing/requirements.txt index ec17054a..514e2606 100644 --- a/examples/w3c-tracing/requirements.txt +++ b/examples/w3c-tracing/requirements.txt @@ -1,5 +1,5 @@ -dapr-ext-grpc >= 1.16.0 -dapr >= 1.16.0 +dapr-ext-grpc >= 1.16.1rc1 +dapr >= 1.16.1rc1 opentelemetry-sdk opentelemetry-instrumentation-grpc opentelemetry-exporter-zipkin diff --git a/examples/workflow/requirements.txt b/examples/workflow/requirements.txt index 3be7c3c7..fab86e72 100644 --- a/examples/workflow/requirements.txt +++ b/examples/workflow/requirements.txt @@ -1,2 +1,2 @@ -dapr-ext-workflow>=1.16.0 -dapr>=1.16.0 +dapr-ext-workflow>=1.16.1rc1 +dapr>=1.16.1rc1 diff --git a/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py b/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py index ff4ea95e..8c6c1296 100644 --- a/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py +++ b/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.0' +__version__ = '1.16.1rc1' diff --git a/ext/dapr-ext-fastapi/setup.cfg b/ext/dapr-ext-fastapi/setup.cfg index ba467c42..8b6080eb 100644 --- a/ext/dapr-ext-fastapi/setup.cfg +++ b/ext/dapr-ext-fastapi/setup.cfg @@ -24,7 +24,7 @@ python_requires = >=3.9 packages = find_namespace: include_package_data = True install_requires = - dapr >= 1.16.0 + dapr >= 1.16.1rc1 uvicorn >= 0.11.6 fastapi >= 0.60.1 @@ -32,5 +32,5 @@ install_requires = include = dapr.* -exclude = +exclude = tests diff --git a/ext/dapr-ext-grpc/dapr/ext/grpc/version.py b/ext/dapr-ext-grpc/dapr/ext/grpc/version.py index ff4ea95e..8c6c1296 100644 --- a/ext/dapr-ext-grpc/dapr/ext/grpc/version.py +++ b/ext/dapr-ext-grpc/dapr/ext/grpc/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.0' +__version__ = '1.16.1rc1' diff --git a/ext/dapr-ext-grpc/setup.cfg b/ext/dapr-ext-grpc/setup.cfg index b05472b1..d08757c7 100644 --- a/ext/dapr-ext-grpc/setup.cfg +++ b/ext/dapr-ext-grpc/setup.cfg @@ -24,12 +24,12 @@ python_requires = >=3.9 packages = find_namespace: include_package_data = True install_requires = - dapr >= 1.16.0 + dapr >= 1.16.1rc1 cloudevents >= 1.0.0 [options.packages.find] include = dapr.* -exclude = +exclude = tests diff --git a/ext/dapr-ext-workflow/dapr/ext/workflow/version.py b/ext/dapr-ext-workflow/dapr/ext/workflow/version.py index ff4ea95e..8c6c1296 100644 --- a/ext/dapr-ext-workflow/dapr/ext/workflow/version.py +++ b/ext/dapr-ext-workflow/dapr/ext/workflow/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.0' +__version__ = '1.16.1rc1' diff --git a/ext/dapr-ext-workflow/setup.cfg b/ext/dapr-ext-workflow/setup.cfg index 1d54cc08..83869566 100644 --- a/ext/dapr-ext-workflow/setup.cfg +++ b/ext/dapr-ext-workflow/setup.cfg @@ -24,7 +24,7 @@ python_requires = >=3.9 packages = find_namespace: include_package_data = True install_requires = - dapr >= 1.16.0 + dapr >= 1.16.1rc1 durabletask-dapr >= 0.2.0a9 [options.packages.find] diff --git a/ext/flask_dapr/flask_dapr/version.py b/ext/flask_dapr/flask_dapr/version.py index ff4ea95e..8c6c1296 100644 --- a/ext/flask_dapr/flask_dapr/version.py +++ b/ext/flask_dapr/flask_dapr/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.0' +__version__ = '1.16.1rc1' diff --git a/ext/flask_dapr/setup.cfg b/ext/flask_dapr/setup.cfg index b86bb284..531a9aea 100644 --- a/ext/flask_dapr/setup.cfg +++ b/ext/flask_dapr/setup.cfg @@ -26,4 +26,4 @@ include_package_data = true zip_safe = false install_requires = Flask >= 1.1 - dapr >= 1.16.0 + dapr >= 1.16.1rc1