Compare commits

...
5 Commits
Author SHA1 Message Date
Mervin Praison 7a36352196 Release v4.5.39 2026-03-11 00:23:03 +00:00
Mervin Praison 6ae4ebedcb Merge branch 'main' of https://github.com/MervinPraison/PraisonAI 2026-03-11 00:20:17 +00:00
Mervin Praison 3377347087 feat(scheduler): add session ID to DeliveryTarget and scheduling commands
Added an optional session_id parameter to DeliveryTarget and scheduling
functions to preserve conversation context during job execution. This
enhancement allows agents to access prior chat history and improves
overall task management flexibility. Updated relevant documentation and
CLI commands accordingly.
2026-03-11 00:19:32 +00:00
github-actions[bot] f47aa248ea docs: auto-update api.md [skip ci] 2026-03-10 18:00:30 +00:00
Mervin Praison 0379e9f196 Release v4.5.38 2026-03-10 18:00:13 +00:00
20 changed files with 770 additions and 54 deletions

No files matched your search

+4 -1
View File
@@ -381,7 +381,7 @@ from praisonaiagents import GuardrailAction, GuardrailConfig, LLMGuardrail
Methods:
* <code title="class GuardrailConfig">GuardrailConfig.<a href="./src/praisonai-agents/praisonaiagents/config/feature_configs.py">to_dict</a>() -> Dict[str, Any]</code>
* <code title="class LLMGuardrail">LLMGuardrail.<a href="./src/praisonai-agents/praisonaiagents/guardrails/llm_guardrail.py">__call__</a>(task_output: TaskOutput) -> Tuple[bool, Union[str, TaskOutput]]</code>
* <code title="class LLMGuardrail">LLMGuardrail.<a href="./src/praisonai-agents/praisonaiagents/guardrails/llm_guardrail.py">__call__</a>(task_output) -> Tuple[bool, Union[str, 'TaskOutput']]</code>
# Planning
@@ -868,7 +868,9 @@ Methods:
* <code title="cli">praisonai examples run <a href="./src/praisonai/praisonai/cli/commands/examples.py">--help</a></code>
* <code title="cli">praisonai examples run-all <a href="./src/praisonai/praisonai/cli/commands/examples.py">--help</a></code>
* <code title="cli">praisonai examples stats <a href="./src/praisonai/praisonai/cli/commands/examples.py">--help</a></code>
* <code title="cli">praisonai gateway channels <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai gateway gateway-callback <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai gateway send <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai gateway start <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai gateway status <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai hooks add <a href="./src/praisonai/praisonai/cli/commands/hooks.py">--help</a></code>
@@ -964,6 +966,7 @@ Methods:
* <code title="cli">praisonai sandbox recreate <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
* <code title="cli">praisonai sandbox sandbox-callback <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
* <code title="cli">praisonai sandbox status <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
* <code title="cli">praisonai schedule add <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule delete <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule describe <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule list <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></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.5.37" \
"praisonai>=4.5.39" \
"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.5.37" \
"praisonai>=4.5.39" \
"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.5.37" \
"praisonai>=4.5.39" \
"praisonai[ui]" \
"praisonai[crewai]"
@@ -67,16 +67,22 @@ class LLMGuardrail:
self.logger.warning(f"Unknown LLM type: {type(llm)}, treating as-is")
return llm
def __call__(self, task_output: TaskOutput) -> Tuple[bool, Union[str, TaskOutput]]:
def __call__(self, task_output) -> Tuple[bool, Union[str, "TaskOutput"]]:
"""Validate the task output using the LLM.
Args:
task_output: The task output to validate
task_output: The task output to validate (TaskOutput or plain str)
Returns:
Tuple of (success, result) where result is the output or error message
"""
try:
# Accept plain str for convenience (e.g. chat-input screening)
if isinstance(task_output, str):
raw_text = task_output
else:
raw_text = task_output.raw
if not self.llm:
self.logger.warning("No LLM provided for guardrail validation")
return True, task_output
@@ -88,7 +94,7 @@ You are a quality assurance validator. Your task is to evaluate the following ou
Validation Criteria: {self.description}
Output to Validate:
{task_output.raw}
{raw_text}
Please evaluate if this output meets the criteria. Respond with:
1. "PASS" if the output meets all criteria
@@ -20,7 +20,7 @@ Default storage: ~/.praisonai/schedules/jobs.json
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .models import Schedule, ScheduleJob
from .models import Schedule, ScheduleJob, DeliveryTarget
from .store import FileScheduleStore
from .parser import parse_schedule
from .runner import ScheduleRunner
@@ -33,10 +33,11 @@ def __getattr__(name: str):
if name in _module_cache:
return _module_cache[name]
if name in ("Schedule", "ScheduleJob"):
from .models import Schedule, ScheduleJob
if name in ("Schedule", "ScheduleJob", "DeliveryTarget"):
from .models import Schedule, ScheduleJob, DeliveryTarget
_module_cache["Schedule"] = Schedule
_module_cache["ScheduleJob"] = ScheduleJob
_module_cache["DeliveryTarget"] = DeliveryTarget
return _module_cache[name]
if name == "FileScheduleStore":
@@ -60,6 +61,7 @@ def __getattr__(name: str):
__all__ = [
"Schedule",
"ScheduleJob",
"DeliveryTarget",
"FileScheduleStore",
"parse_schedule",
"ScheduleRunner",
@@ -51,6 +51,45 @@ class Schedule:
)
@dataclass
class DeliveryTarget:
"""Where to deliver the result when a scheduled job fires.
Attributes:
channel: Platform name (``"telegram"``, ``"discord"``,
``"slack"``, ``"whatsapp"``).
channel_id: Platform-specific chat / channel / group ID.
thread_id: Optional thread ID for threaded delivery.
session_id: Optional session ID to preserve conversation
context when the cron fires.
"""
channel: str = ""
channel_id: str = ""
thread_id: Optional[str] = None
session_id: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
d: Dict[str, Any] = {
"channel": self.channel,
"channel_id": self.channel_id,
}
if self.thread_id is not None:
d["thread_id"] = self.thread_id
if self.session_id is not None:
d["session_id"] = self.session_id
return d
@classmethod
def from_dict(cls, d: Dict[str, Any]) -> "DeliveryTarget":
return cls(
channel=d.get("channel", ""),
channel_id=d.get("channel_id", ""),
thread_id=d.get("thread_id"),
session_id=d.get("session_id"),
)
@dataclass
class ScheduleJob:
"""A persisted scheduled job.
@@ -67,6 +106,8 @@ class ScheduleJob:
delete_after_run: Auto-remove after first execution (one-shot).
created_at: Epoch timestamp.
last_run_at: Epoch timestamp of most recent execution.
delivery: Optional delivery target for routing results back
to a channel bot (e.g. Telegram chat).
"""
name: str = ""
@@ -79,11 +120,12 @@ class ScheduleJob:
delete_after_run: bool = False
created_at: float = field(default_factory=time.time)
last_run_at: Optional[float] = None
delivery: Optional[DeliveryTarget] = None
# ── serialisation ────────────────────────────────────────────────
def to_dict(self) -> Dict[str, Any]:
return {
d: Dict[str, Any] = {
"id": self.id,
"name": self.name,
"schedule": self.schedule.to_dict(),
@@ -95,10 +137,14 @@ class ScheduleJob:
"created_at": self.created_at,
"last_run_at": self.last_run_at,
}
if self.delivery is not None:
d["delivery"] = self.delivery.to_dict()
return d
@classmethod
def from_dict(cls, d: Dict[str, Any]) -> "ScheduleJob":
sched_data = d.get("schedule", {})
delivery_data = d.get("delivery")
return cls(
id=d.get("id", uuid.uuid4().hex[:12]),
name=d.get("name", ""),
@@ -110,4 +156,5 @@ class ScheduleJob:
delete_after_run=d.get("delete_after_run", False),
created_at=d.get("created_at", time.time()),
last_run_at=d.get("last_run_at"),
delivery=DeliveryTarget.from_dict(delivery_data) if isinstance(delivery_data, dict) else None,
)
@@ -41,6 +41,10 @@ def schedule_add(
name: str,
schedule: str,
message: str = "",
channel: str = "",
channel_id: str = "",
agent_id: str = "",
session_id: str = "",
) -> str:
"""Add a new scheduled job.
@@ -53,16 +57,39 @@ def schedule_add(
- "at:2026-03-01T09:00:00" (one-shot ISO timestamp)
- "in 20 minutes" (relative one-shot)
message: The prompt or reminder text to deliver when triggered.
channel: Delivery platform ("telegram", "discord", "slack",
"whatsapp"). If set with channel_id, the result will
be sent back to that chat when the job fires.
channel_id: Target chat/channel/group ID on the platform.
agent_id: Which agent should execute this job. If empty, the
executor uses its default agent.
session_id: Optional session ID to preserve conversation context.
If set, the agent will have access to prior chat history.
Returns:
Confirmation string with the job id.
"""
try:
from ..scheduler.parser import parse_schedule
from ..scheduler.models import ScheduleJob
from ..scheduler.models import ScheduleJob, DeliveryTarget
sched = parse_schedule(schedule)
job = ScheduleJob(name=name, schedule=sched, message=message)
delivery = None
if channel and channel_id:
delivery = DeliveryTarget(
channel=channel,
channel_id=channel_id,
session_id=session_id or None,
)
job = ScheduleJob(
name=name,
schedule=sched,
message=message,
agent_id=agent_id or None,
delivery=delivery,
)
store = _get_store()
@@ -72,7 +99,9 @@ def schedule_add(
return f"A schedule named '{name}' already exists (id: {existing.id}). Remove it first or choose a different name."
store.add(job)
return f"Schedule '{name}' added (id: {job.id}, {schedule})."
delivery_note = f" → deliver to {channel}:{channel_id}" if delivery else ""
agent_note = f" agent={agent_id}" if agent_id else ""
return f"Schedule '{name}' added (id: {job.id}, {schedule}{agent_note}{delivery_note})."
except ValueError as e:
return f"Error adding schedule: {e}"
except Exception as e:
@@ -105,10 +134,15 @@ def schedule_list() -> str:
sched_str = f"at: {sched.at}"
else:
sched_str = str(sched.kind)
lines.append(
f" - {j.name} (id: {j.id}) [{status}] — {sched_str}"
+ (f" — \"{j.message}\"" if j.message else "")
)
parts = [f" - {j.name} (id: {j.id}) [{status}] — {sched_str}"]
if j.agent_id:
parts.append(f" agent={j.agent_id}")
if j.delivery:
parts.append(f" → {j.delivery.channel}:{j.delivery.channel_id}")
if j.message:
parts.append(f' — "{j.message}"')
lines.append("".join(parts))
return "\n".join(lines)
except Exception as e:
logger.error("schedule_list failed: %s", e, exc_info=True)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.5.37"
version = "1.5.39"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
+1 -1
View File
@@ -2564,7 +2564,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.5.37"
version = "1.5.39"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+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.5.37.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.37.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.39.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.39.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
+152 -3
View File
@@ -51,6 +51,149 @@ def gateway_status(
handler.status(host=host, port=port)
@app.command("channels")
def gateway_channels(
config: str = typer.Option("gateway.yaml", "--config", "-c", help="Path to gateway.yaml"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""List channels configured in a gateway.yaml file.
Examples:
praisonai gateway channels
praisonai gateway channels --config my-gateway.yaml --json
"""
import os
import yaml
if not os.path.exists(config):
print(f"Error: Config file not found: {config}")
raise typer.Exit(1)
with open(config) as f:
cfg = yaml.safe_load(f) or {}
channels = cfg.get("channels", {})
if not channels:
print("No channels configured.")
raise typer.Exit(0)
if json_output:
import json
print(json.dumps(channels, indent=2))
raise typer.Exit(0)
try:
from rich.table import Table
from rich.console import Console
console = Console()
table = Table(title="Configured Channels")
table.add_column("Name", style="cyan")
table.add_column("Platform", style="green")
table.add_column("Token", style="yellow")
table.add_column("Config Keys", style="dim")
for name, ch_cfg in channels.items():
platform = ch_cfg.get("platform", "unknown")
token_val = ch_cfg.get("token", "")
has_token = "✅ set" if token_val else "❌ missing"
keys = ", ".join(
k for k in ch_cfg.keys() if k not in ("platform", "token")
)
table.add_row(name, platform, has_token, keys or "—")
console.print(table)
except ImportError:
print(f"{'Name':<20} {'Platform':<12} {'Token':<12}")
print("-" * 44)
for name, ch_cfg in channels.items():
platform = ch_cfg.get("platform", "unknown")
has_token = "set" if ch_cfg.get("token") else "missing"
print(f"{name:<20} {platform:<12} {has_token:<12}")
@app.command("send")
def gateway_send(
config: str = typer.Option("gateway.yaml", "--config", "-c", help="Path to gateway.yaml"),
channel: str = typer.Option(..., "--channel", help="Channel name from config (e.g. 'telegram')"),
channel_id: str = typer.Option(..., "--channel-id", help="Target chat/channel ID"),
message: str = typer.Option(..., "--message", "-m", help="Message text to send"),
thread_id: Optional[str] = typer.Option(None, "--thread-id", help="Optional thread ID"),
):
"""Send a one-shot test message to a channel bot.
Instantiates the bot from gateway.yaml config, sends the message, then exits.
Useful for testing scheduled delivery targets.
Examples:
praisonai gateway send --config gateway.yaml --channel telegram --channel-id 12345 -m "Hello"
"""
import os
import asyncio
import yaml
if not os.path.exists(config):
print(f"Error: Config file not found: {config}")
raise typer.Exit(1)
with open(config) as f:
cfg = yaml.safe_load(f) or {}
channels_cfg = cfg.get("channels", {})
ch_cfg = channels_cfg.get(channel)
if not ch_cfg:
available = ", ".join(channels_cfg.keys()) if channels_cfg else "(none)"
print(f"Error: Channel '{channel}' not found in config. Available: {available}")
raise typer.Exit(1)
platform = ch_cfg.get("platform", channel)
token = ch_cfg.get("token", "")
# Resolve env vars in token
if token and token.startswith("${") and token.endswith("}"):
env_var = token[2:-1]
token = os.environ.get(env_var, "")
if not token:
print(f"Error: Environment variable {env_var} not set")
raise typer.Exit(1)
async def _send():
try:
from praisonai.gateway.server import WebSocketGateway
bot = WebSocketGateway._create_bot(channel, ch_cfg)
except Exception as e:
print(f"Error creating bot: {e}")
raise typer.Exit(1)
try:
await bot.start()
await asyncio.sleep(1) # Let bot initialise
result = await bot.send_message(
channel_id, message, thread_id=thread_id,
)
print(f"✅ Message sent to {channel}:{channel_id}")
if hasattr(result, "message_id"):
print(f" Message ID: {result.message_id}")
except Exception as e:
print(f"❌ Send failed: {e}")
raise typer.Exit(1)
finally:
try:
await bot.stop()
except Exception:
pass
try:
asyncio.run(_send())
except typer.Exit:
raise
except Exception as e:
print(f"Error: {e}")
raise typer.Exit(1)
@app.callback(invoke_without_command=True)
def gateway_callback(ctx: typer.Context):
"""Show gateway help if no subcommand provided."""
@@ -61,8 +204,10 @@ def gateway_callback(ctx: typer.Context):
Manage the gateway server: praisonai gateway <command>
[bold]Commands:[/bold]
[green]start[/green] Start the gateway server
[green]status[/green] Check gateway status
[green]start[/green] Start the gateway server
[green]status[/green] Check gateway status
[green]channels[/green] List channels from gateway.yaml
[green]send[/green] Send a test message to a channel
[bold]Multi-Bot Mode:[/bold]
praisonai gateway start --config gateway.yaml
@@ -73,11 +218,15 @@ Manage the gateway server: praisonai gateway <command>
[bold]Check Status:[/bold]
praisonai gateway status
[bold]Channel Management:[/bold]
praisonai gateway channels --config gateway.yaml
praisonai gateway send --config gateway.yaml --channel telegram --channel-id 12345 -m "test"
"""
try:
from rich import print as rprint
rprint(help_text)
except ImportError:
import re
plain = re.sub(r'\[/?[^\]]+\]', '', help_text)
plain = re.sub(r'\\[/?[^\\]]+\\]', '', help_text)
print(plain)
@@ -37,6 +37,51 @@ def _run_schedule(args: list) -> int:
return 4
@app.command("add")
def schedule_add_cmd(
name: str = typer.Argument(..., help="Schedule name (e.g. 'morning-hello')"),
schedule: str = typer.Option(..., "--schedule", "-s", help="When to run: 'hourly', 'daily', '*/30m', 'cron:0 9 * * *', 'at:2026-03-01T09:00', 'in 20 minutes'"),
message: str = typer.Option("", "--message", "-m", help="Prompt / reminder text"),
agent: str = typer.Option("", "--agent", "-a", help="Agent ID to execute this job (default: first registered agent)"),
channel: str = typer.Option("", "--channel", help="Delivery platform: telegram, discord, slack, whatsapp"),
channel_id: str = typer.Option("", "--channel-id", help="Target chat/channel ID on the platform"),
session_id: str = typer.Option("", "--session-id", help="Session ID to preserve conversation context"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""Add a job to the schedule store (with optional delivery target).
Examples:
praisonai schedule add "morning-hello" -s "cron:0 9 * * *" -m "say hello"
praisonai schedule add "tg-reminder" -s daily -m "check email" --agent support --channel telegram --channel-id 12345
"""
output = get_output_controller()
try:
from praisonaiagents.tools.schedule_tools import schedule_add as _schedule_add
result = _schedule_add(
name=name,
schedule=schedule,
message=message,
agent_id=agent,
channel=channel,
channel_id=channel_id,
session_id=session_id,
)
if json_output:
import json as _json
print(_json.dumps({"result": result}))
else:
if "Error" in result:
output.print_error(result)
raise typer.Exit(1)
else:
output.print_success(result)
except ImportError as e:
output.print_error(f"Schedule tools not available: {e}")
raise typer.Exit(4)
@app.command("start")
def schedule_start(
agents_file: str = typer.Argument("agents.yaml", help="Agents YAML file"),
+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.5.37 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==4.5.39 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
+179 -22
View File
@@ -202,6 +202,9 @@ class WebSocketGateway:
self._channel_bots: Dict[str, Any] = {} # channel_name -> bot instance
self._routing_rules: Dict[str, Dict[str, str]] = {} # channel_name -> {context -> agent_id}
self._channel_tasks: List[asyncio.Task] = []
# Scheduler tick background task
self._scheduler_task: Optional[asyncio.Task] = None
@property
def is_running(self) -> bool:
@@ -722,6 +725,91 @@ class WebSocketGateway:
"channels": channel_status,
}
# ── Scheduled delivery ────────────────────────────────────────────
async def _deliver_scheduled_result(
self, delivery: Any, text: str,
) -> None:
"""Route a scheduled job result to the correct channel bot.
Args:
delivery: A ``DeliveryTarget`` with ``channel`` and ``channel_id``.
text: The agent response to deliver.
"""
channel = getattr(delivery, "channel", "") or ""
channel_id = getattr(delivery, "channel_id", "") or ""
thread_id = getattr(delivery, "thread_id", None)
if not channel or not channel_id:
logger.warning("Delivery target missing channel or channel_id, skipping")
return
bot = self.get_channel_bot(channel)
if bot is None:
# Try case-insensitive lookup
for name, b in self._channel_bots.items():
if name.lower() == channel.lower():
bot = b
break
if bot is None:
logger.warning(
"No channel bot '%s' found for scheduled delivery", channel,
)
return
try:
await bot.send_message(
channel_id, text, thread_id=thread_id,
)
logger.info(
"Delivered scheduled result to %s:%s", channel, channel_id,
)
except Exception as e:
logger.error(
"Failed to deliver to %s:%s: %s", channel, channel_id, e,
)
def _start_scheduler_tick(self, interval: float = 15.0) -> None:
"""Start a background task that polls the scheduler for due jobs.
Creates a ``ScheduledAgentExecutor`` wired to:
- a ``ScheduleRunner`` with a ``FileScheduleStore``
- this gateway's agent registry for resolution
- ``_deliver_scheduled_result`` for outbound delivery
"""
async def _run():
try:
from praisonaiagents.scheduler import (
ScheduleRunner,
FileScheduleStore,
)
from praisonai.scheduler.executor import ScheduledAgentExecutor
except ImportError as e:
logger.warning(
"Scheduler dependencies not available, skipping tick: %s", e,
)
return
store = FileScheduleStore()
runner = ScheduleRunner(store)
def _resolve_agent(agent_id):
if agent_id:
return self._agents.get(agent_id)
# Fallback: first registered agent
return next(iter(self._agents.values()), None) if self._agents else None
executor = ScheduledAgentExecutor(
runner=runner,
agent_resolver=_resolve_agent,
delivery_handler=self._deliver_scheduled_result,
)
await executor.run_loop(interval=interval)
self._scheduler_task = asyncio.create_task(_run())
logger.info("Scheduler tick started (interval=15s)")
# ── Multi-bot lifecycle ───────────────────────────────────────────
@staticmethod
@@ -782,13 +870,15 @@ class WebSocketGateway:
if not isinstance(cdef, dict):
errors.append(f"Channel '{cname}' must be a dictionary")
continue
if cname.lower() not in valid_platforms:
# Resolve platform: explicit field takes priority over key
platform = cdef.get("platform", cname).lower()
if platform not in valid_platforms:
logger.warning(
f"Channel '{cname}' is not a known platform "
f"({', '.join(valid_platforms)})"
f"Channel '{cname}' platform '{platform}' is not "
f"a known platform ({', '.join(sorted(valid_platforms))})"
)
# WhatsApp web mode doesn't require a token
is_wa_web = (cname.lower() == "whatsapp" and
is_wa_web = (platform == "whatsapp" and
cdef.get("mode", "cloud").lower().strip() == "web")
if not cdef.get("token") and not is_wa_web:
errors.append(
@@ -814,9 +904,15 @@ class WebSocketGateway:
return _resolve(raw)
def _create_agents_from_config(self, agents_cfg: Dict[str, Dict[str, Any]]) -> None:
"""Create and register Agent instances from the agents section of gateway.yaml.
def _create_agents_from_config(
self,
agents_cfg: Dict[str, Dict[str, Any]],
*,
default_model: Optional[str] = None,
guardrails_cfg: Optional[Dict[str, Any]] = None,
) -> None:
"""Create and register Agent instances from the agents section of config.
Supports all agent configuration options including:
- tools: List of tool names to resolve via ToolResolver
- tool_choice: Tool selection mode ('auto', 'required', 'none')
@@ -825,10 +921,19 @@ class WebSocketGateway:
- role: Agent role (CrewAI-style)
- goal: Agent goal (CrewAI-style)
- backstory: Agent backstory (CrewAI-style)
- temperature: LLM temperature (optional, uses SDK default)
- base_url: Custom LLM endpoint URL (optional)
- api_key: API key for LLM provider (optional)
Args:
agents_cfg: The ``agents`` section of the config.
default_model: Fallback model when an agent has no ``model`` key
(e.g. from the ``provider.model`` section of UI config).
guardrails_cfg: Optional ``guardrails.registry`` dict to wire per-agent.
"""
from praisonaiagents import Agent
# G1: Resolve tool names to callables (same pattern as agents_generator)
# Resolve tool names to callables (same pattern as agents_generator)
tool_resolver = None
try:
from praisonai.tool_resolver import ToolResolver
@@ -838,15 +943,26 @@ class WebSocketGateway:
for agent_id, agent_def in agents_cfg.items():
instructions = agent_def.get("instructions", "")
model = agent_def.get("model", None)
# G7: Apply provider.model as fallback when agent has no model
model = agent_def.get("model", None) or default_model
memory = agent_def.get("memory", False)
# G4: Support role/goal/backstory for CrewAI-style agents
# G2: Pass temperature through (optional, SDK uses its own default)
temperature = agent_def.get("temperature", None)
# G3/G4: Pass base_url and api_key for multi-provider support
base_url = agent_def.get("base_url", None)
api_key = agent_def.get("api_key", None)
# Support role/goal/backstory for CrewAI-style agents
role = agent_def.get("role", None)
goal = agent_def.get("goal", None)
backstory = agent_def.get("backstory", None)
# G1: Resolve tools from YAML config
# G6: Fall back to system_prompt if instructions is empty
if not instructions:
instructions = agent_def.get("system_prompt", "") or ""
# Resolve tools from YAML config
agent_tools = []
yaml_tool_names = agent_def.get("tools", [])
if yaml_tool_names and tool_resolver:
@@ -860,16 +976,32 @@ class WebSocketGateway:
logger.debug(f"Resolved tool '{tool_name}' for agent '{agent_id}'")
else:
logger.warning(f"Tool '{tool_name}' not found for agent '{agent_id}'")
# Additional agent options from YAML
tool_choice = agent_def.get("tool_choice", None)
reflection = agent_def.get("reflection", True) # Default: enable reflection/interactive mode
reflection = agent_def.get("reflection", True)
allow_delegation = agent_def.get("allow_delegation", False)
# G5: Wire guardrails if config has a matching agent_name or global rule
guardrail_prompt = None
if guardrails_cfg:
for _gr_id, gr_def in guardrails_cfg.items():
gr_agent = gr_def.get("agent_name", "")
if not gr_agent or gr_agent == agent_id:
guardrail_prompt = gr_def.get("description", None)
break # first match wins
# Compose model as dict when temperature is specified
# (Agent accepts temperature via dict model config)
model_cfg = model
if temperature is not None and model and isinstance(model, str):
model_cfg = {"model": model, "temperature": temperature}
# Use model= (preferred) instead of deprecated llm=
agent = Agent(
name=agent_id,
instructions=instructions,
llm=model,
model=model_cfg,
memory=memory,
tools=agent_tools if agent_tools else None,
reflection=reflection,
@@ -877,14 +1009,21 @@ class WebSocketGateway:
role=role,
goal=goal,
backstory=backstory,
base_url=base_url,
api_key=api_key,
guardrails=guardrail_prompt,
)
# Store tool_choice for later use in chat()
if tool_choice:
agent._yaml_tool_choice = tool_choice
self.register_agent(agent, agent_id=agent_id)
logger.info(f"Created agent '{agent_id}' (model={model}, tools={len(agent_tools)}, reflection={reflection})")
logger.info(
f"Created agent '{agent_id}' "
f"(model={model}, tools={len(agent_tools)}, "
f"reflection={reflection})"
)
def _determine_routing_context(
self, channel_type: str, message_metadata: Dict[str, Any]
@@ -934,7 +1073,7 @@ class WebSocketGateway:
from praisonaiagents.bots import BotConfig
for channel_name, ch_cfg in channels_cfg.items():
channel_type = channel_name.lower()
channel_type = ch_cfg.get("platform", channel_name).lower()
token = ch_cfg.get("token", "")
# WhatsApp web mode doesn't require a token
wa_web_mode = (channel_type == "whatsapp" and
@@ -1222,9 +1361,16 @@ class WebSocketGateway:
# Recreate agents
agents_cfg = cfg.get("agents", {})
provider_cfg = cfg.get("provider", {})
default_model = provider_cfg.get("model") if provider_cfg else None
guardrails_cfg = (cfg.get("guardrails") or {}).get("registry")
if agents_cfg:
self._agents.clear()
self._create_agents_from_config(agents_cfg)
self._create_agents_from_config(
agents_cfg,
default_model=default_model,
guardrails_cfg=guardrails_cfg,
)
# Restart channels
channels_cfg = cfg.get("channels", {})
@@ -1273,8 +1419,15 @@ class WebSocketGateway:
# Create agents
agents_cfg = cfg.get("agents", {})
provider_cfg = cfg.get("provider", {})
default_model = provider_cfg.get("model") if provider_cfg else None
guardrails_cfg = (cfg.get("guardrails") or {}).get("registry")
if agents_cfg:
self._create_agents_from_config(agents_cfg)
self._create_agents_from_config(
agents_cfg,
default_model=default_model,
guardrails_cfg=guardrails_cfg,
)
# Start channels + WebSocket server concurrently
channels_cfg = cfg.get("channels", {})
@@ -1289,6 +1442,8 @@ class WebSocketGateway:
self._config_watch_task = asyncio.create_task(
self._watch_config(config_path)
)
# Launch scheduler tick to poll for due jobs
self._start_scheduler_tick()
await self.start()
# Register signal handlers for graceful shutdown using
@@ -1316,4 +1471,6 @@ class WebSocketGateway:
finally:
if self._config_watch_task:
self._config_watch_task.cancel()
if self._scheduler_task:
self._scheduler_task.cancel()
await self.stop_channels()
@@ -17,6 +17,8 @@ __all__ = [
'ScheduleParser',
'ExecutorInterface',
'PraisonAgentExecutor',
'ScheduledAgentExecutor',
'JobResult',
]
# Lazy imports for modules that will be created later
@@ -42,4 +44,7 @@ def __getattr__(name):
elif name == 'create_deployment_scheduler':
from .agent_scheduler import create_agent_scheduler
return create_agent_scheduler
elif name in ('ScheduledAgentExecutor', 'JobResult'):
from .executor import ScheduledAgentExecutor, JobResult
return ScheduledAgentExecutor if name == 'ScheduledAgentExecutor' else JobResult
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,268 @@
"""
ScheduledAgentExecutor — bridges ScheduleRunner + agent resolution.
This is a thin reusable bridge that any consumer (UI, CLI, custom app)
can use to poll the scheduler and dispatch due jobs to agents, without
reimplementing the same polling + agent-lookup logic each time.
Lives in the **wrapper** layer (not the SDK core) because it composes
two independent core modules: gateway (agent registry) and scheduler
(due-job detection).
Usage::
from praisonaiagents.scheduler import ScheduleRunner, FileScheduleStore
from praisonai.scheduler.executor import ScheduledAgentExecutor
store = FileScheduleStore()
runner = ScheduleRunner(store)
executor = ScheduledAgentExecutor(
runner=runner,
agent_resolver=lambda agent_id: my_gateway.get_agent(agent_id),
)
# In your async event loop:
async for job, result in executor.tick():
print(f"Job {job.name}: {result}")
"""
from __future__ import annotations
import asyncio
import logging
import time
from dataclasses import dataclass, field
from typing import (
Any,
AsyncIterator,
Callable,
Dict,
List,
Optional,
Tuple,
TYPE_CHECKING,
)
if TYPE_CHECKING:
from praisonaiagents.scheduler import ScheduleRunner, ScheduleJob
logger = logging.getLogger(__name__)
@dataclass
class JobResult:
"""Result of a single scheduled job execution.
Attributes:
job: The ScheduleJob that was executed.
result: Agent response (str) or None on failure.
status: ``"succeeded"``, ``"failed"``, or ``"skipped"``.
error: Error message if status is ``"failed"``.
duration: Wall-clock seconds for agent execution.
delivered: Whether the result was delivered to a channel bot.
"""
job: Any # ScheduleJob
result: Optional[str] = None
status: str = "succeeded"
error: Optional[str] = None
duration: float = 0.0
delivered: bool = False
class ScheduledAgentExecutor:
"""Bridges ScheduleRunner + agent resolution for any consumer.
This is a **stateless** helper — it does not own a loop or thread.
Callers decide how and when to call :meth:`tick` or :meth:`run_loop`.
Parameters:
runner: An SDK ``ScheduleRunner`` instance.
agent_resolver: A callable ``(agent_id: str | None) -> Agent``
that returns an agent for the given ID. May return ``None``
if no agent is available.
delivery_handler: Optional async callable
``(delivery: DeliveryTarget, text: str) -> None``.
Called after successful execution when the job has a
delivery target. Typically routes to a channel bot's
``send_message()``.
on_success: Optional callback ``(job, result) -> None``.
on_failure: Optional callback ``(job, error) -> None``.
"""
def __init__(
self,
runner: "ScheduleRunner",
agent_resolver: Callable[[Optional[str]], Any],
*,
delivery_handler: Optional[Callable[..., Any]] = None,
on_success: Optional[Callable[..., None]] = None,
on_failure: Optional[Callable[..., None]] = None,
) -> None:
self._runner = runner
self._resolve = agent_resolver
self._deliver = delivery_handler
self._on_success = on_success
self._on_failure = on_failure
# ── public API ───────────────────────────────────────────────────
async def tick(self) -> AsyncIterator[JobResult]:
"""Check for due jobs and execute them, yielding results.
Each due job is dispatched to the resolved agent via
``agent.chat(job.message)`` in a background thread (so sync
agents work correctly in an async context).
Yields:
:class:`JobResult` for each due job.
"""
due_jobs: List["ScheduleJob"] = self._runner.get_due_jobs()
for job in due_jobs:
result = await self._execute_one(job)
yield result
async def tick_all(self) -> List[JobResult]:
"""Like :meth:`tick` but collects all results into a list."""
results: List[JobResult] = []
async for r in self.tick():
results.append(r)
return results
async def run_loop(
self,
interval: float = 15.0,
*,
max_ticks: Optional[int] = None,
) -> None:
"""Convenience loop that calls :meth:`tick` at a fixed interval.
Args:
interval: Seconds between ticks (default 15).
max_ticks: Stop after this many ticks (``None`` = forever).
"""
tick_count = 0
logger.info(
"ScheduledAgentExecutor loop started (interval=%.1fs)", interval,
)
try:
while max_ticks is None or tick_count < max_ticks:
async for result in self.tick():
logger.info(
"Job '%s' %s (%.1fs)",
getattr(result.job, "name", result.job),
result.status,
result.duration,
)
tick_count += 1
await asyncio.sleep(interval)
except asyncio.CancelledError:
pass
finally:
logger.info("ScheduledAgentExecutor loop stopped after %d ticks", tick_count)
# ── internals ────────────────────────────────────────────────────
async def _execute_one(self, job: "ScheduleJob") -> JobResult:
"""Execute a single job and return the result."""
started = time.time()
message = getattr(job, "message", "") or ""
agent_id = getattr(job, "agent_id", None)
# Skip jobs with no message
if not message:
self._runner.mark_run(job)
return JobResult(
job=job,
status="skipped",
error="No message configured",
duration=time.time() - started,
)
# Resolve the agent
try:
agent = self._resolve(agent_id)
except Exception as e:
logger.warning("Agent resolution failed for job '%s': %s", job.id, e)
err = f"Agent resolution failed: {e}"
if self._on_failure:
self._on_failure(job, err)
return JobResult(
job=job, status="failed", error=err, duration=time.time() - started,
)
if agent is None:
err = f"No agent found for agent_id={agent_id!r}"
logger.warning("Skipping job '%s': %s", job.id, err)
if self._on_failure:
self._on_failure(job, err)
return JobResult(
job=job, status="failed", error=err, duration=time.time() - started,
)
# Execute via agent.chat() in a thread (sync-safe)
# Wire session_target: "main" preserves context, "isolated" is fresh
try:
session_target = getattr(job, "session_target", "isolated")
delivery = getattr(job, "delivery", None)
session_id = (
getattr(delivery, "session_id", None)
if delivery else None
)
# Build chat kwargs
chat_kwargs: Dict[str, Any] = {}
if session_target == "main" and session_id:
chat_kwargs["session_id"] = session_id
else:
# Isolated mode: use a cron-specific session
chat_kwargs["session_id"] = f"cron_{job.id}"
result = await asyncio.to_thread(
agent.chat, message, **chat_kwargs,
)
result_str = str(result)
except Exception as e:
logger.warning("Job '%s' execution failed: %s", job.id, e)
err = str(e)
if self._on_failure:
self._on_failure(job, err)
return JobResult(
job=job, status="failed", error=err, duration=time.time() - started,
)
# Success
self._runner.mark_run(job)
duration = time.time() - started
# Deliver to channel bot if delivery target exists
delivered = False
delivery = getattr(job, "delivery", None)
if delivery and self._deliver:
try:
coro = self._deliver(delivery, result_str)
if asyncio.iscoroutine(coro):
await coro
delivered = True
logger.info(
"Delivered job '%s' result to %s:%s",
job.id, delivery.channel, delivery.channel_id,
)
except Exception as e:
logger.warning(
"Delivery failed for job '%s' to %s:%s: %s",
job.id, delivery.channel, delivery.channel_id, e,
)
if self._on_success:
self._on_success(job, result_str)
return JobResult(
job=job,
result=result_str,
status="succeeded",
duration=duration,
delivered=delivered,
)
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.5.37"
__version__ = "4.5.39"
+1 -1
View File
@@ -12,7 +12,7 @@ dependencies = [
"rich>=13.7",
"markdown>=3.5",
"pyparsing>=3.0.0",
"praisonaiagents>=1.5.37",
"praisonaiagents>=1.5.39",
"python-dotenv>=0.19.0",
"litellm>=1.81.0",
"PyYAML>=6.0",
+4 -4
View File
@@ -4124,7 +4124,7 @@ requires-dist = [
{ name = "praisonai-tools", marker = "extra == 'autogen'", specifier = ">=0.1.0" },
{ name = "praisonai-tools", marker = "extra == 'autogen-v4'", specifier = ">=0.1.0" },
{ name = "praisonai-tools", marker = "extra == 'crewai'", specifier = ">=0.1.0" },
{ name = "praisonaiagents", specifier = ">=1.5.37" },
{ name = "praisonaiagents", specifier = ">=1.5.39" },
{ name = "pyautogen", marker = "extra == 'autogen'", specifier = "==0.2.29" },
{ name = "pyjwt", marker = "extra == 'os'", specifier = ">=2.8.0" },
{ name = "pyngrok", marker = "extra == 'call'", specifier = ">=1.4.0" },
@@ -4182,7 +4182,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.5.37"
version = "1.5.39"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
@@ -4191,9 +4191,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "rich" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ef/60/48a335bafc0c3831facaad3b6ef7ed4c5f13abde8d37694db42dfa88c8a8/praisonaiagents-1.5.37.tar.gz", hash = "sha256:43baec064344f559120a5a111da1ac7bfb2ec5c0d94352db98f5f8f96dc6dc8c", size = 1234037, upload-time = "2026-03-10T15:23:34.778Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a9/be/3171cb9b2c982380822408c4c43994e3e27c7cf3e6ced85bdeb8a37a6b21/praisonaiagents-1.5.39.tar.gz", hash = "sha256:578c61d2fc7c64de3e62724c95b7089526fca056a6b6397abf9ac2c8ff9ae8ca", size = 1235116, upload-time = "2026-03-11T00:19:26.686Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/f6/94b3d3d321494495df302f76c51ae42da547ca1716d7a88d4c05905509d1/praisonaiagents-1.5.37-py3-none-any.whl", hash = "sha256:b75cc29ac6517c1e7c46d6a08b4db1c63d3c28dd212aae46fdfe3dbe71537baa", size = 1234837, upload-time = "2026-03-10T15:23:32.966Z" },
{ url = "https://files.pythonhosted.org/packages/d3/52/da460fb48819f81aad48e7bc84cfb1749f6cf701f291611a1195f7c7ac10/praisonaiagents-1.5.39-py3-none-any.whl", hash = "sha256:063045dfc147a65f639750d81d2bec3b571d48d47d7921230d0ca8a35c88c84e", size = 1235755, upload-time = "2026-03-11T00:19:24.008Z" },
]
[[package]]