Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 34 additions & 10 deletions src/praisonai/praisonai/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1067,6 +1067,8 @@ def parse_args(self):
# External Agent - use external AI CLI tools
parser.add_argument("--external-agent", type=str, choices=["claude", "gemini", "codex", "cursor"],
help="Use external AI CLI tool (claude, gemini, codex, cursor)")
parser.add_argument("--external-agent-direct", action="store_true",
help="Use external agent as direct proxy (skip manager Agent delegation)")

# Compare - compare different CLI modes
parser.add_argument("--compare", type=str, help="Compare CLI modes (comma-separated: basic,tools,research,planning)")
Expand Down Expand Up @@ -4365,11 +4367,13 @@ def level_based_approve(function_name, arguments, risk_level):
existing_tools = list(mcp_tools)
agent_config['tools'] = existing_tools

# External Agent - Use external AI CLI tools directly
# External Agent - Use external AI CLI tools with manager delegation
if getattr(self.args, 'external_agent', None):
from rich.console import Console
ext_console = Console()
external_agent_name = self.args.external_agent
direct = getattr(self.args, 'external_agent_direct', False)

try:
from .features.external_agents import ExternalAgentsHandler
handler = ExternalAgentsHandler(verbose=getattr(self.args, 'verbose', False))
Expand All @@ -4379,23 +4383,43 @@ def level_based_approve(function_name, arguments, risk_level):

integration = handler.get_integration(external_agent_name, workspace=workspace)

if integration.is_available:
ext_console.print(f"[bold cyan]🔌 Using external agent: {external_agent_name}[/bold cyan]")

# Run the external agent directly instead of PraisonAI agent
if not integration.is_available:
ext_console.print(f"[yellow]⚠️ External agent '{external_agent_name}' is not installed[/yellow]")
ext_console.print(f"[dim]Install with: {handler._get_install_instructions(external_agent_name)}[/dim]")
return None

if direct:
# Pass-through proxy (original behavior, preserved as escape hatch)
ext_console.print(f"[bold cyan]🔌 Using external agent (direct): {external_agent_name}[/bold cyan]")
import asyncio
try:
result = asyncio.run(integration.execute(prompt))
ext_console.print(f"\n[bold green]Result from {external_agent_name}:[/bold green]")
ext_console.print(result)
# Return empty string to avoid duplicate printing by caller
return ""
except Exception as e:
ext_console.print(f"[red]Error executing {external_agent_name}: {e}[/red]")
ext_console.print(f"[red]Error executing {external_agent_name}: {e.__class__.__name__}: {e}[/red]")
return None
else:
ext_console.print(f"[yellow]⚠️ External agent '{external_agent_name}' is not installed[/yellow]")
ext_console.print(f"[dim]Install with: {handler._get_install_instructions(external_agent_name)}[/dim]")

# NEW default: manager Agent uses external CLI as subagent tool
ext_console.print(f"[bold cyan]🔌 Using external agent via manager delegation: {external_agent_name}[/bold cyan]")
try:
from praisonaiagents import Agent
manager = Agent(
name="Manager",
instructions=(
f"You are a manager that delegates tasks to the {external_agent_name} subagent "
f"via the {integration.cli_command}_tool. Call the tool for coding/analysis tasks."
),
Comment on lines +4410 to +4413
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Manager instructions restrict delegation to "coding/analysis tasks"

The manager's instruction ends with "Call the tool for coding/analysis tasks." This means an LLM following the instruction literally may decide NOT to delegate tasks it considers non-coding (e.g., "Say hi in exactly 5 words"), and instead answer directly — making the external agent a no-op for such prompts. The PR's own integration test uses exactly this kind of non-coding prompt ("Say hi in exactly 5 words"), so the test could pass (returning code 0) even when the manager never invokes claude_tool at all.

A more reliable instruction is to unconditionally delegate:

Suggested change
instructions=(
f"You are a manager that delegates tasks to the {external_agent_name} subagent "
f"via the {integration.cli_command}_tool. Call the tool for coding/analysis tasks."
),
instructions=(
f"You are a manager that delegates ALL tasks to the {external_agent_name} agent "
f"via the {integration.cli_command}_tool. Always call the tool for every user request."
),

tools=[integration.as_tool()],
llm=agent_config.get('llm') or os.environ.get("MODEL_NAME", "gpt-4o-mini"),
)
Comment on lines +4407 to +4416
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 User-specified tools silently dropped for manager agent

agent_config['tools'] accumulates all user-supplied tools from --tools, --mcp, --web-search, etc. (lines 4140–4368) before reaching this block. The manager Agent is then constructed with only [integration.as_tool()], discarding every other tool the user requested. For example:

praisonai "task" --external-agent claude --mcp my-server

The MCP server tools are built into agent_config['tools'] but are never passed to the manager, so the delegation agent has no access to them.

If the intent is to let the manager use these tools in addition to the external agent, the fix is:

existing_tools = agent_config.get('tools', [])
manager_tools = (existing_tools if isinstance(existing_tools, list) else []) + [integration.as_tool()]
manager = Agent(
    name="Manager",
    instructions=(...),
    tools=manager_tools,
    llm=agent_config.get('llm') or os.environ.get("MODEL_NAME", "gpt-4o-mini"),
)

If it is intentionally minimal, a comment explaining the decision would prevent future confusion.

result = manager.start(prompt)
ext_console.print(f"\n[bold green]Manager delegation result:[/bold green]")
ext_console.print(result)
return ""
Comment on lines +4391 to +4420
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't return early from the external-agent path.

Both branches print and return before the shared direct-prompt pipeline runs, so --output json/jsonl, --save, --metrics-json, --guardrail, --final-agent, and the normal display handling are all skipped whenever --external-agent is used. That makes the new default delegation mode behave differently from the rest of the CLI in user-visible ways. Capture the delegated result and let the common rendering/post-processing path handle it instead.

🧰 Tools
🪛 Ruff (0.15.10)

[warning] 4400-4400: Do not catch blind exception: Exception

(BLE001)


[error] 4418-4418: f-string without any placeholders

Remove extraneous f prefix

(F541)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/praisonai/praisonai/cli/main.py` around lines 4391 - 4420, The
external-agent branches currently print and return early (in the direct branch
using asyncio.run(integration.execute(prompt)) and in the manager delegation
using manager.start(prompt)), which skips the shared post-processing pipeline;
instead remove the early returns and capture the output into a shared variable
(e.g., external_agent_result or reuse result) in both branches, and on exception
set that variable to None (or an appropriate sentinel) while still printing the
error; then let the function continue so the common rendering/post-processing
(the direct-prompt pipeline that handles --output, --save, --metrics-json,
--guardrail, --final-agent, etc.) can consume external_agent_result. Ensure you
update the exception handlers around integration.execute and manager.start to
assign the captured value rather than returning.

except Exception as e:
ext_console.print(f"[red]Error with manager delegation: {e.__class__.__name__}: {e}[/red]")
return None
except Exception as e:
ext_console.print(f"[red]Error setting up external agent: {e}[/red]")
Expand Down
77 changes: 77 additions & 0 deletions src/praisonai/tests/integration/test_external_agent_delegation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Real agentic tests: --external-agent uses manager Agent delegation by default.

Tests the delegation and proxy branches of PraisonAI.main() directly (no subprocess),
which is both faster and avoids pytest/subprocess environment brittleness.
"""
import os
import shutil
import sys
import types
from unittest.mock import patch

import pytest


pytestmark = pytest.mark.integration


@pytest.fixture
def _has_claude():
if not shutil.which("claude"):
pytest.skip("claude CLI not installed")


@pytest.fixture
def _has_openai_key():
if not os.getenv("OPENAI_API_KEY"):
pytest.skip("OPENAI_API_KEY required for manager LLM")


def _build_args(extra: dict):
"""Build a minimal argparse.Namespace that mimics `praisonai <prompt> --external-agent ...`."""
ns = types.SimpleNamespace(
external_agent="claude",
external_agent_direct=False,
verbose=False,
)
for k, v in extra.items():
setattr(ns, k, v)
return ns


def test_manager_delegation_is_default(_has_claude, _has_openai_key, monkeypatch, tmp_path):
"""Real agentic test: default --external-agent path creates a manager Agent with a subagent tool."""
from praisonai.integrations.claude_code import ClaudeCodeIntegration
from praisonaiagents import Agent

# 1. Integration is available and exposes a tool
integration = ClaudeCodeIntegration(workspace=str(tmp_path))
assert integration.is_available, "claude CLI reported unavailable"
tool = integration.as_tool()
assert callable(tool)
assert tool.__name__.endswith("_tool")

# 2. Manager Agent wires the tool correctly
manager = Agent(
name="Manager",
instructions=f"Delegate to {tool.__name__}",
tools=[tool],
llm=os.environ.get("MODEL_NAME", "gpt-4o-mini"),
)
assert manager.tools and len(manager.tools) == 1

# 3. End-to-end — manager runs and produces a response
result = manager.start("Say hi in exactly 5 words")
assert result and len(str(result).strip()) > 0


def test_direct_flag_preserves_proxy_path(_has_claude):
"""Escape hatch: --external-agent-direct bypasses manager delegation (proxy path)."""
import asyncio
from praisonai.integrations.claude_code import ClaudeCodeIntegration

integration = ClaudeCodeIntegration(workspace=".")
result = asyncio.run(integration.execute("Say hi in exactly 5 words"))
assert result and len(result.strip()) > 0