Skip to content

Commit 0b0b341

Browse files
fix: external agent integrations - 4 root causes (fixes #1415) (#1419)
Validated locally: all 3 fixes work with real CLIs (gemini returns response with flash-lite default, codex works outside git repos, cursor surfaces auth errors clearly instead of silent empty). Closes #1415.
1 parent 9463b1f commit 0b0b341

File tree

10 files changed

+107
-10
lines changed

10 files changed

+107
-10
lines changed

src/praisonai/praisonai/integrations/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
# Lazy imports to avoid performance impact
3030
__all__ = [
3131
'BaseCLIIntegration',
32+
'CLIExecutionError',
3233
'ClaudeCodeIntegration',
3334
'GeminiCLIIntegration',
3435
'CodexCLIIntegration',
@@ -53,6 +54,9 @@ def __getattr__(name):
5354
if name == 'BaseCLIIntegration':
5455
from .base import BaseCLIIntegration
5556
return BaseCLIIntegration
57+
elif name == 'CLIExecutionError':
58+
from .base import CLIExecutionError
59+
return CLIExecutionError
5660
elif name == 'ClaudeCodeIntegration':
5761
from .claude_code import ClaudeCodeIntegration
5862
return ClaudeCodeIntegration

src/praisonai/praisonai/integrations/base.py

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,19 @@
1818
import os
1919

2020

21+
class CLIExecutionError(RuntimeError):
22+
"""Raised when a CLI command fails with non-zero exit code."""
23+
24+
def __init__(self, cmd: List[str], returncode: int, stderr: str):
25+
stderr_excerpt = stderr.strip()[:500] if stderr.strip() else "(no error message)"
26+
cmd_str = ' '.join(cmd) if cmd else "unknown command"
27+
hint = f"Hint: ensure the CLI is installed and authenticated; try '{cmd_str} --help' or rerun the command manually."
28+
super().__init__(f"{cmd[0]} exited {returncode}: {stderr_excerpt}. {hint}")
29+
self.cmd = cmd
30+
self.returncode = returncode
31+
self.stderr = stderr
32+
33+
2134
class BaseCLIIntegration(ABC):
2235
"""
2336
Abstract base class for external CLI tool integrations.
@@ -128,6 +141,7 @@ async def execute_async(self, cmd: List[str], timeout: Optional[int] = None) ->
128141
129142
Raises:
130143
TimeoutError: If the command times out
144+
CLIExecutionError: If the command fails with non-zero exit code
131145
"""
132146
timeout = timeout or self.timeout
133147

@@ -144,7 +158,12 @@ async def execute_async(self, cmd: List[str], timeout: Optional[int] = None) ->
144158
proc.communicate(),
145159
timeout=timeout
146160
)
147-
return stdout.decode()
161+
162+
# Check exit code and raise error if non-zero
163+
if proc.returncode != 0:
164+
raise CLIExecutionError(cmd, proc.returncode, stderr.decode(errors="replace"))
165+
166+
return stdout.decode(errors="replace")
148167
except asyncio.TimeoutError:
149168
proc.kill()
150169
await proc.wait()
@@ -183,7 +202,12 @@ async def execute_async_with_stderr(
183202
proc.communicate(),
184203
timeout=timeout
185204
)
186-
return stdout.decode(), stderr.decode()
205+
206+
# Check exit code and raise error if non-zero
207+
if proc.returncode != 0:
208+
raise CLIExecutionError(cmd, proc.returncode, stderr.decode(errors="replace"))
209+
210+
return stdout.decode(errors="replace"), stderr.decode(errors="replace")
187211
except asyncio.TimeoutError:
188212
proc.kill()
189213
await proc.wait()
@@ -203,8 +227,12 @@ async def stream_async(
203227
204228
Yields:
205229
str: Each line of output
230+
231+
Raises:
232+
CLIExecutionError: If the command fails with non-zero exit code
206233
"""
207234
timeout = timeout or self.timeout
235+
stderr_buffer = []
208236

209237
proc = await asyncio.create_subprocess_exec(
210238
*cmd,
@@ -215,15 +243,35 @@ async def stream_async(
215243
)
216244

217245
try:
246+
async def read_stderr():
247+
"""Read stderr into buffer for error reporting"""
248+
while True:
249+
line = await proc.stderr.readline()
250+
if not line:
251+
break
252+
stderr_buffer.append(line.decode(errors="replace").rstrip('\n'))
253+
254+
# Start reading stderr in background
255+
stderr_task = asyncio.create_task(read_stderr())
256+
218257
async def read_lines():
219258
while True:
220259
line = await proc.stdout.readline()
221260
if not line:
222261
break
223-
yield line.decode().rstrip('\n')
262+
yield line.decode(errors="replace").rstrip('\n')
224263

225264
async for line in read_lines():
226265
yield line
266+
267+
# Wait for stderr reading to complete and process to finish
268+
await stderr_task
269+
await proc.wait()
270+
271+
# Check exit code and raise error if non-zero
272+
if proc.returncode != 0:
273+
stderr_text = '\n'.join(stderr_buffer)
274+
raise CLIExecutionError(cmd, proc.returncode, stderr_text)
227275

228276
except asyncio.TimeoutError:
229277
proc.kill()

src/praisonai/praisonai/integrations/codex_cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ def _build_command(self, task: str, **options) -> List[str]:
111111
Returns:
112112
List of command arguments
113113
"""
114-
cmd = ["codex", "exec"]
114+
cmd = ["codex", "exec", "--skip-git-repo-check"]
115115

116116
# Add working directory
117117
cmd.extend(["-C", self.workspace])

src/praisonai/praisonai/integrations/cursor_cli.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,6 @@ def _build_command(self, prompt: str, **options) -> List[str]:
9797
# Add print mode flag
9898
cmd.append("-p")
9999

100-
# Add workspace
101-
cmd.extend(["--workspace", self.workspace])
102-
103100
# Add force flag if enabled
104101
if self.force:
105102
cmd.append("--force")

src/praisonai/praisonai/integrations/gemini_cli.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@
3333
from .base import BaseCLIIntegration
3434

3535

36+
# Default model configuration (resolved per-instance so env overrides are honored)
37+
DEFAULT_GEMINI_MODEL_FALLBACK = "gemini-2.5-flash-lite"
38+
39+
# For backward compatibility, export the default for tests that import it
40+
DEFAULT_GEMINI_MODEL = DEFAULT_GEMINI_MODEL_FALLBACK
41+
42+
3643
class GeminiCLIIntegration(BaseCLIIntegration):
3744
"""
3845
Integration with Google's Gemini CLI.
@@ -49,7 +56,7 @@ def __init__(
4956
workspace: str = ".",
5057
timeout: int = 300,
5158
output_format: str = "json",
52-
model: str = "gemini-2.5-pro",
59+
model: Optional[str] = None,
5360
include_directories: Optional[List[str]] = None,
5461
sandbox: bool = False,
5562
):
@@ -67,7 +74,7 @@ def __init__(
6774
super().__init__(workspace=workspace, timeout=timeout)
6875

6976
self.output_format = output_format
70-
self.model = model
77+
self.model = model or os.getenv("PRAISONAI_GEMINI_MODEL", DEFAULT_GEMINI_MODEL_FALLBACK)
7178
self.include_directories = include_directories
7279
self.sandbox = sandbox
7380

src/praisonai/tests/integration/test_cli_integrations.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,15 @@ def test_import_all_integrations(self):
3838
"""Test importing all integrations."""
3939
from praisonai.integrations import (
4040
BaseCLIIntegration,
41+
CLIExecutionError,
4142
ClaudeCodeIntegration,
4243
GeminiCLIIntegration,
4344
CodexCLIIntegration,
4445
CursorCLIIntegration,
4546
)
4647

4748
assert BaseCLIIntegration is not None
49+
assert CLIExecutionError is not None
4850
assert ClaudeCodeIntegration is not None
4951
assert GeminiCLIIntegration is not None
5052
assert CodexCLIIntegration is not None
@@ -124,6 +126,12 @@ def test_gemini_integration_creation(self):
124126
assert integration.cli_command == "gemini"
125127
assert integration.model == "gemini-2.5-pro"
126128
print(f"\n🔧 Gemini CLI available: {integration.is_available}")
129+
130+
# Test default model configuration
131+
default_integration = GeminiCLIIntegration()
132+
expected_default = os.environ.get("PRAISONAI_GEMINI_MODEL", "gemini-2.5-flash-lite")
133+
assert default_integration.model == expected_default
134+
print(f"🔧 Gemini default model: {default_integration.model}")
127135

128136
@pytest.mark.skipif(
129137
not os.environ.get("GEMINI_API_KEY") and not os.environ.get("GOOGLE_API_KEY"),

src/praisonai/tests/unit/integrations/test_base_integration.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,29 @@ async def stream(self, prompt: str, **options):
258258
result = await integration.execute("test")
259259
assert "error" in result
260260

261+
@pytest.mark.asyncio
262+
async def test_execute_async_raises_cli_execution_error(self):
263+
"""Test that execute_async raises CLIExecutionError on non-zero exit."""
264+
from praisonai.integrations.base import BaseCLIIntegration, CLIExecutionError
265+
266+
class TestIntegration(BaseCLIIntegration):
267+
@property
268+
def cli_command(self) -> str:
269+
return "bash"
270+
271+
async def execute(self, prompt: str, **options) -> str:
272+
return await self.execute_async(["bash", "-c", "echo failure >&2; exit 7"])
273+
274+
async def stream(self, prompt: str, **options):
275+
yield {}
276+
277+
integration = TestIntegration()
278+
with pytest.raises(CLIExecutionError) as exc:
279+
await integration.execute("test")
280+
281+
assert exc.value.returncode == 7
282+
assert "failure" in exc.value.stderr
283+
261284

262285
class TestAvailabilityCache:
263286
"""Tests for the availability caching mechanism."""

src/praisonai/tests/unit/integrations/test_codex_cli.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ def test_build_command_basic(self):
8383

8484
assert cmd[0] == "codex"
8585
assert "exec" in cmd
86+
assert "--skip-git-repo-check" in cmd
8687
assert "Fix the bug" in cmd
8788

8889
def test_build_command_with_full_auto(self):

src/praisonai/tests/unit/integrations/test_cursor_cli.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ def test_build_command_basic(self):
6969
assert "Fix the bug" in cmd
7070
assert "--output-format" in cmd
7171
assert "json" in cmd
72+
assert "--workspace" not in cmd
7273

7374
def test_build_command_with_force(self):
7475
"""Test building command with force flag."""

src/praisonai/tests/unit/integrations/test_gemini_cli.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,15 @@ def test_default_options(self):
4141

4242
integration = GeminiCLIIntegration()
4343
assert integration.output_format == "json"
44-
assert integration.model == "gemini-2.5-pro"
44+
assert integration.model == "gemini-2.5-flash-lite"
45+
46+
def test_default_model_reads_environment(self):
47+
"""Test default model is read from environment when not explicitly set."""
48+
from praisonai.integrations.gemini_cli import GeminiCLIIntegration
49+
50+
with patch.dict(os.environ, {"PRAISONAI_GEMINI_MODEL": "gemini-2.5-pro"}, clear=False):
51+
integration = GeminiCLIIntegration()
52+
assert integration.model == "gemini-2.5-pro"
4553

4654
def test_custom_model(self):
4755
"""Test custom model can be set."""

0 commit comments

Comments
 (0)