Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
fdff6cf
initial
filintod Jul 29, 2025
bbf381c
fixes after proto change upstream
filintod Jul 31, 2025
dbf6f56
minor name changes and cleanup unused function
filintod Aug 1, 2025
7a9a6cf
refactors, updates to readme, linting
filintod Aug 6, 2025
e412740
Merge branch 'main' into filinto/tool-calling
filintod Aug 7, 2025
08fdcab
Merge branch 'main' into filinto/tool-calling
filintod Aug 12, 2025
287d4b7
feedback
filintod Aug 12, 2025
b548c00
feedback, updates
filintod Aug 14, 2025
975df5d
fix import in examples
filintod Aug 14, 2025
6bbb0f5
cleanup, import, lint, more conversation helpers
filintod Aug 14, 2025
8697b8e
clarify README, minor test import changes, copyright
filintod Aug 14, 2025
5d16fb5
feedback DRY test_conversation file
filintod Aug 14, 2025
d0703ae
lint
filintod Aug 14, 2025
e10d2d8
move conversation classes in _response module to conversation module.
filintod Aug 16, 2025
3854bbd
minor readme change
filintod Aug 16, 2025
2018978
Update daprdocs/content/en/python-sdk-docs/python-client.md
filintod Aug 19, 2025
ee6729e
lint
filintod Aug 19, 2025
7126b2f
updates to fix issue with tool calling helper when dealing with class…
filintod Aug 21, 2025
fbc0195
coalesce conv helper tests, fix typing lint
filintod Aug 22, 2025
0d275e4
make indent line method doc more dev friendly
filintod Aug 25, 2025
a41a413
tackle some feedback, still missing unit tests
filintod Aug 25, 2025
5db8a56
add unit test to convert_value_to_struct
filintod Aug 25, 2025
6e3d2ba
more unit tests per feedback
filintod Aug 25, 2025
a4870a9
make async version of unit test conversation
filintod Aug 25, 2025
5bec64e
add some information how to run markdown tests with a different runtime
filintod Aug 29, 2025
4bf4aaf
ran tox -e ruff, even though tox -e flake8 was fine
filintod Aug 30, 2025
6ae63f4
Merge branch 'main' into filinto/tool-calling
elena-kolevska Sep 3, 2025
1e3baec
add tests to increase coverage in conversation and conversation_helpe…
filintod Sep 3, 2025
924541c
add more information on execute registered tools, also added more tes…
filintod Sep 3, 2025
9dd3bbf
fix test failing on py 1.13. Merge two unit test files per feedback
filintod Sep 4, 2025
5e1bab3
Linter
elena-kolevska Sep 4, 2025
bc3dcf0
fix typing issue with UnionType in py3.9
filintod Sep 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,10 @@ 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 development version of the runtime, you can use the following command:
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 a folder, for example `examples/.dapr/bin/` directory.
- In your example README, change the `dapr run` command and add a line `--runtime-path ./examples \`.
- Copy a dapr config file `config.yaml` file to the `examples/.dapr` directory. This file is usually in your $(HOME)/.dapr directory if you had installed dapr cli before.
- Copy the binary to your dapr home folder at $HOME/.dapr/bin/daprd.
Or using dapr cli directly: `dapr init --runtime-version <release version>`
- Now you can run the example with `tox -e examples`.


Expand Down
28 changes: 23 additions & 5 deletions dapr/clients/grpc/_conversation_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ def _json_primitive_type(v: Any) -> str:
return dataclass_schema

# Handle plain classes (non-dataclass) using __init__ signature and annotations
if inspect.isclass(python_type):
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)
Expand All @@ -246,7 +246,9 @@ def _json_primitive_type(v: Any) -> str:
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
Expand All @@ -260,14 +262,22 @@ def _json_primitive_type(v: Any) -> str:
inspect.Parameter.KEYWORD_ONLY,
):
required.append(pname)
else:
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)
required.append(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:
Expand Down Expand Up @@ -718,8 +728,13 @@ def _default(o: Any):
except Exception:
pass

# Do not attempt to auto-serialize arbitrary objects via __dict__ to avoid
# partially serialized structures. Let json raise and we will fallback to str(o).
# 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')
Expand Down Expand Up @@ -843,6 +858,9 @@ def _coerce_and_validate(value: Any, expected_type: Any) -> Any:
origin = get_origin(expected_type)
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 origin is Union:
# try each option
Expand Down
57 changes: 53 additions & 4 deletions dapr/clients/grpc/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,14 @@ def tool(
):
"""
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):
Expand Down Expand Up @@ -481,19 +489,35 @@ def _decorate(f: Callable):

@dataclass
class ConversationTools:
"""Tools available for conversation."""
"""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"""
"""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)
Expand Down Expand Up @@ -528,18 +552,43 @@ def _get_tool(name: str) -> ConversationTools:


def execute_registered_tool(name: str, params: Union[Params, str] = None) -> Any:
"""Execute a registered tool."""
"""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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a vulnerable part of our code, so we need to add at least some basic validation, and probably consider sanitisation for the future

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, let's please add a detailed docstring explaining the vulnerability and giving some suggestions to developers on writing safe functions that validate the LLM input.
I know you said you'd be sending a docs PR, but I think it's important to have this here as well.

# 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."""
"""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)


Expand Down
Loading
Loading