Skip to content

Commit d95fc92

Browse files
feat: enhance external CLI integrations with registry pattern and streaming support
- Add ExternalAgentRegistry for dynamic agent registration - Enhance CodexCLIIntegration with approval modes (suggest/auto-edit/full-auto) - Add multi-provider support for Codex CLI - Implement streaming support in TypeScript SDK - Maintain full backward compatibility - Follow protocol-driven architecture from AGENTS.md Fixes #1393 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: MervinPraison <MervinPraison@users.noreply.github.com>
1 parent 8fc47bd commit d95fc92

5 files changed

Lines changed: 394 additions & 18 deletions

File tree

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

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

22+
export interface StreamEvent {
23+
type: string;
24+
content?: string;
25+
data?: any;
26+
}
27+
2228
/**
2329
* Base class for external agent integrations
2430
*/
@@ -42,6 +48,11 @@ export abstract class BaseExternalAgent {
4248
*/
4349
abstract execute(prompt: string): Promise<ExternalAgentResult>;
4450

51+
/**
52+
* Stream output from the external agent
53+
*/
54+
abstract stream(prompt: string): AsyncGenerator<StreamEvent, void, unknown>;
55+
4556
/**
4657
* Get the agent name
4758
*/
@@ -97,6 +108,47 @@ export abstract class BaseExternalAgent {
97108
});
98109
}
99110

111+
/**
112+
* Stream command output line by line
113+
*/
114+
protected async *streamCommand(args: string[]): AsyncGenerator<StreamEvent, void, unknown> {
115+
const { spawn } = await import('child_process');
116+
117+
const proc = spawn(this.config.command, args, {
118+
cwd: this.config.cwd || process.cwd(),
119+
env: { ...process.env, ...this.config.env },
120+
stdio: ['pipe', 'pipe', 'pipe']
121+
});
122+
123+
if (!proc.stdout) {
124+
throw new Error('Failed to create stdout stream');
125+
}
126+
127+
const readline = await import('readline');
128+
const rl = readline.createInterface({
129+
input: proc.stdout,
130+
crlfDelay: Infinity
131+
});
132+
133+
try {
134+
for await (const line of rl) {
135+
if (line.trim()) {
136+
// Try to parse as JSON first
137+
try {
138+
const event = JSON.parse(line);
139+
yield { type: 'json', data: event };
140+
} catch {
141+
// If not JSON, treat as text
142+
yield { type: 'text', content: line };
143+
}
144+
}
145+
}
146+
} finally {
147+
rl.close();
148+
proc.kill();
149+
}
150+
}
151+
100152
/**
101153
* Check if a command exists
102154
*/
@@ -131,6 +183,10 @@ export class ClaudeCodeAgent extends BaseExternalAgent {
131183
return this.runCommand(['--print', prompt]);
132184
}
133185

186+
async *stream(prompt: string): AsyncGenerator<StreamEvent, void, unknown> {
187+
yield* this.streamCommand(['--print', '--output-format', 'stream-json', prompt]);
188+
}
189+
134190
async executeWithSession(prompt: string, sessionId?: string): Promise<ExternalAgentResult> {
135191
const args = ['--print'];
136192
if (sessionId) {
@@ -163,6 +219,10 @@ export class GeminiCliAgent extends BaseExternalAgent {
163219
async execute(prompt: string): Promise<ExternalAgentResult> {
164220
return this.runCommand(['-m', this.model, prompt]);
165221
}
222+
223+
async *stream(prompt: string): AsyncGenerator<StreamEvent, void, unknown> {
224+
yield* this.streamCommand(['-m', this.model, '--json', prompt]);
225+
}
166226
}
167227

168228
/**
@@ -184,6 +244,10 @@ export class CodexCliAgent extends BaseExternalAgent {
184244
async execute(prompt: string): Promise<ExternalAgentResult> {
185245
return this.runCommand(['exec', '--full-auto', prompt]);
186246
}
247+
248+
async *stream(prompt: string): AsyncGenerator<StreamEvent, void, unknown> {
249+
yield* this.streamCommand(['exec', '--full-auto', '--json', prompt]);
250+
}
187251
}
188252

189253
/**
@@ -205,6 +269,16 @@ export class AiderAgent extends BaseExternalAgent {
205269
async execute(prompt: string): Promise<ExternalAgentResult> {
206270
return this.runCommand(['--message', prompt, '--yes']);
207271
}
272+
273+
async *stream(prompt: string): AsyncGenerator<StreamEvent, void, unknown> {
274+
// Aider doesn't support JSON streaming, so just yield text events
275+
const result = await this.execute(prompt);
276+
for (const line of result.output.split('\n')) {
277+
if (line.trim()) {
278+
yield { type: 'text', content: line };
279+
}
280+
}
281+
}
208282
}
209283

210284
/**
@@ -231,6 +305,16 @@ export class GenericExternalAgent extends BaseExternalAgent {
231305
}
232306
return this.runCommand(args);
233307
}
308+
309+
async *stream(prompt: string): AsyncGenerator<StreamEvent, void, unknown> {
310+
const args = [...(this.config.args || [])];
311+
if (this.promptArg) {
312+
args.push(this.promptArg, prompt);
313+
} else {
314+
args.push(prompt);
315+
}
316+
yield* this.streamCommand(args);
317+
}
234318
}
235319

236320
/**

src/praisonai/praisonai/integrations/__init__.py

Lines changed: 17 additions & 1 deletion
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

@@ -77,6 +81,18 @@ def __getattr__(name):
7781
from .managed_agents import ManagedConfig
7882
return ManagedConfig
7983
elif name == 'get_available_integrations':
80-
from .base import get_available_integrations
84+
from .registry 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: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,31 +49,43 @@ def __init__(
4949
self,
5050
workspace: str = ".",
5151
timeout: int = 300,
52-
full_auto: bool = False,
52+
approval_mode: str = "suggest", # suggest, auto-edit, full-auto
5353
sandbox: str = "default",
5454
json_output: bool = False,
5555
output_schema: Optional[str] = None,
5656
output_file: Optional[str] = None,
57+
provider: Optional[str] = None, # OpenAI, OpenRouter, Azure, Gemini, etc.
58+
# Backward compatibility
59+
full_auto: Optional[bool] = None,
5760
):
5861
"""
5962
Initialize Codex CLI integration.
6063
6164
Args:
6265
workspace: Working directory for CLI execution
6366
timeout: Timeout in seconds for CLI execution
64-
full_auto: Whether to allow file modifications (--full-auto)
67+
approval_mode: Approval mode ("suggest", "auto-edit", "full-auto")
6568
sandbox: Sandbox mode ("default", "danger-full-access")
6669
json_output: Whether to use JSON streaming output (--json)
6770
output_schema: Path to JSON schema for structured output
6871
output_file: Path to save the final output (-o)
72+
provider: Model provider ("openai", "openrouter", "azure", "gemini", "ollama", etc.)
6973
"""
7074
super().__init__(workspace=workspace, timeout=timeout)
7175

72-
self.full_auto = full_auto
76+
# Handle backward compatibility
77+
if full_auto is not None:
78+
approval_mode = "full-auto" if full_auto else "suggest"
79+
80+
self.approval_mode = approval_mode
7381
self.sandbox = sandbox
7482
self.json_output = json_output
7583
self.output_schema = output_schema
7684
self.output_file = output_file
85+
self.provider = provider
86+
87+
# Backward compatibility
88+
self.full_auto = approval_mode == "full-auto"
7789

7890
@property
7991
def cli_command(self) -> str:
@@ -99,9 +111,12 @@ def _build_command(self, task: str, **options) -> List[str]:
99111
# Add task
100112
cmd.append(task)
101113

102-
# Add full auto flag if enabled
103-
if self.full_auto:
114+
# Add approval mode
115+
if self.approval_mode == "full-auto":
104116
cmd.append("--full-auto")
117+
elif self.approval_mode == "auto-edit":
118+
cmd.append("--auto-edit")
119+
# suggest is the default, no flag needed
105120

106121
# Add sandbox mode if not default
107122
if self.sandbox and self.sandbox != "default":
@@ -119,6 +134,10 @@ def _build_command(self, task: str, **options) -> List[str]:
119134
if self.output_file:
120135
cmd.extend(["-o", self.output_file])
121136

137+
# Add provider if specified
138+
if self.provider:
139+
cmd.extend(["--provider", self.provider])
140+
122141
return cmd
123142

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

0 commit comments

Comments
 (0)