Compare commits

...
4 Commits
Author SHA1 Message Date
MervinPraison 81dfb6a555 Release v4.6.3 2026-04-14 06:19:12 +01:00
MervinPraison bc52cd67fd docs: auto-update api.md [skip ci] 2026-04-14 05:19:09 +00:00
MervinPraison d4b563987f feat(agent, task): add concurrency limiter and message sink protocols
Implemented a concurrency registry to control concurrent task execution
for agents, allowing users to set limits per agent name. Also added message
sink protocols for managing sequenced task execution messages, enabling
replay of agent outputs and tool calls.

New files include:
- agent/concurrency.py
- task/message_sink.py

Updated the task module to include the new protocols in exports.

Total changes across 13 files with 1099 insertions.
2026-04-14 06:18:36 +01:00
MervinPraison 4c68e61438 docs: auto-update feature parity trackers [skip ci] 2026-04-13 16:08:27 +00:00
26 changed files with 1294 additions and 37 deletions

No files matched your search

+1
View File
@@ -206,6 +206,7 @@ Methods:
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">execute_callback_sync</a>(task_output: TaskOutput) -> None</code>
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">get_next_task</a>(context: Dict[str, Any]) -> Optional[str]</code>
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">initialize_memory</a>()</code>
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">set_status</a>(new_status: str) -> None</code>
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">store_in_memory</a>(content: str, agent_name: str = None, task_id: str = None)</code>
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">to_dict</a>() -> Dict[str, Any]</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">loop</a>(step: Any = None, steps: Optional[List[Any]] = None, over: Optional[str] = None, from_csv: Optional[str] = None, from_file: Optional[str] = None, var_name: str = 'item', parallel: bool = False, max_workers: Optional[int] = None, output_variable: Optional[str] = None) -> Loop</code>
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.2" \
"praisonai>=4.6.3" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.2" \
"praisonai>=4.6.3" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.2" \
"praisonai>=4.6.3" \
"praisonai[ui]" \
"praisonai[crewai]"
@@ -0,0 +1,147 @@
"""Per-Agent Concurrency Limiter for PraisonAI Agents.
Provides a registry-based approach to limit concurrent task execution
per agent name. No Agent constructor param bloat — uses a global registry.
Usage:
from praisonaiagents.agent.concurrency import get_concurrency_registry
registry = get_concurrency_registry()
registry.set_limit("researcher", 2) # max 2 concurrent tasks
# In async code:
async with registry.throttle("researcher"):
await do_work()
# Or manual:
await registry.acquire("researcher")
try:
await do_work()
finally:
registry.release("researcher")
"""
import asyncio
import threading
from contextlib import asynccontextmanager
from typing import Dict, Optional
from praisonaiagents._logging import get_logger
logger = get_logger(__name__)
class ConcurrencyRegistry:
"""Registry for per-agent concurrency limits.
Thread-safe. Each agent name maps to an asyncio.Semaphore.
Limit of 0 means unlimited (no throttling).
"""
def __init__(self, default_limit: int = 0):
self._default_limit = default_limit
self._limits: Dict[str, int] = {}
self._semaphores: Dict[str, asyncio.Semaphore] = {}
self._lock = threading.Lock()
def set_limit(self, agent_name: str, max_concurrent: int) -> None:
"""Set concurrency limit for an agent.
Args:
agent_name: Agent identifier
max_concurrent: Max concurrent tasks (0 = unlimited)
"""
with self._lock:
self._limits[agent_name] = max_concurrent
# Reset semaphore so next acquire creates a fresh one
self._semaphores.pop(agent_name, None)
def get_limit(self, agent_name: str) -> int:
"""Get concurrency limit for an agent."""
with self._lock:
return self._limits.get(agent_name, self._default_limit)
def remove_limit(self, agent_name: str) -> None:
"""Remove concurrency limit for an agent (reverts to default)."""
with self._lock:
self._limits.pop(agent_name, None)
self._semaphores.pop(agent_name, None)
def _get_semaphore(self, agent_name: str) -> Optional[asyncio.Semaphore]:
"""Get or create semaphore for agent. Returns None if unlimited."""
with self._lock:
limit = self._limits.get(agent_name, self._default_limit)
if limit <= 0:
return None
if agent_name not in self._semaphores:
self._semaphores[agent_name] = asyncio.Semaphore(limit)
return self._semaphores[agent_name]
async def acquire(self, agent_name: str) -> None:
"""Acquire concurrency slot for agent. No-op if unlimited."""
sem = self._get_semaphore(agent_name)
if sem is not None:
await sem.acquire()
def acquire_sync(self, agent_name: str) -> None:
"""Synchronous acquire — for non-async code paths.
Note: This creates/reuses an event loop internally.
Prefer async acquire() when possible.
"""
sem = self._get_semaphore(agent_name)
if sem is None:
return
try:
asyncio.get_running_loop()
# If we're in an async context, we can't block
# Just try_acquire or no-op with warning
if not sem._value > 0:
logger.warning(
f"Sync acquire for '{agent_name}' while async loop running and semaphore full. "
f"Consider using async acquire() instead."
)
# Decrement manually for sync context
sem._value = max(0, sem._value - 1)
except RuntimeError:
# No running loop — safe to use asyncio.run
asyncio.get_event_loop().run_until_complete(sem.acquire())
def release(self, agent_name: str) -> None:
"""Release concurrency slot for agent. No-op if unlimited."""
with self._lock:
sem = self._semaphores.get(agent_name)
if sem is not None:
try:
sem.release()
except ValueError:
pass # Already fully released
@asynccontextmanager
async def throttle(self, agent_name: str):
"""Async context manager for throttled execution.
Usage:
async with registry.throttle("agent_name"):
await do_work()
"""
await self.acquire(agent_name)
try:
yield
finally:
self.release(agent_name)
# Singleton
_registry: Optional[ConcurrencyRegistry] = None
_registry_lock = threading.Lock()
def get_concurrency_registry() -> ConcurrencyRegistry:
"""Get the global concurrency registry singleton."""
global _registry
if _registry is None:
with _registry_lock:
if _registry is None:
_registry = ConcurrencyRegistry()
return _registry
@@ -1,4 +1,13 @@
"""Task module for AI agent tasks"""
from .task import Task
from .protocols import TaskStatus, TaskLifecycleManager, InvalidTransitionError, TaskLifecycleProtocol
from .message_sink import (
TaskMessage, TaskMessageSinkProtocol, NoOpTaskMessageSink,
InMemoryTaskMessageSink, TaskMessageEmitter,
)
__all__ = ['Task']
__all__ = [
'Task', 'TaskStatus', 'TaskLifecycleManager', 'InvalidTransitionError', 'TaskLifecycleProtocol',
'TaskMessage', 'TaskMessageSinkProtocol', 'NoOpTaskMessageSink',
'InMemoryTaskMessageSink', 'TaskMessageEmitter',
]
@@ -0,0 +1,146 @@
"""Task Message Sink Protocols for PraisonAI Agents.
Provides pluggable persistence for sequenced task execution messages.
Matches Multica's ReportTaskMessages pattern but as a protocol,
enabling replay of agent output, tool calls, errors, and status changes.
Usage:
from praisonaiagents.task.message_sink import InMemoryTaskMessageSink, TaskMessageEmitter
sink = InMemoryTaskMessageSink()
emitter = TaskMessageEmitter(task_id="t1", sink=sink, agent_name="researcher")
emitter.emit("agent_output", "Hello world")
emitter.emit("tool_call", "search_web('AI trends')")
# Replay
messages = sink.replay("t1")
"""
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable, List, Optional, Dict, Any
@dataclass
class TaskMessage:
"""A single sequenced message from task execution.
Attributes:
task_id: Task this message belongs to
seq_num: Sequence number for ordering (0-indexed)
msg_type: Message type (agent_output, tool_call, tool_result, error, status)
content: Message content
agent_name: Agent that produced this message
metadata: Optional extra metadata
timestamp: ISO timestamp
"""
task_id: str
seq_num: int
msg_type: str
content: str
agent_name: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"task_id": self.task_id,
"seq_num": self.seq_num,
"msg_type": self.msg_type,
"content": self.content,
"agent_name": self.agent_name,
"metadata": self.metadata,
"timestamp": self.timestamp,
}
@runtime_checkable
class TaskMessageSinkProtocol(Protocol):
"""Protocol for persisting task execution messages.
Implementations can write to databases, files, WebSockets, etc.
"""
def emit(self, message: TaskMessage) -> None:
"""Persist a single task message."""
...
def replay(self, task_id: str) -> List[TaskMessage]:
"""Replay all messages for a task, ordered by seq_num."""
...
class NoOpTaskMessageSink:
"""Default sink that does nothing. Zero overhead."""
def emit(self, message: TaskMessage) -> None:
pass
def replay(self, task_id: str) -> List[TaskMessage]:
return []
class InMemoryTaskMessageSink:
"""In-memory sink for testing and debugging.
Stores all messages in a list for inspection and replay.
"""
def __init__(self):
self.messages: List[TaskMessage] = []
def emit(self, message: TaskMessage) -> None:
self.messages.append(message)
def replay(self, task_id: str) -> List[TaskMessage]:
"""Replay messages for a task, ordered by seq_num."""
task_messages = [m for m in self.messages if m.task_id == task_id]
return sorted(task_messages, key=lambda m: m.seq_num)
def clear(self) -> None:
"""Clear all messages."""
self.messages.clear()
class TaskMessageEmitter:
"""Convenience emitter that auto-sequences messages for a task.
Maintains a per-task sequence counter so callers don't need
to track seq_num manually.
Usage:
emitter = TaskMessageEmitter(task_id="t1", sink=sink)
emitter.emit("agent_output", "Hello") # seq_num=0
emitter.emit("tool_call", "search()") # seq_num=1
"""
def __init__(
self,
task_id: str,
sink: TaskMessageSinkProtocol,
agent_name: Optional[str] = None,
):
self.task_id = task_id
self.sink = sink
self.agent_name = agent_name
self._seq_num = 0
def emit(
self,
msg_type: str,
content: str,
agent_name: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Emit a message with auto-incremented sequence number."""
message = TaskMessage(
task_id=self.task_id,
seq_num=self._seq_num,
msg_type=msg_type,
content=content,
agent_name=agent_name or self.agent_name,
metadata=metadata or {},
)
self.sink.emit(message)
self._seq_num += 1
@@ -0,0 +1,106 @@
"""Task Lifecycle Protocols for PraisonAI Agents.
Provides:
- TaskStatus enum (backward-compatible with raw strings)
- TaskLifecycleManager with transition validation
- InvalidTransitionError
- EventBus integration (zero overhead when no subscribers)
"""
from enum import Enum
from typing import Optional, Callable, Dict, Set, Protocol, runtime_checkable
class TaskStatus(str, Enum):
"""Task execution status.
Inherits from str so TaskStatus.NOT_STARTED == "not started" is True.
This ensures full backward compatibility with existing raw string usage.
"""
NOT_STARTED = "not started"
IN_PROGRESS = "in progress"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
def __str__(self) -> str:
return self.value
class InvalidTransitionError(Exception):
"""Raised when an invalid task status transition is attempted."""
def __init__(self, from_status: str, to_status: str, task_id: Optional[str] = None):
self.from_status = from_status
self.to_status = to_status
self.task_id = task_id
tid = f" (task={task_id})" if task_id else ""
super().__init__(
f"Invalid task transition: '{from_status}' -> '{to_status}'{tid}"
)
# Valid transitions: from_status -> set of allowed to_statuses
_VALID_TRANSITIONS: Dict[str, Set[str]] = {
TaskStatus.NOT_STARTED: {TaskStatus.IN_PROGRESS, TaskStatus.CANCELLED, TaskStatus.FAILED},
TaskStatus.IN_PROGRESS: {TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED},
TaskStatus.FAILED: {TaskStatus.IN_PROGRESS}, # retry
TaskStatus.COMPLETED: set(), # terminal
TaskStatus.CANCELLED: set(), # terminal
}
@runtime_checkable
class TaskLifecycleProtocol(Protocol):
"""Protocol for task lifecycle management."""
def can_transition(self, from_status: str, to_status: str) -> bool: ...
def transition(self, from_status: str, to_status: str, task_id: Optional[str] = None) -> str: ...
class TaskLifecycleManager:
"""Default task lifecycle manager with transition validation.
Validates state transitions and optionally fires a callback.
Zero overhead when no callback is configured.
Usage:
mgr = TaskLifecycleManager()
if mgr.can_transition("not started", "in progress"):
new = mgr.transition("not started", "in progress", task_id="t1")
"""
def __init__(
self,
on_transition: Optional[Callable[[str, str, Optional[str]], None]] = None,
):
self._on_transition = on_transition
def can_transition(self, from_status: str, to_status: str) -> bool:
"""Check if a transition is valid."""
allowed = _VALID_TRANSITIONS.get(from_status, set())
return to_status in allowed
def transition(
self,
from_status: str,
to_status: str,
task_id: Optional[str] = None,
) -> str:
"""Execute a validated transition.
Args:
from_status: Current status
to_status: Target status
task_id: Optional task identifier for logging/events
Returns:
The new status string
Raises:
InvalidTransitionError: If the transition is not valid
"""
if not self.can_transition(from_status, to_status):
raise InvalidTransitionError(from_status, to_status, task_id)
if self._on_transition is not None:
self._on_transition(from_status, to_status, task_id)
return to_status
@@ -10,9 +10,14 @@ import uuid
import os
import time
from .protocols import TaskStatus, TaskLifecycleManager
# Set up logger
logger = get_logger(__name__)
# Shared lifecycle manager (stateless, thread-safe)
_lifecycle_manager = TaskLifecycleManager()
class Task:
"""
A unit of work that can be executed by an Agent or a custom handler function.
@@ -185,7 +190,7 @@ class Task:
else:
# on_task_complete takes precedence (or both are None)
self.callback = on_task_complete
self.status = status
self.status = TaskStatus(status) if status in TaskStatus._value2member_map_ else status
self.result = result
self.create_directory = create_directory
self.images = images if images else []
@@ -463,6 +468,29 @@ class Task:
else:
raise ValueError("Guardrail must be either a callable or a string description")
def set_status(self, new_status: str) -> None:
"""Set task status with transition validation.
Validates the transition is legal. If not, logs a warning but
still applies the change for backward compatibility.
Args:
new_status: Target status (string or TaskStatus enum)
"""
# Normalize to string for comparison
new_str = str(new_status)
old_str = str(self.status)
if not _lifecycle_manager.can_transition(old_str, new_str):
logger.warning(
f"Task {self.id}: Invalid status transition '{old_str}' -> '{new_str}'. "
f"Allowing for backward compatibility."
)
# Always set for backward compat
if new_str in TaskStatus._value2member_map_:
self.status = TaskStatus(new_str)
else:
self.status = new_str
def __str__(self):
return f"Task(name='{self.name if self.name else 'None'}', description='{self.description}', agent='{self.agent.name if self.agent else 'None'}', status='{self.status}')"
@@ -0,0 +1,102 @@
"""Token Usage Sink Protocols for PraisonAI Agents.
Provides pluggable persistence for per-task token usage data.
Matches Multica's task_usage table pattern but as a protocol,
so users can plug in any backend (DB, file, API, etc.).
Usage:
from praisonaiagents.telemetry.protocols import InMemoryTokenUsageSink
from praisonaiagents.telemetry.token_collector import TokenCollector
sink = InMemoryTokenUsageSink()
collector = TokenCollector()
collector.set_sink(sink)
"""
from typing import Protocol, runtime_checkable, List, Dict, Any, Optional
from datetime import datetime
@runtime_checkable
class TokenUsageSinkProtocol(Protocol):
"""Protocol for persisting token usage data.
Implementations can write to databases, files, APIs, etc.
The NoOp implementation is used by default for zero overhead.
"""
def persist(
self,
task_id: str,
agent_name: str,
model: str,
metrics: Any,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Persist token usage for a task execution.
Args:
task_id: Task identifier
agent_name: Agent that consumed tokens
model: LLM model name
metrics: TokenMetrics instance
metadata: Optional extra metadata
"""
...
class NoOpTokenUsageSink:
"""Default sink that does nothing. Zero overhead."""
def persist(
self,
task_id: str,
agent_name: str,
model: str,
metrics: Any,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
pass
class InMemoryTokenUsageSink:
"""In-memory sink for testing and debugging.
Stores all usage records in a list for inspection.
"""
def __init__(self):
self.records: List[Dict[str, Any]] = []
def persist(
self,
task_id: str,
agent_name: str,
model: str,
metrics: Any,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
record = {
"task_id": task_id,
"agent_name": agent_name,
"model": model,
"input_tokens": getattr(metrics, "input_tokens", 0),
"output_tokens": getattr(metrics, "output_tokens", 0),
"cached_tokens": getattr(metrics, "cached_tokens", 0),
"total_tokens": getattr(metrics, "total_tokens", 0),
"timestamp": datetime.now().isoformat(),
"metadata": metadata or {},
}
self.records.append(record)
def get_by_task(self, task_id: str) -> List[Dict[str, Any]]:
"""Get all records for a specific task."""
return [r for r in self.records if r["task_id"] == task_id]
def get_by_agent(self, agent_name: str) -> List[Dict[str, Any]]:
"""Get all records for a specific agent."""
return [r for r in self.records if r["agent_name"] == agent_name]
def clear(self) -> None:
"""Clear all records."""
self.records.clear()
@@ -113,6 +113,16 @@ class TokenCollector:
self._session_metrics = SessionTokenMetrics()
self._recent_interactions: List[Dict] = []
self._max_recent = 100
self._sink = None # Optional TokenUsageSinkProtocol
def set_sink(self, sink) -> None:
"""Set a token usage sink for persistence.
Args:
sink: Any object implementing TokenUsageSinkProtocol
(must have a persist() method)
"""
self._sink = sink
def track_tokens(
self,
@@ -140,6 +150,20 @@ class TokenCollector:
# Limit recent interactions
if len(self._recent_interactions) > self._max_recent:
self._recent_interactions.pop(0)
# Call sink outside the lock to avoid holding it during I/O
if self._sink is not None:
try:
task_id = (metadata or {}).get("task_id", "")
self._sink.persist(
task_id=task_id,
agent_name=agent or "",
model=model,
metrics=metrics,
metadata=metadata,
)
except Exception:
pass # Sink errors must not break token tracking
def get_session_summary(self) -> Dict:
"""Get summary of token usage for the session."""
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.6.2"
version = "1.6.3"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -0,0 +1,108 @@
"""Tests for Per-Agent Concurrency Limiter (Gap 2).
Validates:
- ConcurrencyRegistry singleton
- Per-agent semaphore limits
- Default limit applies to unregistered agents
- Async acquire/release
- Thread safety
"""
import asyncio
import pytest
class TestConcurrencyRegistry:
"""Test ConcurrencyRegistry."""
def test_singleton(self):
from praisonaiagents.agent.concurrency import get_concurrency_registry
r1 = get_concurrency_registry()
r2 = get_concurrency_registry()
assert r1 is r2
def test_set_limit(self):
from praisonaiagents.agent.concurrency import ConcurrencyRegistry
reg = ConcurrencyRegistry()
reg.set_limit("researcher", 2)
assert reg.get_limit("researcher") == 2
def test_default_limit(self):
from praisonaiagents.agent.concurrency import ConcurrencyRegistry
reg = ConcurrencyRegistry()
# Unregistered agent gets default (no limit = 0 means unlimited)
assert reg.get_limit("unknown_agent") == 0
def test_set_default_limit(self):
from praisonaiagents.agent.concurrency import ConcurrencyRegistry
reg = ConcurrencyRegistry(default_limit=5)
assert reg.get_limit("any_agent") == 5
def test_remove_limit(self):
from praisonaiagents.agent.concurrency import ConcurrencyRegistry
reg = ConcurrencyRegistry()
reg.set_limit("agent1", 3)
reg.remove_limit("agent1")
assert reg.get_limit("agent1") == 0 # back to default
class TestConcurrencyLimiting:
"""Test actual concurrency limiting via async acquire/release."""
@pytest.mark.asyncio
async def test_acquire_release(self):
from praisonaiagents.agent.concurrency import ConcurrencyRegistry
reg = ConcurrencyRegistry()
reg.set_limit("agent_a", 1)
# Should acquire immediately
await reg.acquire("agent_a")
# Release
reg.release("agent_a")
@pytest.mark.asyncio
async def test_concurrency_limit_enforced(self):
"""Only N tasks can run concurrently per agent."""
from praisonaiagents.agent.concurrency import ConcurrencyRegistry
reg = ConcurrencyRegistry()
reg.set_limit("agent_b", 2)
active = []
max_active = [0]
async def task(name):
await reg.acquire(name)
active.append(1)
max_active[0] = max(max_active[0], len(active))
await asyncio.sleep(0.05)
active.pop()
reg.release(name)
# Run 5 tasks, only 2 should be active at a time
await asyncio.gather(*(task("agent_b") for _ in range(5)))
assert max_active[0] <= 2
@pytest.mark.asyncio
async def test_unlimited_when_no_limit(self):
"""When limit=0 (unlimited), acquire is a no-op."""
from praisonaiagents.agent.concurrency import ConcurrencyRegistry
reg = ConcurrencyRegistry()
# No limit set — should not block
await reg.acquire("no_limit_agent")
reg.release("no_limit_agent")
@pytest.mark.asyncio
async def test_context_manager(self):
"""Test async context manager usage."""
from praisonaiagents.agent.concurrency import ConcurrencyRegistry
reg = ConcurrencyRegistry()
reg.set_limit("ctx_agent", 1)
async with reg.throttle("ctx_agent"):
pass # Should acquire and release cleanly
def test_sync_acquire_release(self):
"""Test synchronous acquire/release for non-async code paths."""
from praisonaiagents.agent.concurrency import ConcurrencyRegistry
reg = ConcurrencyRegistry()
reg.set_limit("sync_agent", 2)
# Sync acquire should work
reg.acquire_sync("sync_agent")
reg.release("sync_agent")
@@ -0,0 +1,133 @@
"""Tests for Task Lifecycle State Machine (Gap 1).
Validates:
- TaskStatus enum values and string compatibility
- Valid state transitions
- Invalid state transitions raise errors
- EventBus integration on transitions
- Backward compatibility with raw string status
"""
import pytest
class TestTaskStatus:
"""Test TaskStatus enum."""
def test_enum_values(self):
from praisonaiagents.task.protocols import TaskStatus
assert TaskStatus.NOT_STARTED == "not started"
assert TaskStatus.IN_PROGRESS == "in progress"
assert TaskStatus.COMPLETED == "completed"
assert TaskStatus.FAILED == "failed"
assert TaskStatus.CANCELLED == "cancelled"
def test_string_equality(self):
"""TaskStatus must compare equal to raw strings for backward compat."""
from praisonaiagents.task.protocols import TaskStatus
assert TaskStatus.NOT_STARTED == "not started"
assert "not started" == TaskStatus.NOT_STARTED
assert TaskStatus.COMPLETED == "completed"
def test_str_representation(self):
from praisonaiagents.task.protocols import TaskStatus
assert str(TaskStatus.NOT_STARTED) == "not started"
assert str(TaskStatus.IN_PROGRESS) == "in progress"
class TestTaskLifecycleManager:
"""Test TaskLifecycleManager transition validation."""
def test_valid_transitions(self):
from praisonaiagents.task.protocols import TaskLifecycleManager, TaskStatus
mgr = TaskLifecycleManager()
# not started -> in progress
assert mgr.can_transition(TaskStatus.NOT_STARTED, TaskStatus.IN_PROGRESS)
# not started -> cancelled
assert mgr.can_transition(TaskStatus.NOT_STARTED, TaskStatus.CANCELLED)
# in progress -> completed
assert mgr.can_transition(TaskStatus.IN_PROGRESS, TaskStatus.COMPLETED)
# in progress -> failed
assert mgr.can_transition(TaskStatus.IN_PROGRESS, TaskStatus.FAILED)
# in progress -> cancelled
assert mgr.can_transition(TaskStatus.IN_PROGRESS, TaskStatus.CANCELLED)
# failed -> in progress (retry)
assert mgr.can_transition(TaskStatus.FAILED, TaskStatus.IN_PROGRESS)
# not started -> failed (import error etc)
assert mgr.can_transition(TaskStatus.NOT_STARTED, TaskStatus.FAILED)
def test_invalid_transitions(self):
from praisonaiagents.task.protocols import TaskLifecycleManager, TaskStatus
mgr = TaskLifecycleManager()
# completed -> in progress (can't resume completed)
assert not mgr.can_transition(TaskStatus.COMPLETED, TaskStatus.IN_PROGRESS)
# cancelled -> in progress
assert not mgr.can_transition(TaskStatus.CANCELLED, TaskStatus.IN_PROGRESS)
# completed -> failed
assert not mgr.can_transition(TaskStatus.COMPLETED, TaskStatus.FAILED)
def test_transition_executes(self):
from praisonaiagents.task.protocols import TaskLifecycleManager, TaskStatus
mgr = TaskLifecycleManager()
new_status = mgr.transition(TaskStatus.NOT_STARTED, TaskStatus.IN_PROGRESS)
assert new_status == TaskStatus.IN_PROGRESS
def test_invalid_transition_raises(self):
from praisonaiagents.task.protocols import TaskLifecycleManager, TaskStatus, InvalidTransitionError
mgr = TaskLifecycleManager()
with pytest.raises(InvalidTransitionError):
mgr.transition(TaskStatus.COMPLETED, TaskStatus.IN_PROGRESS)
def test_transition_callback(self):
"""Callback fires on valid transition."""
from praisonaiagents.task.protocols import TaskLifecycleManager, TaskStatus
events = []
def on_transition(old, new, task_id):
events.append((old, new, task_id))
mgr = TaskLifecycleManager(on_transition=on_transition)
mgr.transition(TaskStatus.NOT_STARTED, TaskStatus.IN_PROGRESS, task_id="t1")
assert len(events) == 1
assert events[0] == (TaskStatus.NOT_STARTED, TaskStatus.IN_PROGRESS, "t1")
class TestTaskStatusIntegration:
"""Test that Task class uses lifecycle manager."""
def test_task_initial_status(self):
from praisonaiagents.task.task import Task
from praisonaiagents.task.protocols import TaskStatus
t = Task(description="Test task")
assert t.status == TaskStatus.NOT_STARTED
assert t.status == "not started" # backward compat
def test_task_set_status_valid(self):
from praisonaiagents.task.task import Task
from praisonaiagents.task.protocols import TaskStatus
t = Task(description="Test task")
t.set_status(TaskStatus.IN_PROGRESS)
assert t.status == TaskStatus.IN_PROGRESS
def test_task_set_status_with_string(self):
"""Backward compat: can set status with raw strings."""
from praisonaiagents.task.task import Task
t = Task(description="Test task")
t.set_status("in progress")
assert t.status == "in progress"
def test_task_invalid_transition_still_sets(self):
"""Invalid transitions log warning but don't crash (backward compat)."""
from praisonaiagents.task.task import Task
t = Task(description="Test task")
t.set_status("completed") # not started -> completed is VALID via in_progress
# Force to completed state
t.status = "completed"
# Now try completed -> in progress (invalid)
# Should still set for backward compat (logs warning internally)
t.set_status("in progress")
assert t.status == "in progress"
def test_task_direct_assignment_still_works(self):
"""Direct task.status = 'x' still works for backward compat."""
from praisonaiagents.task.task import Task
t = Task(description="Test task")
t.status = "in progress"
assert t.status == "in progress"
@@ -0,0 +1,156 @@
"""Tests for TaskMessageSinkProtocol (Gap 4).
Validates:
- Protocol structural subtyping
- NoOpTaskMessageSink (default — zero overhead)
- InMemoryTaskMessageSink for testing/debugging
- Sequenced messages with auto-incrementing seq_num
- Replay by task_id
- Message types: agent_output, tool_call, tool_result, error, status
- Registry pattern for sink management
"""
import pytest
class TestTaskMessageSinkProtocol:
"""Test the protocol definition."""
def test_protocol_exists(self):
from praisonaiagents.task.message_sink import TaskMessageSinkProtocol
assert TaskMessageSinkProtocol is not None
def test_noop_satisfies_protocol(self):
from praisonaiagents.task.message_sink import TaskMessageSinkProtocol, NoOpTaskMessageSink
sink = NoOpTaskMessageSink()
assert isinstance(sink, TaskMessageSinkProtocol)
def test_inmemory_satisfies_protocol(self):
from praisonaiagents.task.message_sink import TaskMessageSinkProtocol, InMemoryTaskMessageSink
sink = InMemoryTaskMessageSink()
assert isinstance(sink, TaskMessageSinkProtocol)
class TestTaskMessage:
"""Test TaskMessage dataclass."""
def test_message_creation(self):
from praisonaiagents.task.message_sink import TaskMessage
msg = TaskMessage(
task_id="t1",
seq_num=1,
msg_type="agent_output",
content="Hello world",
agent_name="researcher",
)
assert msg.task_id == "t1"
assert msg.seq_num == 1
assert msg.msg_type == "agent_output"
assert msg.content == "Hello world"
assert msg.agent_name == "researcher"
assert isinstance(msg.timestamp, str)
def test_message_to_dict(self):
from praisonaiagents.task.message_sink import TaskMessage
msg = TaskMessage(
task_id="t1",
seq_num=0,
msg_type="tool_call",
content="search_web('AI')",
agent_name="researcher",
)
d = msg.to_dict()
assert d["task_id"] == "t1"
assert d["msg_type"] == "tool_call"
assert "timestamp" in d
class TestNoOpTaskMessageSink:
"""Test NoOp sink does nothing."""
def test_emit_is_noop(self):
from praisonaiagents.task.message_sink import NoOpTaskMessageSink, TaskMessage
sink = NoOpTaskMessageSink()
msg = TaskMessage(task_id="t1", seq_num=0, msg_type="agent_output", content="hi")
sink.emit(msg) # Should not raise
def test_replay_returns_empty(self):
from praisonaiagents.task.message_sink import NoOpTaskMessageSink
sink = NoOpTaskMessageSink()
assert sink.replay("t1") == []
class TestInMemoryTaskMessageSink:
"""Test InMemory sink stores and replays messages."""
def test_emit_stores_message(self):
from praisonaiagents.task.message_sink import InMemoryTaskMessageSink, TaskMessage
sink = InMemoryTaskMessageSink()
msg = TaskMessage(task_id="t1", seq_num=0, msg_type="agent_output", content="Hello")
sink.emit(msg)
assert len(sink.messages) == 1
def test_replay_by_task_id(self):
from praisonaiagents.task.message_sink import InMemoryTaskMessageSink, TaskMessage
sink = InMemoryTaskMessageSink()
sink.emit(TaskMessage(task_id="t1", seq_num=0, msg_type="agent_output", content="a"))
sink.emit(TaskMessage(task_id="t2", seq_num=0, msg_type="agent_output", content="b"))
sink.emit(TaskMessage(task_id="t1", seq_num=1, msg_type="tool_call", content="c"))
result = sink.replay("t1")
assert len(result) == 2
assert result[0].seq_num == 0
assert result[1].seq_num == 1
def test_replay_ordered_by_seq_num(self):
from praisonaiagents.task.message_sink import InMemoryTaskMessageSink, TaskMessage
sink = InMemoryTaskMessageSink()
# Insert out of order
sink.emit(TaskMessage(task_id="t1", seq_num=2, msg_type="error", content="err"))
sink.emit(TaskMessage(task_id="t1", seq_num=0, msg_type="agent_output", content="a"))
sink.emit(TaskMessage(task_id="t1", seq_num=1, msg_type="tool_call", content="b"))
result = sink.replay("t1")
assert [m.seq_num for m in result] == [0, 1, 2]
def test_clear(self):
from praisonaiagents.task.message_sink import InMemoryTaskMessageSink, TaskMessage
sink = InMemoryTaskMessageSink()
sink.emit(TaskMessage(task_id="t1", seq_num=0, msg_type="agent_output", content="a"))
sink.clear()
assert len(sink.messages) == 0
def test_message_types(self):
from praisonaiagents.task.message_sink import InMemoryTaskMessageSink, TaskMessage
sink = InMemoryTaskMessageSink()
types = ["agent_output", "tool_call", "tool_result", "error", "status"]
for i, t in enumerate(types):
sink.emit(TaskMessage(task_id="t1", seq_num=i, msg_type=t, content=f"msg_{t}"))
result = sink.replay("t1")
assert len(result) == 5
assert [m.msg_type for m in result] == types
class TestTaskMessageEmitter:
"""Test the convenience emitter that auto-sequences messages."""
def test_emitter_auto_sequences(self):
from praisonaiagents.task.message_sink import InMemoryTaskMessageSink, TaskMessageEmitter
sink = InMemoryTaskMessageSink()
emitter = TaskMessageEmitter(task_id="t1", sink=sink)
emitter.emit("agent_output", "Hello")
emitter.emit("tool_call", "search_web('AI')")
emitter.emit("agent_output", "Done")
result = sink.replay("t1")
assert [m.seq_num for m in result] == [0, 1, 2]
def test_emitter_with_agent_name(self):
from praisonaiagents.task.message_sink import InMemoryTaskMessageSink, TaskMessageEmitter
sink = InMemoryTaskMessageSink()
emitter = TaskMessageEmitter(task_id="t1", sink=sink, agent_name="researcher")
emitter.emit("agent_output", "result")
assert sink.messages[0].agent_name == "researcher"
def test_emitter_with_noop_sink(self):
"""Emitter works with NoOp sink (zero overhead)."""
from praisonaiagents.task.message_sink import NoOpTaskMessageSink, TaskMessageEmitter
sink = NoOpTaskMessageSink()
emitter = TaskMessageEmitter(task_id="t1", sink=sink)
emitter.emit("agent_output", "Hello") # Should not raise
@@ -0,0 +1,136 @@
"""Tests for TokenUsageSinkProtocol (Gap 3).
Validates:
- Protocol structural subtyping
- NoOpTokenUsageSink (default — zero overhead)
- InMemoryTokenUsageSink for testing/debugging
- TokenCollector integration with sink
- Registry pattern for sink management
"""
import pytest
class TestTokenUsageSinkProtocol:
"""Test the protocol definition."""
def test_protocol_exists(self):
from praisonaiagents.telemetry.protocols import TokenUsageSinkProtocol
assert TokenUsageSinkProtocol is not None
def test_noop_satisfies_protocol(self):
from praisonaiagents.telemetry.protocols import TokenUsageSinkProtocol, NoOpTokenUsageSink
sink = NoOpTokenUsageSink()
assert isinstance(sink, TokenUsageSinkProtocol)
def test_inmemory_satisfies_protocol(self):
from praisonaiagents.telemetry.protocols import TokenUsageSinkProtocol, InMemoryTokenUsageSink
sink = InMemoryTokenUsageSink()
assert isinstance(sink, TokenUsageSinkProtocol)
class TestNoOpTokenUsageSink:
"""Test NoOp sink does nothing."""
def test_persist_is_noop(self):
from praisonaiagents.telemetry.protocols import NoOpTokenUsageSink
from praisonaiagents.telemetry.token_collector import TokenMetrics
sink = NoOpTokenUsageSink()
# Should not raise
sink.persist(
task_id="t1",
agent_name="researcher",
model="gpt-4o",
metrics=TokenMetrics(input_tokens=100, output_tokens=50),
)
class TestInMemoryTokenUsageSink:
"""Test InMemory sink stores records."""
def test_persist_stores_record(self):
from praisonaiagents.telemetry.protocols import InMemoryTokenUsageSink
from praisonaiagents.telemetry.token_collector import TokenMetrics
sink = InMemoryTokenUsageSink()
sink.persist(
task_id="t1",
agent_name="researcher",
model="gpt-4o",
metrics=TokenMetrics(input_tokens=100, output_tokens=50),
)
assert len(sink.records) == 1
assert sink.records[0]["task_id"] == "t1"
assert sink.records[0]["model"] == "gpt-4o"
def test_get_by_task(self):
from praisonaiagents.telemetry.protocols import InMemoryTokenUsageSink
from praisonaiagents.telemetry.token_collector import TokenMetrics
sink = InMemoryTokenUsageSink()
m = TokenMetrics(input_tokens=10, output_tokens=5)
sink.persist(task_id="t1", agent_name="a", model="m1", metrics=m)
sink.persist(task_id="t2", agent_name="b", model="m1", metrics=m)
sink.persist(task_id="t1", agent_name="a", model="m2", metrics=m)
result = sink.get_by_task("t1")
assert len(result) == 2
def test_get_by_agent(self):
from praisonaiagents.telemetry.protocols import InMemoryTokenUsageSink
from praisonaiagents.telemetry.token_collector import TokenMetrics
sink = InMemoryTokenUsageSink()
m = TokenMetrics(input_tokens=10, output_tokens=5)
sink.persist(task_id="t1", agent_name="a", model="m1", metrics=m)
sink.persist(task_id="t2", agent_name="b", model="m1", metrics=m)
result = sink.get_by_agent("a")
assert len(result) == 1
def test_clear(self):
from praisonaiagents.telemetry.protocols import InMemoryTokenUsageSink
from praisonaiagents.telemetry.token_collector import TokenMetrics
sink = InMemoryTokenUsageSink()
sink.persist(task_id="t1", agent_name="a", model="m1", metrics=TokenMetrics())
sink.clear()
assert len(sink.records) == 0
class TestTokenCollectorSinkIntegration:
"""Test that TokenCollector calls the sink on track_tokens."""
def test_collector_with_sink(self):
from praisonaiagents.telemetry.token_collector import TokenCollector, TokenMetrics
from praisonaiagents.telemetry.protocols import InMemoryTokenUsageSink
sink = InMemoryTokenUsageSink()
collector = TokenCollector()
collector.set_sink(sink)
collector.track_tokens(
model="gpt-4o",
agent="researcher",
metrics=TokenMetrics(input_tokens=100, output_tokens=50),
metadata={"task_id": "t1"},
)
assert len(sink.records) == 1
assert sink.records[0]["task_id"] == "t1"
def test_collector_without_sink_no_error(self):
"""Default collector with no sink should work fine."""
from praisonaiagents.telemetry.token_collector import TokenCollector, TokenMetrics
collector = TokenCollector()
# Should not raise even without a sink
collector.track_tokens(
model="gpt-4o",
agent="researcher",
metrics=TokenMetrics(input_tokens=100, output_tokens=50),
)
def test_collector_sink_receives_task_id_from_metadata(self):
from praisonaiagents.telemetry.token_collector import TokenCollector, TokenMetrics
from praisonaiagents.telemetry.protocols import InMemoryTokenUsageSink
sink = InMemoryTokenUsageSink()
collector = TokenCollector()
collector.set_sink(sink)
collector.track_tokens(
model="gpt-4o",
agent="researcher",
metrics=TokenMetrics(input_tokens=50, output_tokens=25),
metadata={"task_id": "task-abc"},
)
assert sink.records[0]["task_id"] == "task-abc"
assert sink.records[0]["agent_name"] == "researcher"
+1 -1
View File
@@ -2951,7 +2951,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.6.2"
version = "1.6.3"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+75 -5
View File
@@ -1,14 +1,14 @@
{
"version": "1.5.87",
"lastUpdated": "2026-04-11",
"lastUpdated": "2026-04-13",
"generatedBy": "praisonai._dev.parity.generator",
"sourceOfTruth": "Python SDK (praisonaiagents)",
"status": "PARITY_ACHIEVED",
"summary": {
"pythonCoreFeatures": 321,
"pythonCoreFeatures": 328,
"rustFeatures": 667,
"gapCount": 42,
"parityPercentage": 207.8
"gapCount": 49,
"parityPercentage": 203.4
},
"pythonCoreSDK": {
"exports": [
@@ -20,6 +20,7 @@
"AgentAppProtocol",
"AgentFlow",
"AgentManager",
"AgentMessageEvent",
"AgentOSConfig",
"AgentOSProtocol",
"AgentPluginProtocol",
@@ -71,6 +72,7 @@
"ContextTraceEmitter",
"ContextTraceSink",
"ContextTraceSinkProtocol",
"CustomToolUseEvent",
"DeepResearchAgent",
"DeepResearchResponse",
"DefaultsConfig",
@@ -144,6 +146,7 @@
"MULTI_AGENT_EXECUTION_PRESETS",
"MULTI_AGENT_OUTPUT_PRESETS",
"ManagedBackendProtocol",
"ManagedEvent",
"ManagerConfig",
"Memory",
"MemoryBackend",
@@ -215,12 +218,15 @@
"SecurityPolicy",
"Session",
"SessionConfig",
"SessionErrorEvent",
"SessionIdleEvent",
"SkillLoader",
"SkillManager",
"SkillMetadata",
"SkillProperties",
"SkillsConfig",
"StepResult",
"StopReason",
"StructuredFormatter",
"Task",
"TaskOutput",
@@ -232,6 +238,7 @@
"ToolPluginProtocol",
"ToolRegistry",
"ToolResult",
"ToolUseEvent",
"ToolValidationError",
"Tools",
"TraceSink",
@@ -2853,6 +2860,15 @@
"status": "DONE",
"category": "agents"
},
{
"feature": "AgentMessageEvent",
"python": true,
"rust": false,
"priority": "P3",
"effort": "high",
"status": "TODO",
"category": "other"
},
{
"feature": "AgentOSConfig",
"python": true,
@@ -3177,6 +3193,15 @@
"status": "DONE",
"category": "trace"
},
{
"feature": "CustomToolUseEvent",
"python": true,
"rust": false,
"priority": "P3",
"effort": "high",
"status": "TODO",
"category": "other"
},
{
"feature": "DefaultsConfig",
"python": true,
@@ -3256,7 +3281,7 @@
"priority": "P3",
"effort": "high",
"status": "DONE",
"category": "gateway"
"category": "other"
},
{
"feature": "ExecutionConfig",
@@ -3618,6 +3643,15 @@
"status": "TODO",
"category": "other"
},
{
"feature": "ManagedEvent",
"python": true,
"rust": false,
"priority": "P3",
"effort": "high",
"status": "TODO",
"category": "other"
},
{
"feature": "MemoryBackend",
"python": true,
@@ -3969,6 +4003,24 @@
"status": "DONE",
"category": "gateway"
},
{
"feature": "SessionErrorEvent",
"python": true,
"rust": false,
"priority": "P3",
"effort": "high",
"status": "TODO",
"category": "other"
},
{
"feature": "SessionIdleEvent",
"python": true,
"rust": false,
"priority": "P3",
"effort": "high",
"status": "TODO",
"category": "other"
},
{
"feature": "SkillsConfig",
"python": true,
@@ -3978,6 +4030,15 @@
"status": "DONE",
"category": "config"
},
{
"feature": "StopReason",
"python": true,
"rust": false,
"priority": "P3",
"effort": "high",
"status": "TODO",
"category": "other"
},
{
"feature": "StructuredFormatter",
"python": true,
@@ -4032,6 +4093,15 @@
"status": "DONE",
"category": "plugins"
},
{
"feature": "ToolUseEvent",
"python": true,
"rust": false,
"priority": "P3",
"effort": "high",
"status": "TODO",
"category": "other"
},
{
"feature": "TraceSink",
"python": true,
+11 -4
View File
@@ -1,16 +1,16 @@
# Rust Feature Parity Tracker
> **Python Features:** 321 | **Rust Features:** 667 | **Parity:** 88.8%
> **Python Features:** 328 | **Rust Features:** 667 | **Parity:** 86.9%
## Summary
| Metric | Count |
|--------|-------|
| Python Core Features | 321 |
| Python Core Features | 328 |
| Rust Features | 667 |
| **Actual Gap Count** | **36** |
| **Actual Gap Count** | **43** |
| Language Limitations (N/A) | 5 |
| **Parity** | **88.8%** |
| **Parity** | **86.9%** |
## Implemented Features
@@ -694,6 +694,7 @@ These Python features cannot be directly implemented in Rust due to reserved key
## Missing Features
- ❌ `AgentMessageEvent`
- ❌ `AsyncLearnProtocol`
- ❌ `AutoApproveBackend`
- ❌ `BaseTool`
@@ -701,6 +702,7 @@ These Python features cannot be directly implemented in Rust due to reserved key
- ❌ `BotOSProtocol`
- ❌ `BudgetExceededError`
- ❌ `ConsoleBackend`
- ❌ `CustomToolUseEvent`
- ❌ `DoomLoopDetector`
- ❌ `ErrorContextProtocol`
- ❌ `EscalationPipeline`
@@ -716,12 +718,17 @@ These Python features cannot be directly implemented in Rust due to reserved key
- ❌ `LearnProtocol`
- ❌ `LearnScope`
- ❌ `ManagedBackendProtocol`
- ❌ `ManagedEvent`
- ❌ `NetworkError`
- ❌ `ObservabilityEventType`
- ❌ `ObservabilityHooks`
- ❌ `PraisonAIError`
- ❌ `SessionErrorEvent`
- ❌ `SessionIdleEvent`
- ❌ `StopReason`
- ❌ `StructuredFormatter`
- ❌ `ToolExecutionError`
- ❌ `ToolUseEvent`
- ❌ `ToolValidationError`
- ❌ `configure_structured_logging`
- ❌ `get_logger`
+83 -6
View File
@@ -1,17 +1,17 @@
{
"version": "1.5.87",
"lastUpdated": "2026-04-11",
"lastUpdated": "2026-04-13",
"generatedBy": "praisonai._dev.parity.generator",
"sourceOfTruth": "Python SDK (praisonaiagents)",
"summary": {
"pythonCoreFeatures": 321,
"pythonCoreFeatures": 328,
"pythonWrapperFeatures": 99,
"typescriptFeatures": 1192,
"gapCount": 29,
"gapCount": 36,
"priorityP0": 1,
"priorityP1": 0,
"priorityP2": 0,
"priorityP3": 28
"priorityP3": 35
},
"pythonCoreSDK": {
"path": "/home/runner/work/PraisonAI/PraisonAI/src/praisonai-agents/praisonaiagents",
@@ -24,6 +24,7 @@
"AgentAppProtocol",
"AgentFlow",
"AgentManager",
"AgentMessageEvent",
"AgentOSConfig",
"AgentOSProtocol",
"AgentPluginProtocol",
@@ -75,6 +76,7 @@
"ContextTraceEmitter",
"ContextTraceSink",
"ContextTraceSinkProtocol",
"CustomToolUseEvent",
"DeepResearchAgent",
"DeepResearchResponse",
"DefaultsConfig",
@@ -148,6 +150,7 @@
"MULTI_AGENT_EXECUTION_PRESETS",
"MULTI_AGENT_OUTPUT_PRESETS",
"ManagedBackendProtocol",
"ManagedEvent",
"ManagerConfig",
"Memory",
"MemoryBackend",
@@ -219,12 +222,15 @@
"SecurityPolicy",
"Session",
"SessionConfig",
"SessionErrorEvent",
"SessionIdleEvent",
"SkillLoader",
"SkillManager",
"SkillMetadata",
"SkillProperties",
"SkillsConfig",
"StepResult",
"StopReason",
"StructuredFormatter",
"Task",
"TaskOutput",
@@ -236,6 +242,7 @@
"ToolPluginProtocol",
"ToolRegistry",
"ToolResult",
"ToolUseEvent",
"ToolValidationError",
"Tools",
"TraceSink",
@@ -519,7 +526,6 @@
"track_workflow"
],
"gateway": [
"EventType",
"GatewayClientProtocol",
"GatewayConfig",
"GatewayEvent",
@@ -564,14 +570,17 @@
"Memory"
],
"other": [
"AgentMessageEvent",
"Agents",
"AsyncLearnProtocol",
"AutonomyConfig",
"BudgetExceededError",
"CustomToolUseEvent",
"DoomLoopDetector",
"ErrorContextProtocol",
"EscalationPipeline",
"EscalationStage",
"EventType",
"HandoffCycleError",
"HandoffDepthError",
"HandoffError",
@@ -583,12 +592,17 @@
"LearnManagerProtocol",
"LearnProtocol",
"ManagedBackendProtocol",
"ManagedEvent",
"NetworkError",
"ObservabilityEventType",
"ObservabilityHooks",
"PraisonAIError",
"SessionErrorEvent",
"SessionIdleEvent",
"StopReason",
"StructuredFormatter",
"ToolExecutionError",
"ToolUseEvent",
"ValidationError",
"config",
"configure_structured_logging",
@@ -3930,6 +3944,15 @@
"status": "DONE",
"category": "agents"
},
{
"feature": "AgentMessageEvent",
"python": true,
"typescript": false,
"priority": "P3",
"effort": "high",
"status": "TODO",
"category": "other"
},
{
"feature": "AgentOSConfig",
"python": true,
@@ -4254,6 +4277,15 @@
"status": "DONE",
"category": "trace"
},
{
"feature": "CustomToolUseEvent",
"python": true,
"typescript": false,
"priority": "P3",
"effort": "high",
"status": "TODO",
"category": "other"
},
{
"feature": "DefaultsConfig",
"python": true,
@@ -4333,7 +4365,7 @@
"priority": "P3",
"effort": "high",
"status": "DONE",
"category": "gateway"
"category": "other"
},
{
"feature": "ExecutionConfig",
@@ -4695,6 +4727,15 @@
"status": "TODO",
"category": "other"
},
{
"feature": "ManagedEvent",
"python": true,
"typescript": false,
"priority": "P3",
"effort": "high",
"status": "TODO",
"category": "other"
},
{
"feature": "MemoryBackend",
"python": true,
@@ -5046,6 +5087,24 @@
"status": "DONE",
"category": "gateway"
},
{
"feature": "SessionErrorEvent",
"python": true,
"typescript": false,
"priority": "P3",
"effort": "high",
"status": "TODO",
"category": "other"
},
{
"feature": "SessionIdleEvent",
"python": true,
"typescript": false,
"priority": "P3",
"effort": "high",
"status": "TODO",
"category": "other"
},
{
"feature": "SkillsConfig",
"python": true,
@@ -5055,6 +5114,15 @@
"status": "DONE",
"category": "config"
},
{
"feature": "StopReason",
"python": true,
"typescript": false,
"priority": "P3",
"effort": "high",
"status": "TODO",
"category": "other"
},
{
"feature": "StructuredFormatter",
"python": true,
@@ -5109,6 +5177,15 @@
"status": "DONE",
"category": "plugins"
},
{
"feature": "ToolUseEvent",
"python": true,
"typescript": false,
"priority": "P3",
"effort": "high",
"status": "TODO",
"category": "other"
},
{
"feature": "TraceSink",
"python": true,
+16 -9
View File
@@ -1,20 +1,20 @@
# Feature Parity Tracker
> **Version:** 1.5.87 | **Last Updated:** 2026-04-11
> **Version:** 1.5.87 | **Last Updated:** 2026-04-13
> **Source of Truth:** Python SDK (praisonaiagents)
## Summary
| Metric | Count |
|--------|-------|
| Python Core Features | 321 |
| Python Core Features | 328 |
| Python Wrapper Features | 99 |
| TypeScript Features | 1192 |
| **Gap Count** | **29** |
| **Gap Count** | **36** |
| P0 (Critical) | 1 |
| P1 (High) | 0 |
| P2 (Medium) | 0 |
| P3 (Low) | 28 |
| P3 (Low) | 35 |
## Gap Matrix
@@ -147,15 +147,17 @@
| `enable\_telemetry` | ✅ | ✅ | low | ✅ DONE |
| `get\_telemetry` | ✅ | ✅ | low | ✅ DONE |
### P3_Advanced (179 done, 28 todo)
### P3_Advanced (179 done, 35 todo)
| Feature | Python | TypeScript | Effort | Status |
|---------|--------|------------|--------|--------|
| `AgentMessageEvent` | ✅ | ❌ | high | ⏳ TODO |
| `AsyncLearnProtocol` | ✅ | ❌ | medium | ⏳ TODO |
| `AutoApproveBackend` | ✅ | ❌ | high | ⏳ TODO |
| `BotOSConfig` | ✅ | ❌ | low | ⏳ TODO |
| `BotOSProtocol` | ✅ | ❌ | medium | ⏳ TODO |
| `ConsoleBackend` | ✅ | ❌ | high | ⏳ TODO |
| `CustomToolUseEvent` | ✅ | ❌ | high | ⏳ TODO |
| `DoomLoopDetector` | ✅ | ❌ | high | ⏳ TODO |
| `ErrorContextProtocol` | ✅ | ❌ | medium | ⏳ TODO |
| `EscalationPipeline` | ✅ | ❌ | high | ⏳ TODO |
@@ -170,12 +172,17 @@
| `LearnProtocol` | ✅ | ❌ | medium | ⏳ TODO |
| `LearnScope` | ✅ | ❌ | high | ⏳ TODO |
| `ManagedBackendProtocol` | ✅ | ❌ | medium | ⏳ TODO |
| `ManagedEvent` | ✅ | ❌ | high | ⏳ TODO |
| `NetworkError` | ✅ | ❌ | low | ⏳ TODO |
| `ObservabilityEventType` | ✅ | ❌ | high | ⏳ TODO |
| `ObservabilityHooks` | ✅ | ❌ | high | ⏳ TODO |
| `PraisonAIError` | ✅ | ❌ | low | ⏳ TODO |
| `SessionErrorEvent` | ✅ | ❌ | high | ⏳ TODO |
| `SessionIdleEvent` | ✅ | ❌ | high | ⏳ TODO |
| `StopReason` | ✅ | ❌ | high | ⏳ TODO |
| `StructuredFormatter` | ✅ | ❌ | high | ⏳ TODO |
| `ToolExecutionError` | ✅ | ❌ | low | ⏳ TODO |
| `ToolUseEvent` | ✅ | ❌ | high | ⏳ TODO |
| `ValidationError` | ✅ | ❌ | low | ⏳ TODO |
| `configure\_structured\_logging` | ✅ | ❌ | low | ⏳ TODO |
| `get\_logger` | ✅ | ❌ | low | ⏳ TODO |
@@ -454,10 +461,10 @@ from praisonaiagents import FlowDisplay, track_workflow
</details>
<details>
<summary><strong>gateway</strong> (8 exports)</summary>
<summary><strong>gateway</strong> (7 exports)</summary>
```python
from praisonaiagents import EventType, GatewayClientProtocol, GatewayConfig, GatewayEvent, GatewayMessage, GatewayProtocol, GatewaySessionProtocol, SessionConfig
from praisonaiagents import GatewayClientProtocol, GatewayConfig, GatewayEvent, GatewayMessage, GatewayProtocol, GatewaySessionProtocol, SessionConfig
```
</details>
@@ -517,10 +524,10 @@ from praisonaiagents import Memory
</details>
<details>
<summary><strong>other</strong> (34 exports)</summary>
<summary><strong>other</strong> (42 exports)</summary>
```python
from praisonaiagents import Agents, AsyncLearnProtocol, AutonomyConfig, BudgetExceededError, DoomLoopDetector, ErrorContextProtocol, EscalationPipeline, EscalationStage, HandoffCycleError, HandoffDepthError...
from praisonaiagents import AgentMessageEvent, Agents, AsyncLearnProtocol, AutonomyConfig, BudgetExceededError, CustomToolUseEvent, DoomLoopDetector, ErrorContextProtocol, EscalationPipeline, EscalationStage...
```
</details>
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.2.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.2.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.3.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.3.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
+1 -1
View File
@@ -57,7 +57,7 @@ class CloudDeployer:
file.write("FROM python:3.11-slim\n")
file.write("WORKDIR /app\n")
file.write("COPY . .\n")
file.write("RUN pip install flask praisonai==4.6.2 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==4.6.3 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.6.2"
__version__ = "4.6.3"
+1 -1
View File
@@ -12,7 +12,7 @@ dependencies = [
"rich>=13.7",
"markdown>=3.5",
"pyparsing>=3.0.0",
"praisonaiagents>=1.6.2",
"praisonaiagents>=1.6.3",
"python-dotenv>=0.19.0",
"litellm>=1.81.0,<=1.82.6",
"PyYAML>=6.0",
+1 -1
View File
@@ -5155,7 +5155,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.6.2"
version = "1.6.3"
source = { directory = "../praisonai-agents" }
dependencies = [
{ name = "aiohttp" },