mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-01 08:59:46 +02:00
fix: support object and array parameters in custom HTTP tools (#373)
* fix: support object and array parameters in custom HTTP tools * feat(ui): expose object and array types in the custom tool parameter editor * fix: error handling and schema generation --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Abhishek Kumar <abhishek@a6k.me>
This commit is contained in:
parent
98d2b24cba
commit
dd85c4a1b4
18 changed files with 308 additions and 67 deletions
|
|
@ -29,6 +29,7 @@ from pipecat.services.llm_service import FunctionCallParams
|
|||
|
||||
from api.services.workflow.pipecat_engine_custom_tools import get_function_schema
|
||||
from api.services.workflow.tools.custom_tool import (
|
||||
_coerce_parameter_value,
|
||||
execute_http_tool,
|
||||
tool_to_function_schema,
|
||||
)
|
||||
|
|
@ -141,6 +142,51 @@ class TestToolToFunctionSchema:
|
|||
assert "duration_minutes" in required
|
||||
assert "is_priority" not in required
|
||||
|
||||
def test_tool_with_object_and_array_parameters(self):
|
||||
"""Test converting a tool with object and array parameters."""
|
||||
tool = MockToolModel(
|
||||
tool_uuid="test-uuid-nested",
|
||||
name="Create Booking",
|
||||
description="Create a booking with nested details",
|
||||
category="http_api",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"type": "http_api",
|
||||
"config": {
|
||||
"method": "POST",
|
||||
"url": "https://api.example.com/bookings",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "booking",
|
||||
"type": "object",
|
||||
"description": "Nested booking payload",
|
||||
"required": True,
|
||||
},
|
||||
{
|
||||
"name": "attendees",
|
||||
"type": "array",
|
||||
"description": "Booking attendees",
|
||||
"required": False,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
schema = tool_to_function_schema(tool)
|
||||
|
||||
props = schema["function"]["parameters"]["properties"]
|
||||
assert props["booking"] == {
|
||||
"type": "object",
|
||||
"additionalProperties": True,
|
||||
"description": "Nested booking payload",
|
||||
}
|
||||
assert props["attendees"] == {
|
||||
"type": "array",
|
||||
"items": {},
|
||||
"description": "Booking attendees",
|
||||
}
|
||||
|
||||
def test_preset_parameters_are_not_exposed_to_llm_schema(self):
|
||||
"""Test that preset parameters are injected at runtime, not shown to the LLM."""
|
||||
tool = MockToolModel(
|
||||
|
|
@ -295,6 +341,51 @@ class TestExecuteHttpTool:
|
|||
assert result["status_code"] == 201
|
||||
assert result["data"]["id"] == 123
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_request_sends_nested_json_body(self):
|
||||
"""Test that POST requests preserve nested arguments in the JSON body."""
|
||||
tool = MockToolModel(
|
||||
tool_uuid="test-uuid-nested",
|
||||
name="Create Booking",
|
||||
description="Create a nested booking",
|
||||
category="http_api",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"type": "http_api",
|
||||
"config": {
|
||||
"method": "POST",
|
||||
"url": "https://api.example.com/bookings",
|
||||
"timeout_ms": 5000,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
arguments = {
|
||||
"booking": {
|
||||
"start": "2026-05-28T10:00:00Z",
|
||||
"attendee": {"name": "Jane", "email": "jane@example.com"},
|
||||
"metadata": {"source": "voice"},
|
||||
}
|
||||
}
|
||||
|
||||
with patch(
|
||||
"api.services.workflow.tools.custom_tool.httpx.AsyncClient"
|
||||
) as mock_client_class:
|
||||
mock_client = AsyncMock()
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"bookingId": "booking-123"}
|
||||
mock_client.request.return_value = mock_response
|
||||
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||
|
||||
result = await execute_http_tool(tool, arguments)
|
||||
|
||||
call_kwargs = mock_client.request.call_args.kwargs
|
||||
assert call_kwargs["json"] == arguments
|
||||
assert isinstance(call_kwargs["json"]["booking"], dict)
|
||||
assert isinstance(call_kwargs["json"]["booking"]["attendee"], dict)
|
||||
assert result["status"] == "success"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_request_injects_preset_parameters(self):
|
||||
"""Test that preset parameters are resolved from runtime context."""
|
||||
|
|
@ -640,6 +731,51 @@ class TestExecuteHttpTool:
|
|||
mock_db.get_credential_by_uuid.assert_not_called()
|
||||
|
||||
|
||||
class TestCoerceParameterValue:
|
||||
"""Tests for _coerce_parameter_value function."""
|
||||
|
||||
def test_object_value_returns_dict_unchanged(self):
|
||||
"""Test that object parameters preserve dict values."""
|
||||
value = {"attendee": {"name": "Jane"}}
|
||||
|
||||
assert _coerce_parameter_value(value, "object") is value
|
||||
|
||||
def test_object_value_parses_json_string(self):
|
||||
"""Test that object parameters parse JSON string values."""
|
||||
value = '{"attendee": {"name": "Jane"}}'
|
||||
|
||||
assert _coerce_parameter_value(value, "object") == {
|
||||
"attendee": {"name": "Jane"}
|
||||
}
|
||||
|
||||
def test_array_value_returns_list_unchanged(self):
|
||||
"""Test that array parameters preserve list values."""
|
||||
value = [{"name": "Jane"}, {"name": "Sam"}]
|
||||
|
||||
assert _coerce_parameter_value(value, "array") is value
|
||||
|
||||
def test_array_value_parses_json_string(self):
|
||||
"""Test that array parameters parse JSON string values."""
|
||||
value = '[{"name": "Jane"}, {"name": "Sam"}]'
|
||||
|
||||
assert _coerce_parameter_value(value, "array") == [
|
||||
{"name": "Jane"},
|
||||
{"name": "Sam"},
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("value", ["not json", "[]", "null"])
|
||||
def test_object_value_rejects_invalid_or_wrong_shape(self, value):
|
||||
"""Test that object parameters require a JSON object."""
|
||||
with pytest.raises(ValueError, match="Cannot convert"):
|
||||
_coerce_parameter_value(value, "object")
|
||||
|
||||
@pytest.mark.parametrize("value", ["not json", "{}", "null"])
|
||||
def test_array_value_rejects_invalid_or_wrong_shape(self, value):
|
||||
"""Test that array parameters require a JSON array."""
|
||||
with pytest.raises(ValueError, match="Cannot convert"):
|
||||
_coerce_parameter_value(value, "array")
|
||||
|
||||
|
||||
class TestAuthHeaders:
|
||||
"""Tests for auth header building utilities."""
|
||||
|
||||
|
|
|
|||
|
|
@ -80,6 +80,53 @@ def test_update_tool_request_accepts_mcp_definition():
|
|||
assert req.definition.config.url == "https://x/mcp"
|
||||
|
||||
|
||||
def test_update_tool_request_accepts_http_api_complex_parameter_types():
|
||||
"""HTTP API tools may accept structured JSON parameters."""
|
||||
req = UpdateToolRequest(
|
||||
name="Check Availability New Multi",
|
||||
description="Check Availability when asked for it.",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"type": "http_api",
|
||||
"config": {
|
||||
"method": "POST",
|
||||
"url": "https://automation.dograh.com/webhook/example",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "params",
|
||||
"type": "object",
|
||||
"description": (
|
||||
"An object containing the name and datetime in ISO format"
|
||||
),
|
||||
"required": True,
|
||||
},
|
||||
{
|
||||
"name": "slots",
|
||||
"type": "array",
|
||||
"description": "Candidate availability slots.",
|
||||
"required": False,
|
||||
},
|
||||
],
|
||||
"preset_parameters": [
|
||||
{
|
||||
"name": "phone_number",
|
||||
"type": "string",
|
||||
"value_template": "{{initial_context.phone_number}}",
|
||||
"required": True,
|
||||
}
|
||||
],
|
||||
"timeout_ms": 5000,
|
||||
"customMessageType": "text",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert req.definition.type == "http_api"
|
||||
parameters = req.definition.config.parameters
|
||||
assert parameters[0].type == "object"
|
||||
assert parameters[1].type == "array"
|
||||
|
||||
|
||||
def test_create_tool_request_accepts_mcp_with_all_fields():
|
||||
"""All optional MCP config fields are accepted and preserved."""
|
||||
req = CreateToolRequest(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue