-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
feat: External agents integration across all UI entry points #1423
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9f98625
91e0eaf
533d377
d79f74d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
Comment on lines
+33
to
+54
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Too-narrow
π οΈ Proposed fix- 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):
- # Integration module not available, skip
- continue
+ 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π€ Prompt for AI Agents |
||
|
|
||
|
|
||
| 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) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
greptile-apps[bot] marked this conversation as resolved.
|
||
| 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, | ||
|
greptile-apps[bot] marked this conversation as resolved.
|
||
| ) | ||
| external_settings = load_external_agent_settings_from_chainlit(load_setting) | ||
| except ImportError: | ||
| def chainlit_switches(_settings): # no-op fallback | ||
| return [] | ||
| external_settings = {} | ||
|
Comment on lines
+700
to
+710
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Duplicate import of Line 699 imports π οΈ Proposed fix- # Load external agent settings
- from praisonai.ui._external_agents import chainlit_switches
- try:
- from praisonai.ui._external_agents import load_external_agent_settings_from_chainlit
+ # 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()
except ImportError:
+ def chainlit_switches(_settings): # no-op fallback
+ return []
external_settings = {}π€ Prompt for AI Agents |
||
|
|
||
| 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) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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", "."), | ||
| ) | ||
| ) | ||
|
Comment on lines
+429
to
+436
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Every other call-site that imports from Apply the same defensive pattern used elsewhere: # Add external agent tools
try:
from praisonai.ui._external_agents import external_agent_tools
tools.extend(
external_agent_tools(
external_agents_settings,
workspace=os.environ.get("PRAISONAI_WORKSPACE", "."),
)
)
except ImportError:
pass |
||
|
|
||
| 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) | ||
|
|
||
|
Comment on lines
+545
to
557
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cache invalidation check doesn't actually compare external-agent settings. The comment at line 525 claims the cache is invalidated "if model changed or external agents changed", but the condition at line 527 only compares π οΈ Proposed fix- # Invalidate cached agent if model changed or external agents changed
+ # 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_settings:
cl.user_session.set("_cached_agent", None)
cl.user_session.set("_cached_agent_model", None)
+ cl.user_session.set("_cached_agent_external", None)You'll also need to mirror this in π€ Prompt for AI Agents |
||
| 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: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
lru_cacheoninstalled_external_agentsis process-lifetime β consider documenting the restart requirementThe
@lru_cache(maxsize=1)caches the PATH check for the entire lifetime of the process. If a user installs a new CLI tool (e.g.,npm install -g @google/gemini-cli) while the UI server is running, the new agent will not appear without restarting the server. This can be confusing.At a minimum, add a docstring note:
Alternatively, cache with a TTL (e.g., using
cachetools.TTLCache) or expose ainstalled_external_agents.cache_clear()call via a settings-refresh hook.