diff --git a/src/praisonai/praisonai/ui/_external_agents.py b/src/praisonai/praisonai/ui/_external_agents.py new file mode 100644 index 000000000..f0223c722 --- /dev/null +++ b/src/praisonai/praisonai/ui/_external_agents.py @@ -0,0 +1,141 @@ +"""Shared helpers for wiring external agents into any UI entry point. + +Single source of truth for: +- Listing installed external agents (lazy, cached) +- Rendering Chainlit Switch widgets / aiui settings entries +- Building the tools list from enabled agents +""" + +from functools import lru_cache +from typing import Any, Callable, Dict, List, Optional + +# Map of UI toggle id → (integration class path, pretty label) +EXTERNAL_AGENTS: Dict[str, Dict[str, str]] = { + "claude_enabled": {"module": "claude_code", "cls": "ClaudeCodeIntegration", + "label": "Claude Code (coding, file edits)", "cli": "claude"}, + "gemini_enabled": {"module": "gemini_cli", "cls": "GeminiCLIIntegration", + "label": "Gemini CLI (analysis, search)", "cli": "gemini"}, + "codex_enabled": {"module": "codex_cli", "cls": "CodexCLIIntegration", + "label": "Codex CLI (refactoring)", "cli": "codex"}, + "cursor_enabled": {"module": "cursor_cli", "cls": "CursorCLIIntegration", + "label": "Cursor CLI (IDE tasks)", "cli": "cursor-agent"}, +} + + +@lru_cache(maxsize=1) +def installed_external_agents() -> List[str]: + """Return toggle ids of external agents whose CLI is on PATH.""" + import shutil + return [toggle_id for toggle_id, meta in EXTERNAL_AGENTS.items() + if shutil.which(meta["cli"])] + + +def external_agent_tools(settings: Dict[str, Any], workspace: str = ".") -> list: + """Build tools list from settings dict of toggle_id → bool.""" + import importlib + tools = [] + for toggle_id, enabled in settings.items(): + if not enabled or toggle_id not in EXTERNAL_AGENTS: + continue + meta = EXTERNAL_AGENTS[toggle_id] + try: + mod = importlib.import_module(f"praisonai.integrations.{meta['module']}") + integration = getattr(mod, meta["cls"])(workspace=workspace) + if integration.is_available: + tools.append(integration.as_tool()) + except (ImportError, AttributeError): + continue # Integration module/class not available + except Exception as e: # noqa: BLE001 — isolate faulty integrations + import logging + logging.getLogger(__name__).warning( + "Skipping external agent %s due to error: %s", toggle_id, e + ) + continue + return tools + + +def chainlit_switches(current_settings: Dict[str, bool]): + """Return Chainlit Switch widgets for installed external agents only.""" + from chainlit.input_widget import Switch + return [ + Switch(id=toggle_id, label=EXTERNAL_AGENTS[toggle_id]["label"], + initial=current_settings.get(toggle_id, False)) + for toggle_id in installed_external_agents() + ] + + +def aiui_settings_entries() -> Dict[str, Any]: + """Return aiui settings entries for installed external agents.""" + settings = {} + for toggle_id in installed_external_agents(): + meta = EXTERNAL_AGENTS[toggle_id] + settings[toggle_id] = { + "type": "checkbox", + "label": meta["label"], + "default": False + } + return settings + + +def _parse_setting_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + if value is None: + return False + return str(value).strip().lower() in {"true", "1", "yes", "on"} + + +def load_external_agent_settings_from_chainlit( + load_setting_fn: Optional[Callable[[str], Any]] = None, +) -> Dict[str, bool]: + """Load external agent settings from Chainlit session and persistent storage. + + Args: + load_setting_fn: Optional callback used to load persisted settings by key. + If omitted, falls back to importing ``praisonai.ui.chat.load_setting`` + for backward compatibility. + """ + import chainlit as cl + settings = {toggle_id: False for toggle_id in EXTERNAL_AGENTS} + loader = load_setting_fn + + # Try to load from persistent storage (if load_setting is available) + if loader is None: + try: + # Backward-compatible fallback for callers that don't pass a loader + from praisonai.ui.chat import load_setting as loader # type: ignore + except ImportError: + loader = None + + if loader is not None: + legacy_claude = _parse_setting_bool(loader("claude_code_enabled")) + + # Load all current toggles from persistent storage first + for toggle_id in EXTERNAL_AGENTS: + persistent_value = loader(toggle_id) + if persistent_value: # non-empty string means explicitly stored + settings[toggle_id] = _parse_setting_bool(persistent_value) + + # Apply legacy migration only where no explicit value was stored + if legacy_claude and not loader("claude_enabled"): + settings["claude_enabled"] = True + + # Load from session (may override persistent settings) + # Check for legacy key in session + if _parse_setting_bool(cl.user_session.get("claude_code_enabled", False)): + settings["claude_enabled"] = True + + # Load all current toggles from session + for toggle_id in EXTERNAL_AGENTS: + session_value = cl.user_session.get(toggle_id) + if session_value is not None: + settings[toggle_id] = _parse_setting_bool(session_value) + + return settings + + +def save_external_agent_settings_to_chainlit(settings: Dict[str, bool]): + """Save external agent settings to Chainlit session.""" + import chainlit as cl + for toggle_id, enabled in settings.items(): + cl.user_session.set(toggle_id, enabled) diff --git a/src/praisonai/praisonai/ui/agents.py b/src/praisonai/praisonai/ui/agents.py index f2437dae1..7efa4bb8a 100644 --- a/src/praisonai/praisonai/ui/agents.py +++ b/src/praisonai/praisonai/ui/agents.py @@ -426,6 +426,26 @@ def step_callback_sync(step_details): except Exception as e: logger.error(f"Error in step_callback_sync: {e}", exc_info=True) + # Get external agent tools from current settings + external_tools = [] + try: + from praisonai.ui._external_agents import external_agent_tools, EXTERNAL_AGENTS + settings = cl.user_session.get("settings", {}) + external_settings = {k: settings.get(k, False) for k in EXTERNAL_AGENTS} + workspace = os.environ.get("PRAISONAI_WORKSPACE", ".") + external_tools = external_agent_tools(external_settings, workspace=workspace) + except ImportError: + pass + + # Get existing tools from details + existing_tools = details.get('tools', []) + if isinstance(existing_tools, str): + # If tools is a string, it might be a module reference - keep as is + all_tools = existing_tools + else: + # Combine existing and external tools + all_tools = (existing_tools or []) + external_tools + agent = Agent( name=role_name, role=role_filled, @@ -439,7 +459,8 @@ def step_callback_sync(step_details): max_execution_time=details.get('max_execution_time'), cache=details.get('cache', True), step_callback=step_callback_sync, - reflection=details.get('self_reflect', False) + reflection=details.get('self_reflect', False), + tools=all_tools if all_tools else None ) agents_map[role] = agent @@ -485,8 +506,10 @@ def step_callback_sync(step_details): logger.warning(f"Tool '{tool_name}' not found. Skipping.") # Set the agent's tools after collecting all tools - if role_tools: - agent.tools = role_tools + # Merge resolved YAML tools with external agent tools so both survive + merged_tools = role_tools + external_tools + if merged_tools: + agent.tools = merged_tools for tname, tdetails in details.get('tasks', {}).items(): description_filled = tdetails['description'].format(topic=topic) @@ -674,6 +697,18 @@ async def start_chat(): with open("agents.yaml", "w") as f: f.write("# Add your custom agents here\n") + # Load external agent settings + try: + from praisonai.ui._external_agents import ( + chainlit_switches, + load_external_agent_settings_from_chainlit, + ) + external_settings = load_external_agent_settings_from_chainlit(load_setting) + except ImportError: + def chainlit_switches(_settings): # no-op fallback + return [] + external_settings = {} + settings = await cl.ChatSettings( [ TextInput(id="Model", label="OpenAI - Model", initial=model_name), @@ -685,6 +720,7 @@ async def start_chat(): values=["praisonai", "crewai", "autogen"], initial_index=0, ), + *chainlit_switches(external_settings) ] ).send() cl.user_session.set("settings", settings) @@ -719,6 +755,7 @@ async def start_chat(): ), TextInput(id="agents", label="agents.yaml", initial=yaml_content, multiline=True), TextInput(id="tools", label="tools.py", initial=tools_content, multiline=True), + *chainlit_switches(external_settings) ] ).send() cl.user_session.set("settings", settings) diff --git a/src/praisonai/praisonai/ui/chat.py b/src/praisonai/praisonai/ui/chat.py index 4233f86ec..05cb9e34c 100644 --- a/src/praisonai/praisonai/ui/chat.py +++ b/src/praisonai/praisonai/ui/chat.py @@ -400,28 +400,40 @@ def auth_callback(username: str, password: str): logger.warning(f"Login failed for user: {username}") return None -def _get_or_create_agent(model_name: str, tools_enabled: bool = True): +def _get_or_create_agent(model_name: str, tools_enabled: bool = True, external_agents_settings: dict = None): """Get or create a reusable agent for the session.""" Agent = _get_praisonai_agent() if Agent is None: return None + if external_agents_settings is None: + external_agents_settings = {} # Get cached agent from session cached_agent = cl.user_session.get("_cached_agent") cached_model = cl.user_session.get("_cached_agent_model") + cached_external = cl.user_session.get("_cached_agent_external", {}) - # Reuse if model matches - if cached_agent is not None and cached_model == model_name: + # Reuse if model and external settings match + if (cached_agent is not None and cached_model == model_name + and cached_external == external_agents_settings): return cached_agent # Create new agent with interactive tools _profile_start("create_agent") tools = [] if tools_enabled: - tools = _get_interactive_tools() + tools = list(_get_interactive_tools()) # Copy to avoid mutating cache # Add Tavily if available if os.getenv("TAVILY_API_KEY"): tools.append(tavily_web_search) + # Add external agent tools + from praisonai.ui._external_agents import external_agent_tools + tools.extend( + external_agent_tools( + external_agents_settings, + workspace=os.environ.get("PRAISONAI_WORKSPACE", "."), + ) + ) agent = Agent( name="PraisonAI Assistant", @@ -443,10 +455,27 @@ def _get_or_create_agent(model_name: str, tools_enabled: bool = True): # Cache the agent cl.user_session.set("_cached_agent", agent) cl.user_session.set("_cached_agent_model", model_name) + cl.user_session.set("_cached_agent_external", external_agents_settings) _profile_end("create_agent") return agent + +def _parse_setting_bool(value): + if isinstance(value, bool): + return value + if value is None: + return False + return str(value).strip().lower() in {"true", "1", "yes", "on"} + + +def _get_external_agent_settings_from_session() -> dict: + from praisonai.ui._external_agents import EXTERNAL_AGENTS + return { + toggle_id: _parse_setting_bool(cl.user_session.get(toggle_id, False)) + for toggle_id in EXTERNAL_AGENTS + } + @cl.on_chat_start async def start(): _profile_start("on_chat_start") @@ -459,8 +488,12 @@ async def start(): cl.user_session.set("tools_enabled", tools_enabled) logger.debug(f"Model name: {model_name}, Tools enabled: {tools_enabled}") + # Load external agent settings + from praisonai.ui._external_agents import load_external_agent_settings_from_chainlit, chainlit_switches + external_settings = load_external_agent_settings_from_chainlit(load_setting) + # Pre-create agent for faster first response - _get_or_create_agent(model_name, tools_enabled) + _get_or_create_agent(model_name, tools_enabled, external_settings) settings = cl.ChatSettings( [ @@ -474,7 +507,8 @@ async def start(): id="tools_enabled", label="Enable Tools (ACP, LSP, Web Search)", initial=tools_enabled - ) + ), + *chainlit_switches(external_settings) ] ) cl.user_session.set("settings", settings) @@ -508,14 +542,24 @@ async def setup_agent(settings): cl.user_session.set("model_name", model_name) cl.user_session.set("tools_enabled", tools_enabled) - # Invalidate cached agent if model changed + # Save external agent settings + from praisonai.ui._external_agents import save_external_agent_settings_to_chainlit, EXTERNAL_AGENTS + external_agent_settings = {k: settings.get(k, False) for k in EXTERNAL_AGENTS} + save_external_agent_settings_to_chainlit(external_agent_settings) + + # Invalidate cached agent if model changed or external agents changed cached_model = cl.user_session.get("_cached_agent_model") - if cached_model != model_name: + cached_external = cl.user_session.get("_cached_agent_external", {}) + if cached_model != model_name or cached_external != external_agent_settings: cl.user_session.set("_cached_agent", None) cl.user_session.set("_cached_agent_model", None) + cl.user_session.set("_cached_agent_external", None) save_setting("model_name", model_name) save_setting("tools_enabled", str(tools_enabled).lower()) + # Save external agent settings to persistent storage + for toggle_id, enabled in external_agent_settings.items(): + save_setting(toggle_id, str(enabled).lower()) thread_id = cl.user_session.get("thread_id") if thread_id: @@ -572,7 +616,8 @@ async def main(message: cl.Message): msg = cl.Message(content="") # Try PraisonAI Agent first (faster, with tool reuse) - agent = _get_or_create_agent(model_name, tools_enabled) if tools_enabled else None + external_settings = _get_external_agent_settings_from_session() + agent = _get_or_create_agent(model_name, tools_enabled, external_settings) if tools_enabled else None if agent is not None: _profile_start("agent_response") @@ -781,6 +826,11 @@ async def on_chat_resume(thread: ThreadDict): tools_enabled = (load_setting("tools_enabled") or "true").lower() == "true" logger.debug(f"Model name: {model_name}") + + # Load external agent settings for resume + from praisonai.ui._external_agents import load_external_agent_settings_from_chainlit, chainlit_switches + external_settings = load_external_agent_settings_from_chainlit() + settings = cl.ChatSettings( [ TextInput( @@ -793,7 +843,8 @@ async def on_chat_resume(thread: ThreadDict): id="tools_enabled", label="Enable Tools (ACP, LSP, Web Search)", initial=tools_enabled - ) + ), + *chainlit_switches(external_settings), ] ) await settings.send() @@ -828,7 +879,8 @@ async def on_chat_resume(thread: ThreadDict): cl.user_session.set("message_history", message_history) # Pre-create agent for faster first response - _get_or_create_agent(model_name, tools_enabled) + external_settings = _get_external_agent_settings_from_session() + _get_or_create_agent(model_name, tools_enabled, external_settings) image_data = metadata.get("image") if image_data: diff --git a/src/praisonai/praisonai/ui/code.py b/src/praisonai/praisonai/ui/code.py index a492c5d5d..8986c1012 100644 --- a/src/praisonai/praisonai/ui/code.py +++ b/src/praisonai/praisonai/ui/code.py @@ -246,101 +246,7 @@ def _get_cached_context(repo_path: str, force_refresh: bool = False): return context, token_count, context_tree -# Claude Code Tool Function (lazy subprocess) -async def claude_code_tool(query: str) -> str: - """Execute Claude Code CLI commands for file modifications and coding tasks.""" - subprocess = _get_subprocess() - datetime = _get_datetime() - - try: - repo_path = os.environ.get("PRAISONAI_CODE_REPO_PATH", ".") - - git_available = False - try: - subprocess.run(["git", "status"], cwd=repo_path, capture_output=True, check=True) - git_available = True - except (subprocess.CalledProcessError, FileNotFoundError): - git_available = False - - claude_cmd = ["claude", "--dangerously-skip-permissions", "-p", query] - - user_session_context = cl.user_session.get("claude_code_context", False) - if user_session_context: - claude_cmd.insert(1, "--continue") - - result = subprocess.run( - claude_cmd, - cwd=repo_path, - capture_output=True, - text=True, - timeout=300 - ) - - cl.user_session.set("claude_code_context", True) - - output = result.stdout - if result.stderr: - output += f"\n\nErrors:\n{result.stderr}" - - if git_available and result.returncode == 0: - try: - git_status = subprocess.run( - ["git", "status", "--porcelain"], - cwd=repo_path, - capture_output=True, - text=True, - check=True - ) - - if git_status.stdout.strip(): - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - branch_name = f"claude-code-{timestamp}" - - subprocess.run(["git", "checkout", "-b", branch_name], cwd=repo_path, check=True) - subprocess.run(["git", "add", "."], cwd=repo_path, check=True) - commit_message = f"Claude Code changes: {query[:50]}..." - subprocess.run( - ["git", "commit", "-m", commit_message], - cwd=repo_path, - check=True - ) - - try: - subprocess.run( - ["git", "push", "-u", "origin", branch_name], - cwd=repo_path, - check=True - ) - - remote_url = subprocess.run( - ["git", "config", "--get", "remote.origin.url"], - cwd=repo_path, - capture_output=True, - text=True - ) - - if remote_url.returncode == 0: - repo_url = remote_url.stdout.strip() - if repo_url.endswith(".git"): - repo_url = repo_url[:-4] - if "github.com" in repo_url: - pr_url = f"{repo_url}/compare/main...{branch_name}?quick_pull=1" - output += f"\n\nšŸ“‹ **Pull Request Created:**\n{pr_url}" - - except subprocess.CalledProcessError: - output += f"\n\n🌲 **Branch created:** {branch_name} (push manually if needed)" - - except subprocess.CalledProcessError as e: - output += f"\n\nGit operations failed: {e}" - - return output - - except subprocess.TimeoutExpired: - return "Claude Code execution timed out after 5 minutes." - except subprocess.CalledProcessError as e: - return f"Claude Code execution failed: {e}\nStdout: {e.stdout}\nStderr: {e.stderr}" - except Exception as e: - return f"Error executing Claude Code: {str(e)}" +# External agents now handled via shared helper # Deferred tool loading _tavily_client = None @@ -414,19 +320,24 @@ def auth_callback(input_username: str, input_password: str): else: return None -def _get_or_create_agent(model_name: str, tools_enabled: bool = True, claude_code_enabled: bool = False): +def _get_or_create_agent(model_name: str, tools_enabled: bool = True, external_agents_settings: dict = None): """Get or create a reusable agent for the session.""" Agent = _get_praisonai_agent() if Agent is None: return None + # Default external agents settings + if external_agents_settings is None: + external_agents_settings = {} + # Get cached agent from session cached_agent = cl.user_session.get("_cached_agent") cached_model = cl.user_session.get("_cached_agent_model") - cached_claude = cl.user_session.get("_cached_agent_claude") + cached_external = cl.user_session.get("_cached_agent_external", {}) - # Reuse if model and claude setting match - if cached_agent is not None and cached_model == model_name and cached_claude == claude_code_enabled: + # Reuse if model and external agents settings match + if (cached_agent is not None and cached_model == model_name and + cached_external == external_agents_settings): return cached_agent # Create new agent with interactive tools @@ -437,26 +348,40 @@ def _get_or_create_agent(model_name: str, tools_enabled: bool = True, claude_cod # Add Tavily if available if os.getenv("TAVILY_API_KEY"): tools.append(tavily_web_search) - # Add Claude Code if enabled - if claude_code_enabled: - tools.append(claude_code_tool) + # Add external agent tools + from praisonai.ui._external_agents import external_agent_tools + workspace = os.environ.get("PRAISONAI_CODE_REPO_PATH", ".") + tools.extend(external_agent_tools(external_agents_settings, workspace=workspace)) + + # Build dynamic instructions based on available external agents + available_agents = [] + if external_agents_settings.get("claude_enabled"): + available_agents.append("**Claude Code**: Execute complex coding tasks with Claude Code CLI") + if external_agents_settings.get("gemini_enabled"): + available_agents.append("**Gemini CLI**: Analysis and search capabilities") + if external_agents_settings.get("codex_enabled"): + available_agents.append("**Codex CLI**: Advanced code refactoring") + if external_agents_settings.get("cursor_enabled"): + available_agents.append("**Cursor CLI**: IDE-style development tasks") + + external_capabilities = "\n- ".join([""] + available_agents) if available_agents else "" agent = Agent( name="PraisonAI Code Assistant", - instructions="""You are a powerful AI code assistant with access to comprehensive development tools. + instructions=f"""You are a powerful AI code assistant with access to comprehensive development tools. Available capabilities: - **File Operations**: Read, write, create, edit, delete files using ACP tools - **Code Intelligence**: Find symbols, definitions, references using LSP tools - **Command Execution**: Run shell commands safely -- **Web Search**: Search the web for documentation and solutions (if Tavily API key is set) -- **Claude Code**: Execute complex coding tasks with Claude Code CLI (if enabled) +- **Web Search**: Search the web for documentation and solutions (if Tavily API key is set){external_capabilities} When helping with code: 1. Use ACP tools for safe, reviewable file modifications 2. Use LSP tools to understand code structure before making changes -3. Always explain what you're doing and why -4. Test changes when possible +3. Delegate complex tasks to specialized external agents when available +4. Always explain what you're doing and why +5. Test changes when possible Trust mode is enabled - tool executions are auto-approved for efficiency.""", llm=model_name, @@ -467,7 +392,7 @@ def _get_or_create_agent(model_name: str, tools_enabled: bool = True, claude_cod # Cache the agent cl.user_session.set("_cached_agent", agent) cl.user_session.set("_cached_agent_model", model_name) - cl.user_session.set("_cached_agent_claude", claude_code_enabled) + cl.user_session.set("_cached_agent_external", external_agents_settings) _profile_end("create_agent") return agent @@ -478,18 +403,35 @@ async def start(): _ensure_env_loaded() model_name = load_setting("model_name") or os.getenv("MODEL_NAME", "gpt-4o-mini") - claude_code_enabled = os.getenv("PRAISONAI_CLAUDECODE_ENABLED", "false").lower() == "true" - if not claude_code_enabled: - claude_code_enabled = (load_setting("claude_code_enabled") or "false").lower() == "true" tools_enabled = (load_setting("tools_enabled") or "true").lower() == "true" + # Load external agent settings with backward compatibility + from praisonai.ui._external_agents import chainlit_switches, EXTERNAL_AGENTS + external_settings = {} + + # Check for legacy claude_code_enabled setting + legacy_claude = os.getenv("PRAISONAI_CLAUDECODE_ENABLED", "false").lower() == "true" + if not legacy_claude: + legacy_claude = (load_setting("claude_code_enabled") or "false").lower() == "true" + if legacy_claude: + external_settings["claude_enabled"] = True + + # Load all external agent settings + for toggle_id in EXTERNAL_AGENTS: + if toggle_id not in external_settings: # Don't override claude if set via legacy + setting_value = load_setting(toggle_id) + external_settings[toggle_id] = setting_value and setting_value.lower() == "true" + cl.user_session.set("model_name", model_name) - cl.user_session.set("claude_code_enabled", claude_code_enabled) cl.user_session.set("tools_enabled", tools_enabled) - logger.debug(f"Model name: {model_name}, Claude Code: {claude_code_enabled}, Tools: {tools_enabled}") + # Save external settings to session + for toggle_id, value in external_settings.items(): + cl.user_session.set(toggle_id, value) + + logger.debug(f"Model: {model_name}, Tools: {tools_enabled}, External agents: {external_settings}") # Pre-create agent for faster first response - _get_or_create_agent(model_name, tools_enabled, claude_code_enabled) + _get_or_create_agent(model_name, tools_enabled, external_settings) settings = cl.ChatSettings( [ @@ -504,11 +446,7 @@ async def start(): label="Enable Tools (ACP, LSP, Web Search)", initial=tools_enabled ), - Switch( - id="claude_code_enabled", - label="Enable Claude Code (file modifications & coding)", - initial=claude_code_enabled - ) + *chainlit_switches(external_settings) ] ) cl.user_session.set("settings", settings) @@ -524,11 +462,15 @@ async def start(): if len(tools) > 5: tool_names.append(f"... and {len(tools) - 5} more") + # Build external agents status + enabled_agents = [agent for agent, enabled in external_settings.items() if enabled] + agents_status = ", ".join(enabled_agents) if enabled_agents else "None" + await cl.Message( content=f"šŸš€ **PraisonAI Code Assistant Ready**\n\n" f"**Model:** {model_name}\n" f"**Tools:** {len(tools)} loaded ({', '.join(tool_names)})\n" - f"**Claude Code:** {'Enabled' if claude_code_enabled else 'Disabled'}\n" + f"**External Agents:** {agents_status}\n" f"**Trust Mode:** Enabled (auto-approve tool executions)\n" f"**Context:** {token_count} tokens from workspace\n\n" f"**Files in workspace:**\n```\n{context_tree[:500]}{'...' if len(context_tree) > 500 else ''}\n```" @@ -543,24 +485,35 @@ async def setup_agent(settings): cl.user_session.set("settings", settings) model_name = settings["model_name"] - claude_code_enabled = settings.get("claude_code_enabled", False) tools_enabled = settings.get("tools_enabled", True) + # Extract external agent settings + from praisonai.ui._external_agents import EXTERNAL_AGENTS + external_settings = {toggle_id: settings.get(toggle_id, False) for toggle_id in EXTERNAL_AGENTS} + cl.user_session.set("model_name", model_name) - cl.user_session.set("claude_code_enabled", claude_code_enabled) cl.user_session.set("tools_enabled", tools_enabled) + # Save external settings to session + for toggle_id, value in external_settings.items(): + cl.user_session.set(toggle_id, value) # Invalidate cached agent if settings changed cached_model = cl.user_session.get("_cached_agent_model") - cached_claude = cl.user_session.get("_cached_agent_claude") - if cached_model != model_name or cached_claude != claude_code_enabled: + cached_external = cl.user_session.get("_cached_agent_external", {}) + if cached_model != model_name or cached_external != external_settings: cl.user_session.set("_cached_agent", None) cl.user_session.set("_cached_agent_model", None) - cl.user_session.set("_cached_agent_claude", None) + cl.user_session.set("_cached_agent_external", None) save_setting("model_name", model_name) - save_setting("claude_code_enabled", str(claude_code_enabled).lower()) save_setting("tools_enabled", str(tools_enabled).lower()) + # Save external agent settings + for toggle_id, enabled in external_settings.items(): + save_setting(toggle_id, str(enabled).lower()) + + # Backward compatibility - save claude_enabled as claude_code_enabled too + if "claude_enabled" in external_settings: + save_setting("claude_code_enabled", str(external_settings["claude_enabled"]).lower()) thread_id = cl.user_session.get("thread_id") if thread_id: @@ -573,8 +526,10 @@ async def setup_agent(settings): except json.JSONDecodeError: metadata = {} metadata["model_name"] = model_name - metadata["claude_code_enabled"] = claude_code_enabled metadata["tools_enabled"] = tools_enabled + # Save external agent settings to metadata + for toggle_id, enabled in external_settings.items(): + metadata[toggle_id] = enabled await cl_data.update_thread(thread_id, metadata=metadata) cl.user_session.set("metadata", metadata) @@ -585,8 +540,11 @@ async def main(message: cl.Message): datetime = _get_datetime() model_name = cl.user_session.get("model_name") or load_setting("model_name") or os.getenv("MODEL_NAME", "gpt-4o-mini") - claude_code_enabled = cl.user_session.get("claude_code_enabled", False) tools_enabled = cl.user_session.get("tools_enabled", True) + + # Get external agent settings from session + from praisonai.ui._external_agents import EXTERNAL_AGENTS + external_settings = {toggle_id: cl.user_session.get(toggle_id, False) for toggle_id in EXTERNAL_AGENTS} message_history = cl.user_session.get("message_history", []) repo_path = os.environ.get("PRAISONAI_CODE_REPO_PATH", ".") @@ -624,7 +582,7 @@ async def main(message: cl.Message): msg = cl.Message(content="") # Try PraisonAI Agent first (faster, with tool reuse) - agent = _get_or_create_agent(model_name, tools_enabled, claude_code_enabled) if tools_enabled else None + agent = _get_or_create_agent(model_name, tools_enabled, external_settings) if tools_enabled else None if agent is not None: _profile_start("agent_response") @@ -751,11 +709,25 @@ async def on_chat_resume(thread: ThreadDict): logger.info(f"Resuming chat: {thread['id']}") model_name = load_setting("model_name") or os.getenv("MODEL_NAME", "gpt-4o-mini") - claude_code_enabled = os.getenv("PRAISONAI_CLAUDECODE_ENABLED", "false").lower() == "true" - if not claude_code_enabled: - claude_code_enabled = (load_setting("claude_code_enabled") or "false").lower() == "true" tools_enabled = (load_setting("tools_enabled") or "true").lower() == "true" + # Load external agent settings with backward compatibility + from praisonai.ui._external_agents import chainlit_switches, EXTERNAL_AGENTS + external_settings = {} + + # Check for legacy claude_code_enabled setting + legacy_claude = os.getenv("PRAISONAI_CLAUDECODE_ENABLED", "false").lower() == "true" + if not legacy_claude: + legacy_claude = (load_setting("claude_code_enabled") or "false").lower() == "true" + if legacy_claude: + external_settings["claude_enabled"] = True + + # Load all external agent settings + for toggle_id in EXTERNAL_AGENTS: + if toggle_id not in external_settings: # Don't override claude if set via legacy + setting_value = load_setting(toggle_id) + external_settings[toggle_id] = setting_value and setting_value.lower() == "true" + logger.debug(f"Model name: {model_name}") settings = cl.ChatSettings( [ @@ -770,19 +742,17 @@ async def on_chat_resume(thread: ThreadDict): label="Enable Tools (ACP, LSP, Web Search)", initial=tools_enabled ), - Switch( - id="claude_code_enabled", - label="Enable Claude Code (file modifications & coding)", - initial=claude_code_enabled - ) + *chainlit_switches(external_settings) ] ) await settings.send() cl.user_session.set("thread_id", thread["id"]) cl.user_session.set("model_name", model_name) - cl.user_session.set("claude_code_enabled", claude_code_enabled) cl.user_session.set("tools_enabled", tools_enabled) + # Save external settings to session + for toggle_id, value in external_settings.items(): + cl.user_session.set(toggle_id, value) metadata = thread.get("metadata", {}) if isinstance(metadata, str): @@ -810,7 +780,7 @@ async def on_chat_resume(thread: ThreadDict): cl.user_session.set("message_history", message_history) # Pre-create agent for faster first response - _get_or_create_agent(model_name, tools_enabled, claude_code_enabled) + _get_or_create_agent(model_name, tools_enabled, external_settings) image_data = metadata.get("image") if image_data: diff --git a/src/praisonai/praisonai/ui_chat/default_app.py b/src/praisonai/praisonai/ui_chat/default_app.py index 9b91a2334..7045569f1 100644 --- a/src/praisonai/praisonai/ui_chat/default_app.py +++ b/src/praisonai/praisonai/ui_chat/default_app.py @@ -21,6 +21,16 @@ aiui.set_theme(preset="blue", dark_mode=True, radius="lg") aiui.set_pages(["chat"]) +# Add external agent settings +try: + from praisonai.ui._external_agents import aiui_settings_entries + external_settings = aiui_settings_entries() + if external_settings: + aiui.set_settings(external_settings) +except ImportError: + # External agents not available + pass + @aiui.starters async def get_starters(): @@ -39,11 +49,63 @@ async def on_welcome(): await aiui.say("šŸ‘‹ Hi! I'm your PraisonAI assistant. Ask me anything!") +# Session-scoped agent cache to avoid cross-user state leaks +_agents_cache = {} + +@aiui.settings +async def on_settings(new_settings): + # Clear cache for current session when settings change + session_id = getattr(aiui.current_session, 'id', 'default') + if session_id in _agents_cache: + del _agents_cache[session_id] + +def _get_agent(settings: dict | None = None): + session_id = getattr(aiui.current_session, 'id', 'default') + settings_key = str(sorted((settings or {}).items())) + cache_key = f"{session_id}:{settings_key}" + + if cache_key not in _agents_cache: + try: + from praisonaiagents import Agent + from praisonai.ui._external_agents import external_agent_tools + + # Get external agent tools based on settings + tools = external_agent_tools(settings or {}, workspace=os.environ.get("PRAISONAI_WORKSPACE", ".")) + + agent = Agent( + name="PraisonAI", + instructions="You are a helpful assistant. Delegate coding/analysis tasks to external subagents when available.", + llm=os.getenv("MODEL_NAME", "gpt-4o-mini"), + tools=tools if tools else None, + ) + _agents_cache[cache_key] = agent + except ImportError: + # Fallback to OpenAI if PraisonAI agents not available + _agents_cache[cache_key] = None + return _agents_cache[cache_key] + @aiui.reply -async def on_message(message: str): - """Stream a response from OpenAI.""" +async def on_message(message: str, settings: dict | None = None): + """Stream a response using PraisonAI Agent or fallback to OpenAI.""" await aiui.think("Thinking...") - + + # Try PraisonAI Agent first + agent = _get_agent(settings) + if agent is not None: + try: + # Use async call to avoid blocking the event loop + result = await agent.achat(str(message)) + response_text = str(result) if result else "" + words = response_text.split(" ") + for i, word in enumerate(words): + await aiui.stream_token(word + (" " if i < len(words) - 1 else "")) + return + except Exception as e: + import logging + logging.getLogger(__name__).exception("Agent execution failed") + await aiui.say(f"āš ļø Agent error: {e}. Falling back to OpenAI...") + + # Fallback to direct OpenAI try: from openai import AsyncOpenAI except ImportError: diff --git a/src/praisonai/tests/integration/test_ui_external_agents.py b/src/praisonai/tests/integration/test_ui_external_agents.py new file mode 100644 index 000000000..45296ba6a --- /dev/null +++ b/src/praisonai/tests/integration/test_ui_external_agents.py @@ -0,0 +1,196 @@ +""" +Integration tests for external agents UI functionality. + +Tests the shared helper and UI integration for external agents (Claude/Gemini/Codex/Cursor). +""" + +import pytest +import shutil +from unittest.mock import patch, MagicMock +import sys +import os + +# Mark most tests as unit tests to bypass provider gating +# Only TestRealAgenticIntegration is truly integration-level +pytestmark = pytest.mark.unit + +# Add src path for imports +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../")) + +from praisonai.ui._external_agents import ( + EXTERNAL_AGENTS, + installed_external_agents, + external_agent_tools, + chainlit_switches, + aiui_settings_entries, +) + + +class TestExternalAgentsHelper: + """Test the shared external agents helper.""" + + def test_external_agents_config(self): + """Test that EXTERNAL_AGENTS is properly configured.""" + assert isinstance(EXTERNAL_AGENTS, dict) + assert "claude_enabled" in EXTERNAL_AGENTS + assert "gemini_enabled" in EXTERNAL_AGENTS + assert "codex_enabled" in EXTERNAL_AGENTS + assert "cursor_enabled" in EXTERNAL_AGENTS + + # Check structure of each agent config + for toggle_id, config in EXTERNAL_AGENTS.items(): + assert "module" in config + assert "cls" in config + assert "label" in config + assert "cli" in config + + @patch('shutil.which') + def test_installed_external_agents_caching(self, mock_which): + """Test that installed_external_agents properly caches results.""" + # Clear cache + from praisonai.ui._external_agents import installed_external_agents + installed_external_agents.cache_clear() + + # Mock some CLIs as available + def mock_which_side_effect(cli): + return "/usr/bin/claude" if cli == "claude" else None + + mock_which.side_effect = mock_which_side_effect + + # First call should check all CLIs + result1 = installed_external_agents() + assert result1 == ["claude_enabled"] + assert mock_which.call_count == 4 # Called for each CLI + + # Second call should use cache + mock_which.reset_mock() + result2 = installed_external_agents() + assert result2 == ["claude_enabled"] + assert mock_which.call_count == 0 # Not called due to cache + + @patch('importlib.import_module') + def test_external_agent_tools(self, mock_import): + """Test external_agent_tools builds tools correctly.""" + # Mock integration classes + mock_integration = MagicMock() + mock_integration.is_available = True + mock_tool_func = MagicMock() + mock_integration.as_tool.return_value = mock_tool_func + + mock_module = MagicMock() + mock_module.ClaudeCodeIntegration.return_value = mock_integration + mock_import.return_value = mock_module + + settings = {"claude_enabled": True, "gemini_enabled": False} + tools = external_agent_tools(settings) + + assert len(tools) == 1 + assert tools[0] == mock_tool_func + mock_import.assert_called_once_with("praisonai.integrations.claude_code") + mock_integration.as_tool.assert_called_once() + + def test_external_agent_tools_unavailable_integration(self): + """Test external_agent_tools handles missing integrations gracefully.""" + settings = {"nonexistent_enabled": True} + tools = external_agent_tools(settings) + assert tools == [] + + @patch('chainlit.input_widget.Switch') + @patch('praisonai.ui._external_agents.installed_external_agents') + def test_chainlit_switches(self, mock_installed, mock_switch): + """Test chainlit_switches generates correct switches.""" + mock_installed.return_value = ["claude_enabled", "gemini_enabled"] + mock_switch_instance = MagicMock() + mock_switch.return_value = mock_switch_instance + + current_settings = {"claude_enabled": True, "gemini_enabled": False} + switches = chainlit_switches(current_settings) + + assert len(switches) == 2 + assert all(s == mock_switch_instance for s in switches) + assert mock_switch.call_count == 2 + + @patch('praisonai.ui._external_agents.installed_external_agents') + def test_aiui_settings_entries(self, mock_installed): + """Test aiui_settings_entries generates correct settings.""" + mock_installed.return_value = ["claude_enabled", "gemini_enabled"] + + settings = aiui_settings_entries() + + assert isinstance(settings, dict) + assert "claude_enabled" in settings + assert "gemini_enabled" in settings + assert settings["claude_enabled"]["type"] == "checkbox" + assert settings["claude_enabled"]["default"] is False + + +class TestBackwardCompatibility: + """Test backward compatibility with legacy settings.""" + + def test_claude_code_enabled_legacy_support(self): + """Test that claude_code_enabled is mapped to claude_enabled.""" + # This would require mocking chainlit session which is complex + # In practice, this is tested by the load_external_agent_settings_from_chainlit function + # which handles the legacy mapping + pass + + +class TestAvailabilityGating: + """Test that unavailable CLIs are properly hidden.""" + + @patch('shutil.which') + def test_only_available_agents_shown(self, mock_which): + """Test that only available external agents are shown in UI.""" + # Clear cache + from praisonai.ui._external_agents import installed_external_agents + installed_external_agents.cache_clear() + + # Mock only Claude as available + mock_which.side_effect = lambda cli: "/usr/bin/claude" if cli == "claude" else None + + available = installed_external_agents() + assert available == ["claude_enabled"] + + # Verify all CLIs were checked + expected_calls = [cli_config["cli"] for cli_config in EXTERNAL_AGENTS.values()] + actual_calls = [call[0][0] for call in mock_which.call_args_list] + assert set(actual_calls) == set(expected_calls) + + +@pytest.mark.integration +@pytest.mark.provider_google # Only this class needs provider gating +class TestRealAgenticIntegration: + """Real agentic tests - requires actual integrations to be available.""" + + @pytest.mark.skipif(not shutil.which("echo"), reason="echo command not available") + def test_external_agent_tools_real_execution(self): + """Test that external agent tools can be created and are callable.""" + # This is a simplified test using echo command to simulate external CLI + # In a real test environment with Claude Code installed, this would test actual execution + + settings = {"claude_enabled": False} # Disable to avoid actual execution + tools = external_agent_tools(settings) + + # Should return empty list when all disabled + assert tools == [] + + # Test with mock available integration would go here if Claude Code was installed + + def test_settings_to_tools_mapping_consistency(self): + """Test that settings keys map consistently to integration modules.""" + for toggle_id, config in EXTERNAL_AGENTS.items(): + # Verify the toggle_id pattern is consistent + assert toggle_id.endswith("_enabled") + + # Verify module name is valid identifier + module_name = config["module"] + assert module_name.replace("_", "").isalnum() + + # Verify class name follows PascalCase Integration pattern + class_name = config["cls"] + assert class_name.endswith("Integration") + assert class_name[0].isupper() + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) \ No newline at end of file diff --git a/src/praisonai/tests/unit/test_ui_external_agents_helper.py b/src/praisonai/tests/unit/test_ui_external_agents_helper.py new file mode 100644 index 000000000..108bd9244 --- /dev/null +++ b/src/praisonai/tests/unit/test_ui_external_agents_helper.py @@ -0,0 +1,42 @@ +import sys +import types + +from praisonai.ui import _external_agents as ext + + +class _FakeUserSession: + def __init__(self, values): + self._values = values + + def get(self, key, default=None): + return self._values.get(key, default) + + +def test_load_external_agent_settings_uses_explicit_loader(monkeypatch): + fake_chainlit = types.SimpleNamespace(user_session=_FakeUserSession({})) + monkeypatch.setitem(sys.modules, "chainlit", fake_chainlit) + + values = { + "claude_code_enabled": "true", + "claude_enabled": "false", + "gemini_enabled": "true", + "codex_enabled": "1", + } + settings = ext.load_external_agent_settings_from_chainlit(lambda key: values.get(key)) + + assert set(settings.keys()) == set(ext.EXTERNAL_AGENTS.keys()) + assert settings["claude_enabled"] is False + assert settings["gemini_enabled"] is True + assert settings["codex_enabled"] is True + + +def test_load_external_agent_settings_session_overrides_persistent(monkeypatch): + fake_chainlit = types.SimpleNamespace( + user_session=_FakeUserSession({"gemini_enabled": True, "claude_code_enabled": False}) + ) + monkeypatch.setitem(sys.modules, "chainlit", fake_chainlit) + + values = {"gemini_enabled": "false"} + settings = ext.load_external_agent_settings_from_chainlit(lambda key: values.get(key)) + + assert settings["gemini_enabled"] is True