diff --git a/examples/README.md b/examples/README.md index d25dab54f..1a4a9bc9b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -36,6 +36,8 @@ All necessary details are provided in the comments at the top of each script. | [Advanced Document Search Optimization](/examples/evaluate/document-search/advanced/optimize.py) | [ragbits-evaluate](/packages/ragbits-evaluate) | Example of how to optimize an advanced document search pipeline using the `Optimizer` class. | | [Chat Interface](/examples/chat/chat.py) | [ragbits-chat](/packages/ragbits-chat) | Example of how to use the `ChatInterface` to create a simple chat application. | | [Offline Chat Interface](/examples/chat/offline_chat.py) | [ragbits-chat](/packages/ragbits-chat) | Example of how to use the `ChatInterface` to create a simple chat application that works offline. | +| [Calendar Agent](/examples/chat/calendar_agent.py) | [ragbits-chat](/packages/ragbits-chat) | Calendar management agent with confirmation for destructive actions. Demonstrates rich tool results and partial failure handling. | +| [File Explorer Agent](/examples/chat/file_explorer_agent.py) | [ragbits-chat](/packages/ragbits-chat) | Secure file management agent with path validation and confirmation for all file operations within a restricted directory. | | [Recontextualize Last Message](/examples/chat/recontextualize_message.py) | [ragbits-chat](/packages/ragbits-chat) | Example of how to use the `StandaloneMessageCompressor` compressor to recontextualize the last message in a conversation history. | | [Agents Tool Use](/examples/agents/tool_use.py) | [ragbits-agents](/packages/ragbits-agents) | Example of how to use agent with tools. | | [Agents OpenAI Native Tool Use](/examples/agents/openai_native_tool_use.py) | [ragbits-agents](/packages/ragbits-agents) | Example of how to use agent with OpenAI native tools. | diff --git a/examples/agents/agent_with_confirmation.py b/examples/agents/agent_with_confirmation.py new file mode 100644 index 000000000..a1040f92b --- /dev/null +++ b/examples/agents/agent_with_confirmation.py @@ -0,0 +1,132 @@ +""" +Example demonstrating an agent with tool confirmation using the new non-blocking approach. + +This example shows how to create an agent that requires user confirmation +before executing certain tools. The confirmation is non-blocking: the agent +completes its run when a tool needs confirmation, and resumes after confirmation +is provided. + +Run this example: + uv run python examples/agents/agent_with_confirmation.py +""" + +import asyncio + +from ragbits.agents import Agent +from ragbits.agents._main import AgentRunContext +from ragbits.agents.confirmation import ConfirmationRequest +from ragbits.core.llms import LiteLLM + + +# Define some example tools +def get_weather(city: str) -> str: + """ + Get the weather for a city. + + Args: + city: The city to get weather for + """ + return f"ā˜€ļø Weather in {city}: Sunny, 72°F" + + +def send_email(to: str, subject: str, body: str) -> str: + """ + Send an email to someone. + + Args: + to: Email recipient + subject: Email subject + body: Email body + """ + return f"šŸ“§ Email sent to {to} with subject '{subject}'" + + +def delete_file(filename: str) -> str: + """ + Delete a file from the system. + + Args: + filename: The file to delete + """ + return f"šŸ—‘ļø Deleted file: {filename}" + + +async def main() -> None: + """Run the agent with confirmation example using non-blocking approach.""" + # Create LLM + llm = LiteLLM(model_name="gpt-4o-mini") + + # Create agent + agent: Agent = Agent( + llm=llm, + prompt="You are a helpful assistant. Help the user with their requests.", + tools=[get_weather, send_email, delete_file], + ) + + # Mark tools that require confirmation + for tool in agent.tools: + if tool.name in ["send_email", "delete_file"]: + tool.requires_confirmation = True + print(f"āœ“ Tool '{tool.name}' marked as requiring confirmation") + + print("\n" + "=" * 60) + print("Agent with Non-Blocking Confirmation Example") + print("=" * 60) + print("\nTools available:") + print(" - get_weather (no confirmation)") + print(" - send_email (requires confirmation)") + print(" - delete_file (requires confirmation)") + print("\nTry: 'Send an email to john@example.com about the meeting'") + print("=" * 60 + "\n") + + # Test query + user_query = "Send an email to john@example.com with subject 'Meeting Reminder' about our 2pm meeting tomorrow" + + print(f"User: {user_query}\n") + + # Store pending confirmations + pending_confirmations: list[ConfirmationRequest] = [] + + # First run - agent will encounter confirmation and stop + agent_context: AgentRunContext = AgentRunContext() + async for response in agent.run_streaming(user_query, context=agent_context): + if isinstance(response, str): + print(f"Agent: {response}", end="", flush=True) + + elif isinstance(response, ConfirmationRequest): + pending_confirmations.append(response) + print("\n\nāš ļø CONFIRMATION REQUIRED āš ļø") + print(f"Tool: {response.tool_name}") + print(f"Description: {response.tool_description}") + print(f"Arguments: {response.arguments}") + print(f"Confirmation ID: {response.confirmation_id}") + + # If we have pending confirmations, ask user and re-run agent + if pending_confirmations: + print("\n" + "-" * 60) + user_input = input("\nDo you want to proceed with these actions? (yes/no): ").strip().lower() + confirmed = user_input in ["yes", "y"] + + if confirmed: + print("āœ… Confirmed - re-running agent with confirmation\n") + + # Create new context with confirmations + agent_context = AgentRunContext() + # Pass confirmed tools to the agent context + # This allows the agent to check if tools have been confirmed + agent_context.confirmed_tools = [ # type: ignore[attr-defined] + {"confirmation_id": req.confirmation_id, "confirmed": True} for req in pending_confirmations + ] + + # Re-run agent with the same query and confirmations in context + async for response in agent.run_streaming(user_query, context=agent_context): + if isinstance(response, str): + print(f"Agent: {response}", end="", flush=True) + else: + print("āŒ Cancelled - actions not performed") + + print("\n\nDone!") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/chat/README.md b/examples/chat/README.md new file mode 100644 index 000000000..6266f6233 --- /dev/null +++ b/examples/chat/README.md @@ -0,0 +1,195 @@ +# Chat Examples + +This directory contains examples demonstrating the Ragbits Chat Interface with various agent configurations. + +## Examples Overview + +### Basic Chat Examples + +- **[chat.py](chat.py)** - Simple chat application +- **[offline_chat.py](offline_chat.py)** - Chat application that works offline +- **[recontextualize_message.py](recontextualize_message.py)** - Message compression and recontextualization + +### Agent Examples with Confirmation + +These examples demonstrate agents that require user confirmation for destructive or important actions. The confirmation system is **stateless** - all state is managed in the frontend (IndexedDB/Zustand), allowing the agents to scale horizontally without server-side session management. + +#### Calendar Agent ([calendar_agent.py](calendar_agent.py)) + +A calendar management agent with the following features: + +**Tools:** +- `analyze_calendar()` - Analyze schedule and provide insights +- `get_meetings(date)` - Get meetings for a specific date +- `list_meetings(date_range)` - List all meetings in a range +- `get_availability(date, attendees)` - Check attendee availability +- `schedule_meeting(title, date, time, attendees)` - **Requires confirmation** +- `invite_people(emails, event_id, message)` - **Requires confirmation** +- `delete_event(event_id, reason)` - **Requires confirmation** +- `cancel_meeting(meeting_id, notify)` - **Requires confirmation** +- `reschedule_meeting(meeting_id, new_date, new_time)` - **Requires confirmation** +- `send_reminder(meeting_id, attendees)` - **Requires confirmation** + +**Key Features:** +- **Rich Tool Results**: Tools return structured JSON with detailed success/failure information +- **Partial Failure Handling**: When inviting multiple people, some may succeed while others fail (e.g., out of office) +- **Intelligent Analysis**: Agent analyzes tool results and provides helpful summaries +- **State Management**: Uses mock database (MEETINGS_DB, EMPLOYEES_DB) that persists across tool calls + +**Example Scenarios:** +```bash +# Run the agent +ragbits api run examples.chat.calendar_agent:CalendarChat +``` + +Try these interactions: +1. "Invite john@example.com, bob@example.com, and alice@example.com to meeting_001" + - Result: Bob will have OOO auto-reply, others invited successfully + - Agent explains the partial failure and suggests next steps + +2. "Schedule a meeting for tomorrow at 3pm with the team" + - Agent asks for confirmation + - Creates meeting and returns meeting ID + +3. "Check availability for john@example.com and bob@example.com on 2024-11-10" + - Shows who's available vs. out of office + - No confirmation needed (read-only) + +#### File Explorer Agent ([file_explorer_agent.py](file_explorer_agent.py)) + +A secure file management agent restricted to the `temp/` directory: + +**Tools:** +- `list_files(directory)` - List files and directories +- `read_file(filepath)` - Read file contents +- `get_file_info(filepath)` - Get detailed file information +- `search_files(pattern, directory)` - Search for files by pattern +- `create_file(filepath, content)` - **Requires confirmation** +- `delete_file(filepath)` - **Requires confirmation** +- `move_file(source, destination)` - **Requires confirmation** +- `create_directory(dirpath)` - **Requires confirmation** +- `delete_directory(dirpath)` - **Requires confirmation** + +**Security Features:** +- **Path Validation**: All paths are validated to be within `temp/` directory +- **Path Traversal Prevention**: `../` and absolute paths outside temp/ are blocked +- **Automatic Path Resolution**: Paths are automatically resolved and checked +- **Confirmation Required**: All file modifications require user confirmation + +**Key Features:** +- **Detailed Results**: Every operation returns structured JSON with success/failure info +- **Error Handling**: Clear error messages for common issues (file not found, already exists, etc.) +- **Directory Restrictions**: Cannot delete non-empty directories +- **Rich File Info**: File size, permissions, modification times + +**Example Scenarios:** +```bash +# Run the agent +ragbits api run examples.chat.file_explorer_agent:FileExplorerChat +``` + +Try these interactions: +1. "List all files in the documents folder" + - Shows files with sizes and counts + +2. "Read the report.txt file" + - Displays file contents + +3. "Create a new file called 'todo.txt' with my task list" + - Agent asks for confirmation + - Creates file with specified content + +4. "Search for all markdown files" + - Uses pattern matching to find *.md files + +5. "Try to access /etc/passwd" (security test) + - Agent will reject: "Access denied: Path outside temp/ directory" + +6. "Delete documents/report.txt" + - Agent asks for confirmation + - Deletes file and reports size + +## Confirmation System Architecture + +### How It Works + +1. **Agent marks tools with confirmation:** + ```python + for tool in agent.tools: + if tool.name in ["delete_file", "invite_people", ...]: + tool.requires_confirmation = True + ``` + +2. **When confirmation needed:** + - Agent yields `ConfirmationRequest` with tool details + - Frontend displays confirmation dialog + - Agent stops execution (stateless) + +3. **User confirms:** + - Frontend sends new message with `confirmed_tools` in context + - Agent re-runs, finds matching confirmation_id, executes tool + - Agent analyzes results and responds + +4. **State Management:** + - All confirmation state stored in frontend (IndexedDB) + - No server-side sessions required + - Agents are completely stateless + - History contains full context + +### Modifying Confirmations + +Users can modify tool parameters before confirming: + +``` +User: "Invite john@ex.com, bob@ex.com, alice@ex.com to meeting_001" +Agent: [Shows confirmation with 3 people] +User: "Actually, don't invite Bob" +Agent: [Creates NEW confirmation with 2 people, marks old one superseded] +User: [Confirms] +Agent: Executes with modified parameters +``` + +### Rich Tool Results + +Tools return structured JSON for intelligent agent analysis: + +```json +{ + "success": true/false, + "summary": "Human-readable summary", + "details": { + "successful": [...], + "failed": [...], + "ooo": [...] + } +} +``` + +This allows the agent to: +- Explain what succeeded and what failed +- Suggest remedies for failures +- Provide contextual information +- Offer next steps + +## Running the Examples + +```bash +# Run calendar agent +ragbits api run examples.chat.calendar_agent:CalendarChat + +# Run file explorer agent +ragbits api run examples.chat.file_explorer_agent:FileExplorerChat +``` + +Both examples will start a web server with the Ragbits UI where you can interact with the agents. + +## Testing + +The `temp/` directory contains sample files for testing the file explorer agent: +- `temp/README.txt` - Overview file +- `temp/documents/report.txt` - Sample document +- `temp/documents/notes.md` - Sample markdown file +- `temp/images/photo1.jpg` - Placeholder image file + +Feel free to create, modify, and delete files through the agent! + diff --git a/examples/chat/calendar_agent.py b/examples/chat/calendar_agent.py new file mode 100644 index 000000000..fab3f090b --- /dev/null +++ b/examples/chat/calendar_agent.py @@ -0,0 +1,536 @@ +""" +Ragbits Chat Example: Calendar Agent with Confirmation + +This example demonstrates how to use the ChatInterface with an agent that requires +user confirmation for destructive actions like deleting events or inviting people. + +The confirmation is non-blocking: when a tool needs confirmation, the agent completes +its run and sends a confirmation request to the frontend. After the user confirms, +the frontend sends a new request with the confirmation, and the agent continues. + +To run the script: + ragbits api run examples.chat.calendar_agent:CalendarChat +""" + +import json +import random +from collections.abc import AsyncGenerator + +from ragbits.agents import Agent, ToolCallResult +from ragbits.agents._main import AgentRunContext +from ragbits.agents.confirmation import ConfirmationRequest +from ragbits.chat.interface import ChatInterface +from ragbits.chat.interface.types import ( + ChatContext, + ChatResponse, + ChatResponseType, + LiveUpdateType, +) +from ragbits.chat.interface.ui_customization import HeaderCustomization, UICustomization +from ragbits.core.llms import LiteLLM, ToolCall +from ragbits.core.llms.base import Usage +from ragbits.core.prompt import ChatFormat + + +# Mock database of meetings +MEETINGS_DB = { + "meeting_001": {"title": "Team Sync", "date": "2024-11-07", "time": "14:00", "attendees": ["john@example.com"]}, + "meeting_002": {"title": "Client Call", "date": "2024-11-07", "time": "16:00", "attendees": ["jane@client.com"]}, + "meeting_003": {"title": "Sprint Planning", "date": "2024-11-08", "time": "10:00", "attendees": []}, +} + +# Mock employee database with OOO status +EMPLOYEES_DB = { + "john@example.com": {"name": "John Doe", "status": "active"}, + "jane@example.com": {"name": "Jane Smith", "status": "active"}, + "bob@example.com": {"name": "Bob Wilson", "status": "ooo", "ooo_until": "2024-11-15", "auto_reply": "Out of office until Nov 15"}, + "alice@example.com": {"name": "Alice Johnson", "status": "active"}, + "charlie@example.com": {"name": "Charlie Brown", "status": "active"}, +} + + +# Define calendar tools +def analyze_calendar() -> str: + """Analyze the user's calendar and provide insights.""" + total_meetings = len(MEETINGS_DB) + return f"šŸ“Š Calendar analyzed: You have {total_meetings} meetings scheduled, 2 tomorrow" + + +def get_meetings(date: str = "today") -> str: + """ + Get meetings for a specific date. + + Args: + date: Date to get meetings for (today, tomorrow, or YYYY-MM-DD) + """ + meetings = [m for m in MEETINGS_DB.values() if date in m["date"] or date == "today"] + if not meetings: + return f"šŸ“… No meetings found for {date}" + + result = f"šŸ“… Meetings for {date}:\n" + for meeting in meetings: + result += f" - {meeting['title']} at {meeting['time']}\n" + return result + + +def list_meetings(date_range: str = "week") -> str: + """ + List all meetings in a date range. + + Args: + date_range: Range to list (today, week, month) + """ + result = f"šŸ“‹ Meetings ({date_range}):\n" + for meeting_id, meeting in MEETINGS_DB.items(): + attendee_count = len(meeting["attendees"]) + result += f" [{meeting_id}] {meeting['title']} - {meeting['date']} {meeting['time']} ({attendee_count} attendees)\n" + return result + + +def get_availability(date: str, attendees: list[str]) -> str: + """ + Check availability of attendees for a specific date. + + Args: + date: Date to check (YYYY-MM-DD) + attendees: List of email addresses to check + """ + result = {"date": date, "available": [], "unavailable": [], "ooo": []} + + for email in attendees: + if email in EMPLOYEES_DB: + employee = EMPLOYEES_DB[email] + if employee["status"] == "ooo": + result["ooo"].append({"email": email, "name": employee["name"], "until": employee.get("ooo_until")}) + else: + # Randomly mark some as busy for demo + if random.random() > 0.7: + result["unavailable"].append({"email": email, "name": employee["name"], "reason": "Meeting conflict"}) + else: + result["available"].append({"email": email, "name": employee["name"]}) + else: + result["unavailable"].append({"email": email, "name": "Unknown", "reason": "Not in directory"}) + + return json.dumps(result, indent=2) + + +def invite_people(emails: list[str], event_id: str, message: str = "", context: AgentRunContext | None = None) -> str: + """ + Invite multiple people to a calendar event. Requires confirmation. + + Args: + emails: List of email addresses to invite + event_id: ID of the event + message: Optional message to include + context: Agent run context (automatically injected) + """ + import datetime + + timestamp = datetime.datetime.now().isoformat() + confirmed_via = "button" if (context and context.confirmed_tools) else "natural language" + + print(f"šŸŽÆ TOOL EXECUTED: invite_people at {timestamp}") # noqa: T201 + print(f" Args: emails={emails}, event_id={event_id}, message={message}") # noqa: T201 + print(f" Confirmed via: {confirmed_via}") # noqa: T201 + + # Simulate invitation results with some failures + result = { + "success": True, + "summary": "", + "details": { + "event_id": event_id, + "total_invited": 0, + "successful": [], + "failed": [], + "ooo": [] + } + } + + for email in emails: + if email in EMPLOYEES_DB: + employee = EMPLOYEES_DB[email] + if employee["status"] == "ooo": + result["details"]["ooo"].append({ + "email": email, + "name": employee["name"], + "auto_reply": employee.get("auto_reply", "Out of office"), + "until": employee.get("ooo_until") + }) + else: + result["details"]["successful"].append({ + "email": email, + "name": employee["name"], + "status": "invited" + }) + result["details"]["total_invited"] += 1 + else: + result["details"]["failed"].append({ + "email": email, + "reason": "Email address not found in directory" + }) + + success_count = len(result["details"]["successful"]) + ooo_count = len(result["details"]["ooo"]) + fail_count = len(result["details"]["failed"]) + + result["summary"] = f"āœ‰ļø Invitation results: {success_count} sent" + if ooo_count > 0: + result["summary"] += f", {ooo_count} out of office" + if fail_count > 0: + result["summary"] += f", {fail_count} failed" + result["success"] = False + + return json.dumps(result, indent=2) + + +def delete_event(event_id: str, reason: str = "", context: AgentRunContext | None = None) -> str: + """ + Delete a calendar event. Requires confirmation. + + Args: + event_id: ID of the event to delete + reason: Optional reason for deletion + context: Agent run context (automatically injected) + """ + import datetime + + timestamp = datetime.datetime.now().isoformat() + confirmed_via = "button" if (context and context.confirmed_tools) else "natural language" + + print(f"šŸŽÆ TOOL EXECUTED: delete_event at {timestamp}") # noqa: T201 + print(f" Args: event_id={event_id}, reason={reason}") # noqa: T201 + print(f" Confirmed via: {confirmed_via}") # noqa: T201 + + result = { + "success": False, + "summary": "", + "details": {"event_id": event_id, "reason": reason} + } + + if event_id in MEETINGS_DB: + meeting = MEETINGS_DB[event_id] + result["success"] = True + result["summary"] = f"šŸ—‘ļø Successfully deleted event '{meeting['title']}' on {meeting['date']}" + result["details"]["deleted_meeting"] = meeting + # Actually delete from mock DB + del MEETINGS_DB[event_id] + else: + result["summary"] = f"āŒ Event {event_id} not found" + result["details"]["error"] = "Event not found in calendar" + + return json.dumps(result, indent=2) + + +def schedule_meeting(title: str, date: str, time: str, attendees: list[str], context: AgentRunContext | None = None) -> str: + """ + Schedule a new meeting. Requires confirmation. + + Args: + title: Title of the meeting + date: Date of the meeting (YYYY-MM-DD) + time: Time of the meeting (HH:MM) + attendees: List of attendee email addresses + context: Agent run context (automatically injected) + """ + import datetime + + timestamp = datetime.datetime.now().isoformat() + confirmed_via = "button" if (context and context.confirmed_tools) else "natural language" + + print(f"šŸŽÆ TOOL EXECUTED: schedule_meeting at {timestamp}") # noqa: T201 + print(f" Args: title={title}, date={date}, time={time}, attendees={attendees}") # noqa: T201 + print(f" Confirmed via: {confirmed_via}") # noqa: T201 + + # Generate new meeting ID + meeting_id = f"meeting_{len(MEETINGS_DB) + 1:03d}" + + # Add to mock DB + MEETINGS_DB[meeting_id] = { + "title": title, + "date": date, + "time": time, + "attendees": attendees + } + + result = { + "success": True, + "summary": f"šŸ“… Meeting '{title}' scheduled for {date} at {time}", + "details": { + "meeting_id": meeting_id, + "title": title, + "date": date, + "time": time, + "attendees": attendees, + "attendee_count": len(attendees) + } + } + + return json.dumps(result, indent=2) + + +def cancel_meeting(meeting_id: str, notify: bool = True, context: AgentRunContext | None = None) -> str: + """ + Cancel a meeting (same as delete but with notification option). Requires confirmation. + + Args: + meeting_id: ID of the meeting to cancel + notify: Whether to notify attendees + context: Agent run context (automatically injected) + """ + import datetime + + timestamp = datetime.datetime.now().isoformat() + confirmed_via = "button" if (context and context.confirmed_tools) else "natural language" + + print(f"šŸŽÆ TOOL EXECUTED: cancel_meeting at {timestamp}") # noqa: T201 + print(f" Args: meeting_id={meeting_id}, notify={notify}") # noqa: T201 + print(f" Confirmed via: {confirmed_via}") # noqa: T201 + + result = { + "success": False, + "summary": "", + "details": {"meeting_id": meeting_id, "notify": notify} + } + + if meeting_id in MEETINGS_DB: + meeting = MEETINGS_DB[meeting_id] + result["success"] = True + result["summary"] = f"āŒ Canceled meeting '{meeting['title']}' on {meeting['date']}" + if notify and meeting["attendees"]: + result["summary"] += f" (notified {len(meeting['attendees'])} attendees)" + result["details"]["canceled_meeting"] = meeting + result["details"]["attendees_notified"] = meeting["attendees"] if notify else [] + # Actually delete from mock DB + del MEETINGS_DB[meeting_id] + else: + result["summary"] = f"āŒ Meeting {meeting_id} not found" + result["details"]["error"] = "Meeting not found in calendar" + + return json.dumps(result, indent=2) + + +def reschedule_meeting(meeting_id: str, new_date: str, new_time: str, context: AgentRunContext | None = None) -> str: + """ + Reschedule an existing meeting. Requires confirmation. + + Args: + meeting_id: ID of the meeting to reschedule + new_date: New date (YYYY-MM-DD) + new_time: New time (HH:MM) + context: Agent run context (automatically injected) + """ + import datetime + + timestamp = datetime.datetime.now().isoformat() + confirmed_via = "button" if (context and context.confirmed_tools) else "natural language" + + print(f"šŸŽÆ TOOL EXECUTED: reschedule_meeting at {timestamp}") # noqa: T201 + print(f" Args: meeting_id={meeting_id}, new_date={new_date}, new_time={new_time}") # noqa: T201 + print(f" Confirmed via: {confirmed_via}") # noqa: T201 + + result = { + "success": False, + "summary": "", + "details": {"meeting_id": meeting_id, "new_date": new_date, "new_time": new_time} + } + + if meeting_id in MEETINGS_DB: + meeting = MEETINGS_DB[meeting_id] + old_date = meeting["date"] + old_time = meeting["time"] + + # Update meeting + MEETINGS_DB[meeting_id]["date"] = new_date + MEETINGS_DB[meeting_id]["time"] = new_time + + result["success"] = True + result["summary"] = f"šŸ”„ Rescheduled '{meeting['title']}' from {old_date} {old_time} to {new_date} {new_time}" + result["details"]["old_date"] = old_date + result["details"]["old_time"] = old_time + result["details"]["updated_meeting"] = MEETINGS_DB[meeting_id] + else: + result["summary"] = f"āŒ Meeting {meeting_id} not found" + result["details"]["error"] = "Meeting not found in calendar" + + return json.dumps(result, indent=2) + + +def send_reminder(meeting_id: str, attendees: list[str] | None = None, context: AgentRunContext | None = None) -> str: + """ + Send a reminder for a meeting. Requires confirmation. + + Args: + meeting_id: ID of the meeting + attendees: Specific attendees to remind (None = all attendees) + context: Agent run context (automatically injected) + """ + import datetime + + timestamp = datetime.datetime.now().isoformat() + confirmed_via = "button" if (context and context.confirmed_tools) else "natural language" + + print(f"šŸŽÆ TOOL EXECUTED: send_reminder at {timestamp}") # noqa: T201 + print(f" Args: meeting_id={meeting_id}, attendees={attendees}") # noqa: T201 + print(f" Confirmed via: {confirmed_via}") # noqa: T201 + + result = { + "success": False, + "summary": "", + "details": {"meeting_id": meeting_id} + } + + if meeting_id in MEETINGS_DB: + meeting = MEETINGS_DB[meeting_id] + target_attendees = attendees if attendees else meeting["attendees"] + + result["success"] = True + result["summary"] = f"šŸ”” Sent reminder for '{meeting['title']}' to {len(target_attendees)} attendees" + result["details"]["meeting_title"] = meeting["title"] + result["details"]["date"] = meeting["date"] + result["details"]["time"] = meeting["time"] + result["details"]["reminded"] = target_attendees + else: + result["summary"] = f"āŒ Meeting {meeting_id} not found" + result["details"]["error"] = "Meeting not found in calendar" + + return json.dumps(result, indent=2) + + +class CalendarChat(ChatInterface): + """Calendar agent with confirmation for destructive actions.""" + + ui_customization = UICustomization( + header=HeaderCustomization( + title="Calendar Assistant", subtitle="with confirmation for important actions", logo="šŸ“…" + ), + welcome_message=( + "Hello! I'm your calendar assistant.\n\n" + "I can help you manage your calendar:\n" + "- View and analyze your schedule\n" + "- Schedule new meetings\n" + "- Invite people to events\n" + "- Reschedule or cancel meetings\n" + "- Check availability\n\n" + "I'll ask for confirmation before making any changes." + ), + ) + + conversation_history = True + show_usage = True + + def __init__(self) -> None: + self.llm = LiteLLM(model_name="gpt-4o-mini") + + # Define tools for the agent + self.tools = [ + # Read-only tools (no confirmation) + analyze_calendar, + get_meetings, + list_meetings, + get_availability, + # Destructive tools (require confirmation) + schedule_meeting, + invite_people, + delete_event, + cancel_meeting, + reschedule_meeting, + send_reminder, + ] + + async def chat( + self, + message: str, + history: ChatFormat, + context: ChatContext, + ) -> AsyncGenerator[ChatResponse, None]: + """ + Chat implementation with non-blocking confirmation support. + + The agent will check context.confirmed_tools for any confirmations. + If a tool needs confirmation but hasn't been confirmed yet, it will + yield a ConfirmationRequest and exit. The frontend will then send a + new request with the confirmation in context.confirmed_tools. + """ + # Create agent with history passed explicitly + agent: Agent = Agent( + llm=self.llm, + prompt=""" + You are a helpful calendar assistant. Help users manage their calendar by: + - Analyzing their schedule and providing insights + - Showing meetings for specific dates or ranges + - Checking availability of attendees before scheduling + - Scheduling new meetings + - Inviting people to events + - Rescheduling or canceling meetings + - Sending reminders + + Important guidelines: + 1. Always check availability before scheduling or inviting people + 2. When tool results contain structured data (JSON), analyze it carefully + 3. If some operations fail (e.g., OOO auto-replies), explain what happened + 4. Suggest next actions based on tool results + 5. Handle partial failures gracefully and offer solutions + 6. Parse tool results thoroughly - they contain detailed information about successes and failures + + When analyzing tool results: + - Look for "success", "ooo", "failed" fields in the results + - Explain any failures or issues to the user + - Suggest remedies for failures (e.g., retry later, use different attendees) + - If someone is out of office, tell the user when they'll be back + + Always be clear about what actions you're taking and ask for confirmation + when needed. After executing confirmed actions, analyze the results and + provide a helpful summary to the user. + """, + tools=self.tools, # type: ignore[arg-type] + history=history, + ) + + # Mark specific tools as requiring confirmation + for tool in agent.tools: + if tool.name in ["invite_people", "delete_event", "schedule_meeting", + "cancel_meeting", "reschedule_meeting", "send_reminder"]: + tool.requires_confirmation = True + + # Create agent context with confirmed_tools from the request context + agent_context: AgentRunContext = AgentRunContext() + # Pass confirmed_tools from the chat context to the agent context + # This allows the agent to check if any tools have been confirmed + if context.confirmed_tools: + agent_context.confirmed_tools = context.confirmed_tools + print(f"āœ… Set agent context with confirmed_tools: {agent_context.confirmed_tools}") # noqa: T201 + + # Run agent in streaming mode with the message and history + async for response in agent.run_streaming( + message, + context=agent_context, + ): + # Pattern match on response types + match response: + case str(): + # Regular text response + if response.strip(): + yield self.create_text_response(response) + + case ToolCall(): + # Tool is being called + yield self.create_live_update(response.id, LiveUpdateType.START, f"šŸ”§ {response.name}") + + case ConfirmationRequest(): + # Confirmation needed - send to frontend and wait for user response + # The agent has already stopped execution, so this is just informing the user + yield ChatResponse( + type=ChatResponseType.CONFIRMATION_REQUEST, + content=response, + ) + + case ToolCallResult(): + # Tool execution completed (or pending confirmation) + result_preview = str(response.result)[:50] + yield self.create_live_update( + response.id, LiveUpdateType.FINISH, f"āœ… {response.name}", result_preview + ) + + case Usage(): + # Usage information + yield self.create_usage_response(response) diff --git a/examples/chat/file_explorer_agent.py b/examples/chat/file_explorer_agent.py new file mode 100644 index 000000000..85cb1d8a8 --- /dev/null +++ b/examples/chat/file_explorer_agent.py @@ -0,0 +1,702 @@ +""" +Ragbits Chat Example: File Explorer Agent with Confirmation + +This example demonstrates a file management agent that requires user confirmation +for all destructive operations (create, delete, move files/directories). + +The agent is restricted to operating only within the temp/ directory for security. + +Features: +- List files and directories +- Read file contents +- Create/delete files (with confirmation) +- Move/rename files (with confirmation) +- Create/delete directories (with confirmation) +- Search for files +- Get file information + +Security: +- All paths are validated to be within temp/ directory +- Path traversal attacks are prevented +- Confirmation required for all destructive operations + +To run the script: + ragbits api run examples.chat.file_explorer_agent:FileExplorerChat +""" + +import json +import os +from collections.abc import AsyncGenerator +from pathlib import Path + +from ragbits.agents import Agent, ToolCallResult +from ragbits.agents._main import AgentRunContext +from ragbits.agents.confirmation import ConfirmationRequest +from ragbits.chat.interface import ChatInterface +from ragbits.chat.interface.types import ( + ChatContext, + ChatResponse, + ChatResponseType, + LiveUpdateType, +) +from ragbits.chat.interface.ui_customization import HeaderCustomization, UICustomization +from ragbits.core.llms import LiteLLM, ToolCall +from ragbits.core.llms.base import Usage +from ragbits.core.prompt import ChatFormat + +# Get the absolute path to the temp directory +TEMP_DIR = Path(__file__).parent.parent.parent / "temp" +TEMP_DIR = TEMP_DIR.resolve() + + +def _validate_path(path: str) -> tuple[bool, str, Path | None]: + """ + Validate that a path is within the temp directory. + + Args: + path: Path to validate (relative to temp/ or absolute) + + Returns: + Tuple of (is_valid, error_message, resolved_path) + """ + try: + # Handle relative paths (relative to temp/) + full_path = TEMP_DIR / path if not os.path.isabs(path) else Path(path) + + # Resolve to absolute path and check if it's within temp/ + resolved = full_path.resolve() + + # Security check: ensure path is within TEMP_DIR + if not str(resolved).startswith(str(TEMP_DIR)): + return False, "Access denied: Path outside temp/ directory", None + + return True, "", resolved + except Exception as e: + return False, f"Invalid path: {e!s}", None + + +# File Explorer Tools + + +def list_files(directory: str = "") -> str: + """ + List files and directories in a given path. + + Args: + directory: Directory path (relative to temp/, empty for root) + + Returns: + JSON string with list of files and directories + """ + is_valid, error, dir_path = _validate_path(directory) + if not is_valid: + return json.dumps({"success": False, "error": error}, indent=2) + + result = { + "success": False, + "summary": "", + "details": { + "directory": str(dir_path.relative_to(TEMP_DIR)) if dir_path != TEMP_DIR else ".", + "files": [], + "directories": [], + "total_count": 0, + }, + } + + try: + if not dir_path.exists(): + result["error"] = "Directory does not exist" + result["summary"] = f"āŒ Directory '{directory}' not found" + return json.dumps(result, indent=2) + + if not dir_path.is_dir(): + result["error"] = "Path is not a directory" + result["summary"] = f"āŒ '{directory}' is not a directory" + return json.dumps(result, indent=2) + + for item in sorted(dir_path.iterdir()): + item_info = { + "name": item.name, + "size": item.stat().st_size if item.is_file() else None, + "modified": item.stat().st_mtime, + } + + if item.is_file(): + result["details"]["files"].append(item_info) + elif item.is_dir(): + result["details"]["directories"].append(item_info) + + result["success"] = True + file_count = len(result["details"]["files"]) + dir_count = len(result["details"]["directories"]) + result["details"]["total_count"] = file_count + dir_count + result["summary"] = f"šŸ“ Found {file_count} files and {dir_count} directories" + + except Exception as e: + result["error"] = str(e) + result["summary"] = f"āŒ Error listing directory: {e!s}" + + return json.dumps(result, indent=2) + + +def read_file(filepath: str) -> str: + """ + Read contents of a file. + + Args: + filepath: Path to the file (relative to temp/) + + Returns: + JSON string with file contents + """ + is_valid, error, file_path = _validate_path(filepath) + if not is_valid: + return json.dumps({"success": False, "error": error}, indent=2) + + result = { + "success": False, + "summary": "", + "details": {"filepath": str(file_path.relative_to(TEMP_DIR)), "content": None, "size": None}, + } + + try: + if not file_path.exists(): + result["error"] = "File does not exist" + result["summary"] = f"āŒ File '{filepath}' not found" + return json.dumps(result, indent=2) + + if not file_path.is_file(): + result["error"] = "Path is not a file" + result["summary"] = f"āŒ '{filepath}' is not a file" + return json.dumps(result, indent=2) + + content = file_path.read_text() + result["success"] = True + result["details"]["content"] = content + result["details"]["size"] = len(content) + result["summary"] = f"šŸ“„ Read file '{filepath}' ({len(content)} bytes)" + + except Exception as e: + result["error"] = str(e) + result["summary"] = f"āŒ Error reading file: {e!s}" + + return json.dumps(result, indent=2) + + +def get_file_info(filepath: str) -> str: + """ + Get detailed information about a file or directory. + + Args: + filepath: Path to the file or directory (relative to temp/) + + Returns: + JSON string with file information + """ + is_valid, error, file_path = _validate_path(filepath) + if not is_valid: + return json.dumps({"success": False, "error": error}, indent=2) + + result = {"success": False, "summary": "", "details": {}} + + try: + if not file_path.exists(): + result["error"] = "Path does not exist" + result["summary"] = f"āŒ '{filepath}' not found" + return json.dumps(result, indent=2) + + stat_info = file_path.stat() + result["success"] = True + result["details"] = { + "name": file_path.name, + "path": str(file_path.relative_to(TEMP_DIR)), + "type": "file" if file_path.is_file() else "directory", + "size": stat_info.st_size, + "created": stat_info.st_ctime, + "modified": stat_info.st_mtime, + "permissions": oct(stat_info.st_mode)[-3:], + } + + item_type = "file" if file_path.is_file() else "directory" + result["summary"] = f"ā„¹ļø {item_type.capitalize()} info for '{filepath}'" + + except Exception as e: + result["error"] = str(e) + result["summary"] = f"āŒ Error getting info: {e!s}" + + return json.dumps(result, indent=2) + + +def search_files(pattern: str, directory: str = "") -> str: + """ + Search for files matching a pattern. + + Args: + pattern: Search pattern (glob style, e.g., "*.txt", "*report*") + directory: Directory to search in (relative to temp/, empty for root) + + Returns: + JSON string with search results + """ + is_valid, error, dir_path = _validate_path(directory) + if not is_valid: + return json.dumps({"success": False, "error": error}, indent=2) + + result = { + "success": False, + "summary": "", + "details": { + "pattern": pattern, + "directory": str(dir_path.relative_to(TEMP_DIR)) if dir_path != TEMP_DIR else ".", + "matches": [], + }, + } + + try: + if not dir_path.exists() or not dir_path.is_dir(): + result["error"] = "Directory does not exist" + result["summary"] = f"āŒ Directory '{directory}' not found" + return json.dumps(result, indent=2) + + # Search recursively + for match in dir_path.rglob(pattern): + if match.is_file(): + result["details"]["matches"].append( + {"path": str(match.relative_to(TEMP_DIR)), "name": match.name, "size": match.stat().st_size} + ) + + result["success"] = True + match_count = len(result["details"]["matches"]) + result["summary"] = f"šŸ” Found {match_count} file(s) matching '{pattern}'" + + except Exception as e: + result["error"] = str(e) + result["summary"] = f"āŒ Error searching: {e!s}" + + return json.dumps(result, indent=2) + + +def create_file(filepath: str, content: str, context: AgentRunContext | None = None) -> str: + """ + Create a new file with content. Requires confirmation. + + Args: + filepath: Path for the new file (relative to temp/) + content: Content to write to the file + context: Agent run context (automatically injected) + + Returns: + JSON string with operation result + """ + import datetime + + timestamp = datetime.datetime.now().isoformat() + confirmed_via = "button" if (context and context.confirmed_tools) else "natural language" + + print(f"šŸŽÆ TOOL EXECUTED: create_file at {timestamp}") # noqa: T201 + print(f" Args: filepath={filepath}, content_length={len(content)}") # noqa: T201 + print(f" Confirmed via: {confirmed_via}") # noqa: T201 + + is_valid, error, file_path = _validate_path(filepath) + if not is_valid: + return json.dumps({"success": False, "error": error}, indent=2) + + result = { + "success": False, + "summary": "", + "details": {"filepath": str(file_path.relative_to(TEMP_DIR)), "size": len(content)}, + } + + try: + if file_path.exists(): + result["error"] = "File already exists" + result["summary"] = f"āŒ File '{filepath}' already exists" + return json.dumps(result, indent=2) + + # Create parent directories if needed + file_path.parent.mkdir(parents=True, exist_ok=True) + + file_path.write_text(content) + result["success"] = True + result["summary"] = f"āœ… Created file '{filepath}' ({len(content)} bytes)" + + except Exception as e: + result["error"] = str(e) + result["summary"] = f"āŒ Error creating file: {e!s}" + + return json.dumps(result, indent=2) + + +def delete_file(filepath: str, context: AgentRunContext | None = None) -> str: + """ + Delete a file. Requires confirmation. + + Args: + filepath: Path to the file to delete (relative to temp/) + context: Agent run context (automatically injected) + + Returns: + JSON string with operation result + """ + import datetime + + timestamp = datetime.datetime.now().isoformat() + confirmed_via = "button" if (context and context.confirmed_tools) else "natural language" + + print(f"šŸŽÆ TOOL EXECUTED: delete_file at {timestamp}") # noqa: T201 + print(f" Args: filepath={filepath}") # noqa: T201 + print(f" Confirmed via: {confirmed_via}") # noqa: T201 + + is_valid, error, file_path = _validate_path(filepath) + if not is_valid: + return json.dumps({"success": False, "error": error}, indent=2) + + result = {"success": False, "summary": "", "details": {"filepath": str(file_path.relative_to(TEMP_DIR))}} + + try: + if not file_path.exists(): + result["error"] = "File does not exist" + result["summary"] = f"āŒ File '{filepath}' not found" + return json.dumps(result, indent=2) + + if not file_path.is_file(): + result["error"] = "Path is not a file (use delete_directory for directories)" + result["summary"] = f"āŒ '{filepath}' is not a file" + return json.dumps(result, indent=2) + + size = file_path.stat().st_size + file_path.unlink() + result["success"] = True + result["details"]["deleted_size"] = size + result["summary"] = f"šŸ—‘ļø Deleted file '{filepath}' ({size} bytes)" + + except Exception as e: + result["error"] = str(e) + result["summary"] = f"āŒ Error deleting file: {e!s}" + + return json.dumps(result, indent=2) + + +def move_file(source: str, destination: str, context: AgentRunContext | None = None) -> str: + """ + Move or rename a file. Requires confirmation. + + Args: + source: Source file path (relative to temp/) + destination: Destination file path (relative to temp/) + context: Agent run context (automatically injected) + + Returns: + JSON string with operation result + """ + import datetime + + timestamp = datetime.datetime.now().isoformat() + confirmed_via = "button" if (context and context.confirmed_tools) else "natural language" + + print(f"šŸŽÆ TOOL EXECUTED: move_file at {timestamp}") # noqa: T201 + print(f" Args: source={source}, destination={destination}") # noqa: T201 + print(f" Confirmed via: {confirmed_via}") # noqa: T201 + + # Validate both paths + is_valid_src, error_src, src_path = _validate_path(source) + is_valid_dst, error_dst, dst_path = _validate_path(destination) + + if not is_valid_src: + return json.dumps({"success": False, "error": f"Source: {error_src}"}, indent=2) + if not is_valid_dst: + return json.dumps({"success": False, "error": f"Destination: {error_dst}"}, indent=2) + + result = { + "success": False, + "summary": "", + "details": {"source": str(src_path.relative_to(TEMP_DIR)), "destination": str(dst_path.relative_to(TEMP_DIR))}, + } + + try: + if not src_path.exists(): + result["error"] = "Source file does not exist" + result["summary"] = f"āŒ Source file '{source}' not found" + return json.dumps(result, indent=2) + + if not src_path.is_file(): + result["error"] = "Source is not a file" + result["summary"] = f"āŒ '{source}' is not a file" + return json.dumps(result, indent=2) + + if dst_path.exists(): + result["error"] = "Destination already exists" + result["summary"] = f"āŒ Destination '{destination}' already exists" + return json.dumps(result, indent=2) + + # Create destination parent directories if needed + dst_path.parent.mkdir(parents=True, exist_ok=True) + + src_path.rename(dst_path) + result["success"] = True + result["summary"] = f"šŸ”€ Moved '{source}' to '{destination}'" + + except Exception as e: + result["error"] = str(e) + result["summary"] = f"āŒ Error moving file: {e!s}" + + return json.dumps(result, indent=2) + + +def create_directory(dirpath: str, context: AgentRunContext | None = None) -> str: + """ + Create a new directory. Requires confirmation. + + Args: + dirpath: Path for the new directory (relative to temp/) + context: Agent run context (automatically injected) + + Returns: + JSON string with operation result + """ + import datetime + + timestamp = datetime.datetime.now().isoformat() + confirmed_via = "button" if (context and context.confirmed_tools) else "natural language" + + print(f"šŸŽÆ TOOL EXECUTED: create_directory at {timestamp}") # noqa: T201 + print(f" Args: dirpath={dirpath}") # noqa: T201 + print(f" Confirmed via: {confirmed_via}") # noqa: T201 + + is_valid, error, dir_path = _validate_path(dirpath) + if not is_valid: + return json.dumps({"success": False, "error": error}, indent=2) + + result = {"success": False, "summary": "", "details": {"dirpath": str(dir_path.relative_to(TEMP_DIR))}} + + try: + if dir_path.exists(): + result["error"] = "Directory already exists" + result["summary"] = f"āŒ Directory '{dirpath}' already exists" + return json.dumps(result, indent=2) + + dir_path.mkdir(parents=True, exist_ok=False) + result["success"] = True + result["summary"] = f"šŸ“ Created directory '{dirpath}'" + + except Exception as e: + result["error"] = str(e) + result["summary"] = f"āŒ Error creating directory: {e!s}" + + return json.dumps(result, indent=2) + + +def delete_directory(dirpath: str, context: AgentRunContext | None = None) -> str: + """ + Delete an empty directory. Requires confirmation. + + Args: + dirpath: Path to the directory to delete (relative to temp/) + context: Agent run context (automatically injected) + + Returns: + JSON string with operation result + """ + import datetime + + timestamp = datetime.datetime.now().isoformat() + confirmed_via = "button" if (context and context.confirmed_tools) else "natural language" + + print(f"šŸŽÆ TOOL EXECUTED: delete_directory at {timestamp}") # noqa: T201 + print(f" Args: dirpath={dirpath}") # noqa: T201 + print(f" Confirmed via: {confirmed_via}") # noqa: T201 + + is_valid, error, dir_path = _validate_path(dirpath) + if not is_valid: + return json.dumps({"success": False, "error": error}, indent=2) + + result = {"success": False, "summary": "", "details": {"dirpath": str(dir_path.relative_to(TEMP_DIR))}} + + try: + if not dir_path.exists(): + result["error"] = "Directory does not exist" + result["summary"] = f"āŒ Directory '{dirpath}' not found" + return json.dumps(result, indent=2) + + if not dir_path.is_dir(): + result["error"] = "Path is not a directory" + result["summary"] = f"āŒ '{dirpath}' is not a directory" + return json.dumps(result, indent=2) + + # Check if directory is empty + if any(dir_path.iterdir()): + result["error"] = "Directory is not empty" + result["summary"] = f"āŒ Directory '{dirpath}' is not empty. Delete contents first." + return json.dumps(result, indent=2) + + dir_path.rmdir() + result["success"] = True + result["summary"] = f"šŸ—‘ļø Deleted directory '{dirpath}'" + + except Exception as e: + result["error"] = str(e) + result["summary"] = f"āŒ Error deleting directory: {e!s}" + + return json.dumps(result, indent=2) + + +class FileExplorerChat(ChatInterface): + """File explorer agent with confirmation for destructive actions.""" + + ui_customization = UICustomization( + header=HeaderCustomization( + title="File Explorer Agent", subtitle="secure file management with confirmation", logo="šŸ“‚" + ), + welcome_message=( + "Hello! I'm your file explorer agent.\n\n" + "I can help you manage files in the temp/ directory:\n" + "- List and search files\n" + "- Read file contents\n" + "- Create, delete, and move files\n" + "- Manage directories\n\n" + "Security: All operations are restricted to the temp/ directory.\n" + "I'll ask for confirmation before making any changes." + ), + ) + + conversation_history = True + show_usage = True + + def __init__(self) -> None: + self.llm = LiteLLM(model_name="gpt-4o-mini") + + # Define tools for the agent + self.tools = [ + # Read-only tools (no confirmation) + list_files, + read_file, + get_file_info, + search_files, + # Destructive tools (require confirmation) + create_file, + delete_file, + move_file, + create_directory, + delete_directory, + ] + + async def chat( # noqa: PLR0912 + self, + message: str, + history: ChatFormat, + context: ChatContext, + ) -> AsyncGenerator[ChatResponse, None]: + """ + Chat implementation with non-blocking confirmation support. + + The agent will check context.confirmed_tools for any confirmations. + If a tool needs confirmation but hasn't been confirmed yet, it will + yield a ConfirmationRequest and exit. The frontend will then send a + new request with the confirmation in context.confirmed_tools. + """ + # Create agent with history passed explicitly + print(f"šŸ” Creating agent with {len(self.tools)} tools: {[t.__name__ for t in self.tools]}") # noqa: T201 + agent: Agent = Agent( + llm=self.llm, + prompt=f""" + You are a file explorer agent. You have tools available. + + CRITICAL: When a user asks you to perform an action, you MUST IMMEDIATELY CALL THE APPROPRIATE TOOL. + DO NOT ask for permission in text - the system will automatically show a confirmation dialog. + DO NOT describe what you would do - USE THE TOOL RIGHT AWAY. + + Example: + User: "Create folders test1 and test2" + CORRECT: Immediately call create_directory("test1"), then create_directory("test2") + WRONG: "I'll create two folders for you. Shall I proceed?" + + If an action needs confirmation, the system handles it automatically. + Your job is to CALL THE TOOLS, not talk about calling them. + + Available tools: {', '.join([t.__name__ for t in self.tools])} + Restricted to: {TEMP_DIR} + """, + tools=self.tools, # type: ignore[arg-type] + history=history, + ) + print(f"šŸ” Agent created with {len(agent.tools)} tools") # noqa: T201 + + # Mark specific tools as requiring confirmation + for tool in agent.tools: + if tool.name in ["create_file", "delete_file", "move_file", "create_directory", "delete_directory"]: + tool.requires_confirmation = True + print(f"āœ… Marked tool '{tool.name}' as requiring confirmation") # noqa: T201 + + # Create agent context with confirmed_tools from the request context + agent_context: AgentRunContext = AgentRunContext() + + # Check if user declined any confirmations + has_declined = False + confirmed_count = 0 + declined_count = 0 + if context.confirmed_tools: + agent_context.confirmed_tools = context.confirmed_tools + print(f"āœ… Set agent context with confirmed_tools: {agent_context.confirmed_tools}") # noqa: T201 + + for ct in context.confirmed_tools: + if ct.get("confirmed"): + confirmed_count += 1 + else: + declined_count += 1 + has_declined = True + + # Prepare message for agent based on confirmation status + agent_message = message + if has_declined: + # User declined some actions - inform the agent with details + if confirmed_count > 0: + agent_message = ( + f"[SYSTEM: The user confirmed {confirmed_count} action(s) and " + f"declined {declined_count} action(s). Proceed with only the confirmed " + "actions and report the results. Do NOT ask for confirmation again on declined actions.]" + ) + else: + agent_message = ( + "[SYSTEM: The user declined all actions. Acknowledge this and " + "ask if they want to do something else instead.]" + ) + + # Run agent in streaming mode with the message and history + print("šŸš€ Starting agent.run_streaming...") # noqa: T201 + async for response in agent.run_streaming( + agent_message, + context=agent_context, + ): + # Debug: Log what we're receiving from agent + print(f"šŸ” Agent yielded: {type(response).__name__}", flush=True) # noqa: T201 + + # Pattern match on response types + match response: + case str(): + # Regular text response + if response.strip(): + yield self.create_text_response(response) + + case ToolCall(): + # Tool is being called + print(f"šŸ”§ ToolCall: {response.name}") # noqa: T201 + yield self.create_live_update(response.id, LiveUpdateType.START, f"šŸ”§ {response.name}") + + case ConfirmationRequest(): + # Confirmation needed - send to frontend and wait for user response + print(f"āš ļø ConfirmationRequest received for tool: {response.tool_name}") # noqa: T201 + yield ChatResponse( + type=ChatResponseType.CONFIRMATION_REQUEST, + content=response, + ) + + case ToolCallResult(): + # Tool execution completed (or pending confirmation) + result_preview = str(response.result)[:50] + yield self.create_live_update( + response.id, LiveUpdateType.FINISH, f"āœ… {response.name}", result_preview + ) + + case Usage(): + # Usage information + yield self.create_usage_response(response) diff --git a/packages/ragbits-agents/src/ragbits/agents/_main.py b/packages/ragbits-agents/src/ragbits/agents/_main.py index 43783c1c4..4142fd601 100644 --- a/packages/ragbits-agents/src/ragbits/agents/_main.py +++ b/packages/ragbits-agents/src/ragbits/agents/_main.py @@ -1,4 +1,5 @@ import asyncio +import json import types import uuid from collections.abc import AsyncGenerator, AsyncIterator, Callable @@ -14,10 +15,12 @@ from pydantic import ( BaseModel, Field, + PrivateAttr, ) from typing_extensions import Self from ragbits import agents +from ragbits.agents.confirmation import ConfirmationRequest from ragbits.agents.exceptions import ( AgentInvalidPostProcessorError, AgentInvalidPromptInputError, @@ -78,6 +81,7 @@ class DownstreamAgentResult: str, ToolCall, ToolCallResult, + ConfirmationRequest, "DownstreamAgentResult", BasePrompt, Usage, @@ -144,23 +148,27 @@ class AgentDependencies(BaseModel, Generic[DepsT]): model_config = {"arbitrary_types_allowed": True} - _frozen: bool - _value: DepsT | None + _frozen: bool = PrivateAttr(default=False) + _value: DepsT | None = PrivateAttr(default=None) - def __init__(self, value: DepsT | None = None) -> None: - super().__init__() + def __init__(self, value: DepsT | None = None, **data) -> None: # type: ignore[no-untyped-def] + super().__init__(**data) self._value = value self._frozen = False def __setattr__(self, name: str, value: object) -> None: - is_frozen = False - if name != "_frozen": - try: - is_frozen = object.__getattribute__(self, "_frozen") - except AttributeError: - is_frozen = False + # Check if we're frozen, but allow setting private attributes during init + if name in ("_frozen", "_value"): + super().__setattr__(name, value) + return + + try: + pydantic_private = object.__getattribute__(self, "__pydantic_private__") + is_frozen = pydantic_private.get("_frozen", False) + except AttributeError: + is_frozen = False - if is_frozen and name not in {"_frozen"}: + if is_frozen: raise RuntimeError("Dependencies are immutable after first access") super().__setattr__(name, value) @@ -171,23 +179,34 @@ def value(self) -> DepsT | None: @value.setter def value(self, value: DepsT) -> None: - if self._frozen: + # Access _frozen from __pydantic_private__ to avoid recursion + pydantic_private = object.__getattribute__(self, "__pydantic_private__") + if pydantic_private.get("_frozen"): raise RuntimeError("Dependencies are immutable after first access") - self._value = value + pydantic_private["_value"] = value def _freeze(self) -> None: - if not self._frozen: - self._frozen = True + # Access _frozen from __pydantic_private__ to avoid recursion + pydantic_private = object.__getattribute__(self, "__pydantic_private__") + if not pydantic_private.get("_frozen"): + pydantic_private["_frozen"] = True def __getattr__(self, name: str) -> object: - value = object.__getattribute__(self, "_value") + # Access _value from __pydantic_private__ to avoid recursion + pydantic_private = object.__getattribute__(self, "__pydantic_private__") + value = pydantic_private.get("_value") if value is None: raise AttributeError(name) self._freeze() return getattr(value, name) def __contains__(self, key: str) -> bool: - value = object.__getattribute__(self, "_value") + # Access _value from __pydantic_private__ to avoid recursion + try: + pydantic_private = object.__getattribute__(self, "__pydantic_private__") + value = pydantic_private.get("_value") + except AttributeError: + return False return hasattr(value, key) if value is not None else False @@ -204,6 +223,8 @@ class AgentRunContext(BaseModel, Generic[DepsT]): """Whether to stream events from downstream agents when tools execute other agents.""" downstream_agents: dict[str, "Agent"] = Field(default_factory=dict) """Registry of all agents that participated in this run""" + confirmed_tools: list[dict[str, Any]] | None = None + """List of tools that have been confirmed for execution (for confirmation workflow).""" def register_agent(self, agent: "Agent") -> None: """ @@ -228,7 +249,16 @@ def get_agent(self, agent_id: str) -> "Agent | None": class AgentResultStreaming( - AsyncIterator[str | ToolCall | ToolCallResult | BasePrompt | Usage | SimpleNamespace | DownstreamAgentResult] + AsyncIterator[ + str + | ToolCall + | ToolCallResult + | ConfirmationRequest + | BasePrompt + | Usage + | SimpleNamespace + | DownstreamAgentResult + ] ): """ An async iterator that will collect all yielded items by LLM.generate_streaming(). This object is returned @@ -239,7 +269,15 @@ class AgentResultStreaming( def __init__( self, generator: AsyncGenerator[ - str | ToolCall | ToolCallResult | DownstreamAgentResult | SimpleNamespace | BasePrompt | Usage + str + | ToolCall + | ToolCallResult + | ConfirmationRequest + | DownstreamAgentResult + | SimpleNamespace + | BasePrompt + | Usage, + None, ], ): self._generator = generator @@ -252,12 +290,30 @@ def __init__( def __aiter__( self, - ) -> AsyncIterator[str | ToolCall | ToolCallResult | BasePrompt | Usage | SimpleNamespace | DownstreamAgentResult]: + ) -> AsyncIterator[ + str + | ToolCall + | ToolCallResult + | ConfirmationRequest + | BasePrompt + | Usage + | SimpleNamespace + | DownstreamAgentResult + ]: return self - async def __anext__( + async def __anext__( # noqa: PLR0912 self, - ) -> str | ToolCall | ToolCallResult | BasePrompt | Usage | SimpleNamespace | DownstreamAgentResult: + ) -> ( + str + | ToolCall + | ToolCallResult + | ConfirmationRequest + | BasePrompt + | Usage + | SimpleNamespace + | DownstreamAgentResult + ): try: item = await self._generator.__anext__() @@ -270,6 +326,9 @@ async def __anext__( if self.tool_calls is None: self.tool_calls = [] self.tool_calls.append(item) + case ConfirmationRequest(): + # Pass through confirmation requests to the caller + pass case DownstreamAgentResult(): if item.agent_id not in self.downstream: self.downstream[item.agent_id] = [] @@ -628,7 +687,17 @@ async def _stream_internal( # noqa: PLR0912 options: AgentOptions[LLMClientOptionsT] | None = None, context: AgentRunContext | None = None, tool_choice: ToolChoice | None = None, - ) -> AsyncGenerator[str | ToolCall | ToolCallResult | DownstreamAgentResult | SimpleNamespace | BasePrompt | Usage]: + ) -> AsyncGenerator[ + str + | ToolCall + | ToolCallResult + | DownstreamAgentResult + | ConfirmationRequest + | SimpleNamespace + | BasePrompt + | Usage, + None, + ]: if context is None: context = AgentRunContext() @@ -665,13 +734,23 @@ async def _stream_internal( # noqa: PLR0912 tool_chunks.append(chunk) if len(tool_chunks) > 0: + has_pending_confirmation = False async for result in self._execute_tool_calls( tool_chunks, tools_mapping, context, merged_options.parallel_tool_calling ): yield result - if isinstance(result, ToolCallResult): + if isinstance(result, ConfirmationRequest): + # Mark that we have a pending confirmation + has_pending_confirmation = True + elif isinstance(result, ToolCallResult): prompt_with_history = prompt_with_history.add_tool_use_message(**result.__dict__) - returned_tool_call = True + returned_tool_call = True + + # If we have pending confirmations, stop the agent loop + # The agent should not continue until the user confirms/declines + if has_pending_confirmation: + print("šŸ›‘ Pending confirmations detected, stopping agent loop", flush=True) # noqa: T201 + break turn_count += 1 if streaming_result.usage: @@ -692,13 +771,13 @@ async def _stream_internal( # noqa: PLR0912 yield outputs - async def _execute_tool_calls( + async def _execute_tool_calls( # noqa: PLR0912 self, tool_calls: list[ToolCall], tools_mapping: dict[str, Tool], context: AgentRunContext, parallel_tool_calling: bool, - ) -> AsyncGenerator[ToolCallResult | DownstreamAgentResult, None]: + ) -> AsyncGenerator[ToolCallResult | DownstreamAgentResult | ConfirmationRequest, None]: """Execute tool calls either in parallel or sequentially based on `parallel_tool_calling` value.""" if parallel_tool_calling: queue: asyncio.Queue = asyncio.Queue() @@ -722,11 +801,42 @@ async def monitor() -> None: yield item else: + # Collect all confirmation requests before yielding any + # This ensures the user sees all confirmations at once + confirmation_requests: list[ConfirmationRequest] = [] + pending_results: list[ToolCallResult] = [] + for tool_call in tool_calls: + tool_outputs: list[ToolCallResult | DownstreamAgentResult | ConfirmationRequest] = [] async for result in self._execute_tool( tool_call=tool_call, tools_mapping=tools_mapping, context=context ): + tool_outputs.append(result) + + # Check if this tool needs confirmation + has_confirmation = any(isinstance(r, ConfirmationRequest) for r in tool_outputs) + + if has_confirmation: + # Collect confirmations and pending results, don't yield yet + for output in tool_outputs: + if isinstance(output, ConfirmationRequest): + confirmation_requests.append(output) + elif isinstance(output, ToolCallResult) and output.result == "ā³ Awaiting user confirmation": + pending_results.append(output) + else: + # No confirmation needed, yield results immediately + for output in tool_outputs: + yield output + + # After processing all tools, yield all confirmations at once + if confirmation_requests: + print(f"šŸŽÆ Yielding {len(confirmation_requests)} confirmations in batch", flush=True) # noqa: T201 + for conf in confirmation_requests: + yield conf + print(f"šŸ“‹ Yielding {len(pending_results)} pending results", flush=True) # noqa: T201 + for result in pending_results: yield result + print("āœ… Finished yielding all confirmations and results, agent should pause now", flush=True) # noqa: T201 @staticmethod def _check_token_limits( @@ -838,12 +948,12 @@ async def _get_all_tools(self) -> dict[str, Tool]: return tools_mapping - async def _execute_tool( + async def _execute_tool( # noqa: PLR0912, PLR0915 self, tool_call: ToolCall, tools_mapping: dict[str, Tool], context: AgentRunContext, - ) -> AsyncGenerator[ToolCallResult | DownstreamAgentResult, None]: + ) -> AsyncGenerator[ToolCallResult | DownstreamAgentResult | ConfirmationRequest, None]: if tool_call.type != "function": raise AgentToolNotSupportedError(tool_call.type) if tool_call.name not in tools_mapping: @@ -851,6 +961,58 @@ async def _execute_tool( tool = tools_mapping[tool_call.name] + # Check if tool requires confirmation + if tool.requires_confirmation: + # Check if this tool has been confirmed in the context + confirmed_tools = context.confirmed_tools or [] + + # Generate a stable confirmation ID based on tool name and arguments + import hashlib + + confirmation_id = hashlib.sha256( + f"{tool_call.name}:{json.dumps(tool_call.arguments, sort_keys=True)}".encode() + ).hexdigest()[:16] + + # Check if this specific tool call has been confirmed or declined + is_confirmed = any( + ct.get("confirmation_id") == confirmation_id and ct.get("confirmed") for ct in confirmed_tools + ) + is_declined = any( + ct.get("confirmation_id") == confirmation_id and not ct.get("confirmed", True) for ct in confirmed_tools + ) + + if is_declined: + # Tool was explicitly declined - skip execution entirely + print(f"ā­ļø Tool {tool_call.name} was declined, skipping", flush=True) # noqa: T201 + yield ToolCallResult( + id=tool_call.id, + name=tool_call.name, + arguments=tool_call.arguments, + result="āŒ Action declined by user", + ) + return + + if not is_confirmed: + # Tool not confirmed yet - create and yield confirmation request + request = ConfirmationRequest( + confirmation_id=confirmation_id, + tool_name=tool_call.name, + tool_description=tool.description or "", + arguments=tool_call.arguments, + ) + + # Yield confirmation request (will be streamed to frontend) + yield request + + # Yield a pending result and exit without executing + yield ToolCallResult( + id=tool_call.id, + name=tool_call.name, + arguments=tool_call.arguments, + result="ā³ Awaiting user confirmation", + ) + return + with trace(agent_id=self.id, tool_name=tool_call.name, tool_arguments=tool_call.arguments) as outputs: try: call_args = tool_call.arguments.copy() diff --git a/packages/ragbits-agents/src/ragbits/agents/confirmation.py b/packages/ragbits-agents/src/ragbits/agents/confirmation.py new file mode 100644 index 000000000..6cd0483b4 --- /dev/null +++ b/packages/ragbits-agents/src/ragbits/agents/confirmation.py @@ -0,0 +1,22 @@ +""" +Tool confirmation functionality for agents. + +This module provides the ability to request user confirmation before executing certain tools. +""" + +from typing import Any + +from pydantic import BaseModel + + +class ConfirmationRequest(BaseModel): + """Represents a tool confirmation request sent to the user.""" + + confirmation_id: str + """Unique identifier for this confirmation request.""" + tool_name: str + """Name of the tool requiring confirmation.""" + tool_description: str + """Description of what the tool does.""" + arguments: dict[str, Any] + """Arguments that will be passed to the tool.""" diff --git a/packages/ragbits-agents/src/ragbits/agents/tool.py b/packages/ragbits-agents/src/ragbits/agents/tool.py index c8e279f29..ec6e387df 100644 --- a/packages/ragbits-agents/src/ragbits/agents/tool.py +++ b/packages/ragbits-agents/src/ragbits/agents/tool.py @@ -51,14 +51,17 @@ class Tool: context_var_name: str | None = None """The name of the context variable that this tool accepts.""" id: str | None = None + requires_confirmation: bool = False + """Whether this tool requires user confirmation before execution.""" @classmethod - def from_callable(cls, callable: Callable) -> Self: + def from_callable(cls, callable: Callable, requires_confirmation: bool = False) -> Self: """ Create a Tool instance from a callable function. Args: callable: The function to convert into a Tool + requires_confirmation: Whether this tool requires user confirmation before execution Returns: A new Tool instance representing the callable function. @@ -71,6 +74,7 @@ def from_callable(cls, callable: Callable) -> Self: parameters=schema["function"]["parameters"], on_tool_call=callable, context_var_name=get_context_variable_name(callable), + requires_confirmation=requires_confirmation, ) def to_function_schema(self) -> dict[str, Any]: diff --git a/packages/ragbits-chat/src/ragbits/chat/interface/_interface.py b/packages/ragbits-chat/src/ragbits/chat/interface/_interface.py index ad0153212..7d673656b 100644 --- a/packages/ragbits-chat/src/ragbits/chat/interface/_interface.py +++ b/packages/ragbits-chat/src/ragbits/chat/interface/_interface.py @@ -9,6 +9,7 @@ from collections.abc import AsyncGenerator, Callable from typing import Any +from ragbits.agents.confirmation import ConfirmationRequest from ragbits.agents.tools.todo import Task from ragbits.chat.interface.summary import SummaryGenerator from ragbits.chat.interface.ui_customization import UICustomization @@ -96,6 +97,7 @@ async def wrapper( responses = [] main_response = "" extra_responses = [] + pending_confirmations = [] timestamp = time.time() response_token_count = 0.0 first_token_time = None @@ -119,6 +121,12 @@ async def wrapper( main_response = main_response + response.content # Rough token estimation (words * 1.3 for subword tokens) response_token_count += len(response.content.split()) * 1.3 + elif response.type == ChatResponseType.CONFIRMATION_REQUEST and isinstance( + response.content, ConfirmationRequest + ): + # Collect confirmation requests to store in message extra + pending_confirmations.append(response.content.model_dump()) + extra_responses.append(response) else: extra_responses.append(response) yield response diff --git a/packages/ragbits-chat/src/ragbits/chat/interface/types.py b/packages/ragbits-chat/src/ragbits/chat/interface/types.py index 6c465d5af..de8d61666 100644 --- a/packages/ragbits-chat/src/ragbits/chat/interface/types.py +++ b/packages/ragbits-chat/src/ragbits/chat/interface/types.py @@ -3,6 +3,7 @@ from pydantic import BaseModel, ConfigDict, Field +from ragbits.agents.confirmation import ConfirmationRequest from ragbits.agents.tools.todo import Task from ragbits.chat.auth.types import User from ragbits.chat.interface.forms import UserSettings @@ -23,6 +24,7 @@ class Message(BaseModel): role: MessageRole content: str + extra: dict[str, Any] | None = None class Reference(BaseModel): @@ -125,6 +127,17 @@ class ChatResponseType(str, Enum): CLEAR_MESSAGE = "clear_message" USAGE = "usage" TODO_ITEM = "todo_item" + CONFIRMATION_REQUEST = "confirmation_request" + CONFIRMATION_STATUS = "confirmation_status" + + +class ConfirmationStatus(BaseModel): + """Status update for a confirmation request.""" + + confirmation_id: str + """ID of the confirmation request being updated.""" + status: str # "confirmed" or "declined" + """The confirmation status.""" class ChatContext(BaseModel): @@ -135,6 +148,7 @@ class ChatContext(BaseModel): state: dict[str, Any] = Field(default_factory=dict) user: User | None = None session_id: str | None = None + confirmed_tools: list[dict[str, Any]] | None = None model_config = ConfigDict(extra="allow") @@ -153,6 +167,8 @@ class ChatResponse(BaseModel): | ChunkedContent | None | Task + | ConfirmationRequest + | ConfirmationStatus ) def as_text(self) -> str | None: @@ -235,6 +251,12 @@ def as_task(self) -> Task | None: """ return cast(Task, self.content) if self.type == ChatResponseType.TODO_ITEM else None + def as_confirmation_request(self) -> ConfirmationRequest | None: + """ + Return the content as ConfirmationRequest if this is a confirmation request, else None. + """ + return cast(ConfirmationRequest, self.content) if self.type == ChatResponseType.CONFIRMATION_REQUEST else None + def as_conversation_summary(self) -> str | None: """ Return the content as string if this is an conversation summary response, else None diff --git a/packages/ragbits-chat/src/ragbits/chat/providers/model_provider.py b/packages/ragbits-chat/src/ragbits/chat/providers/model_provider.py index b94bfe4bc..5166b93fa 100644 --- a/packages/ragbits-chat/src/ragbits/chat/providers/model_provider.py +++ b/packages/ragbits-chat/src/ragbits/chat/providers/model_provider.py @@ -10,6 +10,7 @@ from pydantic import BaseModel +from ragbits.agents.confirmation import ConfirmationRequest from ragbits.agents.tools.todo import Task, TaskStatus from ragbits.chat.interface.types import AuthType @@ -56,6 +57,7 @@ def get_models(self) -> dict[str, type[BaseModel | Enum]]: ChatResponseType, ChunkedContent, ConfigResponse, + ConfirmationStatus, FeedbackConfig, FeedbackItem, FeedbackRequest, @@ -87,6 +89,8 @@ def get_models(self) -> dict[str, type[BaseModel | Enum]]: # Core data models "ChatContext": ChatContext, "ChunkedContent": ChunkedContent, + "ConfirmationRequest": ConfirmationRequest, + "ConfirmationStatus": ConfirmationStatus, "LiveUpdate": LiveUpdate, "LiveUpdateContent": LiveUpdateContent, "Message": Message, @@ -144,6 +148,8 @@ def get_categories(self) -> dict[str, list[str]]: "core_data": [ "ChatContext", "ChunkedContent", + "ConfirmationRequest", + "ConfirmationStatus", "LiveUpdate", "LiveUpdateContent", "Message", diff --git a/scripts/generate_typescript_from_json_schema.py b/scripts/generate_typescript_from_json_schema.py index c1d7ec3b3..1770bd2e5 100644 --- a/scripts/generate_typescript_from_json_schema.py +++ b/scripts/generate_typescript_from_json_schema.py @@ -203,6 +203,8 @@ def _generate_chat_response_union_type() -> str: ("MessageUsageChatResponse", "usage", "Record"), ("TodoItemChatResonse", "todo_item", "Task"), ("ConversationSummaryResponse", "conversation_summary", "string"), + ("ConfirmationRequestChatResponse", "confirmation_request", "ConfirmationRequest"), + ("ConfirmationStatusChatResponse", "confirmation_status", "ConfirmationStatus"), ] internal_response_interfaces = [ diff --git a/typescript/@ragbits/api-client/src/autogen.types.ts b/typescript/@ragbits/api-client/src/autogen.types.ts index ebeeb03f8..83c5e48a8 100644 --- a/typescript/@ragbits/api-client/src/autogen.types.ts +++ b/typescript/@ragbits/api-client/src/autogen.types.ts @@ -4,9 +4,9 @@ * DO NOT EDIT MANUALLY */ -import type { RJSFSchema } from '@rjsf/utils' +import type { RJSFSchema } from '@rjsf/utils'; -export type TypeFrom = T[keyof T] +export type TypeFrom = T[keyof T]; /** * Represents the ChatResponseType enum @@ -25,9 +25,11 @@ export const ChatResponseType = { ClearMessage: 'clear_message', Usage: 'usage', TodoItem: 'todo_item', -} as const + ConfirmationRequest: 'confirmation_request', + ConfirmationStatus: 'confirmation_status', +} as const; -export type ChatResponseType = TypeFrom +export type ChatResponseType = TypeFrom; /** * Represents the FeedbackType enum @@ -35,9 +37,9 @@ export type ChatResponseType = TypeFrom export const FeedbackType = { Like: 'like', Dislike: 'dislike', -} as const +} as const; -export type FeedbackType = TypeFrom +export type FeedbackType = TypeFrom; /** * Represents the LiveUpdateType enum @@ -45,9 +47,9 @@ export type FeedbackType = TypeFrom export const LiveUpdateType = { Start: 'START', Finish: 'FINISH', -} as const +} as const; -export type LiveUpdateType = TypeFrom +export type LiveUpdateType = TypeFrom; /** * Represents the MessageRole enum @@ -56,9 +58,9 @@ export const MessageRole = { User: 'user', Assistant: 'assistant', System: 'system', -} as const +} as const; -export type MessageRole = TypeFrom +export type MessageRole = TypeFrom; /** * Represents the TaskStatus enum @@ -70,385 +72,407 @@ export const TaskStatus = { Failed: 'failed', Cancelled: 'cancelled', Retrying: 'retrying', -} as const +} as const; -export type TaskStatus = TypeFrom +export type TaskStatus = TypeFrom; /** * Represents the AuthType enum */ export const AuthType = { Credentials: 'credentials', -} as const +} as const; -export type AuthType = TypeFrom +export type AuthType = TypeFrom; /** * Represents the context of a chat conversation. */ export interface ChatContext { - conversation_id: string | null - message_id: string | null - state: { - [k: string]: unknown - } - user: User | null - session_id: string | null - [k: string]: unknown + conversation_id: string | null; + message_id: string | null; + state: { + [k: string]: unknown; + }; + user: User | null; + session_id: string | null; + confirmed_tools: + | { + [k: string]: unknown; + }[] + | null; + [k: string]: unknown; } /** * Represents a chunk of large content being transmitted. */ export interface ChunkedContent { - id: string - content_type: string - chunk_index: number - total_chunks: number - mime_type: string - data: string + id: string; + content_type: string; + chunk_index: number; + total_chunks: number; + mime_type: string; + data: string; +} + +/** + * Represents a tool confirmation request sent to the user. + */ +export interface ConfirmationRequest { + confirmation_id: string; + tool_name: string; + tool_description: string; + arguments: { + [k: string]: unknown; + }; +} + +/** + * Status update for a confirmation request. + */ +export interface ConfirmationStatus { + confirmation_id: string; + status: string; } /** * Represents an live update performed by an agent. */ export interface LiveUpdate { - update_id: string - type: LiveUpdateType - content: LiveUpdateContent + update_id: string; + type: LiveUpdateType; + content: LiveUpdateContent; } /** * Represents content of a live update. */ export interface LiveUpdateContent { - label: string - description: string | null + label: string; + description: string | null; } /** * Represents a single message in the conversation history. */ export interface Message { - role: MessageRole - content: string + role: MessageRole; + content: string; + extra: { + [k: string]: unknown; + } | null; } /** * Represents a document used as reference for the response. */ export interface Reference { - title: string - content: string - url: string | null + title: string; + content: string; + url: string | null; } /** * Represents an update to conversation state. */ export interface ServerState { - state: { - [k: string]: unknown - } - signature: string + state: { + [k: string]: unknown; + }; + signature: string; } /** * Individual feedback configuration (like/dislike). */ export interface FeedbackItem { - /** - * Whether this feedback type is enabled - */ - enabled: boolean - /** - * Form schema for this feedback type - */ - form: RJSFSchema | null + /** + * Whether this feedback type is enabled + */ + enabled: boolean; + /** + * Form schema for this feedback type + */ + form: RJSFSchema | null; } /** * Represents an image in the conversation. */ export interface Image { - id: string - url: string + id: string; + url: string; } /** * Represents usage for a message. Reflects `Usage` computed properties. */ export interface MessageUsage { - n_requests: number - estimated_cost: number - prompt_tokens: number - completion_tokens: number - total_tokens: number + n_requests: number; + estimated_cost: number; + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; } /** * Simple task representation. */ export interface Task { - id: string - description: string - /** - * Task status options. - */ - status: - | 'pending' - | 'in_progress' - | 'completed' - | 'failed' - | 'cancelled' - | 'retrying' - order: number - summary: string | null - parent_id: string | null - full_response: string | null - dependencies: string[] + id: string; + description: string; + /** + * Task status options. + */ + status: "pending" | "in_progress" | "completed" | "failed" | "cancelled" | "retrying"; + order: number; + summary: string | null; + parent_id: string | null; + full_response: string | null; + dependencies: string[]; } /** * Customization for the header section of the UI. */ export interface HeaderCustomization { - /** - * Custom title to be displayed instead of 'Ragbits Chat' - */ - title: string | null - /** - * Custom subtitle to be displayed instead of 'by deepsense.ai' - */ - subtitle: string | null - /** - * Custom logo URL or content. The logo can also be served from 'static' directory inside 'ui-buid' - */ - logo: string | null + /** + * Custom title to be displayed instead of 'Ragbits Chat' + */ + title: string | null; + /** + * Custom subtitle to be displayed instead of 'by deepsense.ai' + */ + subtitle: string | null; + /** + * Custom logo URL or content. The logo can also be served from 'static' directory inside 'ui-buid' + */ + logo: string | null; } /** * Customization for the UI. */ export interface UICustomization { - /** - * Custom header configuration - */ - header: HeaderCustomization | null - /** - * Custom welcome message to be displayed on the UI. It supports Markdown. - */ - welcome_message: string | null - /** - * Custom meta properties customization - */ - meta: PageMetaCustomization | null + /** + * Custom header configuration + */ + header: HeaderCustomization | null; + /** + * Custom welcome message to be displayed on the UI. It supports Markdown. + */ + welcome_message: string | null; + /** + * Custom meta properties customization + */ + meta: PageMetaCustomization | null; } /** * Customization for the meta properites of the UI */ export interface PageMetaCustomization { - /** - * Custom favicon URL or content. If `None` logo is used.The favicon can also be serverd from 'static' directory inside 'ui-build' - */ - favicon: string | null - /** - * Custom title for the page displayed in the browser's bar. If `None` header title is used. - */ - page_title: string | null + /** + * Custom favicon URL or content. If `None` logo is used.The favicon can also be serverd from 'static' directory inside 'ui-build' + */ + favicon: string | null; + /** + * Custom title for the page displayed in the browser's bar. If `None` header title is used. + */ + page_title: string | null; } /** * Configuration for chat options. */ export interface UserSettings { - /** - * The form to use for chat options. Use Pydantic models to define form objects, that would get converted to JSONSchema and rendered in the UI. - */ - form: RJSFSchema | null + /** + * The form to use for chat options. Use Pydantic models to define form objects, that would get converted to JSONSchema and rendered in the UI. + */ + form: RJSFSchema | null; } /** * Feedback configuration containing like and dislike settings. */ export interface FeedbackConfig { - like: FeedbackItem - dislike: FeedbackItem + like: FeedbackItem; + dislike: FeedbackItem; } /** * Configuration response from the API. */ export interface ConfigResponse { - feedback: FeedbackConfig - /** - * UI customization - */ - customization: UICustomization | null - user_settings: UserSettings - /** - * Debug mode flag - */ - debug_mode: boolean - /** - * Flag to enable conversation history - */ - conversation_history: boolean - /** - * Flag to enable usage statistics - */ - show_usage: boolean - authentication: AuthenticationConfig + feedback: FeedbackConfig; + /** + * UI customization + */ + customization: UICustomization | null; + user_settings: UserSettings; + /** + * Debug mode flag + */ + debug_mode: boolean; + /** + * Flag to enable conversation history + */ + conversation_history: boolean; + /** + * Flag to enable usage statistics + */ + show_usage: boolean; + authentication: AuthenticationConfig; } /** * Response from feedback submission. */ export interface FeedbackResponse { - /** - * Status of the feedback submission - */ - status: string + /** + * Status of the feedback submission + */ + status: string; } /** * Client-side chat request interface. */ export interface ChatRequest { - /** - * The current user message - */ - message: string - /** - * Previous message history - */ - history: Message[] - /** - * User context information - */ - context: { - [k: string]: unknown - } + /** + * The current user message + */ + message: string; + /** + * Previous message history + */ + history: Message[]; + /** + * User context information + */ + context: { + [k: string]: unknown; + }; } /** * Request body for feedback submission */ export interface FeedbackRequest { - /** - * ID of the message receiving feedback - */ - message_id: string - /** - * Type of feedback (like or dislike) - */ - feedback: 'like' | 'dislike' - /** - * Additional feedback details - */ - payload: { - [k: string]: unknown - } + /** + * ID of the message receiving feedback + */ + message_id: string; + /** + * Type of feedback (like or dislike) + */ + feedback: "like" | "dislike"; + /** + * Additional feedback details + */ + payload: { + [k: string]: unknown; + }; } /** * Configuration for authentication. */ export interface AuthenticationConfig { - /** - * Enable/disable authentication - */ - enabled: boolean - /** - * List of available authentication types - */ - auth_types: AuthType[] + /** + * Enable/disable authentication + */ + enabled: boolean; + /** + * List of available authentication types + */ + auth_types: AuthType[]; } /** * Request body for user login */ export interface CredentialsLoginRequest { - /** - * Username - */ - username: string - /** - * Password - */ - password: string + /** + * Username + */ + username: string; + /** + * Password + */ + password: string; } /** * Represents a JWT authentication jwt_token. */ export interface JWTToken { - access_token: string - token_type: string - expires_in: number - refresh_token: string | null - user: User + access_token: string; + token_type: string; + expires_in: number; + refresh_token: string | null; + user: User; } /** * Request body for user login */ export interface LoginRequest { - /** - * Username - */ - username: string - /** - * Password - */ - password: string + /** + * Username + */ + username: string; + /** + * Password + */ + password: string; } /** * Response body for successful login */ export interface LoginResponse { - /** - * Whether login was successful - */ - success: boolean - /** - * User information - */ - user: User | null - /** - * Error message if login failed - */ - error_message: string | null - /** - * Access jwt_token - */ - jwt_token: JWTToken | null + /** + * Whether login was successful + */ + success: boolean; + /** + * User information + */ + user: User | null; + /** + * Error message if login failed + */ + error_message: string | null; + /** + * Access jwt_token + */ + jwt_token: JWTToken | null; } /** * Request body for user logout */ export interface LogoutRequest { - /** - * Session ID to logout - */ - token: string + /** + * Session ID to logout + */ + token: string; } /** * Represents an authenticated user. */ export interface User { - user_id: string - username: string - email: string | null - full_name: string | null - roles: string[] - metadata: { - [k: string]: unknown - } + user_id: string; + username: string; + email: string | null; + full_name: string | null; + roles: string[]; + metadata: { + [k: string]: unknown; + }; } /** @@ -514,6 +538,16 @@ export interface ConversationSummaryResponse { content: string } +export interface ConfirmationRequestChatResponse { + type: 'confirmation_request' + content: ConfirmationRequest +} + +export interface ConfirmationStatusChatResponse { + type: 'confirmation_status' + content: ConfirmationStatus +} + export interface ChunkedChatResponse { type: 'chunked_content' content: ChunkedContent @@ -535,3 +569,5 @@ export type ChatResponse = | MessageUsageChatResponse | TodoItemChatResonse | ConversationSummaryResponse + | ConfirmationRequestChatResponse + | ConfirmationStatusChatResponse diff --git a/typescript/@ragbits/api-client/src/types.ts b/typescript/@ragbits/api-client/src/types.ts index ea084aca3..e2266523b 100644 --- a/typescript/@ragbits/api-client/src/types.ts +++ b/typescript/@ragbits/api-client/src/types.ts @@ -49,6 +49,7 @@ export interface BaseApiEndpoints { '/api/auth/login': EndpointDefinition '/api/auth/logout': EndpointDefinition '/api/theme': EndpointDefinition + } /** diff --git a/typescript/ui/src/core/components/Chat.tsx b/typescript/ui/src/core/components/Chat.tsx index e177cda68..91542fe97 100644 --- a/typescript/ui/src/core/components/Chat.tsx +++ b/typescript/ui/src/core/components/Chat.tsx @@ -8,6 +8,7 @@ import { useConversationProperty, useMessage, useHistoryActions, + useHasPendingConfirmations, } from "../stores/HistoryStore/selectors"; import { ChatMessage } from "./ChatMessage"; import QuickMessageInput from "./inputs/QuickMessageInput"; @@ -23,6 +24,7 @@ export default function Chat() { const lastMessage = useMessage(lastMessageId); const historyIsLoading = useConversationProperty((s) => s.isLoading); const followupMessages = useConversationProperty((s) => s.followupMessages); + const hasPendingConfirmations = useHasPendingConfirmations(); const { sendMessage, stopAnswering } = useHistoryActions(); const [showScrollDownButton, setShowScrollDownButton] = useState(false); const [shouldAutoScroll, setShouldAutoScroll] = useState(true); @@ -148,6 +150,7 @@ export default function Chat() { submit={authorizedSendMessage} stopAnswering={stopAnswering} followupMessages={followupMessages} + isDisabled={hasPendingConfirmations} /> diff --git a/typescript/ui/src/core/components/ChatMessage/ChatMessage.tsx b/typescript/ui/src/core/components/ChatMessage/ChatMessage.tsx index 2a802cb6e..a3be8d196 100644 --- a/typescript/ui/src/core/components/ChatMessage/ChatMessage.tsx +++ b/typescript/ui/src/core/components/ChatMessage/ChatMessage.tsx @@ -7,6 +7,8 @@ import ImageGallery from "./ImageGallery.tsx"; import MessageReferences from "./MessageReferences.tsx"; import MessageActions from "./MessageActions.tsx"; import LoadingIndicator from "./LoadingIndicator.tsx"; +import ConfirmationDialog from "./ConfirmationDialog.tsx"; +import ConfirmationDialogs from "./ConfirmationDialogs.tsx"; import { useConversationProperty, useMessage, @@ -14,6 +16,8 @@ import { import { MessageRole } from "@ragbits/api-client"; import TodoList from "../TodoList.tsx"; import { AnimatePresence, motion } from "framer-motion"; +import { useHistoryStore } from "../../stores/HistoryStore/useHistoryStore.ts"; +import { useRagbitsContext } from "@ragbits/api-client-react"; type ChatMessageProps = { classNames?: { @@ -30,13 +34,24 @@ const ChatMessage = forwardRef( const lastMessageId = useConversationProperty((s) => s.lastMessageId); const isHistoryLoading = useConversationProperty((s) => s.isLoading); const message = useMessage(messageId); + const sendSilentConfirmation = useHistoryStore((s) => s.actions.sendSilentConfirmation); + const { client: ragbitsClient } = useRagbitsContext(); if (!message) { throw new Error("Tried to render non-existent message"); } - const { serverId, content, role, references, liveUpdates, images } = - message; + const { + serverId, + content, + role, + references, + liveUpdates, + images, + confirmationRequest, + confirmationRequests, + confirmationStates, + } = message; const rightAlign = role === MessageRole.User; const isLoading = isHistoryLoading && @@ -51,7 +66,68 @@ const ChatMessage = forwardRef( !isLoading && images && Object.keys(images).length > 0; const showMessageReferences = !isLoading && references && references.length > 0; - const showLiveUpdates = liveUpdates; + const showLiveUpdates = liveUpdates && Object.keys(liveUpdates).length > 0; + + // Support both legacy single confirmation and new multiple confirmations + // Prioritize the new system - if we have multiple confirmations, use that + const showConfirmations = confirmationRequests && confirmationRequests.length > 0; + const showConfirmation = !showConfirmations && !!confirmationRequest; + + const handleConfirmation = (confirmed: boolean) => { + if (!confirmationRequest) return; + + console.log("šŸ” Confirmation button clicked:", { + confirmed, + confirmation_id: confirmationRequest.confirmation_id, + tool_name: confirmationRequest.tool_name, + }); + + // Use sendSilentConfirmation to avoid adding a user message + sendSilentConfirmation( + messageId, + confirmationRequest.confirmation_id, + confirmed, + ragbitsClient, + ); + }; + + const handleSingleConfirmation = (confirmationId: string) => { + console.log("šŸ” Single confirmation:", confirmationId); + sendSilentConfirmation(messageId, confirmationId, true, ragbitsClient); + }; + + const handleSingleSkip = (confirmationId: string) => { + console.log("šŸ” Single skip:", confirmationId); + sendSilentConfirmation(messageId, confirmationId, false, ragbitsClient); + }; + + const handleBulkConfirm = (confirmationIds: string[]) => { + console.log("šŸ” Bulk confirm:", confirmationIds); + + // Build decisions for ALL confirmations (confirmed and declined) + // This ensures the backend knows about all decisions, not just confirmed ones + const allIds = confirmationRequests?.map(req => req.confirmation_id) || []; + const decisions = allIds.reduce( + (acc, id) => ({ ...acc, [id]: confirmationIds.includes(id) }), + {} as Record + ); + + console.log("šŸ“¤ Sending all decisions:", decisions); + sendSilentConfirmation(messageId, allIds, decisions, ragbitsClient); + }; + + const handleBulkSkip = (confirmationIds: string[]) => { + console.log("šŸ” Bulk skip:", confirmationIds); + + // Build decisions for ALL confirmations + const allIds = confirmationRequests?.map(req => req.confirmation_id) || []; + const decisions = allIds.reduce( + (acc, id) => ({ ...acc, [id]: false }), + {} as Record + ); + + sendSilentConfirmation(messageId, allIds, decisions, ragbitsClient); + }; return (
( )} + {showConfirmation && ( + handleConfirmation(true)} + onSkip={() => handleConfirmation(false)} + initialState={message.confirmationState} + /> + )} + {showConfirmations && confirmationStates && ( + + )} void; + onSkip: () => void; + initialState?: "pending" | "confirmed" | "declined" | "skipped"; +}; + +type ConfirmationState = "pending" | "confirmed" | "declined" | "skipped"; + +const ConfirmationDialog = ({ + confirmationRequest, + onConfirm, + onSkip, + initialState = "pending", +}: ConfirmationDialogProps) => { + const [confirmationState, setConfirmationState] = + useState(initialState); + + // Update state if initialState changes (page reload) + useEffect(() => { + setConfirmationState(initialState); + }, [initialState]); + + const handleConfirm = () => { + setConfirmationState("confirmed"); + onConfirm(); + }; + + const handleSkip = () => { + setConfirmationState("declined"); + onSkip(); + }; + + // Helper function to format arguments for display + const formatArguments = (args: Record): string => { + const entries = Object.entries(args); + if (entries.length === 0) return ""; + + // Show key arguments in a readable format + return entries + .slice(0, 3) // Show max 3 arguments + .map(([key, value]) => { + const displayValue = + typeof value === "string" + ? value.length > 30 + ? `${value.substring(0, 30)}...` + : value + : JSON.stringify(value); + return `${key}: ${displayValue}`; + }) + .join(", "); + }; + + // Helper function to create a short, readable description + const getShortDescription = (): string => { + // If there's a tool description, use it + if (confirmationRequest.tool_description) { + return confirmationRequest.tool_description; + } + + // Otherwise, create a description from the tool name and key arguments + const args = confirmationRequest.arguments; + const toolName = confirmationRequest.tool_name.replace(/_/g, " "); + + // Try to find a meaningful primary argument (email, name, id, etc.) + const primaryArg = + args.email || args.to || args.name || args.id || args.event_id; + + if (primaryArg) { + return `${toolName}: ${primaryArg}`; + } + + return `Execute ${toolName}`; + }; + + const shortDescription = getShortDescription(); + const argsDisplay = formatArguments(confirmationRequest.arguments); + const isSkipped = confirmationState === "skipped"; + + return ( + + +
+
+
+ {confirmationState === "pending" && ( + + āš ļø Confirmation needed + + )} + {confirmationState === "confirmed" && ( + + āœ… Confirmed + + )} + {confirmationState === "declined" && ( + + āŒ Declined + + )} + {confirmationState === "skipped" && ( + + ā­ļø Handled naturally + + )} +
+

+ {shortDescription} +

+ {argsDisplay && confirmationState === "pending" && ( +

+ {argsDisplay} +

+ )} +
+ + {confirmationState === "pending" && ( +
+ + +
+ )} +
+
+
+ ); +}; + +export default ConfirmationDialog; diff --git a/typescript/ui/src/core/components/ChatMessage/ConfirmationDialogs.tsx b/typescript/ui/src/core/components/ChatMessage/ConfirmationDialogs.tsx new file mode 100644 index 000000000..e343ef814 --- /dev/null +++ b/typescript/ui/src/core/components/ChatMessage/ConfirmationDialogs.tsx @@ -0,0 +1,266 @@ +import { Button, Chip, Checkbox } from "@heroui/react"; +import { ConfirmationRequest } from "@ragbits/api-client-react"; +import { useState, useEffect } from "react"; +import { AnimatePresence, motion } from "framer-motion"; + +type ConfirmationDialogsProps = { + confirmationRequests: ConfirmationRequest[]; + confirmationStates: Record; + onConfirm: (confirmationId: string) => void; + onSkip: (confirmationId: string) => void; + onBulkConfirm: (confirmationIds: string[]) => void; + onBulkSkip: (confirmationIds: string[]) => void; +}; + +const ConfirmationDialogs = ({ + confirmationRequests, + confirmationStates, + onConfirm, + onSkip, + onBulkConfirm, + onBulkSkip, +}: ConfirmationDialogsProps) => { + const [selectedIds, setSelectedIds] = useState>(new Set()); + + // Helper function to format arguments for display + const formatArguments = (args: Record): string => { + const entries = Object.entries(args); + if (entries.length === 0) return ""; + + // Show key arguments in a readable format + return entries + .slice(0, 3) // Show max 3 arguments + .map(([key, value]) => { + const displayValue = + typeof value === "string" + ? value.length > 30 + ? `${value.substring(0, 30)}...` + : value + : JSON.stringify(value); + return `${key}: ${displayValue}`; + }) + .join(", "); + }; + + // Helper function to create a short, readable description + const getShortDescription = (req: ConfirmationRequest): string => { + // If there's a tool description, use it + if (req.tool_description) { + return req.tool_description; + } + + // Otherwise, create a description from the tool name and key arguments + const args = req.arguments; + const toolName = req.tool_name.replace(/_/g, " "); + + // Try to find a meaningful primary argument (email, name, id, etc.) + const primaryArg = + args.email || args.to || args.name || args.id || args.event_id; + + if (primaryArg) { + return `${toolName}: ${primaryArg}`; + } + + return `Execute ${toolName}`; + }; + + const pendingRequests = confirmationRequests.filter( + (req) => confirmationStates[req.confirmation_id] === "pending" + ); + + const hasPending = pendingRequests.length > 0; + const hasSelected = selectedIds.size > 0; + + const handleToggleSelection = (confirmationId: string) => { + setSelectedIds((prev) => { + const newSet = new Set(prev); + if (newSet.has(confirmationId)) { + newSet.delete(confirmationId); + } else { + newSet.add(confirmationId); + } + return newSet; + }); + }; + + const handleConfirmAll = () => { + const pendingIds = pendingRequests.map((req) => req.confirmation_id); + onBulkConfirm(pendingIds); + setSelectedIds(new Set()); + }; + + const handleSkipAll = () => { + const pendingIds = pendingRequests.map((req) => req.confirmation_id); + onBulkSkip(pendingIds); + setSelectedIds(new Set()); + }; + + const handleConfirmSelected = () => { + const selectedArray = Array.from(selectedIds).filter((id) => + confirmationStates[id] === "pending" + ); + onBulkConfirm(selectedArray); + setSelectedIds(new Set()); + }; + + const handleSingleConfirm = (confirmationId: string) => { + onConfirm(confirmationId); + // Remove from selection if it was selected + setSelectedIds((prev) => { + const newSet = new Set(prev); + newSet.delete(confirmationId); + return newSet; + }); + }; + + const handleSingleSkip = (confirmationId: string) => { + onSkip(confirmationId); + // Remove from selection if it was selected + setSelectedIds((prev) => { + const newSet = new Set(prev); + newSet.delete(confirmationId); + return newSet; + }); + }; + + return ( + + + {/* Bulk action buttons */} + {hasPending && ( +
+ + + {hasSelected && ( + + )} +
+ )} + + {/* List of confirmations */} +
3 ? "max-h-[400px] overflow-y-auto pr-2" : ""}`}> + {confirmationRequests.map((req) => { + const state = confirmationStates[req.confirmation_id] || "pending"; + const isPending = state === "pending"; + const isSkipped = state === "skipped"; + const shortDescription = getShortDescription(req); + const argsDisplay = formatArguments(req.arguments); + const isSelected = selectedIds.has(req.confirmation_id); + + return ( + + {/* Checkbox for bulk selection - only show for pending */} + {isPending && ( + handleToggleSelection(req.confirmation_id)} + size="sm" + className="shrink-0" + /> + )} + +
+
+ {state === "pending" && ( + + āš ļø Confirmation needed + + )} + {state === "confirmed" && ( + + āœ… Confirmed + + )} + {state === "declined" && ( + + āŒ Declined + + )} + {state === "skipped" && ( + + ā­ļø Handled naturally + + )} +
+

+ {shortDescription} +

+ {argsDisplay && isPending && ( +

+ {argsDisplay} +

+ )} +
+ + {isPending && ( +
+ + +
+ )} +
+ ); + })} +
+
+
+ ); +}; + +export default ConfirmationDialogs; + diff --git a/typescript/ui/src/core/components/inputs/PromptInput/PromptInput.tsx b/typescript/ui/src/core/components/inputs/PromptInput/PromptInput.tsx index 3257f9106..43cbf3c46 100644 --- a/typescript/ui/src/core/components/inputs/PromptInput/PromptInput.tsx +++ b/typescript/ui/src/core/components/inputs/PromptInput/PromptInput.tsx @@ -32,6 +32,7 @@ interface PromptInputProps { sendButtonProps?: ButtonProps; customSendIcon?: ReactNode; customStopIcon?: ReactNode; + isDisabled?: boolean; } const PromptInput = ({ @@ -45,6 +46,7 @@ const PromptInput = ({ customSendIcon, customStopIcon, history, + isDisabled = false, }: PromptInputProps) => { const [message, setMessage] = useState(""); const [quickMessages, setQuickMessages] = useState([]); @@ -76,6 +78,7 @@ const PromptInput = ({ const handleSubmit = useCallback( (text?: string) => { if (!message && !text) return; + if (isDisabled) return; // Prevent submission when disabled stopAnswering(); submit(text ?? message); @@ -87,7 +90,7 @@ const PromptInput = ({ setMessage(""); textAreaRef?.current?.focus(); }, - [message, stopAnswering, submit], + [message, stopAnswering, submit, isDisabled], ); const onSubmit = useCallback( @@ -192,7 +195,7 @@ const PromptInput = ({ "!bg-transparent shadow-none group-data-[focus-visible=true]:ring-0 group-data-[focus-visible=true]:ring-offset-0 py-4", }} name="message" - placeholder="Enter a message here" + placeholder={isDisabled ? "Please respond to pending confirmation..." : "Enter a message here"} autoFocus maxRows={16} minRows={1} @@ -201,6 +204,7 @@ const PromptInput = ({ onValueChange={handleValueChange} data-testid="prompt-input-input" data-value={message} + isDisabled={isDisabled} {...inputProps} />
@@ -218,7 +222,7 @@ const PromptInput = ({ isLoading ? "Stop answering" : "Send message to the chat" } color={!isLoading && !message ? "default" : "primary"} - isDisabled={!isLoading && !message} + isDisabled={!isLoading && (!message || isDisabled)} radius="full" size="sm" type={isLoading ? "button" : "submit"} diff --git a/typescript/ui/src/core/stores/HistoryStore/eventHandlers/eventHandlerRegistry.ts b/typescript/ui/src/core/stores/HistoryStore/eventHandlers/eventHandlerRegistry.ts index b52dcb37f..763692eb0 100644 --- a/typescript/ui/src/core/stores/HistoryStore/eventHandlers/eventHandlerRegistry.ts +++ b/typescript/ui/src/core/stores/HistoryStore/eventHandlers/eventHandlerRegistry.ts @@ -9,6 +9,8 @@ import { } from "./nonMessageHandlers"; import { handleClearMessage, + handleConfirmationRequest, + handleConfirmationStatus, handleImage, handleLiveUpdate, handleMessageId, @@ -109,3 +111,9 @@ ChatHandlerRegistry.register(ChatResponseType.TodoItem, { ChatHandlerRegistry.register(ChatResponseType.ConversationSummary, { handle: handleConversationSummary, }); +ChatHandlerRegistry.register(ChatResponseType.ConfirmationRequest, { + handle: handleConfirmationRequest, +}); +ChatHandlerRegistry.register(ChatResponseType.ConfirmationStatus, { + handle: handleConfirmationStatus, +}); diff --git a/typescript/ui/src/core/stores/HistoryStore/eventHandlers/messageHandlers.ts b/typescript/ui/src/core/stores/HistoryStore/eventHandlers/messageHandlers.ts index d03437a1b..828688506 100644 --- a/typescript/ui/src/core/stores/HistoryStore/eventHandlers/messageHandlers.ts +++ b/typescript/ui/src/core/stores/HistoryStore/eventHandlers/messageHandlers.ts @@ -1,5 +1,7 @@ import { ClearMessageResponse, + ConfirmationRequestChatResponse, + ConfirmationStatusChatResponse, ImageChatResponse, LiveUpdateChatResponse, LiveUpdateType, @@ -18,7 +20,25 @@ export const handleText: PrimaryHandler = ( ctx, ) => { const message = draft.history[ctx.messageId]; - message.content += response.content; + + // Check if this is the first text after confirmation(s) + const isAfterConfirmation = + (!!message.confirmationRequest || + (message.confirmationRequests && + message.confirmationRequests.length > 0)) && + message.content.length === 0; + + // Add visual separator if this is the first text after confirmation + const textToAdd = isAfterConfirmation + ? "\n\n" + response.content + : response.content; + + // Add text content + message.content += textToAdd; + + // Don't auto-skip here - it's too aggressive and marks confirmations as skipped + // even during the initial agent response. Instead, confirmations stay "pending" + // until user clicks a button or they get marked as skipped by other means }; export const handleReference: PrimaryHandler = ( @@ -117,3 +137,58 @@ export const handleTodoItem: PrimaryHandler = ( message.tasks = newTasks; }; + +export const handleConfirmationRequest: PrimaryHandler< + ConfirmationRequestChatResponse +> = (response, draft, ctx) => { + const message = draft.history[ctx.messageId]; + + console.log( + "šŸ“„ Confirmation request received:", + response.content.confirmation_id, + ); + + // Initialize arrays if they don't exist + if (!message.confirmationRequests) { + message.confirmationRequests = []; + } + if (!message.confirmationStates) { + message.confirmationStates = {}; + } + + // Add to new array-based system + message.confirmationRequests.push(response.content); + message.confirmationStates[response.content.confirmation_id] = "pending"; + + console.log( + `šŸ“Š Total confirmations now: ${message.confirmationRequests.length}`, + ); + + // Keep legacy single confirmation for backward compatibility (last one wins) + message.confirmationRequest = response.content; + message.confirmationState = "pending"; +}; + +export const handleConfirmationStatus: PrimaryHandler< + ConfirmationStatusChatResponse +> = (response, draft) => { + const { confirmation_id, status } = response.content; + + // Find the message with matching confirmation_id and update its state + Object.values(draft.history).forEach((message) => { + // Update legacy single confirmation + if (message.confirmationRequest?.confirmation_id === confirmation_id) { + message.confirmationState = status as "confirmed" | "declined"; + } + + // Update new multiple confirmations system + if ( + message.confirmationStates && + confirmation_id in message.confirmationStates + ) { + message.confirmationStates[confirmation_id] = status as + | "confirmed" + | "declined"; + } + }); +}; diff --git a/typescript/ui/src/core/stores/HistoryStore/historyStore.ts b/typescript/ui/src/core/stores/HistoryStore/historyStore.ts index 4d6024186..6089a7d01 100644 --- a/typescript/ui/src/core/stores/HistoryStore/historyStore.ts +++ b/typescript/ui/src/core/stores/HistoryStore/historyStore.ts @@ -312,7 +312,7 @@ export const createHistoryStore = immer((set, get) => ({ return newConversation.conversationId; }, - sendMessage: (text, ragbitsClient) => { + sendMessage: (text, ragbitsClient, additionalContext) => { const { _internal: { handleResponse }, primitives: { addMessage, getCurrentConversation, stopAnswering }, @@ -320,6 +320,7 @@ export const createHistoryStore = immer((set, get) => ({ } = get(); const { history, conversationId } = getCurrentConversation(); + addMessage(conversationId, { role: MessageRole.User, content: text, @@ -334,7 +335,124 @@ export const createHistoryStore = immer((set, get) => ({ const chatRequest: ChatRequest = { message: text, history: mapHistoryToMessages(history), - context: getContext(), + context: { ...getContext(), ...additionalContext }, + }; + + // Add new entry for events + set( + updateConversation(conversationId, (draft) => { + draft.eventsLog.push([]); + }), + ); + + const abortController = new AbortController(); + const conversationIdRef = { current: conversationId }; + + set( + updateConversation(conversationId, (draft) => { + draft.abortController = abortController; + draft.isLoading = true; + }), + ); + + ragbitsClient.makeStreamRequest( + "/api/chat", + chatRequest, + { + onMessage: (response: ChatResponse) => + handleResponse(conversationIdRef, assistantResponseId, response), + onError: (error: Error) => { + handleResponse(conversationIdRef, assistantResponseId, { + type: ChatResponseType.Text, + content: error.message, + }); + stopAnswering(conversationIdRef.current); + }, + onClose: () => { + stopAnswering(conversationIdRef.current); + }, + }, + abortController.signal, + ); + }, + + sendSilentConfirmation: ( + messageId: string, + confirmationIds: string | string[], + confirmed: boolean | Record, + ragbitsClient, + ) => { + const { + _internal: { handleResponse }, + primitives: { getCurrentConversation, stopAnswering }, + computed: { getContext }, + } = get(); + + const { history, conversationId } = getCurrentConversation(); + + // Normalize inputs to arrays + const idsArray = Array.isArray(confirmationIds) + ? confirmationIds + : [confirmationIds]; + const decisionsMap = + typeof confirmed === "boolean" + ? idsArray.reduce( + (acc, id) => ({ ...acc, [id]: confirmed }), + {} as Record, + ) + : confirmed; + + // Update confirmation states immediately in the UI + set( + updateConversation(conversationId, (draft) => { + const message = draft.history[messageId]; + if (message) { + // Update legacy single confirmation (backward compatibility) + if ( + message.confirmationRequest && + idsArray.includes(message.confirmationRequest.confirmation_id) + ) { + const decision = + decisionsMap[message.confirmationRequest.confirmation_id]; + message.confirmationState = decision ? "confirmed" : "declined"; + } + + // Update multiple confirmations system + if (message.confirmationStates) { + idsArray.forEach((id) => { + if (id in message.confirmationStates!) { + message.confirmationStates![id] = decisionsMap[id] + ? "confirmed" + : "declined"; + } + }); + } + + // Clear the "ā³ Awaiting user confirmation" live update + // before the agent re-runs and sends new updates + message.liveUpdates = undefined; + } + }), + ); + + // Reuse the same message for the response instead of creating a new one + const assistantResponseId = messageId; + + // Prepare the chat request with confirmed_tools context + // Build the confirmed_tools array from all decisions + const confirmed_tools = idsArray.map((id) => ({ + confirmation_id: id, + confirmed: decisionsMap[id], + })); + + // Use empty message since this is a silent confirmation + const chatRequest: ChatRequest = { + message: "", + history: mapHistoryToMessages(history), + context: { + ...getContext(), + confirmed_tools, + }, }; // Add new entry for events @@ -354,6 +472,7 @@ export const createHistoryStore = immer((set, get) => ({ }), ); + // Use the same assistant message for the response ragbitsClient.makeStreamRequest( "/api/chat", chatRequest, diff --git a/typescript/ui/src/core/stores/HistoryStore/selectors.ts b/typescript/ui/src/core/stores/HistoryStore/selectors.ts index 9d0f259d7..04d9a1b38 100644 --- a/typescript/ui/src/core/stores/HistoryStore/selectors.ts +++ b/typescript/ui/src/core/stores/HistoryStore/selectors.ts @@ -29,3 +29,12 @@ export const useMessages = () => Object.values(s.primitives.getCurrentConversation().history), ), ); + +export const useHasPendingConfirmations = () => + useHistoryStore((s) => { + const history = s.primitives.getCurrentConversation().history; + return Object.values(history).some( + (msg) => + msg.confirmationRequest && msg.confirmationState === "pending", + ); + }); diff --git a/typescript/ui/src/types/history.ts b/typescript/ui/src/types/history.ts index 249264577..77ae47d11 100644 --- a/typescript/ui/src/types/history.ts +++ b/typescript/ui/src/types/history.ts @@ -1,5 +1,6 @@ import { ChatResponse, + ConfirmationRequest, LiveUpdate, MessageRole, Reference, @@ -26,6 +27,15 @@ export interface ChatMessage { images?: Record; usage?: Record; tasks?: Task[]; + // Legacy single confirmation support (kept for backward compatibility) + confirmationRequest?: ConfirmationRequest; + confirmationState?: "pending" | "confirmed" | "declined" | "skipped"; + // New multiple confirmations support + confirmationRequests?: ConfirmationRequest[]; + confirmationStates?: Record< + string, + "pending" | "confirmed" | "declined" | "skipped" + >; } export interface Conversation { @@ -53,7 +63,17 @@ export interface HistoryStore { newConversation: () => string; selectConversation: (conversationId: string) => void; deleteConversation: (conversationId: string) => void; - sendMessage: (text: string, ragbitsClient: RagbitsClient) => void; + sendMessage: ( + text: string, + ragbitsClient: RagbitsClient, + additionalContext?: Record, + ) => void; + sendSilentConfirmation: ( + messageId: string, + confirmationIds: string | string[], + confirmed: boolean | Record, + ragbitsClient: RagbitsClient, + ) => void; stopAnswering: () => void; /** Merge passed extensions with existing object for a given message. New values in the passed extensions * overwrite previous ones.