Skip to content

Commit 17aca46

Browse files
feat: implement model-scoped runtime selection policy (#1947)
* feat: implement model-scoped runtime selection policy - Add AgentRuntimeConfig dataclass for model-scoped runtime configuration - Implement RuntimeRegistry protocol and RuntimeResolver with resolution order: per-model → per-provider → auto → built-in default - Add fail-closed behavior for unknown runtime IDs - Integrate runtime resolution into Agent class and unified execution - Add YAML support for models.<name>.runtime and providers.<name>.runtime_default - Maintain backward compatibility with deprecation warnings for cli_backend - Add comprehensive unit tests for configuration and resolution logic - Update framework validation to support runtime features Fixes #1935 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: MervinPraison <MervinPraison@users.noreply.github.com> * fix: critical runtime selection issues identified by reviewers - Fix fail-closed behavior: Replace catch-all exception with specific handling - Fix hasattr guard performance: Check for None instead of attribute existence - Fix resolution order: Default runtime takes precedence over legacy cli_backend - Fix concurrent access: Remove _cli_backend mutation in _chat_via_runtime - Add deprecation warning for cli_backend parameter - Fix to_dict() to return defensive copies - Fix comment mislabeling in unified_execution_mixin - Fix config mutation in resolver to avoid state leakage Addresses critical P1 issues from Greptile and CodeRabbit reviews. Co-authored-by: Mervin Praison <MervinPraison@users.noreply.github.com> --------- Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com> Co-authored-by: MervinPraison <MervinPraison@users.noreply.github.com>
1 parent 8b82df0 commit 17aca46

11 files changed

Lines changed: 1582 additions & 21 deletions

File tree

src/praisonai-agents/praisonaiagents/agent/agent.py

Lines changed: 177 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -588,7 +588,8 @@ def __init__(
588588
tool_config: Optional[Union[bool, 'ToolConfig']] = None, # Tool execution configuration (timeout, retry, parallel)
589589
learn: Optional[Union[bool, str, Dict[str, Any], 'LearnConfig']] = None, # Continuous learning (peer to memory)
590590
backend: Optional[Any] = None, # External managed agent backend (e.g., ManagedAgentIntegration)
591-
cli_backend: Optional[Union[str, Any]] = None, # CLI backend for delegating turns (e.g., "claude-code")
591+
cli_backend: Optional[Union[str, Any]] = None, # CLI backend for delegating turns (e.g., "claude-code") - DEPRECATED
592+
runtime: Optional[Union[str, Dict[str, Any], 'AgentRuntimeConfig']] = None, # Model-scoped runtime configuration
592593
interrupt_controller: Optional['InterruptController'] = None, # G2: Cooperative cancellation
593594
tool_search: Optional[Union[bool, str, Dict[str, Any], 'ToolSearchConfig']] = False, # Progressive tool disclosure
594595
message_steering: Optional[Union[bool, 'MessageSteeringProtocol']] = False, # Real-time message steering during execution
@@ -689,13 +690,15 @@ def __init__(
689690
- None: Use local execution (default)
690691
When provided, agent can delegate execution to managed infrastructure
691692
for long-running tasks or when local resources are constrained.
692-
cli_backend: CLI backend for delegating full turns to external CLI tools. Accepts:
693-
- str: Backend ID ("claude-code", "codex-cli", "gemini-cli")
694-
- CliBackendProtocol: Custom CLI backend instance
695-
- None: Use standard LLM execution (default)
696-
When provided, agent delegates entire conversation turns to the CLI tool
697-
instead of using the built-in LLM. Enables session continuity and
698-
tool integration through external AI coding assistants.
693+
cli_backend: **DEPRECATED** CLI backend for delegating full turns. Use 'runtime' parameter instead.
694+
This parameter is deprecated in favor of model-scoped runtime configuration.
695+
Will emit deprecation warning when used.
696+
runtime: Model-scoped runtime configuration for turn delegation. Accepts:
697+
- str: Runtime ID ("claude-code", "praisonai")
698+
- Dict[str, Any]: Runtime config {"runtime": "claude-code", "config_overrides": {...}}
699+
- AgentRuntimeConfig: Pre-configured runtime instance
700+
- None: Use model-based resolution (default)
701+
Enables turn delegation with per-model runtime selection and fail-closed behavior.
699702
tool_search: Progressive tool disclosure configuration. Accepts:
700703
- bool: False=disabled (default), True=auto mode
701704
- str: Mode ("auto", "on", "off")
@@ -2032,10 +2035,26 @@ def __init__(
20322035
# Backend - external managed agent backend for hybrid execution
20332036
self.backend = backend
20342037

2035-
# CLI Backend - external CLI backend for delegating full turns
2038+
# CLI Backend - external CLI backend for delegating full turns (DEPRECATED)
20362039
self._cli_backend = None
20372040
if cli_backend is not None:
2041+
from ..utils.deprecation import warn_deprecated_param
2042+
warn_deprecated_param(
2043+
"cli_backend",
2044+
since="1.0.0",
2045+
removal="2.0.0",
2046+
alternative="use 'runtime=' instead for model-scoped runtime configuration",
2047+
stacklevel=3
2048+
)
20382049
self._cli_backend = self._resolve_cli_backend(cli_backend)
2050+
2051+
# Runtime Configuration - model-scoped runtime selection
2052+
self._runtime_config = None
2053+
if runtime is not None:
2054+
self._runtime_config = self._resolve_runtime_config(runtime)
2055+
2056+
# Runtime resolver for per-turn resolution
2057+
self._runtime_resolver = None # Will be initialized on first use
20392058

20402059
# Telemetry - lazy initialized via property for performance
20412060
self.__telemetry = None
@@ -5079,17 +5098,163 @@ def _resolve_cli_backend(self, cli_backend):
50795098

50805099
raise TypeError(f"Invalid cli_backend type: {type(cli_backend)}. Expected CliBackendProtocol instance or factory callable.")
50815100

5082-
async def _chat_via_cli_backend(self, prompt: str, **kwargs) -> Optional[str]:
5101+
def _resolve_runtime_config(self, runtime):
5102+
"""Resolve runtime parameter to AgentRuntimeConfig instance.
5103+
5104+
Args:
5105+
runtime: Runtime configuration parameter
5106+
5107+
Returns:
5108+
AgentRuntimeConfig instance
5109+
5110+
Raises:
5111+
TypeError: If runtime type is invalid
5112+
ValueError: If runtime configuration is invalid
5113+
"""
5114+
try:
5115+
from ..runtime import AgentRuntimeConfig
5116+
except ImportError:
5117+
raise ImportError(
5118+
"Runtime configuration features requested but not available. "
5119+
"This should not happen in a properly installed praisonaiagents package."
5120+
)
5121+
5122+
# If already an AgentRuntimeConfig instance, return as-is
5123+
if isinstance(runtime, AgentRuntimeConfig):
5124+
return runtime
5125+
5126+
# If string, create config with runtime ID
5127+
if isinstance(runtime, str):
5128+
return AgentRuntimeConfig.from_runtime_id(runtime)
5129+
5130+
# If dictionary, create config from dict
5131+
if isinstance(runtime, dict):
5132+
return AgentRuntimeConfig.from_dict(runtime)
5133+
5134+
raise TypeError(f"Invalid runtime type: {type(runtime)}. Expected str, dict, or AgentRuntimeConfig instance.")
5135+
5136+
def _get_runtime_resolver(self):
5137+
"""Get or create runtime resolver instance (lazy initialization)."""
5138+
if self._runtime_resolver is None:
5139+
try:
5140+
from ..runtime.resolver import RuntimeResolver
5141+
self._runtime_resolver = RuntimeResolver()
5142+
except ImportError:
5143+
raise ImportError(
5144+
"Runtime resolution features requested but not available. "
5145+
"This should not happen in a properly installed praisonaiagents package."
5146+
)
5147+
return self._runtime_resolver
5148+
5149+
async def _resolve_turn_runtime(self):
5150+
"""Resolve runtime for current turn based on model and configuration.
5151+
5152+
Returns:
5153+
Runtime instance if resolution succeeds, None otherwise
5154+
"""
5155+
try:
5156+
from ..runtime.resolver import RuntimeResolutionContext
5157+
except ImportError:
5158+
return None
5159+
5160+
# Create resolution context with current model and provider info
5161+
context = RuntimeResolutionContext(
5162+
model_name=getattr(self, 'llm', None),
5163+
provider_name=self._extract_provider_from_model(getattr(self, 'llm', None)),
5164+
agent_config={'agent_id': getattr(self, 'agent_id', None)}
5165+
)
5166+
5167+
try:
5168+
resolver = self._get_runtime_resolver()
5169+
result = resolver.resolve_runtime_instance(
5170+
context=context,
5171+
model_runtime_configs=getattr(self, '_model_runtime_configs', None),
5172+
provider_runtime_configs=getattr(self, '_provider_runtime_configs', None),
5173+
legacy_cli_backend=getattr(self, '_cli_backend', None)
5174+
)
5175+
5176+
if result and result.runtime:
5177+
return result.runtime
5178+
5179+
except (ValueError, RuntimeError) as e:
5180+
# Only fall back for registry not initialized, propagate configuration errors
5181+
if "not initialized" in str(e).lower():
5182+
# Registry not initialized - this is expected in SDK-only mode
5183+
pass
5184+
else:
5185+
# Configuration error (unknown runtime ID etc.) - fail closed
5186+
raise RuntimeError(
5187+
f"Runtime resolution failed for agent={getattr(self, 'display_name', 'unknown')!r}, "
5188+
f"model={getattr(self, 'llm', None)!r}: {e}. "
5189+
"Fix the runtime ID/configuration or remove the runtime override."
5190+
) from e
5191+
5192+
return None
5193+
5194+
def _extract_provider_from_model(self, model_name):
5195+
"""Extract provider name from model name.
5196+
5197+
Args:
5198+
model_name: Model name string (e.g., "anthropic/claude-3-sonnet")
5199+
5200+
Returns:
5201+
Provider name if detectable, None otherwise
5202+
"""
5203+
if not model_name or not isinstance(model_name, str):
5204+
return None
5205+
5206+
# Common provider patterns
5207+
if '/' in model_name:
5208+
return model_name.split('/')[0]
5209+
5210+
# Provider inference based on model name patterns
5211+
model_lower = model_name.lower()
5212+
if 'claude' in model_lower or 'anthropic' in model_lower:
5213+
return 'anthropic'
5214+
elif 'gpt' in model_lower or 'openai' in model_lower:
5215+
return 'openai'
5216+
elif 'gemini' in model_lower or 'google' in model_lower:
5217+
return 'google'
5218+
elif 'llama' in model_lower:
5219+
return 'meta'
5220+
5221+
return None
5222+
5223+
async def _chat_via_runtime(self, runtime_instance, prompt: str, **kwargs) -> Optional[str]:
5224+
"""Chat implementation using resolved runtime instance.
5225+
5226+
This is similar to _chat_via_cli_backend but uses the resolved runtime
5227+
from the model-scoped runtime resolution system.
5228+
5229+
Args:
5230+
runtime_instance: Resolved runtime instance (CliBackendProtocol)
5231+
prompt: User prompt
5232+
**kwargs: Additional chat parameters
5233+
5234+
Returns:
5235+
Runtime response content
5236+
"""
5237+
if not runtime_instance:
5238+
raise RuntimeError("Runtime instance is None")
5239+
5240+
# Delegate to CLI backend implementation with runtime instance
5241+
# Pass runtime instance directly to avoid mutating shared state
5242+
return await self._chat_via_cli_backend(prompt=prompt, cli_backend=runtime_instance, **kwargs)
5243+
5244+
async def _chat_via_cli_backend(self, prompt: str, cli_backend: Any = None, **kwargs) -> Optional[str]:
50835245
"""Chat implementation using CLI backend delegation.
50845246
50855247
Args:
50865248
prompt: User prompt
5249+
cli_backend: Optional specific backend instance to use (for runtime delegation)
50875250
**kwargs: Additional chat parameters (passed through as metadata)
50885251
50895252
Returns:
50905253
CLI backend response content
50915254
"""
5092-
if not self._cli_backend:
5255+
# Use provided backend or fall back to instance backend
5256+
backend = cli_backend or self._cli_backend
5257+
if not backend:
50935258
raise RuntimeError("CLI backend not configured")
50945259

50955260
try:
@@ -5128,7 +5293,7 @@ async def _chat_via_cli_backend(self, prompt: str, **kwargs) -> Optional[str]:
51285293
images = None
51295294

51305295
# Execute CLI backend
5131-
result = await self._cli_backend.execute(
5296+
result = await backend.execute(
51325297
prompt=prompt,
51335298
session=session_binding,
51345299
images=images,

src/praisonai-agents/praisonaiagents/agent/unified_execution_mixin.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,33 @@ async def _unified_chat_impl(
8282
self._ensure_loop_guard().reset_turn()
8383

8484
try:
85-
# CLI Backend routing - delegate entire turn if configured
86-
if hasattr(self, '_cli_backend') and self._cli_backend is not None:
85+
# Runtime resolution - check for model-scoped runtime before legacy CLI backend
86+
runtime_instance = None
87+
if getattr(self, '_runtime_config', None) is not None:
88+
runtime_instance = await self._resolve_turn_runtime()
89+
90+
# Model-scoped runtime routing - delegate entire turn to resolved runtime
91+
if runtime_instance is not None:
92+
return await self._chat_via_runtime(
93+
runtime_instance=runtime_instance,
94+
prompt=prompt,
95+
temperature=temperature,
96+
tools=tools,
97+
output_json=output_json,
98+
output_pydantic=output_pydantic,
99+
reasoning_steps=reasoning_steps,
100+
stream=stream,
101+
task_name=task_name,
102+
task_description=task_description,
103+
task_id=task_id,
104+
config=config,
105+
force_retrieval=force_retrieval,
106+
skip_retrieval=skip_retrieval,
107+
attachments=attachments,
108+
tool_choice=tool_choice
109+
)
110+
elif hasattr(self, '_cli_backend') and self._cli_backend is not None:
111+
# Legacy CLI backend with deprecation warning (emitted in Agent.__init__)
87112
return await self._chat_via_cli_backend(
88113
prompt=prompt,
89114
temperature=temperature,
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""Runtime configuration system for PraisonAI Agents.
2+
3+
Provides model-scoped runtime selection following the protocol-driven design:
4+
- Core protocols and dataclasses in this module
5+
- Heavy implementations in wrapper layer (praisonai package)
6+
- Fail-closed behavior for unknown runtime IDs
7+
"""
8+
9+
def __getattr__(name: str):
10+
"""Lazy loading for runtime components."""
11+
if name == "AgentRuntimeConfig":
12+
from .config import AgentRuntimeConfig
13+
return AgentRuntimeConfig
14+
elif name == "RuntimeRegistry":
15+
from .registry import RuntimeRegistry
16+
return RuntimeRegistry
17+
elif name == "RuntimeResolver":
18+
from .resolver import RuntimeResolver
19+
return RuntimeResolver
20+
else:
21+
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
22+
23+
__all__ = [
24+
"AgentRuntimeConfig",
25+
"RuntimeRegistry",
26+
"RuntimeResolver"
27+
]

0 commit comments

Comments
 (0)