Compare commits

...
6 Commits
Author SHA1 Message Date
MervinPraison d76b56ec6b Release v4.6.4 2026-04-14 09:37:48 +01:00
MervinPraison f5d3e26de9 feat(auth): implement authentication and authorization protocols
Added new authentication and authorization features for PraisonAI Agents.
This includes the implementation of `AuthIdentity`, `AuthBackendProtocol`, and related schemas.
The new API routes and services facilitate session management, role checks,
and workspace context provision for enhanced security and user identity management.
2026-04-14 09:37:26 +01:00
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
66 changed files with 7812 additions and 38 deletions

No files matched your search

+2 -1
View File
@@ -97,4 +97,5 @@ Cargo.lock
src/praisonai/build*.egg-info/
src/praisonai/PraisonAI.egg-info/
jobs/*
jobs/*
*.egg-info
+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.4" \
"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.4" \
"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.4" \
"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
@@ -0,0 +1,31 @@
"""
Authentication & Authorization Protocols for PraisonAI Agents.
This package provides protocol-driven auth interfaces for the core SDK.
Implementations (JWT, OAuth, etc.) live in the platform package.
Quick start::
from praisonaiagents.auth import AuthIdentity, AuthBackendProtocol
class MyAuthBackend:
async def authenticate(self, credentials):
return AuthIdentity(id="user-1", type="user", roles=["owner"])
async def authorize(self, identity, resource, action):
return "owner" in identity.roles
"""
from .protocols import (
AuthIdentity,
AuthBackendProtocol,
WorkspaceContextProtocol,
AuthConfig,
)
__all__ = [
"AuthIdentity",
"AuthBackendProtocol",
"WorkspaceContextProtocol",
"AuthConfig",
]
@@ -0,0 +1,202 @@
"""
Auth protocol definitions for PraisonAI Agents.
Defines the structural contracts for authentication and authorization
backends via typing.Protocol. Any class implementing the required methods
satisfies the protocol without explicit inheritance (structural subtyping).
Design influenced by:
- Multica: JWT-based auth with email codes + Google OAuth, role-based
workspace membership (owner/admin/member), agents as assignees.
- Paperclip: better-auth sessions, company memberships with principal
types (user/agent), fine-grained permission grants.
- PraisonAI patterns: ApprovalProtocol, GatewayProtocol, DbAdapter.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
@dataclass
class AuthIdentity:
"""Authenticated identity (user or agent).
Attributes:
id: Unique identifier for this principal.
type: Principal type — ``"user"`` or ``"agent"``.
workspace_id: Active workspace/tenant scope (optional).
roles: Roles within the workspace (e.g. ``["owner"]``).
email: Email address (users only, optional).
name: Display name (optional).
metadata: Arbitrary extra data for backend-specific info.
"""
id: str
type: str = "user"
workspace_id: Optional[str] = None
roles: List[str] = field(default_factory=list)
email: Optional[str] = None
name: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
@property
def is_user(self) -> bool:
"""Check if this identity is a human user."""
return self.type == "user"
@property
def is_agent(self) -> bool:
"""Check if this identity is an agent."""
return self.type == "agent"
def has_role(self, role: str) -> bool:
"""Check if this identity has a specific role."""
return role in self.roles
def has_any_role(self, *roles: str) -> bool:
"""Check if this identity has any of the specified roles."""
return bool(set(roles) & set(self.roles))
@dataclass
class AuthConfig:
"""Configuration for authentication behaviour.
Follows PraisonAI's ``False/True/Config`` progressive-disclosure pattern:
- ``auth=False`` — disabled (no auth checks).
- ``auth=True`` — use default backend from registry.
- ``auth=AuthConfig(...)`` — full control.
Attributes:
backend: An :class:`AuthBackendProtocol` backend.
``None`` falls back to registry default.
token_ttl: Token time-to-live in seconds (default: 30 days).
require_workspace: Whether workspace_id is required for authorization.
"""
backend: Any = None
token_ttl: int = 30 * 24 * 3600
require_workspace: bool = False
@runtime_checkable
class AuthBackendProtocol(Protocol):
"""Protocol for authentication/authorization backends.
Implement ``authenticate`` and ``authorize`` to create custom auth:
- JWT token validation
- API key lookup
- OAuth token exchange
- Session cookie resolution
Example::
class JWTAuthBackend:
async def authenticate(
self, credentials: dict
) -> AuthIdentity | None:
token = credentials.get("token")
payload = decode_jwt(token)
return AuthIdentity(
id=payload["sub"],
type="user",
email=payload.get("email"),
roles=payload.get("roles", []),
)
async def authorize(
self, identity: AuthIdentity, resource: str, action: str
) -> bool:
return identity.has_role("admin")
"""
async def authenticate(
self,
credentials: Dict[str, Any],
) -> Optional[AuthIdentity]:
"""Authenticate credentials and return an identity.
Args:
credentials: Backend-specific credentials dict.
Common keys: ``token``, ``api_key``, ``email``, ``password``,
``session_id``.
Returns:
AuthIdentity if authentication succeeds, None otherwise.
"""
...
async def authorize(
self,
identity: AuthIdentity,
resource: str,
action: str,
) -> bool:
"""Check if an identity is authorized for an action on a resource.
Args:
identity: The authenticated identity.
resource: Resource identifier (e.g. ``"workspace:ws-123"``
or ``"issue:ISS-42"``).
action: Action to perform (e.g. ``"read"``, ``"write"``,
``"delete"``, ``"admin"``).
Returns:
True if authorized, False otherwise.
"""
...
@runtime_checkable
class WorkspaceContextProtocol(Protocol):
"""Protocol for providing workspace context to agents.
Enables agents to access workspace-specific settings, instructions,
and agent configurations from the platform layer.
Example::
class PlatformWorkspaceContext:
async def get_workspace_context(self, workspace_id):
ws = await db.get_workspace(workspace_id)
return ws.context
async def get_agent_config(self, workspace_id, agent_id):
agent = await db.get_agent(workspace_id, agent_id)
return {"system_prompt": agent.system_prompt, ...}
"""
async def get_workspace_context(
self,
workspace_id: str,
) -> Optional[str]:
"""Get workspace-level context/instructions for agents.
Args:
workspace_id: The workspace identifier.
Returns:
Context string if available, None otherwise.
"""
...
async def get_agent_config(
self,
workspace_id: str,
agent_id: str,
) -> Optional[Dict[str, Any]]:
"""Get agent configuration from the platform.
Args:
workspace_id: The workspace identifier.
agent_id: The agent identifier.
Returns:
Agent configuration dict if found, None otherwise.
Expected keys: ``system_prompt``, ``model``, ``tools``,
``max_concurrent_tasks``.
"""
...
@@ -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.4"
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")
Whitespace-only changes.
@@ -0,0 +1,204 @@
"""Tests for auth protocols in core SDK."""
import asyncio
from typing import Any, Dict, Optional
from praisonaiagents.auth import (
AuthIdentity,
AuthBackendProtocol,
WorkspaceContextProtocol,
AuthConfig,
)
from praisonaiagents.auth.protocols import AuthIdentity as DirectAuthIdentity
# ── AuthIdentity tests ────────────────────────────────────────────────────────
class TestAuthIdentity:
def test_create_user_identity(self):
identity = AuthIdentity(id="u-1", type="user", email="[email protected]")
assert identity.id == "u-1"
assert identity.type == "user"
assert identity.is_user is True
assert identity.is_agent is False
assert identity.email == "[email protected]"
def test_create_agent_identity(self):
identity = AuthIdentity(id="a-1", type="agent", name="CodeBot")
assert identity.is_agent is True
assert identity.is_user is False
assert identity.name == "CodeBot"
def test_roles(self):
identity = AuthIdentity(id="u-1", roles=["owner", "admin"])
assert identity.has_role("owner") is True
assert identity.has_role("member") is False
assert identity.has_any_role("member", "admin") is True
assert identity.has_any_role("guest") is False
def test_defaults(self):
identity = AuthIdentity(id="u-1")
assert identity.type == "user"
assert identity.workspace_id is None
assert identity.roles == []
assert identity.email is None
assert identity.name is None
assert identity.metadata == {}
def test_workspace_scoped(self):
identity = AuthIdentity(
id="u-1", workspace_id="ws-42", roles=["member"]
)
assert identity.workspace_id == "ws-42"
assert identity.has_role("member") is True
def test_metadata(self):
identity = AuthIdentity(id="u-1", metadata={"plan": "pro"})
assert identity.metadata["plan"] == "pro"
def test_import_from_package(self):
assert DirectAuthIdentity is AuthIdentity
# ── AuthConfig tests ──────────────────────────────────────────────────────────
class TestAuthConfig:
def test_defaults(self):
config = AuthConfig()
assert config.backend is None
assert config.token_ttl == 30 * 24 * 3600
assert config.require_workspace is False
def test_custom_config(self):
config = AuthConfig(token_ttl=3600, require_workspace=True)
assert config.token_ttl == 3600
assert config.require_workspace is True
# ── AuthBackendProtocol tests ─────────────────────────────────────────────────
class MockAuthBackend:
"""Mock backend that satisfies AuthBackendProtocol via structural subtyping."""
def __init__(self, user_db: Optional[Dict[str, AuthIdentity]] = None):
self.user_db = user_db or {}
async def authenticate(
self, credentials: Dict[str, Any]
) -> Optional[AuthIdentity]:
token = credentials.get("token", "")
return self.user_db.get(token)
async def authorize(
self, identity: AuthIdentity, resource: str, action: str
) -> bool:
if identity.has_role("owner"):
return True
if action == "read" and identity.has_role("member"):
return True
return False
class TestAuthBackendProtocol:
def test_isinstance_check(self):
backend = MockAuthBackend()
assert isinstance(backend, AuthBackendProtocol)
def test_authenticate_success(self):
identity = AuthIdentity(id="u-1", type="user", roles=["owner"])
backend = MockAuthBackend(user_db={"valid-token": identity})
result = asyncio.run(backend.authenticate({"token": "valid-token"}))
assert result is not None
assert result.id == "u-1"
def test_authenticate_failure(self):
backend = MockAuthBackend()
result = asyncio.run(backend.authenticate({"token": "bad-token"}))
assert result is None
def test_authorize_owner(self):
identity = AuthIdentity(id="u-1", roles=["owner"])
backend = MockAuthBackend()
assert asyncio.run(backend.authorize(identity, "issue:1", "delete")) is True
def test_authorize_member_read(self):
identity = AuthIdentity(id="u-2", roles=["member"])
backend = MockAuthBackend()
assert asyncio.run(backend.authorize(identity, "issue:1", "read")) is True
assert asyncio.run(backend.authorize(identity, "issue:1", "delete")) is False
def test_authorize_no_roles(self):
identity = AuthIdentity(id="u-3", roles=[])
backend = MockAuthBackend()
assert asyncio.run(backend.authorize(identity, "issue:1", "read")) is False
# ── WorkspaceContextProtocol tests ────────────────────────────────────────────
class MockWorkspaceContext:
"""Mock workspace context that satisfies WorkspaceContextProtocol."""
def __init__(self):
self.workspaces = {
"ws-1": "You are an AI assistant for the Acme project.",
}
self.agents = {
("ws-1", "agent-1"): {
"system_prompt": "You help with code reviews.",
"model": "gpt-4o-mini",
"tools": ["code_review"],
},
}
async def get_workspace_context(self, workspace_id: str) -> Optional[str]:
return self.workspaces.get(workspace_id)
async def get_agent_config(
self, workspace_id: str, agent_id: str
) -> Optional[Dict[str, Any]]:
return self.agents.get((workspace_id, agent_id))
class TestWorkspaceContextProtocol:
def test_isinstance_check(self):
ctx = MockWorkspaceContext()
assert isinstance(ctx, WorkspaceContextProtocol)
def test_get_workspace_context(self):
ctx = MockWorkspaceContext()
result = asyncio.run(ctx.get_workspace_context("ws-1"))
assert "Acme" in result
def test_get_workspace_context_missing(self):
ctx = MockWorkspaceContext()
result = asyncio.run(ctx.get_workspace_context("ws-999"))
assert result is None
def test_get_agent_config(self):
ctx = MockWorkspaceContext()
result = asyncio.run(ctx.get_agent_config("ws-1", "agent-1"))
assert result["model"] == "gpt-4o-mini"
def test_get_agent_config_missing(self):
ctx = MockWorkspaceContext()
result = asyncio.run(ctx.get_agent_config("ws-1", "agent-999"))
assert result is None
# ── Non-conforming class should fail isinstance ──────────────────────────────
class NotAnAuthBackend:
def some_method(self):
pass
class TestProtocolRejection:
def test_non_conforming_class_fails(self):
obj = NotAnAuthBackend()
assert not isinstance(obj, AuthBackendProtocol)
assert not isinstance(obj, WorkspaceContextProtocol)
@@ -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.4"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -0,0 +1,8 @@
"""
PraisonAI Platform — workspace, auth, issues, projects.
A separate package providing multi-tenancy, authentication, issue tracking,
and project management on top of the praisonaiagents core SDK.
"""
__version__ = "0.1.0"
@@ -0,0 +1,5 @@
"""FastAPI REST API for the platform."""
from .app import create_app
__all__ = ["create_app"]
@@ -0,0 +1,48 @@
"""FastAPI application factory."""
from __future__ import annotations
from contextlib import asynccontextmanager
from fastapi import FastAPI
from ..db.base import init_db
from .routes.activity import router as activity_router
from .routes.agents import router as agents_router
from .routes.auth import router as auth_router
from .routes.dependencies import router as dependencies_router
from .routes.issues import router as issues_router
from .routes.labels import router as labels_router
from .routes.projects import router as projects_router
from .routes.workspaces import router as workspaces_router
@asynccontextmanager
async def lifespan(app: FastAPI):
"""App lifespan: init DB on startup."""
await init_db()
yield
def create_app() -> FastAPI:
"""Create and configure the FastAPI application."""
app = FastAPI(
title="PraisonAI Platform",
version="0.1.0",
description="Workspace, auth, issues, and projects for PraisonAI agents",
lifespan=lifespan,
)
app.include_router(auth_router, prefix="/api/v1")
app.include_router(workspaces_router, prefix="/api/v1")
app.include_router(projects_router, prefix="/api/v1")
app.include_router(issues_router, prefix="/api/v1")
app.include_router(agents_router, prefix="/api/v1")
app.include_router(labels_router, prefix="/api/v1")
app.include_router(dependencies_router, prefix="/api/v1")
app.include_router(activity_router, prefix="/api/v1")
@app.get("/health")
async def health():
return {"status": "ok"}
return app
@@ -0,0 +1,51 @@
"""FastAPI dependency injection."""
from __future__ import annotations
from typing import AsyncGenerator, Optional
from fastapi import Depends, Header, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from praisonaiagents.auth import AuthIdentity
from ..db.base import get_session
from ..services.auth_service import AuthService
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""Yield an async DB session per request."""
factory = get_session()
async with factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
async def get_current_user(
authorization: Optional[str] = Header(None),
session: AsyncSession = Depends(get_db),
) -> AuthIdentity:
"""Extract and verify the current user from Authorization header."""
if not authorization:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing Authorization header",
)
scheme, _, token = authorization.partition(" ")
if scheme.lower() != "bearer" or not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid Authorization header format. Use: Bearer <token>",
)
auth_svc = AuthService(session)
identity = await auth_svc.authenticate({"token": token})
if identity is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
)
return identity
@@ -0,0 +1 @@
"""API route modules."""
@@ -0,0 +1,43 @@
"""Activity log routes — GAP-9."""
from __future__ import annotations
from typing import List
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from praisonaiagents.auth import AuthIdentity
from ..deps import get_current_user, get_db
from ..schemas import ActivityLogResponse
from ...services.activity_service import ActivityService
router = APIRouter(prefix="/workspaces/{workspace_id}", tags=["activity"])
@router.get("/activity", response_model=List[ActivityLogResponse])
async def list_workspace_activity(
workspace_id: str,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
svc = ActivityService(session)
logs = await svc.list_for_workspace(workspace_id, limit=limit, offset=offset)
return [ActivityLogResponse.model_validate(log) for log in logs]
@router.get("/issues/{issue_id}/activity", response_model=List[ActivityLogResponse])
async def list_issue_activity(
workspace_id: str,
issue_id: str,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
svc = ActivityService(session)
logs = await svc.list_for_issue(issue_id, limit=limit, offset=offset)
return [ActivityLogResponse.model_validate(log) for log in logs]
@@ -0,0 +1,100 @@
"""Agent routes."""
from __future__ import annotations
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from praisonaiagents.auth import AuthIdentity
from ..deps import get_current_user, get_db
from ..schemas import AgentCreate, AgentResponse, AgentUpdate
from ...services.agent_service import AgentService
router = APIRouter(prefix="/workspaces/{workspace_id}/agents", tags=["agents"])
@router.post("/", response_model=AgentResponse, status_code=status.HTTP_201_CREATED)
async def create_agent(
workspace_id: str,
body: AgentCreate,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
svc = AgentService(session)
agent = await svc.create(
workspace_id=workspace_id,
name=body.name,
owner_id=user.id,
runtime_mode=body.runtime_mode,
runtime_config=body.runtime_config,
instructions=body.instructions,
max_concurrent_tasks=body.max_concurrent_tasks,
)
return AgentResponse.model_validate(agent)
@router.get("/", response_model=List[AgentResponse])
async def list_agents(
workspace_id: str,
status_filter: Optional[str] = Query(None, alias="status"),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
svc = AgentService(session)
agents = await svc.list_for_workspace(workspace_id, status=status_filter, limit=limit, offset=offset)
return [AgentResponse.model_validate(a) for a in agents]
@router.get("/{agent_id}", response_model=AgentResponse)
async def get_agent(
workspace_id: str,
agent_id: str,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
svc = AgentService(session)
agent = await svc.get(agent_id)
if agent is None:
raise HTTPException(status_code=404, detail="Agent not found")
return AgentResponse.model_validate(agent)
@router.patch("/{agent_id}", response_model=AgentResponse)
async def update_agent(
workspace_id: str,
agent_id: str,
body: AgentUpdate,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
svc = AgentService(session)
agent = await svc.update(
agent_id,
name=body.name,
status=body.status,
instructions=body.instructions,
runtime_mode=body.runtime_mode,
runtime_config=body.runtime_config,
max_concurrent_tasks=body.max_concurrent_tasks,
)
if agent is None:
raise HTTPException(status_code=404, detail="Agent not found")
return AgentResponse.model_validate(agent)
@router.delete("/{agent_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_agent(
workspace_id: str,
agent_id: str,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
svc = AgentService(session)
deleted = await svc.delete(agent_id)
if not deleted:
raise HTTPException(status_code=404, detail="Agent not found")
@@ -0,0 +1,53 @@
"""Auth routes — register, login, me."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from praisonaiagents.auth import AuthIdentity
from ..deps import get_current_user, get_db
from ..schemas import LoginRequest, RegisterRequest, TokenResponse, UserResponse
from ...services.auth_service import AuthService
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
async def register(body: RegisterRequest, session: AsyncSession = Depends(get_db)):
auth_svc = AuthService(session)
try:
user, token = await auth_svc.register(body.email, body.password, body.name)
except Exception as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
return TokenResponse(
token=token,
user=UserResponse.model_validate(user),
)
@router.post("/login", response_model=TokenResponse)
async def login(body: LoginRequest, session: AsyncSession = Depends(get_db)):
auth_svc = AuthService(session)
result = await auth_svc.login(body.email, body.password)
if result is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
user, token = result
return TokenResponse(
token=token,
user=UserResponse.model_validate(user),
)
@router.get("/me", response_model=UserResponse)
async def me(current_user: AuthIdentity = Depends(get_current_user)):
return UserResponse(
id=current_user.id,
name=current_user.name or "",
email=current_user.email or "",
created_at=None, # type: ignore[arg-type]
)
@@ -0,0 +1,58 @@
"""Issue dependency routes."""
from __future__ import annotations
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from praisonaiagents.auth import AuthIdentity
from ..deps import get_current_user, get_db
from ..schemas import DependencyCreate, DependencyResponse
from ...services.dependency_service import DependencyService
router = APIRouter(
prefix="/workspaces/{workspace_id}/issues/{issue_id}/dependencies",
tags=["dependencies"],
)
@router.post("/", response_model=DependencyResponse, status_code=status.HTTP_201_CREATED)
async def create_dependency(
workspace_id: str,
issue_id: str,
body: DependencyCreate,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
svc = DependencyService(session)
dep = await svc.create(issue_id, body.depends_on_issue_id, body.type)
return DependencyResponse.model_validate(dep)
@router.get("/", response_model=List[DependencyResponse])
async def list_dependencies(
workspace_id: str,
issue_id: str,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
svc = DependencyService(session)
deps = await svc.list_for_issue(issue_id)
return [DependencyResponse.model_validate(d) for d in deps]
@router.delete("/{dep_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_dependency(
workspace_id: str,
issue_id: str,
dep_id: str,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
svc = DependencyService(session)
deleted = await svc.delete(dep_id)
if not deleted:
raise HTTPException(status_code=404, detail="Dependency not found")
@@ -0,0 +1,171 @@
"""Issue and Comment routes."""
from __future__ import annotations
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from praisonaiagents.auth import AuthIdentity
from ..deps import get_current_user, get_db
from ..schemas import (
CommentCreate,
CommentResponse,
IssueCreate,
IssueResponse,
IssueUpdate,
)
from ...services.activity_service import ActivityService
from ...services.comment_service import CommentService
from ...services.issue_service import IssueService
router = APIRouter(prefix="/workspaces/{workspace_id}/issues", tags=["issues"])
@router.post("/", response_model=IssueResponse, status_code=status.HTTP_201_CREATED)
async def create_issue(
workspace_id: str,
body: IssueCreate,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
svc = IssueService(session)
issue = await svc.create(
workspace_id=workspace_id,
title=body.title,
creator_id=user.id,
creator_type="user" if user.is_user else "agent",
project_id=body.project_id,
description=body.description,
status=body.status,
priority=body.priority,
assignee_type=body.assignee_type,
assignee_id=body.assignee_id,
parent_issue_id=body.parent_issue_id,
acceptance_criteria=body.acceptance_criteria,
)
act_svc = ActivityService(session)
await act_svc.log(
workspace_id, "issue.created",
actor_type="user" if user.is_user else "agent",
actor_id=user.id, issue_id=issue.id,
details={"title": issue.title, "identifier": issue.identifier},
)
return IssueResponse.model_validate(issue)
@router.get("/", response_model=List[IssueResponse])
async def list_issues(
workspace_id: str,
status_filter: Optional[str] = Query(None, alias="status"),
project_id: Optional[str] = None,
assignee_id: Optional[str] = None,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
svc = IssueService(session)
issues = await svc.list_for_workspace(
workspace_id,
status=status_filter,
project_id=project_id,
assignee_id=assignee_id,
limit=limit,
offset=offset,
)
return [IssueResponse.model_validate(i) for i in issues]
@router.get("/{issue_id}", response_model=IssueResponse)
async def get_issue(
workspace_id: str,
issue_id: str,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
svc = IssueService(session)
issue = await svc.get(issue_id)
if issue is None:
raise HTTPException(status_code=404, detail="Issue not found")
return IssueResponse.model_validate(issue)
@router.patch("/{issue_id}", response_model=IssueResponse)
async def update_issue(
workspace_id: str,
issue_id: str,
body: IssueUpdate,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
svc = IssueService(session)
issue = await svc.update(
issue_id,
title=body.title,
description=body.description,
status=body.status,
priority=body.priority,
assignee_type=body.assignee_type,
assignee_id=body.assignee_id,
project_id=body.project_id,
)
if issue is None:
raise HTTPException(status_code=404, detail="Issue not found")
act_svc = ActivityService(session)
await act_svc.log(
workspace_id, "issue.updated",
actor_type="user" if user.is_user else "agent",
actor_id=user.id, issue_id=issue.id,
details={"fields": body.model_dump(exclude_none=True)},
)
return IssueResponse.model_validate(issue)
@router.delete("/{issue_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_issue(
workspace_id: str,
issue_id: str,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
svc = IssueService(session)
deleted = await svc.delete(issue_id)
if not deleted:
raise HTTPException(status_code=404, detail="Issue not found")
# ── Comments ─────────────────────────────────────────────────────────────────
@router.post("/{issue_id}/comments", response_model=CommentResponse, status_code=status.HTTP_201_CREATED)
async def add_comment(
workspace_id: str,
issue_id: str,
body: CommentCreate,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
svc = CommentService(session)
comment = await svc.create(
issue_id=issue_id,
author_id=user.id,
content=body.content,
author_type="member" if user.is_user else "agent",
parent_id=body.parent_id,
)
return CommentResponse.model_validate(comment)
@router.get("/{issue_id}/comments", response_model=List[CommentResponse])
async def list_comments(
workspace_id: str,
issue_id: str,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
svc = CommentService(session)
comments = await svc.list_for_issue(issue_id)
return [CommentResponse.model_validate(c) for c in comments]
@@ -0,0 +1,106 @@
"""Label routes — workspace labels and issue-label linking."""
from __future__ import annotations
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from praisonaiagents.auth import AuthIdentity
from ..deps import get_current_user, get_db
from ..schemas import LabelCreate, LabelResponse, LabelUpdate
from ...services.label_service import LabelService
router = APIRouter(prefix="/workspaces/{workspace_id}", tags=["labels"])
@router.post("/labels", response_model=LabelResponse, status_code=status.HTTP_201_CREATED)
async def create_label(
workspace_id: str,
body: LabelCreate,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
svc = LabelService(session)
label = await svc.create(workspace_id, body.name, body.color)
return LabelResponse.model_validate(label)
@router.get("/labels", response_model=List[LabelResponse])
async def list_labels(
workspace_id: str,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
svc = LabelService(session)
labels = await svc.list_for_workspace(workspace_id)
return [LabelResponse.model_validate(l) for l in labels]
@router.patch("/labels/{label_id}", response_model=LabelResponse)
async def update_label(
workspace_id: str,
label_id: str,
body: LabelUpdate,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
svc = LabelService(session)
label = await svc.update(label_id, body.name, body.color)
if label is None:
raise HTTPException(status_code=404, detail="Label not found")
return LabelResponse.model_validate(label)
@router.delete("/labels/{label_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_label(
workspace_id: str,
label_id: str,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
svc = LabelService(session)
deleted = await svc.delete(label_id)
if not deleted:
raise HTTPException(status_code=404, detail="Label not found")
# ── Issue-Label linking ────────────────────────────────────────────────
@router.post("/issues/{issue_id}/labels/{label_id}", status_code=status.HTTP_204_NO_CONTENT)
async def add_label_to_issue(
workspace_id: str,
issue_id: str,
label_id: str,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
svc = LabelService(session)
await svc.add_to_issue(issue_id, label_id)
@router.delete("/issues/{issue_id}/labels/{label_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_label_from_issue(
workspace_id: str,
issue_id: str,
label_id: str,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
svc = LabelService(session)
await svc.remove_from_issue(issue_id, label_id)
@router.get("/issues/{issue_id}/labels", response_model=List[LabelResponse])
async def list_issue_labels(
workspace_id: str,
issue_id: str,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
svc = LabelService(session)
labels = await svc.list_for_issue(issue_id)
return [LabelResponse.model_validate(l) for l in labels]
@@ -0,0 +1,108 @@
"""Project routes."""
from __future__ import annotations
from typing import List
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from praisonaiagents.auth import AuthIdentity
from ..deps import get_current_user, get_db
from ..schemas import ProjectCreate, ProjectResponse, ProjectUpdate
from ...services.project_service import ProjectService
router = APIRouter(prefix="/workspaces/{workspace_id}/projects", tags=["projects"])
@router.post("/", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
async def create_project(
workspace_id: str,
body: ProjectCreate,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
svc = ProjectService(session)
project = await svc.create(
workspace_id=workspace_id,
title=body.title,
description=body.description,
icon=body.icon,
lead_type=body.lead_type,
lead_id=body.lead_id,
)
return ProjectResponse.model_validate(project)
@router.get("/", response_model=List[ProjectResponse])
async def list_projects(
workspace_id: str,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
svc = ProjectService(session)
projects = await svc.list_for_workspace(workspace_id, limit=limit, offset=offset)
return [ProjectResponse.model_validate(p) for p in projects]
@router.get("/{project_id}", response_model=ProjectResponse)
async def get_project(
workspace_id: str,
project_id: str,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
svc = ProjectService(session)
project = await svc.get(project_id)
if project is None:
raise HTTPException(status_code=404, detail="Project not found")
return ProjectResponse.model_validate(project)
@router.patch("/{project_id}", response_model=ProjectResponse)
async def update_project(
workspace_id: str,
project_id: str,
body: ProjectUpdate,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
svc = ProjectService(session)
project = await svc.update(
project_id,
title=body.title,
description=body.description,
status=body.status,
lead_type=body.lead_type,
lead_id=body.lead_id,
)
if project is None:
raise HTTPException(status_code=404, detail="Project not found")
return ProjectResponse.model_validate(project)
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_project(
workspace_id: str,
project_id: str,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
svc = ProjectService(session)
deleted = await svc.delete(project_id)
if not deleted:
raise HTTPException(status_code=404, detail="Project not found")
@router.get("/{project_id}/stats")
async def project_stats(
workspace_id: str,
project_id: str,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
svc = ProjectService(session)
return await svc.get_stats(project_id)
@@ -0,0 +1,140 @@
"""Workspace routes."""
from __future__ import annotations
from typing import List
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from praisonaiagents.auth import AuthIdentity
from ..deps import get_current_user, get_db
from ..schemas import (
MemberAdd,
MemberResponse,
MemberUpdate,
WorkspaceCreate,
WorkspaceResponse,
WorkspaceUpdate,
)
from ...services.member_service import MemberService
from ...services.workspace_service import WorkspaceService
router = APIRouter(prefix="/workspaces", tags=["workspaces"])
@router.post("/", response_model=WorkspaceResponse, status_code=status.HTTP_201_CREATED)
async def create_workspace(
body: WorkspaceCreate,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
ws_svc = WorkspaceService(session)
ws = await ws_svc.create(body.name, user.id, body.slug, body.description)
return WorkspaceResponse.model_validate(ws)
@router.get("/", response_model=List[WorkspaceResponse])
async def list_workspaces(
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
ws_svc = WorkspaceService(session)
workspaces = await ws_svc.list_for_user(user.id, limit=limit, offset=offset)
return [WorkspaceResponse.model_validate(ws) for ws in workspaces]
@router.get("/{workspace_id}", response_model=WorkspaceResponse)
async def get_workspace(
workspace_id: str,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
ws_svc = WorkspaceService(session)
ws = await ws_svc.get(workspace_id)
if ws is None:
raise HTTPException(status_code=404, detail="Workspace not found")
return WorkspaceResponse.model_validate(ws)
@router.patch("/{workspace_id}", response_model=WorkspaceResponse)
async def update_workspace(
workspace_id: str,
body: WorkspaceUpdate,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
ws_svc = WorkspaceService(session)
ws = await ws_svc.update(workspace_id, body.name, body.description, body.settings)
if ws is None:
raise HTTPException(status_code=404, detail="Workspace not found")
return WorkspaceResponse.model_validate(ws)
@router.delete("/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_workspace(
workspace_id: str,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
ws_svc = WorkspaceService(session)
deleted = await ws_svc.delete(workspace_id)
if not deleted:
raise HTTPException(status_code=404, detail="Workspace not found")
# ── Members ──────────────────────────────────────────────────────────────────
@router.post("/{workspace_id}/members", response_model=MemberResponse, status_code=status.HTTP_201_CREATED)
async def add_member(
workspace_id: str,
body: MemberAdd,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
member_svc = MemberService(session)
member = await member_svc.add(workspace_id, body.user_id, body.role)
return MemberResponse.model_validate(member)
@router.get("/{workspace_id}/members", response_model=List[MemberResponse])
async def list_members(
workspace_id: str,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
member_svc = MemberService(session)
members = await member_svc.list_members(workspace_id)
return [MemberResponse.model_validate(m) for m in members]
@router.patch("/{workspace_id}/members/{user_id}", response_model=MemberResponse)
async def update_member_role(
workspace_id: str,
user_id: str,
body: MemberUpdate,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
member_svc = MemberService(session)
member = await member_svc.update_role(workspace_id, user_id, body.role)
if member is None:
raise HTTPException(status_code=404, detail="Member not found")
return MemberResponse.model_validate(member)
@router.delete("/{workspace_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_member(
workspace_id: str,
user_id: str,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
member_svc = MemberService(session)
removed = await member_svc.remove(workspace_id, user_id)
if not removed:
raise HTTPException(status_code=404, detail="Member not found")
@@ -0,0 +1,288 @@
"""Pydantic schemas for API request/response."""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, EmailStr
# ── Auth ─────────────────────────────────────────────────────────────────────
class RegisterRequest(BaseModel):
email: EmailStr
password: str
name: Optional[str] = None
class LoginRequest(BaseModel):
email: EmailStr
password: str
class TokenResponse(BaseModel):
token: str
user: UserResponse
class UserResponse(BaseModel):
id: str
name: str
email: str
avatar_url: Optional[str] = None
created_at: datetime
class Config:
from_attributes = True
# Fix forward reference
TokenResponse.model_rebuild()
# ── Workspace ────────────────────────────────────────────────────────────────
class WorkspaceCreate(BaseModel):
name: str
slug: Optional[str] = None
description: Optional[str] = None
class WorkspaceUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
settings: Optional[Dict[str, Any]] = None
class WorkspaceResponse(BaseModel):
id: str
name: str
slug: str
description: Optional[str] = None
settings: Dict[str, Any] = {}
created_at: datetime
class Config:
from_attributes = True
# ── Member ───────────────────────────────────────────────────────────────────
class MemberAdd(BaseModel):
user_id: str
role: str = "member"
class MemberUpdate(BaseModel):
role: str
class MemberResponse(BaseModel):
id: str
workspace_id: str
user_id: str
role: str
created_at: datetime
class Config:
from_attributes = True
# ── Project ──────────────────────────────────────────────────────────────────
class ProjectCreate(BaseModel):
title: str
description: Optional[str] = None
icon: Optional[str] = None
lead_type: Optional[str] = None
lead_id: Optional[str] = None
class ProjectUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
status: Optional[str] = None
lead_type: Optional[str] = None
lead_id: Optional[str] = None
class ProjectResponse(BaseModel):
id: str
workspace_id: str
title: str
description: Optional[str] = None
icon: Optional[str] = None
status: str
lead_type: Optional[str] = None
lead_id: Optional[str] = None
created_at: datetime
class Config:
from_attributes = True
# ── Issue ────────────────────────────────────────────────────────────────────
class IssueCreate(BaseModel):
title: str
description: Optional[str] = None
project_id: Optional[str] = None
status: str = "backlog"
priority: str = "none"
assignee_type: Optional[str] = None
assignee_id: Optional[str] = None
parent_issue_id: Optional[str] = None
acceptance_criteria: Optional[List[str]] = None
class IssueUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
status: Optional[str] = None
priority: Optional[str] = None
assignee_type: Optional[str] = None
assignee_id: Optional[str] = None
project_id: Optional[str] = None
class IssueResponse(BaseModel):
id: str
workspace_id: str
project_id: Optional[str] = None
title: str
description: Optional[str] = None
status: str
priority: str
assignee_type: Optional[str] = None
assignee_id: Optional[str] = None
creator_type: str
creator_id: str
number: Optional[int] = None
identifier: Optional[str] = None
parent_issue_id: Optional[str] = None
created_at: datetime
class Config:
from_attributes = True
# ── Comment ──────────────────────────────────────────────────────────────────
class CommentCreate(BaseModel):
content: str
parent_id: Optional[str] = None
class CommentResponse(BaseModel):
id: str
issue_id: str
author_type: str
author_id: str
parent_id: Optional[str] = None
content: str
type: str
created_at: datetime
class Config:
from_attributes = True
# ── Agent ────────────────────────────────────────────────────────────────
class AgentCreate(BaseModel):
name: str
runtime_mode: str = "local"
runtime_config: Optional[Dict[str, Any]] = None
instructions: Optional[str] = None
max_concurrent_tasks: int = 1
class AgentUpdate(BaseModel):
name: Optional[str] = None
status: Optional[str] = None
instructions: Optional[str] = None
runtime_mode: Optional[str] = None
runtime_config: Optional[Dict[str, Any]] = None
max_concurrent_tasks: Optional[int] = None
class AgentResponse(BaseModel):
id: str
workspace_id: str
name: str
avatar_url: Optional[str] = None
runtime_mode: str
instructions: Optional[str] = None
status: str
max_concurrent_tasks: int
owner_id: Optional[str] = None
created_at: datetime
class Config:
from_attributes = True
# ── Label ────────────────────────────────────────────────────────────────
class LabelCreate(BaseModel):
name: str
color: str = "#6B7280"
class LabelUpdate(BaseModel):
name: Optional[str] = None
color: Optional[str] = None
class LabelResponse(BaseModel):
id: str
workspace_id: str
name: str
color: str
class Config:
from_attributes = True
# ── IssueDependency ──────────────────────────────────────────────────────
class DependencyCreate(BaseModel):
depends_on_issue_id: str
type: str = "blocks"
class DependencyResponse(BaseModel):
id: str
issue_id: str
depends_on_issue_id: str
type: str
class Config:
from_attributes = True
# ── ActivityLog ──────────────────────────────────────────────────────────
class ActivityLogResponse(BaseModel):
id: str
workspace_id: str
issue_id: Optional[str] = None
actor_type: Optional[str] = None
actor_id: Optional[str] = None
action: str
details: Dict[str, Any] = {}
created_at: datetime
class Config:
from_attributes = True
@@ -0,0 +1,5 @@
"""Platform client SDK for agent ↔ platform integration."""
from .platform_client import PlatformClient
__all__ = ["PlatformClient"]
@@ -0,0 +1,284 @@
"""
PlatformClient — httpx-based SDK for agents to interact with the platform API.
Usage::
client = PlatformClient("http://localhost:8000", token="jwt-token")
workspaces = await client.list_workspaces()
issue = await client.create_issue(
workspace_id="ws-1",
title="Fix bug",
description="The thing is broken",
)
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
import httpx
class PlatformClient:
"""Async HTTP client for the PraisonAI Platform API."""
def __init__(
self,
base_url: str = "http://localhost:8000",
token: Optional[str] = None,
):
self._base_url = base_url.rstrip("/")
self._token = token
def _headers(self) -> Dict[str, str]:
headers: Dict[str, str] = {"Content-Type": "application/json"}
if self._token:
headers["Authorization"] = f"Bearer {self._token}"
return headers
def _url(self, path: str) -> str:
return f"{self._base_url}/api/v1{path}"
# ── Auth ─────────────────────────────────────────────────────────────
async def register(self, email: str, password: str, name: Optional[str] = None) -> Dict[str, Any]:
async with httpx.AsyncClient() as c:
resp = await c.post(
self._url("/auth/register"),
json={"email": email, "password": password, "name": name},
headers=self._headers(),
)
resp.raise_for_status()
data = resp.json()
self._token = data["token"]
return data
async def login(self, email: str, password: str) -> Dict[str, Any]:
async with httpx.AsyncClient() as c:
resp = await c.post(
self._url("/auth/login"),
json={"email": email, "password": password},
headers=self._headers(),
)
resp.raise_for_status()
data = resp.json()
self._token = data["token"]
return data
# ── Workspaces ───────────────────────────────────────────────────────
async def create_workspace(self, name: str, slug: Optional[str] = None) -> Dict[str, Any]:
async with httpx.AsyncClient() as c:
resp = await c.post(
self._url("/workspaces/"),
json={"name": name, "slug": slug},
headers=self._headers(),
)
resp.raise_for_status()
return resp.json()
async def list_workspaces(self) -> List[Dict[str, Any]]:
async with httpx.AsyncClient() as c:
resp = await c.get(self._url("/workspaces/"), headers=self._headers())
resp.raise_for_status()
return resp.json()
async def get_workspace(self, workspace_id: str) -> Dict[str, Any]:
async with httpx.AsyncClient() as c:
resp = await c.get(
self._url(f"/workspaces/{workspace_id}"),
headers=self._headers(),
)
resp.raise_for_status()
return resp.json()
# ── Members ──────────────────────────────────────────────────────────
async def add_member(self, workspace_id: str, user_id: str, role: str = "member") -> Dict[str, Any]:
async with httpx.AsyncClient() as c:
resp = await c.post(
self._url(f"/workspaces/{workspace_id}/members"),
json={"user_id": user_id, "role": role},
headers=self._headers(),
)
resp.raise_for_status()
return resp.json()
async def list_members(self, workspace_id: str) -> List[Dict[str, Any]]:
async with httpx.AsyncClient() as c:
resp = await c.get(
self._url(f"/workspaces/{workspace_id}/members"),
headers=self._headers(),
)
resp.raise_for_status()
return resp.json()
# ── Projects ─────────────────────────────────────────────────────────
async def create_project(
self, workspace_id: str, title: str, description: Optional[str] = None
) -> Dict[str, Any]:
async with httpx.AsyncClient() as c:
resp = await c.post(
self._url(f"/workspaces/{workspace_id}/projects/"),
json={"title": title, "description": description},
headers=self._headers(),
)
resp.raise_for_status()
return resp.json()
async def list_projects(self, workspace_id: str) -> List[Dict[str, Any]]:
async with httpx.AsyncClient() as c:
resp = await c.get(
self._url(f"/workspaces/{workspace_id}/projects/"),
headers=self._headers(),
)
resp.raise_for_status()
return resp.json()
# ── Issues ───────────────────────────────────────────────────────────
async def create_issue(
self,
workspace_id: str,
title: str,
description: Optional[str] = None,
project_id: Optional[str] = None,
priority: str = "none",
assignee_type: Optional[str] = None,
assignee_id: Optional[str] = None,
) -> Dict[str, Any]:
body: Dict[str, Any] = {"title": title, "priority": priority}
if description:
body["description"] = description
if project_id:
body["project_id"] = project_id
if assignee_type:
body["assignee_type"] = assignee_type
if assignee_id:
body["assignee_id"] = assignee_id
async with httpx.AsyncClient() as c:
resp = await c.post(
self._url(f"/workspaces/{workspace_id}/issues/"),
json=body,
headers=self._headers(),
)
resp.raise_for_status()
return resp.json()
async def list_issues(
self,
workspace_id: str,
status: Optional[str] = None,
project_id: Optional[str] = None,
) -> List[Dict[str, Any]]:
params: Dict[str, str] = {}
if status:
params["status"] = status
if project_id:
params["project_id"] = project_id
async with httpx.AsyncClient() as c:
resp = await c.get(
self._url(f"/workspaces/{workspace_id}/issues/"),
params=params,
headers=self._headers(),
)
resp.raise_for_status()
return resp.json()
async def get_issue(self, workspace_id: str, issue_id: str) -> Dict[str, Any]:
async with httpx.AsyncClient() as c:
resp = await c.get(
self._url(f"/workspaces/{workspace_id}/issues/{issue_id}"),
headers=self._headers(),
)
resp.raise_for_status()
return resp.json()
async def update_issue(
self, workspace_id: str, issue_id: str, **fields: Any
) -> Dict[str, Any]:
async with httpx.AsyncClient() as c:
resp = await c.patch(
self._url(f"/workspaces/{workspace_id}/issues/{issue_id}"),
json=fields,
headers=self._headers(),
)
resp.raise_for_status()
return resp.json()
# ── Comments ─────────────────────────────────────────────────────────
async def add_comment(
self, workspace_id: str, issue_id: str, content: str
) -> Dict[str, Any]:
async with httpx.AsyncClient() as c:
resp = await c.post(
self._url(f"/workspaces/{workspace_id}/issues/{issue_id}/comments"),
json={"content": content},
headers=self._headers(),
)
resp.raise_for_status()
return resp.json()
async def list_comments(
self, workspace_id: str, issue_id: str
) -> List[Dict[str, Any]]:
async with httpx.AsyncClient() as c:
resp = await c.get(
self._url(f"/workspaces/{workspace_id}/issues/{issue_id}/comments"),
headers=self._headers(),
)
resp.raise_for_status()
return resp.json()
# ── Agents ────────────────────────────────────────────────────────────
async def create_agent(
self,
workspace_id: str,
name: str,
runtime_mode: str = "local",
instructions: Optional[str] = None,
) -> Dict[str, Any]:
body: Dict[str, Any] = {"name": name, "runtime_mode": runtime_mode}
if instructions:
body["instructions"] = instructions
async with httpx.AsyncClient() as c:
resp = await c.post(
self._url(f"/workspaces/{workspace_id}/agents/"),
json=body,
headers=self._headers(),
)
resp.raise_for_status()
return resp.json()
async def list_agents(self, workspace_id: str) -> List[Dict[str, Any]]:
async with httpx.AsyncClient() as c:
resp = await c.get(
self._url(f"/workspaces/{workspace_id}/agents/"),
headers=self._headers(),
)
resp.raise_for_status()
return resp.json()
async def get_agent(self, workspace_id: str, agent_id: str) -> Dict[str, Any]:
async with httpx.AsyncClient() as c:
resp = await c.get(
self._url(f"/workspaces/{workspace_id}/agents/{agent_id}"),
headers=self._headers(),
)
resp.raise_for_status()
return resp.json()
async def update_agent(
self, workspace_id: str, agent_id: str, **fields: Any
) -> Dict[str, Any]:
async with httpx.AsyncClient() as c:
resp = await c.patch(
self._url(f"/workspaces/{workspace_id}/agents/{agent_id}"),
json=fields,
headers=self._headers(),
)
resp.raise_for_status()
return resp.json()
@@ -0,0 +1,25 @@
"""Platform services — auth, workspace, member, project, issue, agent, label, dependency, activity."""
from .auth_service import AuthService
from .workspace_service import WorkspaceService
from .member_service import MemberService
from .project_service import ProjectService
from .issue_service import IssueService
from .comment_service import CommentService
from .activity_service import ActivityService
from .agent_service import AgentService
from .label_service import LabelService
from .dependency_service import DependencyService
__all__ = [
"AuthService",
"WorkspaceService",
"MemberService",
"ProjectService",
"IssueService",
"CommentService",
"ActivityService",
"AgentService",
"LabelService",
"DependencyService",
]
@@ -0,0 +1,73 @@
"""Activity log service — audit trail for workspace actions."""
from __future__ import annotations
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..db.models import ActivityLog
class ActivityService:
"""Activity log operations."""
def __init__(self, session: AsyncSession):
self._session = session
async def log(
self,
workspace_id: str,
action: str,
actor_type: Optional[str] = None,
actor_id: Optional[str] = None,
issue_id: Optional[str] = None,
details: Optional[dict] = None,
) -> ActivityLog:
"""Record an activity."""
entry = ActivityLog(
workspace_id=workspace_id,
action=action,
actor_type=actor_type,
actor_id=actor_id,
issue_id=issue_id,
details=details or {},
)
self._session.add(entry)
await self._session.flush()
return entry
async def list_for_workspace(
self,
workspace_id: str,
limit: int = 50,
offset: int = 0,
) -> list[ActivityLog]:
"""List recent activity for a workspace."""
stmt = (
select(ActivityLog)
.where(ActivityLog.workspace_id == workspace_id)
.order_by(ActivityLog.created_at.desc())
.limit(limit)
.offset(offset)
)
result = await self._session.execute(stmt)
return list(result.scalars().all())
async def list_for_issue(
self,
issue_id: str,
limit: int = 50,
offset: int = 0,
) -> list[ActivityLog]:
"""List activity for a specific issue."""
stmt = (
select(ActivityLog)
.where(ActivityLog.issue_id == issue_id)
.order_by(ActivityLog.created_at.desc())
.limit(limit)
.offset(offset)
)
result = await self._session.execute(stmt)
return list(result.scalars().all())
@@ -0,0 +1,112 @@
"""
Agent service — CRUD for workspace agents.
Influenced by Multica's agent table (001_init + 021_instructions)
and Paperclip's agents schema (adapter, status, runtime config).
"""
from __future__ import annotations
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..db.models import Agent
VALID_STATUSES = {"idle", "working", "blocked", "error", "offline"}
VALID_RUNTIME_MODES = {"local", "cloud"}
class AgentService:
"""Agent CRUD operations."""
def __init__(self, session: AsyncSession):
self._session = session
async def create(
self,
workspace_id: str,
name: str,
owner_id: Optional[str] = None,
runtime_mode: str = "local",
runtime_config: Optional[dict] = None,
instructions: Optional[str] = None,
status: str = "offline",
max_concurrent_tasks: int = 1,
) -> Agent:
"""Create an agent in a workspace."""
agent = Agent(
workspace_id=workspace_id,
name=name,
owner_id=owner_id,
runtime_mode=runtime_mode,
runtime_config=runtime_config or {},
instructions=instructions,
status=status,
max_concurrent_tasks=max_concurrent_tasks,
)
self._session.add(agent)
await self._session.flush()
return agent
async def get(self, agent_id: str) -> Optional[Agent]:
"""Get agent by ID."""
return await self._session.get(Agent, agent_id)
async def list_for_workspace(
self,
workspace_id: str,
status: Optional[str] = None,
limit: int = 50,
offset: int = 0,
) -> list[Agent]:
"""List agents in a workspace with optional filters."""
stmt = select(Agent).where(Agent.workspace_id == workspace_id)
if status:
stmt = stmt.where(Agent.status == status)
stmt = stmt.order_by(Agent.created_at).limit(limit).offset(offset)
result = await self._session.execute(stmt)
return list(result.scalars().all())
async def update(
self,
agent_id: str,
name: Optional[str] = None,
status: Optional[str] = None,
instructions: Optional[str] = None,
runtime_mode: Optional[str] = None,
runtime_config: Optional[dict] = None,
max_concurrent_tasks: Optional[int] = None,
) -> Optional[Agent]:
"""Update agent fields."""
agent = await self.get(agent_id)
if agent is None:
return None
if name is not None:
agent.name = name
if status is not None:
if status not in VALID_STATUSES:
raise ValueError(f"Invalid status: {status}")
agent.status = status
if instructions is not None:
agent.instructions = instructions
if runtime_mode is not None:
if runtime_mode not in VALID_RUNTIME_MODES:
raise ValueError(f"Invalid runtime_mode: {runtime_mode}")
agent.runtime_mode = runtime_mode
if runtime_config is not None:
agent.runtime_config = runtime_config
if max_concurrent_tasks is not None:
agent.max_concurrent_tasks = max_concurrent_tasks
await self._session.flush()
return agent
async def delete(self, agent_id: str) -> bool:
"""Delete an agent."""
agent = await self.get(agent_id)
if agent is None:
return False
await self._session.delete(agent)
await self._session.flush()
return True
@@ -0,0 +1,152 @@
"""
Auth service — JWT-based authentication.
Implements AuthBackendProtocol from praisonaiagents.auth.
Influenced by Multica's issueJWT + findOrCreateUser pattern.
"""
from __future__ import annotations
import os
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional
import jwt
from passlib.context import CryptContext
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from praisonaiagents.auth import AuthIdentity
from ..db.models import Member, User
_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
_DEFAULT_SECRET = "dev-secret-change-me"
JWT_SECRET = os.environ.get("PLATFORM_JWT_SECRET", _DEFAULT_SECRET)
JWT_ALGORITHM = "HS256"
JWT_TTL_SECONDS = int(os.environ.get("PLATFORM_JWT_TTL", str(30 * 24 * 3600)))
if JWT_SECRET == _DEFAULT_SECRET and os.environ.get("PLATFORM_ENV", "dev") != "dev":
raise RuntimeError(
"PLATFORM_JWT_SECRET must be set to a strong random value in production. "
"Set PLATFORM_ENV=dev to suppress this check during development."
)
class AuthService:
"""JWT authentication service implementing AuthBackendProtocol."""
def __init__(self, session: AsyncSession):
self._session = session
# ── AuthBackendProtocol methods ──────────────────────────────────────
async def authenticate(
self, credentials: Dict[str, Any]
) -> Optional[AuthIdentity]:
"""Authenticate via JWT token or email+password."""
token = credentials.get("token")
if token:
return self._verify_token(token)
email = credentials.get("email")
password = credentials.get("password")
if email and password:
return await self._authenticate_password(email, password)
return None
async def authorize(
self, identity: AuthIdentity, resource: str, action: str
) -> bool:
"""Check workspace-scoped RBAC authorization."""
if not identity.workspace_id:
return action == "read"
stmt = select(Member).where(
Member.workspace_id == identity.workspace_id,
Member.user_id == identity.id,
)
result = await self._session.execute(stmt)
member = result.scalar_one_or_none()
if member is None:
return False
if member.role == "owner":
return True
if member.role == "admin":
return action != "admin"
if member.role == "member":
return action in ("read", "write")
return False
# ── Registration & Login ─────────────────────────────────────────────
async def register(
self, email: str, password: str, name: Optional[str] = None
) -> tuple[User, str]:
"""Register a new user and return (user, jwt_token)."""
user = User(
email=email,
name=name or email.split("@")[0],
password_hash=_pwd_context.hash(password),
)
self._session.add(user)
await self._session.flush()
token = self._issue_token(user)
return user, token
async def login(self, email: str, password: str) -> Optional[tuple[User, str]]:
"""Login with email+password, return (user, jwt_token) or None."""
stmt = select(User).where(User.email == email)
result = await self._session.execute(stmt)
user = result.scalar_one_or_none()
if user is None or not user.password_hash:
return None
if not _pwd_context.verify(password, user.password_hash):
return None
token = self._issue_token(user)
return user, token
# ── Token management ─────────────────────────────────────────────────
def _issue_token(self, user: User) -> str:
"""Issue a JWT for a user."""
now = datetime.now(timezone.utc)
payload = {
"sub": user.id,
"email": user.email,
"name": user.name,
"iat": now,
"exp": now + timedelta(seconds=JWT_TTL_SECONDS),
}
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
def _verify_token(self, token: str) -> Optional[AuthIdentity]:
"""Decode and verify a JWT, returning AuthIdentity or None."""
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
return AuthIdentity(
id=payload["sub"],
type="user",
email=payload.get("email"),
name=payload.get("name"),
)
except jwt.InvalidTokenError:
return None
async def _authenticate_password(
self, email: str, password: str
) -> Optional[AuthIdentity]:
"""Authenticate via email+password, return AuthIdentity."""
result = await self.login(email, password)
if result is None:
return None
user, _ = result
return AuthIdentity(
id=user.id,
type="user",
email=user.email,
name=user.name,
)
@@ -0,0 +1,71 @@
"""Comment service — CRUD for issue comments."""
from __future__ import annotations
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..db.models import Comment
class CommentService:
"""Comment CRUD operations."""
def __init__(self, session: AsyncSession):
self._session = session
async def create(
self,
issue_id: str,
author_id: str,
content: str,
author_type: str = "member",
comment_type: str = "comment",
parent_id: Optional[str] = None,
) -> Comment:
"""Add a comment to an issue."""
comment = Comment(
issue_id=issue_id,
author_type=author_type,
author_id=author_id,
parent_id=parent_id,
content=content,
type=comment_type,
)
self._session.add(comment)
await self._session.flush()
return comment
async def get(self, comment_id: str) -> Optional[Comment]:
"""Get comment by ID."""
return await self._session.get(Comment, comment_id)
async def list_for_issue(self, issue_id: str) -> list[Comment]:
"""List all comments on an issue."""
stmt = (
select(Comment)
.where(Comment.issue_id == issue_id)
.order_by(Comment.created_at)
)
result = await self._session.execute(stmt)
return list(result.scalars().all())
async def update(self, comment_id: str, content: str) -> Optional[Comment]:
"""Update comment content."""
comment = await self.get(comment_id)
if comment is None:
return None
comment.content = content
await self._session.flush()
return comment
async def delete(self, comment_id: str) -> bool:
"""Delete a comment."""
comment = await self.get(comment_id)
if comment is None:
return False
await self._session.delete(comment)
await self._session.flush()
return True
@@ -0,0 +1,64 @@
"""
Issue dependency service — manage issue relationships.
Influenced by Multica's issue_dependency (blocks/blocked_by/related)
and Paperclip's issue_relations.
"""
from __future__ import annotations
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..db.models import IssueDependency
VALID_TYPES = {"blocks", "blocked_by", "related"}
class DependencyService:
"""Issue dependency CRUD operations."""
def __init__(self, session: AsyncSession):
self._session = session
async def create(
self,
issue_id: str,
depends_on_issue_id: str,
dep_type: str = "blocks",
) -> IssueDependency:
"""Create a dependency between two issues."""
if dep_type not in VALID_TYPES:
raise ValueError(f"Invalid type: {dep_type}. Must be one of {VALID_TYPES}")
dep = IssueDependency(
issue_id=issue_id,
depends_on_issue_id=depends_on_issue_id,
type=dep_type,
)
self._session.add(dep)
await self._session.flush()
return dep
async def get(self, dep_id: str) -> Optional[IssueDependency]:
"""Get dependency by ID."""
return await self._session.get(IssueDependency, dep_id)
async def list_for_issue(self, issue_id: str) -> list[IssueDependency]:
"""List all dependencies for an issue (both directions)."""
stmt = select(IssueDependency).where(
(IssueDependency.issue_id == issue_id)
| (IssueDependency.depends_on_issue_id == issue_id)
)
result = await self._session.execute(stmt)
return list(result.scalars().all())
async def delete(self, dep_id: str) -> bool:
"""Delete a dependency."""
dep = await self.get(dep_id)
if dep is None:
return False
await self._session.delete(dep)
await self._session.flush()
return True
@@ -0,0 +1,163 @@
"""
Issue service — CRUD, status workflow, assignment, sub-issues.
Influenced by Multica's issue handler with status state machine,
agent assignment (assignee_type: member|agent), and sub-issues.
"""
from __future__ import annotations
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..db.models import Issue, Workspace
VALID_STATUSES = {"backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"}
VALID_PRIORITIES = {"urgent", "high", "medium", "low", "none"}
VALID_ASSIGNEE_TYPES = {"member", "agent"}
class IssueService:
"""Issue management with status workflow."""
def __init__(self, session: AsyncSession):
self._session = session
async def create(
self,
workspace_id: str,
title: str,
creator_id: str,
creator_type: str = "member",
project_id: Optional[str] = None,
description: Optional[str] = None,
status: str = "backlog",
priority: str = "none",
assignee_type: Optional[str] = None,
assignee_id: Optional[str] = None,
parent_issue_id: Optional[str] = None,
acceptance_criteria: Optional[list] = None,
) -> Issue:
"""Create an issue with auto-assigned number and identifier."""
ws = await self._session.get(Workspace, workspace_id)
number = None
identifier = None
if ws is not None:
ws.issue_counter = (ws.issue_counter or 0) + 1
number = ws.issue_counter
identifier = f"{ws.issue_prefix}-{number}"
issue = Issue(
workspace_id=workspace_id,
project_id=project_id,
title=title,
description=description,
status=status,
priority=priority,
assignee_type=assignee_type,
assignee_id=assignee_id,
creator_type=creator_type,
creator_id=creator_id,
parent_issue_id=parent_issue_id,
acceptance_criteria=acceptance_criteria or [],
number=number,
identifier=identifier,
)
self._session.add(issue)
await self._session.flush()
return issue
async def get(self, issue_id: str) -> Optional[Issue]:
"""Get issue by ID."""
return await self._session.get(Issue, issue_id)
async def list_for_workspace(
self,
workspace_id: str,
status: Optional[str] = None,
project_id: Optional[str] = None,
assignee_id: Optional[str] = None,
limit: int = 50,
offset: int = 0,
) -> list[Issue]:
"""List issues with optional filters."""
stmt = select(Issue).where(Issue.workspace_id == workspace_id)
if status:
stmt = stmt.where(Issue.status == status)
if project_id:
stmt = stmt.where(Issue.project_id == project_id)
if assignee_id:
stmt = stmt.where(Issue.assignee_id == assignee_id)
stmt = stmt.order_by(Issue.position, Issue.created_at).limit(limit).offset(offset)
result = await self._session.execute(stmt)
return list(result.scalars().all())
async def update(
self,
issue_id: str,
title: Optional[str] = None,
description: Optional[str] = None,
status: Optional[str] = None,
priority: Optional[str] = None,
assignee_type: Optional[str] = None,
assignee_id: Optional[str] = None,
project_id: Optional[str] = None,
) -> Optional[Issue]:
"""Update issue fields."""
issue = await self.get(issue_id)
if issue is None:
return None
if title is not None:
issue.title = title
if description is not None:
issue.description = description
if status is not None:
if status not in VALID_STATUSES:
raise ValueError(f"Invalid status: {status}")
issue.status = status
if priority is not None:
if priority not in VALID_PRIORITIES:
raise ValueError(f"Invalid priority: {priority}")
issue.priority = priority
if assignee_type is not None:
issue.assignee_type = assignee_type
if assignee_id is not None:
issue.assignee_id = assignee_id
if project_id is not None:
issue.project_id = project_id
await self._session.flush()
return issue
async def assign(
self,
issue_id: str,
assignee_type: str,
assignee_id: str,
) -> Optional[Issue]:
"""Assign an issue to a member or agent."""
if assignee_type not in VALID_ASSIGNEE_TYPES:
raise ValueError(f"Invalid assignee_type: {assignee_type}")
return await self.update(
issue_id, assignee_type=assignee_type, assignee_id=assignee_id
)
async def transition(self, issue_id: str, new_status: str) -> Optional[Issue]:
"""Transition an issue to a new status."""
return await self.update(issue_id, status=new_status)
async def delete(self, issue_id: str) -> bool:
"""Delete an issue."""
issue = await self.get(issue_id)
if issue is None:
return False
await self._session.delete(issue)
await self._session.flush()
return True
async def list_sub_issues(self, parent_id: str) -> list[Issue]:
"""List sub-issues of a parent issue."""
stmt = select(Issue).where(Issue.parent_issue_id == parent_id)
result = await self._session.execute(stmt)
return list(result.scalars().all())
@@ -0,0 +1,96 @@
"""
Label service — CRUD for workspace issue labels and issue-label links.
Influenced by Multica's issue_label + issue_to_label schema.
"""
from __future__ import annotations
from typing import Optional
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..db.models import IssueLabel, IssueLabelLink
class LabelService:
"""Issue label CRUD operations."""
def __init__(self, session: AsyncSession):
self._session = session
async def create(
self,
workspace_id: str,
name: str,
color: str = "#6B7280",
) -> IssueLabel:
"""Create a label in a workspace."""
label = IssueLabel(workspace_id=workspace_id, name=name, color=color)
self._session.add(label)
await self._session.flush()
return label
async def get(self, label_id: str) -> Optional[IssueLabel]:
"""Get label by ID."""
return await self._session.get(IssueLabel, label_id)
async def list_for_workspace(self, workspace_id: str) -> list[IssueLabel]:
"""List all labels in a workspace."""
stmt = select(IssueLabel).where(IssueLabel.workspace_id == workspace_id)
result = await self._session.execute(stmt)
return list(result.scalars().all())
async def update(
self,
label_id: str,
name: Optional[str] = None,
color: Optional[str] = None,
) -> Optional[IssueLabel]:
"""Update a label."""
label = await self.get(label_id)
if label is None:
return None
if name is not None:
label.name = name
if color is not None:
label.color = color
await self._session.flush()
return label
async def delete(self, label_id: str) -> bool:
"""Delete a label."""
label = await self.get(label_id)
if label is None:
return False
await self._session.delete(label)
await self._session.flush()
return True
# ── Issue-Label linking ────────────────────────────────────────────────
async def add_to_issue(self, issue_id: str, label_id: str) -> None:
"""Attach a label to an issue."""
link = IssueLabelLink(issue_id=issue_id, label_id=label_id)
self._session.add(link)
await self._session.flush()
async def remove_from_issue(self, issue_id: str, label_id: str) -> None:
"""Remove a label from an issue."""
stmt = delete(IssueLabelLink).where(
IssueLabelLink.issue_id == issue_id,
IssueLabelLink.label_id == label_id,
)
await self._session.execute(stmt)
await self._session.flush()
async def list_for_issue(self, issue_id: str) -> list[IssueLabel]:
"""List all labels on an issue."""
stmt = (
select(IssueLabel)
.join(IssueLabelLink, IssueLabelLink.label_id == IssueLabel.id)
.where(IssueLabelLink.issue_id == issue_id)
)
result = await self._session.execute(stmt)
return list(result.scalars().all())
@@ -0,0 +1,96 @@
"""
Member/RBAC service.
Influenced by Multica's member handler (owner/admin/member roles)
and Paperclip's access service.
"""
from __future__ import annotations
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..db.models import Member
VALID_ROLES = {"owner", "admin", "member"}
class MemberService:
"""Member management with role-based access control."""
def __init__(self, session: AsyncSession):
self._session = session
async def add(
self,
workspace_id: str,
user_id: str,
role: str = "member",
) -> Member:
"""Add a user to a workspace."""
if role not in VALID_ROLES:
raise ValueError(f"Invalid role: {role}. Must be one of {VALID_ROLES}")
member = Member(workspace_id=workspace_id, user_id=user_id, role=role)
self._session.add(member)
await self._session.flush()
return member
async def get(self, workspace_id: str, user_id: str) -> Optional[Member]:
"""Get a member by workspace + user."""
stmt = select(Member).where(
Member.workspace_id == workspace_id,
Member.user_id == user_id,
)
result = await self._session.execute(stmt)
return result.scalar_one_or_none()
async def list_members(self, workspace_id: str) -> list[Member]:
"""List all members of a workspace."""
stmt = select(Member).where(Member.workspace_id == workspace_id)
result = await self._session.execute(stmt)
return list(result.scalars().all())
async def update_role(
self,
workspace_id: str,
user_id: str,
new_role: str,
) -> Optional[Member]:
"""Update a member's role."""
if new_role not in VALID_ROLES:
raise ValueError(f"Invalid role: {new_role}. Must be one of {VALID_ROLES}")
member = await self.get(workspace_id, user_id)
if member is None:
return None
member.role = new_role
await self._session.flush()
return member
async def remove(self, workspace_id: str, user_id: str) -> bool:
"""Remove a member from a workspace."""
member = await self.get(workspace_id, user_id)
if member is None:
return False
await self._session.delete(member)
await self._session.flush()
return True
async def has_role(
self,
workspace_id: str,
user_id: str,
required_role: str,
) -> bool:
"""Check if a user has at least the required role.
Role hierarchy: owner > admin > member.
"""
member = await self.get(workspace_id, user_id)
if member is None:
return False
role_levels = {"owner": 3, "admin": 2, "member": 1}
user_level = role_levels.get(member.role, 0)
required_level = role_levels.get(required_role, 0)
return user_level >= required_level
@@ -0,0 +1,105 @@
"""
Project service — CRUD with lead and issue linking.
Influenced by Multica's 034_projects.up.sql and project handler.
"""
from __future__ import annotations
from typing import Optional
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..db.models import Issue, Project
class ProjectService:
"""Project CRUD operations."""
def __init__(self, session: AsyncSession):
self._session = session
async def create(
self,
workspace_id: str,
title: str,
description: Optional[str] = None,
icon: Optional[str] = None,
lead_type: Optional[str] = None,
lead_id: Optional[str] = None,
) -> Project:
"""Create a project."""
project = Project(
workspace_id=workspace_id,
title=title,
description=description,
icon=icon,
lead_type=lead_type,
lead_id=lead_id,
)
self._session.add(project)
await self._session.flush()
return project
async def get(self, project_id: str) -> Optional[Project]:
"""Get project by ID."""
return await self._session.get(Project, project_id)
async def list_for_workspace(
self,
workspace_id: str,
limit: int = 50,
offset: int = 0,
) -> list[Project]:
"""List all projects in a workspace."""
stmt = select(Project).where(Project.workspace_id == workspace_id).limit(limit).offset(offset)
result = await self._session.execute(stmt)
return list(result.scalars().all())
async def update(
self,
project_id: str,
title: Optional[str] = None,
description: Optional[str] = None,
status: Optional[str] = None,
lead_type: Optional[str] = None,
lead_id: Optional[str] = None,
) -> Optional[Project]:
"""Update project fields."""
project = await self.get(project_id)
if project is None:
return None
if title is not None:
project.title = title
if description is not None:
project.description = description
if status is not None:
project.status = status
if lead_type is not None:
project.lead_type = lead_type
if lead_id is not None:
project.lead_id = lead_id
await self._session.flush()
return project
async def delete(self, project_id: str) -> bool:
"""Delete a project."""
project = await self.get(project_id)
if project is None:
return False
await self._session.delete(project)
await self._session.flush()
return True
async def get_stats(self, project_id: str) -> dict:
"""Get issue statistics for a project."""
stmt = (
select(Issue.status, func.count(Issue.id))
.where(Issue.project_id == project_id)
.group_by(Issue.status)
)
result = await self._session.execute(stmt)
stats = {row[0]: row[1] for row in result.all()}
total = sum(stats.values())
return {"total": total, "by_status": stats}
@@ -0,0 +1,119 @@
"""
Workspace service — CRUD with slug validation.
Influenced by Multica's workspace handler (CreateWorkspace, slug pattern)
and Paperclip's company service.
"""
from __future__ import annotations
import re
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..db.models import Member, Workspace
def _slugify(name: str) -> str:
"""Convert name to a URL-safe slug."""
slug = name.lower().strip()
slug = re.sub(r"[^a-z0-9]+", "-", slug)
slug = slug.strip("-")
return slug or "workspace"
class WorkspaceService:
"""Workspace CRUD operations."""
def __init__(self, session: AsyncSession):
self._session = session
async def create(
self,
name: str,
owner_id: str,
slug: Optional[str] = None,
description: Optional[str] = None,
) -> Workspace:
"""Create a workspace and add the creator as owner."""
final_slug = slug or _slugify(name)
final_slug = await self._ensure_unique_slug(final_slug)
ws = Workspace(name=name, slug=final_slug, description=description)
self._session.add(ws)
await self._session.flush()
member = Member(workspace_id=ws.id, user_id=owner_id, role="owner")
self._session.add(member)
await self._session.flush()
return ws
async def get(self, workspace_id: str) -> Optional[Workspace]:
"""Get workspace by ID."""
return await self._session.get(Workspace, workspace_id)
async def get_by_slug(self, slug: str) -> Optional[Workspace]:
"""Get workspace by slug."""
stmt = select(Workspace).where(Workspace.slug == slug)
result = await self._session.execute(stmt)
return result.scalar_one_or_none()
async def list_for_user(
self,
user_id: str,
limit: int = 50,
offset: int = 0,
) -> list[Workspace]:
"""List workspaces a user is a member of."""
stmt = (
select(Workspace)
.join(Member, Member.workspace_id == Workspace.id)
.where(Member.user_id == user_id)
.limit(limit)
.offset(offset)
)
result = await self._session.execute(stmt)
return list(result.scalars().all())
async def update(
self,
workspace_id: str,
name: Optional[str] = None,
description: Optional[str] = None,
settings: Optional[dict] = None,
) -> Optional[Workspace]:
"""Update workspace fields."""
ws = await self.get(workspace_id)
if ws is None:
return None
if name is not None:
ws.name = name
if description is not None:
ws.description = description
if settings is not None:
ws.settings = settings
await self._session.flush()
return ws
async def delete(self, workspace_id: str) -> bool:
"""Delete a workspace."""
ws = await self.get(workspace_id)
if ws is None:
return False
await self._session.delete(ws)
await self._session.flush()
return True
async def _ensure_unique_slug(self, slug: str) -> str:
"""Append a suffix if the slug is already taken."""
base = slug
counter = 1
while True:
stmt = select(Workspace.id).where(Workspace.slug == slug)
result = await self._session.execute(stmt)
if result.scalar_one_or_none() is None:
return slug
slug = f"{base}-{counter}"
counter += 1
+45
View File
@@ -0,0 +1,45 @@
[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "praisonai-platform"
version = "0.1.0"
description = "Platform layer for PraisonAI — workspace, auth, issues, projects"
readme = "README.md"
requires-python = ">=3.10"
authors = [
{ name="Mervin Praison" }
]
dependencies = [
"praisonaiagents>=1.6.0",
"sqlalchemy>=2.0.0",
"aiosqlite>=0.20.0",
"alembic>=1.14.0",
"PyJWT>=2.8.0",
"passlib[bcrypt]>=1.7.4",
"fastapi>=0.115.0",
"uvicorn>=0.34.0",
"httpx>=0.27.0",
"pydantic>=2.10.0",
]
[project.optional-dependencies]
postgres = [
"asyncpg>=0.30.0",
"psycopg2-binary>=2.9.0",
]
test = [
"pytest>=8.0",
"pytest-asyncio>=0.23.0",
"httpx>=0.27.0",
]
[tool.setuptools.packages.find]
where = ["."]
include = ["praisonai_platform*"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
timeout = 30
Whitespace-only changes.
+41
View File
@@ -0,0 +1,41 @@
"""Shared fixtures for platform tests."""
import asyncio
import os
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from praisonai_platform.db.base import Base
os.environ["PLATFORM_JWT_SECRET"] = "test-secret-for-testing-only"
@pytest.fixture(scope="session")
def event_loop():
loop = asyncio.new_event_loop()
yield loop
loop.close()
@pytest_asyncio.fixture
async def engine():
"""Create a fresh in-memory SQLite engine per test."""
eng = create_async_engine(
"sqlite+aiosqlite:///:memory:",
echo=False,
connect_args={"check_same_thread": False},
)
async with eng.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield eng
await eng.dispose()
@pytest_asyncio.fixture
async def session(engine):
"""Create a session per test."""
factory = async_sessionmaker(engine, expire_on_commit=False)
async with factory() as sess:
yield sess
@@ -0,0 +1,226 @@
"""
Integration tests: Full API flow via httpx.AsyncClient + TestClient.
Tests the complete flow: register → login → create workspace → add member →
create project → create issue → assign agent → comment.
"""
import os
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
os.environ["PLATFORM_JWT_SECRET"] = "test-secret-for-testing-only"
from praisonai_platform.api.app import create_app
from praisonai_platform.db.base import Base, reset_engine
from sqlalchemy.ext.asyncio import create_async_engine
@pytest_asyncio.fixture
async def app():
"""Create a fresh app with in-memory DB for each test."""
await reset_engine()
# Patch the engine for testing
from praisonai_platform.db import base as base_mod
eng = create_async_engine(
"sqlite+aiosqlite:///:memory:",
echo=False,
connect_args={"check_same_thread": False},
)
base_mod._engine = eng
base_mod._session_factory = None
async with eng.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
application = create_app()
yield application
await eng.dispose()
base_mod._engine = None
base_mod._session_factory = None
@pytest_asyncio.fixture
async def client(app):
"""httpx async client for the app."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
class TestHealthEndpoint:
@pytest.mark.asyncio
async def test_health(self, client):
resp = await client.get("/health")
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
class TestFullFlow:
"""End-to-end: register → workspace → project → issue → comment."""
@pytest.mark.asyncio
async def test_complete_flow(self, client):
# 1. Register
resp = await client.post("/api/v1/auth/register", json={
"email": "[email protected]",
"password": "testpass",
"name": "Flow User",
})
assert resp.status_code == 201
data = resp.json()
token = data["token"]
user_id = data["user"]["id"]
headers = {"Authorization": f"Bearer {token}"}
# 2. Login
resp = await client.post("/api/v1/auth/login", json={
"email": "[email protected]",
"password": "testpass",
})
assert resp.status_code == 200
assert resp.json()["token"]
# 3. Create workspace
resp = await client.post("/api/v1/workspaces/", json={
"name": "Acme Corp",
"slug": "acme",
}, headers=headers)
assert resp.status_code == 201
ws = resp.json()
ws_id = ws["id"]
assert ws["slug"] == "acme"
# 4. List workspaces
resp = await client.get("/api/v1/workspaces/", headers=headers)
assert resp.status_code == 200
assert len(resp.json()) >= 1
# 5. Get workspace
resp = await client.get(f"/api/v1/workspaces/{ws_id}", headers=headers)
assert resp.status_code == 200
assert resp.json()["name"] == "Acme Corp"
# 6. List members (owner should be auto-added)
resp = await client.get(f"/api/v1/workspaces/{ws_id}/members", headers=headers)
assert resp.status_code == 200
members = resp.json()
assert len(members) >= 1
assert any(m["role"] == "owner" for m in members)
# 7. Create project
resp = await client.post(
f"/api/v1/workspaces/{ws_id}/projects/",
json={"title": "Platform MVP", "description": "Build the platform"},
headers=headers,
)
assert resp.status_code == 201
project = resp.json()
project_id = project["id"]
assert project["title"] == "Platform MVP"
# 8. List projects
resp = await client.get(f"/api/v1/workspaces/{ws_id}/projects/", headers=headers)
assert resp.status_code == 200
assert len(resp.json()) == 1
# 9. Create issue
resp = await client.post(
f"/api/v1/workspaces/{ws_id}/issues/",
json={
"title": "Implement auth",
"description": "JWT + bcrypt",
"project_id": project_id,
"priority": "high",
},
headers=headers,
)
assert resp.status_code == 201
issue = resp.json()
issue_id = issue["id"]
assert issue["priority"] == "high"
assert issue["creator_id"] == user_id
# 10. Update issue (assign to agent)
resp = await client.patch(
f"/api/v1/workspaces/{ws_id}/issues/{issue_id}",
json={"assignee_type": "agent", "assignee_id": "agent-42", "status": "in_progress"},
headers=headers,
)
assert resp.status_code == 200
updated = resp.json()
assert updated["assignee_type"] == "agent"
assert updated["assignee_id"] == "agent-42"
assert updated["status"] == "in_progress"
# 11. Add comment
resp = await client.post(
f"/api/v1/workspaces/{ws_id}/issues/{issue_id}/comments",
json={"content": "Starting work on this now."},
headers=headers,
)
assert resp.status_code == 201
comment = resp.json()
assert comment["content"] == "Starting work on this now."
# 12. List comments
resp = await client.get(
f"/api/v1/workspaces/{ws_id}/issues/{issue_id}/comments",
headers=headers,
)
assert resp.status_code == 200
comments = resp.json()
assert len(comments) == 1
# 13. Project stats
resp = await client.get(
f"/api/v1/workspaces/{ws_id}/projects/{project_id}/stats",
headers=headers,
)
assert resp.status_code == 200
stats = resp.json()
assert stats["total"] == 1
assert stats["by_status"]["in_progress"] == 1
class TestAuthErrors:
@pytest.mark.asyncio
async def test_missing_auth(self, client):
resp = await client.get("/api/v1/workspaces/")
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_bad_token(self, client):
resp = await client.get(
"/api/v1/workspaces/",
headers={"Authorization": "Bearer bad-token"},
)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_wrong_password(self, client):
await client.post("/api/v1/auth/register", json={
"email": "[email protected]",
"password": "correct",
})
resp = await client.post("/api/v1/auth/login", json={
"email": "[email protected]",
"password": "incorrect",
})
assert resp.status_code == 401
class TestNotFound:
@pytest.mark.asyncio
async def test_workspace_not_found(self, client):
# Register to get a token
resp = await client.post("/api/v1/auth/register", json={
"email": "[email protected]",
"password": "pass",
})
token = resp.json()["token"]
headers = {"Authorization": f"Bearer {token}"}
resp = await client.get("/api/v1/workspaces/nonexistent", headers=headers)
assert resp.status_code == 404
@@ -0,0 +1,355 @@
"""
Integration tests for new gap API routes:
- Agent CRUD routes
- Label routes + issue linking
- Dependency routes
- Activity log routes
- Issue numbering in API
- Threaded comments in API
- Pagination query params
"""
import os
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
os.environ["PLATFORM_JWT_SECRET"] = "test-secret-for-testing-only"
from praisonai_platform.api.app import create_app
from praisonai_platform.db.base import Base, reset_engine
from sqlalchemy.ext.asyncio import create_async_engine
@pytest_asyncio.fixture
async def app():
await reset_engine()
from praisonai_platform.db import base as base_mod
eng = create_async_engine(
"sqlite+aiosqlite:///:memory:",
echo=False,
connect_args={"check_same_thread": False},
)
base_mod._engine = eng
base_mod._session_factory = None
async with eng.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
application = create_app()
yield application
await eng.dispose()
base_mod._engine = None
base_mod._session_factory = None
@pytest_asyncio.fixture
async def client(app):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
async def _auth(client) -> tuple:
"""Register and return (token, user_id, headers, workspace_id)."""
resp = await client.post("/api/v1/auth/register", json={
"email": "[email protected]", "password": "testpass", "name": "GapUser",
})
data = resp.json()
token = data["token"]
user_id = data["user"]["id"]
headers = {"Authorization": f"Bearer {token}"}
resp = await client.post("/api/v1/workspaces/", json={
"name": "GapWS", "slug": "gapws",
}, headers=headers)
ws_id = resp.json()["id"]
return token, user_id, headers, ws_id
class TestAgentRoutes:
@pytest.mark.asyncio
async def test_agent_crud(self, client):
_, _, headers, ws_id = await _auth(client)
# Create
resp = await client.post(
f"/api/v1/workspaces/{ws_id}/agents/",
json={"name": "TestBot", "instructions": "Help users"},
headers=headers,
)
assert resp.status_code == 201
agent = resp.json()
agent_id = agent["id"]
assert agent["name"] == "TestBot"
assert agent["instructions"] == "Help users"
assert agent["status"] == "offline"
# List
resp = await client.get(f"/api/v1/workspaces/{ws_id}/agents/", headers=headers)
assert resp.status_code == 200
assert len(resp.json()) == 1
# Get
resp = await client.get(f"/api/v1/workspaces/{ws_id}/agents/{agent_id}", headers=headers)
assert resp.status_code == 200
assert resp.json()["name"] == "TestBot"
# Update
resp = await client.patch(
f"/api/v1/workspaces/{ws_id}/agents/{agent_id}",
json={"name": "UpdatedBot", "status": "idle"},
headers=headers,
)
assert resp.status_code == 200
assert resp.json()["name"] == "UpdatedBot"
assert resp.json()["status"] == "idle"
# Delete
resp = await client.delete(
f"/api/v1/workspaces/{ws_id}/agents/{agent_id}",
headers=headers,
)
assert resp.status_code == 204
@pytest.mark.asyncio
async def test_agent_not_found(self, client):
_, _, headers, ws_id = await _auth(client)
resp = await client.get(
f"/api/v1/workspaces/{ws_id}/agents/nonexistent",
headers=headers,
)
assert resp.status_code == 404
class TestLabelRoutes:
@pytest.mark.asyncio
async def test_label_crud_and_linking(self, client):
_, _, headers, ws_id = await _auth(client)
# Create label
resp = await client.post(
f"/api/v1/workspaces/{ws_id}/labels",
json={"name": "bug", "color": "#FF0000"},
headers=headers,
)
assert resp.status_code == 201
label = resp.json()
label_id = label["id"]
assert label["name"] == "bug"
# List labels
resp = await client.get(f"/api/v1/workspaces/{ws_id}/labels", headers=headers)
assert resp.status_code == 200
assert len(resp.json()) == 1
# Create issue
resp = await client.post(
f"/api/v1/workspaces/{ws_id}/issues/",
json={"title": "Labeled bug"},
headers=headers,
)
issue_id = resp.json()["id"]
# Add label to issue
resp = await client.post(
f"/api/v1/workspaces/{ws_id}/issues/{issue_id}/labels/{label_id}",
headers=headers,
)
assert resp.status_code == 204
# List issue labels
resp = await client.get(
f"/api/v1/workspaces/{ws_id}/issues/{issue_id}/labels",
headers=headers,
)
assert resp.status_code == 200
assert len(resp.json()) == 1
# Remove label from issue
resp = await client.delete(
f"/api/v1/workspaces/{ws_id}/issues/{issue_id}/labels/{label_id}",
headers=headers,
)
assert resp.status_code == 204
# Delete label
resp = await client.delete(
f"/api/v1/workspaces/{ws_id}/labels/{label_id}",
headers=headers,
)
assert resp.status_code == 204
class TestDependencyRoutes:
@pytest.mark.asyncio
async def test_dependency_crud(self, client):
_, _, headers, ws_id = await _auth(client)
# Create two issues
resp1 = await client.post(f"/api/v1/workspaces/{ws_id}/issues/", json={"title": "Blocker"}, headers=headers)
resp2 = await client.post(f"/api/v1/workspaces/{ws_id}/issues/", json={"title": "Blocked"}, headers=headers)
i1_id = resp1.json()["id"]
i2_id = resp2.json()["id"]
# Create dependency
resp = await client.post(
f"/api/v1/workspaces/{ws_id}/issues/{i1_id}/dependencies/",
json={"depends_on_issue_id": i2_id, "type": "blocks"},
headers=headers,
)
assert resp.status_code == 201
dep = resp.json()
dep_id = dep["id"]
assert dep["type"] == "blocks"
# List dependencies
resp = await client.get(
f"/api/v1/workspaces/{ws_id}/issues/{i1_id}/dependencies/",
headers=headers,
)
assert resp.status_code == 200
assert len(resp.json()) == 1
# Delete dependency
resp = await client.delete(
f"/api/v1/workspaces/{ws_id}/issues/{i1_id}/dependencies/{dep_id}",
headers=headers,
)
assert resp.status_code == 204
class TestActivityRoutes:
@pytest.mark.asyncio
async def test_activity_routes(self, client):
_, _, headers, ws_id = await _auth(client)
# Create an issue (triggers activity logging)
resp = await client.post(
f"/api/v1/workspaces/{ws_id}/issues/",
json={"title": "Activity test"},
headers=headers,
)
assert resp.status_code == 201
issue_id = resp.json()["id"]
# List workspace activity
resp = await client.get(
f"/api/v1/workspaces/{ws_id}/activity",
headers=headers,
)
assert resp.status_code == 200
activities = resp.json()
assert len(activities) >= 1
assert any(a["action"] == "issue.created" for a in activities)
# List issue activity
resp = await client.get(
f"/api/v1/workspaces/{ws_id}/issues/{issue_id}/activity",
headers=headers,
)
assert resp.status_code == 200
assert len(resp.json()) >= 1
class TestIssueNumberingAPI:
@pytest.mark.asyncio
async def test_issue_has_number_and_identifier(self, client):
_, _, headers, ws_id = await _auth(client)
resp = await client.post(
f"/api/v1/workspaces/{ws_id}/issues/",
json={"title": "Numbered Issue 1"},
headers=headers,
)
assert resp.status_code == 201
issue1 = resp.json()
assert issue1["number"] == 1
assert issue1["identifier"] == "ISS-1"
resp = await client.post(
f"/api/v1/workspaces/{ws_id}/issues/",
json={"title": "Numbered Issue 2"},
headers=headers,
)
issue2 = resp.json()
assert issue2["number"] == 2
assert issue2["identifier"] == "ISS-2"
class TestThreadedCommentsAPI:
@pytest.mark.asyncio
async def test_threaded_comment(self, client):
_, _, headers, ws_id = await _auth(client)
# Create issue
resp = await client.post(
f"/api/v1/workspaces/{ws_id}/issues/",
json={"title": "Thread test"},
headers=headers,
)
issue_id = resp.json()["id"]
# Top-level comment
resp = await client.post(
f"/api/v1/workspaces/{ws_id}/issues/{issue_id}/comments",
json={"content": "Parent comment"},
headers=headers,
)
assert resp.status_code == 201
parent = resp.json()
assert parent["parent_id"] is None
# Reply
resp = await client.post(
f"/api/v1/workspaces/{ws_id}/issues/{issue_id}/comments",
json={"content": "Reply", "parent_id": parent["id"]},
headers=headers,
)
assert resp.status_code == 201
reply = resp.json()
assert reply["parent_id"] == parent["id"]
class TestPaginationAPI:
@pytest.mark.asyncio
async def test_issue_list_pagination(self, client):
_, _, headers, ws_id = await _auth(client)
for i in range(5):
await client.post(
f"/api/v1/workspaces/{ws_id}/issues/",
json={"title": f"Issue {i}"},
headers=headers,
)
resp = await client.get(
f"/api/v1/workspaces/{ws_id}/issues/?limit=2&offset=0",
headers=headers,
)
assert resp.status_code == 200
assert len(resp.json()) == 2
resp = await client.get(
f"/api/v1/workspaces/{ws_id}/issues/?limit=2&offset=2",
headers=headers,
)
assert resp.status_code == 200
assert len(resp.json()) == 2
@pytest.mark.asyncio
async def test_agent_list_pagination(self, client):
_, _, headers, ws_id = await _auth(client)
for i in range(4):
await client.post(
f"/api/v1/workspaces/{ws_id}/agents/",
json={"name": f"Agent {i}"},
headers=headers,
)
resp = await client.get(
f"/api/v1/workspaces/{ws_id}/agents/?limit=2&offset=0",
headers=headers,
)
assert resp.status_code == 200
assert len(resp.json()) == 2
@@ -0,0 +1,323 @@
"""Tests for all new gap implementations:
GAP-1: Agent CRUD service
GAP-2: Label service + issue linking
GAP-3: Dependency service
GAP-4: Human-readable issue IDs
GAP-5: Threaded comments (parent_id)
GAP-6: Pagination
GAP-10: Agent instructions field
"""
import pytest
from praisonai_platform.services.auth_service import AuthService
from praisonai_platform.services.workspace_service import WorkspaceService
from praisonai_platform.services.issue_service import IssueService
from praisonai_platform.services.agent_service import AgentService
from praisonai_platform.services.label_service import LabelService
from praisonai_platform.services.dependency_service import DependencyService
from praisonai_platform.services.comment_service import CommentService
from praisonai_platform.services.activity_service import ActivityService
# ── Helpers ──────────────────────────────────────────────────────────────
async def _create_user_and_workspace(session, email="[email protected]"):
auth = AuthService(session)
user, _ = await auth.register(email, "pass")
ws_svc = WorkspaceService(session)
ws = await ws_svc.create("TestWS", user.id)
return user, ws
# ── GAP-1: Agent CRUD ────────────────────────────────────────────────────
class TestAgentService:
@pytest.mark.asyncio
async def test_create_agent(self, session):
user, ws = await _create_user_and_workspace(session, "[email protected]")
svc = AgentService(session)
agent = await svc.create(ws.id, "CodeBot", owner_id=user.id)
assert agent.name == "CodeBot"
assert agent.status == "offline"
assert agent.workspace_id == ws.id
assert agent.owner_id == user.id
@pytest.mark.asyncio
async def test_create_agent_with_instructions(self, session):
user, ws = await _create_user_and_workspace(session, "[email protected]")
svc = AgentService(session)
agent = await svc.create(ws.id, "InstrBot", instructions="You are a helpful bot.")
assert agent.instructions == "You are a helpful bot."
@pytest.mark.asyncio
async def test_list_agents(self, session):
user, ws = await _create_user_and_workspace(session, "[email protected]")
svc = AgentService(session)
await svc.create(ws.id, "Agent1")
await svc.create(ws.id, "Agent2")
agents = await svc.list_for_workspace(ws.id)
assert len(agents) == 2
@pytest.mark.asyncio
async def test_list_agents_filter_status(self, session):
user, ws = await _create_user_and_workspace(session, "[email protected]")
svc = AgentService(session)
await svc.create(ws.id, "Active", status="idle")
await svc.create(ws.id, "Off", status="offline")
idle = await svc.list_for_workspace(ws.id, status="idle")
assert len(idle) == 1
assert idle[0].name == "Active"
@pytest.mark.asyncio
async def test_update_agent(self, session):
user, ws = await _create_user_and_workspace(session, "[email protected]")
svc = AgentService(session)
agent = await svc.create(ws.id, "OldName")
updated = await svc.update(agent.id, name="NewName", status="idle")
assert updated.name == "NewName"
assert updated.status == "idle"
@pytest.mark.asyncio
async def test_update_agent_invalid_status(self, session):
user, ws = await _create_user_and_workspace(session, "[email protected]")
svc = AgentService(session)
agent = await svc.create(ws.id, "Bot")
with pytest.raises(ValueError, match="Invalid status"):
await svc.update(agent.id, status="flying")
@pytest.mark.asyncio
async def test_delete_agent(self, session):
user, ws = await _create_user_and_workspace(session, "[email protected]")
svc = AgentService(session)
agent = await svc.create(ws.id, "ToDelete")
assert await svc.delete(agent.id) is True
assert await svc.get(agent.id) is None
@pytest.mark.asyncio
async def test_delete_nonexistent(self, session):
svc = AgentService(session)
assert await svc.delete("nonexistent") is False
# ── GAP-2: Label CRUD + Issue linking ─────────────────────────────────────
class TestLabelService:
@pytest.mark.asyncio
async def test_create_label(self, session):
user, ws = await _create_user_and_workspace(session, "[email protected]")
svc = LabelService(session)
label = await svc.create(ws.id, "bug", "#FF0000")
assert label.name == "bug"
assert label.color == "#FF0000"
@pytest.mark.asyncio
async def test_list_labels(self, session):
user, ws = await _create_user_and_workspace(session, "[email protected]")
svc = LabelService(session)
await svc.create(ws.id, "bug")
await svc.create(ws.id, "feature")
labels = await svc.list_for_workspace(ws.id)
assert len(labels) == 2
@pytest.mark.asyncio
async def test_update_label(self, session):
user, ws = await _create_user_and_workspace(session, "[email protected]")
svc = LabelService(session)
label = await svc.create(ws.id, "old")
updated = await svc.update(label.id, name="new", color="#00FF00")
assert updated.name == "new"
assert updated.color == "#00FF00"
@pytest.mark.asyncio
async def test_delete_label(self, session):
user, ws = await _create_user_and_workspace(session, "[email protected]")
svc = LabelService(session)
label = await svc.create(ws.id, "gone")
assert await svc.delete(label.id) is True
assert await svc.get(label.id) is None
@pytest.mark.asyncio
async def test_add_remove_label_to_issue(self, session):
user, ws = await _create_user_and_workspace(session, "[email protected]")
label_svc = LabelService(session)
issue_svc = IssueService(session)
label = await label_svc.create(ws.id, "bug")
issue = await issue_svc.create(ws.id, "Labeled Issue", user.id)
await label_svc.add_to_issue(issue.id, label.id)
labels = await label_svc.list_for_issue(issue.id)
assert len(labels) == 1
assert labels[0].id == label.id
await label_svc.remove_from_issue(issue.id, label.id)
labels2 = await label_svc.list_for_issue(issue.id)
assert len(labels2) == 0
# ── GAP-3: Dependency service ─────────────────────────────────────────────
class TestDependencyService:
@pytest.mark.asyncio
async def test_create_dependency(self, session):
user, ws = await _create_user_and_workspace(session, "[email protected]")
issue_svc = IssueService(session)
i1 = await issue_svc.create(ws.id, "Blocker", user.id)
i2 = await issue_svc.create(ws.id, "Blocked", user.id)
dep_svc = DependencyService(session)
dep = await dep_svc.create(i1.id, i2.id, "blocks")
assert dep.issue_id == i1.id
assert dep.depends_on_issue_id == i2.id
assert dep.type == "blocks"
@pytest.mark.asyncio
async def test_invalid_dep_type(self, session):
dep_svc = DependencyService(session)
with pytest.raises(ValueError, match="Invalid type"):
await dep_svc.create("a", "b", "depends_on")
@pytest.mark.asyncio
async def test_list_dependencies(self, session):
user, ws = await _create_user_and_workspace(session, "[email protected]")
issue_svc = IssueService(session)
i1 = await issue_svc.create(ws.id, "A", user.id)
i2 = await issue_svc.create(ws.id, "B", user.id)
dep_svc = DependencyService(session)
await dep_svc.create(i1.id, i2.id, "blocks")
deps_from_i1 = await dep_svc.list_for_issue(i1.id)
deps_from_i2 = await dep_svc.list_for_issue(i2.id)
assert len(deps_from_i1) == 1
assert len(deps_from_i2) == 1 # Same dep shows from both sides
@pytest.mark.asyncio
async def test_delete_dependency(self, session):
user, ws = await _create_user_and_workspace(session, "[email protected]")
issue_svc = IssueService(session)
i1 = await issue_svc.create(ws.id, "X", user.id)
i2 = await issue_svc.create(ws.id, "Y", user.id)
dep_svc = DependencyService(session)
dep = await dep_svc.create(i1.id, i2.id, "related")
assert await dep_svc.delete(dep.id) is True
assert await dep_svc.get(dep.id) is None
# ── GAP-4: Human-readable issue IDs ──────────────────────────────────────
class TestIssueNumbering:
@pytest.mark.asyncio
async def test_issue_auto_number(self, session):
user, ws = await _create_user_and_workspace(session, "[email protected]")
svc = IssueService(session)
i1 = await svc.create(ws.id, "First", user.id)
i2 = await svc.create(ws.id, "Second", user.id)
assert i1.number == 1
assert i1.identifier == "ISS-1"
assert i2.number == 2
assert i2.identifier == "ISS-2"
@pytest.mark.asyncio
async def test_custom_issue_prefix(self, session):
auth = AuthService(session)
user, _ = await auth.register("[email protected]", "pass")
ws_svc = WorkspaceService(session)
ws = await ws_svc.create("PrefixWS", user.id)
await ws_svc.update(ws.id, settings={"custom": True})
# Directly update prefix for testing
from praisonai_platform.db.models import Workspace
ws_obj = await session.get(Workspace, ws.id)
ws_obj.issue_prefix = "PRJ"
await session.flush()
svc = IssueService(session)
issue = await svc.create(ws.id, "Custom Prefix", user.id)
assert issue.identifier == "PRJ-1"
@pytest.mark.asyncio
async def test_counter_persists_across_creates(self, session):
user, ws = await _create_user_and_workspace(session, "[email protected]")
svc = IssueService(session)
for i in range(5):
issue = await svc.create(ws.id, f"Issue-{i}", user.id)
assert issue.number == 5
assert issue.identifier == "ISS-5"
# ── GAP-5: Threaded comments ─────────────────────────────────────────────
class TestThreadedComments:
@pytest.mark.asyncio
async def test_reply_to_comment(self, session):
user, ws = await _create_user_and_workspace(session, "[email protected]")
issue_svc = IssueService(session)
issue = await issue_svc.create(ws.id, "ThreadIssue", user.id)
svc = CommentService(session)
parent = await svc.create(issue.id, user.id, "Parent comment")
reply = await svc.create(issue.id, user.id, "Reply", parent_id=parent.id)
assert reply.parent_id == parent.id
@pytest.mark.asyncio
async def test_top_level_comment_no_parent(self, session):
user, ws = await _create_user_and_workspace(session, "[email protected]")
issue_svc = IssueService(session)
issue = await issue_svc.create(ws.id, "TopIssue", user.id)
svc = CommentService(session)
comment = await svc.create(issue.id, user.id, "Top level")
assert comment.parent_id is None
# ── GAP-6: Pagination ────────────────────────────────────────────────────
class TestPagination:
@pytest.mark.asyncio
async def test_issue_pagination(self, session):
user, ws = await _create_user_and_workspace(session, "[email protected]")
svc = IssueService(session)
for i in range(10):
await svc.create(ws.id, f"Issue {i}", user.id)
page1 = await svc.list_for_workspace(ws.id, limit=3, offset=0)
page2 = await svc.list_for_workspace(ws.id, limit=3, offset=3)
assert len(page1) == 3
assert len(page2) == 3
assert page1[0].id != page2[0].id
@pytest.mark.asyncio
async def test_agent_pagination(self, session):
user, ws = await _create_user_and_workspace(session, "[email protected]")
svc = AgentService(session)
for i in range(5):
await svc.create(ws.id, f"Agent-{i}")
page = await svc.list_for_workspace(ws.id, limit=2, offset=0)
assert len(page) == 2
@pytest.mark.asyncio
async def test_activity_pagination(self, session):
user, ws = await _create_user_and_workspace(session, "[email protected]")
svc = ActivityService(session)
for i in range(5):
await svc.log(ws.id, f"action.{i}", "member", user.id)
page = await svc.list_for_workspace(ws.id, limit=2, offset=0)
assert len(page) == 2
# ── GAP-10: Agent instructions in model ──────────────────────────────────
class TestAgentInstructions:
@pytest.mark.asyncio
async def test_instructions_persist(self, session):
user, ws = await _create_user_and_workspace(session, "[email protected]")
svc = AgentService(session)
agent = await svc.create(ws.id, "InstrBot", instructions="Do X then Y")
fetched = await svc.get(agent.id)
assert fetched.instructions == "Do X then Y"
@pytest.mark.asyncio
async def test_update_instructions(self, session):
user, ws = await _create_user_and_workspace(session, "[email protected]")
svc = AgentService(session)
agent = await svc.create(ws.id, "UpdInstr")
updated = await svc.update(agent.id, instructions="New instructions")
assert updated.instructions == "New instructions"
@@ -0,0 +1,531 @@
"""Unit tests for all platform services."""
import pytest
from praisonai_platform.services.auth_service import AuthService
from praisonai_platform.services.workspace_service import WorkspaceService
from praisonai_platform.services.member_service import MemberService
from praisonai_platform.services.project_service import ProjectService
from praisonai_platform.services.issue_service import IssueService
from praisonai_platform.services.comment_service import CommentService
from praisonai_platform.services.activity_service import ActivityService
# ── Auth Service ─────────────────────────────────────────────────────────────
class TestAuthService:
@pytest.mark.asyncio
async def test_register(self, session):
svc = AuthService(session)
user, token = await svc.register("[email protected]", "password123", "Alice")
assert user.email == "[email protected]"
assert user.name == "Alice"
assert user.password_hash is not None
assert token is not None
assert len(token) > 20
@pytest.mark.asyncio
async def test_register_default_name(self, session):
svc = AuthService(session)
user, _ = await svc.register("[email protected]", "pass")
assert user.name == "bob"
@pytest.mark.asyncio
async def test_login_success(self, session):
svc = AuthService(session)
await svc.register("[email protected]", "mypass", "Carol")
result = await svc.login("[email protected]", "mypass")
assert result is not None
user, token = result
assert user.email == "[email protected]"
assert token is not None
@pytest.mark.asyncio
async def test_login_wrong_password(self, session):
svc = AuthService(session)
await svc.register("[email protected]", "correct")
result = await svc.login("[email protected]", "wrong")
assert result is None
@pytest.mark.asyncio
async def test_login_nonexistent_user(self, session):
svc = AuthService(session)
result = await svc.login("[email protected]", "pass")
assert result is None
@pytest.mark.asyncio
async def test_authenticate_with_token(self, session):
svc = AuthService(session)
_, token = await svc.register("[email protected]", "pass", "Eve")
identity = await svc.authenticate({"token": token})
assert identity is not None
assert identity.email == "[email protected]"
assert identity.name == "Eve"
@pytest.mark.asyncio
async def test_authenticate_bad_token(self, session):
svc = AuthService(session)
identity = await svc.authenticate({"token": "invalid.jwt.token"})
assert identity is None
@pytest.mark.asyncio
async def test_authenticate_with_credentials(self, session):
svc = AuthService(session)
await svc.register("[email protected]", "pass123", "Frank")
identity = await svc.authenticate({"email": "[email protected]", "password": "pass123"})
assert identity is not None
assert identity.email == "[email protected]"
@pytest.mark.asyncio
async def test_authorize(self, session):
svc = AuthService(session)
from praisonaiagents.auth import AuthIdentity
identity = AuthIdentity(id="u-1", workspace_id="ws-1", roles=["owner"])
# Without workspace member record, auth will check DB — returns False
result = await svc.authorize(identity, "issue:1", "delete")
assert result is False
# ── Workspace Service ────────────────────────────────────────────────────────
class TestWorkspaceService:
@pytest.mark.asyncio
async def test_create_workspace(self, session):
auth = AuthService(session)
user, _ = await auth.register("[email protected]", "pass")
svc = WorkspaceService(session)
ws = await svc.create("My Workspace", user.id)
assert ws.name == "My Workspace"
assert ws.slug == "my-workspace"
assert ws.id is not None
@pytest.mark.asyncio
async def test_create_workspace_custom_slug(self, session):
auth = AuthService(session)
user, _ = await auth.register("[email protected]", "pass")
svc = WorkspaceService(session)
ws = await svc.create("Test", user.id, slug="custom-slug")
assert ws.slug == "custom-slug"
@pytest.mark.asyncio
async def test_create_workspace_duplicate_slug(self, session):
auth = AuthService(session)
user, _ = await auth.register("[email protected]", "pass")
svc = WorkspaceService(session)
ws1 = await svc.create("Same", user.id, slug="same")
ws2 = await svc.create("Same", user.id, slug="same")
assert ws1.slug == "same"
assert ws2.slug == "same-1"
@pytest.mark.asyncio
async def test_get_workspace(self, session):
auth = AuthService(session)
user, _ = await auth.register("[email protected]", "pass")
svc = WorkspaceService(session)
ws = await svc.create("GetMe", user.id)
fetched = await svc.get(ws.id)
assert fetched is not None
assert fetched.name == "GetMe"
@pytest.mark.asyncio
async def test_get_by_slug(self, session):
auth = AuthService(session)
user, _ = await auth.register("[email protected]", "pass")
svc = WorkspaceService(session)
ws = await svc.create("BySlug", user.id)
fetched = await svc.get_by_slug(ws.slug)
assert fetched is not None
assert fetched.id == ws.id
@pytest.mark.asyncio
async def test_list_for_user(self, session):
auth = AuthService(session)
user, _ = await auth.register("[email protected]", "pass")
svc = WorkspaceService(session)
await svc.create("WS1", user.id)
await svc.create("WS2", user.id)
workspaces = await svc.list_for_user(user.id)
assert len(workspaces) >= 2
@pytest.mark.asyncio
async def test_update_workspace(self, session):
auth = AuthService(session)
user, _ = await auth.register("[email protected]", "pass")
svc = WorkspaceService(session)
ws = await svc.create("Original", user.id)
updated = await svc.update(ws.id, name="Updated")
assert updated.name == "Updated"
@pytest.mark.asyncio
async def test_delete_workspace(self, session):
auth = AuthService(session)
user, _ = await auth.register("[email protected]", "pass")
svc = WorkspaceService(session)
ws = await svc.create("ToDelete", user.id)
assert await svc.delete(ws.id) is True
assert await svc.get(ws.id) is None
@pytest.mark.asyncio
async def test_delete_nonexistent(self, session):
svc = WorkspaceService(session)
assert await svc.delete("nonexistent-id") is False
# ── Member Service ───────────────────────────────────────────────────────────
class TestMemberService:
@pytest.mark.asyncio
async def test_add_member(self, session):
auth = AuthService(session)
user1, _ = await auth.register("[email protected]", "pass")
user2, _ = await auth.register("[email protected]", "pass")
ws_svc = WorkspaceService(session)
ws = await ws_svc.create("MemberWS", user1.id)
svc = MemberService(session)
member = await svc.add(ws.id, user2.id, "member")
assert member.role == "member"
assert member.user_id == user2.id
@pytest.mark.asyncio
async def test_invalid_role(self, session):
svc = MemberService(session)
with pytest.raises(ValueError, match="Invalid role"):
await svc.add("ws-1", "u-1", "superadmin")
@pytest.mark.asyncio
async def test_list_members(self, session):
auth = AuthService(session)
user, _ = await auth.register("[email protected]", "pass")
ws_svc = WorkspaceService(session)
ws = await ws_svc.create("ListMembersWS", user.id)
svc = MemberService(session)
members = await svc.list_members(ws.id)
assert len(members) >= 1 # owner auto-added
@pytest.mark.asyncio
async def test_update_role(self, session):
auth = AuthService(session)
user1, _ = await auth.register("[email protected]", "pass")
user2, _ = await auth.register("[email protected]", "pass")
ws_svc = WorkspaceService(session)
ws = await ws_svc.create("RoleWS", user1.id)
svc = MemberService(session)
await svc.add(ws.id, user2.id, "member")
updated = await svc.update_role(ws.id, user2.id, "admin")
assert updated.role == "admin"
@pytest.mark.asyncio
async def test_remove_member(self, session):
auth = AuthService(session)
user1, _ = await auth.register("[email protected]", "pass")
user2, _ = await auth.register("[email protected]", "pass")
ws_svc = WorkspaceService(session)
ws = await ws_svc.create("RemWS", user1.id)
svc = MemberService(session)
await svc.add(ws.id, user2.id, "member")
assert await svc.remove(ws.id, user2.id) is True
assert await svc.get(ws.id, user2.id) is None
@pytest.mark.asyncio
async def test_has_role(self, session):
auth = AuthService(session)
user, _ = await auth.register("[email protected]", "pass")
ws_svc = WorkspaceService(session)
ws = await ws_svc.create("HasRoleWS", user.id)
svc = MemberService(session)
# Owner should have at least "member" access
assert await svc.has_role(ws.id, user.id, "member") is True
assert await svc.has_role(ws.id, user.id, "admin") is True
assert await svc.has_role(ws.id, user.id, "owner") is True
assert await svc.has_role(ws.id, "nonexistent", "member") is False
# ── Project Service ──────────────────────────────────────────────────────────
class TestProjectService:
@pytest.mark.asyncio
async def test_create_project(self, session):
auth = AuthService(session)
user, _ = await auth.register("[email protected]", "pass")
ws_svc = WorkspaceService(session)
ws = await ws_svc.create("ProjectWS", user.id)
svc = ProjectService(session)
project = await svc.create(ws.id, "My Project", description="A project")
assert project.title == "My Project"
assert project.status == "planned"
@pytest.mark.asyncio
async def test_list_projects(self, session):
auth = AuthService(session)
user, _ = await auth.register("[email protected]", "pass")
ws_svc = WorkspaceService(session)
ws = await ws_svc.create("ProjListWS", user.id)
svc = ProjectService(session)
await svc.create(ws.id, "P1")
await svc.create(ws.id, "P2")
projects = await svc.list_for_workspace(ws.id)
assert len(projects) == 2
@pytest.mark.asyncio
async def test_update_project(self, session):
auth = AuthService(session)
user, _ = await auth.register("[email protected]", "pass")
ws_svc = WorkspaceService(session)
ws = await ws_svc.create("ProjUpdWS", user.id)
svc = ProjectService(session)
project = await svc.create(ws.id, "Original")
updated = await svc.update(project.id, title="Updated", status="in_progress")
assert updated.title == "Updated"
assert updated.status == "in_progress"
@pytest.mark.asyncio
async def test_delete_project(self, session):
auth = AuthService(session)
user, _ = await auth.register("[email protected]", "pass")
ws_svc = WorkspaceService(session)
ws = await ws_svc.create("ProjDelWS", user.id)
svc = ProjectService(session)
project = await svc.create(ws.id, "ToDelete")
assert await svc.delete(project.id) is True
assert await svc.get(project.id) is None
@pytest.mark.asyncio
async def test_get_stats(self, session):
auth = AuthService(session)
user, _ = await auth.register("[email protected]", "pass")
ws_svc = WorkspaceService(session)
ws = await ws_svc.create("ProjStatsWS", user.id)
proj_svc = ProjectService(session)
project = await proj_svc.create(ws.id, "StatsProj")
issue_svc = IssueService(session)
await issue_svc.create(ws.id, "Issue1", user.id, project_id=project.id, status="todo")
await issue_svc.create(ws.id, "Issue2", user.id, project_id=project.id, status="todo")
await issue_svc.create(ws.id, "Issue3", user.id, project_id=project.id, status="done")
stats = await proj_svc.get_stats(project.id)
assert stats["total"] == 3
assert stats["by_status"]["todo"] == 2
assert stats["by_status"]["done"] == 1
# ── Issue Service ────────────────────────────────────────────────────────────
class TestIssueService:
@pytest.mark.asyncio
async def test_create_issue(self, session):
auth = AuthService(session)
user, _ = await auth.register("[email protected]", "pass")
ws_svc = WorkspaceService(session)
ws = await ws_svc.create("IssueWS", user.id)
svc = IssueService(session)
issue = await svc.create(ws.id, "Bug report", user.id)
assert issue.title == "Bug report"
assert issue.status == "backlog"
assert issue.priority == "none"
assert issue.creator_id == user.id
@pytest.mark.asyncio
async def test_create_issue_with_agent_creator(self, session):
auth = AuthService(session)
user, _ = await auth.register("[email protected]", "pass")
ws_svc = WorkspaceService(session)
ws = await ws_svc.create("AgentIssueWS", user.id)
svc = IssueService(session)
issue = await svc.create(ws.id, "Agent Task", "agent-1", creator_type="agent")
assert issue.creator_type == "agent"
assert issue.creator_id == "agent-1"
@pytest.mark.asyncio
async def test_list_issues_with_filters(self, session):
auth = AuthService(session)
user, _ = await auth.register("[email protected]", "pass")
ws_svc = WorkspaceService(session)
ws = await ws_svc.create("FilterWS", user.id)
svc = IssueService(session)
await svc.create(ws.id, "Todo1", user.id, status="todo")
await svc.create(ws.id, "Done1", user.id, status="done")
todo_issues = await svc.list_for_workspace(ws.id, status="todo")
assert len(todo_issues) == 1
assert todo_issues[0].title == "Todo1"
@pytest.mark.asyncio
async def test_assign_to_agent(self, session):
auth = AuthService(session)
user, _ = await auth.register("[email protected]", "pass")
ws_svc = WorkspaceService(session)
ws = await ws_svc.create("AssignWS", user.id)
svc = IssueService(session)
issue = await svc.create(ws.id, "To Assign", user.id)
assigned = await svc.assign(issue.id, "agent", "agent-42")
assert assigned.assignee_type == "agent"
assert assigned.assignee_id == "agent-42"
@pytest.mark.asyncio
async def test_invalid_assignee_type(self, session):
svc = IssueService(session)
with pytest.raises(ValueError, match="Invalid assignee_type"):
await svc.assign("issue-1", "robot", "r-1")
@pytest.mark.asyncio
async def test_transition(self, session):
auth = AuthService(session)
user, _ = await auth.register("[email protected]", "pass")
ws_svc = WorkspaceService(session)
ws = await ws_svc.create("TransWS", user.id)
svc = IssueService(session)
issue = await svc.create(ws.id, "Transition", user.id)
assert issue.status == "backlog"
updated = await svc.transition(issue.id, "in_progress")
assert updated.status == "in_progress"
@pytest.mark.asyncio
async def test_invalid_status(self, session):
auth = AuthService(session)
user, _ = await auth.register("[email protected]", "pass")
ws_svc = WorkspaceService(session)
ws = await ws_svc.create("BadStatWS", user.id)
svc = IssueService(session)
issue = await svc.create(ws.id, "BadStat", user.id)
with pytest.raises(ValueError, match="Invalid status"):
await svc.update(issue.id, status="invalid_status")
@pytest.mark.asyncio
async def test_sub_issues(self, session):
auth = AuthService(session)
user, _ = await auth.register("[email protected]", "pass")
ws_svc = WorkspaceService(session)
ws = await ws_svc.create("SubWS", user.id)
svc = IssueService(session)
parent = await svc.create(ws.id, "Parent Issue", user.id)
child = await svc.create(ws.id, "Child Issue", user.id, parent_issue_id=parent.id)
assert child.parent_issue_id == parent.id
sub_issues = await svc.list_sub_issues(parent.id)
assert len(sub_issues) == 1
assert sub_issues[0].id == child.id
@pytest.mark.asyncio
async def test_delete_issue(self, session):
auth = AuthService(session)
user, _ = await auth.register("[email protected]", "pass")
ws_svc = WorkspaceService(session)
ws = await ws_svc.create("DelIssueWS", user.id)
svc = IssueService(session)
issue = await svc.create(ws.id, "ToDelete", user.id)
assert await svc.delete(issue.id) is True
assert await svc.get(issue.id) is None
# ── Comment Service ──────────────────────────────────────────────────────────
class TestCommentService:
@pytest.mark.asyncio
async def test_create_comment(self, session):
auth = AuthService(session)
user, _ = await auth.register("[email protected]", "pass")
ws_svc = WorkspaceService(session)
ws = await ws_svc.create("CommentWS", user.id)
issue_svc = IssueService(session)
issue = await issue_svc.create(ws.id, "Commentable", user.id)
svc = CommentService(session)
comment = await svc.create(issue.id, user.id, "This is a comment")
assert comment.content == "This is a comment"
assert comment.author_type == "member"
@pytest.mark.asyncio
async def test_agent_comment(self, session):
auth = AuthService(session)
user, _ = await auth.register("[email protected]", "pass")
ws_svc = WorkspaceService(session)
ws = await ws_svc.create("AgentCommentWS", user.id)
issue_svc = IssueService(session)
issue = await issue_svc.create(ws.id, "AgentComment", user.id)
svc = CommentService(session)
comment = await svc.create(issue.id, "agent-1", "Agent progress update", "agent", "progress_update")
assert comment.author_type == "agent"
assert comment.type == "progress_update"
@pytest.mark.asyncio
async def test_list_comments(self, session):
auth = AuthService(session)
user, _ = await auth.register("[email protected]", "pass")
ws_svc = WorkspaceService(session)
ws = await ws_svc.create("ListCommentWS", user.id)
issue_svc = IssueService(session)
issue = await issue_svc.create(ws.id, "WithComments", user.id)
svc = CommentService(session)
await svc.create(issue.id, user.id, "First")
await svc.create(issue.id, user.id, "Second")
comments = await svc.list_for_issue(issue.id)
assert len(comments) == 2
@pytest.mark.asyncio
async def test_update_comment(self, session):
auth = AuthService(session)
user, _ = await auth.register("[email protected]", "pass")
ws_svc = WorkspaceService(session)
ws = await ws_svc.create("UpdCommentWS", user.id)
issue_svc = IssueService(session)
issue = await issue_svc.create(ws.id, "UpdComment", user.id)
svc = CommentService(session)
comment = await svc.create(issue.id, user.id, "Original")
updated = await svc.update(comment.id, "Updated content")
assert updated.content == "Updated content"
@pytest.mark.asyncio
async def test_delete_comment(self, session):
auth = AuthService(session)
user, _ = await auth.register("[email protected]", "pass")
ws_svc = WorkspaceService(session)
ws = await ws_svc.create("DelCommentWS", user.id)
issue_svc = IssueService(session)
issue = await issue_svc.create(ws.id, "DelComment", user.id)
svc = CommentService(session)
comment = await svc.create(issue.id, user.id, "ToDelete")
assert await svc.delete(comment.id) is True
assert await svc.get(comment.id) is None
# ── Activity Service ─────────────────────────────────────────────────────────
class TestActivityService:
@pytest.mark.asyncio
async def test_log_activity(self, session):
auth = AuthService(session)
user, _ = await auth.register("[email protected]", "pass")
ws_svc = WorkspaceService(session)
ws = await ws_svc.create("ActivityWS", user.id)
svc = ActivityService(session)
entry = await svc.log(ws.id, "issue.created", "member", user.id, details={"title": "Bug"})
assert entry.action == "issue.created"
assert entry.details["title"] == "Bug"
@pytest.mark.asyncio
async def test_list_for_workspace(self, session):
auth = AuthService(session)
user, _ = await auth.register("[email protected]", "pass")
ws_svc = WorkspaceService(session)
ws = await ws_svc.create("ActivityListWS", user.id)
svc = ActivityService(session)
await svc.log(ws.id, "workspace.created", "member", user.id)
await svc.log(ws.id, "member.added", "member", user.id)
activities = await svc.list_for_workspace(ws.id)
assert len(activities) >= 2
@pytest.mark.asyncio
async def test_list_for_issue(self, session):
auth = AuthService(session)
user, _ = await auth.register("[email protected]", "pass")
ws_svc = WorkspaceService(session)
ws = await ws_svc.create("ActivityIssueWS", user.id)
issue_svc = IssueService(session)
issue = await issue_svc.create(ws.id, "Tracked", user.id)
svc = ActivityService(session)
await svc.log(ws.id, "issue.created", "member", user.id, issue_id=issue.id)
activities = await svc.list_for_issue(issue.id)
assert len(activities) == 1
+2013
View File
File diff suppressed because it is too large. Load diff
+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.4.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.4.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.4 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.4"
+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.4",
"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.4"
source = { directory = "../praisonai-agents" }
dependencies = [
{ name = "aiohttp" },