-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
fix: resolve wrapper gaps - nested asyncio.run, hot-path logging, fragmented approval schema #1457
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,140 @@ | ||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||
| Approval specification module - unified approval configuration across CLI, YAML, Python. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| This module provides a single canonical ApprovalSpec dataclass that all three | ||||||||||||||||||||||||||||
| surfaces (CLI, YAML, Python) normalize into, preventing fragmentation and | ||||||||||||||||||||||||||||
| ensuring consistent behavior across all entry points. | ||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||
| from dataclasses import dataclass | ||||||||||||||||||||||||||||
| from typing import Optional, Literal, Union, Dict, Any | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| Backend = Literal["console", "slack", "telegram", "discord", "webhook", "http", "agent", "auto", "none"] | ||||||||||||||||||||||||||||
| ApprovalLevel = Literal["low", "medium", "high", "critical"] | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| def _parse_timeout(timeout_val: Optional[Union[str, int, float]]) -> Optional[float]: | ||||||||||||||||||||||||||||
| """Parse timeout value to float, handling 'none' case.""" | ||||||||||||||||||||||||||||
| if timeout_val is None: | ||||||||||||||||||||||||||||
| return None | ||||||||||||||||||||||||||||
| if isinstance(timeout_val, str) and timeout_val.lower() == 'none': | ||||||||||||||||||||||||||||
| return None | ||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||
| return float(timeout_val) | ||||||||||||||||||||||||||||
| except (ValueError, TypeError): | ||||||||||||||||||||||||||||
| raise ValueError(f"Invalid timeout value: {timeout_val}") | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| @dataclass(frozen=True) | ||||||||||||||||||||||||||||
| class ApprovalSpec: | ||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||
| Unified approval specification for CLI, YAML, and Python APIs. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| This replaces the fragmented approval configuration scattered across | ||||||||||||||||||||||||||||
| multiple fields and provides consistent behavior across all surfaces. | ||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||
| enabled: bool = False | ||||||||||||||||||||||||||||
| backend: Backend = "console" | ||||||||||||||||||||||||||||
| approve_all_tools: bool = False | ||||||||||||||||||||||||||||
| timeout: Optional[float] = None | ||||||||||||||||||||||||||||
| approve_level: Optional[ApprovalLevel] = None | ||||||||||||||||||||||||||||
| guardrails: Optional[str] = None | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| @classmethod | ||||||||||||||||||||||||||||
| def from_cli(cls, args) -> "ApprovalSpec": | ||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||
| Create ApprovalSpec from CLI arguments. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| Handles --trust, --approval, --approve-all-tools, --approval-timeout, | ||||||||||||||||||||||||||||
| --approve-level, and --guardrail flags. | ||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||
| # Determine if approval is enabled from any of the CLI flags | ||||||||||||||||||||||||||||
| enabled = bool( | ||||||||||||||||||||||||||||
| getattr(args, 'trust', False) or | ||||||||||||||||||||||||||||
| getattr(args, 'approval', None) or | ||||||||||||||||||||||||||||
| getattr(args, 'approve_all_tools', False) or | ||||||||||||||||||||||||||||
| getattr(args, 'approve_level', None) | ||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||
|
Comment on lines
+51
to
+56
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Include
π Proposed fix enabled = bool(
getattr(args, 'trust', False) or
getattr(args, 'approval', None) or
getattr(args, 'approve_all_tools', False) or
- getattr(args, 'approve_level', None)
+ getattr(args, 'approve_level', None) or
+ getattr(args, 'guardrail', None)
)π Committable suggestion
Suggested change
π€ Prompt for AI Agents |
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| # Determine backend | ||||||||||||||||||||||||||||
| if getattr(args, 'trust', False): | ||||||||||||||||||||||||||||
| backend = "auto" # --trust means auto-approve | ||||||||||||||||||||||||||||
| elif getattr(args, 'approval', None): | ||||||||||||||||||||||||||||
| backend = args.approval | ||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||
| backend = "console" if enabled else "none" | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| return cls( | ||||||||||||||||||||||||||||
| enabled=enabled, | ||||||||||||||||||||||||||||
| backend=backend, # type: ignore[arg-type] | ||||||||||||||||||||||||||||
| approve_all_tools=bool(getattr(args, 'approve_all_tools', False)), | ||||||||||||||||||||||||||||
| timeout=_parse_timeout(getattr(args, 'approval_timeout', None)), | ||||||||||||||||||||||||||||
| approve_level=getattr(args, 'approve_level', None), | ||||||||||||||||||||||||||||
| guardrails=getattr(args, 'guardrail', None), | ||||||||||||||||||||||||||||
|
Comment on lines
+66
to
+72
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Runtime-validate canonical enum fields.
π‘οΈ Proposed validation-from typing import Optional, Literal, Union, Dict, Any
+from typing import Optional, Literal, Union, Dict, Any, get_args
@@
Backend = Literal["console", "slack", "telegram", "discord", "webhook", "http", "agent", "auto", "none"]
ApprovalLevel = Literal["low", "medium", "high", "critical"]
+_BACKENDS = set(get_args(Backend))
+_APPROVAL_LEVELS = set(get_args(ApprovalLevel))
+
+
+def _validate_backend(value: Any) -> Backend:
+ if value not in _BACKENDS:
+ raise ValueError(f"Invalid approval backend: {value!r}. Allowed: {sorted(_BACKENDS)}")
+ return value
+
+
+def _validate_approval_level(value: Any) -> Optional[ApprovalLevel]:
+ if value is None:
+ return None
+ if value not in _APPROVAL_LEVELS:
+ raise ValueError(f"Invalid approve_level: {value!r}. Allowed: {sorted(_APPROVAL_LEVELS)}")
+ return value
@@
- backend=backend, # type: ignore[arg-type]
+ backend=_validate_backend(backend),
@@
- approve_level=getattr(args, 'approve_level', None),
+ approve_level=_validate_approval_level(getattr(args, 'approve_level', None)),
@@
if isinstance(node, str):
- return cls(enabled=True, backend=node) # type: ignore[arg-type]
+ return cls(enabled=True, backend=_validate_backend(node))
@@
- backend=backend, # type: ignore[arg-type]
+ backend=_validate_backend(backend),
@@
- approve_level=node.get("approve_level"),
+ approve_level=_validate_approval_level(node.get("approve_level")),Also applies to: 92-117 π€ Prompt for AI Agents |
||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| @classmethod | ||||||||||||||||||||||||||||
| def from_yaml(cls, node: Union[None, bool, str, Dict[str, Any]]) -> "ApprovalSpec": | ||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||
| Create ApprovalSpec from YAML approval configuration. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| Accepts: | ||||||||||||||||||||||||||||
| - None/False: disabled | ||||||||||||||||||||||||||||
| - True: enabled with console backend | ||||||||||||||||||||||||||||
| - str: enabled with specified backend | ||||||||||||||||||||||||||||
| - dict: full configuration | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| Validates keys to prevent silent typos. | ||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||
| if node is None or node is False: | ||||||||||||||||||||||||||||
| return cls(enabled=False, backend="none") | ||||||||||||||||||||||||||||
| if node is True: | ||||||||||||||||||||||||||||
| return cls(enabled=True, backend="console") | ||||||||||||||||||||||||||||
| if isinstance(node, str): | ||||||||||||||||||||||||||||
| return cls(enabled=True, backend=node) # type: ignore[arg-type] | ||||||||||||||||||||||||||||
| if isinstance(node, dict): | ||||||||||||||||||||||||||||
| # Validate allowed keys to catch typos early | ||||||||||||||||||||||||||||
| allowed = { | ||||||||||||||||||||||||||||
| "enabled", "backend", "approve_all_tools", "timeout", | ||||||||||||||||||||||||||||
| "approve_level", "guardrails", | ||||||||||||||||||||||||||||
| # Legacy aliases for backward compatibility | ||||||||||||||||||||||||||||
| "backend_name", "all_tools", "approval_timeout" | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| unknown = set(node) - allowed | ||||||||||||||||||||||||||||
| if unknown: | ||||||||||||||||||||||||||||
| raise ValueError(f"Unknown approval keys: {sorted(unknown)}. Allowed: {sorted(allowed)}") | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| # Handle legacy aliases | ||||||||||||||||||||||||||||
| backend = node.get("backend") or node.get("backend_name", "console") | ||||||||||||||||||||||||||||
| if "approve_all_tools" in node: | ||||||||||||||||||||||||||||
| approve_all_tools = node.get("approve_all_tools") | ||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||
| approve_all_tools = node.get("all_tools", False) | ||||||||||||||||||||||||||||
| if "timeout" in node: | ||||||||||||||||||||||||||||
| timeout_val = node.get("timeout") | ||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||
| timeout_val = node.get("approval_timeout") | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| return cls( | ||||||||||||||||||||||||||||
| enabled=node.get("enabled", True), | ||||||||||||||||||||||||||||
| backend=backend, # type: ignore[arg-type] | ||||||||||||||||||||||||||||
| approve_all_tools=bool(approve_all_tools), | ||||||||||||||||||||||||||||
| timeout=_parse_timeout(timeout_val) if timeout_val is not None else None, | ||||||||||||||||||||||||||||
| approve_level=node.get("approve_level"), | ||||||||||||||||||||||||||||
| guardrails=node.get("guardrails"), | ||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||
| raise TypeError(f"Unsupported approval node type: {type(node).__name__}") | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| def to_dict(self) -> Dict[str, Any]: | ||||||||||||||||||||||||||||
| """Convert to dictionary for backward compatibility with existing code.""" | ||||||||||||||||||||||||||||
| result = { | ||||||||||||||||||||||||||||
| "enabled": self.enabled, | ||||||||||||||||||||||||||||
| "backend": self.backend, | ||||||||||||||||||||||||||||
| "approve_all_tools": self.approve_all_tools, | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| if self.timeout is not None: | ||||||||||||||||||||||||||||
| result["timeout"] = self.timeout | ||||||||||||||||||||||||||||
| if self.approve_level is not None: | ||||||||||||||||||||||||||||
| result["approve_level"] = self.approve_level | ||||||||||||||||||||||||||||
| if self.guardrails is not None: | ||||||||||||||||||||||||||||
| result["guardrails"] = self.guardrails | ||||||||||||||||||||||||||||
| return result | ||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| """ | ||
| Async bridge module - single source of truth for running coroutines synchronously. | ||
|
|
||
| This module provides a safe way to run async functions from sync contexts, | ||
| handling nested event loop scenarios without creating a new event loop | ||
| on every call (which is expensive and breaks multi-agent workflows). | ||
| """ | ||
| import asyncio | ||
| import threading | ||
| from concurrent.futures import Future | ||
| from typing import Awaitable, TypeVar | ||
|
|
||
| T = TypeVar("T") | ||
|
|
||
| _loop: asyncio.AbstractEventLoop | None = None | ||
| _loop_lock = threading.Lock() | ||
|
|
||
|
|
||
| def _ensure_background_loop() -> asyncio.AbstractEventLoop: | ||
| """Ensure a background event loop exists and return it.""" | ||
| global _loop | ||
| with _loop_lock: | ||
| if _loop is None or _loop.is_closed(): | ||
| _loop = asyncio.new_event_loop() | ||
| t = threading.Thread(target=_loop.run_forever, name="praisonai-async", daemon=True) | ||
| t.start() | ||
| return _loop | ||
|
|
||
|
|
||
| def run_sync(coro: Awaitable[T], *, timeout: float | None = None) -> T: | ||
| """ | ||
| Run a coroutine synchronously, safe inside a running loop. | ||
|
|
||
| This function automatically detects if there's already a running event loop | ||
| and handles the execution appropriately: | ||
| - If no loop is running: uses asyncio.run() (fastest path) | ||
| - If a loop is running: schedules on background loop (safe path) | ||
|
|
||
| Args: | ||
| coro: The coroutine to run | ||
| timeout: Maximum time to wait for completion (seconds) | ||
|
|
||
| Returns: | ||
| The result of the coroutine | ||
|
|
||
| Raises: | ||
| TimeoutError: If timeout is exceeded | ||
| Any exception raised by the coroutine | ||
| """ | ||
| try: | ||
| running = asyncio.get_running_loop() | ||
| except RuntimeError: | ||
| running = None | ||
|
|
||
| if running is None: | ||
| # Cheap path: no outer loop, just run. | ||
| return asyncio.run(coro) | ||
|
|
||
| # Outer loop exists -> schedule on background loop, do NOT nest asyncio.run. | ||
| fut: Future = asyncio.run_coroutine_threadsafe(coro, _ensure_background_loop()) | ||
| return fut.result(timeout=timeout) | ||
|
Comment on lines
+55
to
+61
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π§© Analysis chainπ Script executed: #!/bin/bash
# Description: Verify run_sync no longer uses per-call asyncio.run(coro)
# and that timeout cancellation is present.
rg -n -C3 'asyncio\.run\(coro\)|run_coroutine_threadsafe|FutureTimeoutError|fut\.cancel\(\)' -- src/praisonai/praisonai/_async_bridge.pyRepository: MervinPraison/PraisonAI Length of output: 400 Use the long-lived bridge consistently and cancel timed-out work. Line 57 still creates a fresh event loop per sync call; line 61 can time out while leaving the submitted coroutine running in the background. This reintroduces loop churn and can leak long-running CLI/subprocess work. π Proposed fix-from concurrent.futures import Future
+from concurrent.futures import Future, TimeoutError as FutureTimeoutError
@@
- if running is None:
- # Cheap path: no outer loop, just run.
- return asyncio.run(coro)
-
- # Outer loop exists -> schedule on background loop, do NOT nest asyncio.run.
- fut: Future = asyncio.run_coroutine_threadsafe(coro, _ensure_background_loop())
- return fut.result(timeout=timeout)
+ loop = _ensure_background_loop()
+ if running is loop:
+ raise RuntimeError("run_sync() cannot block the async bridge event loop")
+
+ fut: Future = asyncio.run_coroutine_threadsafe(coro, loop)
+ try:
+ return fut.result(timeout=timeout)
+ except FutureTimeoutError:
+ fut.cancel()
+ raise TimeoutError(f"Coroutine timed out after {timeout}s") from Noneπ€ Prompt for AI Agents |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| """ | ||
| Logging configuration module - single source of truth for PraisonAI logging. | ||
|
|
||
| This module ensures that: | ||
| 1. Only the CLI configures the root logger (no library-side mutation) | ||
| 2. Library code uses namespaced loggers | ||
| 3. No hot-path basicConfig() calls on every instance creation | ||
| 4. Embedders keep their own logging configuration intact | ||
| """ | ||
| import logging | ||
| import os | ||
|
|
||
| _PKG_LOGGER = "praisonai" | ||
| _configured = False | ||
|
|
||
|
|
||
| def configure_cli_logging(level: str | int | None = None) -> None: | ||
| """ | ||
| Configure root logging. Must only be called from the CLI entrypoint. | ||
|
|
||
| Args: | ||
| level: Log level (string like 'INFO' or logging constant) | ||
| """ | ||
| global _configured | ||
| if _configured: | ||
| return | ||
| lvl = level or os.environ.get("LOGLEVEL", "WARNING") | ||
| logging.basicConfig(level=lvl, format="%(asctime)s - %(levelname)s - %(message)s") | ||
| _configured = True | ||
|
|
||
|
|
||
| def get_logger(name: str | None = None) -> logging.Logger: | ||
| """ | ||
| Return a namespaced logger; never touches root logger. | ||
|
|
||
| Args: | ||
| name: Optional logger name suffix | ||
|
|
||
| Returns: | ||
| A logger with the praisonai namespace | ||
| """ | ||
| return logging.getLogger(f"{_PKG_LOGGER}.{name}" if name else _PKG_LOGGER) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Validate timeout bounds and preserve the parse failure cause.
float()accepts values likenan,inf, and negative numbers, which are not valid wait durations for this config. Also address Ruff B904 by chaining the parse error.π‘οΈ Proposed fix
π§° Tools
πͺ Ruff (0.15.10)
[warning] 24-24: Within an
exceptclause, raise exceptions withraise ... from errorraise ... from Noneto distinguish them from errors in exception handling(B904)
π€ Prompt for AI Agents