diff --git a/backend/core/templates/api.py b/backend/core/templates/api.py index b992df02..190075a1 100644 --- a/backend/core/templates/api.py +++ b/backend/core/templates/api.py @@ -48,6 +48,7 @@ class InstallTemplateRequest(BaseModel): profile_mappings: Optional[Dict[str, str]] = None custom_mcp_configs: Optional[Dict[str, Dict[str, Any]]] = None trigger_configs: Optional[Dict[str, Dict[str, Any]]] = None + trigger_variables: Optional[Dict[str, Dict[str, str]]] = None class PublishTemplateRequest(BaseModel): @@ -83,6 +84,7 @@ class InstallationResponse(BaseModel): name: Optional[str] = None missing_regular_credentials: List[Dict[str, Any]] = [] missing_custom_configs: List[Dict[str, Any]] = [] + missing_trigger_variables: Optional[Dict[str, Dict[str, Any]]] = None template_info: Optional[Dict[str, Any]] = None @@ -323,7 +325,8 @@ async def install_template( custom_system_prompt=request.custom_system_prompt, profile_mappings=request.profile_mappings, custom_mcp_configs=request.custom_mcp_configs, - trigger_configs=request.trigger_configs + trigger_configs=request.trigger_configs, + trigger_variables=request.trigger_variables ) result = await installation_service.install_template(install_request) @@ -336,6 +339,7 @@ async def install_template( name=result.name, missing_regular_credentials=result.missing_regular_credentials, missing_custom_configs=result.missing_custom_configs, + missing_trigger_variables=result.missing_trigger_variables, template_info=result.template_info ) diff --git a/backend/core/templates/installation_service.py b/backend/core/templates/installation_service.py index 750a950d..4f385ad9 100644 --- a/backend/core/templates/installation_service.py +++ b/backend/core/templates/installation_service.py @@ -5,6 +5,7 @@ from uuid import uuid4 import os import json import httpx +import re from core.services.supabase import DBConnection from core.utils.logger import logger @@ -34,6 +35,7 @@ class TemplateInstallationRequest: profile_mappings: Optional[Dict[QualifiedName, ProfileId]] = None custom_mcp_configs: Optional[Dict[QualifiedName, ConfigType]] = None trigger_configs: Optional[Dict[str, Dict[str, Any]]] = None + trigger_variables: Optional[Dict[str, Dict[str, str]]] = None @dataclass class TemplateInstallationResult: @@ -42,6 +44,7 @@ class TemplateInstallationResult: name: Optional[str] = None missing_regular_credentials: List[Dict[str, Any]] = field(default_factory=list) missing_custom_configs: List[Dict[str, Any]] = field(default_factory=list) + missing_trigger_variables: Optional[Dict[str, Dict[str, Any]]] = None template_info: Optional[Dict[str, Any]] = None class TemplateInstallationError(Exception): @@ -54,6 +57,41 @@ class InstallationService: def __init__(self, db_connection: DBConnection): self._db = db_connection + def _extract_trigger_variables(self, config: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: + """Extract all trigger variables from template config""" + trigger_variables = {} + triggers = config.get('triggers', []) + + for i, trigger in enumerate(triggers): + trigger_config = trigger.get('config', {}) + trigger_name = trigger.get('name', f'Trigger {i+1}') + agent_prompt = trigger_config.get('agent_prompt', '') + variables = trigger_config.get('trigger_variables', []) + + # If no variables were stored, try to extract them from the prompt + if not variables and agent_prompt: + pattern = r'\{\{(\w+)\}\}' + matches = re.findall(pattern, agent_prompt) + variables = list(set(matches)) + + if variables: + trigger_key = f"trigger_{i}" + trigger_variables[trigger_key] = { + 'trigger_name': trigger_name, + 'trigger_index': i, + 'variables': variables, + 'agent_prompt': agent_prompt + } + + return trigger_variables + + def _replace_variables_in_text(self, text: str, variable_values: Dict[str, str]) -> str: + """Replace {{variable}} patterns in text with actual values""" + for var_name, var_value in variable_values.items(): + pattern = r'\{\{' + re.escape(var_name) + r'\}\}' + text = re.sub(pattern, var_value, text) + return text + async def install_template(self, request: TemplateInstallationRequest) -> TemplateInstallationResult: logger.debug(f"Installing template {request.template_id} for user {request.account_id}") logger.debug(f"Initial profile_mappings from request: {request.profile_mappings}") @@ -86,11 +124,35 @@ class InstallationService: logger.debug(f"Missing profiles: {[p['qualified_name'] for p in missing_profiles]}") logger.debug(f"Missing configs: {[c['qualified_name'] for c in missing_configs]}") - if missing_profiles or missing_configs: + # Check for trigger variables + trigger_variables = self._extract_trigger_variables(template.config) + missing_trigger_variables = {} + + if trigger_variables and not request.trigger_variables: + # All trigger variables are missing + missing_trigger_variables = trigger_variables + elif trigger_variables and request.trigger_variables: + # Check which trigger variables are still missing + for trigger_key, trigger_data in trigger_variables.items(): + if trigger_key not in request.trigger_variables: + missing_trigger_variables[trigger_key] = trigger_data + else: + # Check if all required variables for this trigger are provided + provided_vars = request.trigger_variables.get(trigger_key, {}) + missing_vars = [] + for var in trigger_data['variables']: + if var not in provided_vars: + missing_vars.append(var) + if missing_vars: + trigger_data['missing_variables'] = missing_vars + missing_trigger_variables[trigger_key] = trigger_data + + if missing_profiles or missing_configs or missing_trigger_variables: return TemplateInstallationResult( status='configs_required', missing_regular_credentials=missing_profiles, missing_custom_configs=missing_configs, + missing_trigger_variables=missing_trigger_variables if missing_trigger_variables else None, template_info={ 'template_id': template.template_id, 'name': template.name @@ -116,7 +178,7 @@ class InstallationService: request.custom_system_prompt or template.system_prompt ) - await self._restore_triggers(agent_id, request.account_id, template.config, request.profile_mappings, request.trigger_configs) + await self._restore_triggers(agent_id, request.account_id, template.config, request.profile_mappings, request.trigger_configs, request.trigger_variables) await self._increment_download_count(template.template_id) @@ -410,7 +472,8 @@ class InstallationService: account_id: str, config: Dict[str, Any], profile_mappings: Optional[Dict[str, str]] = None, - trigger_configs: Optional[Dict[str, Dict[str, Any]]] = None + trigger_configs: Optional[Dict[str, Dict[str, Any]]] = None, + trigger_variables: Optional[Dict[str, Dict[str, str]]] = None ) -> None: triggers = config.get('triggers', []) if not triggers: @@ -425,6 +488,17 @@ class InstallationService: trigger_config = trigger.get('config', {}) provider_id = trigger_config.get('provider_id', '') + # Handle trigger variables if any + trigger_key = f"trigger_{i}" + agent_prompt = trigger_config.get('agent_prompt', '') + + if trigger_variables and trigger_key in trigger_variables and agent_prompt: + # Replace variables in the agent prompt + variable_values = trigger_variables[trigger_key] + agent_prompt = self._replace_variables_in_text(agent_prompt, variable_values) + trigger_config['agent_prompt'] = agent_prompt + logger.debug(f"Replaced variables in trigger {i} prompt: {variable_values}") + if provider_id == 'composio': qualified_name = trigger_config.get('qualified_name') @@ -455,7 +529,7 @@ class InstallationService: is_active=trigger.get('is_active', True), trigger_slug=trigger_config.get('trigger_slug', ''), qualified_name=qualified_name, - agent_prompt=trigger_config.get('agent_prompt'), + agent_prompt=agent_prompt, # Use the potentially modified agent_prompt profile_mappings=profile_mappings, trigger_profile_key=trigger_profile_key, trigger_specific_config=trigger_specific_config @@ -466,6 +540,11 @@ class InstallationService: else: failed_count += 1 else: + # For schedule triggers, the agent_prompt is already updated in trigger_config + # We need to ensure trigger_variables are removed if they exist + clean_config = trigger_config.copy() + if 'trigger_variables' in clean_config: + del clean_config['trigger_variables'] trigger_data = { 'trigger_id': str(uuid4()), @@ -474,7 +553,7 @@ class InstallationService: 'name': trigger.get('name', 'Unnamed Trigger'), 'description': trigger.get('description'), 'is_active': trigger.get('is_active', True), - 'config': trigger_config.copy(), + 'config': clean_config, 'created_at': datetime.now(timezone.utc).isoformat(), 'updated_at': datetime.now(timezone.utc).isoformat() } diff --git a/backend/core/templates/template_service.py b/backend/core/templates/template_service.py index 161092bd..970c7c17 100644 --- a/backend/core/templates/template_service.py +++ b/backend/core/templates/template_service.py @@ -1,4 +1,5 @@ import json +import re from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Dict, List, Any, Optional @@ -484,11 +485,25 @@ class TemplateService: trigger_config = trigger.get('config', {}) provider_id = trigger_config.get('provider_id', '') + agent_prompt = trigger_config.get('agent_prompt', '') + sanitized_config = { 'provider_id': provider_id, - 'agent_prompt': trigger_config.get('agent_prompt', ''), + 'agent_prompt': agent_prompt, } + # Extract trigger variables if they exist in the prompt + trigger_variables = trigger_config.get('trigger_variables', []) + if not trigger_variables and agent_prompt: + # Extract variables from the prompt using regex + pattern = r'\{\{(\w+)\}\}' + matches = re.findall(pattern, agent_prompt) + if matches: + trigger_variables = list(set(matches)) + + if trigger_variables: + sanitized_config['trigger_variables'] = trigger_variables + if provider_id == 'schedule': sanitized_config['cron_expression'] = trigger_config.get('cron_expression', '') sanitized_config['timezone'] = trigger_config.get('timezone', 'UTC') @@ -499,7 +514,7 @@ class TemplateService: excluded_fields = { 'profile_id', 'composio_trigger_id', 'provider_id', - 'agent_prompt', 'trigger_slug', 'qualified_name' + 'agent_prompt', 'trigger_slug', 'qualified_name', 'trigger_variables' } trigger_fields = {} diff --git a/backend/core/tools/agent_builder_tools/trigger_tool.py b/backend/core/tools/agent_builder_tools/trigger_tool.py index 938d83e2..4a50a91e 100644 --- a/backend/core/tools/agent_builder_tools/trigger_tool.py +++ b/backend/core/tools/agent_builder_tools/trigger_tool.py @@ -1,4 +1,5 @@ import json +import re from typing import Optional, Dict, Any, List from core.agentpress.tool import ToolResult, openapi_schema from core.agentpress.thread_manager import ThreadManager @@ -17,6 +18,17 @@ from core.composio_integration.composio_trigger_service import ComposioTriggerSe class TriggerTool(AgentBuilderBaseTool): def __init__(self, thread_manager: ThreadManager, db_connection, agent_id: str): super().__init__(thread_manager, db_connection, agent_id) + + def _extract_variables(self, text: str) -> List[str]: + """Extract variable names from a text containing {{variable}} patterns""" + pattern = r'\{\{(\w+)\}\}' + matches = re.findall(pattern, text) + return list(set(matches)) + + def _has_variables(self, text: str) -> bool: + """Check if text contains any {{variable}} patterns""" + pattern = r'\{\{(\w+)\}\}' + return bool(re.search(pattern, text)) async def _sync_triggers_to_version_config(self) -> None: try: @@ -63,7 +75,7 @@ class TriggerTool(AgentBuilderBaseTool): "type": "function", "function": { "name": "create_scheduled_trigger", - "description": "Create a scheduled trigger for the agent to execute at specified times using cron expressions. This allows the agent to run automatically on a schedule.", + "description": "Create a scheduled trigger for the agent to execute at specified times using cron expressions. This allows the agent to run automatically on a schedule. TEMPLATE VARIABLES: Use {{variable_name}} syntax in prompts to create reusable templates. Example: Instead of 'Monitor Apple brand', use 'Monitor {{company_name}} brand'. Users will provide their own values when installing.", "parameters": { "type": "object", "properties": { @@ -81,7 +93,7 @@ class TriggerTool(AgentBuilderBaseTool): }, "agent_prompt": { "type": "string", - "description": "Prompt to send to the agent when triggered" + "description": "Prompt to send to the agent when triggered. Can include variables like {{variable_name}} that will be replaced when users install the template. For example: 'Monitor {{company_name}} brand across all platforms...'" } }, "required": ["name", "cron_expression", "agent_prompt"] @@ -99,12 +111,20 @@ class TriggerTool(AgentBuilderBaseTool): if not agent_prompt: return self.fail_response("agent_prompt is required") + # Extract variables from the prompt + variables = self._extract_variables(agent_prompt) + trigger_config = { "cron_expression": cron_expression, "provider_id": "schedule", "agent_prompt": agent_prompt } + # Add variables to config if any were found + if variables: + trigger_config["trigger_variables"] = variables + logger.debug(f"Found variables in trigger prompt: {variables}") + trigger_svc = get_trigger_service(self.db) try: @@ -120,6 +140,9 @@ class TriggerTool(AgentBuilderBaseTool): result_message += f"**Schedule**: {cron_expression}\n" result_message += f"**Type**: Agent execution\n" result_message += f"**Prompt**: {agent_prompt}\n" + if variables: + result_message += f"**Template Variables Detected**: {', '.join(['{{' + v + '}}' for v in variables])}\n" + result_message += f"*Note: Users will be prompted to provide values for these variables when installing this agent as a template.*\n" result_message += f"\nThe trigger is now active and will run according to the schedule." # Sync triggers to version config @@ -134,7 +157,8 @@ class TriggerTool(AgentBuilderBaseTool): "name": trigger.name, "description": trigger.description, "cron_expression": cron_expression, - "is_active": trigger.is_active + "is_active": trigger.is_active, + "variables": variables if variables else [] } }) except ValueError as ve: @@ -372,7 +396,7 @@ class TriggerTool(AgentBuilderBaseTool): "type": "function", "function": { "name": "create_event_trigger", - "description": "Create a Composio event-based trigger for this agent. First list apps and triggers, then pass the chosen trigger slug, profile_id, and trigger_config.", + "description": "Create a Composio event-based trigger for this agent. First list apps and triggers, then pass the chosen trigger slug, profile_id, and trigger_config. You can use variables in the prompt like {{company_name}} or {{brand_name}} to make templates reusable.", "parameters": { "type": "object", "properties": { @@ -380,7 +404,7 @@ class TriggerTool(AgentBuilderBaseTool): "profile_id": {"type": "string", "description": "Composio profile_id to use (must be connected)"}, "trigger_config": {"type": "object", "description": "Trigger configuration object per trigger schema", "additionalProperties": True}, "name": {"type": "string", "description": "Optional friendly name for the trigger"}, - "agent_prompt": {"type": "string", "description": "Prompt to pass to the agent when triggered"}, + "agent_prompt": {"type": "string", "description": "Prompt to pass to the agent when triggered. Can include variables like {{variable_name}} that will be replaced when users install the template. For example: 'New email received for {{company_name}}...'"}, "connected_account_id": {"type": "string", "description": "Connected account id; if omitted we try to derive from profile"} }, "required": ["slug", "profile_id", "agent_prompt"] @@ -399,6 +423,9 @@ class TriggerTool(AgentBuilderBaseTool): try: if not agent_prompt: return self.fail_response("agent_prompt is required") + + # Extract variables from the prompt + variables = self._extract_variables(agent_prompt) # Get profile config profile_service = ComposioProfileService(self.db) @@ -528,6 +555,11 @@ class TriggerTool(AgentBuilderBaseTool): "agent_prompt": agent_prompt } + # Add variables to config if any were found + if variables: + suna_config["trigger_variables"] = variables + logger.debug(f"Found variables in event trigger prompt: {variables}") + # Create Suna trigger trigger_svc = get_trigger_service(self.db) try: @@ -550,13 +582,17 @@ class TriggerTool(AgentBuilderBaseTool): message = f"Event trigger '{trigger.name}' created successfully.\n" message += "Agent execution configured." + if variables: + message += f"\n**Template Variables Detected**: {', '.join(['{{' + v + '}}' for v in variables])}\n" + message += f"*Note: Users will be prompted to provide values for these variables when installing this agent as a template.*" return self.success_response({ "message": message, "trigger": { "provider": "composio", "slug": slug, - "is_active": trigger.is_active + "is_active": trigger.is_active, + "variables": variables if variables else [] } }) except Exception as e: diff --git a/backend/core/tools/sb_docs_tool.py b/backend/core/tools/sb_docs_tool.py index 33719c04..5a8f94b7 100644 --- a/backend/core/tools/sb_docs_tool.py +++ b/backend/core/tools/sb_docs_tool.py @@ -429,7 +429,7 @@ IMPORTANT: All content must be wrapped in proper HTML tags. Do not use unsupport doc_info = all_metadata["documents"][doc_id] try: - await self.sandbox.fs.remove_file(doc_info["path"]) + await self.sandbox.fs.delete_file(doc_info["path"]) except: pass @@ -509,4 +509,400 @@ IMPORTANT: All content must be wrapped in proper HTML tags. Do not use unsupport "guide": guide, "message": "Use this guide to format HTML content for TipTap editor" }) + + def _generate_pdf_html(self, title: str, content: str, metadata: Optional[Dict] = None) -> str: + css_styles = """ + + """ + + current_time = datetime.now().strftime("%B %d, %Y at %I:%M %p") + + doc_html = f""" + + +
+ + +{html.escape(content_str)}" + title = doc_info["title"] + metadata = doc_info.get("metadata", {}) + + complete_html = self._generate_pdf_html(title, content, metadata) + + temp_html_filename = f"temp_pdf_{doc_id}.html" + temp_html_path = f"/workspace/{temp_html_filename}" + await self.sandbox.fs.upload_file(complete_html.encode(), temp_html_path) + + logger.info(f"Creating PDF from document: {title}") + + pdf_generation_script = f""" +import asyncio +from playwright.async_api import async_playwright +import sys + +async def html_to_pdf(): + try: + async with async_playwright() as p: + browser = await p.chromium.launch( + headless=True, + args=['--no-sandbox', '--disable-setuid-sandbox'] + ) + + page = await browser.new_page() + + await page.goto('file://{temp_html_path}', wait_until='networkidle') + + pdf_filename = '{self._sanitize_filename(title)}_{doc_id}.pdf' + pdf_path = f'/workspace/docs/{{pdf_filename}}' + + await page.pdf( + path=pdf_path, + format='A4', + print_background=True, + margin={{ + 'top': '0.5in', + 'right': '0.5in', + 'bottom': '0.5in', + 'left': '0.5in' + }} + ) + + await browser.close() + + print(pdf_path) + return pdf_path + + except Exception as e: + print(f"ERROR: {{str(e)}}", file=sys.stderr) + sys.exit(1) + +if __name__ == "__main__": + pdf_path = asyncio.run(html_to_pdf()) +""" + + script_path = f"/workspace/temp_pdf_script_{doc_id}.py" + await self.sandbox.fs.upload_file(pdf_generation_script.encode(), script_path) + + response = await self.sandbox.process.exec( + f"cd /workspace && python {script_path}", + timeout=30 + ) + + await self.sandbox.fs.delete_file(temp_html_path) + await self.sandbox.fs.delete_file(script_path) + + if response.exit_code != 0: + logger.error(f"PDF generation failed: {response.result}") + return self.fail_response(f"Failed to generate PDF: {response.result}") + + pdf_path = response.result.strip() + pdf_filename = pdf_path.split('/')[-1] + + pdf_info = { + "doc_id": doc_id, + "title": title, + "pdf_filename": pdf_filename, + "pdf_path": pdf_path, + "created_at": datetime.now().isoformat(), + "source_document": doc_info + } + + all_metadata["documents"][doc_id]["last_pdf_export"] = { + "filename": pdf_filename, + "path": pdf_path, + "exported_at": datetime.now().isoformat() + } + await self._save_metadata(all_metadata) + + preview_url = None + download_url = None + if hasattr(self, '_sandbox_url') and self._sandbox_url: + preview_url = f"{self._sandbox_url}/docs/{pdf_filename}" + download_url = preview_url + + if download: + pdf_content = await self.sandbox.fs.download_file(pdf_path) + + import base64 + pdf_base64 = base64.b64encode(pdf_content).decode('utf-8') + + return self.success_response({ + "success": True, + "message": f"PDF generated successfully from document '{title}'", + "pdf_info": pdf_info, + "pdf_base64": pdf_base64, + "pdf_filename": pdf_filename, + "preview_url": preview_url, + "download_url": download_url, + "sandbox_id": self.sandbox_id + }) + else: + return self.success_response({ + "success": True, + "message": f"PDF saved successfully: {pdf_filename}", + "pdf_info": pdf_info, + "preview_url": preview_url, + "download_url": download_url, + "sandbox_id": self.sandbox_id + }) + + except Exception as e: + logger.error(f"Error converting document to PDF: {str(e)}") + return self.fail_response(f"Error converting document to PDF: {str(e)}") \ No newline at end of file diff --git a/backend/core/tools/sb_kb_tool.py b/backend/core/tools/sb_kb_tool.py index 0877daba..3dc564a8 100644 --- a/backend/core/tools/sb_kb_tool.py +++ b/backend/core/tools/sb_kb_tool.py @@ -5,6 +5,7 @@ from core.sandbox.tool_base import SandboxToolsBase from core.agentpress.thread_manager import ThreadManager from core.utils.config import config from core.knowledge_base.validation import FileNameValidator, ValidationError +from core.utils.logger import logger class SandboxKbTool(SandboxToolsBase): """Tool for knowledge base operations using kb-fusion binary in a Daytona sandbox. @@ -16,11 +17,9 @@ class SandboxKbTool(SandboxToolsBase): self.kb_download_url = f"https://github.com/kortix-ai/kb-fusion/releases/download/v{self.kb_version}/kb" async def _execute_kb_command(self, command: str) -> dict: - """Execute a kb command with OPENAI_API_KEY environment variable set.""" await self._ensure_sandbox() - + env = {"OPENAI_API_KEY": config.OPENAI_API_KEY} if config.OPENAI_API_KEY else {} - response = await self.sandbox.process.exec(command, env=env) return { @@ -244,7 +243,6 @@ class SandboxKbTool(SandboxToolsBase): async def ls_kb(self) -> ToolResult: try: result = await self._execute_kb_command("kb ls") - if result["exit_code"] != 0: return self.fail_response(f"List operation failed: {result['output']}") diff --git a/backend/core/utils/tool_groups.py b/backend/core/utils/tool_groups.py index 31283e9d..c9b76042 100644 --- a/backend/core/utils/tool_groups.py +++ b/backend/core/utils/tool_groups.py @@ -631,6 +631,12 @@ TOOL_GROUPS: Dict[str, ToolGroup] = { description="Create new documents with rich text content", enabled=True ), + ToolMethod( + name="convert_to_pdf", + display_name="Convert to PDF", + description="Convert a document to PDF format", + enabled=True + ), ToolMethod( name="read_document", display_name="Read Document", diff --git a/frontend/src/app/(dashboard)/agents/page.tsx b/frontend/src/app/(dashboard)/agents/page.tsx index afe6ffbb..91a80367 100644 --- a/frontend/src/app/(dashboard)/agents/page.tsx +++ b/frontend/src/app/(dashboard)/agents/page.tsx @@ -333,7 +333,8 @@ export default function AgentsPage() { instanceName?: string, profileMappings?: Record
- Give your agent a name and we'll set everything up. -
-+ This template has triggers with variables that need your values. +
++ Give your agent a name and we'll set everything up. +
+{errors.agent_prompt}
)}
- Use payload
to include trigger data
+ Use {'{{variable_name}}'}
to add variables to the prompt