Skip to content

Commit 38cebe1

Browse files
Merge pull request #1394 from MervinPraison/claude/issue-1393-20260416-1056
feat: enhance external CLI integrations with registry pattern and streaming support
2 parents c7051ed + 52ed8a6 commit 38cebe1

File tree

6 files changed

+497
-17
lines changed

6 files changed

+497
-17
lines changed

src/praisonai-ts/src/cli/features/external-agents.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ export interface ExternalAgentResult {
1919
duration: number;
2020
}
2121

22+
export type StreamEvent =
23+
| { type: 'text'; content: string }
24+
| { type: 'json'; data: unknown }
25+
| { type: 'error'; error: string };
26+
2227
/**
2328
* Base class for external agent integrations
2429
*/
@@ -42,6 +47,11 @@ export abstract class BaseExternalAgent {
4247
*/
4348
abstract execute(prompt: string): Promise<ExternalAgentResult>;
4449

50+
/**
51+
* Stream output from the external agent
52+
*/
53+
abstract stream(prompt: string): AsyncGenerator<StreamEvent, void, unknown>;
54+
4555
/**
4656
* Get the agent name
4757
*/
@@ -97,6 +107,65 @@ export abstract class BaseExternalAgent {
97107
});
98108
}
99109

110+
/**
111+
* Stream command output line by line
112+
*/
113+
protected async *streamCommand(args: string[]): AsyncGenerator<StreamEvent, void, unknown> {
114+
const { spawn } = await import('child_process');
115+
116+
const proc = spawn(this.config.command, args, {
117+
cwd: this.config.cwd || process.cwd(),
118+
env: { ...process.env, ...this.config.env },
119+
timeout: this.config.timeout,
120+
stdio: ['pipe', 'pipe', 'pipe']
121+
});
122+
123+
if (!proc.stdout) {
124+
throw new Error('Failed to create stdout stream');
125+
}
126+
127+
let stderr = '';
128+
proc.stderr?.on('data', (chunk) => {
129+
stderr += chunk.toString();
130+
});
131+
132+
const exit = new Promise<number | null>((resolve, reject) => {
133+
proc.once('error', reject);
134+
proc.once('close', resolve);
135+
});
136+
137+
const readline = await import('readline');
138+
const rl = readline.createInterface({
139+
input: proc.stdout,
140+
crlfDelay: Infinity
141+
});
142+
143+
try {
144+
for await (const line of rl) {
145+
if (line.trim()) {
146+
// Try to parse as JSON first
147+
try {
148+
const event = JSON.parse(line);
149+
yield { type: 'json', data: event };
150+
} catch {
151+
// If not JSON, treat as text
152+
yield { type: 'text', content: line };
153+
}
154+
}
155+
}
156+
157+
const exitCode = await exit;
158+
if (exitCode !== 0) {
159+
throw new Error(stderr || `${this.config.command} exited with code ${exitCode}`);
160+
}
161+
} finally {
162+
rl.close();
163+
if (!proc.killed) {
164+
proc.kill();
165+
}
166+
}
167+
}
168+
100169
/**
101170
* Check if a command exists
102171
*/
@@ -131,6 +200,10 @@ export class ClaudeCodeAgent extends BaseExternalAgent {
131200
return this.runCommand(['--print', prompt]);
132201
}
133202

203+
async *stream(prompt: string): AsyncGenerator<StreamEvent, void, unknown> {
204+
yield* this.streamCommand(['--print', '--output-format', 'stream-json', prompt]);
205+
}
206+
134207
async executeWithSession(prompt: string, sessionId?: string): Promise<ExternalAgentResult> {
135208
const args = ['--print'];
136209
if (sessionId) {
@@ -163,6 +236,10 @@ export class GeminiCliAgent extends BaseExternalAgent {
163236
async execute(prompt: string): Promise<ExternalAgentResult> {
164237
return this.runCommand(['-m', this.model, prompt]);
165238
}
239+
240+
async *stream(prompt: string): AsyncGenerator<StreamEvent, void, unknown> {
241+
yield* this.streamCommand(['-m', this.model, '--json', prompt]);
242+
}
166243
}
167244

168245
/**
@@ -184,6 +261,10 @@ export class CodexCliAgent extends BaseExternalAgent {
184261
async execute(prompt: string): Promise<ExternalAgentResult> {
185262
return this.runCommand(['exec', '--full-auto', prompt]);
186263
}
264+
265+
async *stream(prompt: string): AsyncGenerator<StreamEvent, void, unknown> {
266+
yield* this.streamCommand(['exec', '--full-auto', '--json', prompt]);
267+
}
187268
}
188269

189270
/**
@@ -205,6 +286,19 @@ export class AiderAgent extends BaseExternalAgent {
205286
async execute(prompt: string): Promise<ExternalAgentResult> {
206287
return this.runCommand(['--message', prompt, '--yes']);
207288
}
289+
290+
async *stream(prompt: string): AsyncGenerator<StreamEvent, void, unknown> {
291+
// Aider doesn't support JSON streaming, so just yield text events
292+
const result = await this.execute(prompt);
293+
if (!result.success) {
294+
throw new Error(result.error || 'Aider execution failed');
295+
}
296+
for (const line of result.output.split('\n')) {
297+
if (line.trim()) {
298+
yield { type: 'text', content: line };
299+
}
300+
}
301+
}
208302
}
209303

210304
/**
@@ -231,6 +325,16 @@ export class GenericExternalAgent extends BaseExternalAgent {
231325
}
232326
return this.runCommand(args);
233327
}
328+
329+
async *stream(prompt: string): AsyncGenerator<StreamEvent, void, unknown> {
330+
const args = [...(this.config.args || [])];
331+
if (this.promptArg) {
332+
args.push(this.promptArg, prompt);
333+
} else {
334+
args.push(prompt);
335+
}
336+
yield* this.streamCommand(args);
337+
}
234338
}
235339

236340
/**

src/praisonai/praisonai/integrations/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@
4141
'ManagedAgentIntegration', # backward compat alias
4242
'ManagedBackendConfig', # backward compat alias
4343
'get_available_integrations',
44+
'ExternalAgentRegistry',
45+
'get_registry',
46+
'register_integration',
47+
'create_integration',
4448
]
4549

4650

@@ -79,4 +83,16 @@ def __getattr__(name):
7983
elif name == 'get_available_integrations':
8084
from .base import get_available_integrations
8185
return get_available_integrations
86+
elif name == 'ExternalAgentRegistry':
87+
from .registry import ExternalAgentRegistry
88+
return ExternalAgentRegistry
89+
elif name == 'get_registry':
90+
from .registry import get_registry
91+
return get_registry
92+
elif name == 'register_integration':
93+
from .registry import register_integration
94+
return register_integration
95+
elif name == 'create_integration':
96+
from .registry import create_integration
97+
return create_integration
8298
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

src/praisonai/praisonai/integrations/base.py

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -285,19 +285,47 @@ def get_available_integrations() -> Dict[str, bool]:
285285
"""
286286
Get a dictionary of all integrations and their availability status.
287287
288+
Backward compatibility wrapper. Use ExternalAgentRegistry for new code.
289+
288290
Returns:
289291
dict: Mapping of integration name to availability (True/False)
290292
"""
291-
from .claude_code import ClaudeCodeIntegration
292-
from .gemini_cli import GeminiCLIIntegration
293-
from .codex_cli import CodexCLIIntegration
294-
from .cursor_cli import CursorCLIIntegration
295-
296-
integrations = {
297-
'claude': ClaudeCodeIntegration(),
298-
'gemini': GeminiCLIIntegration(),
299-
'codex': CodexCLIIntegration(),
300-
'cursor': CursorCLIIntegration(),
301-
}
293+
# Import here to avoid circular imports
294+
try:
295+
from .registry import get_registry
296+
import asyncio
297+
298+
registry = get_registry()
299+
300+
# Handle async call in sync context
301+
try:
302+
# Try to get existing event loop
303+
loop = asyncio.get_event_loop()
304+
if loop.is_running():
305+
# We're in an async context, use create_task
306+
import concurrent.futures
307+
with concurrent.futures.ThreadPoolExecutor() as executor:
308+
future = executor.submit(asyncio.run, registry.get_available())
309+
return future.result()
310+
else:
311+
# No running loop, safe to use asyncio.run
312+
return asyncio.run(registry.get_available())
313+
except RuntimeError:
314+
# No event loop, safe to use asyncio.run
315+
return asyncio.run(registry.get_available())
302316

303-
return {name: integration.is_available for name, integration in integrations.items()}
317+
except ImportError:
318+
# Fallback to original implementation
319+
from .claude_code import ClaudeCodeIntegration
320+
from .gemini_cli import GeminiCLIIntegration
321+
from .codex_cli import CodexCLIIntegration
322+
from .cursor_cli import CursorCLIIntegration
323+
324+
integrations = {
325+
'claude': ClaudeCodeIntegration(),
326+
'gemini': GeminiCLIIntegration(),
327+
'codex': CodexCLIIntegration(),
328+
'cursor': CursorCLIIntegration(),
329+
}
330+
331+
return {name: integration.is_available for name, integration in integrations.items()}

src/praisonai/praisonai/integrations/codex_cli.py

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,35 +45,55 @@ class CodexCLIIntegration(BaseCLIIntegration):
4545
output_schema: Path to JSON schema for structured output
4646
"""
4747

48+
VALID_APPROVAL_MODES = {"suggest", "auto-edit", "full-auto"}
49+
4850
def __init__(
4951
self,
5052
workspace: str = ".",
5153
timeout: int = 300,
52-
full_auto: bool = False,
54+
approval_mode: str = "suggest", # suggest, auto-edit, full-auto
5355
sandbox: str = "default",
5456
json_output: bool = False,
5557
output_schema: Optional[str] = None,
5658
output_file: Optional[str] = None,
59+
provider: Optional[str] = None, # OpenAI, OpenRouter, Azure, Gemini, etc.
60+
# Backward compatibility
61+
full_auto: Optional[bool] = None,
5762
):
5863
"""
5964
Initialize Codex CLI integration.
6065
6166
Args:
6267
workspace: Working directory for CLI execution
6368
timeout: Timeout in seconds for CLI execution
64-
full_auto: Whether to allow file modifications (--full-auto)
69+
approval_mode: Approval mode ("suggest", "auto-edit", "full-auto")
6570
sandbox: Sandbox mode ("default", "danger-full-access")
6671
json_output: Whether to use JSON streaming output (--json)
6772
output_schema: Path to JSON schema for structured output
6873
output_file: Path to save the final output (-o)
74+
provider: Model provider ("openai", "openrouter", "azure", "gemini", "ollama", etc.)
6975
"""
7076
super().__init__(workspace=workspace, timeout=timeout)
7177

72-
self.full_auto = full_auto
78+
# Handle backward compatibility
79+
if full_auto is not None:
80+
approval_mode = "full-auto" if full_auto else "suggest"
81+
82+
if approval_mode not in self.VALID_APPROVAL_MODES:
83+
raise ValueError(
84+
f"Invalid approval_mode: '{approval_mode}'. "
85+
f"Must be one of: {', '.join(sorted(self.VALID_APPROVAL_MODES))}"
86+
)
87+
88+
self.approval_mode = approval_mode
7389
self.sandbox = sandbox
7490
self.json_output = json_output
7591
self.output_schema = output_schema
7692
self.output_file = output_file
93+
self.provider = provider
94+
95+
# Backward compatibility
96+
self.full_auto = approval_mode == "full-auto"
7797

7898
@property
7999
def cli_command(self) -> str:
@@ -99,9 +119,12 @@ def _build_command(self, task: str, **options) -> List[str]:
99119
# Add task
100120
cmd.append(task)
101121

102-
# Add full auto flag if enabled
103-
if self.full_auto:
122+
# Add approval mode
123+
if self.approval_mode == "full-auto":
104124
cmd.append("--full-auto")
125+
elif self.approval_mode == "auto-edit":
126+
cmd.append("--auto-edit")
127+
# suggest is the default, no flag needed
105128

106129
# Add sandbox mode if not default
107130
if self.sandbox and self.sandbox != "default":
@@ -119,6 +142,10 @@ def _build_command(self, task: str, **options) -> List[str]:
119142
if self.output_file:
120143
cmd.extend(["-o", self.output_file])
121144

145+
# Add provider if specified
146+
if self.provider:
147+
cmd.extend(["--provider", self.provider])
148+
122149
return cmd
123150

124151
async def execute(self, prompt: str, **options) -> str:

0 commit comments

Comments
 (0)