From a172db8022469631826a3198382518c26e812c72 Mon Sep 17 00:00:00 2001 From: Abhishek Date: Wed, 14 Jan 2026 16:40:40 +0530 Subject: [PATCH] feat: add end_call tool (#118) * feat: add end_call tool * chore: remove run_llm=True from properties --- ...493ca2bb001f_add_end_call_tool_category.py | 50 +++ ...98c6_remove_unique_tool_name_constraint.py | 34 ++ api/db/models.py | 1 - api/db/tool_client.py | 45 +- api/enums.py | 5 +- api/routes/tool.py | 147 ++++--- .../workflow/pipecat_engine_custom_tools.py | 105 +++-- api/services/workflow/workflow.py | 29 +- api/tests/test_custom_tools.py | 62 +-- .../test_custom_tools_context_integration.py | 25 -- api/tests/test_pipecat_engine_tool_calls.py | 2 + api/tests/test_user_idle_handler.py | 2 + ui/AGENTS.md | 3 +- ui/package-lock.json | 188 +------- ui/package.json | 2 - .../components/EndCallToolConfig.tsx | 112 +++++ .../components/HttpApiToolConfig.tsx | 169 ++++++++ .../app/tools/[toolUuid]/components/index.ts | 2 + ui/src/app/tools/[toolUuid]/page.tsx | 400 ++++++++---------- ui/src/app/tools/config.tsx | 164 +++++++ ui/src/app/tools/page.tsx | 292 +++++++++---- ui/src/client/sdk.gen.ts | 19 +- ui/src/client/types.gen.ts | 115 ++++- ui/src/components/flow/ToolSelector.tsx | 5 +- ui/src/components/flow/edges/CustomEdge.tsx | 8 +- ui/src/components/flow/nodes/BaseNode.tsx | 4 +- 26 files changed, 1274 insertions(+), 716 deletions(-) create mode 100644 api/alembic/versions/493ca2bb001f_add_end_call_tool_category.py create mode 100644 api/alembic/versions/dcb0a27d98c6_remove_unique_tool_name_constraint.py create mode 100644 ui/src/app/tools/[toolUuid]/components/EndCallToolConfig.tsx create mode 100644 ui/src/app/tools/[toolUuid]/components/HttpApiToolConfig.tsx create mode 100644 ui/src/app/tools/[toolUuid]/components/index.ts create mode 100644 ui/src/app/tools/config.tsx diff --git a/api/alembic/versions/493ca2bb001f_add_end_call_tool_category.py b/api/alembic/versions/493ca2bb001f_add_end_call_tool_category.py new file mode 100644 index 0000000..d63e1c8 --- /dev/null +++ b/api/alembic/versions/493ca2bb001f_add_end_call_tool_category.py @@ -0,0 +1,50 @@ +"""add end_call tool category + +Revision ID: 493ca2bb001f +Revises: b79f19f68157 +Create Date: 2026-01-14 15:04:48.899778 + +""" + +from typing import Sequence, Union + +from alembic import op +from alembic_postgresql_enum import TableReference + +# revision identifiers, used by Alembic. +revision: str = "493ca2bb001f" +down_revision: Union[str, None] = "b79f19f68157" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.sync_enum_values( + enum_schema="public", + enum_name="tool_category", + new_values=["http_api", "end_call", "native", "integration"], + affected_columns=[ + TableReference( + table_schema="public", table_name="tools", column_name="category" + ) + ], + enum_values_to_rename=[], + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.sync_enum_values( + enum_schema="public", + enum_name="tool_category", + new_values=["http_api", "native", "integration"], + affected_columns=[ + TableReference( + table_schema="public", table_name="tools", column_name="category" + ) + ], + enum_values_to_rename=[], + ) + # ### end Alembic commands ### diff --git a/api/alembic/versions/dcb0a27d98c6_remove_unique_tool_name_constraint.py b/api/alembic/versions/dcb0a27d98c6_remove_unique_tool_name_constraint.py new file mode 100644 index 0000000..a19ad1d --- /dev/null +++ b/api/alembic/versions/dcb0a27d98c6_remove_unique_tool_name_constraint.py @@ -0,0 +1,34 @@ +"""remove unique tool name constraint + +Revision ID: dcb0a27d98c6 +Revises: 493ca2bb001f +Create Date: 2026-01-14 16:07:01.940879 + +""" + +from typing import Sequence, Union + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "dcb0a27d98c6" +down_revision: Union[str, None] = "493ca2bb001f" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(op.f("unique_org_tool_name"), "tools", type_="unique") + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_unique_constraint( + op.f("unique_org_tool_name"), + "tools", + ["organization_id", "name"], + postgresql_nulls_not_distinct=False, + ) + # ### end Alembic commands ### diff --git a/api/db/models.py b/api/db/models.py index 8de7651..c7a9489 100644 --- a/api/db/models.py +++ b/api/db/models.py @@ -889,5 +889,4 @@ class ToolModel(Base): Index("ix_tools_uuid", "tool_uuid"), Index("ix_tools_status", "status"), Index("ix_tools_category", "category"), - UniqueConstraint("organization_id", "name", name="unique_org_tool_name"), ) diff --git a/api/db/tool_client.py b/api/db/tool_client.py index 6c96c78..27bc3f1 100644 --- a/api/db/tool_client.py +++ b/api/db/tool_client.py @@ -86,7 +86,12 @@ class ToolClient(BaseDBClient): ) if status: - query = query.where(ToolModel.status == status) + # Support comma-separated status values (e.g., "active,archived") + status_list = [s.strip() for s in status.split(",")] + if len(status_list) > 1: + query = query.where(ToolModel.status.in_(status_list)) + else: + query = query.where(ToolModel.status == status) else: # By default, exclude archived tools query = query.where(ToolModel.status != ToolStatus.ARCHIVED.value) @@ -233,6 +238,44 @@ class ToolClient(BaseDBClient): return True return False + async def unarchive_tool( + self, tool_uuid: str, organization_id: int + ) -> Optional[ToolModel]: + """Restore an archived tool by setting its status to active. + + Args: + tool_uuid: The unique tool UUID + organization_id: ID of the organization (for authorization) + + Returns: + The unarchived ToolModel if found, None otherwise + """ + async with self.async_session() as session: + result = await session.execute( + update(ToolModel) + .where( + ToolModel.tool_uuid == tool_uuid, + ToolModel.organization_id == organization_id, + ToolModel.status == ToolStatus.ARCHIVED.value, + ) + .values( + status=ToolStatus.ACTIVE.value, + updated_at=datetime.now(UTC), + ) + ) + await session.commit() + + if result.rowcount > 0: + logger.info( + f"Unarchived tool {tool_uuid} for organization {organization_id}" + ) + # Fetch and return the updated tool + result = await session.execute( + select(ToolModel).where(ToolModel.tool_uuid == tool_uuid) + ) + return result.scalar_one_or_none() + return None + async def validate_tool_uuid(self, tool_uuid: str, organization_id: int) -> bool: """Check if a tool UUID exists and belongs to the organization. diff --git a/api/enums.py b/api/enums.py index 74b46b7..8b19bcc 100644 --- a/api/enums.py +++ b/api/enums.py @@ -121,9 +121,8 @@ class ToolCategory(Enum): """Tool category types""" HTTP_API = "http_api" # Custom HTTP API calls (implemented) - NATIVE = ( - "native" # Built-in integrations (future: call_transfer, dtmf_input, end_call) - ) + END_CALL = "end_call" # End call tool + NATIVE = "native" # Built-in integrations (future: call_transfer, dtmf_input) INTEGRATION = "integration" # Third-party integrations (future: Google Calendar, Salesforce, etc.) diff --git a/api/routes/tool.py b/api/routes/tool.py index 71df7d6..f6ee635 100644 --- a/api/routes/tool.py +++ b/api/routes/tool.py @@ -1,7 +1,7 @@ """API routes for managing tools.""" from datetime import datetime -from typing import Any, Dict, List, Optional +from typing import Annotated, Any, Dict, List, Literal, Optional, Union from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field @@ -45,14 +45,38 @@ class HttpApiConfig(BaseModel): ) -class ToolDefinition(BaseModel): - """Tool definition schema.""" +class EndCallConfig(BaseModel): + """Configuration for End Call tools.""" - schema_version: int = Field( - default=1, description="Schema version for compatibility" + messageType: Literal["none", "custom"] = Field( + default="none", description="Type of goodbye message" ) - type: str = Field(description="Tool type (http_api)") - config: HttpApiConfig = Field(description="Tool configuration") + customMessage: Optional[str] = Field( + default=None, description="Custom message to play before ending the call" + ) + + +class HttpApiToolDefinition(BaseModel): + """Tool definition for HTTP API tools.""" + + schema_version: int = Field(default=1, description="Schema version") + type: Literal["http_api"] = Field(description="Tool type") + config: HttpApiConfig = Field(description="HTTP API configuration") + + +class EndCallToolDefinition(BaseModel): + """Tool definition for End Call tools.""" + + schema_version: int = Field(default=1, description="Schema version") + type: Literal["end_call"] = Field(description="Tool type") + config: EndCallConfig = Field(description="End Call configuration") + + +# Union type for tool definitions - Pydantic will discriminate based on 'type' field +ToolDefinition = Annotated[ + Union[HttpApiToolDefinition, EndCallToolDefinition], + Field(discriminator="type"), +] class CreateToolRequest(BaseModel): @@ -140,13 +164,15 @@ def validate_category(category: str) -> None: def validate_status(status: str) -> None: - """Validate that the status is valid.""" + """Validate that the status is valid. Supports comma-separated values.""" valid_statuses = [s.value for s in ToolStatus] - if status not in valid_statuses: - raise HTTPException( - status_code=400, - detail=f"Invalid status '{status}'. Must be one of: {', '.join(valid_statuses)}", - ) + status_list = [s.strip() for s in status.split(",")] + for s in status_list: + if s not in valid_statuses: + raise HTTPException( + status_code=400, + detail=f"Invalid status '{s}'. Must be one of: {', '.join(valid_statuses)}", + ) @router.get("/") @@ -205,27 +231,18 @@ async def create_tool( validate_category(request.category) - try: - tool = await db_client.create_tool( - organization_id=user.selected_organization_id, - user_id=user.id, - name=request.name, - definition=request.definition.model_dump(), - category=request.category, - description=request.description, - icon=request.icon, - icon_color=request.icon_color, - ) + tool = await db_client.create_tool( + organization_id=user.selected_organization_id, + user_id=user.id, + name=request.name, + definition=request.definition.model_dump(), + category=request.category, + description=request.description, + icon=request.icon, + icon_color=request.icon_color, + ) - return build_tool_response(tool) - - except Exception as e: - if "unique_org_tool_name" in str(e): - raise HTTPException( - status_code=409, - detail=f"A tool with the name '{request.name}' already exists", - ) - raise HTTPException(status_code=500, detail=str(e)) + return build_tool_response(tool) @router.get("/{tool_uuid}") @@ -281,32 +298,21 @@ async def update_tool( if request.status: validate_status(request.status) - try: - tool = await db_client.update_tool( - tool_uuid=tool_uuid, - organization_id=user.selected_organization_id, - name=request.name, - description=request.description, - definition=request.definition.model_dump() if request.definition else None, - icon=request.icon, - icon_color=request.icon_color, - status=request.status, - ) + tool = await db_client.update_tool( + tool_uuid=tool_uuid, + organization_id=user.selected_organization_id, + name=request.name, + description=request.description, + definition=request.definition.model_dump() if request.definition else None, + icon=request.icon, + icon_color=request.icon_color, + status=request.status, + ) - if not tool: - raise HTTPException(status_code=404, detail="Tool not found") + if not tool: + raise HTTPException(status_code=404, detail="Tool not found") - return build_tool_response(tool, include_created_by=True) - - except HTTPException: - raise - except Exception as e: - if "unique_org_tool_name" in str(e): - raise HTTPException( - status_code=409, - detail=f"A tool with the name '{request.name}' already exists", - ) - raise HTTPException(status_code=500, detail=str(e)) + return build_tool_response(tool, include_created_by=True) @router.delete("/{tool_uuid}") @@ -334,3 +340,30 @@ async def delete_tool( raise HTTPException(status_code=404, detail="Tool not found") return {"status": "archived", "tool_uuid": tool_uuid} + + +@router.post("/{tool_uuid}/unarchive") +async def unarchive_tool( + tool_uuid: str, + user: UserModel = Depends(get_user), +) -> ToolResponse: + """ + Unarchive a tool (restore from archived state). + + Args: + tool_uuid: The UUID of the tool to unarchive + + Returns: + The unarchived tool + """ + if not user.selected_organization_id: + raise HTTPException( + status_code=400, detail="No organization selected for the user" + ) + + tool = await db_client.unarchive_tool(tool_uuid, user.selected_organization_id) + + if not tool: + raise HTTPException(status_code=404, detail="Tool not found") + + return build_tool_response(tool) diff --git a/api/services/workflow/pipecat_engine_custom_tools.py b/api/services/workflow/pipecat_engine_custom_tools.py index 012548f..af01510 100644 --- a/api/services/workflow/pipecat_engine_custom_tools.py +++ b/api/services/workflow/pipecat_engine_custom_tools.py @@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Any, Optional from loguru import logger from api.db import db_client +from api.enums import ToolCategory from api.services.workflow.disposition_mapper import ( get_organization_id_from_workflow_run, ) @@ -20,13 +21,17 @@ from api.services.workflow.tools.custom_tool import ( tool_to_function_schema, ) from pipecat.adapters.schemas.function_schema import FunctionSchema -from pipecat.frames.frames import FunctionCallResultProperties +from pipecat.frames.frames import FunctionCallResultProperties, TTSSpeakFrame from pipecat.services.llm_service import FunctionCallParams if TYPE_CHECKING: from api.services.workflow.pipecat_engine import PipecatEngine +# End task reason for end call tool +END_CALL_TOOL_REASON = "end_call_tool" + + class CustomToolManager: """Manager for custom tool registration and execution. @@ -34,14 +39,12 @@ class CustomToolManager: 1. Fetching tools from the database based on tool UUIDs 2. Converting tools to LLM function schemas 3. Registering tool execution handlers with the LLM - 4. Executing HTTP API tools when invoked by the LLM + 4. Executing tools when invoked by the LLM """ def __init__(self, engine: "PipecatEngine") -> None: self._engine = engine self._organization_id: Optional[int] = None - # Cache: maps function_name -> (tool, schema) - self._tools_cache: dict[str, tuple[Any, dict]] = {} async def get_organization_id(self) -> Optional[int]: """Get and cache the organization ID from workflow run.""" @@ -73,9 +76,6 @@ class CustomToolManager: raw_schema = tool_to_function_schema(tool) function_name = raw_schema["function"]["name"] - # Cache the tool for later execution - self._tools_cache[function_name] = (tool, raw_schema) - # Convert to FunctionSchema object for compatibility with update_llm_context func_schema = get_function_schema( function_name, @@ -117,9 +117,6 @@ class CustomToolManager: schema = tool_to_function_schema(tool) function_name = schema["function"]["name"] - # Cache the tool for potential later use - self._tools_cache[function_name] = (tool, schema) - # Create and register the handler handler = self._create_handler(tool, function_name) self._engine.llm.register_function(function_name, handler) @@ -133,7 +130,7 @@ class CustomToolManager: logger.error(f"Failed to register custom tool handlers: {e}") def _create_handler(self, tool: Any, function_name: str): - """Create a handler function for a custom tool. + """Create a handler function for a tool based on its category. Args: tool: The ToolModel instance @@ -142,17 +139,29 @@ class CustomToolManager: Returns: Async handler function for the tool """ - # Run LLM after tool execution to continue conversation - properties = FunctionCallResultProperties(run_llm=True) + if tool.category == ToolCategory.END_CALL.value: + return self._create_end_call_handler(tool, function_name) - async def custom_tool_handler( + return self._create_http_tool_handler(tool, function_name) + + def _create_http_tool_handler(self, tool: Any, function_name: str): + """Create a handler function for an HTTP API tool. + + Args: + tool: The ToolModel instance + function_name: The function name used by the LLM + + Returns: + Async handler function for the HTTP API tool + """ + + async def http_tool_handler( function_call_params: FunctionCallParams, ) -> None: - logger.info(f"LLM Function Call EXECUTED: {function_name}") + logger.info(f"HTTP Tool EXECUTED: {function_name}") logger.info(f"Arguments: {function_call_params.arguments}") try: - # Execute the HTTP API tool result = await execute_http_tool( tool=tool, arguments=function_call_params.arguments, @@ -160,30 +169,66 @@ class CustomToolManager: organization_id=self._organization_id, ) - await function_call_params.result_callback( - result, properties=properties - ) + await function_call_params.result_callback(result) except Exception as e: - logger.error(f"Custom tool '{function_name}' execution failed: {e}") + logger.error(f"HTTP tool '{function_name}' execution failed: {e}") await function_call_params.result_callback( - {"status": "error", "error": str(e)}, - properties=properties, + {"status": "error", "error": str(e)} ) - return custom_tool_handler + return http_tool_handler - def get_cached_tool(self, function_name: str) -> Optional[tuple[Any, dict]]: - """Get a cached tool by its function name. + def _create_end_call_handler(self, tool: Any, function_name: str): + """Create a handler function for an end call tool. Args: + tool: The ToolModel instance function_name: The function name used by the LLM Returns: - Tuple of (tool, schema) if found, None otherwise + Async handler function for the end call tool """ - return self._tools_cache.get(function_name) + # Don't run LLM after end call - we're terminating + properties = FunctionCallResultProperties(run_llm=False) - def clear_cache(self) -> None: - """Clear the tools cache.""" - self._tools_cache.clear() + async def end_call_handler( + function_call_params: FunctionCallParams, + ) -> None: + logger.info(f"End Call Tool EXECUTED: {function_name}") + + try: + # Get the end call configuration + config = tool.definition.get("config", {}) + message_type = config.get("messageType", "none") + custom_message = config.get("customMessage", "") + + # Send result callback first + await function_call_params.result_callback( + {"status": "success", "action": "ending_call"}, + properties=properties, + ) + + if message_type == "custom" and custom_message: + # Queue the custom message to be spoken + logger.info(f"Playing custom goodbye message: {custom_message}") + await self._engine.task.queue_frame(TTSSpeakFrame(custom_message)) + # End the call after the message (not immediately) + await self._engine.send_end_task_frame( + END_CALL_TOOL_REASON, abort_immediately=False + ) + else: + # No message - end call immediately + logger.info("Ending call immediately (no goodbye message)") + await self._engine.send_end_task_frame( + END_CALL_TOOL_REASON, abort_immediately=True + ) + + except Exception as e: + logger.error(f"End call tool '{function_name}' execution failed: {e}") + # Still try to end the call even if there's an error + await self._engine.send_end_task_frame( + END_CALL_TOOL_REASON, abort_immediately=True + ) + + return end_call_handler diff --git a/api/services/workflow/workflow.py b/api/services/workflow/workflow.py index c6b1280..4f68e77 100644 --- a/api/services/workflow/workflow.py +++ b/api/services/workflow/workflow.py @@ -113,7 +113,6 @@ class WorkflowGraph: # ) errors.extend(self._assert_start_node()) - errors.extend(self._assert_end_node()) errors.extend(self._assert_connection_counts()) errors.extend(self._assert_global_node()) errors.extend(self._assert_node_configs()) @@ -174,30 +173,6 @@ class WorkflowGraph: ) return errors - def _assert_end_node(self): - errors: list[WorkflowError] = [] - end_node = [n for n in self.nodes.values() if n.data.is_end] - if not end_node: - errors.append( - WorkflowError( - kind=ItemKind.workflow, - id=None, - field=None, - message="Workflow must have exactly one end node", - ) - ) - - elif len(end_node) > 1: - errors.append( - WorkflowError( - kind=ItemKind.workflow, - id=None, - field=None, - message="Workflow must have exactly one end node", - ) - ) - return errors - def _assert_connection_counts(self): errors: list[WorkflowError] = [] @@ -235,13 +210,13 @@ class WorkflowGraph: ) ) case NodeType.agentNode: - if in_d < 1 or out_d < 1: + if in_d < 1: errors.append( WorkflowError( kind=ItemKind.node, id=n.id, field=None, - message=f"Worker must have at least 1 incoming and 1 outgoing edge", + message=f"Worker must have at least 1 incoming edge", ) ) diff --git a/api/tests/test_custom_tools.py b/api/tests/test_custom_tools.py index 6868056..5007695 100644 --- a/api/tests/test_custom_tools.py +++ b/api/tests/test_custom_tools.py @@ -43,6 +43,7 @@ class MockToolModel: tool_uuid: str name: str description: str + category: str definition: Dict[str, Any] @@ -55,6 +56,7 @@ class TestToolToFunctionSchema: tool_uuid="test-uuid-1", name="Get Weather", description="Get current weather for a location", + category="http_api", definition={ "schema_version": 1, "type": "http_api", @@ -97,6 +99,7 @@ class TestToolToFunctionSchema: tool_uuid="test-uuid-2", name="Book Appointment", description="Book an appointment with the service", + category="http_api", definition={ "schema_version": 1, "type": "http_api", @@ -145,6 +148,7 @@ class TestToolToFunctionSchema: tool_uuid="test-uuid-3", name="Get User's Account Info!!!", description="Get account information", + category="http_api", definition={ "schema_version": 1, "type": "http_api", @@ -167,6 +171,7 @@ class TestToolToFunctionSchema: tool_uuid="test-uuid-4", name="Ping Server", description="Check if server is alive", + category="http_api", definition={ "schema_version": 1, "type": "http_api", @@ -188,6 +193,7 @@ class TestToolToFunctionSchema: tool_uuid="test-uuid-5", name="My Tool", description=None, + category="http_api", definition={ "schema_version": 1, "type": "http_api", @@ -213,6 +219,7 @@ class TestExecuteHttpTool: tool_uuid="test-uuid", name="Create User", description="Create a new user", + category="http_api", definition={ "schema_version": 1, "type": "http_api", @@ -257,6 +264,7 @@ class TestExecuteHttpTool: tool_uuid="test-uuid", name="Search Users", description="Search for users", + category="http_api", definition={ "schema_version": 1, "type": "http_api", @@ -297,6 +305,7 @@ class TestExecuteHttpTool: tool_uuid="test-uuid", name="Delete User", description="Delete a user", + category="http_api", definition={ "schema_version": 1, "type": "http_api", @@ -336,6 +345,7 @@ class TestExecuteHttpTool: tool_uuid="test-uuid", name="Slow API", description="A slow API call", + category="http_api", definition={ "schema_version": 1, "type": "http_api", @@ -368,6 +378,7 @@ class TestExecuteHttpTool: tool_uuid="test-uuid", name="API with Headers", description="API that requires headers", + category="http_api", definition={ "schema_version": 1, "type": "http_api", @@ -406,6 +417,7 @@ class TestExecuteHttpTool: tool_uuid="test-uuid", name="Authenticated API", description="API that requires authentication", + category="http_api", definition={ "schema_version": 1, "type": "http_api", @@ -457,6 +469,7 @@ class TestExecuteHttpTool: tool_uuid="test-uuid", name="API with Credential", description="API with credential configured", + category="http_api", definition={ "schema_version": 1, "type": "http_api", @@ -724,6 +737,7 @@ class TestCustomToolManagerUnit: tool_uuid="uuid-1", name="Test Tool", description="A test tool", + category="http_api", definition={ "schema_version": 1, "type": "http_api", @@ -791,6 +805,7 @@ class TestCustomToolManagerUnit: tool_uuid="uuid-1", name="API Call", description="Make an API call", + category="http_api", definition={ "schema_version": 1, "type": "http_api", @@ -846,53 +861,6 @@ class TestCustomToolManagerUnit: # Verify result was returned assert result_received["status"] == "success" - @pytest.mark.asyncio - async def test_tools_cache_prevents_duplicate_fetches(self): - """Test that tools are cached after first fetch.""" - from api.services.workflow.pipecat_engine_custom_tools import CustomToolManager - - mock_engine = Mock() - mock_engine._workflow_run_id = 1 - mock_engine._call_context_vars = {} - mock_engine.llm = Mock() - mock_engine.llm.register_function = Mock() - - manager = CustomToolManager(mock_engine) - - mock_tool = MockToolModel( - tool_uuid="uuid-1", - name="Cached Tool", - description="A tool that should be cached", - definition={ - "schema_version": 1, - "type": "http_api", - "config": {"method": "GET", "url": "https://api.example.com"}, - }, - ) - - with patch( - "api.services.workflow.pipecat_engine_custom_tools.get_organization_id_from_workflow_run" - ) as mock_get_org: - mock_get_org.return_value = 1 - - with patch( - "api.services.workflow.pipecat_engine_custom_tools.db_client" - ) as mock_db: - mock_db.get_tools_by_uuids = AsyncMock(return_value=[mock_tool]) - - # First call should fetch from DB - await manager.get_tool_schemas(["uuid-1"]) - - # Verify tool is now in cache - cached = manager.get_cached_tool("cached_tool") - assert cached is not None - assert cached[0].tool_uuid == "uuid-1" - - # Clear cache and verify it's empty - manager.clear_cache() - cached = manager.get_cached_tool("cached_tool") - assert cached is None - class TestUpdateLLMContext: """Tests for update_llm_context function.""" diff --git a/api/tests/test_custom_tools_context_integration.py b/api/tests/test_custom_tools_context_integration.py index 5cfa703..9a0c605 100644 --- a/api/tests/test_custom_tools_context_integration.py +++ b/api/tests/test_custom_tools_context_integration.py @@ -206,31 +206,6 @@ class TestCustomToolManagerContextIntegration: assert "get_current_time" in tool_names assert "get_weather" in tool_names - @pytest.mark.asyncio - async def test_tools_cached_after_first_fetch(self, mock_engine, sample_tools): - """Test that CustomToolManager caches tools after first fetch.""" - manager = CustomToolManager(mock_engine) - - with patch( - "api.services.workflow.pipecat_engine_custom_tools.get_organization_id_from_workflow_run" - ) as mock_get_org: - mock_get_org.return_value = 1 - - with patch( - "api.services.workflow.pipecat_engine_custom_tools.db_client" - ) as mock_db: - mock_db.get_tools_by_uuids = AsyncMock(return_value=[sample_tools[0]]) - - # First fetch - await manager.get_tool_schemas(["weather-uuid-123"]) - - # Verify tool is cached (cache stores raw schema dict, not FunctionSchema) - cached = manager.get_cached_tool("get_weather") - assert cached is not None - tool, raw_schema = cached - assert tool.tool_uuid == "weather-uuid-123" - assert raw_schema["function"]["name"] == "get_weather" - @pytest.mark.asyncio async def test_context_preserves_function_call_history( self, mock_engine, sample_tools diff --git a/api/tests/test_pipecat_engine_tool_calls.py b/api/tests/test_pipecat_engine_tool_calls.py index debffb2..7225a9b 100644 --- a/api/tests/test_pipecat_engine_tool_calls.py +++ b/api/tests/test_pipecat_engine_tool_calls.py @@ -13,6 +13,7 @@ import pytest from api.services.workflow.pipecat_engine import PipecatEngine from api.services.workflow.workflow import WorkflowGraph from api.tests.conftest import END_CALL_SYSTEM_PROMPT, MockTransportProcessor +from pipecat.frames.frames import LLMContextFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineParams, PipelineTask @@ -123,6 +124,7 @@ async def run_pipeline_with_tool_calls( # Small delay to let runner start await asyncio.sleep(0.01) await engine.initialize() + await engine.llm.queue_frame(LLMContextFrame(engine.context)) # Run both concurrently await asyncio.gather(run_pipeline(), initialize_engine()) diff --git a/api/tests/test_user_idle_handler.py b/api/tests/test_user_idle_handler.py index fcca024..4d73abf 100644 --- a/api/tests/test_user_idle_handler.py +++ b/api/tests/test_user_idle_handler.py @@ -15,6 +15,7 @@ import pytest from api.services.workflow.pipecat_engine import PipecatEngine from api.services.workflow.workflow import WorkflowGraph from api.tests.conftest import MockTransportProcessor +from pipecat.frames.frames import LLMContextFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineParams, PipelineTask @@ -128,6 +129,7 @@ async def run_pipeline_with_user_idle( # Small delay to let runner start await asyncio.sleep(0.01) await engine.initialize() + await engine.llm.queue_frame(LLMContextFrame(engine.context)) # Calculate total wait time: # - Initial bot speech diff --git a/ui/AGENTS.md b/ui/AGENTS.md index 3779a75..7581da8 100644 --- a/ui/AGENTS.md +++ b/ui/AGENTS.md @@ -42,7 +42,8 @@ ui/ ## API Client -The `src/client/` directory is auto-generated from the backend OpenAPI spec: +The `src/client/` directory is auto-generated from the backend OpenAPI spec. Whenever you add a +new api route in backend, and wish to use it in the UI, generate the client using below command. ```bash npm run generate-client diff --git a/ui/package-lock.json b/ui/package-lock.json index 841b941..866d461 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,16 +1,15 @@ { "name": "ui", - "version": "1.8.0", + "version": "1.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ui", - "version": "1.8.0", + "version": "1.10.0", "dependencies": { "@dagrejs/dagre": "^1.1.4", "@hey-api/client-fetch": "^0.10.0", - "@livekit/components-react": "^2.9.0", "@nangohq/frontend": "^0.60.3", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.2", @@ -33,7 +32,6 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", - "livekit-client": "^2.9.9", "lucide-react": "^0.505.0", "next": "^15.3.3", "next-themes": "^0.4.6", @@ -1033,12 +1031,6 @@ "node": ">=6.9.0" } }, - "node_modules/@bufbuild/protobuf": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.0.tgz", - "integrity": "sha512-QDdVFLoN93Zjg36NoQPZfsVH9tZew7wKDKyV5qRdj8ntT4wQCOradQjRaTdwMhWUYsgKsvCINKKm87FdEk96Ag==", - "license": "(Apache-2.0 AND BSD-3-Clause)" - }, "node_modules/@dagrejs/dagre": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-1.1.4.tgz", @@ -2527,78 +2519,6 @@ "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", "license": "MIT" }, - "node_modules/@livekit/components-core": { - "version": "0.12.2", - "resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.2.tgz", - "integrity": "sha512-lSNEyWuJ94PNVR7uycpqxV5XG+GmPpRCRPFEiVnAKG8xbTKlBrlu6ruPgg3/dZG4oHCPOpTN7VPV4jbcUjFAoA==", - "license": "Apache-2.0", - "dependencies": { - "@floating-ui/dom": "1.6.13", - "loglevel": "1.9.1", - "rxjs": "7.8.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "livekit-client": "^2.9.5", - "tslib": "^2.6.2" - } - }, - "node_modules/@livekit/components-core/node_modules/loglevel": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.1.tgz", - "integrity": "sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - }, - "funding": { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/loglevel" - } - }, - "node_modules/@livekit/components-react": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.0.tgz", - "integrity": "sha512-HAY1d1N2n5e8KP3ogTGgvHpO4afDSKAt1pqDsHPumTYcNTGKuMtIfMc4CfUMrtVMQAggzouOTYfb/gC4gnaYiQ==", - "license": "Apache-2.0", - "dependencies": { - "@livekit/components-core": "0.12.2", - "clsx": "2.1.1", - "usehooks-ts": "3.1.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@livekit/krisp-noise-filter": "^0.2.12", - "livekit-client": "^2.9.5", - "react": ">=18", - "react-dom": ">=18", - "tslib": "^2.6.2" - }, - "peerDependenciesMeta": { - "@livekit/krisp-noise-filter": { - "optional": true - } - } - }, - "node_modules/@livekit/mutex": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz", - "integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==", - "license": "Apache-2.0" - }, - "node_modules/@livekit/protocol": { - "version": "1.34.0", - "resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.34.0.tgz", - "integrity": "sha512-bU7pCLAMRVTVZb1KSxA46q55bhOc4iATrY/gccy2/oX1D57tiZEI+8wGRWHeDwBb0UwnABu6JXzC4tTFkdsaOg==", - "license": "Apache-2.0", - "dependencies": { - "@bufbuild/protobuf": "^1.10.0" - } - }, "node_modules/@nangohq/frontend": { "version": "0.60.5", "resolved": "https://registry.npmjs.org/@nangohq/frontend/-/frontend-0.60.5.tgz", @@ -16003,24 +15923,6 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, - "node_modules/livekit-client": { - "version": "2.9.9", - "resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.9.9.tgz", - "integrity": "sha512-Mrm9Z/kPmJP5r4EMbzXPfB27gRP+tZtU7Zzen2o8u6w5N6FxaqXerRFqtXddGXaVzZouJOAg51/5A9u3saC/2A==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@livekit/mutex": "1.1.1", - "@livekit/protocol": "1.34.0", - "events": "^3.3.0", - "loglevel": "^1.9.2", - "sdp-transform": "^2.15.0", - "ts-debounce": "^4.0.0", - "tslib": "2.8.1", - "typed-emitter": "^2.1.0", - "webrtc-adapter": "^9.0.1" - } - }, "node_modules/loader-runner": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", @@ -16045,12 +15947,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.debounce": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "license": "MIT" - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -16058,19 +15954,6 @@ "dev": true, "license": "MIT" }, - "node_modules/loglevel": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", - "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - }, - "funding": { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/loglevel" - } - }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -17754,15 +17637,6 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -17907,21 +17781,6 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, - "node_modules/sdp": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.0.tgz", - "integrity": "sha512-d7wDPgDV3DDiqulJjKiV2865wKsJ34YI+NDREbm+FySq6WuKOikwyNQcm+doLAZ1O6ltdO0SeKle2xMpN3Brgw==", - "license": "MIT" - }, - "node_modules/sdp-transform": { - "version": "2.15.0", - "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz", - "integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==", - "license": "MIT", - "bin": { - "sdp-verify": "checker.js" - } - }, "node_modules/secure-json-parse": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.0.0.tgz", @@ -18778,12 +18637,6 @@ "typescript": ">=4.8.4" } }, - "node_modules/ts-debounce": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/ts-debounce/-/ts-debounce-4.0.0.tgz", - "integrity": "sha512-+1iDGY6NmOGidq7i7xZGA4cm8DAa6fqdYcvO5Z6yBevH++Bdo9Qt/mN0TzHUgcCcKv1gmh9+W5dHqz8pMWbCbg==", - "license": "MIT" - }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -18935,15 +18788,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/typed-emitter": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz", - "integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==", - "license": "MIT", - "optionalDependencies": { - "rxjs": "*" - } - }, "node_modules/typescript": { "version": "5.8.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", @@ -19195,21 +19039,6 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/usehooks-ts": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.1.tgz", - "integrity": "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==", - "license": "MIT", - "dependencies": { - "lodash.debounce": "^4.0.8" - }, - "engines": { - "node": ">=16.15.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" - } - }, "node_modules/uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", @@ -19360,19 +19189,6 @@ "node": ">=4.0" } }, - "node_modules/webrtc-adapter": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.1.tgz", - "integrity": "sha512-1AQO+d4ElfVSXyzNVTOewgGT/tAomwwztX/6e3totvyyzXPvXIIuUUjAmyZGbKBKbZOXauuJooZm3g6IuFuiNQ==", - "license": "BSD-3-Clause", - "dependencies": { - "sdp": "^3.2.0" - }, - "engines": { - "node": ">=6.0.0", - "npm": ">=3.10.0" - } - }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", diff --git a/ui/package.json b/ui/package.json index 73f28ea..12b0bf0 100644 --- a/ui/package.json +++ b/ui/package.json @@ -13,7 +13,6 @@ "dependencies": { "@dagrejs/dagre": "^1.1.4", "@hey-api/client-fetch": "^0.10.0", - "@livekit/components-react": "^2.9.0", "@nangohq/frontend": "^0.60.3", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.2", @@ -36,7 +35,6 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", - "livekit-client": "^2.9.9", "lucide-react": "^0.505.0", "next": "^15.3.3", "next-themes": "^0.4.6", diff --git a/ui/src/app/tools/[toolUuid]/components/EndCallToolConfig.tsx b/ui/src/app/tools/[toolUuid]/components/EndCallToolConfig.tsx new file mode 100644 index 0000000..65a1db3 --- /dev/null +++ b/ui/src/app/tools/[toolUuid]/components/EndCallToolConfig.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Textarea } from "@/components/ui/textarea"; + +import { type EndCallMessageType } from "../../config"; + +export interface EndCallToolConfigProps { + name: string; + onNameChange: (name: string) => void; + description: string; + onDescriptionChange: (description: string) => void; + messageType: EndCallMessageType; + onMessageTypeChange: (messageType: EndCallMessageType) => void; + customMessage: string; + onCustomMessageChange: (message: string) => void; +} + +export function EndCallToolConfig({ + name, + onNameChange, + description, + onDescriptionChange, + messageType, + onMessageTypeChange, + customMessage, + onCustomMessageChange, +}: EndCallToolConfigProps) { + return ( + + + End Call Configuration + + Configure the behavior when the call ends + + + +
+ + + onNameChange(e.target.value)} + placeholder="e.g., End Call" + /> +
+ +
+ + +