Skip to content

Commit d140e33

Browse files
feat: External agents integration across all UI entry points (#1423)
Validated locally: 12/12 tests pass with PRAISONAI_ALLOW_NETWORK=1 PRAISONAI_TEST_PROVIDERS=all. Both AGENTS.md blockers addressed — legacy-key precedence logic corrected, real agentic tests included. DRY shared helper in ui/_external_agents.py eliminates duplicate subprocess reimplementation and enables all 4 external agents across all UI entry points. Closes #1418.
1 parent 36822ef commit d140e33

File tree

7 files changed

+656
-156
lines changed

7 files changed

+656
-156
lines changed
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""Shared helpers for wiring external agents into any UI entry point.
2+
3+
Single source of truth for:
4+
- Listing installed external agents (lazy, cached)
5+
- Rendering Chainlit Switch widgets / aiui settings entries
6+
- Building the tools list from enabled agents
7+
"""
8+
9+
from functools import lru_cache
10+
from typing import Any, Callable, Dict, List, Optional
11+
12+
# Map of UI toggle id → (integration class path, pretty label)
13+
EXTERNAL_AGENTS: Dict[str, Dict[str, str]] = {
14+
"claude_enabled": {"module": "claude_code", "cls": "ClaudeCodeIntegration",
15+
"label": "Claude Code (coding, file edits)", "cli": "claude"},
16+
"gemini_enabled": {"module": "gemini_cli", "cls": "GeminiCLIIntegration",
17+
"label": "Gemini CLI (analysis, search)", "cli": "gemini"},
18+
"codex_enabled": {"module": "codex_cli", "cls": "CodexCLIIntegration",
19+
"label": "Codex CLI (refactoring)", "cli": "codex"},
20+
"cursor_enabled": {"module": "cursor_cli", "cls": "CursorCLIIntegration",
21+
"label": "Cursor CLI (IDE tasks)", "cli": "cursor-agent"},
22+
}
23+
24+
25+
@lru_cache(maxsize=1)
26+
def installed_external_agents() -> List[str]:
27+
"""Return toggle ids of external agents whose CLI is on PATH."""
28+
import shutil
29+
return [toggle_id for toggle_id, meta in EXTERNAL_AGENTS.items()
30+
if shutil.which(meta["cli"])]
31+
32+
33+
def external_agent_tools(settings: Dict[str, Any], workspace: str = ".") -> list:
34+
"""Build tools list from settings dict of toggle_id → bool."""
35+
import importlib
36+
tools = []
37+
for toggle_id, enabled in settings.items():
38+
if not enabled or toggle_id not in EXTERNAL_AGENTS:
39+
continue
40+
meta = EXTERNAL_AGENTS[toggle_id]
41+
try:
42+
mod = importlib.import_module(f"praisonai.integrations.{meta['module']}")
43+
integration = getattr(mod, meta["cls"])(workspace=workspace)
44+
if integration.is_available:
45+
tools.append(integration.as_tool())
46+
except (ImportError, AttributeError):
47+
continue # Integration module/class not available
48+
except Exception as e: # noqa: BLE001 — isolate faulty integrations
49+
import logging
50+
logging.getLogger(__name__).warning(
51+
"Skipping external agent %s due to error: %s", toggle_id, e
52+
)
53+
continue
54+
return tools
55+
56+
57+
def chainlit_switches(current_settings: Dict[str, bool]):
58+
"""Return Chainlit Switch widgets for installed external agents only."""
59+
from chainlit.input_widget import Switch
60+
return [
61+
Switch(id=toggle_id, label=EXTERNAL_AGENTS[toggle_id]["label"],
62+
initial=current_settings.get(toggle_id, False))
63+
for toggle_id in installed_external_agents()
64+
]
65+
66+
67+
def aiui_settings_entries() -> Dict[str, Any]:
68+
"""Return aiui settings entries for installed external agents."""
69+
settings = {}
70+
for toggle_id in installed_external_agents():
71+
meta = EXTERNAL_AGENTS[toggle_id]
72+
settings[toggle_id] = {
73+
"type": "checkbox",
74+
"label": meta["label"],
75+
"default": False
76+
}
77+
return settings
78+
79+
80+
def _parse_setting_bool(value: Any) -> bool:
81+
if isinstance(value, bool):
82+
return value
83+
if value is None:
84+
return False
85+
return str(value).strip().lower() in {"true", "1", "yes", "on"}
86+
87+
88+
def load_external_agent_settings_from_chainlit(
89+
load_setting_fn: Optional[Callable[[str], Any]] = None,
90+
) -> Dict[str, bool]:
91+
"""Load external agent settings from Chainlit session and persistent storage.
92+
93+
Args:
94+
load_setting_fn: Optional callback used to load persisted settings by key.
95+
If omitted, falls back to importing ``praisonai.ui.chat.load_setting``
96+
for backward compatibility.
97+
"""
98+
import chainlit as cl
99+
settings = {toggle_id: False for toggle_id in EXTERNAL_AGENTS}
100+
loader = load_setting_fn
101+
102+
# Try to load from persistent storage (if load_setting is available)
103+
if loader is None:
104+
try:
105+
# Backward-compatible fallback for callers that don't pass a loader
106+
from praisonai.ui.chat import load_setting as loader # type: ignore
107+
except ImportError:
108+
loader = None
109+
110+
if loader is not None:
111+
legacy_claude = _parse_setting_bool(loader("claude_code_enabled"))
112+
113+
# Load all current toggles from persistent storage first
114+
for toggle_id in EXTERNAL_AGENTS:
115+
persistent_value = loader(toggle_id)
116+
if persistent_value: # non-empty string means explicitly stored
117+
settings[toggle_id] = _parse_setting_bool(persistent_value)
118+
119+
# Apply legacy migration only where no explicit value was stored
120+
if legacy_claude and not loader("claude_enabled"):
121+
settings["claude_enabled"] = True
122+
123+
# Load from session (may override persistent settings)
124+
# Check for legacy key in session
125+
if _parse_setting_bool(cl.user_session.get("claude_code_enabled", False)):
126+
settings["claude_enabled"] = True
127+
128+
# Load all current toggles from session
129+
for toggle_id in EXTERNAL_AGENTS:
130+
session_value = cl.user_session.get(toggle_id)
131+
if session_value is not None:
132+
settings[toggle_id] = _parse_setting_bool(session_value)
133+
134+
return settings
135+
136+
137+
def save_external_agent_settings_to_chainlit(settings: Dict[str, bool]):
138+
"""Save external agent settings to Chainlit session."""
139+
import chainlit as cl
140+
for toggle_id, enabled in settings.items():
141+
cl.user_session.set(toggle_id, enabled)

src/praisonai/praisonai/ui/agents.py

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,26 @@ def step_callback_sync(step_details):
426426
except Exception as e:
427427
logger.error(f"Error in step_callback_sync: {e}", exc_info=True)
428428

429+
# Get external agent tools from current settings
430+
external_tools = []
431+
try:
432+
from praisonai.ui._external_agents import external_agent_tools, EXTERNAL_AGENTS
433+
settings = cl.user_session.get("settings", {})
434+
external_settings = {k: settings.get(k, False) for k in EXTERNAL_AGENTS}
435+
workspace = os.environ.get("PRAISONAI_WORKSPACE", ".")
436+
external_tools = external_agent_tools(external_settings, workspace=workspace)
437+
except ImportError:
438+
pass
439+
440+
# Get existing tools from details
441+
existing_tools = details.get('tools', [])
442+
if isinstance(existing_tools, str):
443+
# If tools is a string, it might be a module reference - keep as is
444+
all_tools = existing_tools
445+
else:
446+
# Combine existing and external tools
447+
all_tools = (existing_tools or []) + external_tools
448+
429449
agent = Agent(
430450
name=role_name,
431451
role=role_filled,
@@ -439,7 +459,8 @@ def step_callback_sync(step_details):
439459
max_execution_time=details.get('max_execution_time'),
440460
cache=details.get('cache', True),
441461
step_callback=step_callback_sync,
442-
reflection=details.get('self_reflect', False)
462+
reflection=details.get('self_reflect', False),
463+
tools=all_tools if all_tools else None
443464
)
444465
agents_map[role] = agent
445466

@@ -485,8 +506,10 @@ def step_callback_sync(step_details):
485506
logger.warning(f"Tool '{tool_name}' not found. Skipping.")
486507

487508
# Set the agent's tools after collecting all tools
488-
if role_tools:
489-
agent.tools = role_tools
509+
# Merge resolved YAML tools with external agent tools so both survive
510+
merged_tools = role_tools + external_tools
511+
if merged_tools:
512+
agent.tools = merged_tools
490513

491514
for tname, tdetails in details.get('tasks', {}).items():
492515
description_filled = tdetails['description'].format(topic=topic)
@@ -674,6 +697,18 @@ async def start_chat():
674697
with open("agents.yaml", "w") as f:
675698
f.write("# Add your custom agents here\n")
676699

700+
# Load external agent settings
701+
try:
702+
from praisonai.ui._external_agents import (
703+
chainlit_switches,
704+
load_external_agent_settings_from_chainlit,
705+
)
706+
external_settings = load_external_agent_settings_from_chainlit(load_setting)
707+
except ImportError:
708+
def chainlit_switches(_settings): # no-op fallback
709+
return []
710+
external_settings = {}
711+
677712
settings = await cl.ChatSettings(
678713
[
679714
TextInput(id="Model", label="OpenAI - Model", initial=model_name),
@@ -685,6 +720,7 @@ async def start_chat():
685720
values=["praisonai", "crewai", "autogen"],
686721
initial_index=0,
687722
),
723+
*chainlit_switches(external_settings)
688724
]
689725
).send()
690726
cl.user_session.set("settings", settings)
@@ -719,6 +755,7 @@ async def start_chat():
719755
),
720756
TextInput(id="agents", label="agents.yaml", initial=yaml_content, multiline=True),
721757
TextInput(id="tools", label="tools.py", initial=tools_content, multiline=True),
758+
*chainlit_switches(external_settings)
722759
]
723760
).send()
724761
cl.user_session.set("settings", settings)

src/praisonai/praisonai/ui/chat.py

Lines changed: 63 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -400,28 +400,40 @@ def auth_callback(username: str, password: str):
400400
logger.warning(f"Login failed for user: {username}")
401401
return None
402402

403-
def _get_or_create_agent(model_name: str, tools_enabled: bool = True):
403+
def _get_or_create_agent(model_name: str, tools_enabled: bool = True, external_agents_settings: dict = None):
404404
"""Get or create a reusable agent for the session."""
405405
Agent = _get_praisonai_agent()
406406
if Agent is None:
407407
return None
408+
if external_agents_settings is None:
409+
external_agents_settings = {}
408410

409411
# Get cached agent from session
410412
cached_agent = cl.user_session.get("_cached_agent")
411413
cached_model = cl.user_session.get("_cached_agent_model")
414+
cached_external = cl.user_session.get("_cached_agent_external", {})
412415

413-
# Reuse if model matches
414-
if cached_agent is not None and cached_model == model_name:
416+
# Reuse if model and external settings match
417+
if (cached_agent is not None and cached_model == model_name
418+
and cached_external == external_agents_settings):
415419
return cached_agent
416420

417421
# Create new agent with interactive tools
418422
_profile_start("create_agent")
419423
tools = []
420424
if tools_enabled:
421-
tools = _get_interactive_tools()
425+
tools = list(_get_interactive_tools()) # Copy to avoid mutating cache
422426
# Add Tavily if available
423427
if os.getenv("TAVILY_API_KEY"):
424428
tools.append(tavily_web_search)
429+
# Add external agent tools
430+
from praisonai.ui._external_agents import external_agent_tools
431+
tools.extend(
432+
external_agent_tools(
433+
external_agents_settings,
434+
workspace=os.environ.get("PRAISONAI_WORKSPACE", "."),
435+
)
436+
)
425437

426438
agent = Agent(
427439
name="PraisonAI Assistant",
@@ -443,10 +455,27 @@ def _get_or_create_agent(model_name: str, tools_enabled: bool = True):
443455
# Cache the agent
444456
cl.user_session.set("_cached_agent", agent)
445457
cl.user_session.set("_cached_agent_model", model_name)
458+
cl.user_session.set("_cached_agent_external", external_agents_settings)
446459
_profile_end("create_agent")
447460

448461
return agent
449462

463+
464+
def _parse_setting_bool(value):
465+
if isinstance(value, bool):
466+
return value
467+
if value is None:
468+
return False
469+
return str(value).strip().lower() in {"true", "1", "yes", "on"}
470+
471+
472+
def _get_external_agent_settings_from_session() -> dict:
473+
from praisonai.ui._external_agents import EXTERNAL_AGENTS
474+
return {
475+
toggle_id: _parse_setting_bool(cl.user_session.get(toggle_id, False))
476+
for toggle_id in EXTERNAL_AGENTS
477+
}
478+
450479
@cl.on_chat_start
451480
async def start():
452481
_profile_start("on_chat_start")
@@ -459,8 +488,12 @@ async def start():
459488
cl.user_session.set("tools_enabled", tools_enabled)
460489
logger.debug(f"Model name: {model_name}, Tools enabled: {tools_enabled}")
461490

491+
# Load external agent settings
492+
from praisonai.ui._external_agents import load_external_agent_settings_from_chainlit, chainlit_switches
493+
external_settings = load_external_agent_settings_from_chainlit(load_setting)
494+
462495
# Pre-create agent for faster first response
463-
_get_or_create_agent(model_name, tools_enabled)
496+
_get_or_create_agent(model_name, tools_enabled, external_settings)
464497

465498
settings = cl.ChatSettings(
466499
[
@@ -474,7 +507,8 @@ async def start():
474507
id="tools_enabled",
475508
label="Enable Tools (ACP, LSP, Web Search)",
476509
initial=tools_enabled
477-
)
510+
),
511+
*chainlit_switches(external_settings)
478512
]
479513
)
480514
cl.user_session.set("settings", settings)
@@ -508,14 +542,24 @@ async def setup_agent(settings):
508542
cl.user_session.set("model_name", model_name)
509543
cl.user_session.set("tools_enabled", tools_enabled)
510544

511-
# Invalidate cached agent if model changed
545+
# Save external agent settings
546+
from praisonai.ui._external_agents import save_external_agent_settings_to_chainlit, EXTERNAL_AGENTS
547+
external_agent_settings = {k: settings.get(k, False) for k in EXTERNAL_AGENTS}
548+
save_external_agent_settings_to_chainlit(external_agent_settings)
549+
550+
# Invalidate cached agent if model changed or external agents changed
512551
cached_model = cl.user_session.get("_cached_agent_model")
513-
if cached_model != model_name:
552+
cached_external = cl.user_session.get("_cached_agent_external", {})
553+
if cached_model != model_name or cached_external != external_agent_settings:
514554
cl.user_session.set("_cached_agent", None)
515555
cl.user_session.set("_cached_agent_model", None)
556+
cl.user_session.set("_cached_agent_external", None)
516557

517558
save_setting("model_name", model_name)
518559
save_setting("tools_enabled", str(tools_enabled).lower())
560+
# Save external agent settings to persistent storage
561+
for toggle_id, enabled in external_agent_settings.items():
562+
save_setting(toggle_id, str(enabled).lower())
519563

520564
thread_id = cl.user_session.get("thread_id")
521565
if thread_id:
@@ -572,7 +616,8 @@ async def main(message: cl.Message):
572616
msg = cl.Message(content="")
573617

574618
# Try PraisonAI Agent first (faster, with tool reuse)
575-
agent = _get_or_create_agent(model_name, tools_enabled) if tools_enabled else None
619+
external_settings = _get_external_agent_settings_from_session()
620+
agent = _get_or_create_agent(model_name, tools_enabled, external_settings) if tools_enabled else None
576621

577622
if agent is not None:
578623
_profile_start("agent_response")
@@ -781,6 +826,11 @@ async def on_chat_resume(thread: ThreadDict):
781826
tools_enabled = (load_setting("tools_enabled") or "true").lower() == "true"
782827

783828
logger.debug(f"Model name: {model_name}")
829+
830+
# Load external agent settings for resume
831+
from praisonai.ui._external_agents import load_external_agent_settings_from_chainlit, chainlit_switches
832+
external_settings = load_external_agent_settings_from_chainlit()
833+
784834
settings = cl.ChatSettings(
785835
[
786836
TextInput(
@@ -793,7 +843,8 @@ async def on_chat_resume(thread: ThreadDict):
793843
id="tools_enabled",
794844
label="Enable Tools (ACP, LSP, Web Search)",
795845
initial=tools_enabled
796-
)
846+
),
847+
*chainlit_switches(external_settings),
797848
]
798849
)
799850
await settings.send()
@@ -828,7 +879,8 @@ async def on_chat_resume(thread: ThreadDict):
828879
cl.user_session.set("message_history", message_history)
829880

830881
# Pre-create agent for faster first response
831-
_get_or_create_agent(model_name, tools_enabled)
882+
external_settings = _get_external_agent_settings_from_session()
883+
_get_or_create_agent(model_name, tools_enabled, external_settings)
832884

833885
image_data = metadata.get("image")
834886
if image_data:

0 commit comments

Comments
 (0)