Skip to content

Commit 924541c

Browse files
committed
add more information on execute registered tools, also added more tests for them to validate
Signed-off-by: Filinto Duran <[email protected]>
1 parent 1e3baec commit 924541c

File tree

4 files changed

+175
-9
lines changed

4 files changed

+175
-9
lines changed

README.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -126,11 +126,10 @@ tox -e examples
126126

127127
[Dapr Mechanical Markdown](https://github.com/dapr/mechanical-markdown) is used to test the examples.
128128

129-
If you need to run the examples against a development version of the runtime, you can use the following command:
129+
If you need to run the examples against a pre-released version of the runtime, you can use the following command:
130130
- Get your daprd runtime binary from [here](https://github.com/dapr/dapr/releases) for your platform.
131-
- Copy the binary to a folder, for example `examples/.dapr/bin/` directory.
132-
- In your example README, change the `dapr run` command and add a line `--runtime-path ./examples \`.
133-
- 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.
131+
- Copy the binary to your dapr home folder at $HOME/.dapr/bin/daprd.
132+
Or using dapr cli directly: `dapr init --runtime-version <release version>`
134133
- Now you can run the example with `tox -e examples`.
135134

136135

dapr/clients/grpc/_conversation_helpers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -859,7 +859,7 @@ def _coerce_and_validate(value: Any, expected_type: Any) -> Any:
859859
args = get_args(expected_type)
860860

861861
if expected_type is Any:
862-
raise TypeError(f'We cannot handle parameters with type Any')
862+
raise TypeError('We cannot handle parameters with type Any')
863863

864864
# Optional[T] -> Union[T, None]
865865
if origin is Union:

dapr/clients/grpc/conversation.py

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,14 @@ def tool(
452452
):
453453
"""
454454
Decorate a callable as a conversation tool.
455+
456+
Security note:
457+
- Register only trusted functions. Tool calls may be triggered from LLM outputs and receive
458+
untrusted parameters.
459+
- Use precise type annotations and docstrings for your function; we derive a JSON schema used by
460+
the binder to coerce types and reject unexpected/invalid arguments.
461+
- Add your own guardrails if the tool can perform side effects (filesystem, network, subprocess).
462+
- You can set register=False and call register_tool later to control registration explicitly.
455463
"""
456464

457465
def _decorate(f: Callable):
@@ -481,19 +489,35 @@ def _decorate(f: Callable):
481489

482490
@dataclass
483491
class ConversationTools:
484-
"""Tools available for conversation."""
492+
"""Tools available for conversation.
493+
494+
Notes on safety and validation:
495+
- Tools execute arbitrary Python callables. Register only trusted functions and be mindful of
496+
side effects (filesystem, network, subprocesses).
497+
- Parameters provided by an LLM are untrusted. The invocation path uses bind_params_to_func to
498+
coerce types based on your function annotations and to reject unexpected/invalid arguments.
499+
- Consider adding your own validation/guardrails in your tool implementation.
500+
"""
485501

486502
# currently only function is supported
487503
function: ConversationToolsFunction
488504
backend: Optional[ToolBackend] = None
489505

490506
def invoke(self, params: Params = None) -> Any:
491-
"""execute the tool with params"""
507+
"""Execute the tool with params (synchronous).
508+
509+
params may be:
510+
- Mapping[str, Any]: passed as keyword arguments
511+
- Sequence[Any]: passed as positional arguments
512+
- None: no arguments
513+
Detailed validation and coercion are performed by the backend via bind_params_to_func.
514+
"""
492515
if not self.backend:
493516
raise conv_helpers.ToolExecutionError('Tool backend not set')
494517
return self.backend.invoke(self.function, params)
495518

496519
async def ainvoke(self, params: Params = None, *, timeout: Union[float, None] = None) -> Any:
520+
"""Execute the tool asynchronously. See invoke() for parameter shape and safety notes."""
497521
if not self.backend:
498522
raise conv_helpers.ToolExecutionError('Tool backend not set')
499523
return await self.backend.ainvoke(self.function, params, timeout=timeout)
@@ -528,18 +552,43 @@ def _get_tool(name: str) -> ConversationTools:
528552

529553

530554
def execute_registered_tool(name: str, params: Union[Params, str] = None) -> Any:
531-
"""Execute a registered tool."""
555+
"""Execute a registered tool.
556+
557+
Security considerations:
558+
- A registered tool typically executes user-defined code (or code imported from libraries). Only
559+
register and execute tools you trust. Treat model-provided params as untrusted input.
560+
- Prefer defining a JSON schema for your tool function parameters (ConversationToolsFunction
561+
is created from your function’s signature and annotations). The internal binder performs
562+
type coercion and rejects unexpected/invalid arguments.
563+
- Add your own guardrails if the tool can perform side effects (filesystem, network, subprocess, etc.).
564+
"""
532565
if isinstance(params, str):
533566
params = json.loads(params)
567+
# Minimal upfront shape check; detailed validation happens in bind_params_to_func
568+
if params is not None and not isinstance(params, (Mapping, Sequence)):
569+
raise conv_helpers.ToolArgumentError(
570+
'params must be a mapping (kwargs), a sequence (args), or None'
571+
)
534572
return _get_tool(name).invoke(params)
535573

536574

537575
async def execute_registered_tool_async(
538576
name: str, params: Union[Params, str] = None, *, timeout: Union[float, None] = None
539577
) -> Any:
540-
"""Execute a registered tool asynchronously."""
578+
"""Execute a registered tool asynchronously.
579+
580+
Security considerations:
581+
- Only execute trusted tools; treat model-provided params as untrusted input.
582+
- Prefer well-typed function signatures and schemas for parameter validation. The binder will
583+
coerce and validate, rejecting unexpected arguments.
584+
- For async tools, consider timeouts and guardrails to limit side effects.
585+
"""
541586
if isinstance(params, str):
542587
params = json.loads(params)
588+
if params is not None and not isinstance(params, (Mapping, Sequence)):
589+
raise conv_helpers.ToolArgumentError(
590+
'params must be a mapping (kwargs), a sequence (args), or None'
591+
)
543592
return await _get_tool(name).ainvoke(params, timeout=timeout)
544593

545594

tests/clients/test_conversation.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616

1717
import asyncio
18+
import json
1819
import unittest
1920
import uuid
2021

@@ -27,6 +28,7 @@
2728
from dapr.clients.grpc._conversation_helpers import (
2829
ToolArgumentError,
2930
ToolExecutionError,
31+
ToolNotFoundError,
3032
)
3133
from dapr.clients.grpc.conversation import (
3234
ConversationInput,
@@ -50,6 +52,7 @@
5052
ConversationMessageOfAssistant,
5153
ConversationToolCalls,
5254
ConversationToolCallsOfFunction,
55+
execute_registered_tool,
5356
)
5457
from dapr.clients.grpc.conversation import (
5558
tool as tool_decorator,
@@ -1105,5 +1108,120 @@ def test_empty_and_none_outputs(self):
11051108
self.assertEqual(response_none.to_assistant_messages(), [])
11061109

11071110

1111+
class ExecuteRegisteredToolSyncTests(unittest.TestCase):
1112+
def tearDown(self):
1113+
# Cleanup all tools we may have registered by name prefix
1114+
# (names are randomized per test to avoid collisions)
1115+
pass # Names are unique per test; we explicitly unregister in tests
1116+
1117+
def test_sync_success_with_kwargs_and_sequence_and_json(self):
1118+
name = f'test_add_{uuid.uuid4().hex[:8]}'
1119+
1120+
@tool_decorator(name=name)
1121+
def add(a: int, b: int) -> int:
1122+
return a + b
1123+
1124+
try:
1125+
# kwargs mapping
1126+
out = execute_registered_tool(name, {'a': 2, 'b': 3})
1127+
self.assertEqual(out, 5)
1128+
1129+
# sequence args
1130+
out2 = execute_registered_tool(name, [10, 5])
1131+
self.assertEqual(out2, 15)
1132+
1133+
# JSON string params
1134+
out3 = execute_registered_tool(name, json.dumps({'a': '7', 'b': '8'}))
1135+
self.assertEqual(out3, 15)
1136+
finally:
1137+
unregister_tool(name)
1138+
1139+
def test_sync_invalid_params_type_raises(self):
1140+
name = f'test_echo_{uuid.uuid4().hex[:8]}'
1141+
1142+
@tool_decorator(name=name)
1143+
def echo(x: str) -> str:
1144+
return x
1145+
1146+
try:
1147+
with self.assertRaises(ToolArgumentError):
1148+
execute_registered_tool(name, 123) # not Mapping/Sequence/None
1149+
finally:
1150+
unregister_tool(name)
1151+
1152+
def test_sync_unregistered_tool_raises(self):
1153+
name = f'does_not_exist_{uuid.uuid4().hex[:8]}'
1154+
with self.assertRaises(ToolNotFoundError):
1155+
execute_registered_tool(name, {'a': 1})
1156+
1157+
def test_sync_tool_exception_wrapped(self):
1158+
name = f'test_fail_{uuid.uuid4().hex[:8]}'
1159+
1160+
@tool_decorator(name=name)
1161+
def fail() -> None:
1162+
raise ValueError('boom')
1163+
1164+
try:
1165+
with self.assertRaises(ToolExecutionError):
1166+
execute_registered_tool(name)
1167+
finally:
1168+
unregister_tool(name)
1169+
1170+
1171+
class ExecuteRegisteredToolAsyncTests(unittest.IsolatedAsyncioTestCase):
1172+
async def asyncTearDown(self):
1173+
# Nothing persistent; individual tests unregister.
1174+
pass
1175+
1176+
async def test_async_success_and_json_params(self):
1177+
name = f'test_async_echo_{uuid.uuid4().hex[:8]}'
1178+
1179+
@tool_decorator(name=name)
1180+
async def echo(value: str) -> str:
1181+
await asyncio.sleep(0)
1182+
return value
1183+
1184+
try:
1185+
out = await execute_registered_tool_async(name, {'value': 'hi'})
1186+
self.assertEqual(out, 'hi')
1187+
1188+
out2 = await execute_registered_tool_async(name, json.dumps({'value': 'ok'}))
1189+
self.assertEqual(out2, 'ok')
1190+
finally:
1191+
unregister_tool(name)
1192+
1193+
async def test_async_invalid_params_type_raises(self):
1194+
name = f'test_async_inv_{uuid.uuid4().hex[:8]}'
1195+
1196+
@tool_decorator(name=name)
1197+
async def one(x: int) -> int:
1198+
return x
1199+
1200+
try:
1201+
with self.assertRaises(ToolArgumentError):
1202+
await execute_registered_tool_async(name, 3.14) # invalid type
1203+
finally:
1204+
unregister_tool(name)
1205+
1206+
async def test_async_unregistered_tool_raises(self):
1207+
name = f'does_not_exist_{uuid.uuid4().hex[:8]}'
1208+
with self.assertRaises(ToolNotFoundError):
1209+
await execute_registered_tool_async(name, None)
1210+
1211+
async def test_async_tool_exception_wrapped(self):
1212+
name = f'test_async_fail_{uuid.uuid4().hex[:8]}'
1213+
1214+
@tool_decorator(name=name)
1215+
async def fail_async() -> None:
1216+
await asyncio.sleep(0)
1217+
raise RuntimeError('nope')
1218+
1219+
try:
1220+
with self.assertRaises(ToolExecutionError):
1221+
await execute_registered_tool_async(name)
1222+
finally:
1223+
unregister_tool(name)
1224+
1225+
11081226
if __name__ == '__main__':
11091227
unittest.main()

0 commit comments

Comments
 (0)