mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-07 17:33:33 -10:00
fix: resolve CLI backend dependency architecture violation
- Remove core SDK dependency on wrapper package (fixes AGENTS.md violation) - Move CLI backend string resolution from core to wrapper layer - Create wrapper Agent class that handles string backend resolution - Core Agent now only accepts pre-resolved backend instances or factories - Add is_resume field to CliSessionBinding protocol for type safety - Fix stream() method timeout implementation - Maintain full backward compatibility for users Architecture now correctly follows AGENTS.md: - Core SDK (praisonaiagents): lightweight protocols, no heavy imports - Wrapper (praisonai): heavy implementations, CLI backend registry Co-authored-by: Mervin Praison <[email protected]>
This commit is contained in:
1 parent
25c91a04e3
commit
ba131809c4
5 files changed
+129
-49
No files matched your search
@@ -4692,13 +4692,18 @@ Answer:"""
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _resolve_cli_backend(self, cli_backend):
|
||||
"""Resolve CLI backend parameter to a CliBackendProtocol instance.
|
||||
"""Validate and return CLI backend protocol instance.
|
||||
|
||||
Args:
|
||||
cli_backend: String backend ID or CliBackendProtocol instance
|
||||
cli_backend: CliBackendProtocol instance or callable that returns one
|
||||
|
||||
Returns:
|
||||
CliBackendProtocol instance
|
||||
|
||||
Note:
|
||||
String backend IDs must be resolved to instances in the wrapper layer
|
||||
before passing to Agent. This maintains proper dependency direction
|
||||
per AGENTS.md (core SDK should not import from wrapper).
|
||||
"""
|
||||
# Import protocols
|
||||
try:
|
||||
@@ -4712,19 +4717,23 @@ Answer:"""
|
||||
# If already a protocol instance, return as-is
|
||||
if hasattr(cli_backend, 'execute') and hasattr(cli_backend, 'stream'):
|
||||
return cli_backend
|
||||
|
||||
# If callable (factory function), call it to get instance
|
||||
if callable(cli_backend):
|
||||
instance = cli_backend()
|
||||
if hasattr(instance, 'execute') and hasattr(instance, 'stream'):
|
||||
return instance
|
||||
raise TypeError(f"CLI backend factory returned invalid type: {type(instance)}. Expected CliBackendProtocol.")
|
||||
|
||||
# If string, resolve via registry (lazy import wrapper)
|
||||
# String IDs are no longer supported at core level - must be resolved in wrapper
|
||||
if isinstance(cli_backend, str):
|
||||
try:
|
||||
from praisonai.cli_backends import resolve_cli_backend
|
||||
return resolve_cli_backend(cli_backend)
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
f"CLI backend '{cli_backend}' requested but praisonai wrapper package not installed. "
|
||||
"Install with: pip install praisonai"
|
||||
)
|
||||
raise TypeError(
|
||||
f"String CLI backend IDs ('{cli_backend}') must be resolved to instances "
|
||||
"in the wrapper layer. Use: praisonai.Agent(cli_backend='claude-code') "
|
||||
"or manually resolve: agent = praisonaiagents.Agent(cli_backend=resolve_cli_backend('claude-code'))"
|
||||
)
|
||||
|
||||
raise TypeError(f"Invalid cli_backend type: {type(cli_backend)}. Expected str or CliBackendProtocol.")
|
||||
raise TypeError(f"Invalid cli_backend type: {type(cli_backend)}. Expected CliBackendProtocol instance or factory callable.")
|
||||
|
||||
async def _chat_via_cli_backend(self, prompt: str, **kwargs) -> Optional[str]:
|
||||
"""Chat implementation using CLI backend delegation.
|
||||
|
||||
@@ -66,6 +66,7 @@ class CliSessionBinding:
|
||||
auth_profile_id: Optional[str] = None
|
||||
system_prompt_hash: Optional[str] = None
|
||||
mcp_config_hash: Optional[str] = None
|
||||
is_resume: bool = False # Whether this is resuming an existing session
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -15,6 +15,7 @@ __all__ = [
|
||||
'CloudProvider',
|
||||
'AgentOS', # Production deployment platform (v0.14.16+)
|
||||
'AgentApp', # Silent alias for AgentOS (backward compat)
|
||||
'Agent', # Wrapper Agent with CLI backend string resolution
|
||||
'recipe',
|
||||
'embed',
|
||||
'embedding',
|
||||
@@ -57,6 +58,9 @@ def __getattr__(name):
|
||||
if name == 'PraisonAI':
|
||||
from .cli import PraisonAI
|
||||
return PraisonAI
|
||||
elif name == 'Agent':
|
||||
from .agent import Agent
|
||||
return Agent
|
||||
elif name == 'Deploy':
|
||||
from .deploy import Deploy
|
||||
return Deploy
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Wrapper Agent class with CLI backend resolution support.
|
||||
|
||||
This module provides a wrapper around praisonaiagents.Agent that handles
|
||||
CLI backend string resolution in the wrapper layer, maintaining proper
|
||||
dependency direction per AGENTS.md.
|
||||
"""
|
||||
|
||||
from typing import Union, Optional, Any
|
||||
from praisonaiagents import Agent as CoreAgent
|
||||
|
||||
|
||||
class Agent(CoreAgent):
|
||||
"""Agent wrapper with CLI backend string resolution support.
|
||||
|
||||
This class extends the core Agent to handle CLI backend string resolution
|
||||
in the wrapper layer, maintaining proper architectural separation between
|
||||
core protocols and heavy implementations.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, cli_backend: Optional[Union[str, Any]] = None, **kwargs):
|
||||
"""Initialize Agent with CLI backend string resolution.
|
||||
|
||||
Args:
|
||||
*args: Positional arguments for core Agent
|
||||
cli_backend: CLI backend for delegating full turns. Accepts:
|
||||
- str: Backend ID ("claude-code", "codex-cli") - resolved in wrapper
|
||||
- CliBackendProtocol: Pre-resolved backend instance
|
||||
- callable: Factory function returning CliBackendProtocol
|
||||
- None: No CLI backend
|
||||
**kwargs: Keyword arguments for core Agent
|
||||
"""
|
||||
# Resolve string CLI backends to instances in wrapper layer
|
||||
if isinstance(cli_backend, str):
|
||||
cli_backend = self._resolve_string_backend(cli_backend)
|
||||
|
||||
# Pass resolved backend to core Agent
|
||||
super().__init__(*args, cli_backend=cli_backend, **kwargs)
|
||||
|
||||
def _resolve_string_backend(self, backend_id: str) -> Any:
|
||||
"""Resolve string backend ID to CliBackendProtocol instance.
|
||||
|
||||
Args:
|
||||
backend_id: Backend identifier (e.g., "claude-code")
|
||||
|
||||
Returns:
|
||||
CliBackendProtocol instance
|
||||
|
||||
Raises:
|
||||
ImportError: If CLI backends module not available
|
||||
ValueError: If backend_id is not registered
|
||||
"""
|
||||
try:
|
||||
from .cli_backends import resolve_cli_backend
|
||||
return resolve_cli_backend(backend_id)
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
f"CLI backend '{backend_id}' requested but CLI backends not available. "
|
||||
"This may indicate a packaging or installation issue."
|
||||
)
|
||||
|
||||
|
||||
# Export the wrapper Agent as the default for import from praisonai
|
||||
__all__ = ["Agent"]
|
||||
@@ -172,46 +172,49 @@ class ClaudeCodeBackend:
|
||||
await process.stdin.drain()
|
||||
process.stdin.close()
|
||||
|
||||
# Wrap the reading loop with timeout
|
||||
async def read_stream():
|
||||
async for line in self._read_lines(process.stdout):
|
||||
if line.strip():
|
||||
try:
|
||||
event = json.loads(line)
|
||||
delta_type = event.get("type", "text")
|
||||
content = event.get("content", event.get("data", ""))
|
||||
|
||||
yield CliBackendDelta(
|
||||
type=delta_type,
|
||||
content=str(content),
|
||||
metadata=event
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
# Fallback for non-JSON lines
|
||||
yield CliBackendDelta(
|
||||
type="text",
|
||||
content=line,
|
||||
metadata={}
|
||||
)
|
||||
# Stream with timeout protection
|
||||
timeout_seconds = self.config.timeout_ms / 1000
|
||||
start_time = asyncio.get_event_loop().time()
|
||||
|
||||
async for line in self._read_lines(process.stdout):
|
||||
# Check timeout on each iteration
|
||||
if asyncio.get_event_loop().time() - start_time > timeout_seconds:
|
||||
raise TimeoutError(f"Claude CLI streaming timed out after {self.config.timeout_ms}ms")
|
||||
|
||||
# Wait for process completion
|
||||
if line.strip():
|
||||
try:
|
||||
event = json.loads(line)
|
||||
delta_type = event.get("type", "text")
|
||||
content = event.get("content", event.get("data", ""))
|
||||
|
||||
yield CliBackendDelta(
|
||||
type=delta_type,
|
||||
content=str(content),
|
||||
metadata=event
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
# Fallback for non-JSON lines
|
||||
yield CliBackendDelta(
|
||||
type="text",
|
||||
content=line,
|
||||
metadata={}
|
||||
)
|
||||
|
||||
# Wait for process completion with timeout
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=5.0) # Short final wait
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
|
||||
if process.returncode != 0:
|
||||
stderr = await process.stderr.read()
|
||||
error_msg = stderr.decode() if stderr else f"Process exited {process.returncode}"
|
||||
yield CliBackendDelta(
|
||||
type="error",
|
||||
content=f"Claude CLI error: {error_msg}",
|
||||
metadata={"return_code": process.returncode}
|
||||
)
|
||||
|
||||
# Apply timeout using asyncio.wait_for on the generator
|
||||
try:
|
||||
async for delta in read_stream():
|
||||
yield delta
|
||||
except asyncio.TimeoutError:
|
||||
raise
|
||||
if process.returncode != 0:
|
||||
stderr = await process.stderr.read()
|
||||
error_msg = stderr.decode() if stderr else f"Process exited {process.returncode}"
|
||||
yield CliBackendDelta(
|
||||
type="error",
|
||||
content=f"Claude CLI error: {error_msg}",
|
||||
metadata={"return_code": process.returncode}
|
||||
)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
# Kill the process if it's still running
|
||||
|
||||
Reference in new issue
Block a user