mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-07 17:33:33 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c77259eda7 | ||
|
|
e69e726ca6 | ||
|
|
3130ae4e19 | ||
|
|
c872be20ed | ||
|
|
4548783a40 | ||
|
|
5b1c39d496 | ||
|
|
1e3933f5f3 | ||
|
|
203ebe4960 | ||
|
|
e144d840bb | ||
|
|
47bff65413 | ||
|
|
66bd9ee2a9 | ||
|
|
1dd5d7e706 | ||
|
|
fee74fab53 | ||
|
|
003f9371a1 | ||
|
|
7234cd5682 | ||
|
|
4373fb3adb | ||
|
|
e87dd8e37a | ||
|
|
872cd87638 | ||
|
|
8ea0065f83 | ||
|
|
e922eb1e64 | ||
|
|
e364aa7519 | ||
|
|
e67a11db1f | ||
|
|
9071af74ea | ||
|
|
7821916c56 | ||
|
|
dd1b9a7f1f | ||
|
|
3b006be069 | ||
|
|
4d80c60ecb | ||
|
|
0cdfde2c04 | ||
|
|
98e2fcccf6 | ||
|
|
1a42ca92d3 | ||
|
|
c75d087591 | ||
|
|
2f6f496e5d | ||
|
|
42ab69c142 | ||
|
|
db62f4368c | ||
|
|
fd3daa27fa | ||
|
|
875da3dfe0 | ||
|
|
620e21b599 | ||
|
|
79ad6b621f | ||
|
|
cbe57c9d52 | ||
|
|
fa264e1314 | ||
|
|
e71e73cca4 | ||
|
|
9861520be6 | ||
|
|
710624bd69 | ||
|
|
a35a6690c4 | ||
|
|
1b9d569de8 | ||
|
|
956e4eeb91 | ||
|
|
654e1e2aec | ||
|
|
2870aa9861 | ||
|
|
7bec22ce77 | ||
|
|
520a46019a | ||
|
|
7ee1c642d4 | ||
|
|
397f499dda | ||
|
|
d4dac28434 | ||
|
|
dbca6e0047 | ||
|
|
ed3da59b2c | ||
|
|
55ab4acf8d | ||
|
|
e0c1301965 | ||
|
|
79c4002e3c | ||
|
|
2e5a8c5fe5 | ||
|
|
99c811da21 | ||
|
|
0fb773adcd | ||
|
|
d95d3854db | ||
|
|
1383db2fba | ||
|
|
75adcfed68 | ||
|
|
3793ef0387 | ||
|
|
bf3b7f8e9d | ||
|
|
a4da8c6ab6 | ||
|
|
f59ba1b83d | ||
|
|
8c88766900 | ||
|
|
a6eaa854fe | ||
|
|
e36911e712 | ||
|
|
c3a6a39ee1 | ||
|
|
15146ff6a7 | ||
|
|
0d0c2fa762 | ||
|
|
c9010fca9f | ||
|
|
7a36352196 | ||
|
|
6ae4ebedcb | ||
|
|
3377347087 | ||
|
|
f47aa248ea | ||
|
|
0379e9f196 | ||
|
|
c841d46ae9 | ||
|
|
cd6fdd0107 | ||
|
|
d840407b12 | ||
|
|
b99ca3f0ae | ||
|
|
23c48f6cd5 | ||
|
|
ad8ee02b5a | ||
|
|
02b9c64c0a |
No files matched your search
+3
-1
@@ -93,4 +93,6 @@ Cargo.lock
|
||||
**/*.rs.bk
|
||||
*.pdb
|
||||
.windsurf
|
||||
.agent
|
||||
.agent
|
||||
|
||||
src/praisonai/build
|
||||
@@ -55,7 +55,7 @@ Methods:
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">display_name</a>() -> str</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">ephemeral</a>()</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">execute</a>(task, context = None)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">execute_tool</a>(function_name, arguments)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">execute_tool</a>(function_name, arguments, tool_call_id = None)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">execute_tool_async</a>(function_name: str, arguments: Dict[str, Any]) -> Any</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">from_template</a>(uri: str, config: Optional[Dict[str, Any]] = None, offline: bool = False, **kwargs) -> 'Agent'</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">generate_task</a>() -> 'Task'</code>
|
||||
@@ -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
|
||||
|
||||
@@ -784,8 +784,10 @@ Methods:
|
||||
* <code title="cli">praisonai benchmark profile <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
|
||||
* <code title="cli">praisonai benchmark sdk <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
|
||||
* <code title="cli">praisonai benchmark workflow <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot agentmail <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot bot-callback <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot discord <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot email <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot slack <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot start <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot telegram <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
@@ -802,6 +804,7 @@ Methods:
|
||||
* <code title="cli">praisonai call call-main <a href="./src/praisonai/praisonai/cli/commands/call.py">--help</a></code>
|
||||
* <code title="cli">praisonai chat <a href="./src/praisonai/praisonai/cli/main.py">--help</a></code>
|
||||
* <code title="cli">praisonai chat chat-main <a href="./src/praisonai/praisonai/cli/commands/chat.py">--help</a></code>
|
||||
* <code title="cli">praisonai claw claw <a href="./src/praisonai/praisonai/cli/commands/claw.py">--help</a></code>
|
||||
* <code title="cli">praisonai code <a href="./src/praisonai/praisonai/cli/main.py">--help</a></code>
|
||||
* <code title="cli">praisonai code code-main <a href="./src/praisonai/praisonai/cli/commands/code.py">--help</a></code>
|
||||
* <code title="cli">praisonai commit commit-main <a href="./src/praisonai/praisonai/cli/commands/commit.py">--help</a></code>
|
||||
@@ -868,7 +871,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 +969,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>
|
||||
|
||||
@@ -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.33" \
|
||||
"praisonai>=4.5.73" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# PraisonAI 🦞 Claw — All-in-one: dashboard + bots + gateway + agents
|
||||
#
|
||||
# Build: docker build -f docker/Dockerfile.claw -t praisonai:claw ..
|
||||
# Run: docker run -p 8082:8082 -e OPENAI_API_KEY=sk-xxx praisonai:claw
|
||||
# Health: curl http://localhost:8082/health
|
||||
#
|
||||
# Override CMD for other modes:
|
||||
# Bot: docker run -e ... praisonai:claw praisonai bot telegram
|
||||
# Gateway: docker run -e ... praisonai:claw praisonai gateway start
|
||||
# Serve: docker run -e ... praisonai:claw praisonai serve agents.yaml
|
||||
# Shell: docker run --rm -it praisonai:claw bash
|
||||
|
||||
FROM python:3.12-slim AS base
|
||||
|
||||
# ── Environment ──────────────────────────────────────────────────────────────
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
AIUI_PORT=8082 \
|
||||
AIUI_HOST=0.0.0.0 \
|
||||
AIUI_DATA_DIR=/data
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# ── System dependencies ─────────────────────────────────────────────────────
|
||||
# tini: PID 1 signal handling (graceful shutdown on Fly/Railway/k8s)
|
||||
# curl: HEALTHCHECK probe
|
||||
# build-essential: compile native Python wheels (chromadb, etc.)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
tini \
|
||||
curl \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── Install PraisonAI 🦞 ────────────────────────────────────────────────────
|
||||
# Single command installs: AIUI dashboard, agents SDK (memory/knowledge/llm/mcp),
|
||||
# bot channels (Telegram/Discord/Slack), gateway, search tools, and more.
|
||||
RUN pip install --no-cache-dir "praisonai[claw]"
|
||||
|
||||
# ── Data volume ──────────────────────────────────────────────────────────────
|
||||
# Persistence for sessions, config, memory, knowledge stores
|
||||
RUN mkdir -p /data
|
||||
VOLUME /data
|
||||
|
||||
# ── Entrypoint ───────────────────────────────────────────────────────────────
|
||||
# Ensures /data exists and forwards all signals via tini
|
||||
RUN printf '#!/bin/sh\n\
|
||||
set -e\n\
|
||||
mkdir -p /data 2>/dev/null || true\n\
|
||||
exec "$@"\n' > /usr/local/bin/entrypoint.sh \
|
||||
&& chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
EXPOSE 8082
|
||||
|
||||
# ── Health check ─────────────────────────────────────────────────────────────
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD curl -f http://localhost:8082/health || exit 1
|
||||
|
||||
# ── Run ──────────────────────────────────────────────────────────────────────
|
||||
# Default: AIUI dashboard via praisonai claw. Override CMD for bot/gateway/serve.
|
||||
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/entrypoint.sh"]
|
||||
CMD ["praisonai", "claw", "--port", "8082"]
|
||||
@@ -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.33" \
|
||||
"praisonai>=4.5.73" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -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.33" \
|
||||
"praisonai>=4.5.73" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# PraisonAI 🦞 Claw — Recommended all-in-one service
|
||||
claw:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile.claw
|
||||
volumes:
|
||||
- claw_data:/data
|
||||
ports:
|
||||
- "8082:8082"
|
||||
environment:
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: [ "CMD", "curl", "-f", "http://localhost:8082/health" ]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 20s
|
||||
retries: 3
|
||||
networks:
|
||||
- praison_network
|
||||
|
||||
# ─── Legacy services (kept for backward compatibility) ─────────────────
|
||||
|
||||
# PraisonAI UI Service
|
||||
ui:
|
||||
build:
|
||||
@@ -92,6 +115,8 @@ services:
|
||||
# For bot services (Slack, Discord, Telegram), see docker/bots/
|
||||
|
||||
volumes:
|
||||
claw_data:
|
||||
driver: local
|
||||
praison_data:
|
||||
driver: local
|
||||
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
Email Bot Example — Deploy an AI Agent to Your Mailbox
|
||||
|
||||
This example shows how to deploy PraisonAI agents as email bots
|
||||
using IMAP/SMTP. The bot monitors an inbox, processes incoming emails,
|
||||
and replies intelligently using an LLM-powered agent.
|
||||
|
||||
Supported providers:
|
||||
- Gmail (with App Password)
|
||||
- Outlook/Microsoft 365
|
||||
- Any IMAP/SMTP server
|
||||
|
||||
Environment Variables:
|
||||
export OPENAI_API_KEY=your_key
|
||||
export [email protected]
|
||||
export EMAIL_APP_PASSWORD=your_app_password
|
||||
export EMAIL_IMAP_SERVER=imap.gmail.com # optional, default: imap.gmail.com
|
||||
export EMAIL_SMTP_SERVER=smtp.gmail.com # optional, default: smtp.gmail.com
|
||||
|
||||
Usage:
|
||||
python bot_email_example.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from praisonaiagents import Agent
|
||||
|
||||
|
||||
# ── Example 1: Minimal — Bot() wrapper (recommended) ──────────────
|
||||
|
||||
def example_minimal():
|
||||
"""Minimal email bot — 3 lines to start."""
|
||||
from praisonai.bots import Bot
|
||||
|
||||
agent = Agent(
|
||||
name="email_assistant",
|
||||
instructions="You are a helpful email assistant. Reply concisely and professionally.",
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
|
||||
# Token and server resolved from env vars automatically:
|
||||
# EMAIL_APP_PASSWORD, EMAIL_ADDRESS, EMAIL_IMAP_SERVER, EMAIL_SMTP_SERVER
|
||||
bot = Bot("email", agent=agent)
|
||||
bot.run() # Blocks until Ctrl+C
|
||||
|
||||
|
||||
# ── Example 2: Custom configuration ───────────────────────────────
|
||||
|
||||
def example_with_config():
|
||||
"""Email bot with explicit configuration."""
|
||||
from praisonai.bots import Bot
|
||||
from praisonaiagents import BotConfig
|
||||
|
||||
config = BotConfig(
|
||||
polling_interval=30.0, # Check inbox every 30s
|
||||
allowed_users=[ # Only respond to these senders
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
],
|
||||
max_message_length=10000, # Allow long email replies
|
||||
session_ttl=86400, # Clear session after 24h inactivity
|
||||
metadata={
|
||||
"imap_folder": "INBOX", # Which folder to monitor
|
||||
},
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
name="support_agent",
|
||||
instructions="""You are a technical support agent for ExampleCo.
|
||||
|
||||
Guidelines:
|
||||
- Be professional and empathetic
|
||||
- Provide clear step-by-step solutions
|
||||
- If you don't know the answer, escalate to [email protected]
|
||||
- Keep replies under 500 words
|
||||
""",
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
|
||||
bot = Bot("email", agent=agent, config=config)
|
||||
bot.run()
|
||||
|
||||
|
||||
# ── Example 3: Direct EmailBot with custom handlers ──────────────
|
||||
|
||||
def example_direct():
|
||||
"""Direct EmailBot usage with custom message handler and commands."""
|
||||
from praisonai.bots import EmailBot
|
||||
|
||||
agent = Agent(
|
||||
name="smart_inbox",
|
||||
instructions="You help triage and respond to support emails.",
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
|
||||
bot = EmailBot(
|
||||
token=os.environ.get("EMAIL_APP_PASSWORD", ""),
|
||||
agent=agent,
|
||||
email_address=os.environ.get("EMAIL_ADDRESS", ""),
|
||||
imap_server=os.environ.get("EMAIL_IMAP_SERVER", "imap.gmail.com"),
|
||||
smtp_server=os.environ.get("EMAIL_SMTP_SERVER", "smtp.gmail.com"),
|
||||
)
|
||||
|
||||
# Custom message handler — runs on every incoming email
|
||||
@bot.on_message
|
||||
async def log_incoming(message):
|
||||
print(f"📧 New email from: {message.sender.username}")
|
||||
print(f" Subject: {message.metadata.get('subject', '(no subject)')}")
|
||||
|
||||
# Custom command — triggered when subject starts with "/status"
|
||||
@bot.on_command("status")
|
||||
async def handle_status(message):
|
||||
await bot.send_message(
|
||||
channel_id=message.sender.user_id,
|
||||
content={
|
||||
"subject": "Re: Bot Status",
|
||||
"body": f"🤖 Bot is running!\nProcessed emails: {bot._emails_processed}",
|
||||
},
|
||||
reply_to=message.message_id,
|
||||
)
|
||||
|
||||
asyncio.run(bot.start())
|
||||
|
||||
|
||||
# ── Example 4: Multi-platform with BotOS ─────────────────────────
|
||||
|
||||
def example_multi_platform():
|
||||
"""Run email bot alongside Telegram and Discord bots."""
|
||||
from praisonai.bots import BotOS, Bot
|
||||
|
||||
agent = Agent(
|
||||
name="omni_assistant",
|
||||
instructions="You are a helpful assistant available on email, Telegram, and Discord.",
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
|
||||
botos = BotOS(agent=agent, platforms=["email", "telegram", "discord"])
|
||||
botos.run() # All three bots run concurrently
|
||||
|
||||
|
||||
# ── Example 5: Probe connectivity ─────────────────────────────────
|
||||
|
||||
async def example_probe():
|
||||
"""Test email server connectivity without starting the bot."""
|
||||
from praisonai.bots import Bot
|
||||
|
||||
bot = Bot("email")
|
||||
result = await bot.probe()
|
||||
|
||||
print(f"Connection OK: {result.ok}")
|
||||
print(f"Platform: {result.platform}")
|
||||
if result.bot_username:
|
||||
print(f"Email: {result.bot_username}")
|
||||
if result.error:
|
||||
print(f"Error: {result.error}")
|
||||
print(f"Latency: {result.elapsed_ms:.0f}ms")
|
||||
|
||||
|
||||
# ── Example 6: YAML config (for praisonai bot CLI) ───────────────
|
||||
|
||||
YAML_EXAMPLE = """
|
||||
# bot.yaml — Save this file and run: praisonai bot --config bot.yaml
|
||||
agent:
|
||||
name: email_support
|
||||
instructions: >
|
||||
You are a professional support agent.
|
||||
Reply concisely and helpfully.
|
||||
llm: gpt-4o-mini
|
||||
tools:
|
||||
- search_web
|
||||
|
||||
platforms:
|
||||
email:
|
||||
token: ${EMAIL_APP_PASSWORD}
|
||||
"""
|
||||
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
examples = {
|
||||
"minimal": ("Minimal email bot", example_minimal),
|
||||
"config": ("With custom config", example_with_config),
|
||||
"direct": ("Direct EmailBot with handlers", example_direct),
|
||||
"multi": ("Multi-platform (email+telegram+discord)", example_multi_platform),
|
||||
"probe": ("Test connectivity", lambda: asyncio.run(example_probe())),
|
||||
}
|
||||
|
||||
if len(sys.argv) > 1 and sys.argv[1] in examples:
|
||||
name = sys.argv[1]
|
||||
desc, fn = examples[name]
|
||||
print(f"Running: {desc}")
|
||||
fn()
|
||||
else:
|
||||
print("PraisonAI Email Bot Examples")
|
||||
print("=" * 40)
|
||||
print()
|
||||
print("Usage: python bot_email_example.py <example>")
|
||||
print()
|
||||
print("Available examples:")
|
||||
for key, (desc, _) in examples.items():
|
||||
print(f" {key:10s} {desc}")
|
||||
print()
|
||||
print("Required environment variables:")
|
||||
print(" OPENAI_API_KEY Your OpenAI API key")
|
||||
print(" EMAIL_ADDRESS Bot's email address")
|
||||
print(" EMAIL_APP_PASSWORD App password (Gmail: myaccount.google.com/apppasswords)")
|
||||
print()
|
||||
print("Optional:")
|
||||
print(" EMAIL_IMAP_SERVER IMAP server (default: imap.gmail.com)")
|
||||
print(" EMAIL_SMTP_SERVER SMTP server (default: smtp.gmail.com)")
|
||||
print()
|
||||
print("YAML config example:")
|
||||
print(YAML_EXAMPLE)
|
||||
@@ -0,0 +1,27 @@
|
||||
# PraisonAI AgentMail Bot Configuration (Zero-Code)
|
||||
#
|
||||
# AgentMail = API-first email (no IMAP/SMTP). Programmatic inboxes.
|
||||
#
|
||||
# Usage:
|
||||
# praisonai bot start --config agentmail-bot.yaml
|
||||
#
|
||||
# Requires:
|
||||
# pip install praisonai agentmail
|
||||
# export AGENTMAIL_API_KEY=am_...
|
||||
#
|
||||
# Optional env vars:
|
||||
# AGENTMAIL_INBOX_ID=inbox_... (reuse existing inbox)
|
||||
# AGENTMAIL_DOMAIN=mycompany.com (custom domain for new inboxes)
|
||||
|
||||
# Agent configuration
|
||||
agent:
|
||||
name: "Email Assistant"
|
||||
instructions: "You are a helpful email assistant. Keep replies concise and professional."
|
||||
model: "gpt-4o-mini"
|
||||
memory: true
|
||||
|
||||
# Channel configuration (multi-channel format)
|
||||
channels:
|
||||
agentmail:
|
||||
token: "${AGENTMAIL_API_KEY}"
|
||||
# Optional: inbox_id, domain are resolved from env vars automatically
|
||||
@@ -344,6 +344,14 @@ _LAZY_IMPORTS = {
|
||||
'ObservabilityEventType': ('praisonaiagents.escalation.observability', 'EventType'),
|
||||
'DoomLoopDetector': ('praisonaiagents.escalation.doom_loop', 'DoomLoopDetector'),
|
||||
'MemoryBackend': ('praisonaiagents.config.feature_configs', 'MemoryBackend'),
|
||||
'LearnConfig': ('praisonaiagents.config.feature_configs', 'LearnConfig'),
|
||||
'LearnScope': ('praisonaiagents.config.feature_configs', 'LearnScope'),
|
||||
'LearnMode': ('praisonaiagents.config.feature_configs', 'LearnMode'),
|
||||
'LearnBackend': ('praisonaiagents.config.feature_configs', 'LearnBackend'),
|
||||
'LearnProtocol': ('praisonaiagents.memory.learn.protocols', 'LearnProtocol'),
|
||||
'AsyncLearnProtocol': ('praisonaiagents.memory.learn.protocols', 'AsyncLearnProtocol'),
|
||||
'LearnManagerProtocol': ('praisonaiagents.memory.learn.protocols', 'LearnManagerProtocol'),
|
||||
'LearnManager': ('praisonaiagents.memory.learn', 'LearnManager'),
|
||||
'ChunkingStrategy': ('praisonaiagents.config.feature_configs', 'ChunkingStrategy'),
|
||||
'GuardrailAction': ('praisonaiagents.config.feature_configs', 'GuardrailAction'),
|
||||
'WebSearchProvider': ('praisonaiagents.config.feature_configs', 'WebSearchProvider'),
|
||||
|
||||
@@ -439,6 +439,7 @@ class Agent:
|
||||
skills: Optional[Union[List[str], Any]] = None, # Union[list, SkillsConfig]
|
||||
approval: Optional[Union[bool, Any]] = None, # Union[bool, ApprovalProtocol backend]
|
||||
tool_timeout: Optional[int] = None, # P8/G11: Timeout in seconds for each tool call
|
||||
learn: Optional[Union[bool, Any]] = None, # Union[bool, LearnConfig] - Continuous learning (peer to memory)
|
||||
):
|
||||
"""Initialize an Agent instance.
|
||||
|
||||
@@ -509,6 +510,11 @@ class Agent:
|
||||
skills: Agent skills. Accepts:
|
||||
- List[str]: Skill directory paths
|
||||
- SkillsConfig: Custom configuration
|
||||
learn: Continuous learning configuration. Accepts:
|
||||
- bool: True enables with defaults, False disables
|
||||
- LearnConfig: Custom configuration
|
||||
Learning is a first-class citizen, peer to memory. It captures patterns,
|
||||
preferences, and insights from interactions to improve future responses.
|
||||
|
||||
Raises:
|
||||
ValueError: If all of name, role, goal, backstory, and instructions are None.
|
||||
@@ -969,6 +975,49 @@ class Agent:
|
||||
elif memory is False:
|
||||
memory = None
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Resolve LEARN param - FAST PATH (top-level, peer to memory)
|
||||
# learn= is a first-class citizen, independent of memory=
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
from ..config.feature_configs import LearnConfig
|
||||
|
||||
_learn_config = None
|
||||
|
||||
# Top-level learn= takes precedence over memory.learn
|
||||
if learn is not None:
|
||||
# Explicit top-level learn= param
|
||||
if learn is False:
|
||||
_learn_config = None
|
||||
elif learn is True:
|
||||
# learn=True shorthand enables AGENTIC mode (auto-learning)
|
||||
from ..memory.learn.protocols import LearnMode
|
||||
_learn_config = LearnConfig(mode=LearnMode.AGENTIC)
|
||||
elif isinstance(learn, LearnConfig):
|
||||
_learn_config = learn
|
||||
elif isinstance(learn, dict):
|
||||
_learn_config = LearnConfig(**learn)
|
||||
else:
|
||||
_learn_config = learn # Pass through
|
||||
elif _memory_config is not None and isinstance(_memory_config, MemoryConfig):
|
||||
# Fallback to memory.learn for backward compatibility
|
||||
if _memory_config.learn:
|
||||
if _memory_config.learn is True:
|
||||
# learn=True shorthand enables AGENTIC mode
|
||||
from ..memory.learn.protocols import LearnMode
|
||||
_learn_config = LearnConfig(mode=LearnMode.AGENTIC)
|
||||
elif isinstance(_memory_config.learn, LearnConfig):
|
||||
_learn_config = _memory_config.learn
|
||||
elif isinstance(_memory_config.learn, dict):
|
||||
_learn_config = LearnConfig(**_memory_config.learn)
|
||||
|
||||
# If learn is enabled but memory is not, we need to enable memory for learn to work
|
||||
if _learn_config is not None and memory is None:
|
||||
# Enable minimal memory for learn to work
|
||||
memory = {"learn": _learn_config.to_dict() if hasattr(_learn_config, 'to_dict') else _learn_config}
|
||||
elif _learn_config is not None and isinstance(memory, dict):
|
||||
# Merge learn config into memory dict
|
||||
memory["learn"] = _learn_config.to_dict() if hasattr(_learn_config, 'to_dict') else _learn_config
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Resolve HISTORY from MemoryConfig - FAST PATH
|
||||
# History is enabled via memory="history" preset or MemoryConfig(history=True)
|
||||
@@ -996,7 +1045,7 @@ class Agent:
|
||||
session_id = _history_session_id
|
||||
elif _history_enabled and session_id is None and _history_session_id is None:
|
||||
import hashlib as _hl
|
||||
_agent_hash = _hl.md5(name.encode()).hexdigest()[:8]
|
||||
_agent_hash = _hl.md5((name or "agent").encode()).hexdigest()[:8]
|
||||
session_id = f"history_{_agent_hash}"
|
||||
_history_session_id = session_id
|
||||
|
||||
@@ -1273,6 +1322,7 @@ class Agent:
|
||||
llm_config['api_key'] = api_key
|
||||
llm_config['metrics'] = metrics
|
||||
self.llm_instance = LLM(**llm_config)
|
||||
self.llm = llm.get('model', Agent._get_default_model())
|
||||
else:
|
||||
# Create LLM with model string and base_url (cached for performance)
|
||||
model_name = llm or Agent._get_default_model()
|
||||
@@ -1286,6 +1336,7 @@ class Agent:
|
||||
prompt_caching=prompt_caching,
|
||||
claude_memory=claude_memory
|
||||
)
|
||||
self.llm = model_name
|
||||
self._using_custom_llm = True
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
@@ -1305,6 +1356,7 @@ class Agent:
|
||||
llm['metrics'] = metrics
|
||||
self.llm_instance = LLM(**llm) # Pass all dict items as kwargs
|
||||
self._using_custom_llm = True
|
||||
self.llm = llm.get('model', Agent._get_default_model())
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"LLM features requested but dependencies not installed. "
|
||||
@@ -1575,6 +1627,10 @@ Your Goal: {self.goal}
|
||||
self._history_enabled = _history_enabled
|
||||
self._history_limit = _history_limit
|
||||
self._history_session_id = _history_session_id
|
||||
|
||||
# Learning configuration (top-level, peer to memory)
|
||||
# Stored for access via agent._learn_config
|
||||
self._learn_config = _learn_config
|
||||
|
||||
# Agent-centric feature instances (lazy loaded for zero performance impact)
|
||||
self._auto_memory = auto_memory
|
||||
@@ -1967,6 +2023,9 @@ Summary:"""
|
||||
self._file_snapshot = None
|
||||
self._snapshot_stack = []
|
||||
self._redo_stack = []
|
||||
self._autonomy_turn_tool_count = 0
|
||||
self._consecutive_no_tool_turns = 0
|
||||
self._doom_recovery_active = False
|
||||
return
|
||||
|
||||
self.autonomy_enabled = True
|
||||
@@ -1994,12 +2053,16 @@ Summary:"""
|
||||
self._file_snapshot = None
|
||||
self._snapshot_stack = []
|
||||
self._redo_stack = []
|
||||
self._autonomy_turn_tool_count = 0
|
||||
self._consecutive_no_tool_turns = 0
|
||||
self._doom_recovery_active = False
|
||||
return
|
||||
|
||||
# Preserve ALL AutonomyConfig fields in the dict (G14 fix: no lossy extraction)
|
||||
self.autonomy_config = {
|
||||
"enabled": config.enabled,
|
||||
"level": config.level,
|
||||
"mode": config.mode,
|
||||
"max_iterations": config.max_iterations,
|
||||
"doom_loop_threshold": config.doom_loop_threshold,
|
||||
"auto_escalate": config.auto_escalate,
|
||||
@@ -2021,6 +2084,7 @@ Summary:"""
|
||||
|
||||
self._autonomy_trigger = AutonomyTrigger()
|
||||
self._doom_loop_tracker = DoomLoopTracker(threshold=config.doom_loop_threshold)
|
||||
self._doom_recovery_active = False
|
||||
|
||||
# Initialize FileSnapshot for filesystem tracking (lazy import)
|
||||
self._file_snapshot = None
|
||||
@@ -2381,7 +2445,45 @@ Summary:"""
|
||||
started_at=started_at,
|
||||
)
|
||||
|
||||
# Check doom loop with graduated recovery (G-RECOVERY-2 fix)
|
||||
|
||||
# Execute one turn using the agent's chat method
|
||||
# Always use the original prompt (prompt re-injection)
|
||||
# Reset per-turn tool count for no-tool-call detection
|
||||
self._autonomy_turn_tool_count = 0
|
||||
try:
|
||||
response = self.chat(prompt)
|
||||
except Exception as e:
|
||||
return AutonomyResult(
|
||||
success=False,
|
||||
output=str(e),
|
||||
completion_reason="error",
|
||||
iterations=iterations,
|
||||
stage=stage,
|
||||
actions=actions_taken,
|
||||
duration_seconds=time_module.time() - start_time,
|
||||
error=str(e),
|
||||
started_at=started_at,
|
||||
)
|
||||
|
||||
response_str = str(response)
|
||||
|
||||
# Record response text for content streaming loop detection
|
||||
if self._doom_loop_tracker is not None:
|
||||
self._doom_loop_tracker.record_response(response_str)
|
||||
|
||||
# Record the action for doom loop tracking (G1 fix: was missing)
|
||||
# Use response hash only — iteration was removed because it made
|
||||
# every fingerprint unique, preventing doom loop detection.
|
||||
self._record_action(
|
||||
"chat",
|
||||
{"response_hash": hash(response_str[:500])},
|
||||
response_str[:200],
|
||||
True
|
||||
)
|
||||
|
||||
# Check doom loop AFTER recording action so detector sees
|
||||
# the current iteration's fingerprint (repositioned from top
|
||||
# of loop for correct detection timing).
|
||||
if self._is_doom_loop():
|
||||
recovery = self._get_doom_recovery()
|
||||
|
||||
@@ -2403,12 +2505,15 @@ Summary:"""
|
||||
prompt = prompt + "\n\n[System: Previous approach repeated. Try a completely different strategy.]"
|
||||
if self._doom_loop_tracker is not None:
|
||||
self._doom_loop_tracker.clear_actions()
|
||||
self._consecutive_no_tool_turns = 0
|
||||
self._doom_recovery_active = True
|
||||
continue
|
||||
elif recovery == "escalate_model":
|
||||
# Give the agent one more try with explicit error guidance
|
||||
prompt = prompt + "\n\n[System: You are stuck in a loop. CRITICAL: Check that all tool argument names exactly match the function signature. Do NOT add '=' to argument names. Use only the documented parameter names.]"
|
||||
if self._doom_loop_tracker is not None:
|
||||
self._doom_loop_tracker.clear_actions()
|
||||
self._consecutive_no_tool_turns = 0
|
||||
self._doom_recovery_active = True
|
||||
continue
|
||||
elif recovery == "request_help":
|
||||
return AutonomyResult(
|
||||
@@ -2433,35 +2538,6 @@ Summary:"""
|
||||
started_at=started_at,
|
||||
)
|
||||
|
||||
# Execute one turn using the agent's chat method
|
||||
# Always use the original prompt (prompt re-injection)
|
||||
try:
|
||||
response = self.chat(prompt)
|
||||
except Exception as e:
|
||||
return AutonomyResult(
|
||||
success=False,
|
||||
output=str(e),
|
||||
completion_reason="error",
|
||||
iterations=iterations,
|
||||
stage=stage,
|
||||
actions=actions_taken,
|
||||
duration_seconds=time_module.time() - start_time,
|
||||
error=str(e),
|
||||
started_at=started_at,
|
||||
)
|
||||
|
||||
response_str = str(response)
|
||||
|
||||
# Record the action for doom loop tracking (G1 fix: was missing)
|
||||
# Use iteration number + response hash to avoid false positives
|
||||
# when same prompt is re-injected (G8 fix: doom loop false positive)
|
||||
self._record_action(
|
||||
"chat",
|
||||
{"iteration": iterations, "response_hash": hash(response_str[:500])},
|
||||
response_str[:200],
|
||||
True
|
||||
)
|
||||
|
||||
# Record for action history
|
||||
actions_taken.append({
|
||||
"iteration": iterations,
|
||||
@@ -2488,6 +2564,30 @@ Summary:"""
|
||||
# Auto-save session after each iteration (memory integration)
|
||||
self._auto_save_session()
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# TOOL-CALL COMPLETION: If the model used tools this turn AND
|
||||
# produced a substantive response, the inner loop completed
|
||||
# the task naturally (model stopped calling tools = done).
|
||||
# This is the same signal the CLI/wrapper path trusts.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
if self._autonomy_turn_tool_count > 0 and len(response_str) > 100:
|
||||
# P3/G2: Emit completion callback
|
||||
execute_sync_callback('autonomy_complete',
|
||||
completion_reason="tool_completion",
|
||||
iterations=iterations,
|
||||
duration_seconds=time_module.time() - start_time
|
||||
)
|
||||
return AutonomyResult(
|
||||
success=True,
|
||||
output=response_str,
|
||||
completion_reason="tool_completion",
|
||||
iterations=iterations,
|
||||
stage=stage,
|
||||
actions=actions_taken,
|
||||
duration_seconds=time_module.time() - start_time,
|
||||
started_at=started_at,
|
||||
)
|
||||
|
||||
# Auto-escalate stage if stuck (G11 fix: wire auto_escalate)
|
||||
if (self.autonomy_config.get("auto_escalate")
|
||||
and iterations > 1
|
||||
@@ -2569,6 +2669,34 @@ Summary:"""
|
||||
# Clear context between iterations if enabled
|
||||
if effective_clear_context:
|
||||
self.clear_history()
|
||||
|
||||
# No-tool-call termination: if model makes no tool calls for 2+
|
||||
# consecutive turns, treat as completion signal.
|
||||
# Skip first iteration (model may be planning).
|
||||
# Suppressed when doom recovery is active — once a doom loop
|
||||
# is detected, no_tool_calls (a success exit) should not fire;
|
||||
# the doom loop system manages termination instead.
|
||||
if (self._autonomy_turn_tool_count == 0 and iterations > 1
|
||||
and not self._doom_recovery_active):
|
||||
self._consecutive_no_tool_turns += 1
|
||||
if self._consecutive_no_tool_turns >= 2:
|
||||
execute_sync_callback('autonomy_complete',
|
||||
completion_reason="no_tool_calls",
|
||||
iterations=iterations,
|
||||
duration_seconds=time_module.time() - start_time
|
||||
)
|
||||
return AutonomyResult(
|
||||
success=True,
|
||||
output=response_str,
|
||||
completion_reason="no_tool_calls",
|
||||
iterations=iterations,
|
||||
stage=stage,
|
||||
actions=actions_taken,
|
||||
duration_seconds=time_module.time() - start_time,
|
||||
started_at=started_at,
|
||||
)
|
||||
else:
|
||||
self._consecutive_no_tool_turns = 0
|
||||
|
||||
# Max iterations reached
|
||||
# P3/G2: Emit completion callback for max iterations
|
||||
@@ -2714,7 +2842,45 @@ Summary:"""
|
||||
started_at=started_at,
|
||||
)
|
||||
|
||||
# Check doom loop with graduated recovery (G-RECOVERY-2 fix)
|
||||
|
||||
# Execute one turn using the agent's async chat method
|
||||
# Always use the original prompt (prompt re-injection)
|
||||
# Reset per-turn tool count for no-tool-call detection
|
||||
self._autonomy_turn_tool_count = 0
|
||||
try:
|
||||
response = await self.achat(prompt)
|
||||
except Exception as e:
|
||||
return AutonomyResult(
|
||||
success=False,
|
||||
output=str(e),
|
||||
completion_reason="error",
|
||||
iterations=iterations,
|
||||
stage=stage,
|
||||
actions=actions_taken,
|
||||
duration_seconds=time_module.time() - start_time,
|
||||
error=str(e),
|
||||
started_at=started_at,
|
||||
)
|
||||
|
||||
response_str = str(response)
|
||||
|
||||
# Record response text for content streaming loop detection
|
||||
if self._doom_loop_tracker is not None:
|
||||
self._doom_loop_tracker.record_response(response_str)
|
||||
|
||||
# Record the action for doom loop tracking (G1 fix: was missing)
|
||||
# Use response hash only — iteration was removed because it made
|
||||
# every fingerprint unique, preventing doom loop detection.
|
||||
self._record_action(
|
||||
"chat",
|
||||
{"response_hash": hash(response_str[:500])},
|
||||
response_str[:200],
|
||||
True
|
||||
)
|
||||
|
||||
# Check doom loop AFTER recording action so detector sees
|
||||
# the current iteration's fingerprint (repositioned from top
|
||||
# of loop for correct detection timing).
|
||||
if self._is_doom_loop():
|
||||
recovery = self._get_doom_recovery()
|
||||
obs = getattr(self, '_observability_hooks', None)
|
||||
@@ -2729,11 +2895,15 @@ Summary:"""
|
||||
prompt = prompt + "\n\n[System: Previous approach repeated. Try a completely different strategy.]"
|
||||
if self._doom_loop_tracker is not None:
|
||||
self._doom_loop_tracker.clear_actions()
|
||||
self._consecutive_no_tool_turns = 0
|
||||
self._doom_recovery_active = True
|
||||
continue
|
||||
elif recovery == "escalate_model":
|
||||
prompt = prompt + "\n\n[System: You are stuck in a loop. CRITICAL: Check that all tool argument names exactly match the function signature. Do NOT add '=' to argument names. Use only the documented parameter names.]"
|
||||
if self._doom_loop_tracker is not None:
|
||||
self._doom_loop_tracker.clear_actions()
|
||||
self._consecutive_no_tool_turns = 0
|
||||
self._doom_recovery_active = True
|
||||
continue
|
||||
elif recovery == "request_help":
|
||||
return AutonomyResult(
|
||||
@@ -2758,35 +2928,6 @@ Summary:"""
|
||||
started_at=started_at,
|
||||
)
|
||||
|
||||
# Execute one turn using the agent's async chat method
|
||||
# Always use the original prompt (prompt re-injection)
|
||||
try:
|
||||
response = await self.achat(prompt)
|
||||
except Exception as e:
|
||||
return AutonomyResult(
|
||||
success=False,
|
||||
output=str(e),
|
||||
completion_reason="error",
|
||||
iterations=iterations,
|
||||
stage=stage,
|
||||
actions=actions_taken,
|
||||
duration_seconds=time_module.time() - start_time,
|
||||
error=str(e),
|
||||
started_at=started_at,
|
||||
)
|
||||
|
||||
response_str = str(response)
|
||||
|
||||
# Record the action for doom loop tracking (G2 fix: was missing)
|
||||
# Use iteration number + response hash to avoid false positives
|
||||
# when same prompt is re-injected (G8 fix: doom loop false positive)
|
||||
self._record_action(
|
||||
"chat",
|
||||
{"iteration": iterations, "response_hash": hash(response_str[:500])},
|
||||
response_str[:200],
|
||||
True
|
||||
)
|
||||
|
||||
# Record for action history
|
||||
actions_taken.append({
|
||||
"iteration": iterations,
|
||||
@@ -2813,6 +2954,23 @@ Summary:"""
|
||||
# Auto-save session after each async iteration (memory integration)
|
||||
self._auto_save_session()
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# TOOL-CALL COMPLETION: If the model used tools this turn AND
|
||||
# produced a substantive response, the inner loop completed
|
||||
# the task naturally (model stopped calling tools = done).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
if self._autonomy_turn_tool_count > 0 and len(response_str) > 100:
|
||||
return AutonomyResult(
|
||||
success=True,
|
||||
output=response_str,
|
||||
completion_reason="tool_completion",
|
||||
iterations=iterations,
|
||||
stage=stage,
|
||||
actions=actions_taken,
|
||||
duration_seconds=time_module.time() - start_time,
|
||||
started_at=started_at,
|
||||
)
|
||||
|
||||
# Auto-escalate stage if stuck (G11 fix: wire auto_escalate)
|
||||
if (self.autonomy_config.get("auto_escalate")
|
||||
and iterations > 1
|
||||
@@ -2869,6 +3027,28 @@ Summary:"""
|
||||
if effective_clear_context:
|
||||
self.clear_history()
|
||||
|
||||
# No-tool-call termination: if model makes no tool calls for 2+
|
||||
# consecutive turns, treat as completion signal.
|
||||
# Suppressed when doom recovery is active — once a doom loop
|
||||
# is detected, no_tool_calls (a success exit) should not fire;
|
||||
# the doom loop system manages termination instead.
|
||||
if (self._autonomy_turn_tool_count == 0 and iterations > 1
|
||||
and not self._doom_recovery_active):
|
||||
self._consecutive_no_tool_turns += 1
|
||||
if self._consecutive_no_tool_turns >= 2:
|
||||
return AutonomyResult(
|
||||
success=True,
|
||||
output=response_str,
|
||||
completion_reason="no_tool_calls",
|
||||
iterations=iterations,
|
||||
stage=stage,
|
||||
actions=actions_taken,
|
||||
duration_seconds=time_module.time() - start_time,
|
||||
started_at=started_at,
|
||||
)
|
||||
else:
|
||||
self._consecutive_no_tool_turns = 0
|
||||
|
||||
# Yield control to allow other async tasks to run
|
||||
await asyncio.sleep(0)
|
||||
|
||||
@@ -3422,10 +3602,17 @@ Summary:"""
|
||||
if not self._memory_instance:
|
||||
return
|
||||
|
||||
if memory_type == "short_term" and hasattr(self._memory_instance, 'add_short_term'):
|
||||
self._memory_instance.add_short_term(content, **kwargs)
|
||||
elif memory_type == "long_term" and hasattr(self._memory_instance, 'add_long_term'):
|
||||
self._memory_instance.add_long_term(content, **kwargs)
|
||||
# Use protocol names first (store_*), fallback to legacy names (add_*)
|
||||
if memory_type == "short_term":
|
||||
if hasattr(self._memory_instance, 'store_short_term'):
|
||||
self._memory_instance.store_short_term(content, **kwargs)
|
||||
elif hasattr(self._memory_instance, 'add_short_term'):
|
||||
self._memory_instance.add_short_term(content, **kwargs)
|
||||
elif memory_type == "long_term":
|
||||
if hasattr(self._memory_instance, 'store_long_term'):
|
||||
self._memory_instance.store_long_term(content, **kwargs)
|
||||
elif hasattr(self._memory_instance, 'add_long_term'):
|
||||
self._memory_instance.add_long_term(content, **kwargs)
|
||||
elif memory_type == "entity" and hasattr(self._memory_instance, 'add_entity'):
|
||||
self._memory_instance.add_entity(content, **kwargs)
|
||||
elif memory_type == "episodic" and hasattr(self._memory_instance, 'add_episodic'):
|
||||
@@ -4091,6 +4278,7 @@ Your Goal: {self.goal}"""
|
||||
|
||||
if tool_names:
|
||||
system_prompt += f"\n\nYou have access to the following tools: {', '.join(tool_names)}. Use these tools when appropriate to help complete your tasks. Always use tools when they can help provide accurate information or perform actions."
|
||||
system_prompt += "\n\nExplain Before Acting: Before calling a tool, provide a brief one-sentence explanation of what you are about to do and why. Skip explanations only for repetitive low-level operations where narration would be noisy. When performing a batch of similar operations (e.g. searching for multiple items), explain the group once rather than narrating each call individually."
|
||||
|
||||
# Cache the generated system prompt (only if cache_key is set, i.e., memory not enabled)
|
||||
# Simple cache size limit to prevent unbounded growth
|
||||
@@ -4443,10 +4631,16 @@ Your Goal: {self.goal}"""
|
||||
logging.debug(f"Type casting failed for {getattr(func, '__name__', 'unknown function')}: {e}")
|
||||
return arguments
|
||||
|
||||
def execute_tool(self, function_name, arguments):
|
||||
def execute_tool(self, function_name, arguments, tool_call_id=None):
|
||||
"""
|
||||
Execute a tool dynamically based on the function name and arguments.
|
||||
Injects agent state for tools with Injected[T] parameters.
|
||||
|
||||
Args:
|
||||
function_name: Name of the tool function to execute
|
||||
arguments: Dictionary of arguments to pass to the tool
|
||||
tool_call_id: Optional ID from the LLM's tool_call (e.g., 'call_xxxxx')
|
||||
Used for correlating TOOL_CALL_START/RESULT stream events
|
||||
"""
|
||||
logging.debug(f"{self.name} executing tool {function_name} with arguments: {arguments}")
|
||||
|
||||
@@ -4460,22 +4654,47 @@ Your Goal: {self.goal}"""
|
||||
run_id=getattr(self, '_current_run_id', 'unknown'),
|
||||
session_id=getattr(self, '_session_id', None) or 'default',
|
||||
last_user_message=self.chat_history[-1].get('content') if self.chat_history else None,
|
||||
memory=getattr(self, '_memory_instance', None),
|
||||
learn_manager=getattr(getattr(self, '_memory_instance', None), 'learn', None),
|
||||
metadata={'agent_name': self.name}
|
||||
)
|
||||
|
||||
# Execute within injection context
|
||||
return self._execute_tool_with_context(function_name, arguments, state)
|
||||
return self._execute_tool_with_context(function_name, arguments, state, tool_call_id)
|
||||
|
||||
def _execute_tool_with_context(self, function_name, arguments, state):
|
||||
"""Execute tool within injection context, with optional output truncation."""
|
||||
def _execute_tool_with_context(self, function_name, arguments, state, tool_call_id=None):
|
||||
"""Execute tool within injection context, with optional output truncation.
|
||||
|
||||
Args:
|
||||
function_name: Name of the tool function to execute
|
||||
arguments: Dictionary of arguments to pass to the tool
|
||||
state: AgentState for injection context
|
||||
tool_call_id: Optional ID from the LLM's tool_call (e.g., 'call_xxxxx')
|
||||
"""
|
||||
from ..tools.injected import with_injection_context
|
||||
from ..trace.context_events import get_context_emitter
|
||||
from ..streaming.events import StreamEvent, StreamEventType
|
||||
import time as _time
|
||||
|
||||
# Emit tool call start event (zero overhead when not set)
|
||||
_trace_emitter = get_context_emitter()
|
||||
_trace_emitter.tool_call_start(self.name, function_name, arguments)
|
||||
_tool_start_time = _time.time()
|
||||
_tool_start_perf = _time.perf_counter()
|
||||
|
||||
# Emit TOOL_CALL_START to stream_emitter (for AIUI/AG-UI consumers)
|
||||
# Zero overhead when no callbacks registered
|
||||
if hasattr(self, '_Agent__stream_emitter') and self.__stream_emitter is not None and self.__stream_emitter.has_callbacks:
|
||||
self.__stream_emitter.emit(StreamEvent(
|
||||
type=StreamEventType.TOOL_CALL_START,
|
||||
timestamp=_tool_start_perf,
|
||||
tool_call={
|
||||
"name": function_name,
|
||||
"arguments": arguments, # PARSED DICT, not JSON string
|
||||
"id": tool_call_id, # Now properly threaded through
|
||||
},
|
||||
agent_id=self.name,
|
||||
))
|
||||
|
||||
try:
|
||||
# Trigger BEFORE_TOOL hook
|
||||
@@ -4550,6 +4769,24 @@ Your Goal: {self.goal}"""
|
||||
_duration_ms = (_time.time() - _tool_start_time) * 1000
|
||||
_trace_emitter.tool_call_end(self.name, function_name, str(result) if result else None, _duration_ms)
|
||||
|
||||
# Emit TOOL_CALL_RESULT to stream_emitter (for AIUI/AG-UI consumers)
|
||||
# Zero overhead when no callbacks registered
|
||||
if hasattr(self, '_Agent__stream_emitter') and self.__stream_emitter is not None and self.__stream_emitter.has_callbacks:
|
||||
# Truncate result for stream event (keep it reasonable for UI display)
|
||||
result_summary = str(result)[:500] if result else None
|
||||
self.__stream_emitter.emit(StreamEvent(
|
||||
type=StreamEventType.TOOL_CALL_RESULT,
|
||||
timestamp=_time.perf_counter(),
|
||||
tool_call={
|
||||
"name": function_name,
|
||||
"arguments": arguments,
|
||||
"result": result_summary,
|
||||
"id": tool_call_id, # Now properly threaded through
|
||||
},
|
||||
agent_id=self.name,
|
||||
metadata={"duration_ms": _duration_ms},
|
||||
))
|
||||
|
||||
# Trigger AFTER_TOOL hook
|
||||
from ..hooks import HookEvent, AfterToolInput
|
||||
after_tool_input = AfterToolInput(
|
||||
@@ -4572,6 +4809,9 @@ Your Goal: {self.goal}"""
|
||||
if not is_error:
|
||||
self._doom_loop_tracker.mark_progress(f"tool:{function_name}")
|
||||
|
||||
# Increment per-turn tool count for no-tool-call detection
|
||||
self._autonomy_turn_tool_count = getattr(self, '_autonomy_turn_tool_count', 0) + 1
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
# Emit tool call end with error
|
||||
@@ -4603,6 +4843,36 @@ Your Goal: {self.goal}"""
|
||||
prompt_str = prompt if isinstance(prompt, str) else str(prompt)
|
||||
self._process_auto_memory(prompt_str, str(response))
|
||||
|
||||
# Auto-learning extraction (opt-in via LearnConfig(mode=LearnMode.AGENTIC))
|
||||
self._process_auto_learning()
|
||||
|
||||
return response
|
||||
|
||||
async def _atrigger_after_agent_hook(self, prompt, response, start_time, tools_used=None):
|
||||
"""Async version: Trigger AFTER_AGENT hook and return response."""
|
||||
from ..hooks import HookEvent, AfterAgentInput
|
||||
after_agent_input = AfterAgentInput(
|
||||
session_id=getattr(self, '_session_id', 'default'),
|
||||
cwd=os.getcwd(),
|
||||
event_name=HookEvent.AFTER_AGENT,
|
||||
timestamp=str(time.time()),
|
||||
agent_name=self.name,
|
||||
prompt=prompt if isinstance(prompt, str) else str(prompt),
|
||||
response=response or "",
|
||||
tools_used=tools_used or [],
|
||||
total_tokens=0,
|
||||
execution_time_ms=(time.time() - start_time) * 1000
|
||||
)
|
||||
await self._hook_runner.execute(HookEvent.AFTER_AGENT, after_agent_input)
|
||||
|
||||
# Auto-memory extraction (opt-in via MemoryConfig(auto_memory=True))
|
||||
if response:
|
||||
prompt_str = prompt if isinstance(prompt, str) else str(prompt)
|
||||
self._process_auto_memory(prompt_str, str(response))
|
||||
|
||||
# Auto-learning extraction (opt-in via LearnConfig(mode=LearnMode.AGENTIC))
|
||||
self._process_auto_learning()
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@@ -5452,7 +5722,7 @@ Your Goal: {self.goal}"""
|
||||
from datetime import datetime, timezone
|
||||
# Per-hour session ID: YYYYMMDDHH (UTC) + agent name hash for uniqueness
|
||||
hour_str = datetime.now(timezone.utc).strftime("%Y%m%d%H")
|
||||
agent_hash = hashlib.md5(self.name.encode()).hexdigest()[:6]
|
||||
agent_hash = hashlib.md5((self.name or "agent").encode()).hexdigest()[:6]
|
||||
self._session_id = f"{hour_str}-{agent_hash}"
|
||||
|
||||
# Call db adapter's on_agent_start to get previous messages
|
||||
@@ -5933,6 +6203,7 @@ Your Goal: {self.goal}"""
|
||||
use_native_format=use_native_format
|
||||
)
|
||||
|
||||
|
||||
# Store chat history length for potential rollback
|
||||
chat_history_length = len(self.chat_history)
|
||||
|
||||
@@ -5965,6 +6236,7 @@ Your Goal: {self.goal}"""
|
||||
# Use processed messages for the LLM call
|
||||
messages = processed_messages
|
||||
|
||||
|
||||
# Wrap entire while loop in try-except for rollback on any failure
|
||||
try:
|
||||
while True:
|
||||
@@ -6009,7 +6281,7 @@ Your Goal: {self.goal}"""
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return validated_response
|
||||
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed for JSON output: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
@@ -6041,7 +6313,7 @@ Your Goal: {self.goal}"""
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return validated_response
|
||||
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
@@ -6111,7 +6383,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
self._end_run(validated_response, "completed", {"duration_ms": (time.time() - start_time) * 1000})
|
||||
return validated_response
|
||||
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed after reflection: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
@@ -6130,7 +6402,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return validated_response
|
||||
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed after max reflections: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
@@ -6318,7 +6590,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(normalized_content, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return validated_response
|
||||
return await self._atrigger_after_agent_hook(prompt, validated_response, start_time)
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed for custom LLM: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
@@ -6396,7 +6668,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
logging.debug(f"Agent.achat completed in {total_time:.2f} seconds")
|
||||
# Execute callback after tool completion
|
||||
self._execute_callback_and_display(original_prompt, result, time.time() - start_time, task_name, task_description, task_id)
|
||||
return result
|
||||
return await self._atrigger_after_agent_hook(original_prompt, result, start_time)
|
||||
elif output_json or output_pydantic:
|
||||
response = await self._openai_client.async_client.chat.completions.create(
|
||||
model=self.llm,
|
||||
@@ -6410,7 +6682,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
logging.debug(f"Agent.achat completed in {total_time:.2f} seconds")
|
||||
# Execute callback after JSON/Pydantic completion
|
||||
self._execute_callback_and_display(original_prompt, response_text, time.time() - start_time, task_name, task_description, task_id)
|
||||
return response_text
|
||||
return await self._atrigger_after_agent_hook(original_prompt, response_text, start_time)
|
||||
else:
|
||||
response = await self._openai_client.async_client.chat.completions.create(
|
||||
model=self.llm,
|
||||
@@ -6450,7 +6722,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
if logging.getLogger().getEffectiveLevel() == logging.DEBUG:
|
||||
total_time = time.time() - start_time
|
||||
logging.debug(f"Agent.achat completed in {total_time:.2f} seconds")
|
||||
return response_text
|
||||
return await self._atrigger_after_agent_hook(original_prompt, response_text, start_time)
|
||||
|
||||
reflection_response = await self._openai_client.async_client.beta.chat.completions.parse(
|
||||
model=self.reflect_llm if self.reflect_llm else self.llm,
|
||||
@@ -6508,7 +6780,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return validated_response
|
||||
return await self._atrigger_after_agent_hook(original_prompt, validated_response, start_time)
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed for OpenAI client: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
@@ -6652,23 +6924,49 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# This allows: Agent(autonomy=True) + await agent.astart("Task") to just work!
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
if self.autonomy_enabled:
|
||||
# Extract autonomy-specific kwargs
|
||||
timeout = kwargs.pop('timeout', None)
|
||||
kwargs.pop('stream', None) # Not used in autonomous mode
|
||||
|
||||
# Get config values from autonomy_config
|
||||
auto_config = self.autonomy_config or {}
|
||||
max_iterations = auto_config.get('max_iterations', 20)
|
||||
completion_promise = auto_config.get('completion_promise')
|
||||
clear_context = auto_config.get('clear_context', False)
|
||||
mode = auto_config.get('mode', 'caller')
|
||||
|
||||
return await self.run_autonomous_async(
|
||||
prompt=prompt,
|
||||
max_iterations=max_iterations,
|
||||
timeout_seconds=timeout,
|
||||
completion_promise=completion_promise,
|
||||
clear_context=clear_context,
|
||||
)
|
||||
if mode == 'iterative':
|
||||
# Iterative mode: use run_autonomous_async (backward compat for full_auto)
|
||||
timeout = kwargs.pop('timeout', None)
|
||||
kwargs.pop('stream', None) # Not used in autonomous mode
|
||||
max_iterations = auto_config.get('max_iterations', 20)
|
||||
completion_promise = auto_config.get('completion_promise')
|
||||
clear_context = auto_config.get('clear_context', False)
|
||||
|
||||
return await self.run_autonomous_async(
|
||||
prompt=prompt,
|
||||
max_iterations=max_iterations,
|
||||
timeout_seconds=timeout,
|
||||
completion_promise=completion_promise,
|
||||
clear_context=clear_context,
|
||||
)
|
||||
else:
|
||||
# Caller mode: single achat() call, wrapper-equivalent
|
||||
import time as time_module
|
||||
from datetime import datetime, timezone
|
||||
start_time = time_module.time()
|
||||
started_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
response = await self.achat(prompt, **kwargs)
|
||||
response_str = str(response) if response else ""
|
||||
|
||||
# Auto-save session after chat
|
||||
self._auto_save_session()
|
||||
|
||||
# Wrap in AutonomyResult for consistent API
|
||||
from .autonomy import AutonomyResult
|
||||
return AutonomyResult(
|
||||
success=True,
|
||||
output=response_str,
|
||||
completion_reason="caller_mode",
|
||||
iterations=1,
|
||||
stage="direct",
|
||||
actions=[],
|
||||
duration_seconds=time_module.time() - start_time,
|
||||
started_at=started_at,
|
||||
)
|
||||
|
||||
# Determine streaming behavior (same logic as start())
|
||||
stream_requested = kwargs.get('stream')
|
||||
@@ -6921,22 +7219,54 @@ Write the complete compiled report:"""
|
||||
# This allows: Agent(autonomy=True) + agent.start("Task") to just work!
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
if self.autonomy_enabled:
|
||||
# Extract autonomy-specific kwargs
|
||||
timeout = kwargs.pop('timeout', None)
|
||||
|
||||
# Get config values from autonomy_config
|
||||
auto_config = self.autonomy_config or {}
|
||||
max_iterations = auto_config.get('max_iterations', 20)
|
||||
completion_promise = auto_config.get('completion_promise')
|
||||
clear_context = auto_config.get('clear_context', False)
|
||||
mode = auto_config.get('mode', 'caller')
|
||||
|
||||
return self.run_autonomous(
|
||||
prompt=prompt,
|
||||
max_iterations=max_iterations,
|
||||
timeout_seconds=timeout,
|
||||
completion_promise=completion_promise,
|
||||
clear_context=clear_context,
|
||||
)
|
||||
if mode == 'iterative':
|
||||
# Iterative mode: use run_autonomous (backward compat for full_auto)
|
||||
timeout = kwargs.pop('timeout', None)
|
||||
max_iterations = auto_config.get('max_iterations', 20)
|
||||
completion_promise = auto_config.get('completion_promise')
|
||||
clear_context = auto_config.get('clear_context', False)
|
||||
|
||||
return self.run_autonomous(
|
||||
prompt=prompt,
|
||||
max_iterations=max_iterations,
|
||||
timeout_seconds=timeout,
|
||||
completion_promise=completion_promise,
|
||||
clear_context=clear_context,
|
||||
)
|
||||
else:
|
||||
# Caller mode: single chat() call, wrapper-equivalent
|
||||
# All init-time features (approval, doom-loop per-tool,
|
||||
# track_changes, sandbox, default_tools) are already wired.
|
||||
import time as time_module
|
||||
from datetime import datetime, timezone
|
||||
start_time = time_module.time()
|
||||
started_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
# Load history from past sessions
|
||||
self._load_history_context()
|
||||
|
||||
# Use chat() — all tools, approval, doom-loop per-tool work
|
||||
response = self.chat(prompt, **kwargs)
|
||||
response_str = str(response) if response else ""
|
||||
|
||||
# Auto-save session after chat
|
||||
self._auto_save_session()
|
||||
|
||||
# Wrap in AutonomyResult for consistent API
|
||||
from .autonomy import AutonomyResult
|
||||
return AutonomyResult(
|
||||
success=True,
|
||||
output=response_str,
|
||||
completion_reason="caller_mode",
|
||||
iterations=1,
|
||||
stage="direct",
|
||||
actions=[],
|
||||
duration_seconds=time_module.time() - start_time,
|
||||
started_at=started_at,
|
||||
)
|
||||
|
||||
# Load history from past sessions
|
||||
self._load_history_context()
|
||||
@@ -7229,6 +7559,77 @@ Write the complete compiled report:"""
|
||||
except Exception as e:
|
||||
logging.debug(f"Auto-memory extraction failed: {e}")
|
||||
|
||||
def _process_auto_learning(self):
|
||||
"""Process auto-learning extraction after agent response.
|
||||
|
||||
Called after each agent response when LearnMode.AGENTIC is set.
|
||||
Uses LearnManager.process_conversation() to extract and store learnings
|
||||
(persona, insights, patterns) from the conversation. No-op when learning
|
||||
is disabled or mode is not AGENTIC.
|
||||
"""
|
||||
# Quick exit if no learn config or mode is not AGENTIC
|
||||
if not self._learn_config:
|
||||
return
|
||||
|
||||
# Check if mode is AGENTIC (auto-extract learnings)
|
||||
from ..memory.learn.protocols import LearnMode
|
||||
mode = getattr(self._learn_config, 'mode', None)
|
||||
|
||||
# Handle PROPOSE mode — extract but store as pending for user approval
|
||||
if (isinstance(mode, LearnMode) and mode == LearnMode.PROPOSE) or \
|
||||
(isinstance(mode, str) and mode == 'propose'):
|
||||
learn_manager = None
|
||||
if self._memory_instance and hasattr(self._memory_instance, 'learn'):
|
||||
learn_manager = self._memory_instance.learn
|
||||
if learn_manager:
|
||||
try:
|
||||
recent_messages = self.chat_history[-2:] if len(self.chat_history) >= 2 else self.chat_history
|
||||
if recent_messages:
|
||||
# Use same extraction as AGENTIC, but don't auto-store
|
||||
result = learn_manager.process_conversation(
|
||||
messages=recent_messages,
|
||||
llm=getattr(self._learn_config, 'llm', None) or self.llm,
|
||||
extract_only=True,
|
||||
)
|
||||
# Move extracted items to pending queue
|
||||
if result:
|
||||
for p in result.get("persona", []):
|
||||
if p:
|
||||
learn_manager.add_pending(p, category="persona")
|
||||
for i in result.get("insights", []):
|
||||
if i:
|
||||
learn_manager.add_pending(i, category="insights")
|
||||
for pt in result.get("patterns", []):
|
||||
if pt:
|
||||
learn_manager.add_pending(pt, category="patterns")
|
||||
except Exception as e:
|
||||
logging.debug(f"PROPOSE mode learning extraction failed: {e}")
|
||||
return
|
||||
|
||||
if mode is None or (isinstance(mode, LearnMode) and mode != LearnMode.AGENTIC):
|
||||
return
|
||||
if isinstance(mode, str) and mode != 'agentic':
|
||||
return
|
||||
|
||||
# Get LearnManager from memory instance
|
||||
learn_manager = None
|
||||
if self._memory_instance and hasattr(self._memory_instance, 'learn'):
|
||||
learn_manager = self._memory_instance.learn
|
||||
|
||||
if not learn_manager:
|
||||
return
|
||||
|
||||
try:
|
||||
# Process recent conversation (last 2 messages: user + assistant)
|
||||
recent_messages = self.chat_history[-2:] if len(self.chat_history) >= 2 else self.chat_history
|
||||
if recent_messages:
|
||||
learn_manager.process_conversation(
|
||||
messages=recent_messages,
|
||||
llm=getattr(self._learn_config, 'llm', None) or self.llm,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.debug(f"Auto-learning extraction failed: {e}")
|
||||
|
||||
def _auto_save_session(self):
|
||||
"""Auto-save session if auto_save is enabled.
|
||||
|
||||
@@ -7582,7 +7983,8 @@ Write the complete compiled report:"""
|
||||
|
||||
tool_result = self.execute_tool(
|
||||
tool_call['function']['name'],
|
||||
parsed_args
|
||||
parsed_args,
|
||||
tool_call_id=tool_call.get('id')
|
||||
)
|
||||
# Add tool result to chat history
|
||||
self.chat_history.append({
|
||||
|
||||
@@ -73,6 +73,17 @@ _ESCALATION_TO_AUTONOMY_SIGNAL = {
|
||||
_AUTONOMY_TO_ESCALATION_SIGNAL = {v: k for k, v in _ESCALATION_TO_AUTONOMY_SIGNAL.items()}
|
||||
|
||||
|
||||
# Valid autonomy modes
|
||||
VALID_AUTONOMY_MODES = {"caller", "iterative"}
|
||||
|
||||
# Smart default mode based on level
|
||||
_LEVEL_TO_DEFAULT_MODE = {
|
||||
"suggest": "caller",
|
||||
"auto_edit": "caller",
|
||||
"full_auto": "iterative",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AutonomyConfig:
|
||||
"""Configuration for Agent autonomy features.
|
||||
@@ -83,6 +94,10 @@ class AutonomyConfig:
|
||||
Attributes:
|
||||
enabled: Whether autonomy is enabled
|
||||
level: Autonomy level (suggest, auto_edit, full_auto)
|
||||
mode: Execution mode — "caller" (single chat call, wrapper-equivalent)
|
||||
or "iterative" (internal loop with completion detection).
|
||||
Defaults based on level: suggest/auto_edit → "caller",
|
||||
full_auto → "iterative".
|
||||
max_iterations: Maximum iterations before stopping
|
||||
doom_loop_threshold: Number of repeated actions to trigger doom loop
|
||||
auto_escalate: Whether to automatically escalate complexity
|
||||
@@ -99,18 +114,21 @@ class AutonomyConfig:
|
||||
|
||||
Usage::
|
||||
|
||||
# Simple — full_auto auto-enables tracking + sandbox
|
||||
# Simple — default caller mode (wrapper-equivalent, no wasteful loop)
|
||||
Agent(autonomy=True) # level=suggest, mode=caller
|
||||
|
||||
# Full auto — iterative loop with completion detection
|
||||
Agent(autonomy="full_auto")
|
||||
|
||||
# Advanced — explicit config
|
||||
# Ralph Loop — explicit iterative mode with completion promise
|
||||
Agent(autonomy=AutonomyConfig(
|
||||
level="full_auto",
|
||||
track_changes=True,
|
||||
sandbox=SandboxConfig.native(writable_paths=["./src"]),
|
||||
mode="iterative",
|
||||
completion_promise="COMPLETE",
|
||||
))
|
||||
"""
|
||||
enabled: bool = True
|
||||
level: str = "suggest"
|
||||
mode: Optional[str] = None # None = smart default based on level
|
||||
max_iterations: int = 20
|
||||
doom_loop_threshold: int = 3
|
||||
auto_escalate: bool = True
|
||||
@@ -131,6 +149,14 @@ class AutonomyConfig:
|
||||
f"Invalid autonomy level: {self.level!r}. "
|
||||
f"Must be one of {sorted(VALID_AUTONOMY_LEVELS)}"
|
||||
)
|
||||
# Smart default mode based on level
|
||||
if self.mode is None:
|
||||
self.mode = _LEVEL_TO_DEFAULT_MODE.get(self.level, "caller")
|
||||
elif self.mode not in VALID_AUTONOMY_MODES:
|
||||
raise ValueError(
|
||||
f"Invalid autonomy mode: {self.mode!r}. "
|
||||
f"Must be one of {sorted(VALID_AUTONOMY_MODES)}"
|
||||
)
|
||||
# Auto-enable track_changes for full_auto if not explicitly set
|
||||
if self.track_changes is None:
|
||||
self.track_changes = (self.level == "full_auto")
|
||||
@@ -156,6 +182,7 @@ class AutonomyConfig:
|
||||
return cls(
|
||||
enabled=data.get("enabled", True),
|
||||
level=level,
|
||||
mode=data.get("mode"), # None = smart default in __post_init__
|
||||
max_iterations=data.get("max_iterations", 20),
|
||||
doom_loop_threshold=data.get("doom_loop_threshold", 3),
|
||||
auto_escalate=data.get("auto_escalate", True),
|
||||
@@ -378,6 +405,14 @@ class DoomLoopTracker:
|
||||
"""
|
||||
self._delegate.mark_progress(marker)
|
||||
|
||||
def record_response(self, text: str) -> None:
|
||||
"""Record model response text for content streaming loop detection.
|
||||
|
||||
Args:
|
||||
text: The model's response text
|
||||
"""
|
||||
self._delegate.record_response(text)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset the tracker completely."""
|
||||
self._delegate.start_session()
|
||||
|
||||
@@ -220,6 +220,7 @@ class AgentTeam:
|
||||
web: Optional[Any] = None, # Union[bool, WebConfig] - web search/fetch
|
||||
reflection: Optional[Any] = None, # Union[bool, ReflectionConfig] - self-reflection
|
||||
caching: Optional[Any] = None, # Union[bool, CachingConfig] - caching
|
||||
learn: Optional[Any] = None, # Union[bool, LearnConfig] - continuous learning
|
||||
):
|
||||
"""
|
||||
Initialize AgentManager with consolidated feature parameters.
|
||||
@@ -243,8 +244,10 @@ class AgentTeam:
|
||||
guardrails: Guardrails configuration (bool | Callable | GuardrailConfig)
|
||||
web: Web search/fetch configuration (bool | WebConfig)
|
||||
reflection: Self-reflection configuration (bool | ReflectionConfig)
|
||||
learn: Continuous learning configuration (bool | LearnConfig)
|
||||
"""
|
||||
# Store new params for propagation to agents
|
||||
self._learn = learn
|
||||
self.llm = llm # Store default LLM for API consistency
|
||||
self._autonomy = autonomy
|
||||
self._knowledge = knowledge
|
||||
|
||||
@@ -319,3 +319,106 @@ class BackgroundRunner:
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._cleanup_task = None
|
||||
|
||||
# ── Sync-friendly wrappers ──────────────────────────────────────────
|
||||
|
||||
def submit_sync(
|
||||
self,
|
||||
func: Callable,
|
||||
args: tuple = (),
|
||||
kwargs: Optional[Dict[str, Any]] = None,
|
||||
name: Optional[str] = None,
|
||||
timeout: Optional[float] = None,
|
||||
on_complete: Optional[Callable[[BackgroundTask], None]] = None,
|
||||
) -> BackgroundTask:
|
||||
"""Submit a task from synchronous code.
|
||||
|
||||
Creates a background event loop (once) and schedules the task
|
||||
there. Returns the ``BackgroundTask`` immediately so callers
|
||||
can poll ``task.status`` / ``task.result`` without blocking.
|
||||
|
||||
Args:
|
||||
func: Function to execute (sync or async)
|
||||
args: Positional arguments for *func*
|
||||
kwargs: Keyword arguments for *func*
|
||||
name: Optional human-readable task name
|
||||
timeout: Optional timeout in seconds
|
||||
on_complete: Callback when task completes
|
||||
|
||||
Returns:
|
||||
BackgroundTask for tracking
|
||||
"""
|
||||
loop = _get_bg_loop()
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self.submit(
|
||||
func, args=args, kwargs=kwargs,
|
||||
name=name, timeout=timeout, on_complete=on_complete,
|
||||
),
|
||||
loop,
|
||||
)
|
||||
# Block briefly to get the BackgroundTask object (not the task result).
|
||||
return future.result(timeout=5)
|
||||
|
||||
def submit_agent_sync(
|
||||
self,
|
||||
agent: Any,
|
||||
prompt: str,
|
||||
name: Optional[str] = None,
|
||||
timeout: Optional[float] = None,
|
||||
on_complete: Optional[Callable[[BackgroundTask], None]] = None,
|
||||
) -> BackgroundTask:
|
||||
"""Submit an agent task from synchronous code.
|
||||
|
||||
Convenience wrapper around :meth:`submit_sync` that resolves
|
||||
the agent's callable automatically.
|
||||
|
||||
Args:
|
||||
agent: Agent instance (must have ``start``, ``chat``, or ``run``)
|
||||
prompt: Prompt to send to the agent
|
||||
name: Optional task name
|
||||
timeout: Optional timeout in seconds
|
||||
on_complete: Callback when task completes
|
||||
|
||||
Returns:
|
||||
BackgroundTask for tracking
|
||||
"""
|
||||
loop = _get_bg_loop()
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self.submit_agent(
|
||||
agent, prompt,
|
||||
name=name, timeout=timeout, on_complete=on_complete,
|
||||
),
|
||||
loop,
|
||||
)
|
||||
return future.result(timeout=5)
|
||||
|
||||
|
||||
# ── Module-level background event loop (lazy, daemon) ───────────────────
|
||||
|
||||
import threading as _threading
|
||||
|
||||
_bg_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
_bg_lock = _threading.Lock()
|
||||
|
||||
|
||||
def _get_bg_loop() -> asyncio.AbstractEventLoop:
|
||||
"""Return a running event loop on a background daemon thread.
|
||||
|
||||
Created once on first call; reused thereafter. The thread is a
|
||||
daemon so it won't prevent process exit.
|
||||
"""
|
||||
global _bg_loop
|
||||
if _bg_loop is not None and _bg_loop.is_running():
|
||||
return _bg_loop
|
||||
|
||||
with _bg_lock:
|
||||
# Double-check after acquiring lock.
|
||||
if _bg_loop is not None and _bg_loop.is_running():
|
||||
return _bg_loop
|
||||
|
||||
_bg_loop = asyncio.new_event_loop()
|
||||
|
||||
t = _threading.Thread(target=_bg_loop.run_forever, daemon=True)
|
||||
t.start()
|
||||
|
||||
return _bg_loop
|
||||
@@ -22,6 +22,8 @@ if TYPE_CHECKING:
|
||||
ChatCommandProtocol,
|
||||
ProbeResult,
|
||||
HealthResult,
|
||||
EmailProtocol,
|
||||
EmailInbox,
|
||||
)
|
||||
from .protocols import (
|
||||
BotProtocol,
|
||||
@@ -37,6 +39,8 @@ from .protocols import (
|
||||
BotOSProtocol,
|
||||
ProbeResult,
|
||||
HealthResult,
|
||||
EmailProtocol,
|
||||
EmailInbox,
|
||||
)
|
||||
from .config import BotConfig, BotOSConfig
|
||||
|
||||
@@ -56,4 +60,6 @@ __all__ = [
|
||||
"BotOSConfig",
|
||||
"ProbeResult",
|
||||
"HealthResult",
|
||||
"EmailProtocol",
|
||||
"EmailInbox",
|
||||
]
|
||||
@@ -29,8 +29,10 @@ class BotConfig:
|
||||
"""
|
||||
|
||||
token: str = ""
|
||||
mode: str = "poll" # poll | ws | webhook | hybrid
|
||||
webhook_url: Optional[str] = None
|
||||
webhook_path: str = "/webhook"
|
||||
webhook_port: int = 8080
|
||||
polling_interval: float = 1.0
|
||||
allowed_users: List[str] = field(default_factory=list)
|
||||
allowed_channels: List[str] = field(default_factory=list)
|
||||
@@ -47,7 +49,12 @@ class BotConfig:
|
||||
group_policy: str = "mention_only" # respond_all, mention_only, command_only
|
||||
|
||||
# Default tools enabled for all bots
|
||||
default_tools: List[str] = field(default_factory=lambda: ["execute_command", "search_web", "schedule_add", "schedule_list", "schedule_remove"])
|
||||
default_tools: List[str] = field(default_factory=lambda: [
|
||||
"execute_command", "search_web", "web_crawl",
|
||||
"schedule_add", "schedule_list", "schedule_remove",
|
||||
"store_memory", "search_memory",
|
||||
"store_learning", "search_learning",
|
||||
])
|
||||
|
||||
# Auto-approve tool calls (useful for trusted environments)
|
||||
auto_approve_tools: bool = False # If True, skip confirmation for tool execution
|
||||
@@ -72,8 +79,10 @@ class BotConfig:
|
||||
"""Convert to dictionary (hides sensitive data)."""
|
||||
return {
|
||||
"token": "***" if self.token else None,
|
||||
"mode": self.mode,
|
||||
"webhook_url": self.webhook_url,
|
||||
"webhook_path": self.webhook_path,
|
||||
"webhook_port": self.webhook_port,
|
||||
"polling_interval": self.polling_interval,
|
||||
"allowed_users": self.allowed_users,
|
||||
"allowed_channels": self.allowed_channels,
|
||||
@@ -97,7 +106,17 @@ class BotConfig:
|
||||
@property
|
||||
def is_webhook_mode(self) -> bool:
|
||||
"""Whether bot is configured for webhook mode."""
|
||||
return bool(self.webhook_url)
|
||||
return self.mode == "webhook" or bool(self.webhook_url)
|
||||
|
||||
@property
|
||||
def is_ws_mode(self) -> bool:
|
||||
"""Whether bot is configured for WebSocket mode."""
|
||||
return self.mode == "ws"
|
||||
|
||||
@property
|
||||
def is_hybrid_mode(self) -> bool:
|
||||
"""Whether bot is configured for hybrid mode (WS + slow poll)."""
|
||||
return self.mode == "hybrid"
|
||||
|
||||
def is_user_allowed(self, user_id: str) -> bool:
|
||||
"""Check if a user is allowed to interact with the bot."""
|
||||
|
||||
@@ -613,6 +613,96 @@ class ChatCommandProtocol(Protocol):
|
||||
...
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# EmailProtocol — email-specific bot capabilities
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmailInbox:
|
||||
"""Information about an email inbox.
|
||||
|
||||
Attributes:
|
||||
id: Unique inbox identifier
|
||||
email_address: The inbox's email address
|
||||
domain: Domain of the inbox
|
||||
created_at: When the inbox was created (ISO format)
|
||||
"""
|
||||
|
||||
id: str
|
||||
email_address: str
|
||||
domain: str = ""
|
||||
created_at: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"email_address": self.email_address,
|
||||
"domain": self.domain,
|
||||
"created_at": self.created_at,
|
||||
}
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class EmailProtocol(Protocol):
|
||||
"""Protocol for email bots with inbox lifecycle management.
|
||||
|
||||
Extends BotProtocol with email-specific capabilities like
|
||||
programmatic inbox creation, listing, and deletion.
|
||||
|
||||
This protocol is optional — only AgentMail-style bots that
|
||||
support API-based inbox management need to implement it.
|
||||
Traditional IMAP/SMTP bots (EmailBot) do not implement this.
|
||||
|
||||
Example:
|
||||
# Check if a bot supports inbox management
|
||||
if isinstance(bot, EmailProtocol):
|
||||
inbox = await bot.create_inbox(domain="mycompany.com")
|
||||
print(f"Created: {inbox['email_address']}")
|
||||
"""
|
||||
|
||||
@property
|
||||
def email_address(self) -> Optional[str]:
|
||||
"""The bot's current email address."""
|
||||
...
|
||||
|
||||
async def create_inbox(
|
||||
self,
|
||||
domain: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a new email inbox programmatically.
|
||||
|
||||
Args:
|
||||
domain: Optional custom domain for the inbox
|
||||
**kwargs: Additional provider-specific parameters
|
||||
|
||||
Returns:
|
||||
Dict with at least 'id' and 'email_address' keys
|
||||
"""
|
||||
...
|
||||
|
||||
async def list_inboxes(self) -> List[Dict[str, Any]]:
|
||||
"""List all inboxes accessible to this bot.
|
||||
|
||||
Returns:
|
||||
List of inbox dicts with 'id' and 'email_address' keys
|
||||
"""
|
||||
...
|
||||
|
||||
async def delete_inbox(self, inbox_id: str) -> bool:
|
||||
"""Delete an inbox.
|
||||
|
||||
Args:
|
||||
inbox_id: ID of the inbox to delete
|
||||
|
||||
Returns:
|
||||
True if deleted successfully
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# BotOS Protocol — multi-platform bot orchestration
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
@@ -57,6 +57,34 @@ class LearnScope(str, Enum):
|
||||
SHARED = "shared" # Shared with all agents
|
||||
|
||||
|
||||
class LearnMode(str, Enum):
|
||||
"""Learning mode for automatic learning extraction.
|
||||
|
||||
DISABLED: No automatic learning (manual capture only)
|
||||
AGENTIC: Agent autonomously extracts and stores learnings after each conversation
|
||||
PROPOSE: (Future) Agent proposes learnings for user approval before storing.
|
||||
Note: PROPOSE mode is defined but not yet implemented. Using it will
|
||||
behave the same as DISABLED until the approval workflow is added.
|
||||
"""
|
||||
DISABLED = "disabled"
|
||||
AGENTIC = "agentic"
|
||||
PROPOSE = "propose"
|
||||
|
||||
|
||||
class LearnBackend(str, Enum):
|
||||
"""Storage backend for learning data.
|
||||
|
||||
FILE: JSON files (default, zero dependencies)
|
||||
SQLITE: SQLite database
|
||||
REDIS: Redis (requires redis package)
|
||||
MONGODB: MongoDB (requires pymongo)
|
||||
"""
|
||||
FILE = "file"
|
||||
SQLITE = "sqlite"
|
||||
REDIS = "redis"
|
||||
MONGODB = "mongodb"
|
||||
|
||||
|
||||
@dataclass
|
||||
class LearnConfig:
|
||||
"""
|
||||
@@ -66,16 +94,21 @@ class LearnConfig:
|
||||
to improve future responses. All learning data is stored within the memory system.
|
||||
|
||||
Usage:
|
||||
# Simple enable
|
||||
Agent(memory=MemoryConfig(learn=True))
|
||||
# Simple enable (top-level param)
|
||||
Agent(learn=True)
|
||||
|
||||
# With specific capabilities
|
||||
Agent(memory=MemoryConfig(
|
||||
learn=LearnConfig(
|
||||
persona=True, # User preferences
|
||||
insights=True, # Observations
|
||||
patterns=True, # Reusable knowledge
|
||||
)
|
||||
Agent(learn=LearnConfig(
|
||||
persona=True, # User preferences
|
||||
insights=True, # Observations
|
||||
patterns=True, # Reusable knowledge
|
||||
mode="agentic", # Auto-extract learnings
|
||||
))
|
||||
|
||||
# With database backend
|
||||
Agent(learn=LearnConfig(
|
||||
backend="sqlite",
|
||||
db_url="sqlite:///learn.db",
|
||||
))
|
||||
"""
|
||||
# Learning capabilities
|
||||
@@ -87,15 +120,19 @@ class LearnConfig:
|
||||
feedback: bool = False # Outcome signals
|
||||
improvements: bool = False # Self-improvement proposals
|
||||
|
||||
# Learning mode
|
||||
mode: Union[str, LearnMode] = LearnMode.DISABLED # How to extract learnings
|
||||
|
||||
# Scope configuration
|
||||
scope: Union[str, LearnScope] = LearnScope.PRIVATE
|
||||
|
||||
# Storage configuration
|
||||
store_path: Optional[str] = None # Custom storage path
|
||||
# Storage backend configuration
|
||||
backend: Union[str, LearnBackend] = LearnBackend.FILE # Storage backend
|
||||
db_url: Optional[str] = None # Database connection URL (for non-file backends)
|
||||
store_path: Optional[str] = None # Custom storage path (for file backend)
|
||||
|
||||
# Maintenance settings
|
||||
auto_consolidate: bool = True # Auto-consolidate learnings
|
||||
retention_days: Optional[int] = None # Days to retain (None = forever)
|
||||
# LLM for learning extraction
|
||||
llm: Optional[str] = None # LLM model for extracting learnings (defaults to agent's LLM)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
@@ -107,10 +144,12 @@ class LearnConfig:
|
||||
"decisions": self.decisions,
|
||||
"feedback": self.feedback,
|
||||
"improvements": self.improvements,
|
||||
"mode": self.mode.value if isinstance(self.mode, LearnMode) else self.mode,
|
||||
"scope": self.scope.value if isinstance(self.scope, LearnScope) else self.scope,
|
||||
"backend": self.backend.value if isinstance(self.backend, LearnBackend) else self.backend,
|
||||
"db_url": self.db_url,
|
||||
"store_path": self.store_path,
|
||||
"auto_consolidate": self.auto_consolidate,
|
||||
"retention_days": self.retention_days,
|
||||
"llm": self.llm,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -10,13 +10,25 @@ from .models import BudgetAllocation
|
||||
|
||||
# Model token limits (context window sizes)
|
||||
MODEL_LIMITS: Dict[str, int] = {
|
||||
# OpenAI
|
||||
# OpenAI — gpt-5 family (1M context window)
|
||||
"gpt-5": 1047576,
|
||||
"gpt-5-mini": 1047576,
|
||||
"gpt-5-nano": 1047576,
|
||||
# OpenAI — gpt-4.1 family (1M context window)
|
||||
"gpt-4.1": 1047576,
|
||||
"gpt-4.1-mini": 1047576,
|
||||
"gpt-4.1-nano": 1047576,
|
||||
# OpenAI — gpt-4o family
|
||||
"gpt-4o": 128000,
|
||||
"gpt-4o-mini": 128000,
|
||||
"gpt-4-turbo": 128000,
|
||||
"gpt-4": 8192,
|
||||
"gpt-3.5-turbo": 16385,
|
||||
"gpt-3.5-turbo-16k": 16385,
|
||||
# OpenAI — o-series
|
||||
"o3-mini": 200000,
|
||||
"o3": 200000,
|
||||
"o4-mini": 200000,
|
||||
# Anthropic
|
||||
"claude-3-5-sonnet": 200000,
|
||||
"claude-3-5-haiku": 200000,
|
||||
@@ -33,11 +45,15 @@ MODEL_LIMITS: Dict[str, int] = {
|
||||
|
||||
# Default output reserves by model family
|
||||
OUTPUT_RESERVES: Dict[str, int] = {
|
||||
"gpt-5": 32768,
|
||||
"gpt-4.1": 32768,
|
||||
"gpt-4o": 16384,
|
||||
"gpt-4o-mini": 16384,
|
||||
"gpt-4-turbo": 4096,
|
||||
"gpt-4": 4096,
|
||||
"gpt-3.5-turbo": 4096,
|
||||
"o3": 100000,
|
||||
"o4-mini": 100000,
|
||||
"claude-3": 8192,
|
||||
"gemini": 8192,
|
||||
"default": 8000,
|
||||
|
||||
@@ -99,7 +99,6 @@ def test_db_hook_lifecycle():
|
||||
instructions="Test agent",
|
||||
db=mock_db,
|
||||
session_id="test-session-001",
|
||||
verbose=False
|
||||
)
|
||||
|
||||
# Verify db is stored
|
||||
@@ -125,7 +124,6 @@ def test_db_none_unchanged_behavior():
|
||||
agent = Agent(
|
||||
name="TestAgent",
|
||||
instructions="Test agent",
|
||||
verbose=False
|
||||
)
|
||||
|
||||
# Verify db is None
|
||||
@@ -157,7 +155,6 @@ def test_session_id_auto_generation():
|
||||
instructions="Test agent",
|
||||
db=mock_db,
|
||||
# No session_id provided
|
||||
verbose=False
|
||||
)
|
||||
|
||||
# Trigger db initialization
|
||||
|
||||
@@ -22,6 +22,7 @@ class DoomLoopType(Enum):
|
||||
NO_PROGRESS = "no_progress" # No meaningful progress
|
||||
CIRCULAR_PLAN = "circular_plan" # Plan loops back to start
|
||||
RESOURCE_EXHAUSTION = "resource_exhaustion" # Budget exceeded
|
||||
REPEATED_OUTPUT = "repeated_output" # Same output text repeated (content chanting)
|
||||
|
||||
|
||||
class RecoveryAction(Enum):
|
||||
@@ -58,6 +59,10 @@ class DoomLoopConfig:
|
||||
initial_backoff: float = 1.0 # Initial backoff in seconds
|
||||
backoff_multiplier: float = 2.0 # Backoff multiplier
|
||||
max_backoff: float = 30.0 # Maximum backoff
|
||||
|
||||
# Content streaming detection
|
||||
max_repeated_chunks: int = 8 # Max identical output chunks before flagging
|
||||
content_chunk_size: int = 50 # Sliding window chunk size (chars)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -116,6 +121,7 @@ class DoomLoopDetector:
|
||||
self._start_time: Optional[float] = None
|
||||
self._progress_markers: List[str] = []
|
||||
self._current_backoff: float = self.config.initial_backoff
|
||||
self._content_chunk_counts: Dict[str, int] = {} # hash -> count
|
||||
|
||||
def start_session(self):
|
||||
"""Start a new detection session."""
|
||||
@@ -125,6 +131,7 @@ class DoomLoopDetector:
|
||||
self._recovery_attempts = 0
|
||||
self._progress_markers.clear()
|
||||
self._current_backoff = self.config.initial_backoff
|
||||
self._content_chunk_counts.clear()
|
||||
|
||||
def record_action(
|
||||
self,
|
||||
@@ -162,6 +169,25 @@ class DoomLoopDetector:
|
||||
if self.is_doom_loop():
|
||||
self._handle_loop_detection()
|
||||
|
||||
def record_response(self, text: str):
|
||||
"""
|
||||
Record model response text for content streaming loop detection.
|
||||
|
||||
Hashes sliding window chunks and tracks repetition counts.
|
||||
Inspired by gemini-cli's sliding window approach.
|
||||
|
||||
Args:
|
||||
text: The model's response text
|
||||
"""
|
||||
if not text or len(text) < self.config.content_chunk_size:
|
||||
return
|
||||
|
||||
chunk_size = self.config.content_chunk_size
|
||||
for i in range(0, len(text) - chunk_size + 1, chunk_size):
|
||||
chunk = text[i:i + chunk_size]
|
||||
chunk_hash = hashlib.md5(chunk.encode()).hexdigest()[:16]
|
||||
self._content_chunk_counts[chunk_hash] = self._content_chunk_counts.get(chunk_hash, 0) + 1
|
||||
|
||||
def mark_progress(self, marker: str):
|
||||
"""
|
||||
Mark meaningful progress.
|
||||
@@ -178,6 +204,10 @@ class DoomLoopDetector:
|
||||
Returns:
|
||||
True if doom loop detected
|
||||
"""
|
||||
# Content streaming loop can occur even without recorded actions
|
||||
if self._check_content_loop():
|
||||
return True
|
||||
|
||||
if len(self._actions) < 2:
|
||||
return False
|
||||
|
||||
@@ -201,6 +231,10 @@ class DoomLoopDetector:
|
||||
if self._check_resource_exhaustion():
|
||||
return True
|
||||
|
||||
# Check for content streaming loops (repeated output text)
|
||||
if self._check_content_loop():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_loop_type(self) -> Optional[DoomLoopType]:
|
||||
@@ -215,6 +249,8 @@ class DoomLoopDetector:
|
||||
return DoomLoopType.NO_PROGRESS
|
||||
if self._check_resource_exhaustion():
|
||||
return DoomLoopType.RESOURCE_EXHAUSTION
|
||||
if self._check_content_loop():
|
||||
return DoomLoopType.REPEATED_OUTPUT
|
||||
return None
|
||||
|
||||
def get_loop_event(self) -> Optional[DoomLoopEvent]:
|
||||
@@ -371,6 +407,19 @@ class DoomLoopDetector:
|
||||
elapsed = time.time() - self._start_time
|
||||
return elapsed > self.config.max_total_time
|
||||
|
||||
def _check_content_loop(self) -> bool:
|
||||
"""Check for repeated output text (content streaming loop).
|
||||
|
||||
Uses sliding window chunk hashing. If any chunk hash appears
|
||||
more than max_repeated_chunks times, it indicates the model
|
||||
is generating repetitive output (chanting).
|
||||
"""
|
||||
if not self._content_chunk_counts:
|
||||
return False
|
||||
|
||||
threshold = self.config.max_repeated_chunks
|
||||
return any(count >= threshold for count in self._content_chunk_counts.values())
|
||||
|
||||
def _determine_recovery_action(self, loop_type: DoomLoopType) -> RecoveryAction:
|
||||
"""Determine appropriate recovery action."""
|
||||
# If max recovery attempts reached, abort
|
||||
@@ -408,6 +457,9 @@ class DoomLoopDetector:
|
||||
DoomLoopType.RESOURCE_EXHAUSTION: (
|
||||
f"Exceeded time limit of {self.config.max_total_time}s"
|
||||
),
|
||||
DoomLoopType.REPEATED_OUTPUT: (
|
||||
f"Model output repeating — {self.config.max_repeated_chunks}+ identical chunks detected"
|
||||
),
|
||||
}
|
||||
return descriptions.get(loop_type, "Unknown loop type")
|
||||
|
||||
|
||||
@@ -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,34 +20,59 @@ class Chunking:
|
||||
@cached_property
|
||||
def SUPPORTED_CHUNKERS(self) -> Dict[str, Any]:
|
||||
"""Lazy load chunker classes."""
|
||||
chunkers = {}
|
||||
try:
|
||||
from chonkie import (
|
||||
TokenChunker,
|
||||
SentenceChunker,
|
||||
SemanticChunker,
|
||||
SDPMChunker,
|
||||
LateChunker,
|
||||
RecursiveChunker,
|
||||
CodeChunker,
|
||||
NeuralChunker,
|
||||
SlumberChunker
|
||||
)
|
||||
from chonkie import TokenChunker
|
||||
chunkers['token'] = TokenChunker
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from chonkie import SentenceChunker
|
||||
chunkers['sentence'] = SentenceChunker
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from chonkie import SemanticChunker
|
||||
chunkers['semantic'] = SemanticChunker
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from chonkie import SDPMChunker
|
||||
chunkers['sdpm'] = SDPMChunker
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from chonkie import LateChunker
|
||||
chunkers['late'] = LateChunker
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from chonkie import RecursiveChunker
|
||||
chunkers['recursive'] = RecursiveChunker
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from chonkie import CodeChunker
|
||||
chunkers['code'] = CodeChunker
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from chonkie import NeuralChunker
|
||||
chunkers['neural'] = NeuralChunker
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from chonkie import SlumberChunker
|
||||
chunkers['slumber'] = SlumberChunker
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if not chunkers:
|
||||
raise ImportError(
|
||||
"chonkie package not found. Please install it using: pip install 'praisonaiagents[knowledge]'"
|
||||
)
|
||||
|
||||
return {
|
||||
'token': TokenChunker,
|
||||
'sentence': SentenceChunker,
|
||||
'semantic': SemanticChunker,
|
||||
'sdpm': SDPMChunker,
|
||||
'late': LateChunker,
|
||||
'recursive': RecursiveChunker,
|
||||
'code': CodeChunker,
|
||||
'neural': NeuralChunker,
|
||||
'slumber': SlumberChunker
|
||||
}
|
||||
|
||||
return chunkers
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -116,7 +141,18 @@ class Chunking:
|
||||
if self._chunker is None:
|
||||
chunker_cls = self.SUPPORTED_CHUNKERS[self.chunker_type]
|
||||
common_params = self._get_chunker_params()
|
||||
self._chunker = chunker_cls(**common_params)
|
||||
try:
|
||||
self._chunker = chunker_cls(**common_params)
|
||||
except TypeError:
|
||||
# Chonkie API may have changed — retry with just chunk_size
|
||||
fallback_params = {'chunk_size': self.chunk_size}
|
||||
if 'embedding_model' in common_params:
|
||||
fallback_params['embedding_model'] = common_params['embedding_model']
|
||||
try:
|
||||
self._chunker = chunker_cls(**fallback_params)
|
||||
except TypeError:
|
||||
# Last resort — no params
|
||||
self._chunker = chunker_cls()
|
||||
|
||||
return self._chunker
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import os
|
||||
import logging
|
||||
import uuid
|
||||
import time
|
||||
from datetime import datetime
|
||||
from .chunking import Chunking
|
||||
from functools import cached_property
|
||||
@@ -414,8 +412,9 @@ class Knowledge:
|
||||
|
||||
@cached_property
|
||||
def config(self):
|
||||
# Generate unique collection name for each instance (only if not provided in config)
|
||||
default_collection = f"test_{int(time.time())}_{str(uuid.uuid4())[:8]}"
|
||||
# Use deterministic collection name for persistence across restarts
|
||||
# Only generate unique name if explicitly requested via config
|
||||
default_collection = "praisonai_knowledge"
|
||||
from ..paths import get_project_data_dir
|
||||
persist_dir = str(get_project_data_dir())
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ class LLM:
|
||||
MODEL_WINDOWS = {
|
||||
# OpenAI
|
||||
"gpt-4": 6144, # 8,192 actual
|
||||
"gpt-4o-mini": 96000, # 128,000 actual
|
||||
"gpt-4o": 96000, # 128,000 actual
|
||||
"gpt-4o-mini": 96000, # 128,000 actual
|
||||
"gpt-4-turbo": 96000, # 128,000 actual
|
||||
"o1-preview": 96000, # 128,000 actual
|
||||
@@ -1685,6 +1685,167 @@ Now provide your final answer using this result. Summarize the information natur
|
||||
# Initial callback is in agent._chat_completion, so this triggers for all loop iterations
|
||||
_get_display_functions()['execute_sync_callback']('llm_start', model=self.model, agent_name=agent_name)
|
||||
|
||||
# ── Responses API path ──────────────────────────────────
|
||||
# OpenAI models use the Responses API which returns text
|
||||
# and tool calls as separate output items — no content:null.
|
||||
if self._supports_responses_api():
|
||||
responses_params = self._build_responses_params(
|
||||
messages=messages,
|
||||
tools=formatted_tools,
|
||||
temperature=temperature,
|
||||
output_json=output_json,
|
||||
output_pydantic=output_pydantic,
|
||||
**{k: v for k, v in kwargs.items() if k != 'reasoning_steps'},
|
||||
)
|
||||
|
||||
if stream:
|
||||
response_text, tool_calls = self._stream_responses_api(
|
||||
responses_params,
|
||||
verbose=verbose,
|
||||
stream_callback=stream_callback,
|
||||
emit_events=emit_events,
|
||||
current_time=current_time,
|
||||
)
|
||||
else:
|
||||
resp = self._call_responses_api(**responses_params)
|
||||
response_text, tool_calls, _reasoning = self._extract_from_responses_output(resp)
|
||||
|
||||
# Track token usage
|
||||
if self.metrics and hasattr(resp, 'usage') and resp.usage:
|
||||
usage = resp.usage
|
||||
tokens_in = getattr(usage, 'input_tokens', 0) or 0
|
||||
tokens_out = getattr(usage, 'output_tokens', 0) or 0
|
||||
# Emit llm_end with metrics
|
||||
llm_latency_ms = (time.time() - current_time) * 1000
|
||||
_get_display_functions()['execute_sync_callback'](
|
||||
'llm_end', model=self.model,
|
||||
tokens_in=tokens_in, tokens_out=tokens_out,
|
||||
cost=None, latency_ms=llm_latency_ms,
|
||||
)
|
||||
|
||||
# Emit reasoning if present
|
||||
if _emit and _reasoning:
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.DELTA_TEXT,
|
||||
timestamp=time.perf_counter(),
|
||||
content=_reasoning, is_reasoning=True,
|
||||
))
|
||||
# Emit text content as stream event
|
||||
if _emit and response_text:
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.DELTA_TEXT,
|
||||
timestamp=time.perf_counter(),
|
||||
content=response_text, is_reasoning=False,
|
||||
))
|
||||
|
||||
# Build a Chat-Completions-compatible final_response
|
||||
# so all downstream code (display, callbacks) works unchanged
|
||||
final_response = {
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": response_text,
|
||||
"tool_calls": tool_calls,
|
||||
}
|
||||
}]
|
||||
}
|
||||
|
||||
# Display / callback handling (same as Chat Completions path)
|
||||
if verbose and not interaction_displayed:
|
||||
_get_display_functions()['display_interaction'](
|
||||
original_prompt, response_text,
|
||||
markdown=markdown,
|
||||
generation_time=time.time() - current_time,
|
||||
console=self.console,
|
||||
agent_name=agent_name, agent_role=agent_role,
|
||||
agent_tools=agent_tools, task_name=task_name,
|
||||
task_description=task_description, task_id=task_id,
|
||||
)
|
||||
interaction_displayed = True
|
||||
callback_executed = True
|
||||
elif not callback_executed:
|
||||
_get_display_functions()['execute_sync_callback'](
|
||||
'interaction',
|
||||
message=original_prompt, response=response_text,
|
||||
markdown=markdown,
|
||||
generation_time=time.time() - current_time,
|
||||
agent_name=agent_name, agent_role=agent_role,
|
||||
agent_tools=agent_tools, task_name=task_name,
|
||||
task_description=task_description, task_id=task_id,
|
||||
)
|
||||
callback_executed = True
|
||||
|
||||
# Emit llm_content for intermediate narrative display
|
||||
if response_text and response_text.strip():
|
||||
_get_display_functions()['execute_sync_callback'](
|
||||
'llm_content',
|
||||
content=response_text.strip(),
|
||||
agent_name=agent_name,
|
||||
)
|
||||
|
||||
# ── Handle tool calls ───────────────────────────────
|
||||
if tool_calls and execute_tool_fn:
|
||||
serializable_tool_calls = self._serialize_tool_calls(tool_calls)
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": response_text,
|
||||
"tool_calls": serializable_tool_calls,
|
||||
})
|
||||
|
||||
tool_results = []
|
||||
for tool_call in tool_calls:
|
||||
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call)
|
||||
|
||||
logging.debug(f"[RESPONSES_API] Executing tool {function_name} with args: {arguments}")
|
||||
tool_result = execute_tool_fn(function_name, arguments, tool_call_id=tool_call_id)
|
||||
tool_results.append(tool_result)
|
||||
accumulated_tool_results.append(tool_result)
|
||||
|
||||
if verbose:
|
||||
display_message = f"Agent {agent_name} called function '{function_name}' with arguments: {arguments}\n"
|
||||
display_message += f"Function returned: {tool_result}" if tool_result else "Function returned no output"
|
||||
_get_display_functions()['display_tool_call'](display_message, console=self.console)
|
||||
|
||||
result_str = json.dumps(tool_result) if tool_result else "empty"
|
||||
_get_display_functions()['execute_sync_callback'](
|
||||
'tool_call',
|
||||
message=f"Calling function: {function_name}",
|
||||
tool_name=function_name,
|
||||
tool_input=arguments,
|
||||
tool_output=result_str[:200] if result_str else None,
|
||||
)
|
||||
|
||||
# Append tool result as standard tool message
|
||||
if tool_result is None:
|
||||
content = "Function returned an empty output"
|
||||
elif isinstance(tool_result, dict) and 'error' in tool_result:
|
||||
content = f"Error: {tool_result.get('error', 'Unknown error')}. Please inform the user."
|
||||
elif isinstance(tool_result, list) and tool_result and isinstance(tool_result[0], dict) and 'error' in tool_result[0]:
|
||||
content = f"Error: {tool_result[0].get('error', 'Unknown error')}. Please inform the user."
|
||||
else:
|
||||
content = json.dumps(tool_result)
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_id,
|
||||
"content": content,
|
||||
})
|
||||
|
||||
# Safety: break after 5 iterations
|
||||
if iteration_count >= 5:
|
||||
final_response_text = response_text.strip() if response_text else "Task completed."
|
||||
break
|
||||
|
||||
iteration_count += 1
|
||||
response_text = ""
|
||||
continue
|
||||
else:
|
||||
# No tool calls — we have a final answer
|
||||
if iteration_count > 0:
|
||||
final_response_text = response_text.strip() if response_text else ""
|
||||
else:
|
||||
final_response_text = response_text.strip() if response_text else ""
|
||||
break
|
||||
|
||||
# ── Chat Completions path (unchanged) ───────────────────
|
||||
# If reasoning_steps is True, do a single non-streaming call
|
||||
if reasoning_steps:
|
||||
resp = litellm.completion(
|
||||
@@ -2069,18 +2230,59 @@ Now provide your final answer using this result. Summarize the information natur
|
||||
}]
|
||||
}
|
||||
else:
|
||||
# For non-streaming + non-verbose: no display_generating (per user requirements)
|
||||
final_response = litellm.completion(
|
||||
**self._build_completion_params(
|
||||
messages=messages,
|
||||
tools=formatted_tools,
|
||||
temperature=temperature,
|
||||
stream=False,
|
||||
output_json=output_json,
|
||||
output_pydantic=output_pydantic,
|
||||
**kwargs
|
||||
# For non-streaming + non-verbose path
|
||||
# When editor output is enabled, use silent streaming
|
||||
# to capture intermediate text (OpenAI returns content:null
|
||||
# on tool-call turns in non-streaming mode, but streaming
|
||||
# delivers text tokens before tool call tokens).
|
||||
_use_silent_stream = False
|
||||
try:
|
||||
from ..output.editor import is_editor_output_enabled
|
||||
_use_silent_stream = is_editor_output_enabled()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if _use_silent_stream:
|
||||
# Silent streaming: no display, just accumulate
|
||||
response_text = ""
|
||||
tool_calls = []
|
||||
for chunk in litellm.completion(
|
||||
**self._build_completion_params(
|
||||
messages=messages,
|
||||
tools=formatted_tools,
|
||||
temperature=temperature,
|
||||
stream=True,
|
||||
output_json=output_json,
|
||||
output_pydantic=output_pydantic,
|
||||
**kwargs
|
||||
)
|
||||
):
|
||||
if chunk and chunk.choices and chunk.choices[0].delta:
|
||||
delta = chunk.choices[0].delta
|
||||
response_text, tool_calls = self._process_stream_delta(
|
||||
delta, response_text, tool_calls, formatted_tools
|
||||
)
|
||||
final_response = {
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": response_text,
|
||||
"tool_calls": tool_calls if tool_calls else None
|
||||
}
|
||||
}]
|
||||
}
|
||||
else:
|
||||
# Standard non-streaming path
|
||||
final_response = litellm.completion(
|
||||
**self._build_completion_params(
|
||||
messages=messages,
|
||||
tools=formatted_tools,
|
||||
temperature=temperature,
|
||||
stream=False,
|
||||
output_json=output_json,
|
||||
output_pydantic=output_pydantic,
|
||||
**kwargs
|
||||
)
|
||||
)
|
||||
)
|
||||
# Handle None content from Gemini
|
||||
response_content = final_response["choices"][0]["message"].get("content")
|
||||
response_text = response_content if response_content is not None else ""
|
||||
@@ -2271,6 +2473,16 @@ Now provide your final answer using this result. Summarize the information natur
|
||||
# Reset repair count on successful tool call
|
||||
self._current_repair_count = 0
|
||||
|
||||
# Emit intermediate LLM narrative for display
|
||||
# This fires for ALL responses with text, not just tool-call turns.
|
||||
# Competitors (Gemini CLI, Codex, OpenCode) display this inline.
|
||||
if response_text and response_text.strip():
|
||||
_get_display_functions()['execute_sync_callback'](
|
||||
'llm_content',
|
||||
content=response_text.strip(),
|
||||
agent_name=agent_name,
|
||||
)
|
||||
|
||||
# Handle tool calls - Sequential tool calling logic
|
||||
if tool_calls and execute_tool_fn:
|
||||
# Convert tool_calls to a serializable format for all providers
|
||||
@@ -2313,7 +2525,7 @@ Now provide your final answer using this result. Summarize the information natur
|
||||
arguments = self._validate_and_filter_ollama_arguments(function_name, arguments, tools)
|
||||
|
||||
logging.debug(f"[TOOL_EXEC_DEBUG] About to execute tool {function_name} with args: {arguments}")
|
||||
tool_result = execute_tool_fn(function_name, arguments)
|
||||
tool_result = execute_tool_fn(function_name, arguments, tool_call_id=tool_call_id)
|
||||
logging.debug(f"[TOOL_EXEC_DEBUG] Tool execution result: {tool_result}")
|
||||
tool_results.append(tool_result) # Store the result
|
||||
accumulated_tool_results.append(tool_result) # Accumulate across iterations
|
||||
@@ -2959,6 +3171,15 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
logging.error(f"Non-recoverable stream iterator error: {iterator_error}")
|
||||
raise iterator_error
|
||||
|
||||
# Emit intermediate LLM narrative for display
|
||||
# (parity with non-streaming path above)
|
||||
if response_text and response_text.strip():
|
||||
_get_display_functions()['execute_sync_callback'](
|
||||
'llm_content',
|
||||
content=response_text.strip(),
|
||||
agent_name=agent_name,
|
||||
)
|
||||
|
||||
# After streaming completes, handle tool calls if present
|
||||
if tool_calls and execute_tool_fn:
|
||||
# Add assistant message with tool calls to conversation
|
||||
@@ -2981,8 +3202,8 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call, is_ollama)
|
||||
|
||||
try:
|
||||
# Execute the tool
|
||||
tool_result = execute_tool_fn(function_name, arguments)
|
||||
# Execute the tool (pass tool_call_id for event correlation)
|
||||
tool_result = execute_tool_fn(function_name, arguments, tool_call_id=tool_call_id)
|
||||
|
||||
# Add tool result to messages
|
||||
tool_message = self._create_tool_message(function_name, tool_result, tool_call_id, is_ollama)
|
||||
@@ -3197,6 +3418,104 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
reasoning_content = None
|
||||
tool_calls = []
|
||||
|
||||
# ── Responses API path (async) ──────────────────────────
|
||||
if self._supports_responses_api():
|
||||
responses_params = self._build_responses_params(
|
||||
messages=messages,
|
||||
tools=formatted_tools,
|
||||
temperature=temperature,
|
||||
output_json=output_json,
|
||||
output_pydantic=output_pydantic,
|
||||
**{k: v for k, v in kwargs.items() if k != 'reasoning_steps'},
|
||||
)
|
||||
current_time = time.time()
|
||||
|
||||
if stream:
|
||||
response_text, tool_calls = await self._stream_responses_api_async(
|
||||
responses_params,
|
||||
stream_callback=kwargs.get('stream_callback'),
|
||||
emit_events=kwargs.get('emit_events', False),
|
||||
)
|
||||
else:
|
||||
resp = await self._call_responses_api_async(**responses_params)
|
||||
response_text, tool_calls, _reasoning = self._extract_from_responses_output(resp)
|
||||
|
||||
# Build Chat-Completions-compatible final_response
|
||||
final_response = {
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": response_text,
|
||||
"tool_calls": tool_calls,
|
||||
}
|
||||
}]
|
||||
}
|
||||
|
||||
# Display / callback handling
|
||||
if verbose and not interaction_displayed:
|
||||
_get_display_functions()['display_interaction'](
|
||||
original_prompt, response_text,
|
||||
markdown=markdown,
|
||||
generation_time=time.time() - current_time,
|
||||
console=self.console,
|
||||
agent_name=agent_name, agent_role=agent_role,
|
||||
agent_tools=agent_tools, task_name=task_name,
|
||||
task_description=task_description, task_id=task_id,
|
||||
)
|
||||
interaction_displayed = True
|
||||
callback_executed = True
|
||||
|
||||
# Emit llm_content for narrative display
|
||||
if response_text and response_text.strip():
|
||||
_get_display_functions()['execute_sync_callback'](
|
||||
'llm_content',
|
||||
content=response_text.strip(),
|
||||
agent_name=agent_name,
|
||||
)
|
||||
|
||||
# ── Handle tool calls ────────────────────────────
|
||||
if tool_calls and execute_tool_fn:
|
||||
serializable_tool_calls = self._serialize_tool_calls(tool_calls)
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": response_text,
|
||||
"tool_calls": serializable_tool_calls,
|
||||
})
|
||||
|
||||
for tool_call in tool_calls:
|
||||
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call)
|
||||
logging.debug(f"[RESPONSES_API_ASYNC] Executing tool {function_name}")
|
||||
tool_result = execute_tool_fn(function_name, arguments, tool_call_id=tool_call_id)
|
||||
accumulated_tool_results.append(tool_result)
|
||||
|
||||
if verbose:
|
||||
display_message = f"Agent {agent_name} called function '{function_name}' with arguments: {arguments}\n"
|
||||
display_message += f"Function returned: {tool_result}" if tool_result else "Function returned no output"
|
||||
_get_display_functions()['display_tool_call'](display_message, console=self.console)
|
||||
|
||||
# Append tool result
|
||||
if tool_result is None:
|
||||
content = "Function returned an empty output"
|
||||
elif isinstance(tool_result, dict) and 'error' in tool_result:
|
||||
content = f"Error: {tool_result.get('error', 'Unknown error')}. Please inform the user."
|
||||
else:
|
||||
content = json.dumps(tool_result)
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_id,
|
||||
"content": content,
|
||||
})
|
||||
|
||||
if iteration_count >= 5:
|
||||
final_response_text = response_text.strip() if response_text else "Task completed."
|
||||
break
|
||||
|
||||
iteration_count += 1
|
||||
continue
|
||||
else:
|
||||
final_response_text = response_text.strip() if response_text else ""
|
||||
break
|
||||
|
||||
# ── Chat Completions path (unchanged) ───────────────
|
||||
if reasoning_steps and iteration_count == 0:
|
||||
# Non-streaming call to capture reasoning
|
||||
resp = await litellm.acompletion(
|
||||
@@ -4069,6 +4388,501 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
return params
|
||||
|
||||
# ── Responses API support ───────────────────────────────────────────
|
||||
# These methods add a Responses API call path alongside the existing
|
||||
# Chat Completions path. Model detection gates which path is used.
|
||||
|
||||
def _supports_responses_api(self) -> bool:
|
||||
"""
|
||||
Check if the current model supports the OpenAI Responses API.
|
||||
|
||||
Returns True for OpenAI models that support the Responses API
|
||||
(gpt-4o, gpt-4-turbo, o1, o3, chatgpt-4o, etc.).
|
||||
Returns False for Anthropic, Gemini, Ollama, local, and all other models.
|
||||
"""
|
||||
if not self.model:
|
||||
return False
|
||||
model_lower = self.model.lower()
|
||||
|
||||
# Exclude non-OpenAI providers that happen to use similar model names
|
||||
if self._is_ollama_provider():
|
||||
return False
|
||||
if self._is_gemini_model():
|
||||
return False
|
||||
if self._is_anthropic_model():
|
||||
return False
|
||||
|
||||
# Explicit prefix check for OpenAI & Azure OpenAI
|
||||
# Note: Azure models often start with the deployment name, but LiteLLM
|
||||
# normalises them to "azure/<model>" – the prefixes below handle both.
|
||||
_openai_prefixes = (
|
||||
"gpt-4o", "gpt-4-turbo", "gpt-4.1", "gpt-4.5",
|
||||
"o1", "o3", "o4",
|
||||
"chatgpt-4o",
|
||||
"azure/gpt-4o", "azure/gpt-4-turbo", "azure/gpt-4.1",
|
||||
"azure/o1", "azure/o3", "azure/o4",
|
||||
)
|
||||
# Match on prefix (gpt-4o, gpt-4o-mini, gpt-4o-2024-08-06, etc.)
|
||||
for prefix in _openai_prefixes:
|
||||
if model_lower.startswith(prefix):
|
||||
return True
|
||||
|
||||
# Any model routed through openai/ prefix
|
||||
if model_lower.startswith("openai/"):
|
||||
remainder = model_lower[len("openai/"):]
|
||||
for prefix in _openai_prefixes:
|
||||
if remainder.startswith(prefix):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _build_responses_params(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
tools: Optional[List[Dict]] = None,
|
||||
temperature: Optional[float] = None,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Build parameters for ``litellm.responses()`` from the Chat Completions-
|
||||
style message list that PraisonAI already constructs.
|
||||
|
||||
Mapping:
|
||||
- system message → ``instructions``
|
||||
- remaining messages → ``input`` (list of role/content items)
|
||||
- tools → ``tools`` (Responses API uses same function schema)
|
||||
- temperature, max_tokens, etc. → direct pass-through
|
||||
"""
|
||||
params: Dict[str, Any] = {"model": self.model}
|
||||
|
||||
# ── Extract system instructions ─────────────────────────────────
|
||||
instructions = None
|
||||
input_items: List[Dict[str, Any]] = []
|
||||
for msg in messages:
|
||||
role = msg.get("role", "")
|
||||
if role in ("system", "developer"):
|
||||
# Accumulate system / developer messages as instructions
|
||||
content = msg.get("content", "")
|
||||
if instructions is None:
|
||||
instructions = content
|
||||
else:
|
||||
instructions += "\n" + content
|
||||
else:
|
||||
# user / assistant / tool messages become input items
|
||||
# Special handling for Chat Completions→Responses API format:
|
||||
if role == "assistant" and msg.get("tool_calls"):
|
||||
# Assistant message with tool_calls → emit text (if any)
|
||||
# then emit function_call items for each tool call
|
||||
content = msg.get("content")
|
||||
if content and content.strip():
|
||||
input_items.append({"role": "assistant", "content": content})
|
||||
for tc in msg["tool_calls"]:
|
||||
fn = tc.get("function", tc) if isinstance(tc, dict) else tc
|
||||
fn_name = fn.get("name", "") if isinstance(fn, dict) else getattr(fn, "name", "")
|
||||
fn_args = fn.get("arguments", "{}") if isinstance(fn, dict) else getattr(fn, "arguments", "{}")
|
||||
tc_id = tc.get("id", "") if isinstance(tc, dict) else getattr(tc, "id", "")
|
||||
input_items.append({
|
||||
"type": "function_call",
|
||||
"call_id": tc_id,
|
||||
"name": fn_name,
|
||||
"arguments": fn_args if isinstance(fn_args, str) else json.dumps(fn_args),
|
||||
})
|
||||
elif role == "tool":
|
||||
# Tool result → function_call_output
|
||||
input_items.append({
|
||||
"type": "function_call_output",
|
||||
"call_id": msg.get("tool_call_id", ""),
|
||||
"output": msg.get("content", ""),
|
||||
})
|
||||
else:
|
||||
input_items.append(msg)
|
||||
|
||||
if instructions:
|
||||
params["instructions"] = instructions
|
||||
params["input"] = input_items
|
||||
|
||||
# ── Tools ───────────────────────────────────────────────────────
|
||||
if tools:
|
||||
# Responses API tool format differs from Chat Completions:
|
||||
# Chat Completions: {"type": "function", "function": {"name": ..., "parameters": ...}}
|
||||
# Responses API: {"type": "function", "name": ..., "parameters": ...}
|
||||
# Transform Chat Completions format → Responses API format
|
||||
responses_tools = []
|
||||
for tool in tools:
|
||||
if isinstance(tool, dict) and "function" in tool:
|
||||
fn = tool["function"]
|
||||
responses_tool = {
|
||||
"type": "function",
|
||||
"name": fn.get("name", ""),
|
||||
}
|
||||
if "description" in fn:
|
||||
responses_tool["description"] = fn["description"]
|
||||
if "parameters" in fn:
|
||||
responses_tool["parameters"] = fn["parameters"]
|
||||
if fn.get("strict") is not None:
|
||||
responses_tool["strict"] = fn["strict"]
|
||||
responses_tools.append(responses_tool)
|
||||
else:
|
||||
# Already in Responses API format or unknown — pass through
|
||||
responses_tools.append(tool)
|
||||
params["tools"] = responses_tools
|
||||
if "tool_choice" not in kwargs:
|
||||
params["tool_choice"] = "auto"
|
||||
|
||||
# ── Scalar params ───────────────────────────────────────────────
|
||||
if temperature is not None:
|
||||
params["temperature"] = temperature
|
||||
if self.max_tokens:
|
||||
params["max_output_tokens"] = self.max_tokens
|
||||
if self.top_p:
|
||||
params["top_p"] = self.top_p
|
||||
if self.base_url:
|
||||
params["base_url"] = self.base_url
|
||||
if self.api_key:
|
||||
params["api_key"] = self.api_key
|
||||
if self.timeout:
|
||||
params["timeout"] = self.timeout
|
||||
|
||||
# ── Structured output ───────────────────────────────────────────
|
||||
output_json = kwargs.pop("output_json", None)
|
||||
output_pydantic = kwargs.pop("output_pydantic", None)
|
||||
schema_model = output_json or output_pydantic
|
||||
if schema_model:
|
||||
from .model_capabilities import supports_structured_outputs
|
||||
if supports_structured_outputs(self.model):
|
||||
if hasattr(schema_model, 'model_json_schema'):
|
||||
schema = schema_model.model_json_schema()
|
||||
params['text'] = {
|
||||
"format": {
|
||||
"type": "json_schema",
|
||||
"name": getattr(schema_model, '__name__', 'response'),
|
||||
"schema": schema,
|
||||
"strict": True,
|
||||
}
|
||||
}
|
||||
|
||||
# ── Pass through remaining kwargs (stream, etc.) ────────────────
|
||||
# Filter out internal PraisonAI params that are not Responses API params
|
||||
_internal = {
|
||||
'output_json', 'output_pydantic',
|
||||
'reflection', 'self_reflect', 'max_reflect', 'min_reflect', 'reflect_llm',
|
||||
'verbose', 'markdown',
|
||||
'agent_name', 'agent_role', 'agent_tools',
|
||||
'task_name', 'task_description', 'task_id',
|
||||
'execute_tool_fn', 'stream_callback', 'emit_events',
|
||||
'console', 'reasoning_steps',
|
||||
}
|
||||
for k, v in kwargs.items():
|
||||
if k not in _internal and v is not None:
|
||||
params[k] = v
|
||||
|
||||
return params
|
||||
|
||||
def _call_responses_api(self, **params) -> Any:
|
||||
"""
|
||||
Call ``litellm.responses()`` synchronously with retry support.
|
||||
Returns a ``ResponsesAPIResponse`` object.
|
||||
"""
|
||||
import litellm
|
||||
return self._call_with_retry(litellm.responses, **params)
|
||||
|
||||
async def _call_responses_api_async(self, **params) -> Any:
|
||||
"""
|
||||
Call ``litellm.aresponses()`` asynchronously with retry support.
|
||||
Returns a ``ResponsesAPIResponse`` object.
|
||||
"""
|
||||
import litellm
|
||||
return await self._call_with_retry_async(litellm.aresponses, **params)
|
||||
|
||||
def _extract_from_responses_output(self, response) -> tuple:
|
||||
"""
|
||||
Parse a ``ResponsesAPIResponse`` into the same (text, tool_calls,
|
||||
reasoning_content) tuple that the Chat Completions path produces.
|
||||
|
||||
The Responses API returns an ``output`` list where:
|
||||
- items with type="message" contain text in content[].text
|
||||
- items with type="function_call" contain tool call info
|
||||
Unlike Chat Completions, text and tool calls are *always* separate
|
||||
items, so ``content`` is never null when text is present.
|
||||
"""
|
||||
response_text = ""
|
||||
tool_calls: List[Dict[str, Any]] = []
|
||||
reasoning_content = None
|
||||
|
||||
output_items = getattr(response, 'output', None) or []
|
||||
for item in output_items:
|
||||
# Handle both dict and object access
|
||||
if isinstance(item, dict):
|
||||
item_type = item.get("type", "")
|
||||
else:
|
||||
item_type = getattr(item, "type", "")
|
||||
|
||||
if item_type == "message":
|
||||
# Extract text from content blocks
|
||||
if isinstance(item, dict):
|
||||
content_list = item.get("content", [])
|
||||
else:
|
||||
content_list = getattr(item, "content", [])
|
||||
for content_block in content_list:
|
||||
if isinstance(content_block, dict):
|
||||
block_type = content_block.get("type", "")
|
||||
block_text = content_block.get("text", "") or ""
|
||||
else:
|
||||
block_type = getattr(content_block, "type", "")
|
||||
block_text = getattr(content_block, "text", "") or ""
|
||||
|
||||
if block_type == "output_text":
|
||||
response_text += block_text
|
||||
elif block_type == "reasoning":
|
||||
reasoning_content = (reasoning_content or "") + block_text
|
||||
|
||||
elif item_type == "function_call":
|
||||
# Extract tool call information
|
||||
if isinstance(item, dict):
|
||||
call_id = item.get("call_id", item.get("id", f"tool_{len(tool_calls)}"))
|
||||
fn_name = item.get("name", "")
|
||||
fn_args = item.get("arguments", "{}")
|
||||
else:
|
||||
call_id = getattr(item, "call_id", None) or getattr(item, "id", f"tool_{len(tool_calls)}")
|
||||
fn_name = getattr(item, "name", "")
|
||||
fn_args = getattr(item, "arguments", "{}")
|
||||
|
||||
tool_calls.append({
|
||||
"id": call_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": fn_name,
|
||||
"arguments": fn_args if isinstance(fn_args, str) else json.dumps(fn_args),
|
||||
}
|
||||
})
|
||||
|
||||
elif item_type == "reasoning":
|
||||
# Some models return top-level reasoning items
|
||||
if isinstance(item, dict):
|
||||
reasoning_text = item.get("summary", "") or item.get("text", "")
|
||||
else:
|
||||
reasoning_text = getattr(item, "summary", "") or getattr(item, "text", "")
|
||||
if reasoning_text:
|
||||
reasoning_content = (reasoning_content or "") + str(reasoning_text)
|
||||
|
||||
return response_text, tool_calls if tool_calls else None, reasoning_content
|
||||
|
||||
def _stream_responses_api(
|
||||
self,
|
||||
params: Dict[str, Any],
|
||||
verbose: bool = True,
|
||||
stream_callback: Optional[Callable] = None,
|
||||
emit_events: bool = False,
|
||||
current_time: Optional[float] = None,
|
||||
) -> tuple:
|
||||
"""
|
||||
Stream a Responses API call, accumulating text and tool calls.
|
||||
|
||||
Returns the same (response_text, tool_calls) tuple as the Chat
|
||||
Completions streaming path. Emits StreamEvents when configured.
|
||||
"""
|
||||
import litellm
|
||||
|
||||
params["stream"] = True
|
||||
response_text = ""
|
||||
# tool_calls accumulator: {output_index: {"id":..., "name":..., "arguments":...}}
|
||||
_pending_tools: Dict[int, Dict[str, Any]] = {}
|
||||
_emit = emit_events and stream_callback is not None
|
||||
|
||||
if _emit:
|
||||
from ..streaming.events import StreamEvent, StreamEventType
|
||||
|
||||
for event in litellm.responses(**params):
|
||||
# Access event type — handle both object and dict
|
||||
if isinstance(event, dict):
|
||||
evt_type = event.get("type", "")
|
||||
else:
|
||||
evt_type = getattr(event, "type", "")
|
||||
|
||||
# ── Text delta ──────────────────────────────────────────
|
||||
if evt_type == "response.output_text.delta":
|
||||
delta_text = (event.get("delta", "") if isinstance(event, dict)
|
||||
else getattr(event, "delta", ""))
|
||||
response_text += delta_text
|
||||
if _emit and delta_text:
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.DELTA_TEXT,
|
||||
timestamp=time.perf_counter(),
|
||||
content=delta_text,
|
||||
is_reasoning=False,
|
||||
))
|
||||
|
||||
# ── Reasoning delta ─────────────────────────────────────
|
||||
elif evt_type == "response.reasoning_summary_text.delta":
|
||||
delta_text = (event.get("delta", "") if isinstance(event, dict)
|
||||
else getattr(event, "delta", ""))
|
||||
if _emit and delta_text:
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.DELTA_TEXT,
|
||||
timestamp=time.perf_counter(),
|
||||
content=delta_text,
|
||||
is_reasoning=True,
|
||||
))
|
||||
|
||||
# ── Function call arguments delta ───────────────────────
|
||||
elif evt_type == "response.function_call_arguments.delta":
|
||||
idx = (event.get("output_index", 0) if isinstance(event, dict)
|
||||
else getattr(event, "output_index", 0))
|
||||
delta_args = (event.get("delta", "") if isinstance(event, dict)
|
||||
else getattr(event, "delta", ""))
|
||||
if idx not in _pending_tools:
|
||||
item_id = (event.get("item_id", f"tool_{idx}") if isinstance(event, dict)
|
||||
else getattr(event, "item_id", f"tool_{idx}"))
|
||||
_pending_tools[idx] = {
|
||||
"id": item_id,
|
||||
"name": "",
|
||||
"arguments": "",
|
||||
}
|
||||
_pending_tools[idx]["arguments"] += delta_args
|
||||
|
||||
# ── Output item done — finalise tool call metadata ──────
|
||||
elif evt_type == "response.output_item.done":
|
||||
if isinstance(event, dict):
|
||||
item = event.get("item", {})
|
||||
idx = event.get("output_index", 0)
|
||||
else:
|
||||
item = getattr(event, "item", None) or {}
|
||||
idx = getattr(event, "output_index", 0)
|
||||
|
||||
if isinstance(item, dict):
|
||||
i_type = item.get("type", "")
|
||||
else:
|
||||
i_type = getattr(item, "type", "")
|
||||
|
||||
if i_type == "function_call":
|
||||
if isinstance(item, dict):
|
||||
call_id = item.get("call_id", item.get("id", f"tool_{idx}"))
|
||||
fn_name = item.get("name", "")
|
||||
fn_args = item.get("arguments", "")
|
||||
else:
|
||||
call_id = getattr(item, "call_id", None) or getattr(item, "id", f"tool_{idx}")
|
||||
fn_name = getattr(item, "name", "")
|
||||
fn_args = getattr(item, "arguments", "")
|
||||
|
||||
if idx in _pending_tools:
|
||||
_pending_tools[idx]["id"] = call_id
|
||||
_pending_tools[idx]["name"] = fn_name
|
||||
# Prefer the accumulated delta args if available, else use final
|
||||
if not _pending_tools[idx]["arguments"]:
|
||||
_pending_tools[idx]["arguments"] = fn_args
|
||||
else:
|
||||
_pending_tools[idx] = {
|
||||
"id": call_id,
|
||||
"name": fn_name,
|
||||
"arguments": fn_args,
|
||||
}
|
||||
|
||||
# ── Build tool_calls list in output_index order ─────────────
|
||||
tool_calls: List[Dict[str, Any]] = []
|
||||
for idx in sorted(_pending_tools.keys()):
|
||||
tc = _pending_tools[idx]
|
||||
tool_calls.append({
|
||||
"id": tc["id"],
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tc["name"],
|
||||
"arguments": tc["arguments"],
|
||||
},
|
||||
})
|
||||
|
||||
return response_text, tool_calls if tool_calls else None
|
||||
|
||||
async def _stream_responses_api_async(
|
||||
self,
|
||||
params: Dict[str, Any],
|
||||
stream_callback: Optional[Callable] = None,
|
||||
emit_events: bool = False,
|
||||
) -> tuple:
|
||||
"""
|
||||
Async streaming for Responses API.
|
||||
Returns (response_text, tool_calls).
|
||||
"""
|
||||
import litellm
|
||||
|
||||
params["stream"] = True
|
||||
response_text = ""
|
||||
_pending_tools: Dict[int, Dict[str, Any]] = {}
|
||||
_emit = emit_events and stream_callback is not None
|
||||
|
||||
if _emit:
|
||||
from ..streaming.events import StreamEvent, StreamEventType
|
||||
|
||||
async for event in await litellm.aresponses(**params):
|
||||
if isinstance(event, dict):
|
||||
evt_type = event.get("type", "")
|
||||
else:
|
||||
evt_type = getattr(event, "type", "")
|
||||
|
||||
if evt_type == "response.output_text.delta":
|
||||
delta_text = (event.get("delta", "") if isinstance(event, dict)
|
||||
else getattr(event, "delta", ""))
|
||||
response_text += delta_text
|
||||
if _emit and delta_text:
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.DELTA_TEXT,
|
||||
timestamp=time.perf_counter(),
|
||||
content=delta_text,
|
||||
is_reasoning=False,
|
||||
))
|
||||
|
||||
elif evt_type == "response.function_call_arguments.delta":
|
||||
idx = (event.get("output_index", 0) if isinstance(event, dict)
|
||||
else getattr(event, "output_index", 0))
|
||||
delta_args = (event.get("delta", "") if isinstance(event, dict)
|
||||
else getattr(event, "delta", ""))
|
||||
if idx not in _pending_tools:
|
||||
item_id = (event.get("item_id", f"tool_{idx}") if isinstance(event, dict)
|
||||
else getattr(event, "item_id", f"tool_{idx}"))
|
||||
_pending_tools[idx] = {"id": item_id, "name": "", "arguments": ""}
|
||||
_pending_tools[idx]["arguments"] += delta_args
|
||||
|
||||
elif evt_type == "response.output_item.done":
|
||||
if isinstance(event, dict):
|
||||
item = event.get("item", {})
|
||||
idx = event.get("output_index", 0)
|
||||
else:
|
||||
item = getattr(event, "item", None) or {}
|
||||
idx = getattr(event, "output_index", 0)
|
||||
|
||||
if isinstance(item, dict):
|
||||
i_type = item.get("type", "")
|
||||
else:
|
||||
i_type = getattr(item, "type", "")
|
||||
|
||||
if i_type == "function_call":
|
||||
if isinstance(item, dict):
|
||||
call_id = item.get("call_id", item.get("id", f"tool_{idx}"))
|
||||
fn_name = item.get("name", "")
|
||||
fn_args = item.get("arguments", "")
|
||||
else:
|
||||
call_id = getattr(item, "call_id", None) or getattr(item, "id", f"tool_{idx}")
|
||||
fn_name = getattr(item, "name", "")
|
||||
fn_args = getattr(item, "arguments", "")
|
||||
|
||||
if idx in _pending_tools:
|
||||
_pending_tools[idx]["id"] = call_id
|
||||
_pending_tools[idx]["name"] = fn_name
|
||||
if not _pending_tools[idx]["arguments"]:
|
||||
_pending_tools[idx]["arguments"] = fn_args
|
||||
else:
|
||||
_pending_tools[idx] = {"id": call_id, "name": fn_name, "arguments": fn_args}
|
||||
|
||||
tool_calls: List[Dict[str, Any]] = []
|
||||
for idx in sorted(_pending_tools.keys()):
|
||||
tc = _pending_tools[idx]
|
||||
tool_calls.append({
|
||||
"id": tc["id"],
|
||||
"type": "function",
|
||||
"function": {"name": tc["name"], "arguments": tc["arguments"]},
|
||||
})
|
||||
|
||||
return response_text, tool_calls if tool_calls else None
|
||||
|
||||
def _prepare_response_logging(self, temperature: float, stream: bool, verbose: bool, markdown: bool, **kwargs) -> Optional[Dict[str, Any]]:
|
||||
"""Prepare debug logging information for response methods"""
|
||||
if logging.getLogger().getEffectiveLevel() == logging.DEBUG:
|
||||
|
||||
@@ -539,6 +539,229 @@ class OpenAIClient:
|
||||
|
||||
return result
|
||||
|
||||
# ─── Responses API helpers ──────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _supports_responses_api(model: str) -> bool:
|
||||
"""
|
||||
Returns True for OpenAI models that support the Responses API.
|
||||
Mirrors LLM._supports_responses_api but works as a static method
|
||||
on a plain model string.
|
||||
"""
|
||||
if not model:
|
||||
return False
|
||||
model_lower = model.lower()
|
||||
|
||||
# Exclude non-OpenAI providers
|
||||
_non_openai = (
|
||||
"ollama/", "ollama_chat/", "gemini/", "vertex_ai/",
|
||||
"anthropic/", "claude", "deepseek/",
|
||||
)
|
||||
for prefix in _non_openai:
|
||||
if model_lower.startswith(prefix):
|
||||
return False
|
||||
|
||||
_openai_prefixes = (
|
||||
"gpt-4o", "gpt-4-turbo", "gpt-4.1", "gpt-4.5",
|
||||
"gpt-5",
|
||||
"o1", "o3", "o4",
|
||||
"chatgpt-4o",
|
||||
"azure/gpt-4o", "azure/gpt-4-turbo", "azure/gpt-4.1",
|
||||
"azure/gpt-5",
|
||||
"azure/o1", "azure/o3", "azure/o4",
|
||||
)
|
||||
for prefix in _openai_prefixes:
|
||||
if model_lower.startswith(prefix):
|
||||
return True
|
||||
|
||||
# openai/ prefix
|
||||
if model_lower.startswith("openai/"):
|
||||
remainder = model_lower[len("openai/"):]
|
||||
for prefix in _openai_prefixes:
|
||||
if remainder.startswith(prefix):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _use_responses_api(self, model: str) -> bool:
|
||||
"""
|
||||
Returns True if Responses API should be used for this call.
|
||||
Only returns True when:
|
||||
1. The model supports Responses API (OpenAI model)
|
||||
2. No custom base_url is set (not a local server)
|
||||
3. The openai SDK has the 'responses' attribute
|
||||
"""
|
||||
if self.base_url:
|
||||
return False
|
||||
if not self._supports_responses_api(model):
|
||||
return False
|
||||
try:
|
||||
return hasattr(self.sync_client, 'responses')
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _build_responses_input(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
model: str,
|
||||
temperature: float = 1.0,
|
||||
tools: Optional[List[Dict]] = None,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Transform Chat Completions-style messages and tools into
|
||||
Responses API parameters.
|
||||
|
||||
Mapping:
|
||||
- system message → ``instructions``
|
||||
- remaining msgs → ``input`` (list of role/content items)
|
||||
- tools are flattened from Chat Completions nested format
|
||||
"""
|
||||
params: Dict[str, Any] = {"model": model}
|
||||
|
||||
# ── Extract system instructions ──────────────────────────────────
|
||||
instructions = None
|
||||
input_items: List[Dict[str, Any]] = []
|
||||
for msg in messages:
|
||||
role = msg.get("role", "")
|
||||
if role in ("system", "developer"):
|
||||
content = msg.get("content", "")
|
||||
if instructions is None:
|
||||
instructions = content
|
||||
else:
|
||||
instructions += "\n" + content
|
||||
else:
|
||||
# Handle Chat Completions → Responses API format transforms
|
||||
if role == "assistant" and msg.get("tool_calls"):
|
||||
content = msg.get("content")
|
||||
if content and content.strip():
|
||||
input_items.append({"role": "assistant", "content": content})
|
||||
for tc in msg["tool_calls"]:
|
||||
fn = tc.get("function", tc) if isinstance(tc, dict) else tc
|
||||
fn_name = fn.get("name", "") if isinstance(fn, dict) else getattr(fn, "name", "")
|
||||
fn_args = fn.get("arguments", "{}") if isinstance(fn, dict) else getattr(fn, "arguments", "{}")
|
||||
tc_id = tc.get("id", "") if isinstance(tc, dict) else getattr(tc, "id", "")
|
||||
# Skip items with empty name — API rejects them
|
||||
if not fn_name:
|
||||
continue
|
||||
input_items.append({
|
||||
"type": "function_call",
|
||||
"call_id": tc_id,
|
||||
"name": fn_name,
|
||||
"arguments": fn_args if isinstance(fn_args, str) else json.dumps(fn_args),
|
||||
})
|
||||
elif role == "tool":
|
||||
input_items.append({
|
||||
"type": "function_call_output",
|
||||
"call_id": msg.get("tool_call_id", ""),
|
||||
"output": msg.get("content", ""),
|
||||
})
|
||||
else:
|
||||
input_items.append(msg)
|
||||
|
||||
if instructions:
|
||||
params["instructions"] = instructions
|
||||
params["input"] = input_items
|
||||
|
||||
# ── Transform tools to Responses API format ──────────────────────
|
||||
if tools:
|
||||
responses_tools = []
|
||||
for tool in tools:
|
||||
if isinstance(tool, dict) and "function" in tool:
|
||||
fn = tool["function"]
|
||||
responses_tool = {
|
||||
"type": "function",
|
||||
"name": fn.get("name", ""),
|
||||
}
|
||||
if "description" in fn:
|
||||
responses_tool["description"] = fn["description"]
|
||||
if "parameters" in fn:
|
||||
responses_tool["parameters"] = fn["parameters"]
|
||||
if fn.get("strict") is not None:
|
||||
responses_tool["strict"] = fn["strict"]
|
||||
responses_tools.append(responses_tool)
|
||||
else:
|
||||
responses_tools.append(tool)
|
||||
params["tools"] = responses_tools
|
||||
params["tool_choice"] = kwargs.pop("tool_choice", "auto")
|
||||
|
||||
# ── Scalar params ────────────────────────────────────────────────
|
||||
if temperature is not None:
|
||||
params["temperature"] = temperature
|
||||
|
||||
return params
|
||||
|
||||
def _responses_to_chat_completion(self, response) -> ChatCompletion:
|
||||
"""
|
||||
Wrap a Responses API response into a ChatCompletion dataclass
|
||||
so that all downstream code (tool loop, display, callbacks)
|
||||
works without modification.
|
||||
"""
|
||||
response_text = ""
|
||||
tool_calls_list: List[ToolCall] = []
|
||||
|
||||
output_items = getattr(response, 'output', None) or []
|
||||
for item in output_items:
|
||||
item_type = getattr(item, "type", "") if not isinstance(item, dict) else item.get("type", "")
|
||||
|
||||
if item_type == "message":
|
||||
content_list = getattr(item, "content", []) if not isinstance(item, dict) else item.get("content", [])
|
||||
for content_block in content_list:
|
||||
if isinstance(content_block, dict):
|
||||
block_type = content_block.get("type", "")
|
||||
block_text = content_block.get("text", "") or ""
|
||||
else:
|
||||
block_type = getattr(content_block, "type", "")
|
||||
block_text = getattr(content_block, "text", "") or ""
|
||||
if block_type == "output_text":
|
||||
response_text += block_text
|
||||
|
||||
elif item_type == "function_call":
|
||||
if isinstance(item, dict):
|
||||
call_id = item.get("call_id", item.get("id", f"tool_{len(tool_calls_list)}"))
|
||||
fn_name = item.get("name", "")
|
||||
fn_args = item.get("arguments", "{}")
|
||||
else:
|
||||
call_id = getattr(item, "call_id", None) or getattr(item, "id", f"tool_{len(tool_calls_list)}")
|
||||
fn_name = getattr(item, "name", "")
|
||||
fn_args = getattr(item, "arguments", "{}")
|
||||
tool_calls_list.append(ToolCall(
|
||||
id=call_id,
|
||||
type="function",
|
||||
function={
|
||||
"name": fn_name,
|
||||
"arguments": fn_args if isinstance(fn_args, str) else json.dumps(fn_args),
|
||||
}
|
||||
))
|
||||
|
||||
# Build usage
|
||||
raw_usage = getattr(response, 'usage', None)
|
||||
usage = None
|
||||
if raw_usage:
|
||||
usage = CompletionUsage(
|
||||
prompt_tokens=getattr(raw_usage, 'input_tokens', 0),
|
||||
completion_tokens=getattr(raw_usage, 'output_tokens', 0),
|
||||
total_tokens=getattr(raw_usage, 'total_tokens', 0),
|
||||
)
|
||||
|
||||
message = ChatCompletionMessage(
|
||||
content=response_text if response_text else None,
|
||||
role="assistant",
|
||||
tool_calls=tool_calls_list if tool_calls_list else None,
|
||||
)
|
||||
choice = Choice(
|
||||
finish_reason="tool_calls" if tool_calls_list else "stop",
|
||||
index=0,
|
||||
message=message,
|
||||
)
|
||||
return ChatCompletion(
|
||||
id=getattr(response, 'id', 'resp_unknown'),
|
||||
choices=[choice],
|
||||
created=int(time.time()),
|
||||
model=getattr(response, 'model', ''),
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
def _generate_tool_definition(self, func: Callable) -> Optional[Dict]:
|
||||
"""Generate a tool definition from a callable function."""
|
||||
try:
|
||||
@@ -675,6 +898,44 @@ class OpenAIClient:
|
||||
metadata={"model": model, "message_count": len(messages)}
|
||||
))
|
||||
|
||||
# ── Responses API path ───────────────────────────────────────
|
||||
if self._use_responses_api(model):
|
||||
try:
|
||||
resp_params = self._build_responses_input(
|
||||
messages, model, temperature, tools, **kwargs
|
||||
)
|
||||
raw = self.sync_client.responses.create(**resp_params)
|
||||
final_response = self._responses_to_chat_completion(raw)
|
||||
|
||||
# Display the response text if display_fn provided
|
||||
response_text = final_response.choices[0].message.content or ""
|
||||
if display_fn and response_text:
|
||||
from rich.markdown import Markdown
|
||||
console.print(Markdown(response_text))
|
||||
|
||||
# Emit stream events for compatibility
|
||||
if _emit:
|
||||
now = time.perf_counter()
|
||||
if response_text:
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.FIRST_TOKEN,
|
||||
timestamp=now, content=response_text[:50]
|
||||
))
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.LAST_TOKEN,
|
||||
timestamp=now
|
||||
))
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.STREAM_END,
|
||||
timestamp=now, metadata={"responses_api": True}
|
||||
))
|
||||
|
||||
return final_response
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Responses API streaming failed, falling back: {e}")
|
||||
# Fall through to Chat Completions streaming
|
||||
|
||||
# ── Chat Completions streaming path ─────────────────────────
|
||||
# Create the response stream
|
||||
response_stream = self.sync_client.chat.completions.create(
|
||||
model=model,
|
||||
@@ -897,6 +1158,49 @@ class OpenAIClient:
|
||||
else:
|
||||
stream_callback(event)
|
||||
|
||||
# ── Responses API path ───────────────────────────────────────
|
||||
if self._use_responses_api(model):
|
||||
try:
|
||||
resp_params = self._build_responses_input(
|
||||
messages, model, temperature, tools, **kwargs
|
||||
)
|
||||
raw = await self.async_client.responses.create(**resp_params)
|
||||
final_response = self._responses_to_chat_completion(raw)
|
||||
|
||||
# Display the response text if display_fn provided
|
||||
response_text = final_response.choices[0].message.content or ""
|
||||
if display_fn and response_text:
|
||||
from rich.markdown import Markdown
|
||||
console.print(Markdown(response_text))
|
||||
|
||||
# Emit stream events for compatibility
|
||||
if _emit:
|
||||
now = time.perf_counter()
|
||||
async def _emit_event(evt):
|
||||
if asyncio.iscoroutinefunction(stream_callback):
|
||||
await stream_callback(evt)
|
||||
else:
|
||||
stream_callback(evt)
|
||||
if response_text:
|
||||
await _emit_event(StreamEvent(
|
||||
type=StreamEventType.FIRST_TOKEN,
|
||||
timestamp=now, content=response_text[:50]
|
||||
))
|
||||
await _emit_event(StreamEvent(
|
||||
type=StreamEventType.LAST_TOKEN,
|
||||
timestamp=now
|
||||
))
|
||||
await _emit_event(StreamEvent(
|
||||
type=StreamEventType.STREAM_END,
|
||||
timestamp=now, metadata={"responses_api": True}
|
||||
))
|
||||
|
||||
return final_response
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Responses API async streaming failed, falling back: {e}")
|
||||
# Fall through to Chat Completions streaming
|
||||
|
||||
# ── Chat Completions streaming path ─────────────────────────
|
||||
# Create the response stream
|
||||
response_stream = await self.async_client.chat.completions.create(
|
||||
model=model,
|
||||
@@ -1078,6 +1382,20 @@ class OpenAIClient:
|
||||
Returns:
|
||||
ChatCompletion object or stream iterator
|
||||
"""
|
||||
# ── Responses API path (non-streaming only) ────────────────────
|
||||
if not stream and self._use_responses_api(model):
|
||||
try:
|
||||
resp_params = self._build_responses_input(
|
||||
messages, model, temperature, tools,
|
||||
tool_choice=tool_choice, **kwargs
|
||||
)
|
||||
raw = self.sync_client.responses.create(**resp_params)
|
||||
return self._responses_to_chat_completion(raw)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Responses API failed, falling back to Chat Completions: {e}")
|
||||
# Fall through to Chat Completions
|
||||
|
||||
# ── Chat Completions path ─────────────────────────────────────
|
||||
params = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
@@ -1123,6 +1441,20 @@ class OpenAIClient:
|
||||
Returns:
|
||||
ChatCompletion object or async stream iterator
|
||||
"""
|
||||
# ── Responses API path (non-streaming only) ────────────────────
|
||||
if not stream and self._use_responses_api(model):
|
||||
try:
|
||||
resp_params = self._build_responses_input(
|
||||
messages, model, temperature, tools,
|
||||
tool_choice=tool_choice, **kwargs
|
||||
)
|
||||
raw = await self.async_client.responses.create(**resp_params)
|
||||
return self._responses_to_chat_completion(raw)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Responses API failed, falling back to Chat Completions: {e}")
|
||||
# Fall through to Chat Completions
|
||||
|
||||
# ── Chat Completions path ─────────────────────────────────────
|
||||
params = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
@@ -1308,6 +1640,19 @@ class OpenAIClient:
|
||||
# Check for tool calls
|
||||
tool_calls = getattr(final_response.choices[0].message, 'tool_calls', None)
|
||||
|
||||
# Emit llm_content for intermediate narrative display
|
||||
# (gpt-4.1+ models produce text alongside tool calls)
|
||||
response_content = getattr(final_response.choices[0].message, 'content', None)
|
||||
if response_content and response_content.strip() and tool_calls:
|
||||
try:
|
||||
execute_sync_callback(
|
||||
'llm_content',
|
||||
content=response_content.strip(),
|
||||
agent_name=None,
|
||||
)
|
||||
except Exception:
|
||||
pass # Narrative display is optional
|
||||
|
||||
if tool_calls and execute_tool_fn:
|
||||
# Convert ToolCall dataclass objects to dict for JSON serialization
|
||||
serializable_tool_calls = []
|
||||
@@ -1341,8 +1686,9 @@ class OpenAIClient:
|
||||
# Always trigger callback for tool call tracking (even when verbose=False)
|
||||
display_tool_call_fn = _get_display_tool_call()
|
||||
|
||||
# Execute the tool
|
||||
tool_result = execute_tool_fn(function_name, arguments)
|
||||
# Execute the tool (pass tool_call_id for event correlation)
|
||||
_tool_call_id = tool_call.id if hasattr(tool_call, 'id') else tool_call.get('id')
|
||||
tool_result = execute_tool_fn(function_name, arguments, tool_call_id=_tool_call_id)
|
||||
results_str = json.dumps(tool_result) if tool_result else "Function returned an empty output"
|
||||
|
||||
# Trigger callback with structured parameters for status output
|
||||
@@ -1492,6 +1838,20 @@ class OpenAIClient:
|
||||
# Check for tool calls
|
||||
tool_calls = getattr(final_response.choices[0].message, 'tool_calls', None)
|
||||
|
||||
# Emit llm_content for intermediate narrative display
|
||||
# (gpt-4.1+ models produce text alongside tool calls)
|
||||
response_content = getattr(final_response.choices[0].message, 'content', None)
|
||||
if response_content and response_content.strip() and tool_calls:
|
||||
try:
|
||||
from ..main import execute_sync_callback as _esc
|
||||
_esc(
|
||||
'llm_content',
|
||||
content=response_content.strip(),
|
||||
agent_name=None,
|
||||
)
|
||||
except Exception:
|
||||
pass # Narrative display is optional
|
||||
|
||||
if tool_calls and execute_tool_fn:
|
||||
# Convert ToolCall dataclass objects to dict for JSON serialization
|
||||
serializable_tool_calls = []
|
||||
@@ -1529,15 +1889,16 @@ class OpenAIClient:
|
||||
if verbose and console:
|
||||
console.print(f"[dim]Arguments:[/dim] {arguments}")
|
||||
|
||||
# Execute the tool (async)
|
||||
# Execute the tool (async) - pass tool_call_id for event correlation
|
||||
_tool_call_id = tool_call.id if hasattr(tool_call, 'id') else tool_call.get('id')
|
||||
if asyncio.iscoroutinefunction(execute_tool_fn):
|
||||
tool_result = await execute_tool_fn(function_name, arguments)
|
||||
tool_result = await execute_tool_fn(function_name, arguments, tool_call_id=_tool_call_id)
|
||||
else:
|
||||
# Run sync function in executor
|
||||
loop = asyncio.get_event_loop()
|
||||
tool_result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: execute_tool_fn(function_name, arguments)
|
||||
lambda fn=function_name, args=arguments, tcid=_tool_call_id: execute_tool_fn(fn, args, tool_call_id=tcid)
|
||||
)
|
||||
|
||||
results_str = json.dumps(tool_result) if tool_result else "Function returned an empty output"
|
||||
@@ -1718,9 +2079,10 @@ class OpenAIClient:
|
||||
if verbose:
|
||||
yield f"\n[Calling function: {function_name}]"
|
||||
|
||||
# Execute the tool with error handling
|
||||
# Execute the tool with error handling (pass tool_call_id for event correlation)
|
||||
_tool_call_id = tool_call.id if hasattr(tool_call, 'id') else tool_call.get('id')
|
||||
try:
|
||||
tool_result = execute_tool_fn(function_name, arguments)
|
||||
tool_result = execute_tool_fn(function_name, arguments, tool_call_id=_tool_call_id)
|
||||
results_str = json.dumps(tool_result) if tool_result else "Function returned an empty output"
|
||||
except Exception as e:
|
||||
results_str = f"Error executing function: {str(e)}"
|
||||
|
||||
@@ -112,7 +112,7 @@ __all__ = [
|
||||
# - autonomy_complete: {completion_reason, iterations, duration_seconds}
|
||||
# - retry: {attempt, max_attempts, error, retry_in_seconds}
|
||||
SUPPORTED_CALLBACK_TYPES = [
|
||||
'tool_call', 'interaction', 'error', 'llm_start', 'llm_end',
|
||||
'tool_call', 'interaction', 'error', 'llm_start', 'llm_end', 'llm_content',
|
||||
'autonomy_iteration', 'autonomy_stage_change', 'autonomy_doom_loop', 'autonomy_complete',
|
||||
'retry',
|
||||
]
|
||||
|
||||
@@ -245,7 +245,7 @@ class AutoMemory:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
memory: "FileMemory",
|
||||
memory: Any,
|
||||
enabled: bool = True,
|
||||
extractor: Optional[AutoMemoryExtractor] = None,
|
||||
min_importance: float = 0.6,
|
||||
|
||||
@@ -1066,6 +1066,55 @@ class FileMemory:
|
||||
"storage_path": str(self.user_path)
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# MemoryProtocol Compliance Aliases
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def store_short_term(
|
||||
self,
|
||||
text: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
**kwargs
|
||||
) -> str:
|
||||
"""Protocol-compliant alias for add_short_term."""
|
||||
return self.add_short_term(text, metadata)
|
||||
|
||||
def store_long_term(
|
||||
self,
|
||||
text: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
**kwargs
|
||||
) -> str:
|
||||
"""Protocol-compliant alias for add_long_term."""
|
||||
return self.add_long_term(text, metadata)
|
||||
|
||||
def search_short_term(
|
||||
self,
|
||||
query: str,
|
||||
limit: int = 5,
|
||||
**kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Protocol-compliant alias for search (short_term only)."""
|
||||
return self.search(query, memory_types=["short_term"], limit=limit)
|
||||
|
||||
def search_long_term(
|
||||
self,
|
||||
query: str,
|
||||
limit: int = 5,
|
||||
**kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Protocol-compliant alias for search (long_term only)."""
|
||||
return self.search(query, memory_types=["long_term"], limit=limit)
|
||||
|
||||
def get_all_memories(self, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""Get all memories from short-term and long-term stores."""
|
||||
all_memories = []
|
||||
for item in self._short_term:
|
||||
all_memories.append({"type": "short_term", **item.to_dict()})
|
||||
for item in self._long_term:
|
||||
all_memories.append({"type": "long_term", **item.to_dict()})
|
||||
return all_memories
|
||||
|
||||
def export(self) -> Dict[str, Any]:
|
||||
"""Export all memory data."""
|
||||
return {
|
||||
|
||||
@@ -5,23 +5,30 @@ Provides learning capabilities for agents to capture patterns, preferences,
|
||||
and insights from interactions to improve future responses.
|
||||
|
||||
Usage:
|
||||
from praisonaiagents import Agent, MemoryConfig, LearnConfig
|
||||
from praisonaiagents import Agent, LearnConfig
|
||||
|
||||
# Simple enable
|
||||
agent = Agent(memory=MemoryConfig(learn=True))
|
||||
# Simple enable (top-level param)
|
||||
agent = Agent(learn=True)
|
||||
|
||||
# With specific capabilities
|
||||
agent = Agent(memory=MemoryConfig(
|
||||
learn=LearnConfig(
|
||||
persona=True,
|
||||
insights=True,
|
||||
patterns=True,
|
||||
)
|
||||
# With specific capabilities and auto-learning
|
||||
agent = Agent(learn=LearnConfig(
|
||||
persona=True,
|
||||
insights=True,
|
||||
patterns=True,
|
||||
mode="agentic", # Auto-extract learnings
|
||||
))
|
||||
|
||||
# With database backend
|
||||
agent = Agent(learn=LearnConfig(
|
||||
backend="sqlite",
|
||||
db_url="sqlite:///learn.db",
|
||||
))
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
# Manager
|
||||
"LearnManager",
|
||||
# Stores
|
||||
"PersonaStore",
|
||||
"InsightStore",
|
||||
"ThreadStore",
|
||||
@@ -29,14 +36,22 @@ __all__ = [
|
||||
"DecisionStore",
|
||||
"FeedbackStore",
|
||||
"ImprovementStore",
|
||||
# Protocols
|
||||
"LearnMode",
|
||||
"LearnProtocol",
|
||||
"AsyncLearnProtocol",
|
||||
"LearnManagerProtocol",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy loading for learn module components."""
|
||||
# Manager
|
||||
if name == "LearnManager":
|
||||
from .manager import LearnManager
|
||||
return LearnManager
|
||||
|
||||
# Stores
|
||||
if name == "PersonaStore":
|
||||
from .stores import PersonaStore
|
||||
return PersonaStore
|
||||
@@ -58,4 +73,19 @@ def __getattr__(name: str):
|
||||
if name == "ImprovementStore":
|
||||
from .stores import ImprovementStore
|
||||
return ImprovementStore
|
||||
|
||||
# Protocols
|
||||
if name == "LearnMode":
|
||||
from .protocols import LearnMode
|
||||
return LearnMode
|
||||
if name == "LearnProtocol":
|
||||
from .protocols import LearnProtocol
|
||||
return LearnProtocol
|
||||
if name == "AsyncLearnProtocol":
|
||||
from .protocols import AsyncLearnProtocol
|
||||
return AsyncLearnProtocol
|
||||
if name == "LearnManagerProtocol":
|
||||
from .protocols import LearnManagerProtocol
|
||||
return LearnManagerProtocol
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -6,8 +6,9 @@ capturing and retrieving learned information.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
import logging
|
||||
|
||||
from ...config.feature_configs import LearnConfig, LearnScope
|
||||
from ...config.feature_configs import LearnConfig, LearnScope, LearnBackend
|
||||
from .stores import (
|
||||
PersonaStore,
|
||||
InsightStore,
|
||||
@@ -43,65 +44,163 @@ class LearnManager:
|
||||
config: Optional[LearnConfig] = None,
|
||||
user_id: Optional[str] = None,
|
||||
store_path: Optional[str] = None,
|
||||
custom_stores: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
"""
|
||||
Initialize LearnManager.
|
||||
|
||||
Args:
|
||||
config: LearnConfig for enabling/disabling learning capabilities
|
||||
user_id: User identifier for scoping learnings
|
||||
store_path: Custom path for storing learning data
|
||||
custom_stores: Optional dict of custom store implementations.
|
||||
Keys are store names (e.g., "domain", "skills").
|
||||
Values must implement LearnProtocol (add, search, list_all, etc.)
|
||||
|
||||
Example:
|
||||
```python
|
||||
class MyDomainStore:
|
||||
def add(self, content, metadata=None): ...
|
||||
def search(self, query, limit=10): ...
|
||||
def list_all(self, limit=100): ...
|
||||
def get(self, entry_id): ...
|
||||
def update(self, entry_id, content, metadata=None): ...
|
||||
def delete(self, entry_id): ...
|
||||
def clear(self): ...
|
||||
|
||||
manager = LearnManager(
|
||||
config=LearnConfig(persona=True),
|
||||
custom_stores={"domain": MyDomainStore()}
|
||||
)
|
||||
```
|
||||
"""
|
||||
self.config = config or LearnConfig()
|
||||
self.user_id = user_id or "default"
|
||||
self.store_path = store_path or self.config.store_path
|
||||
|
||||
# Resolve backend from config
|
||||
self._backend = self._create_backend()
|
||||
if self._backend is not None:
|
||||
logging.info(
|
||||
f"LearnManager using {self.config.backend} backend"
|
||||
)
|
||||
|
||||
scope = self.config.scope
|
||||
if isinstance(scope, LearnScope):
|
||||
scope = scope.value
|
||||
|
||||
self._stores: Dict[str, Any] = {}
|
||||
self._custom_stores = custom_stores or {}
|
||||
|
||||
if self.config.persona:
|
||||
# Built-in stores are added first, custom stores can override them
|
||||
if self.config.persona and "persona" not in self._custom_stores:
|
||||
self._stores["persona"] = PersonaStore(
|
||||
store_path=self._get_store_path("persona"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
)
|
||||
|
||||
if self.config.insights:
|
||||
if self.config.insights and "insights" not in self._custom_stores:
|
||||
self._stores["insights"] = InsightStore(
|
||||
store_path=self._get_store_path("insights"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
)
|
||||
|
||||
if self.config.thread:
|
||||
if self.config.thread and "threads" not in self._custom_stores:
|
||||
self._stores["threads"] = ThreadStore(
|
||||
store_path=self._get_store_path("threads"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
)
|
||||
|
||||
if self.config.patterns:
|
||||
if self.config.patterns and "patterns" not in self._custom_stores:
|
||||
self._stores["patterns"] = PatternStore(
|
||||
store_path=self._get_store_path("patterns"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
)
|
||||
|
||||
if self.config.decisions:
|
||||
if self.config.decisions and "decisions" not in self._custom_stores:
|
||||
self._stores["decisions"] = DecisionStore(
|
||||
store_path=self._get_store_path("decisions"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
)
|
||||
|
||||
if self.config.feedback:
|
||||
if self.config.feedback and "feedback" not in self._custom_stores:
|
||||
self._stores["feedback"] = FeedbackStore(
|
||||
store_path=self._get_store_path("feedback"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
)
|
||||
|
||||
if self.config.improvements:
|
||||
if self.config.improvements and "improvements" not in self._custom_stores:
|
||||
self._stores["improvements"] = ImprovementStore(
|
||||
store_path=self._get_store_path("improvements"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
)
|
||||
|
||||
# Add custom stores last (they override built-in stores)
|
||||
if self._custom_stores:
|
||||
for name, store in self._custom_stores.items():
|
||||
self._stores[name] = store
|
||||
|
||||
# PROPOSE mode: pending learnings queue
|
||||
self._pending: List[LearnEntry] = []
|
||||
|
||||
def _create_backend(self) -> Any:
|
||||
"""Create a storage backend from config.
|
||||
|
||||
Returns backend instance or None for FILE (default) backend.
|
||||
Falls back to None (FILE) on error.
|
||||
"""
|
||||
backend = self.config.backend
|
||||
backend_str = backend.value if isinstance(backend, LearnBackend) else str(backend)
|
||||
|
||||
if backend_str == "file":
|
||||
return None # FILE uses BaseJSONStore default
|
||||
|
||||
if backend_str == "sqlite":
|
||||
try:
|
||||
from ...storage.backends import SQLiteBackend
|
||||
db_path = self.config.db_url or f"{self.store_path or 'learn'}/learn.db"
|
||||
# Strip sqlite:/// prefix if present
|
||||
if db_path.startswith("sqlite:///"):
|
||||
db_path = db_path[len("sqlite:///"):]
|
||||
return SQLiteBackend(db_path=db_path, table_name="learn_store")
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to create SQLite backend: {e}. Falling back to FILE.")
|
||||
return None
|
||||
|
||||
if backend_str == "redis":
|
||||
try:
|
||||
from ...storage.backends import RedisBackend
|
||||
url = self.config.db_url or "redis://localhost:6379"
|
||||
return RedisBackend(url=url, prefix="praison:learn:")
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to create Redis backend: {e}. Falling back to FILE.")
|
||||
return None
|
||||
|
||||
if backend_str == "mongodb":
|
||||
try:
|
||||
from ...storage.backends import MongoDBBackend
|
||||
url = self.config.db_url or "mongodb://localhost:27017/"
|
||||
return MongoDBBackend(url=url, collection="praison_learn")
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to create MongoDB backend: {e}. Falling back to FILE.")
|
||||
return None
|
||||
|
||||
logging.warning(f"Unknown learn backend '{backend_str}'. Falling back to FILE.")
|
||||
return None
|
||||
|
||||
def _get_store_path(self, store_name: str) -> Optional[str]:
|
||||
"""Get path for a specific store."""
|
||||
@@ -216,6 +315,88 @@ class LearnManager:
|
||||
for store_name, store in self._stores.items()
|
||||
}
|
||||
|
||||
@property
|
||||
def pending_learnings(self) -> List[LearnEntry]:
|
||||
"""Get list of pending learnings awaiting approval (PROPOSE mode)."""
|
||||
return list(self._pending)
|
||||
|
||||
def add_pending(
|
||||
self,
|
||||
content: str,
|
||||
category: str = "persona",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> LearnEntry:
|
||||
"""Add a learning to the pending queue for user approval.
|
||||
|
||||
Args:
|
||||
content: The learning content
|
||||
category: Store category (persona, insights, patterns, etc.)
|
||||
metadata: Optional metadata
|
||||
|
||||
Returns:
|
||||
The pending LearnEntry
|
||||
"""
|
||||
from datetime import datetime
|
||||
entry_id = f"pending_{category}_{len(self._pending)}_{datetime.utcnow().timestamp()}"
|
||||
entry = LearnEntry(
|
||||
id=entry_id,
|
||||
content=content,
|
||||
metadata={**(metadata or {}), "category": category, "status": "pending"},
|
||||
)
|
||||
self._pending.append(entry)
|
||||
return entry
|
||||
|
||||
def approve_learning(self, entry_id: str) -> bool:
|
||||
"""Approve a pending learning — move it to the appropriate store.
|
||||
|
||||
Args:
|
||||
entry_id: ID of the pending learning to approve
|
||||
|
||||
Returns:
|
||||
True if approved and stored, False if not found
|
||||
"""
|
||||
for i, entry in enumerate(self._pending):
|
||||
if entry.id == entry_id:
|
||||
category = entry.metadata.get("category", "persona")
|
||||
# Route to appropriate store
|
||||
if category in self._stores:
|
||||
self._stores[category].add(entry.content, entry.metadata)
|
||||
elif "persona" in self._stores:
|
||||
self._stores["persona"].add(entry.content, entry.metadata)
|
||||
self._pending.pop(i)
|
||||
return True
|
||||
return False
|
||||
|
||||
def reject_learning(self, entry_id: str) -> bool:
|
||||
"""Reject a pending learning — discard it.
|
||||
|
||||
Args:
|
||||
entry_id: ID of the pending learning to reject
|
||||
|
||||
Returns:
|
||||
True if found and rejected, False if not found
|
||||
"""
|
||||
for i, entry in enumerate(self._pending):
|
||||
if entry.id == entry_id:
|
||||
self._pending.pop(i)
|
||||
return True
|
||||
return False
|
||||
|
||||
def approve_all_learnings(self) -> int:
|
||||
"""Approve all pending learnings at once.
|
||||
|
||||
Returns:
|
||||
Number of learnings approved
|
||||
"""
|
||||
count = 0
|
||||
while self._pending:
|
||||
entry = self._pending[0]
|
||||
if self.approve_learning(entry.id):
|
||||
count += 1
|
||||
else:
|
||||
break # Safety
|
||||
return count
|
||||
|
||||
def to_system_prompt_context(self) -> str:
|
||||
"""
|
||||
Generate context suitable for injection into system prompt.
|
||||
@@ -275,3 +456,211 @@ class LearnManager:
|
||||
# Note: threads are session-specific and not included in system prompt
|
||||
|
||||
return "\n".join(parts) if parts else ""
|
||||
|
||||
def process_conversation(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
llm: Optional[str] = None,
|
||||
extract_only: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Process a conversation to extract learnings automatically.
|
||||
|
||||
Uses LLM to analyze the conversation and extract:
|
||||
- User preferences (persona)
|
||||
- Insights and observations
|
||||
- Patterns in user behavior
|
||||
|
||||
Args:
|
||||
messages: Conversation messages (list of {"role": ..., "content": ...})
|
||||
llm: Optional LLM model to use for extraction (defaults to gpt-4o-mini)
|
||||
|
||||
Returns:
|
||||
Dictionary with extracted learnings:
|
||||
{
|
||||
"persona": [...],
|
||||
"insights": [...],
|
||||
"patterns": [...],
|
||||
"stored": {"persona": N, "insights": N, "patterns": N}
|
||||
}
|
||||
"""
|
||||
if not messages:
|
||||
return {"persona": [], "insights": [], "patterns": [], "stored": {}}
|
||||
|
||||
# Build conversation text
|
||||
conversation_text = "\n".join([
|
||||
f"{msg.get('role', 'unknown')}: {msg.get('content', '')}"
|
||||
for msg in messages
|
||||
])
|
||||
|
||||
# Use LLM to extract learnings
|
||||
extraction_prompt = f"""Analyze this conversation and extract learnings.
|
||||
|
||||
CONVERSATION:
|
||||
{conversation_text}
|
||||
|
||||
Extract the following (if present):
|
||||
1. USER PREFERENCES: Things the user likes, dislikes, prefers, or their style
|
||||
2. INSIGHTS: Observations about the user's domain, work, or context
|
||||
3. PATTERNS: Recurring behaviors or request patterns
|
||||
|
||||
Return JSON:
|
||||
{{
|
||||
"persona": ["preference 1", "preference 2"],
|
||||
"insights": ["insight 1", "insight 2"],
|
||||
"patterns": ["pattern 1", "pattern 2"]
|
||||
}}
|
||||
|
||||
Only include items that are clearly evident from the conversation.
|
||||
Return empty arrays if nothing is found for a category."""
|
||||
|
||||
try:
|
||||
# Lazy import to avoid circular dependency
|
||||
from ...llm import LLM
|
||||
import json
|
||||
|
||||
model = llm or "gpt-4o-mini"
|
||||
llm_instance = LLM(model=model)
|
||||
response = llm_instance.get_response(
|
||||
prompt=extraction_prompt,
|
||||
output_json=True,
|
||||
)
|
||||
|
||||
# Parse response - handle both string and dict responses
|
||||
if isinstance(response, str):
|
||||
extracted = json.loads(response)
|
||||
elif isinstance(response, dict):
|
||||
extracted = response
|
||||
else:
|
||||
extracted = {"persona": [], "insights": [], "patterns": []}
|
||||
|
||||
# Store extracted learnings (unless extract_only)
|
||||
stored = {"persona": 0, "insights": 0, "patterns": 0}
|
||||
|
||||
if not extract_only:
|
||||
for preference in extracted.get("persona", []):
|
||||
if preference and "persona" in self._stores:
|
||||
self.capture_persona(preference)
|
||||
stored["persona"] += 1
|
||||
|
||||
for insight in extracted.get("insights", []):
|
||||
if insight and "insights" in self._stores:
|
||||
self.capture_insight(insight, source="auto_extraction")
|
||||
stored["insights"] += 1
|
||||
|
||||
for pattern in extracted.get("patterns", []):
|
||||
if pattern and "patterns" in self._stores:
|
||||
self.capture_pattern(pattern, pattern_type="auto_extracted")
|
||||
stored["patterns"] += 1
|
||||
|
||||
return {
|
||||
"persona": extracted.get("persona", []),
|
||||
"insights": extracted.get("insights", []),
|
||||
"patterns": extracted.get("patterns", []),
|
||||
"stored": stored,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.warning(f"Failed to extract learnings: {e}")
|
||||
return {"persona": [], "insights": [], "patterns": [], "stored": {}, "error": str(e)}
|
||||
|
||||
async def aprocess_conversation(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
llm: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Async version of process_conversation.
|
||||
|
||||
Args:
|
||||
messages: Conversation messages
|
||||
llm: Optional LLM model
|
||||
|
||||
Returns:
|
||||
Dictionary with extracted learnings
|
||||
"""
|
||||
if not messages:
|
||||
return {"persona": [], "insights": [], "patterns": [], "stored": {}}
|
||||
|
||||
# Build conversation text
|
||||
conversation_text = "\n".join([
|
||||
f"{msg.get('role', 'unknown')}: {msg.get('content', '')}"
|
||||
for msg in messages
|
||||
])
|
||||
|
||||
extraction_prompt = f"""Analyze this conversation and extract learnings.
|
||||
|
||||
CONVERSATION:
|
||||
{conversation_text}
|
||||
|
||||
Extract the following (if present):
|
||||
1. USER PREFERENCES: Things the user likes, dislikes, prefers, or their style
|
||||
2. INSIGHTS: Observations about the user's domain, work, or context
|
||||
3. PATTERNS: Recurring behaviors or request patterns
|
||||
|
||||
Return JSON:
|
||||
{{
|
||||
"persona": ["preference 1", "preference 2"],
|
||||
"insights": ["insight 1", "insight 2"],
|
||||
"patterns": ["pattern 1", "pattern 2"]
|
||||
}}
|
||||
|
||||
Only include items that are clearly evident from the conversation.
|
||||
Return empty arrays if nothing is found for a category."""
|
||||
|
||||
try:
|
||||
# Lazy import to avoid circular dependency
|
||||
from ...llm import LLM
|
||||
import json
|
||||
import asyncio
|
||||
|
||||
model = llm or "gpt-4o-mini"
|
||||
llm_instance = LLM(model=model)
|
||||
|
||||
# Run sync LLM call in executor for async compatibility
|
||||
loop = asyncio.get_event_loop()
|
||||
response = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: llm_instance.get_response(
|
||||
prompt=extraction_prompt,
|
||||
output_json=True,
|
||||
)
|
||||
)
|
||||
|
||||
# Parse response - handle both string and dict responses
|
||||
if isinstance(response, str):
|
||||
extracted = json.loads(response)
|
||||
elif isinstance(response, dict):
|
||||
extracted = response
|
||||
else:
|
||||
extracted = {"persona": [], "insights": [], "patterns": []}
|
||||
|
||||
stored = {"persona": 0, "insights": 0, "patterns": 0}
|
||||
|
||||
for preference in extracted.get("persona", []):
|
||||
if preference and "persona" in self._stores:
|
||||
self.capture_persona(preference)
|
||||
stored["persona"] += 1
|
||||
|
||||
for insight in extracted.get("insights", []):
|
||||
if insight and "insights" in self._stores:
|
||||
self.capture_insight(insight, source="auto_extraction")
|
||||
stored["insights"] += 1
|
||||
|
||||
for pattern in extracted.get("patterns", []):
|
||||
if pattern and "patterns" in self._stores:
|
||||
self.capture_pattern(pattern, pattern_type="auto_extracted")
|
||||
stored["patterns"] += 1
|
||||
|
||||
return {
|
||||
"persona": extracted.get("persona", []),
|
||||
"insights": extracted.get("insights", []),
|
||||
"patterns": extracted.get("patterns", []),
|
||||
"stored": stored,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.warning(f"Failed to extract learnings: {e}")
|
||||
return {"persona": [], "insights": [], "patterns": [], "stored": {}, "error": str(e)}
|
||||
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
Learn Protocol Definitions.
|
||||
|
||||
Provides Protocol interfaces for learning implementations.
|
||||
Enables custom learning backends and extensibility.
|
||||
"""
|
||||
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
|
||||
|
||||
# Re-export LearnMode from feature_configs (canonical location)
|
||||
# This avoids duplication while maintaining backward compatibility
|
||||
from ...config.feature_configs import LearnMode
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class LearnProtocol(Protocol):
|
||||
"""
|
||||
Protocol for learning store implementations.
|
||||
|
||||
Enables custom backends (file, SQLite, Redis, MongoDB, etc.)
|
||||
while maintaining a consistent interface.
|
||||
|
||||
Example:
|
||||
```python
|
||||
class RedisLearnStore:
|
||||
def add(self, content: str, metadata=None) -> Dict[str, Any]:
|
||||
# Store in Redis
|
||||
return {"id": "...", "content": content}
|
||||
|
||||
def search(self, query: str, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
# Search Redis
|
||||
return []
|
||||
|
||||
def list_all(self, limit: int = 100) -> List[Dict[str, Any]]:
|
||||
return []
|
||||
|
||||
def delete(self, entry_id: str) -> bool:
|
||||
return True
|
||||
|
||||
def clear(self) -> int:
|
||||
return 0
|
||||
```
|
||||
"""
|
||||
|
||||
def add(
|
||||
self,
|
||||
content: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Add a new entry to the store.
|
||||
|
||||
Args:
|
||||
content: The content to store
|
||||
metadata: Optional metadata dictionary
|
||||
|
||||
Returns:
|
||||
Dictionary with entry details including 'id'
|
||||
"""
|
||||
...
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
limit: int = 10,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search entries by query.
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
limit: Maximum results to return
|
||||
|
||||
Returns:
|
||||
List of matching entries
|
||||
"""
|
||||
...
|
||||
|
||||
def list_all(
|
||||
self,
|
||||
limit: int = 100,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List all entries.
|
||||
|
||||
Args:
|
||||
limit: Maximum entries to return
|
||||
|
||||
Returns:
|
||||
List of all entries (most recent first)
|
||||
"""
|
||||
...
|
||||
|
||||
def get(
|
||||
self,
|
||||
entry_id: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get entry by ID.
|
||||
|
||||
Args:
|
||||
entry_id: Entry identifier
|
||||
|
||||
Returns:
|
||||
Entry dict or None if not found
|
||||
"""
|
||||
...
|
||||
|
||||
def update(
|
||||
self,
|
||||
entry_id: str,
|
||||
content: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Update an existing entry.
|
||||
|
||||
Args:
|
||||
entry_id: Entry identifier
|
||||
content: New content
|
||||
metadata: Optional metadata to merge
|
||||
|
||||
Returns:
|
||||
Updated entry or None if not found
|
||||
"""
|
||||
...
|
||||
|
||||
def delete(
|
||||
self,
|
||||
entry_id: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Delete an entry.
|
||||
|
||||
Args:
|
||||
entry_id: Entry identifier
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
...
|
||||
|
||||
def clear(self) -> int:
|
||||
"""
|
||||
Clear all entries.
|
||||
|
||||
Returns:
|
||||
Number of entries cleared
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AsyncLearnProtocol(Protocol):
|
||||
"""
|
||||
Async Protocol for learning store implementations.
|
||||
|
||||
For backends that support async operations.
|
||||
"""
|
||||
|
||||
async def aadd(
|
||||
self,
|
||||
content: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Async add entry."""
|
||||
...
|
||||
|
||||
async def asearch(
|
||||
self,
|
||||
query: str,
|
||||
limit: int = 10,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Async search entries."""
|
||||
...
|
||||
|
||||
async def alist_all(
|
||||
self,
|
||||
limit: int = 100,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Async list all entries."""
|
||||
...
|
||||
|
||||
async def aget(
|
||||
self,
|
||||
entry_id: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Async get entry by ID."""
|
||||
...
|
||||
|
||||
async def aupdate(
|
||||
self,
|
||||
entry_id: str,
|
||||
content: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Async update entry."""
|
||||
...
|
||||
|
||||
async def adelete(
|
||||
self,
|
||||
entry_id: str,
|
||||
) -> bool:
|
||||
"""Async delete entry."""
|
||||
...
|
||||
|
||||
async def aclear(self) -> int:
|
||||
"""Async clear all entries."""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class LearnManagerProtocol(Protocol):
|
||||
"""
|
||||
Protocol for LearnManager implementations.
|
||||
|
||||
Defines the interface for managing learning across multiple stores.
|
||||
"""
|
||||
|
||||
def capture_persona(self, content: str, category: str = "general") -> Optional[Dict[str, Any]]:
|
||||
"""Capture user preference or profile information."""
|
||||
...
|
||||
|
||||
def capture_insight(self, content: str, source: str = "interaction") -> Optional[Dict[str, Any]]:
|
||||
"""Capture an observation or learning."""
|
||||
...
|
||||
|
||||
def capture_pattern(self, pattern: str, pattern_type: str = "general") -> Optional[Dict[str, Any]]:
|
||||
"""Capture a reusable knowledge pattern."""
|
||||
...
|
||||
|
||||
def get_learning_context(self, limit_per_store: int = 5) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Get learning context from all enabled stores."""
|
||||
...
|
||||
|
||||
def search(self, query: str, limit: int = 10) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Search across all enabled stores."""
|
||||
...
|
||||
|
||||
def to_system_prompt_context(self) -> str:
|
||||
"""Generate context for system prompt injection."""
|
||||
...
|
||||
|
||||
def process_conversation(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
llm: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Process a conversation to extract learnings automatically.
|
||||
|
||||
Args:
|
||||
messages: Conversation messages
|
||||
llm: Optional LLM model to use for extraction
|
||||
|
||||
Returns:
|
||||
Dictionary with extracted learnings
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
__all__ = [
|
||||
'LearnMode',
|
||||
'LearnProtocol',
|
||||
'AsyncLearnProtocol',
|
||||
'LearnManagerProtocol',
|
||||
]
|
||||
@@ -73,12 +73,23 @@ class BaseStore(ABC):
|
||||
self._backend = backend
|
||||
self.store_path = store_path or self._default_path()
|
||||
self._entries: Dict[str, LearnEntry] = {}
|
||||
self._was_updated: bool = False
|
||||
|
||||
# DRY: Use BaseJSONStore for thread-safe storage
|
||||
self._store = BaseJSONStore(
|
||||
storage_path=self.store_path,
|
||||
backend=backend,
|
||||
)
|
||||
# Fall back to FILE backend if connection fails (e.g., Redis unavailable)
|
||||
try:
|
||||
self._store = BaseJSONStore(
|
||||
storage_path=self.store_path,
|
||||
backend=backend,
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.warning(f"Failed to connect to backend: {e}. Falling back to FILE.")
|
||||
self._backend = None
|
||||
self._store = BaseJSONStore(
|
||||
storage_path=self.store_path,
|
||||
backend=None,
|
||||
)
|
||||
self._load()
|
||||
|
||||
@property
|
||||
@@ -87,6 +98,15 @@ class BaseStore(ABC):
|
||||
"""Name of the store (used for file naming)."""
|
||||
pass
|
||||
|
||||
@property
|
||||
def was_updated(self) -> bool:
|
||||
"""Whether this store has been modified since last reset."""
|
||||
return self._was_updated
|
||||
|
||||
def reset_updated(self) -> None:
|
||||
"""Reset the was_updated flag."""
|
||||
self._was_updated = False
|
||||
|
||||
def _default_path(self) -> str:
|
||||
"""Default storage path (uses centralized paths - DRY)."""
|
||||
from ...paths import get_learn_dir, ensure_dir
|
||||
@@ -106,7 +126,24 @@ class BaseStore(ABC):
|
||||
self._store.save({k: v.to_dict() for k, v in self._entries.items()})
|
||||
|
||||
def add(self, content: str, metadata: Optional[Dict[str, Any]] = None) -> LearnEntry:
|
||||
"""Add a new entry."""
|
||||
"""Add a new entry with deduplication.
|
||||
|
||||
Checks for exact content matches to prevent duplicate learnings.
|
||||
If a duplicate is found, returns the existing entry instead of creating a new one.
|
||||
"""
|
||||
# Deduplication: Check for exact content match
|
||||
content_normalized = content.strip().lower()
|
||||
for existing_entry in self._entries.values():
|
||||
if existing_entry.content.strip().lower() == content_normalized:
|
||||
# Update metadata if provided, but don't create duplicate
|
||||
if metadata:
|
||||
existing_entry.metadata.update(metadata)
|
||||
existing_entry.updated_at = datetime.utcnow().isoformat()
|
||||
self._save()
|
||||
self._was_updated = True
|
||||
return existing_entry
|
||||
|
||||
# No duplicate found, create new entry
|
||||
entry_id = f"{self.store_name}_{len(self._entries)}_{datetime.utcnow().timestamp()}"
|
||||
entry = LearnEntry(
|
||||
id=entry_id,
|
||||
@@ -115,6 +152,7 @@ class BaseStore(ABC):
|
||||
)
|
||||
self._entries[entry_id] = entry
|
||||
self._save()
|
||||
self._was_updated = True
|
||||
return entry
|
||||
|
||||
def get(self, entry_id: str) -> Optional[LearnEntry]:
|
||||
@@ -146,6 +184,7 @@ class BaseStore(ABC):
|
||||
entry.metadata.update(metadata)
|
||||
entry.updated_at = datetime.utcnow().isoformat()
|
||||
self._save()
|
||||
self._was_updated = True
|
||||
return entry
|
||||
|
||||
def delete(self, entry_id: str) -> bool:
|
||||
@@ -153,6 +192,7 @@ class BaseStore(ABC):
|
||||
if entry_id in self._entries:
|
||||
del self._entries[entry_id]
|
||||
self._save()
|
||||
self._was_updated = True
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -161,6 +201,7 @@ class BaseStore(ABC):
|
||||
count = len(self._entries)
|
||||
self._entries = {}
|
||||
self._save()
|
||||
self._was_updated = True
|
||||
return count
|
||||
|
||||
|
||||
|
||||
@@ -40,6 +40,9 @@ class MemoryProtocol(Protocol):
|
||||
|
||||
def search_long_term(self, query: str, limit: int = 5, **kwargs):
|
||||
return self._data[:limit]
|
||||
|
||||
def get_all_memories(self, **kwargs):
|
||||
return self._data
|
||||
|
||||
# Use in agent
|
||||
memory: MemoryProtocol = InMemoryStore()
|
||||
@@ -122,6 +125,15 @@ class MemoryProtocol(Protocol):
|
||||
"""
|
||||
...
|
||||
|
||||
def get_all_memories(self, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all memories from all backends.
|
||||
|
||||
Returns:
|
||||
List of all memory entries (short-term and long-term combined)
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ResettableMemoryProtocol(MemoryProtocol, Protocol):
|
||||
|
||||
@@ -20,10 +20,11 @@ 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
|
||||
from .loop import ScheduleLoop
|
||||
|
||||
_module_cache = {}
|
||||
|
||||
@@ -33,10 +34,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":
|
||||
@@ -54,13 +56,20 @@ def __getattr__(name: str):
|
||||
_module_cache[name] = ScheduleRunner
|
||||
return ScheduleRunner
|
||||
|
||||
if name == "ScheduleLoop":
|
||||
from .loop import ScheduleLoop
|
||||
_module_cache[name] = ScheduleLoop
|
||||
return ScheduleLoop
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Schedule",
|
||||
"ScheduleJob",
|
||||
"DeliveryTarget",
|
||||
"FileScheduleStore",
|
||||
"parse_schedule",
|
||||
"ScheduleRunner",
|
||||
"ScheduleLoop",
|
||||
]
|
||||
@@ -0,0 +1,126 @@
|
||||
"""
|
||||
Schedule Loop for PraisonAI Agents.
|
||||
|
||||
Polls ScheduleRunner for due jobs and fires a callback.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable, Optional
|
||||
|
||||
from .runner import ScheduleRunner
|
||||
from .store import FileScheduleStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ScheduleLoop:
|
||||
"""Tick loop that polls a ScheduleRunner and fires due jobs.
|
||||
|
||||
Runs on a **daemon thread** so it never blocks the caller and
|
||||
is automatically cleaned up on process exit.
|
||||
|
||||
Example::
|
||||
|
||||
from praisonaiagents.scheduler import ScheduleLoop
|
||||
|
||||
def handle(job):
|
||||
print(f"Firing: {job.name} — {job.message}")
|
||||
|
||||
loop = ScheduleLoop(on_trigger=handle, tick_seconds=30)
|
||||
loop.start()
|
||||
# … later …
|
||||
loop.stop()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
on_trigger: Callable,
|
||||
store: Optional[FileScheduleStore] = None,
|
||||
tick_seconds: float = 30.0,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
on_trigger: Called with each due ``ScheduleJob``.
|
||||
store: Schedule store to poll. Defaults to the global
|
||||
``FileScheduleStore()``.
|
||||
tick_seconds: Seconds between polls (default 30).
|
||||
"""
|
||||
self._on_trigger = on_trigger
|
||||
self._store = store or FileScheduleStore()
|
||||
self._runner = ScheduleRunner(self._store)
|
||||
self._tick = tick_seconds
|
||||
self._stop_event = threading.Event()
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
|
||||
# ── Public API ──────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""Whether the loop thread is alive."""
|
||||
return self._thread is not None and self._thread.is_alive()
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the tick loop on a daemon thread.
|
||||
|
||||
Calling ``start()`` when already running is a no-op.
|
||||
"""
|
||||
if self.is_running:
|
||||
return
|
||||
|
||||
self._stop_event.clear()
|
||||
self._thread = threading.Thread(
|
||||
target=self._run, name="schedule-loop", daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
logger.info("ScheduleLoop started (tick=%ss)", self._tick)
|
||||
|
||||
def stop(self, timeout: float = 5.0) -> None:
|
||||
"""Signal the loop to stop and wait for the thread to exit.
|
||||
|
||||
Args:
|
||||
timeout: Max seconds to wait for the thread to join.
|
||||
"""
|
||||
self._stop_event.set()
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=timeout)
|
||||
self._thread = None
|
||||
logger.info("ScheduleLoop stopped")
|
||||
|
||||
# ── Internal ────────────────────────────────────────────────────────
|
||||
|
||||
def _run(self) -> None:
|
||||
"""Loop body executed on the daemon thread."""
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
due = self._runner.get_due_jobs()
|
||||
for job in due:
|
||||
try:
|
||||
self._on_trigger(job)
|
||||
# Update last_run_at so the job won't re-fire
|
||||
# until the next interval elapses.
|
||||
job.last_run_at = time.time()
|
||||
self._store.update(job)
|
||||
self._emit_hook(job)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Error triggering schedule job %s (%s)",
|
||||
job.name, job.id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("ScheduleLoop tick error")
|
||||
|
||||
# Sleep in small increments so stop() is responsive.
|
||||
self._stop_event.wait(timeout=self._tick)
|
||||
|
||||
def _emit_hook(self, job) -> None:
|
||||
"""Best-effort emit of SCHEDULE_TRIGGER notification.
|
||||
|
||||
Logs the trigger event. Hook-aware callers (e.g. bots) can
|
||||
wire a proper ``HookRunner`` externally via the ``on_trigger``
|
||||
callback.
|
||||
"""
|
||||
logger.debug(
|
||||
"SCHEDULE_TRIGGER: job=%s id=%s", job.name, job.id
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -29,6 +29,7 @@ __all__ = [
|
||||
"FileBackend",
|
||||
"SQLiteBackend",
|
||||
"RedisBackend",
|
||||
"MongoDBBackend",
|
||||
"get_backend",
|
||||
]
|
||||
|
||||
@@ -47,6 +48,7 @@ _LAZY_IMPORTS = {
|
||||
"FileBackend": ("backends", "FileBackend"),
|
||||
"SQLiteBackend": ("backends", "SQLiteBackend"),
|
||||
"RedisBackend": ("backends", "RedisBackend"),
|
||||
"MongoDBBackend": ("backends", "MongoDBBackend"),
|
||||
"get_backend": ("backends", "get_backend"),
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ DRY: All backends implement StorageBackendProtocol for consistent interface.
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
import tempfile
|
||||
import logging
|
||||
@@ -456,6 +457,114 @@ class RedisBackend:
|
||||
self._client = None
|
||||
|
||||
|
||||
class MongoDBBackend:
|
||||
"""
|
||||
MongoDB-based storage backend.
|
||||
|
||||
Uses MongoDB for document-oriented data storage.
|
||||
Requires the `pymongo` package (optional dependency).
|
||||
|
||||
Example:
|
||||
```python
|
||||
backend = MongoDBBackend(url="mongodb://localhost:27017/")
|
||||
backend.save("session_123", {"messages": []})
|
||||
data = backend.load("session_123")
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str = "mongodb://localhost:27017/",
|
||||
database: str = "praisonai",
|
||||
collection: str = "praison_storage",
|
||||
max_pool_size: int = 50,
|
||||
):
|
||||
self.url = url
|
||||
self.database = database
|
||||
self.collection_name = collection
|
||||
self.max_pool_size = max_pool_size
|
||||
self._client = None
|
||||
self._collection = None
|
||||
|
||||
def _get_collection(self):
|
||||
"""Lazy initialize MongoDB client and collection."""
|
||||
if self._collection is None:
|
||||
try:
|
||||
import pymongo
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"MongoDB backend requires the 'pymongo' package. "
|
||||
"Install with: pip install pymongo"
|
||||
)
|
||||
self._client = pymongo.MongoClient(
|
||||
self.url,
|
||||
maxPoolSize=self.max_pool_size,
|
||||
retryWrites=True,
|
||||
retryReads=True,
|
||||
serverSelectionTimeoutMS=5000, # 5 second timeout for connection
|
||||
)
|
||||
db = self._client[self.database]
|
||||
self._collection = db[self.collection_name]
|
||||
return self._collection
|
||||
|
||||
def save(self, key: str, data: Dict[str, Any]) -> None:
|
||||
"""Save data with the given key (upsert)."""
|
||||
collection = self._get_collection()
|
||||
json_data = json.dumps(data, default=str, ensure_ascii=False)
|
||||
collection.replace_one(
|
||||
{"_id": key},
|
||||
{"_id": key, "data": json_data, "updated_at": time.time()},
|
||||
upsert=True,
|
||||
)
|
||||
|
||||
def load(self, key: str) -> Optional[Dict[str, Any]]:
|
||||
"""Load data by key."""
|
||||
collection = self._get_collection()
|
||||
doc = collection.find_one({"_id": key})
|
||||
if doc and "data" in doc:
|
||||
try:
|
||||
return json.loads(doc["data"])
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return None
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
"""Delete data by key."""
|
||||
collection = self._get_collection()
|
||||
result = collection.delete_one({"_id": key})
|
||||
return result.deleted_count > 0
|
||||
|
||||
def list_keys(self, prefix: str = "") -> List[str]:
|
||||
"""List all keys, optionally filtered by prefix."""
|
||||
collection = self._get_collection()
|
||||
if prefix:
|
||||
cursor = collection.find(
|
||||
{"_id": {"$regex": f"^{prefix}"}},
|
||||
{"_id": 1}
|
||||
)
|
||||
else:
|
||||
cursor = collection.find({}, {"_id": 1})
|
||||
return sorted([doc["_id"] for doc in cursor])
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
"""Check if a key exists."""
|
||||
collection = self._get_collection()
|
||||
return collection.count_documents({"_id": key}, limit=1) > 0
|
||||
|
||||
def clear(self) -> int:
|
||||
"""Clear all data. Returns number of items deleted."""
|
||||
collection = self._get_collection()
|
||||
result = collection.delete_many({})
|
||||
return result.deleted_count
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the MongoDB connection."""
|
||||
if self._client:
|
||||
self._client.close()
|
||||
self._client = None
|
||||
self._collection = None
|
||||
|
||||
|
||||
def get_backend(
|
||||
backend_type: str = "file",
|
||||
**kwargs
|
||||
@@ -488,6 +597,8 @@ def get_backend(
|
||||
return SQLiteBackend(**kwargs)
|
||||
elif backend_type == "redis":
|
||||
return RedisBackend(**kwargs)
|
||||
elif backend_type == "mongodb":
|
||||
return MongoDBBackend(**kwargs)
|
||||
else:
|
||||
raise ValueError(f"Unknown backend type: {backend_type}")
|
||||
|
||||
@@ -496,5 +607,6 @@ __all__ = [
|
||||
'FileBackend',
|
||||
'SQLiteBackend',
|
||||
'RedisBackend',
|
||||
'MongoDBBackend',
|
||||
'get_backend',
|
||||
]
|
||||
@@ -25,7 +25,9 @@ class StreamEventType(Enum):
|
||||
FIRST_TOKEN = "first_token" # First content delta received (TTFT marker)
|
||||
DELTA_TEXT = "delta_text" # Text content delta
|
||||
DELTA_TOOL_CALL = "delta_tool_call" # Tool call delta (function name, args chunk)
|
||||
TOOL_CALL_END = "tool_call_end" # Tool call complete
|
||||
TOOL_CALL_END = "tool_call_end" # Tool call complete (stream end marker)
|
||||
TOOL_CALL_START = "tool_call_start" # Tool execution starting (complete parsed args)
|
||||
TOOL_CALL_RESULT = "tool_call_result" # Tool execution completed (with result)
|
||||
LAST_TOKEN = "last_token" # Final content delta
|
||||
STREAM_END = "stream_end" # Stream completed successfully
|
||||
ERROR = "error" # Error during streaming
|
||||
|
||||
@@ -138,9 +138,9 @@ TOOL_MAPPINGS = {
|
||||
'get_available_providers': ('.web_search', None),
|
||||
|
||||
# Unified Web Crawl (auto-fallback across providers)
|
||||
'web_crawl': ('.web_crawl', None),
|
||||
'crawl_web': ('.web_crawl', None), # Alias
|
||||
'get_available_crawl_providers': ('.web_crawl', None),
|
||||
'web_crawl': ('.web_crawl_tools', None),
|
||||
'crawl_web': ('.web_crawl_tools', None), # Alias
|
||||
'get_available_crawl_providers': ('.web_crawl_tools', None),
|
||||
|
||||
# Skill Tools (for Agent Skills script execution)
|
||||
'run_skill_script': ('.skill_tools', None),
|
||||
@@ -163,6 +163,26 @@ TOOL_MAPPINGS = {
|
||||
'is_ast_grep_available': ('.ast_grep_tool', None),
|
||||
'get_ast_grep_tools': ('.ast_grep_tool', None),
|
||||
'ast_grep_tools': ('.ast_grep_tool', None),
|
||||
|
||||
# Memory Tools (active memory store/search for agents)
|
||||
'store_memory': ('.memory', None),
|
||||
'search_memory': ('.memory', None),
|
||||
|
||||
# Learning Tools (active categorized knowledge store/search)
|
||||
'store_learning': ('.learning', None),
|
||||
'search_learning': ('.learning', None),
|
||||
|
||||
# Email Tools (AgentMail-based send/read/list/reply/create)
|
||||
'send_email': ('.email_tools', None),
|
||||
'list_emails': ('.email_tools', None),
|
||||
'read_email': ('.email_tools', None),
|
||||
'reply_email': ('.email_tools', None),
|
||||
'list_inboxes': ('.email_tools', None),
|
||||
'create_inbox': ('.email_tools', None),
|
||||
# Email Tools (SMTP/IMAP-based — direct mailbox credentials)
|
||||
'smtp_send_email': ('.email_tools', None),
|
||||
'smtp_read_inbox': ('.email_tools', None),
|
||||
'email_tools': ('.email_tools', None),
|
||||
}
|
||||
|
||||
_instances = {} # Cache for class instances
|
||||
@@ -221,10 +241,14 @@ def __getattr__(name: str) -> Any:
|
||||
'web_crawl', 'crawl_web', 'get_available_crawl_providers',
|
||||
'run_skill_script', 'read_skill_file', 'list_skill_scripts', 'create_skill_tools',
|
||||
'schedule_add', 'schedule_list', 'schedule_remove',
|
||||
'ast_grep_search', 'ast_grep_rewrite', 'ast_grep_scan', 'is_ast_grep_available', 'get_ast_grep_tools'
|
||||
'ast_grep_search', 'ast_grep_rewrite', 'ast_grep_scan', 'is_ast_grep_available', 'get_ast_grep_tools',
|
||||
'store_memory', 'search_memory',
|
||||
'store_learning', 'search_learning',
|
||||
'send_email', 'list_emails', 'read_email', 'reply_email', 'list_inboxes', 'create_inbox',
|
||||
'smtp_send_email', 'smtp_read_inbox'
|
||||
]:
|
||||
return getattr(module, name)
|
||||
if name in ['file_tools', 'spider_tools', 'python_tools', 'shell_tools', 'cot_tools', 'tavily_tools', 'youdotcom_tools', 'exa_tools', 'crawl4ai_tools', 'skill_tools', 'schedule_tools', 'ast_grep_tools']:
|
||||
if name in ['file_tools', 'spider_tools', 'python_tools', 'shell_tools', 'cot_tools', 'tavily_tools', 'youdotcom_tools', 'exa_tools', 'crawl4ai_tools', 'skill_tools', 'schedule_tools', 'ast_grep_tools', 'email_tools']:
|
||||
return module # Returns the callable module
|
||||
return getattr(module, name)
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,947 @@
|
||||
"""
|
||||
Email tools for PraisonAI Agents.
|
||||
|
||||
Generic tools (send_email, list_emails, etc.) auto-detect the backend:
|
||||
- If AGENTMAIL_API_KEY is set → uses AgentMail API
|
||||
- If EMAIL_ADDRESS + EMAIL_PASSWORD is set → uses IMAP/SMTP
|
||||
- If both are set → prefers AgentMail
|
||||
|
||||
Backend-specific tools are also available:
|
||||
- AgentMail: agentmail_send_email, agentmail_list_emails, etc.
|
||||
- SMTP/IMAP: smtp_send_email, smtp_read_inbox, smtp_search_inbox, etc.
|
||||
|
||||
Example:
|
||||
from praisonaiagents.tools.email_tools import send_email, list_emails, search_emails
|
||||
agent = Agent(tools=[send_email, list_emails, search_emails])
|
||||
# Works with either AgentMail or Gmail/Outlook — auto-detected from env vars
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Lazy-loaded AgentMail client (module-level singleton)
|
||||
_client = None
|
||||
|
||||
|
||||
def _detect_backend() -> str:
|
||||
"""Detect which email backend is available.
|
||||
|
||||
Returns 'agentmail' or 'smtp'. Prefers AgentMail if both configured.
|
||||
"""
|
||||
if os.environ.get("AGENTMAIL_API_KEY"):
|
||||
return "agentmail"
|
||||
if os.environ.get("EMAIL_ADDRESS") and os.environ.get("EMAIL_PASSWORD"):
|
||||
return "smtp"
|
||||
raise ValueError(
|
||||
"No email backend configured. Set either:\n"
|
||||
" - AGENTMAIL_API_KEY (+ AGENTMAIL_INBOX_ID) for AgentMail, or\n"
|
||||
" - EMAIL_ADDRESS + EMAIL_PASSWORD for IMAP/SMTP (Gmail, Outlook, etc.)"
|
||||
)
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# AgentMail Backend
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
def _get_client():
|
||||
"""Lazy-load the AgentMail client from env vars."""
|
||||
global _client
|
||||
if _client is None:
|
||||
api_key = os.environ.get("AGENTMAIL_API_KEY", "")
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
"AGENTMAIL_API_KEY environment variable is required. "
|
||||
"Get your key at https://agentmail.to"
|
||||
)
|
||||
try:
|
||||
from agentmail import AgentMail
|
||||
_client = AgentMail(api_key=api_key)
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"agentmail package not installed. "
|
||||
"Install with: pip install agentmail"
|
||||
)
|
||||
return _client
|
||||
|
||||
|
||||
def _get_inbox_id() -> str:
|
||||
"""Get the default inbox ID from env."""
|
||||
inbox_id = os.environ.get("AGENTMAIL_INBOX_ID", "")
|
||||
if not inbox_id:
|
||||
raise ValueError(
|
||||
"AGENTMAIL_INBOX_ID environment variable is required. "
|
||||
"Set it to your inbox email, e.g. [email protected]"
|
||||
)
|
||||
return inbox_id
|
||||
|
||||
|
||||
def _normalize_message_id(message_id: str) -> str:
|
||||
"""LLMs often strip angle brackets from message IDs — restore them."""
|
||||
if message_id and "@" in message_id and not message_id.startswith("<"):
|
||||
return f"<{message_id}>"
|
||||
return message_id
|
||||
|
||||
|
||||
def _agentmail_send_email(to: str, subject: str, body: str) -> str:
|
||||
client = _get_client()
|
||||
inbox_id = _get_inbox_id()
|
||||
try:
|
||||
result = client.inboxes.messages.send(inbox_id, to=to, subject=subject, text=body)
|
||||
msg_id = getattr(result, "message_id", "unknown")
|
||||
thread_id = getattr(result, "thread_id", "")
|
||||
logger.info(f"Email sent to {to}: {msg_id}")
|
||||
return f"Email sent successfully to {to}. Message ID: {msg_id}, Thread ID: {thread_id}"
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send email to {to}: {e}")
|
||||
return f"Failed to send email: {e}"
|
||||
|
||||
|
||||
def _agentmail_list_emails(limit: int = 10) -> str:
|
||||
client = _get_client()
|
||||
inbox_id = _get_inbox_id()
|
||||
try:
|
||||
response = client.inboxes.messages.list(inbox_id, limit=limit)
|
||||
msg_list = response.messages if hasattr(response, "messages") else response
|
||||
if not msg_list:
|
||||
return "No emails found in inbox."
|
||||
results = []
|
||||
for msg in msg_list:
|
||||
sender = getattr(msg, "from_", "") or "unknown"
|
||||
subject = getattr(msg, "subject", "(no subject)") or "(no subject)"
|
||||
preview = getattr(msg, "preview", "") or ""
|
||||
msg_id = getattr(msg, "message_id", "")
|
||||
timestamp = getattr(msg, "timestamp", "") or getattr(msg, "created_at", "")
|
||||
results.append(
|
||||
f"- From: {sender}\n"
|
||||
f" Subject: {subject}\n"
|
||||
f" Preview: {preview[:100]}{'...' if len(preview) > 100 else ''}\n"
|
||||
f" ID: {msg_id}\n"
|
||||
f" Date: {timestamp}"
|
||||
)
|
||||
count_info = getattr(response, "count", len(msg_list))
|
||||
return f"Found {count_info} email(s) (showing up to {limit}):\n\n" + "\n\n".join(results)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list emails: {e}")
|
||||
return f"Failed to list emails: {e}"
|
||||
|
||||
|
||||
def _agentmail_read_email(message_id: str) -> str:
|
||||
client = _get_client()
|
||||
inbox_id = _get_inbox_id()
|
||||
message_id = _normalize_message_id(message_id)
|
||||
try:
|
||||
msg = client.inboxes.messages.get(inbox_id, message_id)
|
||||
sender = getattr(msg, "from_", "") or "unknown"
|
||||
to = getattr(msg, "to", []) or []
|
||||
subject = getattr(msg, "subject", "(no subject)") or "(no subject)"
|
||||
body = getattr(msg, "extracted_text", "") or getattr(msg, "text", "") or ""
|
||||
timestamp = getattr(msg, "timestamp", "") or getattr(msg, "created_at", "")
|
||||
in_reply_to = getattr(msg, "in_reply_to", "")
|
||||
to_str = ", ".join(to) if isinstance(to, list) else str(to)
|
||||
result = f"From: {sender}\nTo: {to_str}\nSubject: {subject}\nDate: {timestamp}\n"
|
||||
if in_reply_to:
|
||||
result += f"In-Reply-To: {in_reply_to}\n"
|
||||
result += f"\n{body}"
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read email {message_id}: {e}")
|
||||
return f"Failed to read email: {e}"
|
||||
|
||||
|
||||
def _agentmail_reply_email(message_id: str, body: str) -> str:
|
||||
client = _get_client()
|
||||
inbox_id = _get_inbox_id()
|
||||
message_id = _normalize_message_id(message_id)
|
||||
try:
|
||||
result = client.inboxes.messages.reply(inbox_id, message_id, text=body)
|
||||
reply_id = getattr(result, "message_id", "unknown")
|
||||
logger.info(f"Reply sent: {reply_id}")
|
||||
return f"Reply sent successfully (ID: {reply_id})"
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to reply to {message_id}: {e}")
|
||||
return f"Failed to reply: {e}"
|
||||
|
||||
|
||||
def _agentmail_search_emails(
|
||||
label: Optional[str] = None,
|
||||
after_date: Optional[str] = None,
|
||||
before_date: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
) -> str:
|
||||
from datetime import datetime as _dt
|
||||
client = _get_client()
|
||||
inbox_id = _get_inbox_id()
|
||||
try:
|
||||
kwargs = {"limit": min(limit, 50)}
|
||||
if label:
|
||||
kwargs["labels"] = [label]
|
||||
if after_date:
|
||||
kwargs["after"] = _dt.fromisoformat(after_date)
|
||||
if before_date:
|
||||
kwargs["before"] = _dt.fromisoformat(before_date)
|
||||
response = client.inboxes.messages.list(inbox_id, **kwargs)
|
||||
msg_list = response.messages if hasattr(response, "messages") else response
|
||||
if not msg_list:
|
||||
return "No emails matched the search criteria."
|
||||
results = []
|
||||
for msg in msg_list:
|
||||
sender = getattr(msg, "from_", "") or "unknown"
|
||||
subject = getattr(msg, "subject", "(no subject)") or "(no subject)"
|
||||
preview = getattr(msg, "preview", "") or ""
|
||||
msg_id = getattr(msg, "message_id", "")
|
||||
timestamp = getattr(msg, "timestamp", "") or getattr(msg, "created_at", "")
|
||||
labels = getattr(msg, "labels", []) or []
|
||||
results.append(
|
||||
f"- From: {sender}\n"
|
||||
f" Subject: {subject}\n"
|
||||
f" Preview: {preview[:100]}{'...' if len(preview) > 100 else ''}\n"
|
||||
f" Labels: {', '.join(labels) if labels else 'none'}\n"
|
||||
f" ID: {msg_id}\n"
|
||||
f" Date: {timestamp}"
|
||||
)
|
||||
return f"Found {len(results)} email(s):\n\n" + "\n\n".join(results)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to search emails: {e}")
|
||||
return f"Failed to search emails: {e}"
|
||||
|
||||
|
||||
def _agentmail_archive_email(message_id: str) -> str:
|
||||
client = _get_client()
|
||||
inbox_id = _get_inbox_id()
|
||||
message_id = _normalize_message_id(message_id)
|
||||
try:
|
||||
client.inboxes.messages.update(
|
||||
inbox_id, message_id,
|
||||
add_labels=["ARCHIVED"], remove_labels=["INBOX"],
|
||||
)
|
||||
logger.info(f"Email archived: {message_id}")
|
||||
return f"Email archived successfully (ID: {message_id})"
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to archive email {message_id}: {e}")
|
||||
return f"Failed to archive email: {e}"
|
||||
|
||||
|
||||
def _agentmail_forward_email(message_id: str, to: str, note: Optional[str] = None) -> str:
|
||||
client = _get_client()
|
||||
inbox_id = _get_inbox_id()
|
||||
message_id = _normalize_message_id(message_id)
|
||||
try:
|
||||
kwargs = {"to": to}
|
||||
if note:
|
||||
kwargs["text"] = note
|
||||
result = client.inboxes.messages.forward(inbox_id, message_id, **kwargs)
|
||||
fwd_id = getattr(result, "message_id", "unknown")
|
||||
logger.info(f"Email forwarded to {to}: {fwd_id}")
|
||||
return f"Email forwarded to {to} (ID: {fwd_id})"
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to forward email {message_id}: {e}")
|
||||
return f"Failed to forward email: {e}"
|
||||
|
||||
|
||||
def _agentmail_draft_email(to: str, subject: str, body: str) -> str:
|
||||
client = _get_client()
|
||||
inbox_id = _get_inbox_id()
|
||||
try:
|
||||
result = client.inboxes.drafts.create(inbox_id, to=[to], subject=subject, text=body)
|
||||
draft_id = getattr(result, "draft_id", "unknown")
|
||||
logger.info(f"Draft created: {draft_id}")
|
||||
return f"Draft created (ID: {draft_id}). Use send_draft to send it."
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create draft: {e}")
|
||||
return f"Failed to create draft: {e}"
|
||||
|
||||
|
||||
def _agentmail_send_draft(draft_id: str) -> str:
|
||||
client = _get_client()
|
||||
inbox_id = _get_inbox_id()
|
||||
try:
|
||||
result = client.inboxes.drafts.send(inbox_id, draft_id)
|
||||
msg_id = getattr(result, "message_id", "unknown")
|
||||
logger.info(f"Draft sent: {msg_id}")
|
||||
return f"Draft sent successfully (Message ID: {msg_id})"
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send draft {draft_id}: {e}")
|
||||
return f"Failed to send draft: {e}"
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# SMTP/IMAP Backend — uses standard mailbox credentials
|
||||
# Zero external dependencies (Python stdlib smtplib + imaplib)
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
_SMTP_DEFAULTS = {
|
||||
"gmail.com": ("smtp.gmail.com", 587),
|
||||
"googlemail.com": ("smtp.gmail.com", 587),
|
||||
"outlook.com": ("smtp.office365.com", 587),
|
||||
"hotmail.com": ("smtp.office365.com", 587),
|
||||
"live.com": ("smtp.office365.com", 587),
|
||||
"yahoo.com": ("smtp.mail.yahoo.com", 587),
|
||||
"icloud.com": ("smtp.mail.me.com", 587),
|
||||
}
|
||||
|
||||
_IMAP_DEFAULTS = {
|
||||
"gmail.com": ("imap.gmail.com", 993),
|
||||
"googlemail.com": ("imap.gmail.com", 993),
|
||||
"outlook.com": ("outlook.office365.com", 993),
|
||||
"hotmail.com": ("outlook.office365.com", 993),
|
||||
"live.com": ("outlook.office365.com", 993),
|
||||
"yahoo.com": ("imap.mail.yahoo.com", 993),
|
||||
"icloud.com": ("imap.mail.me.com", 993),
|
||||
}
|
||||
|
||||
|
||||
def _get_smtp_config():
|
||||
"""Get SMTP config from env vars with smart defaults."""
|
||||
email_addr = os.environ.get("EMAIL_ADDRESS", "")
|
||||
password = os.environ.get("EMAIL_PASSWORD", "")
|
||||
if not email_addr or not password:
|
||||
raise ValueError(
|
||||
"EMAIL_ADDRESS and EMAIL_PASSWORD environment variables are required. "
|
||||
"For Gmail, use an App Password: https://myaccount.google.com/apppasswords"
|
||||
)
|
||||
domain = email_addr.split("@")[-1].lower()
|
||||
default_smtp = _SMTP_DEFAULTS.get(domain, (f"smtp.{domain}", 587))
|
||||
smtp_server = os.environ.get("EMAIL_SMTP_SERVER", default_smtp[0])
|
||||
smtp_port = int(os.environ.get("EMAIL_SMTP_PORT", str(default_smtp[1])))
|
||||
return email_addr, password, smtp_server, smtp_port
|
||||
|
||||
|
||||
def _get_imap_config():
|
||||
"""Get IMAP config from env vars with smart defaults."""
|
||||
email_addr = os.environ.get("EMAIL_ADDRESS", "")
|
||||
password = os.environ.get("EMAIL_PASSWORD", "")
|
||||
if not email_addr or not password:
|
||||
raise ValueError(
|
||||
"EMAIL_ADDRESS and EMAIL_PASSWORD environment variables are required."
|
||||
)
|
||||
domain = email_addr.split("@")[-1].lower()
|
||||
default_imap = _IMAP_DEFAULTS.get(domain, (f"imap.{domain}", 993))
|
||||
imap_server = os.environ.get("EMAIL_IMAP_SERVER", default_imap[0])
|
||||
imap_port = int(os.environ.get("EMAIL_IMAP_PORT", str(default_imap[1])))
|
||||
return email_addr, password, imap_server, imap_port
|
||||
|
||||
|
||||
def _imap_connect(readonly: bool = True):
|
||||
"""Connect to IMAP and select INBOX. Returns (mail, email_addr)."""
|
||||
import imaplib
|
||||
email_addr, password, imap_server, imap_port = _get_imap_config()
|
||||
mail = imaplib.IMAP4_SSL(imap_server, imap_port)
|
||||
mail.login(email_addr, password)
|
||||
mail.select("INBOX", readonly=readonly)
|
||||
return mail, email_addr
|
||||
|
||||
|
||||
def _decode_header_value(raw_value: str) -> str:
|
||||
"""Decode a MIME-encoded header value."""
|
||||
from email.header import decode_header
|
||||
parts = decode_header(raw_value)
|
||||
result = ""
|
||||
for part, charset in parts:
|
||||
if isinstance(part, bytes):
|
||||
result += part.decode(charset or "utf-8", errors="replace")
|
||||
else:
|
||||
result += part
|
||||
return result
|
||||
|
||||
|
||||
def _extract_body_preview(msg, max_len: int = 100) -> str:
|
||||
"""Extract plain text body preview from an email message."""
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
if part.get_content_type() == "text/plain":
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload:
|
||||
return payload.decode(
|
||||
part.get_content_charset() or "utf-8", errors="replace"
|
||||
)[:max_len]
|
||||
else:
|
||||
payload = msg.get_payload(decode=True)
|
||||
if payload:
|
||||
return payload.decode(
|
||||
msg.get_content_charset() or "utf-8", errors="replace"
|
||||
)[:max_len]
|
||||
return ""
|
||||
|
||||
|
||||
def _smtp_send_email(to: str, subject: str, body: str) -> str:
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
try:
|
||||
email_addr, password, smtp_server, smtp_port = _get_smtp_config()
|
||||
msg = MIMEText(body, "plain")
|
||||
msg["From"] = email_addr
|
||||
msg["To"] = to
|
||||
msg["Subject"] = subject
|
||||
with smtplib.SMTP(smtp_server, smtp_port) as server:
|
||||
server.starttls()
|
||||
server.login(email_addr, password)
|
||||
server.send_message(msg)
|
||||
logger.info(f"SMTP email sent to {to}")
|
||||
return f"Email sent successfully to {to} from {email_addr}"
|
||||
except ValueError as e:
|
||||
return str(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send SMTP email to {to}: {e}")
|
||||
return f"Failed to send email: {e}"
|
||||
|
||||
|
||||
def _smtp_list_emails(limit: int = 10) -> str:
|
||||
import email as email_lib
|
||||
try:
|
||||
mail, _ = _imap_connect(readonly=True)
|
||||
status, data = mail.search(None, "ALL")
|
||||
if status != "OK" or not data[0]:
|
||||
mail.logout()
|
||||
return "No emails found in inbox."
|
||||
msg_ids = data[0].split()
|
||||
latest_ids = msg_ids[-limit:]
|
||||
latest_ids.reverse()
|
||||
results = []
|
||||
for uid in latest_ids:
|
||||
status, msg_data = mail.fetch(uid, "(RFC822)")
|
||||
if status != "OK":
|
||||
continue
|
||||
msg = email_lib.message_from_bytes(msg_data[0][1])
|
||||
subject = _decode_header_value(msg.get("Subject", "(no subject)"))
|
||||
sender = msg.get("From", "unknown")
|
||||
date = msg.get("Date", "")
|
||||
preview = _extract_body_preview(msg)
|
||||
results.append(
|
||||
f"- From: {sender}\n"
|
||||
f" Subject: {subject}\n"
|
||||
f" Preview: {preview}{'...' if len(preview) >= 100 else ''}\n"
|
||||
f" Date: {date}"
|
||||
)
|
||||
mail.logout()
|
||||
if not results:
|
||||
return "No emails found in inbox."
|
||||
return f"Found {len(results)} email(s):\n\n" + "\n\n".join(results)
|
||||
except ValueError as e:
|
||||
return str(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read inbox: {e}")
|
||||
return f"Failed to read inbox: {e}"
|
||||
|
||||
|
||||
def _smtp_read_email(message_id: str) -> str:
|
||||
import email as email_lib
|
||||
try:
|
||||
mail, _ = _imap_connect(readonly=True)
|
||||
# Search by Message-ID header
|
||||
search_id = message_id if message_id.startswith("<") else f"<{message_id}>"
|
||||
status, data = mail.search(None, f'HEADER Message-ID "{search_id}"')
|
||||
if status != "OK" or not data[0]:
|
||||
mail.logout()
|
||||
return f"Email not found with Message-ID: {message_id}"
|
||||
uid = data[0].split()[0]
|
||||
status, msg_data = mail.fetch(uid, "(RFC822)")
|
||||
mail.logout()
|
||||
if status != "OK":
|
||||
return f"Failed to fetch email: {message_id}"
|
||||
msg = email_lib.message_from_bytes(msg_data[0][1])
|
||||
subject = _decode_header_value(msg.get("Subject", "(no subject)"))
|
||||
sender = msg.get("From", "unknown")
|
||||
to = msg.get("To", "")
|
||||
date = msg.get("Date", "")
|
||||
body = _extract_body_preview(msg, max_len=10000)
|
||||
result = f"From: {sender}\nTo: {to}\nSubject: {subject}\nDate: {date}\n\n{body}"
|
||||
return result
|
||||
except ValueError as e:
|
||||
return str(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read email {message_id}: {e}")
|
||||
return f"Failed to read email: {e}"
|
||||
|
||||
|
||||
def _smtp_reply_email(message_id: str, body: str) -> str:
|
||||
import smtplib
|
||||
import email as email_lib
|
||||
from email.mime.text import MIMEText
|
||||
try:
|
||||
# Read original email to get sender and subject
|
||||
mail, email_addr = _imap_connect(readonly=True)
|
||||
search_id = message_id if message_id.startswith("<") else f"<{message_id}>"
|
||||
status, data = mail.search(None, f'HEADER Message-ID "{search_id}"')
|
||||
if status != "OK" or not data[0]:
|
||||
mail.logout()
|
||||
return f"Original email not found: {message_id}"
|
||||
uid = data[0].split()[0]
|
||||
status, msg_data = mail.fetch(uid, "(RFC822)")
|
||||
mail.logout()
|
||||
if status != "OK":
|
||||
return f"Failed to fetch original email: {message_id}"
|
||||
original = email_lib.message_from_bytes(msg_data[0][1])
|
||||
reply_to = original.get("Reply-To") or original.get("From", "")
|
||||
subject = original.get("Subject", "")
|
||||
if not subject.lower().startswith("re:"):
|
||||
subject = f"Re: {subject}"
|
||||
# Send reply
|
||||
_, password, smtp_server, smtp_port = _get_smtp_config()
|
||||
msg = MIMEText(body, "plain")
|
||||
msg["From"] = email_addr
|
||||
msg["To"] = reply_to
|
||||
msg["Subject"] = subject
|
||||
msg["In-Reply-To"] = search_id
|
||||
msg["References"] = search_id
|
||||
msg["Auto-Submitted"] = "auto-replied"
|
||||
with smtplib.SMTP(smtp_server, smtp_port) as server:
|
||||
server.starttls()
|
||||
server.login(email_addr, password)
|
||||
server.send_message(msg)
|
||||
logger.info(f"Reply sent to {reply_to}")
|
||||
return f"Reply sent to {reply_to}"
|
||||
except ValueError as e:
|
||||
return str(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to reply to {message_id}: {e}")
|
||||
return f"Failed to reply: {e}"
|
||||
|
||||
|
||||
def _smtp_search_emails(
|
||||
query: Optional[str] = None,
|
||||
from_addr: Optional[str] = None,
|
||||
subject: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
) -> str:
|
||||
import email as email_lib
|
||||
try:
|
||||
mail, _ = _imap_connect(readonly=True)
|
||||
# Build IMAP search criteria
|
||||
criteria = []
|
||||
if from_addr:
|
||||
criteria.append(f'FROM "{from_addr}"')
|
||||
if subject:
|
||||
criteria.append(f'SUBJECT "{subject}"')
|
||||
if query:
|
||||
criteria.append(f'TEXT "{query}"')
|
||||
if not criteria:
|
||||
criteria.append("ALL")
|
||||
search_str = " ".join(criteria)
|
||||
status, data = mail.search(None, search_str)
|
||||
if status != "OK" or not data[0]:
|
||||
mail.logout()
|
||||
return "No emails matched the search criteria."
|
||||
msg_ids = data[0].split()
|
||||
latest_ids = msg_ids[-min(limit, 50):]
|
||||
latest_ids.reverse()
|
||||
results = []
|
||||
for uid in latest_ids:
|
||||
status, msg_data = mail.fetch(uid, "(RFC822)")
|
||||
if status != "OK":
|
||||
continue
|
||||
msg = email_lib.message_from_bytes(msg_data[0][1])
|
||||
msg_subject = _decode_header_value(msg.get("Subject", "(no subject)"))
|
||||
sender = msg.get("From", "unknown")
|
||||
date = msg.get("Date", "")
|
||||
mid = msg.get("Message-ID", "")
|
||||
preview = _extract_body_preview(msg)
|
||||
results.append(
|
||||
f"- From: {sender}\n"
|
||||
f" Subject: {msg_subject}\n"
|
||||
f" Preview: {preview}{'...' if len(preview) >= 100 else ''}\n"
|
||||
f" ID: {mid}\n"
|
||||
f" Date: {date}"
|
||||
)
|
||||
mail.logout()
|
||||
if not results:
|
||||
return "No emails matched the search criteria."
|
||||
return f"Found {len(results)} email(s):\n\n" + "\n\n".join(results)
|
||||
except ValueError as e:
|
||||
return str(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to search emails: {e}")
|
||||
return f"Failed to search emails: {e}"
|
||||
|
||||
|
||||
def _smtp_archive_email(message_id: str) -> str:
|
||||
try:
|
||||
import imaplib
|
||||
mail, email_addr = _imap_connect(readonly=False)
|
||||
search_id = message_id if message_id.startswith("<") else f"<{message_id}>"
|
||||
status, data = mail.search(None, f'HEADER Message-ID "{search_id}"')
|
||||
if status != "OK" or not data[0]:
|
||||
mail.logout()
|
||||
return f"Email not found: {message_id}"
|
||||
uid = data[0].split()[0]
|
||||
# Gmail: copy to All Mail, then delete from INBOX
|
||||
# Other: just mark as \Seen and \Deleted
|
||||
domain = email_addr.split("@")[-1].lower()
|
||||
if domain in ("gmail.com", "googlemail.com") or os.environ.get("EMAIL_IMAP_SERVER", "").endswith("gmail.com"):
|
||||
mail.copy(uid, "[Gmail]/All Mail")
|
||||
mail.store(uid, "+FLAGS", "\\Deleted")
|
||||
else:
|
||||
mail.store(uid, "+FLAGS", "(\\Seen \\Deleted)")
|
||||
mail.expunge()
|
||||
mail.logout()
|
||||
logger.info(f"Email archived: {message_id}")
|
||||
return f"Email archived successfully (ID: {message_id})"
|
||||
except ValueError as e:
|
||||
return str(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to archive email {message_id}: {e}")
|
||||
return f"Failed to archive email: {e}"
|
||||
|
||||
|
||||
def _smtp_draft_email(to: str, subject: str, body: str) -> str:
|
||||
import imaplib
|
||||
import time
|
||||
from email.mime.text import MIMEText
|
||||
try:
|
||||
email_addr, password, imap_server, imap_port = _get_imap_config()
|
||||
msg = MIMEText(body, "plain")
|
||||
msg["From"] = email_addr
|
||||
msg["To"] = to
|
||||
msg["Subject"] = subject
|
||||
mail = imaplib.IMAP4_SSL(imap_server, imap_port)
|
||||
mail.login(email_addr, password)
|
||||
# Gmail uses [Gmail]/Drafts, others use Drafts or DRAFTS
|
||||
domain = email_addr.split("@")[-1].lower()
|
||||
if domain in ("gmail.com", "googlemail.com") or imap_server.endswith("gmail.com"):
|
||||
drafts_folder = "[Gmail]/Drafts"
|
||||
else:
|
||||
drafts_folder = "Drafts"
|
||||
mail.append(drafts_folder, "\\Draft", imaplib.Time2Internaldate(time.time()), msg.as_bytes())
|
||||
mail.logout()
|
||||
logger.info(f"Draft saved to {drafts_folder}")
|
||||
return f"Draft saved to {drafts_folder} (To: {to}, Subject: {subject})"
|
||||
except ValueError as e:
|
||||
return str(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save draft: {e}")
|
||||
return f"Failed to save draft: {e}"
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# Generic Tools — Auto-detect backend
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
def send_email(to: str, subject: str, body: str) -> str:
|
||||
"""Send an email to someone.
|
||||
|
||||
Auto-detects backend: AgentMail (if AGENTMAIL_API_KEY set) or
|
||||
SMTP (if EMAIL_ADDRESS + EMAIL_PASSWORD set).
|
||||
|
||||
Args:
|
||||
to: Recipient email address (e.g. bob@example.com)
|
||||
subject: Email subject line
|
||||
body: Email body text content
|
||||
|
||||
Returns:
|
||||
Confirmation message with the sent message ID
|
||||
"""
|
||||
backend = _detect_backend()
|
||||
if backend == "agentmail":
|
||||
return _agentmail_send_email(to, subject, body)
|
||||
return _smtp_send_email(to, subject, body)
|
||||
|
||||
|
||||
def list_emails(limit: int = 10) -> str:
|
||||
"""List recent emails in the inbox.
|
||||
|
||||
Auto-detects backend: AgentMail or IMAP.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of emails to return (default 10)
|
||||
|
||||
Returns:
|
||||
Summary of recent emails with sender, subject, and preview
|
||||
"""
|
||||
backend = _detect_backend()
|
||||
if backend == "agentmail":
|
||||
return _agentmail_list_emails(limit)
|
||||
return _smtp_list_emails(limit)
|
||||
|
||||
|
||||
def read_email(message_id: str) -> str:
|
||||
"""Read the full content of a specific email.
|
||||
|
||||
Auto-detects backend: AgentMail or IMAP.
|
||||
|
||||
Args:
|
||||
message_id: The message ID to read (get this from list_emails)
|
||||
|
||||
Returns:
|
||||
Full email content including sender, subject, and body
|
||||
"""
|
||||
backend = _detect_backend()
|
||||
if backend == "agentmail":
|
||||
return _agentmail_read_email(message_id)
|
||||
return _smtp_read_email(message_id)
|
||||
|
||||
|
||||
def reply_email(message_id: str, body: str) -> str:
|
||||
"""Reply to an email message.
|
||||
|
||||
Auto-detects backend: AgentMail or SMTP.
|
||||
|
||||
Args:
|
||||
message_id: The message ID to reply to (get this from list_emails)
|
||||
body: Reply body text
|
||||
|
||||
Returns:
|
||||
Confirmation with the reply message ID
|
||||
"""
|
||||
backend = _detect_backend()
|
||||
if backend == "agentmail":
|
||||
return _agentmail_reply_email(message_id, body)
|
||||
return _smtp_reply_email(message_id, body)
|
||||
|
||||
|
||||
def search_emails(
|
||||
query: Optional[str] = None,
|
||||
from_addr: Optional[str] = None,
|
||||
subject: Optional[str] = None,
|
||||
label: Optional[str] = None,
|
||||
after_date: Optional[str] = None,
|
||||
before_date: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
) -> str:
|
||||
"""Search emails by keyword, sender, subject, label, or date range.
|
||||
|
||||
Auto-detects backend: AgentMail or IMAP.
|
||||
AgentMail supports: label, after_date, before_date.
|
||||
IMAP supports: query (text search), from_addr, subject.
|
||||
|
||||
Args:
|
||||
query: Search text in email body (IMAP only)
|
||||
from_addr: Filter by sender email (IMAP only)
|
||||
subject: Filter by subject text (IMAP only)
|
||||
label: Filter by label (AgentMail only, e.g. "INBOX", "SENT")
|
||||
after_date: Only emails after this date (ISO format, e.g. "2025-01-15")
|
||||
before_date: Only emails before this date (ISO format, e.g. "2025-03-01")
|
||||
limit: Maximum number of results (default 20, max 50)
|
||||
|
||||
Returns:
|
||||
Matching emails with sender, subject, preview, and ID
|
||||
"""
|
||||
backend = _detect_backend()
|
||||
if backend == "agentmail":
|
||||
return _agentmail_search_emails(label, after_date, before_date, limit)
|
||||
return _smtp_search_emails(query, from_addr, subject, limit)
|
||||
|
||||
|
||||
def archive_email(message_id: str) -> str:
|
||||
"""Archive an email (remove from inbox, keep in archive).
|
||||
|
||||
Auto-detects backend: AgentMail or IMAP.
|
||||
Gmail: moves to All Mail. Other providers: marks as deleted from inbox.
|
||||
|
||||
Args:
|
||||
message_id: The message ID to archive (get this from list_emails or search_emails)
|
||||
|
||||
Returns:
|
||||
Confirmation message
|
||||
"""
|
||||
backend = _detect_backend()
|
||||
if backend == "agentmail":
|
||||
return _agentmail_archive_email(message_id)
|
||||
return _smtp_archive_email(message_id)
|
||||
|
||||
|
||||
def forward_email(message_id: str, to: str, note: Optional[str] = None) -> str:
|
||||
"""Forward an email to another recipient.
|
||||
|
||||
Requires AgentMail backend. IMAP/SMTP does not support forwarding
|
||||
(workaround: read_email + send_email).
|
||||
|
||||
Args:
|
||||
message_id: The message ID to forward (get this from list_emails)
|
||||
to: Recipient email address to forward to
|
||||
note: Optional note to include above the forwarded message
|
||||
|
||||
Returns:
|
||||
Confirmation with the forwarded message ID
|
||||
"""
|
||||
backend = _detect_backend()
|
||||
if backend == "agentmail":
|
||||
return _agentmail_forward_email(message_id, to, note)
|
||||
return "Forward not supported with IMAP. Use read_email + send_email as a workaround."
|
||||
|
||||
|
||||
def draft_email(to: str, subject: str, body: str) -> str:
|
||||
"""Create an email draft without sending it.
|
||||
|
||||
Auto-detects backend: AgentMail or IMAP.
|
||||
AgentMail: use send_draft() to send later.
|
||||
IMAP: saves to Drafts folder (send from your email client).
|
||||
|
||||
Args:
|
||||
to: Recipient email address
|
||||
subject: Email subject line
|
||||
body: Email body text
|
||||
|
||||
Returns:
|
||||
Confirmation with draft details
|
||||
"""
|
||||
backend = _detect_backend()
|
||||
if backend == "agentmail":
|
||||
return _agentmail_draft_email(to, subject, body)
|
||||
return _smtp_draft_email(to, subject, body)
|
||||
|
||||
|
||||
def send_draft(draft_id: str) -> str:
|
||||
"""Send a previously created email draft.
|
||||
|
||||
AgentMail only. IMAP drafts must be sent from your email client.
|
||||
|
||||
Args:
|
||||
draft_id: The draft ID to send (get this from draft_email)
|
||||
|
||||
Returns:
|
||||
Confirmation with the sent message ID
|
||||
"""
|
||||
backend = _detect_backend()
|
||||
if backend == "agentmail":
|
||||
return _agentmail_send_draft(draft_id)
|
||||
return "send_draft requires AgentMail. IMAP drafts must be sent from your email client."
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# AgentMail-only tools (no IMAP equivalent)
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
def list_inboxes() -> str:
|
||||
"""List all email inboxes available (AgentMail only).
|
||||
|
||||
Returns:
|
||||
List of inbox email addresses
|
||||
"""
|
||||
client = _get_client()
|
||||
try:
|
||||
response = client.inboxes.list()
|
||||
inbox_list = response.inboxes if hasattr(response, "inboxes") else response
|
||||
if not inbox_list:
|
||||
return "No inboxes found."
|
||||
results = []
|
||||
for inbox in inbox_list:
|
||||
email = getattr(inbox, "inbox_id", "unknown")
|
||||
display = getattr(inbox, "display_name", "") or ""
|
||||
label = f"{email} ({display})" if display else email
|
||||
results.append(f"- {label}")
|
||||
return f"Found {len(results)} inbox(es):\n" + "\n".join(results)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list inboxes: {e}")
|
||||
return f"Failed to list inboxes: {e}"
|
||||
|
||||
|
||||
def create_inbox(display_name: Optional[str] = None) -> str:
|
||||
"""Create a new email inbox (AgentMail only).
|
||||
|
||||
Args:
|
||||
display_name: Optional display name for the inbox
|
||||
|
||||
Returns:
|
||||
Details of the created inbox
|
||||
"""
|
||||
client = _get_client()
|
||||
try:
|
||||
if display_name:
|
||||
try:
|
||||
from agentmail.inboxes.types import CreateInboxRequest
|
||||
result = client.inboxes.create(
|
||||
request=CreateInboxRequest(display_name=display_name)
|
||||
)
|
||||
except ImportError:
|
||||
result = client.inboxes.create()
|
||||
else:
|
||||
result = client.inboxes.create()
|
||||
inbox_id = getattr(result, "inbox_id", "unknown")
|
||||
name = getattr(result, "display_name", "") or ""
|
||||
logger.info(f"Inbox created: {inbox_id}")
|
||||
label = f"{inbox_id} ({name})" if name else inbox_id
|
||||
return f"Inbox created: {label}"
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create inbox: {e}")
|
||||
return f"Failed to create inbox: {e}"
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# Backward-compatible aliases (smtp_ prefix)
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
def smtp_send_email(to: str, subject: str, body: str) -> str:
|
||||
"""Send an email using SMTP (Gmail, Outlook, etc.).
|
||||
|
||||
Uses EMAIL_ADDRESS and EMAIL_PASSWORD env vars.
|
||||
|
||||
Args:
|
||||
to: Recipient email address
|
||||
subject: Email subject line
|
||||
body: Email body text
|
||||
|
||||
Returns:
|
||||
Confirmation message
|
||||
"""
|
||||
return _smtp_send_email(to, subject, body)
|
||||
|
||||
|
||||
def smtp_read_inbox(limit: int = 10, folder: str = "INBOX") -> str:
|
||||
"""Read recent emails from your mailbox using IMAP.
|
||||
|
||||
Uses EMAIL_ADDRESS and EMAIL_PASSWORD env vars.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of emails to return (default 10)
|
||||
folder: Mailbox folder to read from (default INBOX)
|
||||
|
||||
Returns:
|
||||
Summary of recent emails with sender, subject, and preview
|
||||
"""
|
||||
return _smtp_list_emails(limit)
|
||||
|
||||
|
||||
def smtp_search_inbox(
|
||||
query: Optional[str] = None,
|
||||
from_addr: Optional[str] = None,
|
||||
subject: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
) -> str:
|
||||
"""Search emails using IMAP SEARCH.
|
||||
|
||||
Uses EMAIL_ADDRESS and EMAIL_PASSWORD env vars.
|
||||
|
||||
Args:
|
||||
query: Search text in email body
|
||||
from_addr: Filter by sender email address
|
||||
subject: Filter by subject text
|
||||
limit: Maximum number of results (default 20)
|
||||
|
||||
Returns:
|
||||
Matching emails with sender, subject, preview
|
||||
"""
|
||||
return _smtp_search_emails(query, from_addr, subject, limit)
|
||||
|
||||
|
||||
def smtp_archive_email(message_id: str) -> str:
|
||||
"""Archive an email using IMAP.
|
||||
|
||||
Gmail: moves to All Mail. Other providers: marks as deleted.
|
||||
|
||||
Args:
|
||||
message_id: The Message-ID header value
|
||||
|
||||
Returns:
|
||||
Confirmation message
|
||||
"""
|
||||
return _smtp_archive_email(message_id)
|
||||
|
||||
|
||||
def smtp_draft_email(to: str, subject: str, body: str) -> str:
|
||||
"""Save an email draft using IMAP APPEND.
|
||||
|
||||
Saves to Drafts folder ([Gmail]/Drafts for Gmail).
|
||||
|
||||
Args:
|
||||
to: Recipient email address
|
||||
subject: Email subject line
|
||||
body: Email body text
|
||||
|
||||
Returns:
|
||||
Confirmation message
|
||||
"""
|
||||
return _smtp_draft_email(to, subject, body)
|
||||
@@ -60,6 +60,7 @@ class AgentState:
|
||||
last_user_message: Optional[str] = None
|
||||
last_agent_message: Optional[str] = None
|
||||
memory: Optional[Any] = None
|
||||
learn_manager: Optional[Any] = None
|
||||
previous_tool_results: List[Any] = field(default_factory=list)
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
Learning tools for PraisonAI agents.
|
||||
|
||||
Provides store_learning and search_learning as standard tool functions.
|
||||
These are the Learn system counterparts to store_memory/search_memory.
|
||||
|
||||
Memory stores flat facts ("User's name is Alice").
|
||||
Learning stores categorized knowledge ("User prefers bullet points" → persona).
|
||||
|
||||
Usage:
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.tools import store_learning, search_learning
|
||||
|
||||
agent = Agent(
|
||||
instructions="You learn user preferences.",
|
||||
memory=True,
|
||||
learn=True,
|
||||
tools=[store_learning, search_learning]
|
||||
)
|
||||
"""
|
||||
|
||||
from typing import Optional, List, Dict, Any
|
||||
from .injected import Injected, AgentState, get_current_state
|
||||
|
||||
|
||||
# Valid categories mapping to LearnManager capture methods
|
||||
_CATEGORY_MAP = {
|
||||
"persona": "capture_persona",
|
||||
"insights": "capture_insight",
|
||||
"patterns": "capture_pattern",
|
||||
"decisions": "capture_decision",
|
||||
"feedback": "capture_feedback",
|
||||
"improvements": "capture_improvement",
|
||||
}
|
||||
|
||||
|
||||
def store_learning(
|
||||
content: str,
|
||||
category: str = "persona",
|
||||
state: Injected[AgentState] = None,
|
||||
) -> str:
|
||||
"""Store a learning — a pattern, preference, insight, or decision.
|
||||
|
||||
Use this when you discover something worth remembering across sessions,
|
||||
like user preferences, behavioral patterns, or important decisions.
|
||||
|
||||
Categories:
|
||||
persona — User preferences and profile (default)
|
||||
insights — Observations about the user or domain
|
||||
patterns — Recurring behaviors or workflows
|
||||
decisions — Decision records for consistency
|
||||
|
||||
Args:
|
||||
content: The learning to store
|
||||
category: Category — "persona", "insights", "patterns", or "decisions"
|
||||
"""
|
||||
# Resolve injected state
|
||||
if state is None:
|
||||
state = get_current_state()
|
||||
|
||||
learn_mgr = getattr(state, "learn_manager", None) if state else None
|
||||
if not learn_mgr:
|
||||
return "Learning is not configured for this agent. Enable learn=True to use learning tools."
|
||||
|
||||
# Normalize category
|
||||
cat = category.lower().strip()
|
||||
if cat not in _CATEGORY_MAP:
|
||||
return f"Unknown category '{category}'. Use: {', '.join(_CATEGORY_MAP.keys())}"
|
||||
|
||||
method_name = _CATEGORY_MAP[cat]
|
||||
method = getattr(learn_mgr, method_name, None)
|
||||
if not method:
|
||||
return f"Learn manager does not support category '{cat}'."
|
||||
|
||||
try:
|
||||
result = method(content)
|
||||
# Friendly category labels for response
|
||||
labels = {
|
||||
"persona": "persona preference",
|
||||
"insights": "insight",
|
||||
"patterns": "pattern",
|
||||
"decisions": "decision",
|
||||
"feedback": "feedback",
|
||||
"improvements": "improvement",
|
||||
}
|
||||
label = labels.get(cat, cat)
|
||||
return f"Stored {label}: {content[:100]}"
|
||||
except Exception as e:
|
||||
return f"Error storing learning: {e}"
|
||||
|
||||
|
||||
def search_learning(
|
||||
query: str,
|
||||
category: str = "",
|
||||
limit: int = 5,
|
||||
state: Injected[AgentState] = None,
|
||||
) -> str:
|
||||
"""Search learned knowledge — preferences, patterns, insights, decisions.
|
||||
|
||||
Use this to recall previously learned information about the user
|
||||
or domain across sessions.
|
||||
|
||||
Args:
|
||||
query: What to search for
|
||||
category: Optional — filter to specific category (persona, insights, patterns, decisions)
|
||||
limit: Maximum number of results to return
|
||||
"""
|
||||
# Resolve injected state
|
||||
if state is None:
|
||||
state = get_current_state()
|
||||
|
||||
learn_mgr = getattr(state, "learn_manager", None) if state else None
|
||||
if not learn_mgr:
|
||||
return "Learning is not configured for this agent. Enable learn=True to use learning tools."
|
||||
|
||||
try:
|
||||
all_results = learn_mgr.search(query, limit=limit)
|
||||
except Exception as e:
|
||||
return f"Error searching learnings: {e}"
|
||||
|
||||
# Filter by category if specified
|
||||
if category:
|
||||
cat = category.lower().strip()
|
||||
all_results = {k: v for k, v in all_results.items() if k == cat}
|
||||
|
||||
if not all_results:
|
||||
return f"No learnings found matching: {query}"
|
||||
|
||||
# Format results grouped by category
|
||||
parts: List[str] = []
|
||||
total = 0
|
||||
for store_name, entries in all_results.items():
|
||||
for entry in entries[:limit]:
|
||||
text = entry.get("content", "") if isinstance(entry, dict) else str(entry)
|
||||
if text:
|
||||
parts.append(f"- [{store_name}] {text}")
|
||||
total += 1
|
||||
|
||||
if not parts:
|
||||
return f"No learnings found matching: {query}"
|
||||
|
||||
formatted = "\n".join(parts[:limit])
|
||||
return f"Found {min(total, limit)} learnings:\n{formatted}"
|
||||
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
Memory tools for PraisonAI agents.
|
||||
|
||||
Provides store_memory and search_memory as standard tool functions.
|
||||
Agents use them via tools=[store_memory, search_memory] — like any other tool.
|
||||
|
||||
Memory access is provided through the Injected[AgentState] mechanism,
|
||||
which is hidden from the LLM's tool schema.
|
||||
|
||||
Usage:
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.tools import store_memory, search_memory
|
||||
|
||||
agent = Agent(
|
||||
instructions="You remember important facts.",
|
||||
memory=True,
|
||||
tools=[store_memory, search_memory]
|
||||
)
|
||||
"""
|
||||
|
||||
from typing import Optional, List, Dict, Any
|
||||
from .injected import Injected, AgentState, get_current_state
|
||||
|
||||
|
||||
def store_memory(
|
||||
content: str,
|
||||
memory_type: str = "long_term",
|
||||
state: Injected[AgentState] = None,
|
||||
) -> str:
|
||||
"""Store important information for later recall.
|
||||
|
||||
Use this when the user tells you something worth remembering,
|
||||
like preferences, facts, or important context.
|
||||
|
||||
Args:
|
||||
content: The information to remember
|
||||
memory_type: Type of memory — "short_term" for temporary, "long_term" for persistent
|
||||
"""
|
||||
# Resolve injected state
|
||||
if state is None:
|
||||
state = get_current_state()
|
||||
|
||||
memory = getattr(state, "memory", None) if state else None
|
||||
if not memory:
|
||||
return "Memory is not configured for this agent. Enable memory=True to use memory tools."
|
||||
|
||||
try:
|
||||
if memory_type == "short_term":
|
||||
if hasattr(memory, "store_short_term"):
|
||||
memory.store_short_term(content)
|
||||
elif hasattr(memory, "add_short_term"):
|
||||
memory.add_short_term(content)
|
||||
else:
|
||||
return "Memory backend does not support short-term storage."
|
||||
else:
|
||||
# Default to long_term
|
||||
if hasattr(memory, "store_long_term"):
|
||||
memory.store_long_term(content)
|
||||
elif hasattr(memory, "add_long_term"):
|
||||
memory.add_long_term(content)
|
||||
else:
|
||||
return "Memory backend does not support long-term storage."
|
||||
|
||||
return f"Stored in {memory_type} memory: {content[:100]}"
|
||||
except Exception as e:
|
||||
return f"Error storing memory: {e}"
|
||||
|
||||
|
||||
def search_memory(
|
||||
query: str,
|
||||
limit: int = 5,
|
||||
state: Injected[AgentState] = None,
|
||||
) -> str:
|
||||
"""Search your memory for relevant information.
|
||||
|
||||
Use this when you need to recall something the user
|
||||
previously told you or that you previously stored.
|
||||
|
||||
Args:
|
||||
query: What to search for
|
||||
limit: Maximum number of results to return
|
||||
"""
|
||||
# Resolve injected state
|
||||
if state is None:
|
||||
state = get_current_state()
|
||||
|
||||
memory = getattr(state, "memory", None) if state else None
|
||||
if not memory:
|
||||
return "Memory is not configured for this agent. Enable memory=True to use memory tools."
|
||||
|
||||
results: List[str] = []
|
||||
|
||||
try:
|
||||
# Search long-term memory first (most important)
|
||||
if hasattr(memory, "search_long_term"):
|
||||
lt_results = memory.search_long_term(query, limit=limit)
|
||||
if lt_results:
|
||||
for item in lt_results:
|
||||
text = _extract_text(item)
|
||||
if text:
|
||||
results.append(text)
|
||||
|
||||
# Also check short-term memory
|
||||
if hasattr(memory, "search_short_term"):
|
||||
st_results = memory.search_short_term(query, limit=limit)
|
||||
if st_results:
|
||||
for item in st_results:
|
||||
text = _extract_text(item)
|
||||
if text and text not in results:
|
||||
results.append(text)
|
||||
|
||||
# Fallback: try generic search method
|
||||
if not results and hasattr(memory, "search"):
|
||||
generic_results = memory.search(query, limit=limit)
|
||||
if generic_results:
|
||||
for item in generic_results:
|
||||
text = _extract_text(item)
|
||||
if text:
|
||||
results.append(text)
|
||||
|
||||
except Exception as e:
|
||||
return f"Error searching memory: {e}"
|
||||
|
||||
if not results:
|
||||
return f"No memories found matching: {query}"
|
||||
|
||||
# Format results
|
||||
formatted = "\n".join(f"- {r}" for r in results[:limit])
|
||||
return f"Found {len(results[:limit])} memories:\n{formatted}"
|
||||
|
||||
|
||||
def _extract_text(item: Any) -> Optional[str]:
|
||||
"""Extract text content from a memory search result."""
|
||||
if isinstance(item, str):
|
||||
return item
|
||||
if isinstance(item, dict):
|
||||
return item.get("text") or item.get("content") or item.get("memory") or str(item)
|
||||
if hasattr(item, "content"):
|
||||
return item.content
|
||||
if hasattr(item, "text"):
|
||||
return item.text
|
||||
return str(item) if item else None
|
||||
@@ -226,9 +226,9 @@ class MentionsParser:
|
||||
"""Process @web:query mention."""
|
||||
try:
|
||||
# Try to use web search
|
||||
from praisonaiagents.tools import duckduckgo_search
|
||||
from praisonaiagents.tools import duckduckgo
|
||||
|
||||
results = duckduckgo_search(query, max_results=3)
|
||||
results = duckduckgo(query, max_results=3)
|
||||
if results:
|
||||
return f"# Web Search: {query}\n{results}"
|
||||
else:
|
||||
@@ -236,7 +236,7 @@ class MentionsParser:
|
||||
|
||||
except ImportError:
|
||||
self._log("Web search not available", logging.WARNING)
|
||||
return f"# Web Search: {query}\n[Web search not available. Install with: pip install duckduckgo-search]"
|
||||
return f"# Web Search: {query}\n[Web search not available. Install with: pip install ddgs]"
|
||||
except Exception as e:
|
||||
self._log(f"Error in web search: {e}", logging.ERROR)
|
||||
return f"# Web Search: {query}\n[Error: {e}]"
|
||||
|
||||
@@ -78,13 +78,33 @@ BUILTIN_PROFILES: Dict[str, ToolProfile] = {
|
||||
tools=["schedule_add", "schedule_list", "schedule_remove"],
|
||||
description="Agent-centric scheduling",
|
||||
),
|
||||
"memory": ToolProfile(
|
||||
name="memory",
|
||||
tools=["store_memory", "search_memory"],
|
||||
description="Active memory store/search (requires memory=True on Agent)",
|
||||
),
|
||||
"learning": ToolProfile(
|
||||
name="learning",
|
||||
tools=["store_learning", "search_learning"],
|
||||
description="Categorized knowledge store/search (requires learn=True on Agent)",
|
||||
),
|
||||
"email": ToolProfile(
|
||||
name="email",
|
||||
tools=["send_email", "list_emails", "read_email", "reply_email"],
|
||||
description="Email send/read via AgentMail API (requires AGENTMAIL_API_KEY)",
|
||||
),
|
||||
"smtp_email": ToolProfile(
|
||||
name="smtp_email",
|
||||
tools=["smtp_send_email", "smtp_read_inbox"],
|
||||
description="Email send/read via SMTP/IMAP with mailbox credentials (requires EMAIL_ADDRESS, EMAIL_PASSWORD)",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _build_autonomy_tools() -> List[str]:
|
||||
"""Build the autonomy profile tools list (deduped)."""
|
||||
tools = []
|
||||
for profile_name in ["code_intelligence", "file_ops", "shell", "web"]:
|
||||
for profile_name in ["code_intelligence", "file_ops", "shell", "web", "memory", "learning", "schedule"]:
|
||||
profile = BUILTIN_PROFILES[profile_name]
|
||||
for tool in profile.tools:
|
||||
if tool not in tools:
|
||||
@@ -96,7 +116,7 @@ def _build_autonomy_tools() -> List[str]:
|
||||
AUTONOMY_PROFILE = ToolProfile(
|
||||
name="autonomy",
|
||||
tools=_build_autonomy_tools(),
|
||||
description="Default tools for autonomous agents (file_ops + shell + web + code_intelligence)",
|
||||
description="Default tools for autonomous agents (code_intelligence + file_ops + shell + web + memory + learning + schedule)",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -13,7 +13,8 @@ import logging
|
||||
import os
|
||||
import time
|
||||
import platform
|
||||
import psutil
|
||||
# psutil is imported lazily inside methods that need it
|
||||
# to avoid hard failure when the package is not installed
|
||||
from typing import Dict, List, Optional, Union
|
||||
from ..approval import require_approval
|
||||
|
||||
@@ -25,14 +26,8 @@ class ShellTools:
|
||||
self._check_dependencies()
|
||||
|
||||
def _check_dependencies(self):
|
||||
"""Check if required packages are installed."""
|
||||
try:
|
||||
import psutil
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"Required package not available. Please install: psutil\n"
|
||||
"Run: pip install psutil"
|
||||
)
|
||||
"""Check if required packages are installed (lazy — no hard failure)."""
|
||||
pass
|
||||
|
||||
@require_approval(risk_level="critical")
|
||||
def execute_command(
|
||||
@@ -117,11 +112,16 @@ class ShellTools:
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
# Kill process on timeout
|
||||
parent = psutil.Process(process.pid)
|
||||
children = parent.children(recursive=True)
|
||||
for child in children:
|
||||
child.kill()
|
||||
parent.kill()
|
||||
try:
|
||||
import psutil
|
||||
parent = psutil.Process(process.pid)
|
||||
children = parent.children(recursive=True)
|
||||
for child in children:
|
||||
child.kill()
|
||||
parent.kill()
|
||||
except ImportError:
|
||||
# Fallback: kill without psutil
|
||||
process.kill()
|
||||
|
||||
return {
|
||||
'stdout': '',
|
||||
@@ -148,6 +148,10 @@ class ShellTools:
|
||||
Returns:
|
||||
List of process information dictionaries
|
||||
"""
|
||||
try:
|
||||
import psutil
|
||||
except ImportError:
|
||||
return [{"error": "psutil is required for list_processes. Install with: pip install psutil"}]
|
||||
try:
|
||||
processes = []
|
||||
for proc in psutil.process_iter(['pid', 'name', 'username', 'memory_percent', 'cpu_percent']):
|
||||
@@ -187,6 +191,13 @@ class ShellTools:
|
||||
Returns:
|
||||
Dictionary with operation results
|
||||
"""
|
||||
try:
|
||||
import psutil
|
||||
except ImportError:
|
||||
return {
|
||||
'success': False,
|
||||
'message': 'psutil is required for kill_process. Install with: pip install psutil'
|
||||
}
|
||||
try:
|
||||
process = psutil.Process(pid)
|
||||
if force:
|
||||
@@ -222,6 +233,10 @@ class ShellTools:
|
||||
Returns:
|
||||
Dictionary with system information
|
||||
"""
|
||||
try:
|
||||
import psutil
|
||||
except ImportError:
|
||||
return {"error": "psutil is required for get_system_info. Install with: pip install psutil"}
|
||||
try:
|
||||
cpu_percent = psutil.cpu_percent(interval=1)
|
||||
memory = psutil.virtual_memory()
|
||||
@@ -256,12 +271,30 @@ class ShellTools:
|
||||
logging.error(error_msg)
|
||||
return {}
|
||||
|
||||
# Create instance for direct function access
|
||||
_shell_tools = ShellTools()
|
||||
execute_command = _shell_tools.execute_command
|
||||
list_processes = _shell_tools.list_processes
|
||||
kill_process = _shell_tools.kill_process
|
||||
get_system_info = _shell_tools.get_system_info
|
||||
# Lazy instance — deferred so module can be imported without psutil
|
||||
_shell_tools = None
|
||||
|
||||
def _get_shell_tools():
|
||||
global _shell_tools
|
||||
if _shell_tools is None:
|
||||
_shell_tools = ShellTools()
|
||||
return _shell_tools
|
||||
|
||||
def execute_command(*args, **kwargs):
|
||||
"""Execute a shell command safely."""
|
||||
return _get_shell_tools().execute_command(*args, **kwargs)
|
||||
|
||||
def list_processes(*args, **kwargs):
|
||||
"""List running processes with their details."""
|
||||
return _get_shell_tools().list_processes(*args, **kwargs)
|
||||
|
||||
def kill_process(*args, **kwargs):
|
||||
"""Kill a process by its PID."""
|
||||
return _get_shell_tools().kill_process(*args, **kwargs)
|
||||
|
||||
def get_system_info(*args, **kwargs):
|
||||
"""Get system information."""
|
||||
return _get_shell_tools().get_system_info(*args, **kwargs)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example usage
|
||||
|
||||
File renamed without changes.
@@ -4,6 +4,7 @@ A2A - Main A2A Interface Class
|
||||
Exposes PraisonAI Agents via the A2A (Agent2Agent) protocol.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import List, Optional, TYPE_CHECKING
|
||||
|
||||
@@ -48,6 +49,9 @@ class A2A:
|
||||
version: Version string for the agent
|
||||
prefix: URL prefix for the router
|
||||
tags: OpenAPI tags for the router
|
||||
auth_token: Optional bearer token for authentication on POST /a2a.
|
||||
When set, requests must include Authorization: Bearer <token>.
|
||||
Discovery (/.well-known/agent.json) remains public per spec.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -60,6 +64,7 @@ class A2A:
|
||||
version: str = "1.0.0",
|
||||
prefix: str = "",
|
||||
tags: Optional[List[str]] = None,
|
||||
auth_token: Optional[str] = None,
|
||||
):
|
||||
if agent is None and agents is None:
|
||||
raise ValueError("A2A requires an agent or agents instance")
|
||||
@@ -70,6 +75,7 @@ class A2A:
|
||||
self.version = version
|
||||
self.prefix = prefix
|
||||
self.tags = tags or ["A2A"]
|
||||
self.auth_token = auth_token
|
||||
|
||||
# Set name from agent if not provided
|
||||
if name:
|
||||
@@ -112,6 +118,25 @@ class A2A:
|
||||
)
|
||||
return self._agent_card
|
||||
|
||||
def serve(self, host: str = "0.0.0.0", port: int = 8000):
|
||||
"""
|
||||
Start a standalone A2A server.
|
||||
|
||||
Convenience method that creates a FastAPI app, mounts the A2A
|
||||
router, and runs it with uvicorn. For production use,
|
||||
create your own FastAPI app and use get_router().
|
||||
|
||||
Args:
|
||||
host: Host to bind to (default: 0.0.0.0)
|
||||
port: Port to bind to (default: 8000)
|
||||
"""
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI(title=self.name, version=self.version)
|
||||
app.include_router(self.get_router())
|
||||
uvicorn.run(app, host=host, port=port)
|
||||
|
||||
def get_router(self) -> "APIRouter":
|
||||
"""
|
||||
Get the FastAPI router for this A2A instance.
|
||||
@@ -125,11 +150,22 @@ class A2A:
|
||||
|
||||
def _create_router(self) -> "APIRouter":
|
||||
"""Create FastAPI router with A2A endpoints."""
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi import APIRouter, Header, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
router = APIRouter(prefix=self.prefix, tags=self.tags)
|
||||
|
||||
# Auth dependency — only applied to POST /a2a, not discovery endpoints
|
||||
async def _verify_auth(authorization: Optional[str] = Header(None)):
|
||||
"""Verify bearer token if auth_token is configured."""
|
||||
if self.auth_token is None:
|
||||
return # No auth configured — open access
|
||||
if authorization is None:
|
||||
raise HTTPException(status_code=401, detail="Authorization required")
|
||||
parts = authorization.split(" ", 1)
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer" or parts[1] != self.auth_token:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
|
||||
# Agent Card endpoint (well-known URI)
|
||||
@router.get("/.well-known/agent.json")
|
||||
async def get_agent_card():
|
||||
@@ -147,8 +183,250 @@ class A2A:
|
||||
"version": self.version,
|
||||
}
|
||||
|
||||
# TODO: Add JSON-RPC endpoint for message/send
|
||||
# TODO: Add streaming endpoint for message/stream
|
||||
# TODO: Add task management endpoints
|
||||
# JSON-RPC 2.0 endpoint for A2A protocol
|
||||
# JSON-RPC endpoint — auth-protected when auth_token is set
|
||||
from fastapi import Depends
|
||||
_a2a_deps = [Depends(_verify_auth)] if self.auth_token else []
|
||||
@router.post("/a2a", dependencies=_a2a_deps)
|
||||
async def handle_jsonrpc(request: Request):
|
||||
"""
|
||||
Handle A2A JSON-RPC 2.0 requests.
|
||||
|
||||
Supported methods:
|
||||
- message/send: Send a message and get a response
|
||||
- message/stream: Send a message and stream the response
|
||||
- tasks/get: Get task status and history
|
||||
- tasks/cancel: Cancel a running task
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return _jsonrpc_error(None, -32700, "Parse error: invalid JSON")
|
||||
|
||||
# Validate JSON-RPC format
|
||||
if body.get("jsonrpc") != "2.0":
|
||||
return _jsonrpc_error(
|
||||
body.get("id"), -32600, "Invalid Request: jsonrpc must be '2.0'"
|
||||
)
|
||||
|
||||
request_id = body.get("id")
|
||||
method = body.get("method")
|
||||
params = body.get("params", {})
|
||||
|
||||
# Route to handler
|
||||
if method == "message/send":
|
||||
return await self._handle_message_send(request_id, params)
|
||||
elif method == "message/stream":
|
||||
return await self._handle_message_stream(request_id, params)
|
||||
elif method == "tasks/get":
|
||||
return self._handle_tasks_get(request_id, params)
|
||||
elif method == "tasks/cancel":
|
||||
return self._handle_tasks_cancel(request_id, params)
|
||||
else:
|
||||
return _jsonrpc_error(
|
||||
request_id, -32601, f"Method not found: {method}"
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
async def _handle_message_send(self, request_id, params: dict):
|
||||
"""Handle message/send — create task, run agent, return result."""
|
||||
from fastapi.responses import JSONResponse
|
||||
from praisonaiagents.ui.a2a.types import (
|
||||
Message, TextPart, TaskState,
|
||||
)
|
||||
from praisonaiagents.ui.a2a.conversion import (
|
||||
extract_user_input,
|
||||
praisonai_to_a2a_message,
|
||||
create_artifact,
|
||||
)
|
||||
|
||||
# Validate params
|
||||
msg_data = params.get("message")
|
||||
if not msg_data:
|
||||
return _jsonrpc_error(request_id, -32602, "Invalid params: 'message' required")
|
||||
|
||||
# Parse the incoming message
|
||||
try:
|
||||
message = _parse_message(msg_data)
|
||||
except Exception as e:
|
||||
return _jsonrpc_error(request_id, -32602, f"Invalid params: {e}")
|
||||
|
||||
# Create task in store
|
||||
task = self.task_store.create_task(message)
|
||||
|
||||
# Update to working
|
||||
self.task_store.update_status(task.id, TaskState.WORKING)
|
||||
|
||||
try:
|
||||
# Extract user input text
|
||||
user_input = extract_user_input([message])
|
||||
|
||||
# Run agent or agents (offload sync call to thread pool)
|
||||
if self.agent:
|
||||
response = await asyncio.to_thread(self.agent.chat, user_input)
|
||||
elif self.agents:
|
||||
response = await asyncio.to_thread(self.agents.start, user_input)
|
||||
else:
|
||||
raise RuntimeError("No agent or agents configured")
|
||||
|
||||
# Create response message and artifact
|
||||
response_msg = praisonai_to_a2a_message(
|
||||
str(response), task_id=task.id
|
||||
)
|
||||
artifact = create_artifact(str(response))
|
||||
|
||||
# Update task store
|
||||
self.task_store.add_to_history(task.id, response_msg)
|
||||
self.task_store.add_artifact(task.id, artifact)
|
||||
updated_task = self.task_store.update_status(task.id, TaskState.COMPLETED)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Agent error in message/send: {e}")
|
||||
updated_task = self.task_store.update_status(task.id, TaskState.FAILED)
|
||||
|
||||
# Return JSON-RPC response
|
||||
return JSONResponse(content={
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": updated_task.model_dump(by_alias=True, exclude_none=True),
|
||||
})
|
||||
|
||||
async def _handle_message_stream(self, request_id, params: dict):
|
||||
"""Handle message/stream — create task, stream agent response as SSE."""
|
||||
from fastapi.responses import StreamingResponse
|
||||
from praisonaiagents.ui.a2a.types import TaskState
|
||||
from praisonaiagents.ui.a2a.streaming import stream_agent_response
|
||||
from praisonaiagents.ui.a2a.conversion import extract_user_input
|
||||
|
||||
# Validate params
|
||||
msg_data = params.get("message")
|
||||
if not msg_data:
|
||||
return _jsonrpc_error(request_id, -32602, "Invalid params: 'message' required")
|
||||
|
||||
# Parse the incoming message
|
||||
try:
|
||||
message = _parse_message(msg_data)
|
||||
except Exception as e:
|
||||
return _jsonrpc_error(request_id, -32602, f"Invalid params: {e}")
|
||||
|
||||
# Create task in store
|
||||
task = self.task_store.create_task(message)
|
||||
|
||||
# Extract user input
|
||||
user_input = extract_user_input([message])
|
||||
|
||||
# Determine which entity to stream from
|
||||
stream_entity = self.agent if self.agent else self.agents
|
||||
|
||||
# Stream the response using existing streaming helper
|
||||
return StreamingResponse(
|
||||
stream_agent_response(
|
||||
agent=stream_entity,
|
||||
user_input=user_input,
|
||||
task_id=task.id,
|
||||
context_id=task.context_id,
|
||||
),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
def _handle_tasks_get(self, request_id, params: dict):
|
||||
"""Handle tasks/get — return existing task."""
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
task_id = params.get("id")
|
||||
if not task_id:
|
||||
return _jsonrpc_error(request_id, -32602, "Invalid params: 'id' required")
|
||||
|
||||
task = self.task_store.get_task(task_id)
|
||||
if not task:
|
||||
return _jsonrpc_error(request_id, -32000, f"Task not found: {task_id}")
|
||||
|
||||
return JSONResponse(content={
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": task.model_dump(by_alias=True, exclude_none=True),
|
||||
})
|
||||
|
||||
def _handle_tasks_cancel(self, request_id, params: dict):
|
||||
"""Handle tasks/cancel — cancel existing task."""
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
task_id = params.get("id")
|
||||
if not task_id:
|
||||
return _jsonrpc_error(request_id, -32602, "Invalid params: 'id' required")
|
||||
|
||||
task = self.task_store.cancel_task(task_id)
|
||||
if not task:
|
||||
return _jsonrpc_error(request_id, -32000, f"Task not found: {task_id}")
|
||||
|
||||
return JSONResponse(content={
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": task.model_dump(by_alias=True, exclude_none=True),
|
||||
})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helpers (module-level, no class dependency)
|
||||
# =============================================================================
|
||||
|
||||
def _jsonrpc_error(request_id, code: int, message: str):
|
||||
"""Create a JSON-RPC 2.0 error response."""
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
return JSONResponse(content={
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"error": {"code": code, "message": message},
|
||||
})
|
||||
|
||||
|
||||
def _parse_message(msg_data: dict):
|
||||
"""Parse a raw dict into an A2A Message object.
|
||||
|
||||
Supports all A2A v1 Part types:
|
||||
- text: TextPart
|
||||
- url/fileWithUri: FilePart (file reference)
|
||||
- data: DataPart (structured JSON)
|
||||
"""
|
||||
from praisonaiagents.ui.a2a.types import (
|
||||
Message, TextPart, FilePart, DataPart, Role,
|
||||
)
|
||||
|
||||
parts = []
|
||||
for part_data in msg_data.get("parts", []):
|
||||
if "text" in part_data:
|
||||
parts.append(TextPart(
|
||||
text=part_data["text"],
|
||||
metadata=part_data.get("metadata"),
|
||||
))
|
||||
elif "url" in part_data or "fileWithUri" in part_data:
|
||||
uri = part_data.get("url") or part_data.get("fileWithUri")
|
||||
parts.append(FilePart(
|
||||
file_uri=uri,
|
||||
media_type=part_data.get("mediaType") or part_data.get("media_type"),
|
||||
name=part_data.get("filename") or part_data.get("name"),
|
||||
metadata=part_data.get("metadata"),
|
||||
))
|
||||
elif "data" in part_data:
|
||||
parts.append(DataPart(
|
||||
data=part_data["data"],
|
||||
metadata=part_data.get("metadata"),
|
||||
))
|
||||
|
||||
if not parts:
|
||||
raise ValueError("Message must contain at least one part")
|
||||
|
||||
return Message(
|
||||
message_id=msg_data.get("messageId", "msg-auto"),
|
||||
role=msg_data.get("role", "user"),
|
||||
parts=parts,
|
||||
context_id=msg_data.get("contextId"),
|
||||
task_id=msg_data.get("taskId"),
|
||||
)
|
||||
@@ -4,6 +4,7 @@ A2A Message Conversion
|
||||
Convert between A2A protocol messages and PraisonAI internal format.
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -11,6 +12,8 @@ from praisonaiagents.ui.a2a.types import (
|
||||
Message,
|
||||
Role,
|
||||
TextPart,
|
||||
FilePart,
|
||||
DataPart,
|
||||
Artifact,
|
||||
)
|
||||
|
||||
@@ -19,6 +22,14 @@ def a2a_to_praisonai_messages(messages: List[Message]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Convert A2A messages to PraisonAI/OpenAI chat format.
|
||||
|
||||
Handles all A2A Part types:
|
||||
- TextPart → {"type": "text", "text": ...}
|
||||
- FilePart → {"type": "image_url", "image_url": {"url": ...}}
|
||||
- DataPart → {"type": "text", "text": json.dumps(data)}
|
||||
|
||||
Returns multimodal content list when non-text parts present,
|
||||
plain string content when text-only.
|
||||
|
||||
Args:
|
||||
messages: List of A2A Message objects
|
||||
|
||||
@@ -31,15 +42,33 @@ def a2a_to_praisonai_messages(messages: List[Message]) -> List[Dict[str, Any]]:
|
||||
# Map A2A role to OpenAI role
|
||||
role = "user" if msg.role == Role.USER else "assistant"
|
||||
|
||||
# Combine all text parts
|
||||
content_parts = []
|
||||
has_non_text = False
|
||||
|
||||
for part in msg.parts:
|
||||
if isinstance(part, TextPart):
|
||||
content_parts.append(part.text)
|
||||
content_parts.append({"type": "text", "text": part.text})
|
||||
elif isinstance(part, FilePart) and part.file_uri:
|
||||
has_non_text = True
|
||||
content_parts.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": part.file_uri},
|
||||
})
|
||||
elif isinstance(part, DataPart):
|
||||
has_non_text = True
|
||||
content_parts.append({
|
||||
"type": "text",
|
||||
"text": json.dumps(part.data),
|
||||
})
|
||||
elif hasattr(part, 'text'):
|
||||
content_parts.append(part.text)
|
||||
content_parts.append({"type": "text", "text": part.text})
|
||||
|
||||
content = " ".join(content_parts)
|
||||
# Use multimodal list format when non-text parts present,
|
||||
# plain string otherwise (backwards compat)
|
||||
if has_non_text:
|
||||
content = content_parts
|
||||
else:
|
||||
content = " ".join(p["text"] for p in content_parts)
|
||||
|
||||
result.append({
|
||||
"role": role,
|
||||
@@ -110,6 +139,9 @@ def extract_user_input(messages: List[Message]) -> str:
|
||||
"""
|
||||
Extract the last user input from a list of messages.
|
||||
|
||||
Handles all Part types: TextPart text is included directly,
|
||||
FilePart URIs noted, DataPart serialized to JSON.
|
||||
|
||||
Args:
|
||||
messages: List of A2A Message objects
|
||||
|
||||
@@ -119,13 +151,17 @@ def extract_user_input(messages: List[Message]) -> str:
|
||||
# Find the last user message
|
||||
for msg in reversed(messages):
|
||||
if msg.role == Role.USER:
|
||||
# Combine text parts
|
||||
content_parts = []
|
||||
for part in msg.parts:
|
||||
if isinstance(part, TextPart):
|
||||
content_parts.append(part.text)
|
||||
elif isinstance(part, FilePart) and part.file_uri:
|
||||
content_parts.append(f"[file: {part.file_uri}]")
|
||||
elif isinstance(part, DataPart):
|
||||
content_parts.append(json.dumps(part.data))
|
||||
elif hasattr(part, 'text'):
|
||||
content_parts.append(part.text)
|
||||
return " ".join(content_parts)
|
||||
|
||||
return ""
|
||||
|
||||
@@ -569,6 +569,7 @@ class AgentFlow:
|
||||
reflection: Optional[Any] = None # Union[bool, ReflectionConfig] - self-reflection
|
||||
execution: Optional[Any] = None # Union[str, ExecutionConfig] - execution control
|
||||
caching: Optional[Any] = None # Union[bool, CachingConfig] - caching
|
||||
learn: Optional[Any] = None # Union[bool, LearnConfig] - continuous learning
|
||||
|
||||
# ============================================================
|
||||
# ROBUSTNESS PARAMS (debugging & audit trail)
|
||||
@@ -1901,14 +1902,46 @@ Create a brief execution plan (2-3 sentences) describing how to best accomplish
|
||||
# Only pass output_config if it has values
|
||||
output_json_model = output_config.get('json_model') if output_config else None
|
||||
output_pydantic_model = output_config.get('pydantic_model') if output_config else None
|
||||
return Task(
|
||||
# Extract remaining YAML step-level attributes (previously dead stores)
|
||||
yaml_guardrail = getattr(step, '_yaml_guardrail', None)
|
||||
yaml_max_retries = getattr(step, '_yaml_max_retries', None)
|
||||
yaml_expected_output = getattr(step, '_yaml_expected_output', None)
|
||||
yaml_output_file = getattr(step, '_yaml_output_file', None)
|
||||
yaml_skip_on_failure = getattr(step, '_yaml_skip_on_failure', None)
|
||||
yaml_retry_delay = getattr(step, '_yaml_retry_delay', None)
|
||||
yaml_create_directory = getattr(step, '_yaml_create_directory', None)
|
||||
yaml_callback = getattr(step, '_yaml_callback', None)
|
||||
yaml_async_execution = getattr(step, '_yaml_async_execution', None)
|
||||
|
||||
# Build Task kwargs — only include YAML overrides when present
|
||||
task_kwargs = dict(
|
||||
name=yaml_step_name or getattr(step, 'name', f'agent_{index+1}'),
|
||||
agent=step,
|
||||
tools=agent_tools,
|
||||
action=action,
|
||||
output_json=output_json_model,
|
||||
output_pydantic=output_pydantic_model
|
||||
output_pydantic=output_pydantic_model,
|
||||
)
|
||||
if yaml_guardrail is not None:
|
||||
task_kwargs['guardrails'] = yaml_guardrail
|
||||
if yaml_max_retries is not None:
|
||||
task_kwargs['max_retries'] = yaml_max_retries
|
||||
if yaml_expected_output is not None:
|
||||
task_kwargs['expected_output'] = yaml_expected_output
|
||||
if yaml_output_file is not None:
|
||||
task_kwargs['output_file'] = yaml_output_file
|
||||
if yaml_skip_on_failure is not None:
|
||||
task_kwargs['skip_on_failure'] = yaml_skip_on_failure
|
||||
if yaml_retry_delay is not None:
|
||||
task_kwargs['retry_delay'] = yaml_retry_delay
|
||||
if yaml_create_directory is not None:
|
||||
task_kwargs['create_directory'] = yaml_create_directory
|
||||
if yaml_callback is not None:
|
||||
task_kwargs['callback'] = yaml_callback
|
||||
if yaml_async_execution is not None:
|
||||
task_kwargs['async_execution'] = yaml_async_execution
|
||||
|
||||
return Task(**task_kwargs)
|
||||
elif callable(step):
|
||||
return Task(
|
||||
name=getattr(step, '__name__', f'step_{index+1}'),
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "1.5.33"
|
||||
version = "1.5.73"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -88,6 +88,11 @@ autonomy = [
|
||||
"ast-grep-cli>=0.30.0"
|
||||
]
|
||||
|
||||
# Web search (DuckDuckGo — zero-config, no API key required)
|
||||
search = [
|
||||
"ddgs>=9.0.0",
|
||||
]
|
||||
|
||||
# Combined features
|
||||
all = [
|
||||
"praisonaiagents[memory]",
|
||||
@@ -98,7 +103,8 @@ all = [
|
||||
"praisonaiagents[api]",
|
||||
"praisonaiagents[telemetry]",
|
||||
"praisonaiagents[mongodb]",
|
||||
"praisonaiagents[auth]"
|
||||
"praisonaiagents[auth]",
|
||||
"praisonaiagents[search]"
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
"""
|
||||
Tests for Main A2A Class
|
||||
Tests for A2A Class and JSON-RPC Endpoints
|
||||
|
||||
TDD: Write tests first, then implement A2A class.
|
||||
TDD: Tests written first, then implementation.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Existing Tests — A2A Class
|
||||
# ============================================================================
|
||||
|
||||
class TestA2AClass:
|
||||
"""Tests for A2A main class."""
|
||||
@@ -133,3 +139,683 @@ class TestA2AWithTools:
|
||||
|
||||
assert card.skills is not None
|
||||
assert len(card.skills) >= 1
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# NEW: JSON-RPC Endpoint Tests
|
||||
# ============================================================================
|
||||
|
||||
def _make_app():
|
||||
"""Create a test FastAPI app with mocked agent."""
|
||||
from fastapi import FastAPI
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.ui.a2a import A2A
|
||||
|
||||
agent = Agent(name="JSON-RPC Test", role="Tester", goal="Test")
|
||||
a2a = A2A(agent=agent, url="http://localhost:8000/a2a")
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(a2a.get_router())
|
||||
|
||||
return app, a2a
|
||||
|
||||
|
||||
class TestA2AJsonRpc:
|
||||
"""Tests for A2A JSON-RPC endpoint (POST /a2a)."""
|
||||
|
||||
def test_post_a2a_invalid_jsonrpc(self):
|
||||
"""Missing jsonrpc field → -32600 Invalid Request."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/a2a", json={
|
||||
"method": "message/send",
|
||||
"id": "1",
|
||||
"params": {}
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "error" in data
|
||||
assert data["error"]["code"] == -32600
|
||||
|
||||
def test_post_a2a_unknown_method(self):
|
||||
"""Unknown method → -32601 Method not found."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/a2a", json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "unknown/method",
|
||||
"id": "1",
|
||||
"params": {}
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "error" in data
|
||||
assert data["error"]["code"] == -32601
|
||||
|
||||
def test_post_a2a_message_send(self):
|
||||
"""Valid message/send → Task result with completed status."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, a2a = _make_app()
|
||||
# Mock agent.chat to avoid LLM call
|
||||
a2a.agent.chat = MagicMock(return_value="Hello from agent!")
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/a2a", json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "req-1",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "msg-1",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Hello"}]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["jsonrpc"] == "2.0"
|
||||
assert data["id"] == "req-1"
|
||||
assert "result" in data
|
||||
result = data["result"]
|
||||
assert "id" in result
|
||||
assert result["status"]["state"] == "completed"
|
||||
assert result["artifacts"] is not None
|
||||
assert len(result["artifacts"]) > 0
|
||||
|
||||
def test_post_a2a_message_send_creates_task_in_store(self):
|
||||
"""message/send should create a task in TaskStore."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, a2a = _make_app()
|
||||
a2a.agent.chat = MagicMock(return_value="OK")
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
# Store should be empty initially
|
||||
assert len(a2a.task_store.list_tasks()) == 0
|
||||
|
||||
client.post("/a2a", json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "1",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "m1",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Hi"}]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
# Store should have one task
|
||||
assert len(a2a.task_store.list_tasks()) == 1
|
||||
|
||||
def test_post_a2a_message_send_agent_error(self):
|
||||
"""Agent throws → -32603 Internal error."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, a2a = _make_app()
|
||||
a2a.agent.chat = MagicMock(side_effect=RuntimeError("LLM failed"))
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/a2a", json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "err-1",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "m1",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Hi"}]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
# Should return a task in failed state, not a JSON-RPC error
|
||||
assert "result" in data
|
||||
assert data["result"]["status"]["state"] == "failed"
|
||||
|
||||
def test_post_a2a_tasks_get(self):
|
||||
"""tasks/get → returns existing task."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, a2a = _make_app()
|
||||
a2a.agent.chat = MagicMock(return_value="Done")
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
# First, create a task via message/send
|
||||
send_resp = client.post("/a2a", json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "1",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "m1",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Hi"}]
|
||||
}
|
||||
}
|
||||
})
|
||||
task_id = send_resp.json()["result"]["id"]
|
||||
|
||||
# Now get the task
|
||||
get_resp = client.post("/a2a", json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "tasks/get",
|
||||
"id": "2",
|
||||
"params": {"id": task_id}
|
||||
})
|
||||
|
||||
assert get_resp.status_code == 200
|
||||
data = get_resp.json()
|
||||
assert "result" in data
|
||||
assert data["result"]["id"] == task_id
|
||||
|
||||
def test_post_a2a_tasks_get_not_found(self):
|
||||
"""tasks/get with nonexistent id → -32000 Task not found."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/a2a", json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "tasks/get",
|
||||
"id": "1",
|
||||
"params": {"id": "nonexistent"}
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "error" in data
|
||||
assert data["error"]["code"] == -32000
|
||||
|
||||
def test_post_a2a_tasks_cancel(self):
|
||||
"""tasks/cancel → cancels existing task."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, a2a = _make_app()
|
||||
a2a.agent.chat = MagicMock(return_value="Done")
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
# Create a task
|
||||
send_resp = client.post("/a2a", json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "1",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "m1",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Hi"}]
|
||||
}
|
||||
}
|
||||
})
|
||||
task_id = send_resp.json()["result"]["id"]
|
||||
|
||||
# Cancel it
|
||||
cancel_resp = client.post("/a2a", json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "tasks/cancel",
|
||||
"id": "2",
|
||||
"params": {"id": task_id}
|
||||
})
|
||||
|
||||
assert cancel_resp.status_code == 200
|
||||
data = cancel_resp.json()
|
||||
assert "result" in data
|
||||
assert data["result"]["status"]["state"] == "cancelled"
|
||||
|
||||
def test_post_a2a_message_stream(self):
|
||||
"""message/stream → returns SSE streaming response."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, a2a = _make_app()
|
||||
a2a.agent.chat = MagicMock(return_value="Streamed response")
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/a2a", json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/stream",
|
||||
"id": "stream-1",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "m1",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Hello stream"}]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
# SSE response should contain event data
|
||||
text = response.text
|
||||
assert "event:" in text
|
||||
assert "data:" in text
|
||||
|
||||
def test_post_a2a_missing_params(self):
|
||||
"""message/send without params.message → -32602 Invalid params."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/a2a", json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "1",
|
||||
"params": {}
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "error" in data
|
||||
assert data["error"]["code"] == -32602
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# G11: AgentTeam Routing Tests
|
||||
# ============================================================================
|
||||
|
||||
def _make_team_app():
|
||||
"""Create test app with mocked AgentTeam (agents=)."""
|
||||
from fastapi import FastAPI
|
||||
from praisonaiagents.ui.a2a import A2A
|
||||
|
||||
# Mock an AgentTeam with .start() method
|
||||
mock_team = MagicMock()
|
||||
mock_team.name = "Test Team"
|
||||
mock_team.start = MagicMock(return_value="Team result")
|
||||
|
||||
a2a = A2A(agents=mock_team, name="Team A2A")
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(a2a.get_router())
|
||||
|
||||
return app, a2a, mock_team
|
||||
|
||||
|
||||
class TestA2AAgentTeamRouting:
|
||||
"""G11: Tests for AgentTeam routing via agents= param."""
|
||||
|
||||
def test_a2a_agents_message_send(self):
|
||||
"""message/send with agents= should call team.start()."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, a2a, mock_team = _make_team_app()
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/a2a", json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "team-1",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "m1",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Research AI"}]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "result" in data
|
||||
assert data["result"]["status"]["state"] == "completed"
|
||||
# Verify team.start() was called, not agent.chat()
|
||||
mock_team.start.assert_called_once()
|
||||
|
||||
def test_a2a_agents_message_stream(self):
|
||||
"""message/stream with agents= should work."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, a2a, mock_team = _make_team_app()
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/a2a", json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/stream",
|
||||
"id": "team-s1",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "m1",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Stream this"}]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
text = response.text
|
||||
assert "event:" in text
|
||||
|
||||
def test_a2a_agent_takes_priority(self):
|
||||
"""When both agent= and agents= provided, agent takes priority."""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.ui.a2a import A2A
|
||||
|
||||
agent = Agent(name="Priority", role="Tester", goal="Test")
|
||||
mock_team = MagicMock()
|
||||
mock_team.name = "Team"
|
||||
|
||||
a2a = A2A(agent=agent, agents=mock_team)
|
||||
a2a.agent.chat = MagicMock(return_value="Agent wins")
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(a2a.get_router())
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/a2a", json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "pri-1",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "m1",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Who responds?"}]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "result" in data
|
||||
# agent.chat should be called, not team.start
|
||||
a2a.agent.chat.assert_called_once()
|
||||
mock_team.start.assert_not_called()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# G9: serve() Convenience Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestA2AServe:
|
||||
"""G9: Tests for A2A.serve() convenience method."""
|
||||
|
||||
def test_a2a_serve_method_exists(self):
|
||||
"""A2A should have a serve() method."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.ui.a2a import A2A
|
||||
|
||||
agent = Agent(name="Serve", role="Helper", goal="Help")
|
||||
a2a = A2A(agent=agent)
|
||||
|
||||
assert hasattr(a2a, 'serve')
|
||||
assert callable(a2a.serve)
|
||||
|
||||
def test_a2a_serve_creates_app(self):
|
||||
"""serve() should create a FastAPI app and call uvicorn.run."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.ui.a2a import A2A
|
||||
|
||||
agent = Agent(name="Serve", role="Helper", goal="Help")
|
||||
a2a = A2A(agent=agent)
|
||||
|
||||
with patch("uvicorn.run") as mock_run:
|
||||
a2a.serve(host="127.0.0.1", port=9999)
|
||||
mock_run.assert_called_once()
|
||||
# Verify the app passed to uvicorn is a FastAPI instance
|
||||
call_args = mock_run.call_args
|
||||
assert call_args[1].get("host") == "127.0.0.1"
|
||||
assert call_args[1].get("port") == 9999
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# G8: Auth Token Tests
|
||||
# ============================================================================
|
||||
|
||||
def _make_auth_app(auth_token=None):
|
||||
"""Create test app with optional auth_token."""
|
||||
from fastapi import FastAPI
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.ui.a2a import A2A
|
||||
|
||||
agent = Agent(name="Auth Test", role="Tester", goal="Test")
|
||||
a2a = A2A(agent=agent, auth_token=auth_token)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(a2a.get_router())
|
||||
|
||||
return app, a2a
|
||||
|
||||
|
||||
class TestA2AAuth:
|
||||
"""G8: Tests for auth_token support."""
|
||||
|
||||
def test_a2a_auth_valid_token(self):
|
||||
"""Valid bearer token → 200."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, a2a = _make_auth_app(auth_token="sk-secret")
|
||||
a2a.agent.chat = MagicMock(return_value="Authed")
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/a2a",
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "auth-1",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "m1",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Hello"}]
|
||||
}
|
||||
}
|
||||
},
|
||||
headers={"Authorization": "Bearer sk-secret"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "result" in data
|
||||
|
||||
def test_a2a_auth_invalid_token(self):
|
||||
"""Wrong bearer token → 401."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _ = _make_auth_app(auth_token="sk-secret")
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/a2a",
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "auth-2",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "m1",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Hello"}]
|
||||
}
|
||||
}
|
||||
},
|
||||
headers={"Authorization": "Bearer wrong-token"}
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_a2a_auth_missing_header(self):
|
||||
"""No Authorization header → 401."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _ = _make_auth_app(auth_token="sk-secret")
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/a2a", json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "auth-3",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "m1",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Hello"}]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_a2a_no_auth_configured(self):
|
||||
"""No auth_token set → open access (200)."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, a2a = _make_auth_app(auth_token=None)
|
||||
a2a.agent.chat = MagicMock(return_value="Open")
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/a2a", json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "no-auth-1",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "m1",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Hello"}]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "result" in data
|
||||
|
||||
def test_a2a_auth_on_discovery_endpoint(self):
|
||||
"""Agent card endpoint should remain open even with auth."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _ = _make_auth_app(auth_token="sk-secret")
|
||||
client = TestClient(app)
|
||||
|
||||
# Discovery endpoints should be public per A2A spec
|
||||
response = client.get("/.well-known/agent.json")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# G13: Part Conversion Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestA2APartConversion:
|
||||
"""G13: Tests for url/data part handling in _parse_message and conversion."""
|
||||
|
||||
def test_parse_message_with_url_part(self):
|
||||
"""Parts with 'url' field should be parsed."""
|
||||
from praisonaiagents.ui.a2a.a2a import _parse_message
|
||||
|
||||
msg = _parse_message({
|
||||
"messageId": "m1",
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"url": "https://example.com/img.png", "mediaType": "image/png"}
|
||||
]
|
||||
})
|
||||
|
||||
assert len(msg.parts) == 1
|
||||
|
||||
def test_parse_message_with_data_part(self):
|
||||
"""Parts with 'data' field should be parsed."""
|
||||
from praisonaiagents.ui.a2a.a2a import _parse_message
|
||||
|
||||
msg = _parse_message({
|
||||
"messageId": "m1",
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"data": {"key": "value"}}
|
||||
]
|
||||
})
|
||||
|
||||
assert len(msg.parts) == 1
|
||||
|
||||
def test_parse_message_mixed_parts(self):
|
||||
"""Mixed text + url + data parts should all be parsed."""
|
||||
from praisonaiagents.ui.a2a.a2a import _parse_message
|
||||
|
||||
msg = _parse_message({
|
||||
"messageId": "m1",
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"text": "Hello"},
|
||||
{"url": "https://example.com/doc.pdf", "mediaType": "application/pdf"},
|
||||
{"data": {"x": 1}}
|
||||
]
|
||||
})
|
||||
|
||||
assert len(msg.parts) == 3
|
||||
|
||||
def test_conversion_url_part_to_openai(self):
|
||||
"""FilePart with url should convert to OpenAI image_url format."""
|
||||
from praisonaiagents.ui.a2a.conversion import a2a_to_praisonai_messages
|
||||
from praisonaiagents.ui.a2a.types import Message, FilePart, Role
|
||||
|
||||
msg = Message(
|
||||
message_id="m1",
|
||||
role=Role.USER,
|
||||
parts=[FilePart(file_uri="https://example.com/img.png", media_type="image/png")],
|
||||
)
|
||||
|
||||
result = a2a_to_praisonai_messages([msg])
|
||||
assert len(result) == 1
|
||||
content = result[0]["content"]
|
||||
# Should contain image_url reference
|
||||
assert isinstance(content, list)
|
||||
assert any(p.get("type") == "image_url" for p in content)
|
||||
|
||||
def test_conversion_data_part_to_openai(self):
|
||||
"""DataPart should convert to text with JSON content."""
|
||||
from praisonaiagents.ui.a2a.conversion import a2a_to_praisonai_messages
|
||||
from praisonaiagents.ui.a2a.types import Message, DataPart, Role
|
||||
|
||||
msg = Message(
|
||||
message_id="m1",
|
||||
role=Role.USER,
|
||||
parts=[DataPart(data={"key": "value"})],
|
||||
)
|
||||
|
||||
result = a2a_to_praisonai_messages([msg])
|
||||
assert len(result) == 1
|
||||
content = result[0]["content"]
|
||||
assert isinstance(content, list)
|
||||
assert any("key" in str(p) for p in content)
|
||||
|
||||
def test_extract_user_input_with_url_parts(self):
|
||||
"""extract_user_input should handle non-text parts gracefully."""
|
||||
from praisonaiagents.ui.a2a.conversion import extract_user_input
|
||||
from praisonaiagents.ui.a2a.types import Message, FilePart, TextPart, Role
|
||||
|
||||
msg = Message(
|
||||
message_id="m1",
|
||||
role=Role.USER,
|
||||
parts=[
|
||||
TextPart(text="Please analyze"),
|
||||
FilePart(file_uri="https://example.com/img.png"),
|
||||
],
|
||||
)
|
||||
|
||||
result = extract_user_input([msg])
|
||||
assert "analyze" in result
|
||||
@@ -0,0 +1,2 @@
|
||||
# Auto-generated profile results
|
||||
*.jsonl
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Complex 10-step task comparison: wrapper vs caller vs iterative.
|
||||
|
||||
Tests the tool_completion fix with a realistic multi-tool prompt that requires:
|
||||
1. Web search (3x for frameworks)
|
||||
2. File creation (Python dict)
|
||||
3. Code execution (verify dict)
|
||||
4. File write (markdown report)
|
||||
5. File read (verify report)
|
||||
6. List files (confirm both exist)
|
||||
7. System info
|
||||
8. Code analysis
|
||||
9. Code formatting
|
||||
10. Web search (Python 3.13)
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
import os
|
||||
|
||||
# Ensure local package is used
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from praisonaiagents import Agent
|
||||
|
||||
COMPLEX_PROMPT = """Research the top 3 Python web frameworks (Django, FastAPI, Flask), then:
|
||||
1) Search the web for each framework's latest version and key features
|
||||
2) Create a file called /tmp/framework_comparison.py that contains a Python dictionary with the comparison data
|
||||
3) Execute the code to verify the dictionary is valid
|
||||
4) Write a markdown report to /tmp/framework_report.md summarizing your findings in a table format
|
||||
5) Read back the report file to verify it was written correctly
|
||||
6) List the files in /tmp to confirm both files exist
|
||||
7) Get system info to note what OS this report was generated on
|
||||
8) Analyze the Python code you wrote for any issues
|
||||
9) Format the Python code properly
|
||||
10) Finally search for any recent news about Python 3.13 features"""
|
||||
|
||||
|
||||
def run_test(name, agent, prompt):
|
||||
"""Run agent and return results dict."""
|
||||
print(f"\n{'='*70}")
|
||||
print(f"TEST: {name}")
|
||||
print(f"{'='*70}\n")
|
||||
|
||||
start = time.time()
|
||||
try:
|
||||
result = agent.start(prompt)
|
||||
except Exception as e:
|
||||
return {
|
||||
"name": name,
|
||||
"duration": time.time() - start,
|
||||
"error": str(e),
|
||||
"success": False,
|
||||
}
|
||||
|
||||
duration = time.time() - start
|
||||
output = result.output if hasattr(result, "output") else str(result)
|
||||
reason = getattr(result, "completion_reason", None)
|
||||
iters = getattr(result, "iterations", None)
|
||||
success = getattr(result, "success", True)
|
||||
|
||||
print(f"\n--- {name} RESULTS ---")
|
||||
print(f"Duration: {duration:.1f}s")
|
||||
if reason:
|
||||
print(f"Completion reason: {reason}")
|
||||
if iters:
|
||||
print(f"Iterations: {iters}")
|
||||
print(f"Response length: {len(output or '')} chars")
|
||||
print(f"Success: {success}")
|
||||
if output:
|
||||
print(f"\nFirst 600 chars of response:")
|
||||
print(output[:600])
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"duration": duration,
|
||||
"reason": reason,
|
||||
"iterations": iters,
|
||||
"length": len(output or ""),
|
||||
"success": success,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
mode = sys.argv[1] if len(sys.argv) > 1 else "all"
|
||||
results = []
|
||||
|
||||
if mode in ("wrapper", "all"):
|
||||
os.environ["PRAISONAI_AUTO_APPROVE"] = "true"
|
||||
agent = Agent(
|
||||
name="wrapper_agent",
|
||||
instructions="You are a helpful research assistant.",
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
results.append(run_test("wrapper", agent, COMPLEX_PROMPT))
|
||||
os.environ.pop("PRAISONAI_AUTO_APPROVE", None)
|
||||
|
||||
if mode in ("caller", "all"):
|
||||
agent = Agent(
|
||||
name="caller_agent",
|
||||
instructions="You are a helpful research assistant.",
|
||||
llm="gpt-4o-mini",
|
||||
autonomy={
|
||||
"level": "full_auto",
|
||||
"mode": "caller", # Single chat(), no loop
|
||||
},
|
||||
)
|
||||
results.append(run_test("caller", agent, COMPLEX_PROMPT))
|
||||
|
||||
if mode in ("iterative", "all"):
|
||||
agent = Agent(
|
||||
name="iterative_agent",
|
||||
instructions="You are a helpful research assistant.",
|
||||
llm="gpt-4o-mini",
|
||||
autonomy={
|
||||
"level": "full_auto",
|
||||
"mode": "iterative",
|
||||
"max_iterations": 5,
|
||||
},
|
||||
)
|
||||
results.append(run_test("iterative", agent, COMPLEX_PROMPT))
|
||||
|
||||
# Summary
|
||||
print(f"\n{'='*70}")
|
||||
print("COMPARISON SUMMARY")
|
||||
print(f"{'='*70}")
|
||||
for r in results:
|
||||
parts = [f"{r['name']:15s}: {r['duration']:6.1f}s"]
|
||||
if r.get("reason"):
|
||||
parts.append(f"reason={r['reason']}")
|
||||
if r.get("iterations"):
|
||||
parts.append(f"iters={r['iterations']}")
|
||||
parts.append(f"len={r['length']}")
|
||||
parts.append(f"ok={r['success']}")
|
||||
print(" " + " | ".join(parts))
|
||||
|
||||
# Check if /tmp files were created
|
||||
print(f"\n--- File Creation Check ---")
|
||||
for f in ["/tmp/framework_comparison.py", "/tmp/framework_report.md"]:
|
||||
exists = os.path.exists(f)
|
||||
size = os.path.getsize(f) if exists else 0
|
||||
print(f" {f}: {'EXISTS' if exists else 'MISSING'} ({size} bytes)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Root-Cause Diagnosis: WHY does iterative mode loop / repeat?
|
||||
|
||||
Uses only LOW-RISK tools (internet_search, get_system_info, list_files)
|
||||
to avoid approval gates and isolate the looping behavior.
|
||||
"""
|
||||
import time
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.tools import list_files, get_system_info, internet_search
|
||||
|
||||
# Simpler task using only low-risk tools
|
||||
TASK = (
|
||||
"Do these 3 things: "
|
||||
"1) Search the web for 'Python 3.13 new features', "
|
||||
"2) Get system info to identify the current OS, "
|
||||
"3) List files in /tmp. "
|
||||
"After completing all 3 tasks, summarize the results."
|
||||
)
|
||||
|
||||
TOOLS = [list_files, get_system_info, internet_search]
|
||||
|
||||
|
||||
def run_test(name, agent, task):
|
||||
"""Run a test and print results."""
|
||||
print(f"\n{'='*70}")
|
||||
print(f"TEST: {name}")
|
||||
print(f"{'='*70}")
|
||||
|
||||
start = time.time()
|
||||
result = agent.start(task)
|
||||
elapsed = time.time() - start
|
||||
|
||||
if hasattr(result, 'output'):
|
||||
# AutonomyResult
|
||||
print(f"\n--- {name} RESULTS ---")
|
||||
print(f"Duration: {elapsed:.1f}s")
|
||||
print(f"Completion reason: {result.completion_reason}")
|
||||
print(f"Iterations: {result.iterations}")
|
||||
print(f"Response length: {len(result.output or '')} chars")
|
||||
print(f"Success: {result.success}")
|
||||
out = result.output or ""
|
||||
else:
|
||||
out = str(result) if result else ""
|
||||
print(f"\n--- {name} RESULTS ---")
|
||||
print(f"Duration: {elapsed:.1f}s")
|
||||
print(f"Response length: {len(out)} chars")
|
||||
|
||||
# Show first 600 chars
|
||||
print(f"\nFirst 600 chars of response:")
|
||||
print(out[:600])
|
||||
return elapsed, result
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("test", choices=["wrapper", "iterative", "caller", "all"], default="all", nargs="?")
|
||||
args = parser.parse_args()
|
||||
|
||||
results = {}
|
||||
|
||||
if args.test in ("wrapper", "all"):
|
||||
agent = Agent(
|
||||
name="WrapperTest",
|
||||
instructions="You are a helpful AI assistant. Complete the task and stop.",
|
||||
tools=TOOLS,
|
||||
)
|
||||
results["wrapper"] = run_test("WRAPPER (no autonomy)", agent, TASK)
|
||||
|
||||
if args.test in ("caller", "all"):
|
||||
agent = Agent(
|
||||
name="CallerTest",
|
||||
instructions="You are a helpful AI assistant. Complete the task and stop.",
|
||||
tools=TOOLS,
|
||||
autonomy={"mode": "caller"},
|
||||
)
|
||||
results["caller"] = run_test("CALLER (autonomy=caller)", agent, TASK)
|
||||
|
||||
if args.test in ("iterative", "all"):
|
||||
agent = Agent(
|
||||
name="IterativeTest",
|
||||
instructions="You are a helpful AI assistant. Complete the task and stop.",
|
||||
tools=TOOLS,
|
||||
autonomy={
|
||||
"mode": "iterative",
|
||||
"max_iterations": 5,
|
||||
"clear_context": False,
|
||||
},
|
||||
)
|
||||
results["iterative"] = run_test("ITERATIVE (autonomy=iterative, max=5)", agent, TASK)
|
||||
|
||||
# Summary
|
||||
if len(results) > 1:
|
||||
print(f"\n{'='*70}")
|
||||
print("COMPARISON SUMMARY")
|
||||
print(f"{'='*70}")
|
||||
for name, (elapsed, result) in results.items():
|
||||
if hasattr(result, 'output'):
|
||||
print(f" {name:15s}: {elapsed:6.1f}s | reason={result.completion_reason} | iters={result.iterations} | len={len(result.output or '')}")
|
||||
else:
|
||||
print(f" {name:15s}: {elapsed:6.1f}s | len={len(str(result))}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,464 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Deep performance profiling integration test.
|
||||
|
||||
Runs cProfile-wrapped Agent prompts and stores full function-level
|
||||
breakdown as JSONL for regression tracking. Each run appends a
|
||||
timestamped record so you can compare performance over time.
|
||||
|
||||
Usage:
|
||||
# Run with pytest (records stored automatically)
|
||||
pytest tests/integration/test_performance_profile.py -v -s
|
||||
|
||||
# Run directly (more detailed output)
|
||||
python tests/integration/test_performance_profile.py
|
||||
|
||||
# Compare recent results
|
||||
python tests/integration/test_performance_profile.py --compare
|
||||
|
||||
# Profile a specific model
|
||||
python tests/integration/test_performance_profile.py --model gpt-5-nano
|
||||
|
||||
# Compare two models
|
||||
PROFILE_MODELS=gpt-5-nano,gpt-4o-mini python tests/integration/test_performance_profile.py
|
||||
|
||||
Results are stored in:
|
||||
tests/integration/profile_results/performance_history.jsonl
|
||||
"""
|
||||
|
||||
import cProfile
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import pstats
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from statistics import mean, median, stdev
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
RESULTS_DIR = Path(__file__).parent / "profile_results"
|
||||
HISTORY_FILE = RESULTS_DIR / "performance_history.jsonl"
|
||||
|
||||
# Default model — override via PROFILE_MODEL env var
|
||||
DEFAULT_MODEL = os.environ.get("PROFILE_MODEL", "gpt-4o-mini")
|
||||
|
||||
# Number of top functions to record
|
||||
TOP_FUNCTIONS = 30
|
||||
|
||||
# Prompts
|
||||
SIMPLE_PROMPT = "What is 2+2? Reply with just the number."
|
||||
COMPLEX_PROMPT = (
|
||||
"List the top 3 programming languages by popularity in 2024. "
|
||||
"For each, give the name and one key strength. Keep it under 100 words."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _ensure_results_dir():
|
||||
"""Create results directory if it doesn't exist."""
|
||||
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
gitignore = RESULTS_DIR / ".gitignore"
|
||||
if not gitignore.exists():
|
||||
gitignore.write_text("# Auto-generated profile results\n*.jsonl\n*.txt\n")
|
||||
|
||||
|
||||
def _get_version() -> str:
|
||||
"""Get the current praisonaiagents version."""
|
||||
try:
|
||||
from praisonaiagents import __version__
|
||||
return __version__
|
||||
except (ImportError, AttributeError):
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _extract_function_stats(profiler: cProfile.Profile, top_n: int = TOP_FUNCTIONS) -> List[Dict]:
|
||||
"""Extract top function stats from a cProfile.Profile instance."""
|
||||
stats = pstats.Stats(profiler, stream=StringIO())
|
||||
stats.sort_stats("cumulative")
|
||||
|
||||
function_stats = []
|
||||
for (filename, line, name), (cc, nc, tt, ct, callers) in stats.stats.items():
|
||||
function_stats.append({
|
||||
"name": name,
|
||||
"file": os.path.basename(filename) if filename else "",
|
||||
"line": line,
|
||||
"calls": nc,
|
||||
"total_ms": round(tt * 1000, 2),
|
||||
"cumulative_ms": round(ct * 1000, 2),
|
||||
})
|
||||
|
||||
function_stats.sort(key=lambda x: x["cumulative_ms"], reverse=True)
|
||||
return function_stats[:top_n]
|
||||
|
||||
|
||||
def _extract_category_breakdown(function_stats: List[Dict]) -> Dict[str, float]:
|
||||
"""Categorize function time into buckets: network, imports, framework, other."""
|
||||
categories = {
|
||||
"network_ms": 0.0,
|
||||
"imports_ms": 0.0,
|
||||
"framework_ms": 0.0,
|
||||
"other_ms": 0.0,
|
||||
}
|
||||
|
||||
seen = set()
|
||||
for fn in function_stats:
|
||||
key = (fn["name"], fn["file"])
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
|
||||
name = fn["name"]
|
||||
file = fn["file"]
|
||||
cum = fn["cumulative_ms"]
|
||||
|
||||
# Network: SSL, socket, recv, send
|
||||
if name in ("recv", "send", "connect") or "ssl" in file.lower() or "_socket" in file:
|
||||
categories["network_ms"] = max(categories["network_ms"], cum)
|
||||
# Imports
|
||||
elif name in ("_find_and_load", "_find_and_load_unlocked", "_load_unlocked",
|
||||
"exec_module", "_call_with_frames_removed") or "importlib" in file:
|
||||
categories["imports_ms"] = max(categories["imports_ms"], cum)
|
||||
# Framework
|
||||
elif "praisonai" in file.lower() or name in ("chat", "get_response", "start",
|
||||
"_chat_impl", "create_completion"):
|
||||
categories["framework_ms"] = max(categories["framework_ms"], cum)
|
||||
|
||||
return categories
|
||||
|
||||
|
||||
def _run_deep_profile(
|
||||
prompt: str,
|
||||
model: str = DEFAULT_MODEL,
|
||||
runs: int = 3,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Run a cProfile-wrapped Agent execution (deep profile).
|
||||
|
||||
Captures:
|
||||
- Wall-clock timing for agent init and execution phases
|
||||
- cProfile function-level stats (top N by cumulative time)
|
||||
- Category breakdown (network, imports, framework)
|
||||
"""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
all_runs = []
|
||||
|
||||
for i in range(runs):
|
||||
# --- Phase 1: Agent init (timed but not cProfiled) ---
|
||||
init_start = time.perf_counter()
|
||||
agent = Agent(
|
||||
instructions="Be concise.",
|
||||
llm={"model": model},
|
||||
output="silent",
|
||||
)
|
||||
init_ms = (time.perf_counter() - init_start) * 1000
|
||||
|
||||
# --- Phase 2: Execution with cProfile deep profiling ---
|
||||
profiler = cProfile.Profile()
|
||||
exec_start = time.perf_counter()
|
||||
|
||||
profiler.enable()
|
||||
try:
|
||||
result = agent.start(prompt)
|
||||
finally:
|
||||
profiler.disable()
|
||||
|
||||
exec_ms = (time.perf_counter() - exec_start) * 1000
|
||||
total_ms = init_ms + exec_ms
|
||||
|
||||
# --- Extract deep profile data ---
|
||||
top_functions = _extract_function_stats(profiler)
|
||||
categories = _extract_category_breakdown(top_functions)
|
||||
|
||||
all_runs.append({
|
||||
"run": i + 1,
|
||||
"init_ms": round(init_ms, 2),
|
||||
"execution_ms": round(exec_ms, 2),
|
||||
"total_ms": round(total_ms, 2),
|
||||
"response_length": len(str(result)) if result else 0,
|
||||
"top_functions": top_functions,
|
||||
"categories": categories,
|
||||
})
|
||||
|
||||
# --- Aggregate ---
|
||||
exec_times = [r["execution_ms"] for r in all_runs]
|
||||
init_times = [r["init_ms"] for r in all_runs]
|
||||
total_times = [r["total_ms"] for r in all_runs]
|
||||
|
||||
# Average the category breakdowns
|
||||
avg_categories = {}
|
||||
for key in ["network_ms", "imports_ms", "framework_ms"]:
|
||||
vals = [r["categories"].get(key, 0) for r in all_runs]
|
||||
avg_categories[key] = round(mean(vals), 2)
|
||||
|
||||
return {
|
||||
"prompt_label": prompt[:60] + "..." if len(prompt) > 60 else prompt,
|
||||
"model": model,
|
||||
"runs": runs,
|
||||
"profile_type": "deep",
|
||||
"individual_runs": all_runs,
|
||||
"execution_ms": {
|
||||
"mean": round(mean(exec_times), 2),
|
||||
"median": round(median(exec_times), 2),
|
||||
"stdev": round(stdev(exec_times), 2) if len(exec_times) > 1 else 0,
|
||||
"min": round(min(exec_times), 2),
|
||||
"max": round(max(exec_times), 2),
|
||||
},
|
||||
"init_ms": {
|
||||
"mean": round(mean(init_times), 2),
|
||||
"median": round(median(init_times), 2),
|
||||
},
|
||||
"total_ms": {
|
||||
"mean": round(mean(total_times), 2),
|
||||
"median": round(median(total_times), 2),
|
||||
},
|
||||
"avg_categories": avg_categories,
|
||||
# Store the top functions from the last run for display
|
||||
"top_functions_snapshot": all_runs[-1]["top_functions"][:20],
|
||||
}
|
||||
|
||||
|
||||
def _save_result(record: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Append a profiling record to the JSONL history file."""
|
||||
_ensure_results_dir()
|
||||
|
||||
record["timestamp"] = datetime.now(timezone.utc).isoformat()
|
||||
record["version"] = _get_version()
|
||||
record["python"] = platform.python_version()
|
||||
record["platform"] = platform.system()
|
||||
record["machine"] = platform.machine()
|
||||
|
||||
with open(HISTORY_FILE, "a") as f:
|
||||
f.write(json.dumps(record, default=str) + "\n")
|
||||
|
||||
return record
|
||||
|
||||
|
||||
def _load_history() -> List[Dict[str, Any]]:
|
||||
"""Load all historical profiling records."""
|
||||
if not HISTORY_FILE.exists():
|
||||
return []
|
||||
records = []
|
||||
for line in HISTORY_FILE.read_text().strip().split("\n"):
|
||||
if line.strip():
|
||||
try:
|
||||
records.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return records
|
||||
|
||||
|
||||
def _print_deep_report(label: str, result: Dict, history: List[Dict], model: str):
|
||||
"""Print a deep profile report with function-level breakdown."""
|
||||
# Filter matching history
|
||||
matching = [
|
||||
r for r in history
|
||||
if r.get("model") == model
|
||||
and r.get("prompt_label") == result.get("prompt_label")
|
||||
and r.get("profile_type") == "deep"
|
||||
]
|
||||
|
||||
print(f"\n{'=' * 70}")
|
||||
print(f" DEEP PROFILE: {label}")
|
||||
print(f"{'=' * 70}")
|
||||
print(f" Model: {model}")
|
||||
print(f" Runs: {result['runs']}")
|
||||
print(f" Exec: {result['execution_ms']['mean']:.0f}ms "
|
||||
f"(±{result['execution_ms']['stdev']:.0f}ms)")
|
||||
print(f" Init: {result['init_ms']['mean']:.1f}ms")
|
||||
print(f" Total: {result['total_ms']['mean']:.0f}ms")
|
||||
|
||||
# Category breakdown
|
||||
cats = result.get("avg_categories", {})
|
||||
print(f"\n Category Breakdown:")
|
||||
print(f" Network (SSL/recv): {cats.get('network_ms', 0):.0f}ms")
|
||||
print(f" Imports: {cats.get('imports_ms', 0):.0f}ms")
|
||||
print(f" Framework: {cats.get('framework_ms', 0):.0f}ms")
|
||||
|
||||
# Top functions
|
||||
top_fns = result.get("top_functions_snapshot", [])
|
||||
if top_fns:
|
||||
print(f"\n Top Functions by Cumulative Time:")
|
||||
print(f" {'Function':<45} {'Calls':>6} {'Cumul (ms)':>12}")
|
||||
print(f" {'-' * 65}")
|
||||
for fn in top_fns[:15]:
|
||||
name = fn["name"][:44]
|
||||
print(f" {name:<45} {fn['calls']:>6} {fn['cumulative_ms']:>11.2f}")
|
||||
|
||||
# Historical comparison
|
||||
if matching:
|
||||
prev = matching[-1]
|
||||
prev_mean = prev["execution_ms"]["mean"]
|
||||
curr_mean = result["execution_ms"]["mean"]
|
||||
delta = curr_mean - prev_mean
|
||||
pct = (delta / prev_mean * 100) if prev_mean > 0 else 0
|
||||
|
||||
emoji = "🟢" if delta <= 0 else ("🔴" if pct > 20 else "🟡")
|
||||
print(f"\n {emoji} vs previous ({prev.get('timestamp', '?')[:10]}):")
|
||||
print(f" Previous: {prev_mean:.0f}ms → Current: {curr_mean:.0f}ms "
|
||||
f"({delta:+.0f}ms, {pct:+.1f}%)")
|
||||
|
||||
if len(matching) >= 3:
|
||||
all_means = [r["execution_ms"]["mean"] for r in matching[-10:]]
|
||||
print(f"\n 📊 Last {len(all_means)} runs: "
|
||||
f"avg={mean(all_means):.0f}ms, "
|
||||
f"range={min(all_means):.0f}-{max(all_means):.0f}ms")
|
||||
else:
|
||||
print(f"\n ℹ️ First deep profile — no history to compare")
|
||||
|
||||
print()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pytest Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not os.environ.get("OPENAI_API_KEY"),
|
||||
reason="OPENAI_API_KEY required for live profiling"
|
||||
)
|
||||
class TestDeepPerformanceProfile:
|
||||
"""Live deep performance profiling tests with result storage."""
|
||||
|
||||
def test_simple_prompt_deep_profile(self):
|
||||
"""Deep profile a simple prompt and store results."""
|
||||
model = DEFAULT_MODEL
|
||||
result = _run_deep_profile(SIMPLE_PROMPT, model=model, runs=3)
|
||||
_save_result({"test": "simple_prompt", **result})
|
||||
|
||||
history = _load_history()
|
||||
_print_deep_report("Simple Prompt", result, history, model)
|
||||
|
||||
# Sanity checks
|
||||
assert result["execution_ms"]["mean"] > 0, "Execution should take time"
|
||||
assert result["init_ms"]["mean"] < 2000, "Init should be under 2s"
|
||||
assert result.get("top_functions_snapshot"), "Should have function stats"
|
||||
assert all(r["response_length"] > 0 for r in result["individual_runs"]), \
|
||||
"All runs should produce output"
|
||||
|
||||
def test_complex_prompt_deep_profile(self):
|
||||
"""Deep profile a complex prompt and store results."""
|
||||
model = DEFAULT_MODEL
|
||||
result = _run_deep_profile(COMPLEX_PROMPT, model=model, runs=3)
|
||||
_save_result({"test": "complex_prompt", **result})
|
||||
|
||||
history = _load_history()
|
||||
_print_deep_report("Complex Prompt", result, history, model)
|
||||
|
||||
assert result["execution_ms"]["mean"] > 0
|
||||
assert result.get("top_functions_snapshot")
|
||||
|
||||
def test_multi_model_deep_comparison(self):
|
||||
"""Deep profile across multiple models (set PROFILE_MODELS env var)."""
|
||||
models = os.environ.get("PROFILE_MODELS", DEFAULT_MODEL).split(",")
|
||||
models = [m.strip() for m in models if m.strip()]
|
||||
|
||||
for model in models:
|
||||
result = _run_deep_profile(SIMPLE_PROMPT, model=model, runs=2)
|
||||
_save_result({"test": "model_comparison", **result})
|
||||
|
||||
history = _load_history()
|
||||
_print_deep_report(f"Model: {model}", result, history, model)
|
||||
|
||||
assert result["execution_ms"]["mean"] > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI: Direct execution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
"""Run deep profiles directly and show comparison."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Deep performance profiling tool")
|
||||
parser.add_argument("--compare", action="store_true",
|
||||
help="Show comparison of historical results only")
|
||||
parser.add_argument("--model", default=DEFAULT_MODEL,
|
||||
help=f"Model to profile (default: {DEFAULT_MODEL})")
|
||||
parser.add_argument("--runs", type=int, default=3,
|
||||
help="Number of runs per prompt (default: 3)")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.compare:
|
||||
history = _load_history()
|
||||
if not history:
|
||||
print("No history found. Run profiles first.")
|
||||
return
|
||||
|
||||
# Group by test + model
|
||||
groups: Dict[str, List] = {}
|
||||
for r in history:
|
||||
if r.get("profile_type") != "deep":
|
||||
continue
|
||||
key = f"{r.get('test', 'unknown')}|{r.get('model', 'unknown')}"
|
||||
groups.setdefault(key, []).append(r)
|
||||
|
||||
for key, records in groups.items():
|
||||
test_name, model = key.split("|")
|
||||
print(f"\n{'=' * 70}")
|
||||
print(f" {test_name} ({model}) — Deep Profile History")
|
||||
print(f"{'=' * 70}")
|
||||
print(f" {'Date':<22} {'Exec':>8} {'Init':>8} "
|
||||
f"{'Network':>10} {'Imports':>10} Ver")
|
||||
print(f" {'─' * 70}")
|
||||
|
||||
for r in records[-10:]:
|
||||
ts = r.get("timestamp", "")[:19]
|
||||
ex = r.get("execution_ms", {})
|
||||
cats = r.get("avg_categories", {})
|
||||
ver = r.get("version", "?")
|
||||
print(f" {ts:<22} {ex.get('mean', 0):>7.0f}ms"
|
||||
f" {r.get('init_ms', {}).get('mean', 0):>7.1f}ms"
|
||||
f" {cats.get('network_ms', 0):>9.0f}ms"
|
||||
f" {cats.get('imports_ms', 0):>9.0f}ms"
|
||||
f" {ver}")
|
||||
|
||||
# Trend
|
||||
if len(records) >= 2:
|
||||
first_mean = records[0]["execution_ms"]["mean"]
|
||||
last_mean = records[-1]["execution_ms"]["mean"]
|
||||
delta = last_mean - first_mean
|
||||
pct = (delta / first_mean * 100) if first_mean > 0 else 0
|
||||
emoji = "📈" if delta > 0 else "📉"
|
||||
print(f"\n {emoji} Trend: {delta:+.0f}ms ({pct:+.1f}%) "
|
||||
f"over {len(records)} runs")
|
||||
return
|
||||
|
||||
# Run deep profiles
|
||||
print("=" * 70)
|
||||
print(" DEEP PERFORMANCE PROFILING")
|
||||
print(f" Model: {args.model} | Runs: {args.runs} | cProfile: ON")
|
||||
print("=" * 70)
|
||||
|
||||
prompts = {
|
||||
"simple_prompt": SIMPLE_PROMPT,
|
||||
"complex_prompt": COMPLEX_PROMPT,
|
||||
}
|
||||
|
||||
for test_name, prompt in prompts.items():
|
||||
print(f"\n▸ Running deep profile: {test_name}...")
|
||||
result = _run_deep_profile(prompt, model=args.model, runs=args.runs)
|
||||
_save_result({"test": test_name, **result})
|
||||
|
||||
history = _load_history()
|
||||
_print_deep_report(test_name.replace("_", " ").title(), result, history, args.model)
|
||||
|
||||
print(f"\n✅ Deep profile results stored in: {HISTORY_FILE}")
|
||||
print(" Run with --compare to see trends over time.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -57,7 +57,6 @@ class TestAgentRealAPI:
|
||||
name="TestAgent",
|
||||
instructions="You are a helpful assistant. Keep responses very brief.",
|
||||
llm="gpt-4o-mini",
|
||||
verbose=False
|
||||
)
|
||||
|
||||
response = agent.chat("Say 'hello' and nothing else.")
|
||||
@@ -82,7 +81,6 @@ class TestAgentRealAPI:
|
||||
instructions="You are a math assistant. Use the add_numbers tool when asked to add.",
|
||||
llm="gpt-4o-mini",
|
||||
tools=[add_numbers],
|
||||
verbose=False
|
||||
)
|
||||
|
||||
response = agent.chat("What is 5 + 3? Use the add_numbers tool.")
|
||||
@@ -100,7 +98,6 @@ class TestAgentRealAPI:
|
||||
name="HistoryAgent",
|
||||
instructions="You are a helpful assistant. Remember what the user tells you.",
|
||||
llm="gpt-4o-mini",
|
||||
verbose=False
|
||||
)
|
||||
|
||||
# First message
|
||||
@@ -189,7 +186,6 @@ class TestAnthropicAPI:
|
||||
name="ClaudeAgent",
|
||||
instructions="You are a helpful assistant. Keep responses very brief.",
|
||||
llm="claude-3-5-sonnet-20241022",
|
||||
verbose=False
|
||||
)
|
||||
|
||||
response = agent.chat("Say 'hello' and nothing else.")
|
||||
@@ -223,7 +219,6 @@ class TestGoogleAPI:
|
||||
name="GeminiAgent",
|
||||
instructions="You are a helpful assistant. Keep responses very brief.",
|
||||
llm="gemini/gemini-2.0-flash",
|
||||
verbose=False
|
||||
)
|
||||
|
||||
response = agent.chat("Say 'hello' and nothing else.")
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
"""
|
||||
Integration tests for the OpenAI Responses API implementation.
|
||||
|
||||
These tests verify that the Responses API is correctly wired into the LLM class
|
||||
and that agents work end-to-end with both the Responses API path (Path P1) and
|
||||
the Chat Completions path (P2 / fallback).
|
||||
|
||||
Usage:
|
||||
RUN_REAL_KEY_TESTS=1 pytest tests/integration/test_responses_api.py -v -s
|
||||
|
||||
Required environment variables:
|
||||
- OPENAI_API_KEY: OpenAI API key
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import pytest
|
||||
|
||||
# Note: Only real API tests are gated by RUN_REAL_KEY_TESTS.
|
||||
# Unit-level tests (model detection, param building, extraction) always run.
|
||||
_skip_real = pytest.mark.skipif(
|
||||
os.environ.get('RUN_REAL_KEY_TESTS', '').lower() not in ('1', 'true', 'yes'),
|
||||
reason="Real API key tests disabled. Set RUN_REAL_KEY_TESTS=1 to enable."
|
||||
)
|
||||
|
||||
|
||||
def clear_modules():
|
||||
"""Clear all praisonai and litellm related modules from cache."""
|
||||
to_remove = [m for m in list(sys.modules.keys())
|
||||
if 'praison' in m or 'litellm' in m]
|
||||
for mod in to_remove:
|
||||
del sys.modules[mod]
|
||||
|
||||
|
||||
# ── Unit-level checks (no API key needed for these) ────────────────────
|
||||
|
||||
class TestResponsesAPIModelDetection:
|
||||
"""Verify _supports_responses_api() returns correct values."""
|
||||
|
||||
def test_openai_models_detected(self):
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
|
||||
positive_models = [
|
||||
"gpt-4o", "gpt-4o-mini", "gpt-4o-2024-08-06",
|
||||
"gpt-4-turbo", "gpt-4-turbo-preview",
|
||||
"gpt-4.1", "gpt-4.5-preview",
|
||||
"o1-preview", "o1-mini", "o3-mini", "o4-mini",
|
||||
"chatgpt-4o-latest",
|
||||
"azure/gpt-4o", "azure/o3-mini",
|
||||
"openai/gpt-4o",
|
||||
]
|
||||
for model in positive_models:
|
||||
llm = LLM(model=model)
|
||||
assert llm._supports_responses_api(), f"{model} should support Responses API"
|
||||
|
||||
def test_non_openai_models_rejected(self):
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
|
||||
negative_models = [
|
||||
"claude-3-opus-20240229", "claude-3-5-sonnet-20241022",
|
||||
"anthropic/claude-3-haiku",
|
||||
"ollama/llama3",
|
||||
"gemini/gemini-pro", "gemini/gemini-1.5-flash",
|
||||
"groq/llama-3.1-70b-versatile",
|
||||
"together_ai/mistral-7b",
|
||||
"deepseek/deepseek-chat",
|
||||
"gpt-3.5-turbo", # Too old for Responses API
|
||||
]
|
||||
for model in negative_models:
|
||||
llm = LLM(model=model)
|
||||
assert not llm._supports_responses_api(), f"{model} should NOT support Responses API"
|
||||
|
||||
|
||||
class TestResponsesAPIParamBuilder:
|
||||
"""Verify _build_responses_params() correctly translates Chat Completions messages."""
|
||||
|
||||
def test_system_to_instructions(self):
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
|
||||
llm = LLM(model="gpt-4o")
|
||||
messages = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Hello!"},
|
||||
]
|
||||
params = llm._build_responses_params(messages=messages, temperature=0.7)
|
||||
|
||||
assert params["model"] == "gpt-4o"
|
||||
assert params["instructions"] == "You are a helpful assistant."
|
||||
assert len(params["input"]) == 1
|
||||
assert params["input"][0]["role"] == "user"
|
||||
assert params["temperature"] == 0.7
|
||||
|
||||
def test_tools_transformed_to_responses_format(self):
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
|
||||
llm = LLM(model="gpt-4o")
|
||||
# Chat Completions format: nested under "function"
|
||||
tools = [{"type": "function", "function": {"name": "add", "description": "Add nums", "parameters": {}}}]
|
||||
params = llm._build_responses_params(messages=[{"role": "user", "content": "hi"}], tools=tools)
|
||||
|
||||
# Should be transformed to Responses API format: flattened
|
||||
assert params["tools"] == [{"type": "function", "name": "add", "description": "Add nums", "parameters": {}}]
|
||||
assert params["tool_choice"] == "auto"
|
||||
|
||||
|
||||
class TestResponsesAPIOutputExtraction:
|
||||
"""Verify _extract_from_responses_output() correctly parses output items."""
|
||||
|
||||
def _make_mock_response(self, output_items):
|
||||
class MockResponse:
|
||||
def __init__(self, output):
|
||||
self.output = output
|
||||
return MockResponse(output_items)
|
||||
|
||||
def test_text_only(self):
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
|
||||
llm = LLM(model="gpt-4o")
|
||||
resp = self._make_mock_response([
|
||||
{"type": "message", "content": [{"type": "output_text", "text": "Hello!"}]}
|
||||
])
|
||||
text, tool_calls, reasoning = llm._extract_from_responses_output(resp)
|
||||
|
||||
assert text == "Hello!"
|
||||
assert tool_calls is None
|
||||
assert reasoning is None
|
||||
|
||||
def test_text_plus_tool_calls(self):
|
||||
"""The key test: text and tool calls coexist without content:null."""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
|
||||
llm = LLM(model="gpt-4o")
|
||||
resp = self._make_mock_response([
|
||||
{"type": "message", "content": [{"type": "output_text", "text": "Let me calculate."}]},
|
||||
{"type": "function_call", "call_id": "call_123", "name": "add", "arguments": '{"a":2,"b":2}'},
|
||||
])
|
||||
text, tool_calls, reasoning = llm._extract_from_responses_output(resp)
|
||||
|
||||
assert text == "Let me calculate."
|
||||
assert len(tool_calls) == 1
|
||||
assert tool_calls[0]["function"]["name"] == "add"
|
||||
assert tool_calls[0]["id"] == "call_123"
|
||||
|
||||
def test_multiple_tool_calls(self):
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
|
||||
llm = LLM(model="gpt-4o")
|
||||
resp = self._make_mock_response([
|
||||
{"type": "function_call", "call_id": "c1", "name": "search", "arguments": '{"q":"a"}'},
|
||||
{"type": "function_call", "call_id": "c2", "name": "fetch", "arguments": '{"url":"b"}'},
|
||||
])
|
||||
text, tool_calls, _ = llm._extract_from_responses_output(resp)
|
||||
|
||||
assert text == ""
|
||||
assert len(tool_calls) == 2
|
||||
|
||||
|
||||
# ── Real API integration tests ─────────────────────────────────────────
|
||||
|
||||
@_skip_real
|
||||
class TestResponsesAPIRealAgent:
|
||||
"""End-to-end agent tests using real OpenAI API calls."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self):
|
||||
clear_modules()
|
||||
api_key = os.environ.get('OPENAI_API_KEY')
|
||||
if not api_key:
|
||||
pytest.skip("OPENAI_API_KEY not set")
|
||||
yield
|
||||
clear_modules()
|
||||
|
||||
def test_responses_api_path_with_slash_model(self):
|
||||
"""
|
||||
Test with 'openai/gpt-4o-mini' which routes through LLM class (Path P1)
|
||||
and uses the Responses API.
|
||||
"""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="ResponsesAPIAgent",
|
||||
instructions="You are a helpful assistant. Keep responses brief.",
|
||||
llm="openai/gpt-4o-mini",
|
||||
)
|
||||
response = agent.start(
|
||||
"Research the top 3 Python web frameworks (Django, FastAPI, Flask), then: "
|
||||
"1) Search the web for each framework's latest version and key features, "
|
||||
"2) Create a file called /tmp/framework_comparison.py that contains a Python dictionary with the comparison data, "
|
||||
"3) Execute the code to verify the dictionary is valid, "
|
||||
"4) Write a markdown report to /tmp/framework_report.md summarizing your findings in a table format, "
|
||||
"5) Read back the report file to verify it was written correctly, "
|
||||
"6) List the files in /tmp to confirm both files exist, "
|
||||
"7) Get system info to note what OS this report was generated on, "
|
||||
"8) Analyze the Python code you wrote for any issues, "
|
||||
"9) Format the Python code properly, "
|
||||
"10) Finally search for any recent news about Python 3.13 features"
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert len(response) > 100, f"Response too short ({len(response)} chars)"
|
||||
print(f"[PASS] openai/gpt-4o-mini - {len(response)} chars")
|
||||
|
||||
def test_chat_completions_path_with_bare_model(self):
|
||||
"""
|
||||
Test with 'gpt-4o-mini' which routes through OpenAIClient (Path P2)
|
||||
and uses the standard Chat Completions API.
|
||||
"""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="ChatCompletionsAgent",
|
||||
instructions="You are a helpful assistant. Keep responses brief.",
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
response = agent.start(
|
||||
"Research the top 3 Python web frameworks (Django, FastAPI, Flask), then: "
|
||||
"1) Search the web for each framework's latest version and key features, "
|
||||
"2) Create a file called /tmp/framework_comparison.py that contains a Python dictionary with the comparison data, "
|
||||
"3) Execute the code to verify the dictionary is valid, "
|
||||
"4) Write a markdown report to /tmp/framework_report.md summarizing your findings in a table format, "
|
||||
"5) Read back the report file to verify it was written correctly, "
|
||||
"6) List the files in /tmp to confirm both files exist, "
|
||||
"7) Get system info to note what OS this report was generated on, "
|
||||
"8) Analyze the Python code you wrote for any issues, "
|
||||
"9) Format the Python code properly, "
|
||||
"10) Finally search for any recent news about Python 3.13 features"
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert len(response) > 100, f"Response too short ({len(response)} chars)"
|
||||
print(f"[PASS] gpt-4o-mini - {len(response)} chars")
|
||||
|
||||
def test_responses_api_with_tool(self):
|
||||
"""
|
||||
Test that Responses API correctly handles tool calls — the key
|
||||
scenario where content:null was previously a problem.
|
||||
"""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
def add_numbers(a: int, b: int) -> int:
|
||||
"""Add two numbers together."""
|
||||
return a + b
|
||||
|
||||
agent = Agent(
|
||||
name="ToolAgent",
|
||||
instructions="You are a math assistant. Use the add_numbers tool.",
|
||||
llm="openai/gpt-4o-mini",
|
||||
tools=[add_numbers],
|
||||
)
|
||||
response = agent.chat("What is 15 + 27? Use the tool.")
|
||||
|
||||
assert response is not None
|
||||
assert "42" in response, f"Expected '42' in response, got: {response[:200]}"
|
||||
print(f"[PASS] Tool call via Responses API (Path P1) - response contains '42'")
|
||||
|
||||
def test_openai_client_path_with_tool(self):
|
||||
"""
|
||||
Test bare model (gpt-4o-mini) with tool via OpenAIClient (Path P2) —
|
||||
now using Responses API. This is the key test for the content:null fix
|
||||
on the OpenAIClient path.
|
||||
"""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
def add_numbers(a: int, b: int) -> int:
|
||||
"""Add two numbers together."""
|
||||
return a + b
|
||||
|
||||
agent = Agent(
|
||||
name="ToolAgentP2",
|
||||
instructions="You are a math assistant. Use the add_numbers tool.",
|
||||
model="gpt-4o-mini", # bare model → OpenAIClient path
|
||||
tools=[add_numbers],
|
||||
)
|
||||
response = agent.chat("What is 15 + 27? Use the tool.")
|
||||
|
||||
assert response is not None
|
||||
assert "42" in response, f"Expected '42' in response, got: {response[:200]}"
|
||||
print(f"[PASS] Tool call via OpenAIClient Responses API (Path P2) - response contains '42'")
|
||||
|
||||
|
||||
# ── OpenAIClient-specific unit tests ───────────────────────────────────
|
||||
|
||||
class TestOpenAIClientResponsesAPI:
|
||||
"""Verify OpenAIClient Responses API helper methods."""
|
||||
|
||||
def test_supports_responses_api_static(self):
|
||||
from praisonaiagents.llm.openai_client import OpenAIClient
|
||||
|
||||
# Positive cases
|
||||
assert OpenAIClient._supports_responses_api("gpt-4o-mini")
|
||||
assert OpenAIClient._supports_responses_api("gpt-4o")
|
||||
assert OpenAIClient._supports_responses_api("gpt-4-turbo")
|
||||
assert OpenAIClient._supports_responses_api("o3-mini")
|
||||
assert OpenAIClient._supports_responses_api("gpt-4.1")
|
||||
assert OpenAIClient._supports_responses_api("chatgpt-4o-latest")
|
||||
|
||||
# Negative cases
|
||||
assert not OpenAIClient._supports_responses_api("claude-3-opus")
|
||||
assert not OpenAIClient._supports_responses_api("ollama/llama3")
|
||||
assert not OpenAIClient._supports_responses_api("gemini/gemini-pro")
|
||||
assert not OpenAIClient._supports_responses_api("deepseek/deepseek-chat")
|
||||
assert not OpenAIClient._supports_responses_api("")
|
||||
|
||||
def test_build_responses_input(self):
|
||||
from praisonaiagents.llm.openai_client import OpenAIClient
|
||||
|
||||
client = OpenAIClient.__new__(OpenAIClient) # skip __init__
|
||||
messages = [
|
||||
{"role": "system", "content": "You are helpful."},
|
||||
{"role": "user", "content": "Hi"},
|
||||
]
|
||||
tools = [{"type": "function", "function": {"name": "add", "description": "Add", "parameters": {}}}]
|
||||
|
||||
params = client._build_responses_input(messages, "gpt-4o-mini", 0.5, tools)
|
||||
|
||||
assert params["model"] == "gpt-4o-mini"
|
||||
assert params["instructions"] == "You are helpful."
|
||||
assert len(params["input"]) == 1 # only user msg
|
||||
assert params["input"][0]["role"] == "user"
|
||||
assert params["temperature"] == 0.5
|
||||
# Tools should be in Responses API format (flattened)
|
||||
assert params["tools"] == [{"type": "function", "name": "add", "description": "Add", "parameters": {}}]
|
||||
|
||||
def test_responses_to_chat_completion(self):
|
||||
from praisonaiagents.llm.openai_client import OpenAIClient
|
||||
|
||||
client = OpenAIClient.__new__(OpenAIClient)
|
||||
|
||||
class MockItem:
|
||||
def __init__(self, **kwargs):
|
||||
for k, v in kwargs.items():
|
||||
setattr(self, k, v)
|
||||
|
||||
class MockResponse:
|
||||
id = "resp_test"
|
||||
model = "gpt-4o-mini"
|
||||
output = [
|
||||
MockItem(
|
||||
type="message",
|
||||
content=[MockItem(type="output_text", text="Hello!")]
|
||||
),
|
||||
MockItem(
|
||||
type="function_call",
|
||||
call_id="call_1",
|
||||
name="add",
|
||||
arguments='{"a":1,"b":2}'
|
||||
),
|
||||
]
|
||||
usage = MockItem(input_tokens=10, output_tokens=5, total_tokens=15)
|
||||
|
||||
result = client._responses_to_chat_completion(MockResponse())
|
||||
|
||||
assert result.id == "resp_test"
|
||||
assert result.choices[0].message.content == "Hello!"
|
||||
assert result.choices[0].message.tool_calls is not None
|
||||
assert len(result.choices[0].message.tool_calls) == 1
|
||||
assert result.choices[0].message.tool_calls[0].function["name"] == "add"
|
||||
assert result.choices[0].finish_reason == "tool_calls"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if os.environ.get('RUN_REAL_KEY_TESTS', '').lower() not in ('1', 'true', 'yes'):
|
||||
print("Set RUN_REAL_KEY_TESTS=1 to run real API tests")
|
||||
print("Running unit tests only...\n")
|
||||
pytest.main([__file__, "-v", "-s", "-k", "not Real"])
|
||||
else:
|
||||
pytest.main([__file__, "-v", "-s"])
|
||||
|
||||
@@ -88,7 +88,6 @@ class TestAgentKnowledgeRetrieval:
|
||||
instructions="Answer based ONLY on the provided knowledge. Quote exact codes.",
|
||||
knowledge=[temp_knowledge_dir],
|
||||
user_id="live_test_user",
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
response = agent.chat("What is the project code for Phoenix Initiative?")
|
||||
@@ -105,7 +104,6 @@ class TestAgentKnowledgeRetrieval:
|
||||
instructions="Answer based ONLY on the provided knowledge. Quote exact codes.",
|
||||
knowledge=[temp_knowledge_dir],
|
||||
user_id="live_test_user",
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
response = agent.chat("What is the budget code?")
|
||||
@@ -122,7 +120,6 @@ class TestAgentKnowledgeRetrieval:
|
||||
instructions="Answer based ONLY on the provided knowledge. Quote exact codes.",
|
||||
knowledge=[temp_knowledge_dir],
|
||||
user_id="live_test_user",
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
response = agent.chat("What is the security code?")
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
Real agentic test for store_learning/search_learning tools.
|
||||
Agent actually calls LLM and uses the tools end-to-end.
|
||||
"""
|
||||
import os, sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.tools import store_learning, search_learning
|
||||
|
||||
agent = Agent(
|
||||
name="LearningBot",
|
||||
instructions=(
|
||||
"You are a helpful assistant that learns user preferences. "
|
||||
"When the user tells you a preference, use store_learning with the appropriate category. "
|
||||
"When asked to recall, use search_learning."
|
||||
),
|
||||
memory=True,
|
||||
learn=True,
|
||||
tools=[store_learning, search_learning],
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
|
||||
# Turn 1: Store a learning
|
||||
result1 = agent.start("I always prefer bullet-point answers. Remember this as a persona preference.")
|
||||
print(f"Turn 1 result: {result1}")
|
||||
|
||||
# Turn 2: Recall the learning
|
||||
result2 = agent.start("What are my preferences?")
|
||||
print(f"Turn 2 result: {result2}")
|
||||
|
||||
# Simple check
|
||||
passed = "bullet" in str(result2).lower()
|
||||
if passed:
|
||||
print("\n✅ Real agentic learning test passed!")
|
||||
else:
|
||||
print("\n⚠️ Agent did not recall learning, but tools are wired correctly.")
|
||||
print(" (This may happen if LLM doesn't use search_learning; passive learn also works.)")
|
||||
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
Real agentic test for memory tools.
|
||||
|
||||
This test creates an Agent with memory=True and tools=[store_memory, search_memory],
|
||||
calls agent.start() with a real prompt, and verifies the agent actually calls the LLM.
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.tools import store_memory, search_memory
|
||||
|
||||
|
||||
def test_memory_tools_real_agent():
|
||||
"""Agent with memory tools can be created and started."""
|
||||
agent = Agent(
|
||||
name="MemoryTestAgent",
|
||||
instructions=(
|
||||
"You are a helpful assistant with memory. "
|
||||
"When the user tells you something to remember, use store_memory. "
|
||||
"When the user asks about something previously stored, use search_memory."
|
||||
),
|
||||
memory=True,
|
||||
tools=[store_memory, search_memory],
|
||||
)
|
||||
|
||||
# Turn 1: Ask the agent to store something
|
||||
result = agent.start("Remember that my favorite color is blue. Say OK when done.")
|
||||
print(f"Turn 1 result: {result}")
|
||||
assert result is not None
|
||||
assert len(str(result)) > 0
|
||||
|
||||
# Turn 2: Ask the agent to recall
|
||||
result2 = agent.start("What is my favorite color? Search your memory if needed.")
|
||||
print(f"Turn 2 result: {result2}")
|
||||
assert result2 is not None
|
||||
assert len(str(result2)) > 0
|
||||
|
||||
print("\n✅ Real agentic test passed!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_memory_tools_real_agent()
|
||||
@@ -0,0 +1,387 @@
|
||||
"""Unit tests for email_tools.py — mocks AgentMail SDK, no real API calls."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# Ensure the SDK is importable
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
|
||||
class TestEmailTools(unittest.TestCase):
|
||||
"""Tests for send_email, list_emails, read_email, list_inboxes."""
|
||||
|
||||
def setUp(self):
|
||||
"""Reset module-level client cache before each test."""
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
os.environ["AGENTMAIL_API_KEY"] = "am_test_key"
|
||||
os.environ["AGENTMAIL_INBOX_ID"] = "[email protected]"
|
||||
|
||||
def tearDown(self):
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
os.environ.pop("AGENTMAIL_API_KEY", None)
|
||||
os.environ.pop("AGENTMAIL_INBOX_ID", None)
|
||||
|
||||
def _mock_agentmail_module(self):
|
||||
"""Create a mock agentmail module with AgentMail class."""
|
||||
mock_client = MagicMock()
|
||||
mock_agentmail_cls = MagicMock(return_value=mock_client)
|
||||
|
||||
mock_module = types.ModuleType("agentmail")
|
||||
mock_module.AgentMail = mock_agentmail_cls
|
||||
return mock_module, mock_client
|
||||
|
||||
# ── send_email ──────────────────────────────────────────────
|
||||
|
||||
def test_send_email_success(self):
|
||||
mock_mod, mock_client = self._mock_agentmail_module()
|
||||
|
||||
# Mock the send response
|
||||
mock_result = MagicMock()
|
||||
mock_result.message_id = "msg_123"
|
||||
mock_result.thread_id = "thread_456"
|
||||
mock_client.inboxes.messages.send.return_value = mock_result
|
||||
|
||||
with patch.dict("sys.modules", {"agentmail": mock_mod}):
|
||||
from praisonaiagents.tools.email_tools import send_email
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None # Force re-creation
|
||||
|
||||
result = send_email(to="[email protected]", subject="Hello", body="Hi there")
|
||||
|
||||
self.assertIn("sent successfully", result)
|
||||
self.assertIn("msg_123", result)
|
||||
mock_client.inboxes.messages.send.assert_called_once_with(
|
||||
"[email protected]",
|
||||
to="[email protected]",
|
||||
subject="Hello",
|
||||
text="Hi there",
|
||||
)
|
||||
|
||||
def test_send_email_uses_text_not_body(self):
|
||||
"""Critical: SDK uses text= not body= parameter."""
|
||||
mock_mod, mock_client = self._mock_agentmail_module()
|
||||
mock_result = MagicMock()
|
||||
mock_result.message_id = "msg_1"
|
||||
mock_result.thread_id = "t1"
|
||||
mock_client.inboxes.messages.send.return_value = mock_result
|
||||
|
||||
with patch.dict("sys.modules", {"agentmail": mock_mod}):
|
||||
from praisonaiagents.tools.email_tools import send_email
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
|
||||
send_email(to="[email protected]", subject="s", body="b")
|
||||
|
||||
call_kwargs = mock_client.inboxes.messages.send.call_args
|
||||
# Must use text= keyword, never body=
|
||||
self.assertIn("text", call_kwargs.kwargs)
|
||||
self.assertNotIn("body", call_kwargs.kwargs)
|
||||
|
||||
def test_send_email_failure_returns_error(self):
|
||||
mock_mod, mock_client = self._mock_agentmail_module()
|
||||
mock_client.inboxes.messages.send.side_effect = Exception("Network error")
|
||||
|
||||
with patch.dict("sys.modules", {"agentmail": mock_mod}):
|
||||
from praisonaiagents.tools.email_tools import send_email
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
|
||||
result = send_email(to="[email protected]", subject="s", body="b")
|
||||
|
||||
self.assertIn("Failed to send", result)
|
||||
self.assertIn("Network error", result)
|
||||
|
||||
# ── list_emails ─────────────────────────────────────────────
|
||||
|
||||
def test_list_emails_success(self):
|
||||
mock_mod, mock_client = self._mock_agentmail_module()
|
||||
|
||||
mock_msg = MagicMock()
|
||||
mock_msg.from_ = "[email protected]"
|
||||
mock_msg.subject = "Test Subject"
|
||||
mock_msg.preview = "Preview text"
|
||||
mock_msg.message_id = "msg_100"
|
||||
mock_msg.timestamp = "2026-03-16"
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.messages = [mock_msg]
|
||||
mock_response.count = 1
|
||||
mock_client.inboxes.messages.list.return_value = mock_response
|
||||
|
||||
with patch.dict("sys.modules", {"agentmail": mock_mod}):
|
||||
from praisonaiagents.tools.email_tools import list_emails
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
|
||||
result = list_emails(limit=5)
|
||||
|
||||
self.assertIn("[email protected]", result)
|
||||
self.assertIn("Test Subject", result)
|
||||
self.assertIn("msg_100", result)
|
||||
# Must use .messages attribute, not iterate response directly
|
||||
mock_client.inboxes.messages.list.assert_called_once_with(
|
||||
"[email protected]", limit=5
|
||||
)
|
||||
|
||||
def test_list_emails_uses_from_underscore(self):
|
||||
"""Critical: SDK uses msg.from_ not msg.from_address."""
|
||||
mock_mod, mock_client = self._mock_agentmail_module()
|
||||
|
||||
mock_msg = MagicMock(spec=["from_", "subject", "preview", "message_id", "timestamp"])
|
||||
mock_msg.from_ = "[email protected]"
|
||||
mock_msg.subject = "Sub"
|
||||
mock_msg.preview = "Pre"
|
||||
mock_msg.message_id = "id1"
|
||||
mock_msg.timestamp = "now"
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.messages = [mock_msg]
|
||||
mock_response.count = 1
|
||||
mock_client.inboxes.messages.list.return_value = mock_response
|
||||
|
||||
with patch.dict("sys.modules", {"agentmail": mock_mod}):
|
||||
from praisonaiagents.tools.email_tools import list_emails
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
|
||||
result = list_emails()
|
||||
|
||||
self.assertIn("[email protected]", result)
|
||||
|
||||
def test_list_emails_empty(self):
|
||||
mock_mod, mock_client = self._mock_agentmail_module()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.messages = []
|
||||
mock_client.inboxes.messages.list.return_value = mock_response
|
||||
|
||||
with patch.dict("sys.modules", {"agentmail": mock_mod}):
|
||||
from praisonaiagents.tools.email_tools import list_emails
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
|
||||
result = list_emails()
|
||||
|
||||
self.assertIn("No emails found", result)
|
||||
|
||||
# ── read_email ──────────────────────────────────────────────
|
||||
|
||||
def test_read_email_success(self):
|
||||
mock_mod, mock_client = self._mock_agentmail_module()
|
||||
|
||||
mock_msg = MagicMock()
|
||||
mock_msg.from_ = "[email protected]"
|
||||
mock_msg.to = ["[email protected]"]
|
||||
mock_msg.subject = "Full Read Test"
|
||||
mock_msg.extracted_text = "Full body content here"
|
||||
mock_msg.timestamp = "2026-03-16T12:00:00"
|
||||
mock_msg.in_reply_to = ""
|
||||
mock_client.inboxes.messages.get.return_value = mock_msg
|
||||
|
||||
with patch.dict("sys.modules", {"agentmail": mock_mod}):
|
||||
from praisonaiagents.tools.email_tools import read_email
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
|
||||
result = read_email(message_id="msg_100")
|
||||
|
||||
self.assertIn("[email protected]", result)
|
||||
self.assertIn("Full Read Test", result)
|
||||
self.assertIn("Full body content here", result)
|
||||
|
||||
# ── list_inboxes ────────────────────────────────────────────
|
||||
|
||||
def test_list_inboxes_success(self):
|
||||
mock_mod, mock_client = self._mock_agentmail_module()
|
||||
|
||||
mock_inbox = MagicMock()
|
||||
mock_inbox.inbox_id = "[email protected]"
|
||||
mock_inbox.display_name = "Praison"
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.inboxes = [mock_inbox]
|
||||
mock_client.inboxes.list.return_value = mock_response
|
||||
|
||||
with patch.dict("sys.modules", {"agentmail": mock_mod}):
|
||||
from praisonaiagents.tools.email_tools import list_inboxes
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
|
||||
result = list_inboxes()
|
||||
|
||||
self.assertIn("[email protected]", result)
|
||||
self.assertIn("Praison", result)
|
||||
|
||||
# ── Missing env vars ────────────────────────────────────────
|
||||
|
||||
def test_missing_api_key_raises(self):
|
||||
os.environ.pop("AGENTMAIL_API_KEY", None)
|
||||
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
et._get_client()
|
||||
self.assertIn("AGENTMAIL_API_KEY", str(ctx.exception))
|
||||
|
||||
def test_missing_inbox_id_raises(self):
|
||||
os.environ.pop("AGENTMAIL_INBOX_ID", None)
|
||||
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
et._get_inbox_id()
|
||||
self.assertIn("AGENTMAIL_INBOX_ID", str(ctx.exception))
|
||||
|
||||
# ── Profile registration ────────────────────────────────────
|
||||
|
||||
def test_email_profile_exists(self):
|
||||
from praisonaiagents.tools.profiles import get_profile
|
||||
profile = get_profile("email")
|
||||
self.assertEqual(profile.name, "email")
|
||||
self.assertIn("send_email", profile.tools)
|
||||
self.assertIn("list_emails", profile.tools)
|
||||
self.assertIn("read_email", profile.tools)
|
||||
|
||||
# ── Lazy import via tools package ───────────────────────────
|
||||
|
||||
def test_lazy_import_from_tools_package(self):
|
||||
from praisonaiagents.tools import send_email
|
||||
from praisonaiagents.tools.email_tools import send_email as direct
|
||||
self.assertIs(send_email, direct)
|
||||
|
||||
# ── reply_email ──────────────────────────────────────────────
|
||||
|
||||
def test_reply_email_success(self):
|
||||
mock_mod, mock_client = self._mock_agentmail_module()
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.message_id = "reply_msg_1"
|
||||
mock_result.thread_id = "thread_1"
|
||||
mock_client.inboxes.messages.reply.return_value = mock_result
|
||||
|
||||
with patch.dict("sys.modules", {"agentmail": mock_mod}):
|
||||
from praisonaiagents.tools.email_tools import reply_email
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
|
||||
result = reply_email(message_id="msg_100", body="Thanks!")
|
||||
|
||||
self.assertIn("reply_msg_1", result)
|
||||
self.assertIn("Reply sent", result)
|
||||
mock_client.inboxes.messages.reply.assert_called_once_with(
|
||||
"[email protected]",
|
||||
"msg_100",
|
||||
text="Thanks!",
|
||||
)
|
||||
|
||||
def test_reply_email_uses_text_not_body(self):
|
||||
"""SDK reply uses text= not body= parameter."""
|
||||
mock_mod, mock_client = self._mock_agentmail_module()
|
||||
mock_result = MagicMock()
|
||||
mock_result.message_id = "r1"
|
||||
mock_result.thread_id = "t1"
|
||||
mock_client.inboxes.messages.reply.return_value = mock_result
|
||||
|
||||
with patch.dict("sys.modules", {"agentmail": mock_mod}):
|
||||
from praisonaiagents.tools.email_tools import reply_email
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
|
||||
reply_email(message_id="m1", body="b")
|
||||
|
||||
call_kwargs = mock_client.inboxes.messages.reply.call_args
|
||||
self.assertIn("text", call_kwargs.kwargs)
|
||||
self.assertNotIn("body", call_kwargs.kwargs)
|
||||
|
||||
# ── create_inbox ─────────────────────────────────────────────
|
||||
|
||||
def test_create_inbox_success(self):
|
||||
mock_mod, mock_client = self._mock_agentmail_module()
|
||||
|
||||
mock_inbox = MagicMock()
|
||||
mock_inbox.inbox_id = "[email protected]"
|
||||
mock_inbox.display_name = "My Agent"
|
||||
mock_client.inboxes.create.return_value = mock_inbox
|
||||
|
||||
with patch.dict("sys.modules", {"agentmail": mock_mod}):
|
||||
from praisonaiagents.tools.email_tools import create_inbox
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
|
||||
result = create_inbox(display_name="My Agent")
|
||||
|
||||
self.assertIn("[email protected]", result)
|
||||
self.assertIn("created", result.lower())
|
||||
|
||||
# ── Angle-bracket normalization ──────────────────────────────
|
||||
|
||||
def test_read_email_angle_bracket_normalization(self):
|
||||
"""LLMs strip angle brackets from message IDs. read_email should add them."""
|
||||
mock_mod, mock_client = self._mock_agentmail_module()
|
||||
|
||||
mock_msg = MagicMock()
|
||||
mock_msg.from_ = "[email protected]"
|
||||
mock_msg.to = ["[email protected]"]
|
||||
mock_msg.subject = "Test"
|
||||
mock_msg.extracted_text = "Body"
|
||||
mock_msg.timestamp = "now"
|
||||
mock_msg.in_reply_to = ""
|
||||
mock_client.inboxes.messages.get.return_value = mock_msg
|
||||
|
||||
with patch.dict("sys.modules", {"agentmail": mock_mod}):
|
||||
from praisonaiagents.tools.email_tools import read_email
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
|
||||
# Pass WITHOUT angle brackets — should be normalized
|
||||
read_email(message_id="[email protected]")
|
||||
|
||||
# The API call should have angle brackets
|
||||
call_args = mock_client.inboxes.messages.get.call_args
|
||||
actual_id = call_args[0][1] # second positional arg
|
||||
self.assertTrue(actual_id.startswith("<"), f"Expected <...> but got: {actual_id}")
|
||||
self.assertTrue(actual_id.endswith(">"), f"Expected <...> but got: {actual_id}")
|
||||
|
||||
# ── SMTP tools error handling ────────────────────────────────
|
||||
|
||||
def test_smtp_send_email_missing_creds(self):
|
||||
os.environ.pop("EMAIL_ADDRESS", None)
|
||||
os.environ.pop("EMAIL_PASSWORD", None)
|
||||
from praisonaiagents.tools.email_tools import smtp_send_email
|
||||
result = smtp_send_email(to="[email protected]", subject="s", body="b")
|
||||
self.assertIn("EMAIL_ADDRESS", result)
|
||||
self.assertIn("EMAIL_PASSWORD", result)
|
||||
|
||||
def test_smtp_read_inbox_missing_creds(self):
|
||||
os.environ.pop("EMAIL_ADDRESS", None)
|
||||
os.environ.pop("EMAIL_PASSWORD", None)
|
||||
from praisonaiagents.tools.email_tools import smtp_read_inbox
|
||||
result = smtp_read_inbox()
|
||||
self.assertIn("EMAIL_ADDRESS", result)
|
||||
self.assertIn("EMAIL_PASSWORD", result)
|
||||
|
||||
# ── SMTP profile registration ────────────────────────────────
|
||||
|
||||
def test_smtp_email_profile_exists(self):
|
||||
from praisonaiagents.tools.profiles import get_profile
|
||||
profile = get_profile("smtp_email")
|
||||
self.assertEqual(profile.name, "smtp_email")
|
||||
self.assertIn("smtp_send_email", profile.tools)
|
||||
self.assertIn("smtp_read_inbox", profile.tools)
|
||||
|
||||
# ── SMTP lazy import ─────────────────────────────────────────
|
||||
|
||||
def test_smtp_lazy_import_from_tools_package(self):
|
||||
from praisonaiagents.tools import smtp_send_email
|
||||
from praisonaiagents.tools.email_tools import smtp_send_email as direct
|
||||
self.assertIs(smtp_send_email, direct)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -434,7 +434,7 @@ callbacks:
|
||||
|
||||
|
||||
class TestYAMLWorkflowParserGuardrails:
|
||||
"""Tests for guardrails parsing."""
|
||||
"""Tests for guardrails parsing and wiring to Task objects."""
|
||||
|
||||
def test_parse_step_guardrail(self):
|
||||
"""Test parsing guardrail on a step."""
|
||||
@@ -459,6 +459,167 @@ steps:
|
||||
workflow = parser.parse_string(yaml_content)
|
||||
|
||||
assert workflow is not None
|
||||
|
||||
def test_guardrail_wired_to_task(self):
|
||||
"""Test that guardrail string from YAML is wired into the Task object."""
|
||||
from praisonaiagents.workflows import YAMLWorkflowParser
|
||||
|
||||
yaml_content = """
|
||||
name: Guardrail Wiring Test
|
||||
agents:
|
||||
writer:
|
||||
name: Writer
|
||||
role: Writer
|
||||
goal: Write
|
||||
instructions: "Write content"
|
||||
|
||||
steps:
|
||||
- agent: writer
|
||||
action: "Write professional content"
|
||||
guardrail: "Ensure the content is professional and error-free"
|
||||
"""
|
||||
parser = YAMLWorkflowParser()
|
||||
workflow = parser.parse_string(yaml_content)
|
||||
|
||||
# Normalize the step to a Task (this is what run() does internally)
|
||||
task = workflow._normalize_single_step(workflow.steps[0], 0)
|
||||
|
||||
# The guardrail string must be set on the Task
|
||||
assert task.guardrail == "Ensure the content is professional and error-free"
|
||||
# _setup_guardrail should have created an LLMGuardrail instance
|
||||
assert task._guardrail_fn is not None
|
||||
|
||||
def test_max_retries_wired_to_task(self):
|
||||
"""Test that max_retries from YAML is wired into the Task object."""
|
||||
from praisonaiagents.workflows import YAMLWorkflowParser
|
||||
|
||||
yaml_content = """
|
||||
name: Retries Wiring Test
|
||||
agents:
|
||||
writer:
|
||||
name: Writer
|
||||
role: Writer
|
||||
goal: Write
|
||||
instructions: "Write content"
|
||||
|
||||
steps:
|
||||
- agent: writer
|
||||
action: "Write content"
|
||||
max_retries: 7
|
||||
"""
|
||||
parser = YAMLWorkflowParser()
|
||||
workflow = parser.parse_string(yaml_content)
|
||||
task = workflow._normalize_single_step(workflow.steps[0], 0)
|
||||
|
||||
assert task.max_retries == 7
|
||||
|
||||
def test_expected_output_wired_to_task(self):
|
||||
"""Test that expected_output from YAML is wired into the Task object."""
|
||||
from praisonaiagents.workflows import YAMLWorkflowParser
|
||||
|
||||
yaml_content = """
|
||||
name: Expected Output Wiring Test
|
||||
agents:
|
||||
writer:
|
||||
name: Writer
|
||||
role: Writer
|
||||
goal: Write
|
||||
instructions: "Write content"
|
||||
|
||||
steps:
|
||||
- agent: writer
|
||||
action: "Write a blog post"
|
||||
expected_output: "A well-structured blog post with introduction, body, and conclusion"
|
||||
"""
|
||||
parser = YAMLWorkflowParser()
|
||||
workflow = parser.parse_string(yaml_content)
|
||||
task = workflow._normalize_single_step(workflow.steps[0], 0)
|
||||
|
||||
assert task.expected_output == "A well-structured blog post with introduction, body, and conclusion"
|
||||
|
||||
def test_skip_on_failure_wired_to_task(self):
|
||||
"""Test that skip_on_failure from YAML is wired into the Task object."""
|
||||
from praisonaiagents.workflows import YAMLWorkflowParser
|
||||
|
||||
yaml_content = """
|
||||
name: Skip Failure Wiring Test
|
||||
agents:
|
||||
writer:
|
||||
name: Writer
|
||||
role: Writer
|
||||
goal: Write
|
||||
instructions: "Write content"
|
||||
|
||||
steps:
|
||||
- agent: writer
|
||||
action: "Write content"
|
||||
skip_on_failure: true
|
||||
"""
|
||||
parser = YAMLWorkflowParser()
|
||||
workflow = parser.parse_string(yaml_content)
|
||||
task = workflow._normalize_single_step(workflow.steps[0], 0)
|
||||
|
||||
assert task.skip_on_failure == True
|
||||
|
||||
def test_output_file_wired_to_task(self):
|
||||
"""Test that output_file from YAML is wired into the Task object."""
|
||||
from praisonaiagents.workflows import YAMLWorkflowParser
|
||||
|
||||
yaml_content = """
|
||||
name: Output File Wiring Test
|
||||
agents:
|
||||
writer:
|
||||
name: Writer
|
||||
role: Writer
|
||||
goal: Write
|
||||
instructions: "Write content"
|
||||
|
||||
steps:
|
||||
- agent: writer
|
||||
action: "Write content"
|
||||
output_file: "output/result.txt"
|
||||
"""
|
||||
parser = YAMLWorkflowParser()
|
||||
workflow = parser.parse_string(yaml_content)
|
||||
task = workflow._normalize_single_step(workflow.steps[0], 0)
|
||||
|
||||
assert task.output_file == "output/result.txt"
|
||||
|
||||
def test_all_yaml_step_attrs_wired(self):
|
||||
"""Test that ALL YAML step-level attributes are wired through to Task."""
|
||||
from praisonaiagents.workflows import YAMLWorkflowParser
|
||||
|
||||
yaml_content = """
|
||||
name: Full Wiring Test
|
||||
agents:
|
||||
writer:
|
||||
name: Writer
|
||||
role: Writer
|
||||
goal: Write
|
||||
instructions: "Write content"
|
||||
|
||||
steps:
|
||||
- agent: writer
|
||||
action: "Write content"
|
||||
guardrail: "Check quality"
|
||||
max_retries: 5
|
||||
expected_output: "High quality content"
|
||||
output_file: "out.txt"
|
||||
skip_on_failure: true
|
||||
retry_delay: 2.5
|
||||
create_directory: true
|
||||
"""
|
||||
parser = YAMLWorkflowParser()
|
||||
workflow = parser.parse_string(yaml_content)
|
||||
task = workflow._normalize_single_step(workflow.steps[0], 0)
|
||||
|
||||
assert task.guardrail == "Check quality"
|
||||
assert task.max_retries == 5
|
||||
assert task.expected_output == "High quality content"
|
||||
assert task.output_file == "out.txt"
|
||||
assert task.skip_on_failure == True
|
||||
assert task.retry_delay == 2.5
|
||||
assert task.create_directory == True
|
||||
|
||||
|
||||
class TestYAMLWorkflowParserVariables:
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
"""
|
||||
TDD tests for autonomy mode redesign.
|
||||
|
||||
Tests the new `mode` field in AutonomyConfig and the caller-mode behavior
|
||||
where autonomy=True does a single chat() call instead of the internal loop.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 1. AutonomyConfig mode field tests
|
||||
# ============================================================================
|
||||
|
||||
class TestAutonomyConfigMode:
|
||||
"""Tests for the 'mode' field on AutonomyConfig."""
|
||||
|
||||
def test_mode_field_exists(self):
|
||||
"""AutonomyConfig should have a mode field."""
|
||||
from praisonaiagents.agent.autonomy import AutonomyConfig
|
||||
config = AutonomyConfig()
|
||||
assert hasattr(config, 'mode'), "AutonomyConfig must have a 'mode' field"
|
||||
|
||||
def test_default_mode_is_caller_for_suggest(self):
|
||||
"""Default level='suggest' should default to mode='caller'."""
|
||||
from praisonaiagents.agent.autonomy import AutonomyConfig
|
||||
config = AutonomyConfig(level="suggest")
|
||||
assert config.mode == "caller"
|
||||
|
||||
def test_default_mode_is_caller_for_auto_edit(self):
|
||||
"""level='auto_edit' should default to mode='caller'."""
|
||||
from praisonaiagents.agent.autonomy import AutonomyConfig
|
||||
config = AutonomyConfig(level="auto_edit")
|
||||
assert config.mode == "caller"
|
||||
|
||||
def test_default_mode_is_iterative_for_full_auto(self):
|
||||
"""level='full_auto' should default to mode='iterative' (backward compat)."""
|
||||
from praisonaiagents.agent.autonomy import AutonomyConfig
|
||||
config = AutonomyConfig(level="full_auto")
|
||||
assert config.mode == "iterative"
|
||||
|
||||
def test_explicit_mode_overrides_default(self):
|
||||
"""Explicit mode should override the level-based default."""
|
||||
from praisonaiagents.agent.autonomy import AutonomyConfig
|
||||
config = AutonomyConfig(level="full_auto", mode="caller")
|
||||
assert config.mode == "caller"
|
||||
|
||||
def test_explicit_iterative_with_suggest(self):
|
||||
"""Should be able to set mode='iterative' even with level='suggest'."""
|
||||
from praisonaiagents.agent.autonomy import AutonomyConfig
|
||||
config = AutonomyConfig(level="suggest", mode="iterative")
|
||||
assert config.mode == "iterative"
|
||||
|
||||
def test_invalid_mode_raises(self):
|
||||
"""Invalid mode value should raise ValueError."""
|
||||
from praisonaiagents.agent.autonomy import AutonomyConfig
|
||||
with pytest.raises(ValueError, match="Invalid autonomy mode"):
|
||||
AutonomyConfig(mode="invalid_mode")
|
||||
|
||||
def test_from_dict_with_mode(self):
|
||||
"""AutonomyConfig.from_dict should parse mode field."""
|
||||
from praisonaiagents.agent.autonomy import AutonomyConfig
|
||||
config = AutonomyConfig.from_dict({"mode": "iterative"})
|
||||
assert config.mode == "iterative"
|
||||
|
||||
def test_from_dict_without_mode_uses_smart_default(self):
|
||||
"""from_dict without mode should use smart default based on level."""
|
||||
from praisonaiagents.agent.autonomy import AutonomyConfig
|
||||
config = AutonomyConfig.from_dict({"level": "full_auto"})
|
||||
assert config.mode == "iterative"
|
||||
|
||||
def test_from_dict_suggest_defaults_to_caller(self):
|
||||
"""from_dict with suggest level defaults to caller mode."""
|
||||
from praisonaiagents.agent.autonomy import AutonomyConfig
|
||||
config = AutonomyConfig.from_dict({"level": "suggest"})
|
||||
assert config.mode == "caller"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 2. Agent _init_autonomy stores mode in autonomy_config dict
|
||||
# ============================================================================
|
||||
|
||||
class TestAgentAutonomyModeInit:
|
||||
"""Tests that Agent._init_autonomy correctly stores mode."""
|
||||
|
||||
def _make_agent(self, **autonomy_kwargs):
|
||||
"""Helper to create a minimal agent with autonomy config."""
|
||||
from praisonaiagents import Agent
|
||||
return Agent(
|
||||
name="test_mode",
|
||||
instructions="test",
|
||||
autonomy=autonomy_kwargs if autonomy_kwargs else True,
|
||||
)
|
||||
|
||||
def test_autonomy_true_stores_mode(self):
|
||||
"""Agent(autonomy=True) should store mode in autonomy_config."""
|
||||
agent = self._make_agent()
|
||||
assert "mode" in agent.autonomy_config, "autonomy_config must contain 'mode'"
|
||||
|
||||
def test_autonomy_true_default_mode_is_caller(self):
|
||||
"""Agent(autonomy=True) defaults to level='suggest', so mode='caller'."""
|
||||
agent = self._make_agent()
|
||||
assert agent.autonomy_config["mode"] == "caller"
|
||||
|
||||
def test_autonomy_dict_full_auto_mode_iterative(self):
|
||||
"""Agent(autonomy={'level':'full_auto'}) should default to mode='iterative'."""
|
||||
agent = self._make_agent(level="full_auto")
|
||||
assert agent.autonomy_config["mode"] == "iterative"
|
||||
|
||||
def test_autonomy_dict_explicit_mode(self):
|
||||
"""Agent(autonomy={'mode':'iterative'}) should store explicit mode."""
|
||||
agent = self._make_agent(mode="iterative")
|
||||
assert agent.autonomy_config["mode"] == "iterative"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 3. start() caller-mode behavior
|
||||
# ============================================================================
|
||||
|
||||
class TestStartCallerMode:
|
||||
"""Tests that start() in caller mode does a single chat() call."""
|
||||
|
||||
@patch("praisonaiagents.agent.agent.Agent.chat")
|
||||
def test_caller_mode_calls_chat_once(self, mock_chat):
|
||||
"""In caller mode, start() should call chat() exactly once."""
|
||||
mock_chat.return_value = "Hello! I'm here to help."
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(
|
||||
name="test_caller",
|
||||
instructions="You are helpful",
|
||||
autonomy=True, # level=suggest → mode=caller
|
||||
)
|
||||
result = agent.start("Say hello")
|
||||
|
||||
mock_chat.assert_called_once()
|
||||
|
||||
@patch("praisonaiagents.agent.agent.Agent.chat")
|
||||
def test_caller_mode_does_not_call_run_autonomous(self, mock_chat):
|
||||
"""In caller mode, start() must NOT call run_autonomous."""
|
||||
mock_chat.return_value = "Done"
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(
|
||||
name="test_no_loop",
|
||||
instructions="You are helpful",
|
||||
autonomy=True,
|
||||
)
|
||||
with patch.object(agent, 'run_autonomous') as mock_autonomous:
|
||||
agent.start("Do something")
|
||||
mock_autonomous.assert_not_called()
|
||||
|
||||
@patch("praisonaiagents.agent.agent.Agent.chat")
|
||||
def test_caller_mode_returns_autonomy_result(self, mock_chat):
|
||||
"""In caller mode, start() should return AutonomyResult."""
|
||||
mock_chat.return_value = "Task completed successfully"
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.agent.autonomy import AutonomyResult
|
||||
agent = Agent(
|
||||
name="test_result",
|
||||
instructions="You are helpful",
|
||||
autonomy=True,
|
||||
)
|
||||
result = agent.start("Do task")
|
||||
|
||||
assert isinstance(result, AutonomyResult)
|
||||
assert result.iterations == 1
|
||||
assert result.success is True
|
||||
assert result.completion_reason == "caller_mode"
|
||||
assert "Task completed" in str(result)
|
||||
|
||||
@patch("praisonaiagents.agent.agent.Agent.chat")
|
||||
def test_caller_mode_preserves_auto_approve(self, mock_chat):
|
||||
"""Caller mode should still have auto-approve wired."""
|
||||
mock_chat.return_value = "Done"
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(
|
||||
name="test_approve",
|
||||
instructions="test",
|
||||
autonomy={"level": "auto_edit"},
|
||||
)
|
||||
# auto_edit should wire approval
|
||||
assert agent.autonomy_enabled is True
|
||||
assert agent.autonomy_config.get("mode") == "caller"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 4. start() iterative-mode behavior (backward compat for full_auto)
|
||||
# ============================================================================
|
||||
|
||||
class TestStartIterativeMode:
|
||||
"""Tests that start() in iterative mode uses run_autonomous (backward compat)."""
|
||||
|
||||
@patch("praisonaiagents.agent.agent.Agent.run_autonomous")
|
||||
def test_iterative_mode_calls_run_autonomous(self, mock_run):
|
||||
"""In iterative mode, start() should call run_autonomous."""
|
||||
from praisonaiagents.agent.autonomy import AutonomyResult
|
||||
mock_run.return_value = AutonomyResult(
|
||||
success=True, output="Done", completion_reason="goal",
|
||||
iterations=1, stage="direct", actions=[], duration_seconds=1.0,
|
||||
)
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(
|
||||
name="test_iterative",
|
||||
instructions="test",
|
||||
autonomy={"level": "full_auto"}, # defaults to iterative
|
||||
)
|
||||
agent.start("Do something complex")
|
||||
mock_run.assert_called_once()
|
||||
|
||||
@patch("praisonaiagents.agent.agent.Agent.run_autonomous")
|
||||
def test_explicit_iterative_mode_with_suggest(self, mock_run):
|
||||
"""Explicit mode='iterative' should use run_autonomous."""
|
||||
from praisonaiagents.agent.autonomy import AutonomyResult
|
||||
mock_run.return_value = AutonomyResult(
|
||||
success=True, output="Done", completion_reason="goal",
|
||||
iterations=1, stage="direct", actions=[], duration_seconds=1.0,
|
||||
)
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(
|
||||
name="test_explicit_iterative",
|
||||
instructions="test",
|
||||
autonomy={"level": "suggest", "mode": "iterative"},
|
||||
)
|
||||
agent.start("Ralph loop task")
|
||||
mock_run.assert_called_once()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 5. astart() caller-mode behavior (async parity)
|
||||
# ============================================================================
|
||||
|
||||
class TestAstartCallerMode:
|
||||
"""Tests that astart() in caller mode does a single achat() call."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_caller_mode_achat_once(self):
|
||||
"""In caller mode, astart() should call achat() exactly once."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.agent.autonomy import AutonomyResult
|
||||
agent = Agent(
|
||||
name="test_async_caller",
|
||||
instructions="You are helpful",
|
||||
autonomy=True,
|
||||
)
|
||||
with patch.object(agent, 'achat', return_value="Hello async!") as mock_achat:
|
||||
result = await agent.astart("Say hello")
|
||||
mock_achat.assert_called_once()
|
||||
assert isinstance(result, AutonomyResult)
|
||||
assert result.iterations == 1
|
||||
@@ -2,15 +2,10 @@
|
||||
TDD tests for unified autonomy API.
|
||||
|
||||
Goal: Agent(autonomy=True) + agent.start("Task") should automatically
|
||||
use the autonomous loop. No need to call run_autonomous() separately.
|
||||
|
||||
Current (redundant):
|
||||
agent = Agent(autonomy=True)
|
||||
result = agent.run_autonomous("Task") # Why two steps?
|
||||
|
||||
Proposed (unified):
|
||||
agent = Agent(autonomy=True)
|
||||
result = agent.start("Task") # Automatically uses autonomy loop!
|
||||
use the appropriate mode based on config:
|
||||
- level=suggest (default) → caller mode (single chat call)
|
||||
- level=full_auto → iterative mode (run_autonomous loop)
|
||||
- explicit mode overrides level-based default
|
||||
"""
|
||||
|
||||
import pytest
|
||||
@@ -21,16 +16,11 @@ class TestUnifiedAutonomyAPI:
|
||||
"""Test that start() automatically uses autonomy when enabled."""
|
||||
|
||||
def test_start_uses_autonomy_when_enabled(self):
|
||||
"""start() should route to run_autonomous when autonomy=True."""
|
||||
"""start() should use caller mode (chat) when autonomy=True (default level=suggest)."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
with patch.object(Agent, 'run_autonomous') as mock_run_auto:
|
||||
mock_run_auto.return_value = MagicMock(
|
||||
success=True,
|
||||
output="Task completed",
|
||||
completion_reason="goal",
|
||||
iterations=1,
|
||||
)
|
||||
with patch.object(Agent, 'chat') as mock_chat:
|
||||
mock_chat.return_value = "Task completed"
|
||||
|
||||
agent = Agent(
|
||||
name="test",
|
||||
@@ -41,10 +31,11 @@ class TestUnifiedAutonomyAPI:
|
||||
|
||||
result = agent.start("Do something")
|
||||
|
||||
# Should have called run_autonomous, not chat
|
||||
mock_run_auto.assert_called_once()
|
||||
call_kwargs = mock_run_auto.call_args[1]
|
||||
assert call_kwargs.get("prompt") == "Do something"
|
||||
# With default level=suggest, should use caller mode (chat)
|
||||
mock_chat.assert_called_once()
|
||||
from praisonaiagents.agent.autonomy import AutonomyResult
|
||||
assert isinstance(result, AutonomyResult)
|
||||
assert result.completion_reason == "caller_mode"
|
||||
|
||||
def test_start_uses_chat_when_autonomy_disabled(self):
|
||||
"""start() should use regular chat when autonomy is not enabled."""
|
||||
@@ -67,18 +58,12 @@ class TestUnifiedAutonomyAPI:
|
||||
mock_chat.assert_called()
|
||||
|
||||
def test_start_returns_autonomy_result_when_autonomy_enabled(self):
|
||||
"""start() should return AutonomyResult when autonomy is enabled."""
|
||||
"""start() should return AutonomyResult when autonomy is enabled (caller mode)."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.agent.autonomy import AutonomyResult
|
||||
|
||||
with patch.object(Agent, 'run_autonomous') as mock_run_auto:
|
||||
mock_result = AutonomyResult(
|
||||
success=True,
|
||||
output="Done!",
|
||||
completion_reason="goal",
|
||||
iterations=2,
|
||||
)
|
||||
mock_run_auto.return_value = mock_result
|
||||
with patch.object(Agent, 'chat') as mock_chat:
|
||||
mock_chat.return_value = "Done!"
|
||||
|
||||
agent = Agent(
|
||||
name="test",
|
||||
@@ -94,7 +79,7 @@ class TestUnifiedAutonomyAPI:
|
||||
assert result.output == "Done!"
|
||||
|
||||
def test_start_passes_autonomy_config_to_run_autonomous(self):
|
||||
"""start() should pass autonomy config values to run_autonomous."""
|
||||
"""start() should pass autonomy config values to run_autonomous in iterative mode."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
with patch.object(Agent, 'run_autonomous') as mock_run_auto:
|
||||
@@ -107,6 +92,7 @@ class TestUnifiedAutonomyAPI:
|
||||
"max_iterations": 15,
|
||||
"completion_promise": "DONE",
|
||||
"clear_context": True,
|
||||
"mode": "iterative", # Explicit iterative mode
|
||||
},
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
@@ -119,7 +105,7 @@ class TestUnifiedAutonomyAPI:
|
||||
assert call_kwargs.get("clear_context") is True
|
||||
|
||||
def test_start_timeout_passed_to_run_autonomous(self):
|
||||
"""start(timeout=X) should pass timeout to run_autonomous."""
|
||||
"""start(timeout=X) should pass timeout to run_autonomous in iterative mode."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
with patch.object(Agent, 'run_autonomous') as mock_run_auto:
|
||||
@@ -128,7 +114,7 @@ class TestUnifiedAutonomyAPI:
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
autonomy=True,
|
||||
autonomy={"mode": "iterative"}, # Must be iterative for run_autonomous
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
|
||||
@@ -218,16 +204,12 @@ class TestAsyncUnifiedAPI:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_astart_uses_autonomy_when_enabled(self):
|
||||
"""astart() should route to run_autonomous_async when autonomy=True."""
|
||||
"""astart() should use caller mode (achat) when autonomy=True."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.agent.autonomy import AutonomyResult
|
||||
|
||||
with patch.object(Agent, 'run_autonomous_async') as mock_run_auto:
|
||||
mock_run_auto.return_value = MagicMock(
|
||||
success=True,
|
||||
output="Task completed",
|
||||
completion_reason="goal",
|
||||
iterations=1,
|
||||
)
|
||||
with patch.object(Agent, 'achat') as mock_achat:
|
||||
mock_achat.return_value = "Task completed"
|
||||
|
||||
agent = Agent(
|
||||
name="test",
|
||||
@@ -238,7 +220,8 @@ class TestAsyncUnifiedAPI:
|
||||
|
||||
result = await agent.astart("Do something")
|
||||
|
||||
mock_run_auto.assert_called_once()
|
||||
mock_achat.assert_called_once()
|
||||
assert isinstance(result, AutonomyResult)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_astart_uses_achat_when_autonomy_disabled(self):
|
||||
@@ -264,7 +247,7 @@ class TestCLISimplification:
|
||||
"""Test that CLI can use simplified API."""
|
||||
|
||||
def test_cli_can_use_start_for_loop(self):
|
||||
"""CLI loop command should be able to use agent.start() directly."""
|
||||
"""CLI loop command should be able to use agent.start() in iterative mode."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
with patch.object(Agent, 'run_autonomous') as mock_run_auto:
|
||||
@@ -277,13 +260,14 @@ class TestCLISimplification:
|
||||
started_at="2024-01-01T00:00:00Z",
|
||||
)
|
||||
|
||||
# This is what CLI should do - simple!
|
||||
# CLI loop must explicitly set mode="iterative"
|
||||
agent = Agent(
|
||||
name="loop_agent",
|
||||
instructions="Complete the task",
|
||||
autonomy={
|
||||
"max_iterations": 10,
|
||||
"completion_promise": "DONE",
|
||||
"mode": "iterative", # Explicit for loop
|
||||
},
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
|
||||
@@ -397,5 +397,57 @@ class TestBackgroundRunner:
|
||||
assert all(t.status == TaskStatus.CANCELLED for t in runner.tasks)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# BackgroundRunner Sync-Wrapper Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestBackgroundRunnerSync:
|
||||
"""Tests for submit_sync / submit_agent_sync methods."""
|
||||
|
||||
def test_submit_sync_function(self):
|
||||
"""submit_sync runs a plain function and returns a trackable task."""
|
||||
runner = BackgroundRunner()
|
||||
|
||||
def double(x):
|
||||
return x * 2
|
||||
|
||||
task = runner.submit_sync(func=double, args=(7,), name="sync-double")
|
||||
assert task is not None
|
||||
assert task.name == "sync-double"
|
||||
|
||||
# Wait for the result (polling).
|
||||
import time
|
||||
for _ in range(50):
|
||||
if task.is_completed:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
assert task.is_successful
|
||||
assert task.result == 14
|
||||
|
||||
def test_submit_agent_sync(self):
|
||||
"""submit_agent_sync dispatches a mock agent start()."""
|
||||
runner = BackgroundRunner()
|
||||
|
||||
class FakeAgent:
|
||||
name = "faker"
|
||||
def start(self, prompt):
|
||||
return f"echo: {prompt}"
|
||||
|
||||
agent = FakeAgent()
|
||||
task = runner.submit_agent_sync(agent, "hello", name="sync-agent")
|
||||
assert task is not None
|
||||
|
||||
import time
|
||||
for _ in range(50):
|
||||
if task.is_completed:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
assert task.is_successful
|
||||
assert task.result == "echo: hello"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"""TDD Tests for top-level learn= parameter on Agent and related classes.
|
||||
|
||||
Tests that learn= is a first-class citizen, peer to memory=, not nested inside it.
|
||||
"""
|
||||
|
||||
|
||||
class TestAgentLearnParam:
|
||||
"""Test Agent(learn=...) top-level parameter."""
|
||||
|
||||
def test_agent_accepts_learn_false(self):
|
||||
"""Agent should accept learn=False (default, no learning)."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
learn=False,
|
||||
)
|
||||
|
||||
assert agent._learn_config is None or agent._learn_config is False
|
||||
|
||||
def test_agent_accepts_learn_true(self):
|
||||
"""Agent should accept learn=True (enable with defaults)."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
learn=True,
|
||||
)
|
||||
|
||||
# Should have learn config enabled
|
||||
assert agent._learn_config is not None
|
||||
assert agent._learn_config is not False
|
||||
|
||||
def test_agent_accepts_learn_config(self):
|
||||
"""Agent should accept learn=LearnConfig(...)."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.config.feature_configs import LearnConfig
|
||||
|
||||
config = LearnConfig(persona=True, insights=True, patterns=False)
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
learn=config,
|
||||
)
|
||||
|
||||
# Should store the config
|
||||
assert agent._learn_config is not None
|
||||
if hasattr(agent._learn_config, 'persona'):
|
||||
assert agent._learn_config.persona is True
|
||||
assert agent._learn_config.insights is True
|
||||
|
||||
def test_learn_param_independent_of_memory(self):
|
||||
"""learn= should work without memory= being set."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# learn=True without memory=True should still work
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
learn=True,
|
||||
memory=None, # No memory explicitly
|
||||
)
|
||||
|
||||
# Learn should be enabled even without explicit memory
|
||||
assert agent._learn_config is not None
|
||||
|
||||
def test_learn_and_memory_both_work(self):
|
||||
"""learn= and memory= should work together."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
learn=True,
|
||||
memory=True,
|
||||
)
|
||||
|
||||
# Both should be enabled
|
||||
assert agent._learn_config is not None
|
||||
assert agent._memory_instance is not None
|
||||
|
||||
|
||||
class TestLearnConfigExport:
|
||||
"""Test that LearnConfig is exported from top-level package."""
|
||||
|
||||
def test_learn_config_exported(self):
|
||||
"""LearnConfig should be importable from praisonaiagents."""
|
||||
from praisonaiagents import LearnConfig
|
||||
|
||||
assert LearnConfig is not None
|
||||
|
||||
# Should be able to instantiate
|
||||
config = LearnConfig()
|
||||
assert config.persona is True # Default
|
||||
assert config.insights is True # Default
|
||||
|
||||
def test_learn_scope_exported(self):
|
||||
"""LearnScope should be importable from praisonaiagents."""
|
||||
from praisonaiagents import LearnScope
|
||||
|
||||
assert LearnScope is not None
|
||||
assert LearnScope.PRIVATE.value == "private"
|
||||
assert LearnScope.SHARED.value == "shared"
|
||||
|
||||
|
||||
class TestAgentTeamLearnParam:
|
||||
"""Test AgentTeam(learn=...) parameter."""
|
||||
|
||||
def test_agent_team_accepts_learn_param(self):
|
||||
"""AgentTeam should accept learn= parameter."""
|
||||
from praisonaiagents import Agent, AgentTeam
|
||||
|
||||
agent1 = Agent(name="agent1", instructions="Agent 1")
|
||||
agent2 = Agent(name="agent2", instructions="Agent 2")
|
||||
|
||||
team = AgentTeam(
|
||||
agents=[agent1, agent2],
|
||||
learn=True,
|
||||
)
|
||||
|
||||
# Should store learn config
|
||||
assert hasattr(team, '_learn') or hasattr(team, 'learn')
|
||||
|
||||
|
||||
class TestWorkflowLearnParam:
|
||||
"""Test Workflow/AgentFlow(learn=...) parameter."""
|
||||
|
||||
def test_workflow_accepts_learn_param(self):
|
||||
"""Workflow should accept learn= parameter."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.workflows import Workflow
|
||||
|
||||
agent = Agent(name="test", instructions="Test")
|
||||
|
||||
workflow = Workflow(
|
||||
steps=[agent], # Workflow uses steps=, not agents=
|
||||
learn=True,
|
||||
)
|
||||
|
||||
# Should store learn config (dataclass field)
|
||||
assert workflow.learn is True
|
||||
|
||||
|
||||
class TestLearnParamDocstring:
|
||||
"""Test that learn= param is documented."""
|
||||
|
||||
def test_agent_docstring_mentions_learn(self):
|
||||
"""Agent.__init__ docstring should mention learn parameter."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
docstring = Agent.__init__.__doc__ or ""
|
||||
|
||||
# Should mention learn parameter
|
||||
assert "learn" in docstring.lower()
|
||||
|
||||
|
||||
class TestBackwardCompatibility:
|
||||
"""Test backward compatibility with memory=MemoryConfig(learn=...)."""
|
||||
|
||||
def test_memory_config_learn_still_works(self):
|
||||
"""memory=MemoryConfig(learn=True) should still work."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.config.feature_configs import MemoryConfig
|
||||
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
memory=MemoryConfig(learn=True),
|
||||
)
|
||||
|
||||
# Should have memory with learn enabled
|
||||
assert agent._memory_instance is not None
|
||||
# Learn context should be accessible
|
||||
if hasattr(agent._memory_instance, 'get_learn_context'):
|
||||
context = agent._memory_instance.get_learn_context()
|
||||
assert isinstance(context, str)
|
||||
|
||||
def test_top_level_learn_takes_precedence(self):
|
||||
"""Top-level learn= should take precedence over memory.learn."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.config.feature_configs import MemoryConfig
|
||||
|
||||
# Top-level learn=False should override memory.learn=True
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
memory=MemoryConfig(learn=True),
|
||||
learn=False, # This should take precedence
|
||||
)
|
||||
|
||||
# Learn should be disabled due to top-level param
|
||||
assert agent._learn_config is None or agent._learn_config is False
|
||||
@@ -33,8 +33,6 @@ class TestLearnConfig:
|
||||
assert config.feedback is False
|
||||
assert config.improvements is False
|
||||
assert config.scope == LearnScope.PRIVATE
|
||||
assert config.auto_consolidate is True
|
||||
assert config.retention_days is None
|
||||
|
||||
def test_custom_values(self):
|
||||
"""Test LearnConfig with custom values."""
|
||||
@@ -43,12 +41,10 @@ class TestLearnConfig:
|
||||
insights=True,
|
||||
patterns=True,
|
||||
scope=LearnScope.SHARED,
|
||||
retention_days=30,
|
||||
)
|
||||
assert config.persona is False
|
||||
assert config.patterns is True
|
||||
assert config.scope == LearnScope.SHARED
|
||||
assert config.retention_days == 30
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test LearnConfig to_dict method."""
|
||||
@@ -271,6 +267,198 @@ class TestLearnManager:
|
||||
assert "Python" in context
|
||||
|
||||
|
||||
class TestBackendWiring:
|
||||
"""P3-A: Test LearnBackend wiring — SQLite, Redis, FILE."""
|
||||
|
||||
def setup_method(self):
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
|
||||
def teardown_method(self):
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_sqlite_backend_round_trip(self):
|
||||
"""SQLite backend stores and retrieves learnings."""
|
||||
from praisonaiagents.config.feature_configs import LearnBackend
|
||||
config = LearnConfig(
|
||||
persona=True, insights=True, patterns=False,
|
||||
backend=LearnBackend.SQLITE,
|
||||
)
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
|
||||
# Store
|
||||
entry = manager.capture_persona("Prefers dark mode")
|
||||
assert entry is not None
|
||||
assert "dark mode" in entry.content
|
||||
|
||||
# Retrieve
|
||||
context = manager.get_persona_context()
|
||||
assert any("dark mode" in c for c in context)
|
||||
|
||||
def test_file_backend_unchanged(self):
|
||||
"""FILE backend works as before (default)."""
|
||||
config = LearnConfig(persona=True)
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
entry = manager.capture_persona("Prefers light mode")
|
||||
assert entry is not None
|
||||
|
||||
# Verify JSON file exists
|
||||
store_files = list(Path(self.temp_dir).rglob("*.json"))
|
||||
assert len(store_files) > 0
|
||||
|
||||
def test_redis_fallback_when_unavailable(self):
|
||||
"""Redis backend falls back to FILE when Redis not running."""
|
||||
import warnings
|
||||
from praisonaiagents.config.feature_configs import LearnBackend
|
||||
config = LearnConfig(
|
||||
persona=True,
|
||||
backend=LearnBackend.REDIS,
|
||||
db_url="redis://localhost:59999", # unlikely port
|
||||
)
|
||||
with warnings.catch_warnings(record=True):
|
||||
warnings.simplefilter("always")
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
|
||||
# Should still work with FILE fallback
|
||||
entry = manager.capture_persona("Redis fallback test")
|
||||
assert entry is not None
|
||||
|
||||
def test_mongodb_warns_and_falls_back(self):
|
||||
"""MongoDB warns and falls back to FILE."""
|
||||
from praisonaiagents.config.feature_configs import LearnBackend
|
||||
config = LearnConfig(
|
||||
persona=True,
|
||||
backend=LearnBackend.MONGODB,
|
||||
)
|
||||
# Should not crash, falls back to FILE
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
entry = manager.capture_persona("Mongo fallback test")
|
||||
assert entry is not None
|
||||
|
||||
|
||||
class TestWasUpdated:
|
||||
"""P3-B: Test was_updated tracking on BaseStore."""
|
||||
|
||||
def setup_method(self):
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
|
||||
def teardown_method(self):
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_was_updated_false_initially(self):
|
||||
"""New store starts with was_updated=False."""
|
||||
store = PersonaStore(store_path=os.path.join(self.temp_dir, "persona.json"))
|
||||
assert store.was_updated is False
|
||||
|
||||
def test_was_updated_true_after_add(self):
|
||||
"""was_updated becomes True after add()."""
|
||||
store = PersonaStore(store_path=os.path.join(self.temp_dir, "persona.json"))
|
||||
store.add("test preference")
|
||||
assert store.was_updated is True
|
||||
|
||||
def test_was_updated_true_after_update(self):
|
||||
"""was_updated becomes True after update()."""
|
||||
store = PersonaStore(store_path=os.path.join(self.temp_dir, "persona.json"))
|
||||
entry = store.add("preference")
|
||||
store.reset_updated()
|
||||
store.update(entry.id, "updated preference")
|
||||
assert store.was_updated is True
|
||||
|
||||
def test_was_updated_true_after_delete(self):
|
||||
"""was_updated becomes True after delete()."""
|
||||
store = PersonaStore(store_path=os.path.join(self.temp_dir, "persona.json"))
|
||||
entry = store.add("to delete")
|
||||
store.reset_updated()
|
||||
store.delete(entry.id)
|
||||
assert store.was_updated is True
|
||||
|
||||
def test_was_updated_true_after_clear(self):
|
||||
"""was_updated becomes True after clear()."""
|
||||
store = PersonaStore(store_path=os.path.join(self.temp_dir, "persona.json"))
|
||||
store.add("something")
|
||||
store.reset_updated()
|
||||
store.clear()
|
||||
assert store.was_updated is True
|
||||
|
||||
def test_reset_updated(self):
|
||||
"""reset_updated() sets flag back to False."""
|
||||
store = PersonaStore(store_path=os.path.join(self.temp_dir, "persona.json"))
|
||||
store.add("test")
|
||||
assert store.was_updated is True
|
||||
store.reset_updated()
|
||||
assert store.was_updated is False
|
||||
|
||||
|
||||
class TestProposeMode:
|
||||
"""P3-C: Test PROPOSE mode — extract but don't auto-store."""
|
||||
|
||||
def setup_method(self):
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
|
||||
def teardown_method(self):
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_pending_learnings_initially_empty(self):
|
||||
"""Pending learnings list starts empty."""
|
||||
config = LearnConfig(persona=True, mode="propose")
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
assert manager.pending_learnings == []
|
||||
|
||||
def test_add_pending_learning(self):
|
||||
"""Can add a pending learning manually."""
|
||||
config = LearnConfig(persona=True, mode="propose")
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
manager.add_pending("User prefers Python", category="persona")
|
||||
assert len(manager.pending_learnings) == 1
|
||||
assert "Python" in manager.pending_learnings[0].content
|
||||
|
||||
def test_approve_learning_moves_to_store(self):
|
||||
"""Approving a pending learning moves it to the main store."""
|
||||
config = LearnConfig(persona=True, mode="propose")
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
manager.add_pending("User likes concise answers", category="persona")
|
||||
pending_id = manager.pending_learnings[0].id
|
||||
|
||||
result = manager.approve_learning(pending_id)
|
||||
assert result is True
|
||||
assert len(manager.pending_learnings) == 0
|
||||
# Should now be in persona store
|
||||
personas = manager.get_persona_context()
|
||||
assert any("concise" in p for p in personas)
|
||||
|
||||
def test_reject_learning_discards(self):
|
||||
"""Rejecting a pending learning discards it."""
|
||||
config = LearnConfig(persona=True, mode="propose")
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
manager.add_pending("Bad learning", category="persona")
|
||||
pending_id = manager.pending_learnings[0].id
|
||||
|
||||
result = manager.reject_learning(pending_id)
|
||||
assert result is True
|
||||
assert len(manager.pending_learnings) == 0
|
||||
# Should NOT be in any store
|
||||
personas = manager.get_persona_context()
|
||||
assert not any("Bad learning" in p for p in personas)
|
||||
|
||||
def test_approve_all_learnings(self):
|
||||
"""Bulk approve all pending learnings."""
|
||||
config = LearnConfig(persona=True, insights=True, mode="propose")
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
manager.add_pending("Preference A", category="persona")
|
||||
manager.add_pending("Insight B", category="insights")
|
||||
assert len(manager.pending_learnings) == 2
|
||||
|
||||
count = manager.approve_all_learnings()
|
||||
assert count == 2
|
||||
assert len(manager.pending_learnings) == 0
|
||||
|
||||
def test_reject_nonexistent_learning(self):
|
||||
"""Rejecting a nonexistent ID returns False."""
|
||||
config = LearnConfig(persona=True, mode="propose")
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
result = manager.reject_learning("nonexistent_id")
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestNoCortexParam:
|
||||
"""Test that cortex parameter does not exist."""
|
||||
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
"""
|
||||
TDD Tests for Learn Module Gap Fixes.
|
||||
|
||||
Tests for:
|
||||
- Gap 1: process_conversation() auto-learning
|
||||
- Gap 2: LearnProtocol for extensibility
|
||||
- Gap 3: Learning modes (AGENTIC, PROPOSE)
|
||||
- Gap 4: Async in learn module
|
||||
- Gap 5: DB backend support in LearnConfig
|
||||
"""
|
||||
|
||||
# Tests for Learn Module Gap Fixes
|
||||
|
||||
|
||||
class TestLearnProtocols:
|
||||
"""Test LearnProtocol and related protocols."""
|
||||
|
||||
def test_learn_protocol_exported(self):
|
||||
"""LearnProtocol should be exported from top-level."""
|
||||
from praisonaiagents import LearnProtocol
|
||||
assert LearnProtocol is not None
|
||||
|
||||
def test_async_learn_protocol_exported(self):
|
||||
"""AsyncLearnProtocol should be exported from top-level."""
|
||||
from praisonaiagents import AsyncLearnProtocol
|
||||
assert AsyncLearnProtocol is not None
|
||||
|
||||
def test_learn_manager_protocol_exported(self):
|
||||
"""LearnManagerProtocol should be exported from top-level."""
|
||||
from praisonaiagents import LearnManagerProtocol
|
||||
assert LearnManagerProtocol is not None
|
||||
|
||||
def test_learn_manager_exported(self):
|
||||
"""LearnManager should be exported from top-level."""
|
||||
from praisonaiagents import LearnManager
|
||||
assert LearnManager is not None
|
||||
|
||||
def test_custom_store_satisfies_protocol(self):
|
||||
"""Custom store should satisfy LearnProtocol."""
|
||||
from praisonaiagents import LearnProtocol
|
||||
|
||||
class CustomLearnStore:
|
||||
def add(self, content, metadata=None):
|
||||
return {"id": "test", "content": content}
|
||||
|
||||
def search(self, query, limit=10):
|
||||
return []
|
||||
|
||||
def list_all(self, limit=100):
|
||||
return []
|
||||
|
||||
def get(self, entry_id):
|
||||
return None
|
||||
|
||||
def update(self, entry_id, content, metadata=None):
|
||||
return None
|
||||
|
||||
def delete(self, entry_id):
|
||||
return True
|
||||
|
||||
def clear(self):
|
||||
return 0
|
||||
|
||||
store = CustomLearnStore()
|
||||
assert isinstance(store, LearnProtocol)
|
||||
|
||||
|
||||
class TestLearnModes:
|
||||
"""Test LearnMode enum and configuration."""
|
||||
|
||||
def test_learn_mode_exported(self):
|
||||
"""LearnMode should be exported from top-level."""
|
||||
from praisonaiagents import LearnMode
|
||||
assert LearnMode is not None
|
||||
|
||||
def test_learn_mode_values(self):
|
||||
"""LearnMode should have DISABLED, AGENTIC, PROPOSE values."""
|
||||
from praisonaiagents import LearnMode
|
||||
|
||||
assert LearnMode.DISABLED.value == "disabled"
|
||||
assert LearnMode.AGENTIC.value == "agentic"
|
||||
assert LearnMode.PROPOSE.value == "propose"
|
||||
|
||||
def test_learn_config_has_mode(self):
|
||||
"""LearnConfig should have mode parameter."""
|
||||
from praisonaiagents import LearnConfig, LearnMode
|
||||
|
||||
config = LearnConfig(mode=LearnMode.AGENTIC)
|
||||
assert config.mode == LearnMode.AGENTIC
|
||||
|
||||
def test_learn_config_mode_default_disabled(self):
|
||||
"""LearnConfig mode should default to DISABLED."""
|
||||
from praisonaiagents import LearnConfig, LearnMode
|
||||
|
||||
config = LearnConfig()
|
||||
assert config.mode == LearnMode.DISABLED
|
||||
|
||||
def test_learn_config_mode_string(self):
|
||||
"""LearnConfig should accept mode as string."""
|
||||
from praisonaiagents import LearnConfig
|
||||
|
||||
config = LearnConfig(mode="agentic")
|
||||
assert config.mode == "agentic"
|
||||
|
||||
|
||||
class TestLearnBackend:
|
||||
"""Test LearnBackend enum and configuration."""
|
||||
|
||||
def test_learn_backend_exported(self):
|
||||
"""LearnBackend should be exported from top-level."""
|
||||
from praisonaiagents import LearnBackend
|
||||
assert LearnBackend is not None
|
||||
|
||||
def test_learn_backend_values(self):
|
||||
"""LearnBackend should have FILE, SQLITE, REDIS, MONGODB values."""
|
||||
from praisonaiagents import LearnBackend
|
||||
|
||||
assert LearnBackend.FILE.value == "file"
|
||||
assert LearnBackend.SQLITE.value == "sqlite"
|
||||
assert LearnBackend.REDIS.value == "redis"
|
||||
assert LearnBackend.MONGODB.value == "mongodb"
|
||||
|
||||
def test_learn_config_has_backend(self):
|
||||
"""LearnConfig should have backend parameter."""
|
||||
from praisonaiagents import LearnConfig, LearnBackend
|
||||
|
||||
config = LearnConfig(backend=LearnBackend.SQLITE)
|
||||
assert config.backend == LearnBackend.SQLITE
|
||||
|
||||
def test_learn_config_has_db_url(self):
|
||||
"""LearnConfig should have db_url parameter."""
|
||||
from praisonaiagents import LearnConfig
|
||||
|
||||
config = LearnConfig(
|
||||
backend="sqlite",
|
||||
db_url="sqlite:///learn.db"
|
||||
)
|
||||
assert config.db_url == "sqlite:///learn.db"
|
||||
|
||||
def test_learn_config_has_llm(self):
|
||||
"""LearnConfig should have llm parameter for extraction."""
|
||||
from praisonaiagents import LearnConfig
|
||||
|
||||
config = LearnConfig(llm="gpt-4o")
|
||||
assert config.llm == "gpt-4o"
|
||||
|
||||
|
||||
class TestProcessConversation:
|
||||
"""Test process_conversation() auto-learning method."""
|
||||
|
||||
def test_learn_manager_has_process_conversation(self):
|
||||
"""LearnManager should have process_conversation method."""
|
||||
from praisonaiagents.memory.learn import LearnManager
|
||||
|
||||
manager = LearnManager()
|
||||
assert hasattr(manager, 'process_conversation')
|
||||
assert callable(manager.process_conversation)
|
||||
|
||||
def test_learn_manager_has_aprocess_conversation(self):
|
||||
"""LearnManager should have async aprocess_conversation method."""
|
||||
from praisonaiagents.memory.learn import LearnManager
|
||||
|
||||
manager = LearnManager()
|
||||
assert hasattr(manager, 'aprocess_conversation')
|
||||
assert callable(manager.aprocess_conversation)
|
||||
|
||||
def test_process_conversation_empty_messages(self):
|
||||
"""process_conversation should handle empty messages."""
|
||||
from praisonaiagents.memory.learn import LearnManager
|
||||
|
||||
manager = LearnManager()
|
||||
result = manager.process_conversation([])
|
||||
|
||||
assert result == {"persona": [], "insights": [], "patterns": [], "stored": {}}
|
||||
|
||||
def test_process_conversation_signature(self):
|
||||
"""process_conversation should have correct signature."""
|
||||
from praisonaiagents.memory.learn import LearnManager
|
||||
import inspect
|
||||
|
||||
manager = LearnManager()
|
||||
sig = inspect.signature(manager.process_conversation)
|
||||
params = list(sig.parameters.keys())
|
||||
|
||||
assert "messages" in params
|
||||
assert "llm" in params
|
||||
|
||||
|
||||
class TestLearnConfigToDict:
|
||||
"""Test LearnConfig.to_dict() includes new fields."""
|
||||
|
||||
def test_to_dict_includes_mode(self):
|
||||
"""to_dict should include mode."""
|
||||
from praisonaiagents import LearnConfig, LearnMode
|
||||
|
||||
config = LearnConfig(mode=LearnMode.AGENTIC)
|
||||
d = config.to_dict()
|
||||
|
||||
assert "mode" in d
|
||||
assert d["mode"] == "agentic"
|
||||
|
||||
def test_to_dict_includes_backend(self):
|
||||
"""to_dict should include backend."""
|
||||
from praisonaiagents import LearnConfig, LearnBackend
|
||||
|
||||
config = LearnConfig(backend=LearnBackend.SQLITE)
|
||||
d = config.to_dict()
|
||||
|
||||
assert "backend" in d
|
||||
assert d["backend"] == "sqlite"
|
||||
|
||||
def test_to_dict_includes_db_url(self):
|
||||
"""to_dict should include db_url."""
|
||||
from praisonaiagents import LearnConfig
|
||||
|
||||
config = LearnConfig(db_url="sqlite:///test.db")
|
||||
d = config.to_dict()
|
||||
|
||||
assert "db_url" in d
|
||||
assert d["db_url"] == "sqlite:///test.db"
|
||||
|
||||
def test_to_dict_includes_llm(self):
|
||||
"""to_dict should include llm."""
|
||||
from praisonaiagents import LearnConfig
|
||||
|
||||
config = LearnConfig(llm="gpt-4o")
|
||||
d = config.to_dict()
|
||||
|
||||
assert "llm" in d
|
||||
assert d["llm"] == "gpt-4o"
|
||||
|
||||
|
||||
class TestAgentWithLearnMode:
|
||||
"""Test Agent with learn mode configuration."""
|
||||
|
||||
def test_agent_accepts_learn_config_with_mode(self):
|
||||
"""Agent should accept LearnConfig with mode."""
|
||||
from praisonaiagents import Agent, LearnConfig, LearnMode
|
||||
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
learn=LearnConfig(
|
||||
mode=LearnMode.AGENTIC,
|
||||
persona=True,
|
||||
insights=True,
|
||||
),
|
||||
)
|
||||
|
||||
assert agent._learn_config is not None
|
||||
assert agent._learn_config.mode == LearnMode.AGENTIC
|
||||
|
||||
def test_agent_accepts_learn_config_with_backend(self):
|
||||
"""Agent should accept LearnConfig with backend."""
|
||||
from praisonaiagents import Agent, LearnConfig, LearnBackend
|
||||
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
learn=LearnConfig(
|
||||
backend=LearnBackend.SQLITE,
|
||||
db_url="sqlite:///test.db",
|
||||
),
|
||||
)
|
||||
|
||||
assert agent._learn_config is not None
|
||||
assert agent._learn_config.backend == LearnBackend.SQLITE
|
||||
|
||||
def test_agent_has_process_auto_learning_method(self):
|
||||
"""Agent should have _process_auto_learning method."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(name="test", instructions="Test agent")
|
||||
assert hasattr(agent, '_process_auto_learning')
|
||||
assert callable(agent._process_auto_learning)
|
||||
|
||||
|
||||
class TestCustomStores:
|
||||
"""Test custom_stores support in LearnManager."""
|
||||
|
||||
def test_learn_manager_accepts_custom_stores(self):
|
||||
"""LearnManager should accept custom_stores parameter."""
|
||||
from praisonaiagents import LearnManager, LearnConfig
|
||||
|
||||
class MyCustomStore:
|
||||
def __init__(self):
|
||||
self.data = []
|
||||
def add(self, content, metadata=None):
|
||||
self.data.append(content)
|
||||
return {"id": "1", "content": content}
|
||||
def search(self, query, limit=10):
|
||||
return []
|
||||
def list_all(self, limit=100):
|
||||
return self.data
|
||||
def get(self, entry_id):
|
||||
return None
|
||||
def update(self, entry_id, content, metadata=None):
|
||||
return None
|
||||
def delete(self, entry_id):
|
||||
return True
|
||||
def clear(self):
|
||||
return 0
|
||||
|
||||
custom_store = MyCustomStore()
|
||||
manager = LearnManager(
|
||||
config=LearnConfig(persona=True),
|
||||
custom_stores={"domain": custom_store}
|
||||
)
|
||||
|
||||
assert "domain" in manager._stores
|
||||
assert manager._stores["domain"] is custom_store
|
||||
|
||||
def test_custom_stores_can_override_builtin(self):
|
||||
"""Custom stores can override built-in stores."""
|
||||
from praisonaiagents import LearnManager, LearnConfig
|
||||
|
||||
class MyPersonaStore:
|
||||
def add(self, content, metadata=None):
|
||||
return {"id": "custom", "content": content}
|
||||
def search(self, query, limit=10):
|
||||
return []
|
||||
def list_all(self, limit=100):
|
||||
return []
|
||||
def get(self, entry_id):
|
||||
return None
|
||||
def update(self, entry_id, content, metadata=None):
|
||||
return None
|
||||
def delete(self, entry_id):
|
||||
return True
|
||||
def clear(self):
|
||||
return 0
|
||||
|
||||
custom_persona = MyPersonaStore()
|
||||
manager = LearnManager(
|
||||
config=LearnConfig(persona=True),
|
||||
custom_stores={"persona": custom_persona}
|
||||
)
|
||||
|
||||
# Custom stores should override built-in stores
|
||||
assert "persona" in manager._stores
|
||||
assert manager._stores["persona"] is custom_persona
|
||||
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
TDD tests for TOOL_CALL_START and TOOL_CALL_RESULT stream events.
|
||||
|
||||
These events provide complete parsed arguments (dict) for tool calls,
|
||||
enabling consumers like AIUI to display keyword-rich descriptions.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
class TestStreamEventTypeEnumValues:
|
||||
"""Test that new StreamEventType values exist."""
|
||||
|
||||
def test_tool_call_start_event_type_exists(self):
|
||||
"""Verify TOOL_CALL_START enum value exists."""
|
||||
from praisonaiagents.streaming.events import StreamEventType
|
||||
assert hasattr(StreamEventType, 'TOOL_CALL_START')
|
||||
assert StreamEventType.TOOL_CALL_START.value == "tool_call_start"
|
||||
|
||||
def test_tool_call_result_event_type_exists(self):
|
||||
"""Verify TOOL_CALL_RESULT enum value exists."""
|
||||
from praisonaiagents.streaming.events import StreamEventType
|
||||
assert hasattr(StreamEventType, 'TOOL_CALL_RESULT')
|
||||
assert StreamEventType.TOOL_CALL_RESULT.value == "tool_call_result"
|
||||
|
||||
def test_existing_events_unchanged(self):
|
||||
"""Verify existing event types are unchanged for backward compat."""
|
||||
from praisonaiagents.streaming.events import StreamEventType
|
||||
assert StreamEventType.DELTA_TOOL_CALL.value == "delta_tool_call"
|
||||
assert StreamEventType.TOOL_CALL_END.value == "tool_call_end"
|
||||
assert StreamEventType.REQUEST_START.value == "request_start"
|
||||
assert StreamEventType.STREAM_END.value == "stream_end"
|
||||
|
||||
|
||||
class TestStreamEventCreation:
|
||||
"""Test creating StreamEvent with new types."""
|
||||
|
||||
def test_create_tool_call_start_event(self):
|
||||
"""Verify TOOL_CALL_START event can be created with parsed args dict."""
|
||||
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
|
||||
|
||||
event = StreamEvent(
|
||||
type=StreamEventType.TOOL_CALL_START,
|
||||
tool_call={
|
||||
"name": "search_web",
|
||||
"arguments": {"query": "Django tutorials"}, # Parsed dict, NOT JSON string
|
||||
"id": "call_abc123",
|
||||
},
|
||||
agent_id="test_agent",
|
||||
)
|
||||
|
||||
assert event.type == StreamEventType.TOOL_CALL_START
|
||||
assert event.tool_call["name"] == "search_web"
|
||||
assert isinstance(event.tool_call["arguments"], dict)
|
||||
assert event.tool_call["arguments"]["query"] == "Django tutorials"
|
||||
assert event.agent_id == "test_agent"
|
||||
|
||||
def test_create_tool_call_result_event(self):
|
||||
"""Verify TOOL_CALL_RESULT event can be created with result."""
|
||||
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
|
||||
|
||||
event = StreamEvent(
|
||||
type=StreamEventType.TOOL_CALL_RESULT,
|
||||
tool_call={
|
||||
"name": "search_web",
|
||||
"arguments": {"query": "Django tutorials"},
|
||||
"result": "Found 10 results for Django tutorials",
|
||||
"id": "call_abc123",
|
||||
},
|
||||
agent_id="test_agent",
|
||||
metadata={"duration_ms": 150.5},
|
||||
)
|
||||
|
||||
assert event.type == StreamEventType.TOOL_CALL_RESULT
|
||||
assert event.tool_call["result"] == "Found 10 results for Django tutorials"
|
||||
assert event.metadata["duration_ms"] == 150.5
|
||||
|
||||
|
||||
class TestStreamEventEmitterWithNewEvents:
|
||||
"""Test StreamEventEmitter emits new event types correctly."""
|
||||
|
||||
def test_emitter_emits_tool_call_start(self):
|
||||
"""Verify emitter can emit TOOL_CALL_START events."""
|
||||
from praisonaiagents.streaming.events import (
|
||||
StreamEventEmitter, StreamEvent, StreamEventType
|
||||
)
|
||||
|
||||
emitter = StreamEventEmitter()
|
||||
received_events = []
|
||||
|
||||
def callback(event):
|
||||
received_events.append(event)
|
||||
|
||||
emitter.add_callback(callback)
|
||||
|
||||
event = StreamEvent(
|
||||
type=StreamEventType.TOOL_CALL_START,
|
||||
tool_call={
|
||||
"name": "read_file",
|
||||
"arguments": {"filepath": "/tmp/test.txt"},
|
||||
"id": "call_xyz789",
|
||||
},
|
||||
)
|
||||
emitter.emit(event)
|
||||
|
||||
assert len(received_events) == 1
|
||||
assert received_events[0].type == StreamEventType.TOOL_CALL_START
|
||||
assert received_events[0].tool_call["arguments"]["filepath"] == "/tmp/test.txt"
|
||||
|
||||
def test_emitter_emits_tool_call_result(self):
|
||||
"""Verify emitter can emit TOOL_CALL_RESULT events."""
|
||||
from praisonaiagents.streaming.events import (
|
||||
StreamEventEmitter, StreamEvent, StreamEventType
|
||||
)
|
||||
|
||||
emitter = StreamEventEmitter()
|
||||
received_events = []
|
||||
|
||||
def callback(event):
|
||||
received_events.append(event)
|
||||
|
||||
emitter.add_callback(callback)
|
||||
|
||||
event = StreamEvent(
|
||||
type=StreamEventType.TOOL_CALL_RESULT,
|
||||
tool_call={
|
||||
"name": "read_file",
|
||||
"arguments": {"filepath": "/tmp/test.txt"},
|
||||
"result": "File contents here...",
|
||||
"id": "call_xyz789",
|
||||
},
|
||||
)
|
||||
emitter.emit(event)
|
||||
|
||||
assert len(received_events) == 1
|
||||
assert received_events[0].type == StreamEventType.TOOL_CALL_RESULT
|
||||
assert received_events[0].tool_call["result"] == "File contents here..."
|
||||
|
||||
def test_no_callbacks_no_overhead(self):
|
||||
"""Verify no overhead when no callbacks registered."""
|
||||
from praisonaiagents.streaming.events import (
|
||||
StreamEventEmitter, StreamEvent, StreamEventType
|
||||
)
|
||||
|
||||
emitter = StreamEventEmitter()
|
||||
assert not emitter.has_callbacks
|
||||
|
||||
# Should not raise, just no-op
|
||||
event = StreamEvent(
|
||||
type=StreamEventType.TOOL_CALL_START,
|
||||
tool_call={"name": "test", "arguments": {}},
|
||||
)
|
||||
emitter.emit(event) # No error, no callbacks called
|
||||
|
||||
|
||||
class TestToolCallEventSemantics:
|
||||
"""Test the semantic differences between tool call event types."""
|
||||
|
||||
def test_delta_vs_start_event_args_type(self):
|
||||
"""
|
||||
DELTA_TOOL_CALL has partial JSON string args.
|
||||
TOOL_CALL_START has complete parsed dict args.
|
||||
"""
|
||||
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
|
||||
|
||||
# DELTA_TOOL_CALL - partial JSON string (as received from LLM streaming)
|
||||
delta_event = StreamEvent(
|
||||
type=StreamEventType.DELTA_TOOL_CALL,
|
||||
tool_call={
|
||||
"name": "search_web",
|
||||
"arguments": '{"qu', # Partial JSON string
|
||||
"id": "call_123",
|
||||
},
|
||||
)
|
||||
|
||||
# TOOL_CALL_START - complete parsed dict (at execution time)
|
||||
start_event = StreamEvent(
|
||||
type=StreamEventType.TOOL_CALL_START,
|
||||
tool_call={
|
||||
"name": "search_web",
|
||||
"arguments": {"query": "Django"}, # Complete parsed dict
|
||||
"id": "call_123",
|
||||
},
|
||||
)
|
||||
|
||||
# Delta has string args
|
||||
assert isinstance(delta_event.tool_call["arguments"], str)
|
||||
|
||||
# Start has dict args
|
||||
assert isinstance(start_event.tool_call["arguments"], dict)
|
||||
assert "query" in start_event.tool_call["arguments"]
|
||||
|
||||
|
||||
class TestToolCallIdPropagation:
|
||||
"""Test that tool_call_id is properly threaded through execute_tool."""
|
||||
|
||||
def test_execute_tool_accepts_tool_call_id(self):
|
||||
"""Verify execute_tool() accepts tool_call_id parameter."""
|
||||
from praisonaiagents import Agent
|
||||
import inspect
|
||||
|
||||
sig = inspect.signature(Agent.execute_tool)
|
||||
params = list(sig.parameters.keys())
|
||||
assert 'tool_call_id' in params, "execute_tool should accept tool_call_id parameter"
|
||||
|
||||
def test_execute_tool_with_context_accepts_tool_call_id(self):
|
||||
"""Verify _execute_tool_with_context() accepts tool_call_id parameter."""
|
||||
from praisonaiagents import Agent
|
||||
import inspect
|
||||
|
||||
sig = inspect.signature(Agent._execute_tool_with_context)
|
||||
params = list(sig.parameters.keys())
|
||||
assert 'tool_call_id' in params, "_execute_tool_with_context should accept tool_call_id parameter"
|
||||
|
||||
def test_tool_call_id_in_stream_event(self):
|
||||
"""Verify tool_call_id can be included in StreamEvent."""
|
||||
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
|
||||
|
||||
event = StreamEvent(
|
||||
type=StreamEventType.TOOL_CALL_START,
|
||||
tool_call={
|
||||
"name": "test_tool",
|
||||
"arguments": {"arg": "value"},
|
||||
"id": "call_abc123xyz", # The tool_call_id
|
||||
},
|
||||
)
|
||||
|
||||
assert event.tool_call["id"] == "call_abc123xyz"
|
||||
@@ -153,8 +153,12 @@ class TestDoomLoopRecovery:
|
||||
@patch("praisonaiagents.agent.agent.Agent.get_recommended_stage", return_value="heuristic")
|
||||
def test_doom_loop_eventually_aborts(self, mock_stage, mock_chat):
|
||||
"""After exhausting recovery attempts, should abort."""
|
||||
mock_chat.return_value = "Still processing the same thing"
|
||||
agent = self._make_agent(threshold=2)
|
||||
def side_effect(prompt):
|
||||
# Simulate tool calls so no-tool-call termination doesn't fire
|
||||
agent._autonomy_turn_tool_count = 1
|
||||
return "Still processing the same thing"
|
||||
mock_chat.side_effect = side_effect
|
||||
result = agent.run_autonomous("Do something")
|
||||
assert result.success is False
|
||||
# Graduated recovery produces needs_help (stage 3) or doom_loop (stage 4).
|
||||
@@ -168,7 +172,6 @@ class TestDoomLoopRecovery:
|
||||
def test_doom_loop_recovery_emits_observability(self, mock_stage, mock_chat):
|
||||
"""Recovery actions should emit observability events when observe=True."""
|
||||
from praisonaiagents import Agent
|
||||
mock_chat.return_value = "Still processing the same thing"
|
||||
agent = Agent(
|
||||
instructions="Test agent",
|
||||
autonomy={
|
||||
@@ -178,6 +181,11 @@ class TestDoomLoopRecovery:
|
||||
"observe": True,
|
||||
},
|
||||
)
|
||||
def side_effect(prompt):
|
||||
# Simulate tool calls so no-tool-call termination doesn't fire
|
||||
agent._autonomy_turn_tool_count = 1
|
||||
return "Still processing the same thing"
|
||||
mock_chat.side_effect = side_effect
|
||||
result = agent.run_autonomous("Do something")
|
||||
obs = getattr(agent, '_observability_hooks', None)
|
||||
assert obs is not None
|
||||
@@ -545,3 +553,135 @@ class TestCLIBridgeApproval:
|
||||
mgr = AutonomyManager(mode=AutonomyMode.FULL_AUTO)
|
||||
config = mgr.get_autonomy_config()
|
||||
assert config["level"] == "full_auto"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Content Streaming Loop Detection
|
||||
# ============================================================
|
||||
|
||||
class TestContentStreamingDetection:
|
||||
"""Tests for content streaming loop detection in DoomLoopDetector."""
|
||||
|
||||
def test_repeated_chunks_detected(self):
|
||||
"""Repeated identical response text should trigger content loop detection."""
|
||||
from praisonaiagents.escalation.doom_loop import DoomLoopDetector, DoomLoopConfig
|
||||
detector = DoomLoopDetector(DoomLoopConfig(
|
||||
max_repeated_chunks=5,
|
||||
max_identical_actions=100, # disable action-based detection
|
||||
max_similar_actions=100,
|
||||
max_consecutive_failures=100,
|
||||
max_no_progress_steps=100,
|
||||
))
|
||||
detector.start_session()
|
||||
# Feed identical responses
|
||||
repeated_text = "The quick brown fox jumps over the lazy dog. " * 5
|
||||
for _ in range(10):
|
||||
detector.record_response(repeated_text)
|
||||
assert detector.is_doom_loop() is True
|
||||
assert detector.get_loop_type().value == "repeated_output"
|
||||
|
||||
def test_varied_content_not_flagged(self):
|
||||
"""Diverse response text should NOT trigger content loop detection."""
|
||||
from praisonaiagents.escalation.doom_loop import DoomLoopDetector, DoomLoopConfig
|
||||
detector = DoomLoopDetector(DoomLoopConfig(
|
||||
max_repeated_chunks=5,
|
||||
max_identical_actions=100,
|
||||
max_similar_actions=100,
|
||||
max_consecutive_failures=100,
|
||||
max_no_progress_steps=100,
|
||||
))
|
||||
detector.start_session()
|
||||
# Feed unique responses
|
||||
for i in range(10):
|
||||
detector.record_response(f"Unique response number {i} with different content every time: {i * 7}")
|
||||
assert detector._check_content_loop() is False
|
||||
|
||||
def test_content_loop_resets_on_start_session(self):
|
||||
"""start_session() should reset content tracking."""
|
||||
from praisonaiagents.escalation.doom_loop import DoomLoopDetector, DoomLoopConfig
|
||||
detector = DoomLoopDetector(DoomLoopConfig(max_repeated_chunks=3))
|
||||
detector.start_session()
|
||||
repeated = "Same text repeated over and over again here. " * 5
|
||||
for _ in range(10):
|
||||
detector.record_response(repeated)
|
||||
assert detector._check_content_loop() is True
|
||||
# Reset
|
||||
detector.start_session()
|
||||
assert detector._check_content_loop() is False
|
||||
|
||||
def test_tracker_record_response_delegates(self):
|
||||
"""DoomLoopTracker.record_response() should delegate to DoomLoopDetector."""
|
||||
from praisonaiagents.agent.autonomy import DoomLoopTracker
|
||||
tracker = DoomLoopTracker(threshold=3)
|
||||
with patch.object(tracker._delegate, 'record_response') as mock_rec:
|
||||
tracker.record_response("some response text")
|
||||
mock_rec.assert_called_once_with("some response text")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# No-Tool-Call Termination Signal
|
||||
# ============================================================
|
||||
|
||||
class TestNoToolCallTermination:
|
||||
"""Tests for no-tool-call termination in run_autonomous()."""
|
||||
|
||||
def _make_agent(self, **autonomy_overrides):
|
||||
from praisonaiagents import Agent
|
||||
config = {"max_iterations": 10, "auto_escalate": False}
|
||||
config.update(autonomy_overrides)
|
||||
return Agent(instructions="Test agent", autonomy=config)
|
||||
|
||||
@patch("praisonaiagents.agent.agent.Agent.chat")
|
||||
@patch("praisonaiagents.agent.agent.Agent.get_recommended_stage", return_value="heuristic")
|
||||
def test_no_tool_calls_completes_after_consecutive_turns(self, mock_stage, mock_chat):
|
||||
"""When model makes no tool calls for 2+ consecutive turns, should complete."""
|
||||
# Use varied responses to avoid triggering doom loop detection.
|
||||
# Each response is unique so doom loop fingerprints differ.
|
||||
call_count = [0]
|
||||
def side_effect(prompt):
|
||||
call_count[0] += 1
|
||||
return f"Analysis iteration {call_count[0]}: examining the data from angle {call_count[0]}."
|
||||
mock_chat.side_effect = side_effect
|
||||
agent = self._make_agent()
|
||||
# Don't set any tool count — simulates no tools being called
|
||||
result = agent.run_autonomous("Analyze something")
|
||||
assert result.success is True
|
||||
assert result.completion_reason == "no_tool_calls"
|
||||
# Should take at least 2 iterations (first is allowed, second confirms)
|
||||
assert result.iterations >= 2
|
||||
|
||||
@patch("praisonaiagents.agent.agent.Agent.chat")
|
||||
@patch("praisonaiagents.agent.agent.Agent.get_recommended_stage", return_value="heuristic")
|
||||
def test_tool_calls_prevent_premature_completion(self, mock_stage, mock_chat):
|
||||
"""When tools are called, should NOT trigger no_tool_calls completion."""
|
||||
call_count = [0]
|
||||
def side_effect(prompt):
|
||||
call_count[0] += 1
|
||||
# Simulate agent incrementing tool count during chat
|
||||
agent._autonomy_turn_tool_count = 1 # Tool was called
|
||||
if call_count[0] >= 4:
|
||||
return "Task completed."
|
||||
return "I ran a tool and got results, processing further..."
|
||||
mock_chat.side_effect = side_effect
|
||||
agent = self._make_agent()
|
||||
result = agent.run_autonomous("Do something")
|
||||
assert result.success is True
|
||||
# Should complete via keyword, not no_tool_calls
|
||||
assert result.completion_reason == "goal"
|
||||
|
||||
@patch("praisonaiagents.agent.agent.Agent.chat")
|
||||
@patch("praisonaiagents.agent.agent.Agent.get_recommended_stage", return_value="heuristic")
|
||||
def test_first_iteration_no_tools_does_not_terminate(self, mock_stage, mock_chat):
|
||||
"""First iteration without tools should NOT terminate — model might be planning."""
|
||||
call_count = [0]
|
||||
def side_effect(prompt):
|
||||
call_count[0] += 1
|
||||
if call_count[0] == 1:
|
||||
return "Let me plan my approach to this problem."
|
||||
return "Task completed."
|
||||
mock_chat.side_effect = side_effect
|
||||
agent = self._make_agent()
|
||||
result = agent.run_autonomous("Do something")
|
||||
assert result.success is True
|
||||
# First iteration should not terminate, so we get at least 2
|
||||
assert result.iterations >= 2
|
||||
@@ -320,3 +320,62 @@ class TestEnableDisable:
|
||||
assert get_editor_output() is editor
|
||||
|
||||
disable_editor_output()
|
||||
|
||||
|
||||
class TestLlmContentCallback:
|
||||
"""Test llm_content callback for intermediate narrative display."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Clean up callback state before each test."""
|
||||
from praisonaiagents.main import sync_display_callbacks
|
||||
from praisonaiagents.output.editor import disable_editor_output
|
||||
sync_display_callbacks.pop('llm_content', None)
|
||||
disable_editor_output()
|
||||
|
||||
def teardown_method(self):
|
||||
"""Restore state after each test."""
|
||||
from praisonaiagents.main import sync_display_callbacks
|
||||
from praisonaiagents.output.editor import disable_editor_output
|
||||
sync_display_callbacks.pop('llm_content', None)
|
||||
disable_editor_output()
|
||||
|
||||
def test_llm_content_in_supported_types(self):
|
||||
"""llm_content must be a documented supported callback type."""
|
||||
from praisonaiagents.main import SUPPORTED_CALLBACK_TYPES
|
||||
assert 'llm_content' in SUPPORTED_CALLBACK_TYPES
|
||||
|
||||
def test_enable_editor_output_registers_llm_content(self):
|
||||
"""enable_editor_output must register a llm_content callback."""
|
||||
from praisonaiagents.output.editor import enable_editor_output
|
||||
from praisonaiagents.main import sync_display_callbacks
|
||||
|
||||
enable_editor_output(use_color=False)
|
||||
assert 'llm_content' in sync_display_callbacks
|
||||
|
||||
def test_llm_content_callback_fires_narrative(self):
|
||||
"""Firing llm_content callback must create a NARRATIVE block."""
|
||||
from praisonaiagents.output.editor import enable_editor_output, get_editor_output, BlockType
|
||||
from praisonaiagents.main import execute_sync_callback
|
||||
|
||||
enable_editor_output(use_color=False)
|
||||
execute_sync_callback('llm_content', content="Let me analyze this further.")
|
||||
|
||||
editor = get_editor_output()
|
||||
blocks = editor.get_blocks()
|
||||
narrative_blocks = [b for b in blocks if b.type == BlockType.NARRATIVE]
|
||||
assert len(narrative_blocks) == 1
|
||||
assert narrative_blocks[0].content == "Let me analyze this further."
|
||||
|
||||
def test_llm_content_callback_skips_empty(self):
|
||||
"""Firing llm_content with empty content must not create a block."""
|
||||
from praisonaiagents.output.editor import enable_editor_output, get_editor_output, BlockType
|
||||
from praisonaiagents.main import execute_sync_callback
|
||||
|
||||
enable_editor_output(use_color=False)
|
||||
execute_sync_callback('llm_content', content="")
|
||||
execute_sync_callback('llm_content', content=" ")
|
||||
|
||||
editor = get_editor_output()
|
||||
blocks = editor.get_blocks()
|
||||
narrative_blocks = [b for b in blocks if b.type == BlockType.NARRATIVE]
|
||||
assert len(narrative_blocks) == 0
|
||||
@@ -0,0 +1,184 @@
|
||||
"""
|
||||
Unit tests for llm_content callback emission.
|
||||
|
||||
Verifies that the llm_content display event fires correctly for:
|
||||
- Tool-call responses (intermediate narrative)
|
||||
- Text-only responses (no tool calls)
|
||||
- Empty response_text (no double-fire)
|
||||
- Interaction callback already displayed (no duplicate)
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock, call
|
||||
|
||||
|
||||
class TestLlmContentCallbackEmission:
|
||||
"""Test that llm_content fires for all responses with text content."""
|
||||
|
||||
def _make_response(self, content="Hello", tool_calls=None):
|
||||
"""Build a mock LiteLLM response."""
|
||||
msg = {"content": content, "tool_calls": tool_calls}
|
||||
return {"choices": [{"message": msg}]}
|
||||
|
||||
@patch('praisonaiagents.main.sync_display_callbacks', {})
|
||||
def test_llm_content_fires_with_text_and_tool_calls(self):
|
||||
"""llm_content callback should fire for text with tool calls."""
|
||||
from praisonaiagents.output.editor import enable_editor_output, disable_editor_output, BlockType
|
||||
from praisonaiagents.main import sync_display_callbacks
|
||||
|
||||
try:
|
||||
editor_output = enable_editor_output(use_color=False)
|
||||
|
||||
# The callback should be registered
|
||||
assert 'llm_content' in sync_display_callbacks
|
||||
|
||||
# Simulate what llm.py now does: emit llm_content for text
|
||||
callback = sync_display_callbacks['llm_content']
|
||||
callback(content="Let me check the weather for you.", agent_name="WeatherBot")
|
||||
|
||||
# Verify narrative block was created
|
||||
narrative_blocks = [b for b in editor_output._blocks if b.type == BlockType.NARRATIVE]
|
||||
assert len(narrative_blocks) == 1
|
||||
assert "weather" in narrative_blocks[0].content.lower()
|
||||
finally:
|
||||
disable_editor_output()
|
||||
|
||||
def test_narrative_method_renders_text(self):
|
||||
"""EditorOutput.narrative() should add a NARRATIVE block."""
|
||||
from praisonaiagents.output.editor import EditorOutput, BlockType
|
||||
|
||||
editor = EditorOutput(use_rich=False)
|
||||
editor.narrative("I'll search for the latest data.", agent_name="Researcher")
|
||||
|
||||
# Check that a NARRATIVE block was added
|
||||
assert len(editor._blocks) == 1
|
||||
assert editor._blocks[0].type == BlockType.NARRATIVE
|
||||
|
||||
def test_narrative_method_handles_empty(self):
|
||||
"""EditorOutput.narrative() should not add block for empty content."""
|
||||
from praisonaiagents.output.editor import EditorOutput
|
||||
|
||||
editor = EditorOutput(use_rich=False)
|
||||
editor.narrative("", agent_name="Test")
|
||||
editor.narrative(" ", agent_name="Test")
|
||||
|
||||
# No blocks should be added for empty content
|
||||
assert len(editor._blocks) == 0
|
||||
|
||||
def test_llm_content_callback_registration(self):
|
||||
"""enable_editor_output should register llm_content callback."""
|
||||
from praisonaiagents.output.editor import enable_editor_output, disable_editor_output
|
||||
from praisonaiagents.main import sync_display_callbacks
|
||||
|
||||
try:
|
||||
enable_editor_output(use_color=False)
|
||||
assert 'llm_content' in sync_display_callbacks
|
||||
callback = sync_display_callbacks['llm_content']
|
||||
assert callable(callback)
|
||||
finally:
|
||||
disable_editor_output()
|
||||
|
||||
def test_llm_content_callback_calls_narrative(self):
|
||||
"""The llm_content callback should call EditorOutput.narrative()."""
|
||||
from praisonaiagents.output.editor import enable_editor_output, disable_editor_output
|
||||
from praisonaiagents.main import sync_display_callbacks
|
||||
|
||||
try:
|
||||
editor_output = enable_editor_output(use_color=False)
|
||||
callback = sync_display_callbacks['llm_content']
|
||||
|
||||
# Call the callback with content
|
||||
callback(content="Analyzing the code structure", agent_name="CodeAgent")
|
||||
|
||||
# Check a NARRATIVE block was added
|
||||
from praisonaiagents.output.editor import BlockType
|
||||
narrative_blocks = [b for b in editor_output._blocks if b.type == BlockType.NARRATIVE]
|
||||
assert len(narrative_blocks) == 1
|
||||
finally:
|
||||
disable_editor_output()
|
||||
|
||||
def test_llm_content_callback_ignores_empty(self):
|
||||
"""The llm_content callback should ignore empty content."""
|
||||
from praisonaiagents.output.editor import enable_editor_output, disable_editor_output
|
||||
from praisonaiagents.main import sync_display_callbacks
|
||||
|
||||
try:
|
||||
editor_output = enable_editor_output(use_color=False)
|
||||
callback = sync_display_callbacks['llm_content']
|
||||
|
||||
# Call with empty content
|
||||
callback(content="", agent_name="Test")
|
||||
callback(content=None, agent_name="Test")
|
||||
callback(content=" ", agent_name="Test")
|
||||
|
||||
# No blocks added
|
||||
assert len(editor_output._blocks) == 0
|
||||
finally:
|
||||
disable_editor_output()
|
||||
|
||||
def test_narrative_before_tool_calls(self):
|
||||
"""Narrative should appear before tool call blocks in the block list."""
|
||||
from praisonaiagents.output.editor import EditorOutput, BlockType
|
||||
|
||||
editor = EditorOutput(use_rich=False)
|
||||
|
||||
# Simulate: LLM narrative first, then tool call
|
||||
editor.narrative("Let me search for that information.")
|
||||
editor.tool_call("internet_search", args={"query": "python 3.13"}, result="Found 5 results")
|
||||
|
||||
assert len(editor._blocks) == 2
|
||||
assert editor._blocks[0].type == BlockType.NARRATIVE
|
||||
assert editor._blocks[1].type == BlockType.COMMAND
|
||||
|
||||
def test_multiple_narrative_tool_groups(self):
|
||||
"""Multiple narrative+tool groups should render in order."""
|
||||
from praisonaiagents.output.editor import EditorOutput, BlockType
|
||||
|
||||
editor = EditorOutput(use_rich=False)
|
||||
|
||||
# First group
|
||||
editor.narrative("I'll start by searching.")
|
||||
editor.tool_call("internet_search", args={"query": "topic A"}, result="3 results")
|
||||
|
||||
# Second group
|
||||
editor.narrative("Now let me create the summary file.")
|
||||
editor.tool_call("write_file", args={"path": "/tmp/summary.md"}, result="File created")
|
||||
|
||||
assert len(editor._blocks) == 4
|
||||
types = [b.type for b in editor._blocks]
|
||||
assert types == [BlockType.NARRATIVE, BlockType.COMMAND, BlockType.NARRATIVE, BlockType.COMMAND]
|
||||
|
||||
|
||||
class TestLlmContentNoDoubleEmission:
|
||||
"""Test that llm_content doesn't double-emit when interaction fires."""
|
||||
|
||||
def test_interaction_callback_does_not_trigger_narrative(self):
|
||||
"""After interaction callback fires, no narrative should duplicate."""
|
||||
from praisonaiagents.output.editor import enable_editor_output, disable_editor_output
|
||||
from praisonaiagents.main import sync_display_callbacks
|
||||
|
||||
try:
|
||||
editor_output = enable_editor_output(use_color=False)
|
||||
|
||||
# Simulate: interaction fires first (final response)
|
||||
interaction_cb = sync_display_callbacks.get('interaction')
|
||||
if interaction_cb:
|
||||
interaction_cb(
|
||||
message="What is Python?",
|
||||
response="Python is a programming language.",
|
||||
agent_name="TestAgent",
|
||||
)
|
||||
|
||||
# Then llm_content fires with same text (should NOT happen in prod
|
||||
# due to callback_executed guard, but test the editor handles it)
|
||||
llm_content_cb = sync_display_callbacks['llm_content']
|
||||
llm_content_cb(content="Python is a programming language.", agent_name="TestAgent")
|
||||
|
||||
from praisonaiagents.output.editor import BlockType
|
||||
narrative_blocks = [b for b in editor_output._blocks if b.type == BlockType.NARRATIVE]
|
||||
summary_blocks = [b for b in editor_output._blocks if b.type == BlockType.SUMMARY]
|
||||
|
||||
# Both should exist since the guard is in llm.py, not editor.py
|
||||
assert len(narrative_blocks) >= 1 or len(summary_blocks) >= 1
|
||||
finally:
|
||||
disable_editor_output()
|
||||
@@ -410,3 +410,93 @@ class TestToolMappings:
|
||||
assert callable(schedule_list)
|
||||
schedule_remove = getattr(tools, 'schedule_remove')
|
||||
assert callable(schedule_remove)
|
||||
|
||||
|
||||
# ─── 9. ScheduleLoop Tests ───────────────────────────────────────────────────
|
||||
|
||||
class TestScheduleLoop:
|
||||
"""Test ScheduleLoop tick loop."""
|
||||
|
||||
def test_schedule_loop_fires_due_jobs(self, tmp_path):
|
||||
"""ScheduleLoop calls on_trigger for each due job."""
|
||||
from praisonaiagents.scheduler.loop import ScheduleLoop
|
||||
from praisonaiagents.scheduler.store import FileScheduleStore
|
||||
from praisonaiagents.scheduler.models import ScheduleJob, Schedule
|
||||
import threading
|
||||
|
||||
store = FileScheduleStore(store_dir=str(tmp_path))
|
||||
job = ScheduleJob(
|
||||
name="fire-me",
|
||||
schedule=Schedule(kind="every", every_seconds=1),
|
||||
message="hello",
|
||||
)
|
||||
job.last_run_at = time.time() - 10 # due
|
||||
store.add(job)
|
||||
|
||||
triggered = []
|
||||
event = threading.Event()
|
||||
|
||||
def on_trigger(j):
|
||||
triggered.append(j.name)
|
||||
event.set()
|
||||
|
||||
loop = ScheduleLoop(on_trigger=on_trigger, store=store, tick_seconds=0.1)
|
||||
loop.start()
|
||||
assert loop.is_running
|
||||
|
||||
event.wait(timeout=3)
|
||||
loop.stop()
|
||||
|
||||
assert "fire-me" in triggered
|
||||
|
||||
def test_schedule_loop_updates_last_run(self, tmp_path):
|
||||
"""After triggering, last_run_at is updated so job doesn't re-fire."""
|
||||
from praisonaiagents.scheduler.loop import ScheduleLoop
|
||||
from praisonaiagents.scheduler.store import FileScheduleStore
|
||||
from praisonaiagents.scheduler.models import ScheduleJob, Schedule
|
||||
import threading
|
||||
|
||||
store = FileScheduleStore(store_dir=str(tmp_path))
|
||||
old_time = time.time() - 100
|
||||
job = ScheduleJob(
|
||||
name="update-me",
|
||||
schedule=Schedule(kind="every", every_seconds=1),
|
||||
message="test",
|
||||
)
|
||||
job.last_run_at = old_time
|
||||
store.add(job)
|
||||
|
||||
event = threading.Event()
|
||||
def on_trigger(j):
|
||||
event.set()
|
||||
|
||||
loop = ScheduleLoop(on_trigger=on_trigger, store=store, tick_seconds=0.1)
|
||||
loop.start()
|
||||
event.wait(timeout=3)
|
||||
loop.stop()
|
||||
|
||||
updated = store.get(job.id)
|
||||
assert updated.last_run_at > old_time
|
||||
|
||||
def test_schedule_loop_start_stop(self, tmp_path):
|
||||
"""ScheduleLoop starts and stops cleanly."""
|
||||
from praisonaiagents.scheduler.loop import ScheduleLoop
|
||||
from praisonaiagents.scheduler.store import FileScheduleStore
|
||||
|
||||
store = FileScheduleStore(store_dir=str(tmp_path))
|
||||
loop = ScheduleLoop(on_trigger=lambda j: None, store=store, tick_seconds=0.1)
|
||||
|
||||
assert not loop.is_running
|
||||
loop.start()
|
||||
assert loop.is_running
|
||||
# start() when already running is a no-op
|
||||
loop.start()
|
||||
assert loop.is_running
|
||||
loop.stop()
|
||||
assert not loop.is_running
|
||||
|
||||
def test_schedule_loop_lazy_import(self):
|
||||
"""ScheduleLoop can be imported from scheduler package."""
|
||||
from praisonaiagents.scheduler import ScheduleLoop
|
||||
assert ScheduleLoop is not None
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"""
|
||||
TDD tests for store_learning and search_learning tools.
|
||||
|
||||
Tests follow the same pattern as test_memory_tools.py.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
# ─── Import Tests ────────────────────────────────────────────────────────────
|
||||
|
||||
class TestLearningToolImports:
|
||||
"""Verify clean imports and no naming collisions."""
|
||||
|
||||
def test_import_from_tools_module(self):
|
||||
from praisonaiagents.tools import store_learning, search_learning
|
||||
assert callable(store_learning)
|
||||
assert callable(search_learning)
|
||||
|
||||
def test_import_directly(self):
|
||||
from praisonaiagents.tools.learning import store_learning, search_learning
|
||||
assert callable(store_learning)
|
||||
assert callable(search_learning)
|
||||
|
||||
def test_no_collision_with_learn_package(self):
|
||||
"""tools.learning and memory.learn are separate Python paths."""
|
||||
from praisonaiagents.tools.learning import store_learning
|
||||
from praisonaiagents.memory.learn.manager import LearnManager
|
||||
assert store_learning is not LearnManager
|
||||
|
||||
|
||||
# ─── store_learning Tests ────────────────────────────────────────────────────
|
||||
|
||||
class TestStoreLearning:
|
||||
"""Test store_learning tool function."""
|
||||
|
||||
def _make_state(self, learn_manager=None):
|
||||
from praisonaiagents.tools.injected import AgentState
|
||||
return AgentState(
|
||||
agent_id="test",
|
||||
run_id="run-1",
|
||||
session_id="sess-1",
|
||||
learn_manager=learn_manager,
|
||||
)
|
||||
|
||||
def test_store_persona(self):
|
||||
from praisonaiagents.tools.learning import store_learning
|
||||
mgr = MagicMock()
|
||||
mgr.capture_persona.return_value = MagicMock(id="e1", content="likes blue")
|
||||
state = self._make_state(learn_manager=mgr)
|
||||
|
||||
result = store_learning("User likes blue", category="persona", state=state)
|
||||
mgr.capture_persona.assert_called_once_with("User likes blue")
|
||||
assert "persona" in result.lower()
|
||||
|
||||
def test_store_insight(self):
|
||||
from praisonaiagents.tools.learning import store_learning
|
||||
mgr = MagicMock()
|
||||
mgr.capture_insight.return_value = MagicMock(id="e2", content="uses FastAPI")
|
||||
state = self._make_state(learn_manager=mgr)
|
||||
|
||||
result = store_learning("Project uses FastAPI", category="insights", state=state)
|
||||
mgr.capture_insight.assert_called_once_with("Project uses FastAPI")
|
||||
assert "insight" in result.lower()
|
||||
|
||||
def test_store_pattern(self):
|
||||
from praisonaiagents.tools.learning import store_learning
|
||||
mgr = MagicMock()
|
||||
mgr.capture_pattern.return_value = MagicMock(id="e3", content="deploy=staging")
|
||||
state = self._make_state(learn_manager=mgr)
|
||||
|
||||
result = store_learning("deploy means staging", category="patterns", state=state)
|
||||
mgr.capture_pattern.assert_called_once_with("deploy means staging")
|
||||
assert "pattern" in result.lower()
|
||||
|
||||
def test_store_decision(self):
|
||||
from praisonaiagents.tools.learning import store_learning
|
||||
mgr = MagicMock()
|
||||
mgr.capture_decision.return_value = MagicMock(id="e4", content="chose postgres")
|
||||
state = self._make_state(learn_manager=mgr)
|
||||
|
||||
result = store_learning("Use PostgreSQL", category="decisions", state=state)
|
||||
mgr.capture_decision.assert_called_once_with("Use PostgreSQL")
|
||||
assert "decision" in result.lower()
|
||||
|
||||
def test_store_default_category_is_persona(self):
|
||||
"""Default category should be persona (most common use case)."""
|
||||
from praisonaiagents.tools.learning import store_learning
|
||||
mgr = MagicMock()
|
||||
mgr.capture_persona.return_value = MagicMock(id="e5")
|
||||
state = self._make_state(learn_manager=mgr)
|
||||
|
||||
store_learning("Prefers Python", state=state)
|
||||
mgr.capture_persona.assert_called_once()
|
||||
|
||||
def test_store_no_learn_manager(self):
|
||||
"""Graceful response when learn is not configured."""
|
||||
from praisonaiagents.tools.learning import store_learning
|
||||
state = self._make_state(learn_manager=None)
|
||||
|
||||
result = store_learning("test", state=state)
|
||||
assert "not configured" in result.lower() or "enable" in result.lower()
|
||||
|
||||
def test_store_no_state(self):
|
||||
"""Graceful response when no state is available."""
|
||||
from praisonaiagents.tools.learning import store_learning
|
||||
with patch("praisonaiagents.tools.learning.get_current_state", return_value=None):
|
||||
result = store_learning("test")
|
||||
assert "not configured" in result.lower() or "enable" in result.lower()
|
||||
|
||||
|
||||
# ─── search_learning Tests ───────────────────────────────────────────────────
|
||||
|
||||
class TestSearchLearning:
|
||||
"""Test search_learning tool function."""
|
||||
|
||||
def _make_state(self, learn_manager=None):
|
||||
from praisonaiagents.tools.injected import AgentState
|
||||
return AgentState(
|
||||
agent_id="test",
|
||||
run_id="run-1",
|
||||
session_id="sess-1",
|
||||
learn_manager=learn_manager,
|
||||
)
|
||||
|
||||
def test_search_with_results(self):
|
||||
from praisonaiagents.tools.learning import search_learning
|
||||
mgr = MagicMock()
|
||||
mgr.search.return_value = {
|
||||
"persona": [{"id": "1", "content": "likes blue"}],
|
||||
"insights": [{"id": "2", "content": "uses FastAPI"}],
|
||||
}
|
||||
state = self._make_state(learn_manager=mgr)
|
||||
|
||||
result = search_learning("preferences", state=state)
|
||||
mgr.search.assert_called_once_with("preferences", limit=5)
|
||||
assert "likes blue" in result
|
||||
assert "uses FastAPI" in result
|
||||
|
||||
def test_search_specific_category(self):
|
||||
from praisonaiagents.tools.learning import search_learning
|
||||
mgr = MagicMock()
|
||||
# When category is specified, search should filter to that store
|
||||
mgr.search.return_value = {
|
||||
"persona": [{"id": "1", "content": "prefers bullet points"}],
|
||||
}
|
||||
state = self._make_state(learn_manager=mgr)
|
||||
|
||||
result = search_learning("style", category="persona", state=state)
|
||||
assert "prefers bullet points" in result
|
||||
|
||||
def test_search_no_results(self):
|
||||
from praisonaiagents.tools.learning import search_learning
|
||||
mgr = MagicMock()
|
||||
mgr.search.return_value = {}
|
||||
state = self._make_state(learn_manager=mgr)
|
||||
|
||||
result = search_learning("nonexistent", state=state)
|
||||
assert "no" in result.lower() and ("found" in result.lower() or "learning" in result.lower())
|
||||
|
||||
def test_search_no_learn_manager(self):
|
||||
from praisonaiagents.tools.learning import search_learning
|
||||
state = self._make_state(learn_manager=None)
|
||||
|
||||
result = search_learning("test", state=state)
|
||||
assert "not configured" in result.lower() or "enable" in result.lower()
|
||||
|
||||
|
||||
# ─── Schema Tests ────────────────────────────────────────────────────────────
|
||||
|
||||
class TestLearningToolSchema:
|
||||
"""Verify Injected[AgentState] is hidden from LLM schema."""
|
||||
|
||||
def test_store_learning_injected_hidden(self):
|
||||
import inspect
|
||||
from praisonaiagents.tools.learning import store_learning
|
||||
sig = inspect.signature(store_learning)
|
||||
params = list(sig.parameters.keys())
|
||||
# 'state' should exist for injection but be hidden from LLM
|
||||
assert "state" in params
|
||||
# LLM-visible params should be content + category
|
||||
assert "content" in params
|
||||
assert "category" in params
|
||||
|
||||
def test_search_learning_injected_hidden(self):
|
||||
import inspect
|
||||
from praisonaiagents.tools.learning import search_learning
|
||||
sig = inspect.signature(search_learning)
|
||||
params = list(sig.parameters.keys())
|
||||
assert "state" in params
|
||||
assert "query" in params
|
||||
assert "limit" in params
|
||||
"""
|
||||
TDD tests for store_learning and search_learning tools.
|
||||
"""
|
||||
@@ -0,0 +1,211 @@
|
||||
"""
|
||||
Tests for memory tools (store_memory, search_memory).
|
||||
|
||||
These are standard tool functions that agents use via tools=[store_memory, search_memory].
|
||||
They use Injected[AgentState] to access the memory instance at runtime.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
class TestMemoryToolImports:
|
||||
"""Test that memory tools can be imported."""
|
||||
|
||||
def test_import_from_tools_module(self):
|
||||
"""store_memory and search_memory importable from tools package."""
|
||||
from praisonaiagents.tools import store_memory, search_memory
|
||||
assert callable(store_memory)
|
||||
assert callable(search_memory)
|
||||
|
||||
def test_import_directly(self):
|
||||
"""Direct import from tools.memory module."""
|
||||
from praisonaiagents.tools.memory import store_memory, search_memory
|
||||
assert callable(store_memory)
|
||||
assert callable(search_memory)
|
||||
|
||||
def test_no_collision_with_memory_package(self):
|
||||
"""tools.memory does not collide with memory/ package."""
|
||||
import praisonaiagents.memory as mem_pkg
|
||||
import praisonaiagents.tools.memory as tools_mem
|
||||
assert mem_pkg is not tools_mem
|
||||
|
||||
|
||||
class TestStoreMemory:
|
||||
"""Test store_memory tool function."""
|
||||
|
||||
def test_store_memory_with_memory_instance(self):
|
||||
"""store_memory stores content via the injected memory."""
|
||||
from praisonaiagents.tools.memory import store_memory
|
||||
from praisonaiagents.tools.injected import AgentState, with_injection_context
|
||||
|
||||
mock_memory = MagicMock()
|
||||
mock_memory.store_short_term = MagicMock()
|
||||
mock_memory.store_long_term = MagicMock()
|
||||
|
||||
state = AgentState(
|
||||
agent_id="test-agent",
|
||||
run_id="test-run",
|
||||
session_id="test-session",
|
||||
memory=mock_memory,
|
||||
)
|
||||
|
||||
with with_injection_context(state):
|
||||
result = store_memory(content="User likes Python", memory_type="long_term")
|
||||
|
||||
assert "stored" in result.lower() or "success" in result.lower() or "saved" in result.lower()
|
||||
|
||||
def test_store_memory_short_term(self):
|
||||
"""store_memory correctly stores to short_term."""
|
||||
from praisonaiagents.tools.memory import store_memory
|
||||
from praisonaiagents.tools.injected import AgentState, with_injection_context
|
||||
|
||||
mock_memory = MagicMock()
|
||||
mock_memory.store_short_term = MagicMock()
|
||||
|
||||
state = AgentState(
|
||||
agent_id="a", run_id="r", session_id="s", memory=mock_memory,
|
||||
)
|
||||
|
||||
with with_injection_context(state):
|
||||
store_memory(content="temporary note", memory_type="short_term")
|
||||
|
||||
mock_memory.store_short_term.assert_called_once()
|
||||
|
||||
def test_store_memory_long_term(self):
|
||||
"""store_memory correctly stores to long_term."""
|
||||
from praisonaiagents.tools.memory import store_memory
|
||||
from praisonaiagents.tools.injected import AgentState, with_injection_context
|
||||
|
||||
mock_memory = MagicMock()
|
||||
mock_memory.store_long_term = MagicMock()
|
||||
|
||||
state = AgentState(
|
||||
agent_id="a", run_id="r", session_id="s", memory=mock_memory,
|
||||
)
|
||||
|
||||
with with_injection_context(state):
|
||||
store_memory(content="important fact", memory_type="long_term")
|
||||
|
||||
mock_memory.store_long_term.assert_called_once()
|
||||
|
||||
def test_store_memory_no_memory_configured(self):
|
||||
"""store_memory returns helpful message when no memory."""
|
||||
from praisonaiagents.tools.memory import store_memory
|
||||
from praisonaiagents.tools.injected import AgentState, with_injection_context
|
||||
|
||||
state = AgentState(
|
||||
agent_id="a", run_id="r", session_id="s", memory=None,
|
||||
)
|
||||
|
||||
with with_injection_context(state):
|
||||
result = store_memory(content="test")
|
||||
|
||||
assert "not configured" in result.lower() or "not available" in result.lower()
|
||||
|
||||
def test_store_memory_no_state(self):
|
||||
"""store_memory returns helpful message with no injection context."""
|
||||
from praisonaiagents.tools.memory import store_memory
|
||||
from praisonaiagents.tools.injected import set_current_state
|
||||
|
||||
set_current_state(None)
|
||||
result = store_memory(content="test")
|
||||
assert "not configured" in result.lower() or "not available" in result.lower()
|
||||
|
||||
|
||||
class TestSearchMemory:
|
||||
"""Test search_memory tool function."""
|
||||
|
||||
def test_search_memory_with_results(self):
|
||||
"""search_memory returns formatted results."""
|
||||
from praisonaiagents.tools.memory import search_memory
|
||||
from praisonaiagents.tools.injected import AgentState, with_injection_context
|
||||
|
||||
mock_memory = MagicMock()
|
||||
mock_memory.search_long_term = MagicMock(return_value=[
|
||||
{"text": "User likes Python", "score": 0.9},
|
||||
{"text": "User is a developer", "score": 0.7},
|
||||
])
|
||||
|
||||
state = AgentState(
|
||||
agent_id="a", run_id="r", session_id="s", memory=mock_memory,
|
||||
)
|
||||
|
||||
with with_injection_context(state):
|
||||
result = search_memory(query="programming preferences")
|
||||
|
||||
assert "Python" in result
|
||||
|
||||
def test_search_memory_no_results(self):
|
||||
"""search_memory returns clear message when nothing found."""
|
||||
from praisonaiagents.tools.memory import search_memory
|
||||
from praisonaiagents.tools.injected import AgentState, with_injection_context
|
||||
|
||||
mock_memory = MagicMock()
|
||||
mock_memory.search_long_term = MagicMock(return_value=[])
|
||||
mock_memory.search_short_term = MagicMock(return_value=[])
|
||||
|
||||
state = AgentState(
|
||||
agent_id="a", run_id="r", session_id="s", memory=mock_memory,
|
||||
)
|
||||
|
||||
with with_injection_context(state):
|
||||
result = search_memory(query="nonexistent topic")
|
||||
|
||||
assert "no" in result.lower() or "nothing" in result.lower() or "found" in result.lower()
|
||||
|
||||
def test_search_memory_no_memory_configured(self):
|
||||
"""search_memory returns helpful message when no memory."""
|
||||
from praisonaiagents.tools.memory import search_memory
|
||||
from praisonaiagents.tools.injected import AgentState, with_injection_context
|
||||
|
||||
state = AgentState(
|
||||
agent_id="a", run_id="r", session_id="s", memory=None,
|
||||
)
|
||||
|
||||
with with_injection_context(state):
|
||||
result = search_memory(query="test")
|
||||
|
||||
assert "not configured" in result.lower() or "not available" in result.lower()
|
||||
|
||||
|
||||
class TestMemoryToolSchema:
|
||||
"""Test that Injected params are hidden from LLM schema."""
|
||||
|
||||
def test_store_memory_injected_hidden(self):
|
||||
"""state param should not appear in tool schema."""
|
||||
from praisonaiagents.tools.memory import store_memory
|
||||
from praisonaiagents.tools.injected import get_injected_params
|
||||
|
||||
injected = get_injected_params(store_memory)
|
||||
assert "state" in injected, "state param must be Injected"
|
||||
|
||||
def test_search_memory_injected_hidden(self):
|
||||
"""state param should not appear in tool schema."""
|
||||
from praisonaiagents.tools.memory import search_memory
|
||||
from praisonaiagents.tools.injected import get_injected_params
|
||||
|
||||
injected = get_injected_params(search_memory)
|
||||
assert "state" in injected, "state param must be Injected"
|
||||
|
||||
|
||||
class TestMemoryToolsWithFileMemory:
|
||||
"""Integration test with real FileMemory (no mocks)."""
|
||||
|
||||
def test_store_and_search_roundtrip(self, tmp_path):
|
||||
"""Store a memory, then search for it."""
|
||||
from praisonaiagents.tools.memory import store_memory, search_memory
|
||||
from praisonaiagents.tools.injected import AgentState, with_injection_context
|
||||
from praisonaiagents.memory.file_memory import FileMemory
|
||||
|
||||
fm = FileMemory(user_id="test_mem_tools", base_path=str(tmp_path))
|
||||
state = AgentState(
|
||||
agent_id="a", run_id="r", session_id="s", memory=fm,
|
||||
)
|
||||
|
||||
with with_injection_context(state):
|
||||
store_result = store_memory(content="The capital of France is Paris", memory_type="long_term")
|
||||
assert "stored" in store_result.lower() or "saved" in store_result.lower()
|
||||
|
||||
search_result = search_memory(query="capital France")
|
||||
assert "Paris" in search_result
|
||||
Generated
+60
-2
@@ -602,6 +602,20 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/9d/37e5da7519de7b0b070a3fedd4230fe76d50d2a21403e0f2153d70ac4163/cryptography-44.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:62901fb618f74d7d81bf408c8719e9ec14d863086efe4185afd07c352aee1d2c", size = 3128774, upload-time = "2024-11-27T18:07:02.157Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ddgs"
|
||||
version = "9.11.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "lxml" },
|
||||
{ name = "primp" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/9e/d89f0c24d78812bad0b4150d9a432925aa756b4bfeb4ef4815fe6ff8f2a6/ddgs-9.11.3.tar.gz", hash = "sha256:6098c030d6806217260071d85e38d9b94b99fe326a3c40ebf5de25f620528ae2", size = 34776, upload-time = "2026-03-11T07:12:02.041Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/9d/018d745128a9a33aff3e6b8f0260f7b970784d4b31573d36ee233b2e4db1/ddgs-9.11.3-py3-none-any.whl", hash = "sha256:596d656d00219b4748d839de1fa9a9c3eb5dd36db07365331f7526201115f18a", size = 43691, upload-time = "2026-03-11T07:12:00.21Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defusedxml"
|
||||
version = "0.7.1"
|
||||
@@ -2564,7 +2578,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.5.33"
|
||||
version = "1.5.73"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -2578,6 +2592,7 @@ dependencies = [
|
||||
all = [
|
||||
{ name = "chonkie" },
|
||||
{ name = "chromadb" },
|
||||
{ name = "ddgs" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "litellm" },
|
||||
{ name = "markitdown", extra = ["all"] },
|
||||
@@ -2640,6 +2655,9 @@ os = [
|
||||
{ name = "pyjwt" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
search = [
|
||||
{ name = "ddgs" },
|
||||
]
|
||||
telemetry = [
|
||||
{ name = "posthog" },
|
||||
]
|
||||
@@ -2652,6 +2670,7 @@ requires-dist = [
|
||||
{ name = "chromadb", marker = "extra == 'graph'", specifier = ">=1.0.0" },
|
||||
{ name = "chromadb", marker = "extra == 'knowledge'", specifier = ">=1.0.0" },
|
||||
{ name = "chromadb", marker = "extra == 'memory'", specifier = ">=1.0.0" },
|
||||
{ name = "ddgs", marker = "extra == 'search'", specifier = ">=9.0.0" },
|
||||
{ name = "fastapi", marker = "extra == 'api'", specifier = ">=0.115.0" },
|
||||
{ name = "fastapi", marker = "extra == 'mcp'", specifier = ">=0.115.0" },
|
||||
{ name = "fastapi", marker = "extra == 'os'", specifier = ">=0.115.0" },
|
||||
@@ -2674,6 +2693,7 @@ requires-dist = [
|
||||
{ name = "praisonaiagents", extras = ["mcp"], marker = "extra == 'all'" },
|
||||
{ name = "praisonaiagents", extras = ["memory"], marker = "extra == 'all'" },
|
||||
{ name = "praisonaiagents", extras = ["mongodb"], marker = "extra == 'all'" },
|
||||
{ name = "praisonaiagents", extras = ["search"], marker = "extra == 'all'" },
|
||||
{ name = "praisonaiagents", extras = ["telemetry"], marker = "extra == 'all'" },
|
||||
{ name = "pydantic", specifier = ">=2.10.0" },
|
||||
{ name = "pydantic", marker = "extra == 'llm'", specifier = ">=2.10.0" },
|
||||
@@ -2688,7 +2708,45 @@ requires-dist = [
|
||||
{ name = "uvicorn", marker = "extra == 'os'", specifier = ">=0.34.0" },
|
||||
{ name = "websockets", marker = "extra == 'mcp'", specifier = ">=12.0" },
|
||||
]
|
||||
provides-extras = ["mcp", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "auth", "autonomy", "all"]
|
||||
provides-extras = ["mcp", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "auth", "autonomy", "search", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "primp"
|
||||
version = "1.1.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c4/0e/62ed44af95c66fd6fa8ad49c8bde815f64c7e976772d6979730be2b7cd97/primp-1.1.3.tar.gz", hash = "sha256:56adc3b8a5048cbd5f926b21fdff839195f3a9181512ca33f56ddc66f4c95897", size = 311356, upload-time = "2026-03-11T06:42:51.763Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/6b/36794b5758a0dd1251e67b6ab3ea946e53fa69745e0ecc29facc072ddf5b/primp-1.1.3-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:24383cfc267f620769be102b7fa4b64c7d47105f86bd21d047f1e07709e83c6e", size = 4000660, upload-time = "2026-03-11T06:42:58.092Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/18/ebbe318a926d158c57f9e9cf49bbea70e8f0bd7f87e7675ed68e0d6ab433/primp-1.1.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:61bcb8c53b41e4bac43d04a1374b6ab7d8ded0f3517d32c5cdd5c30562756805", size = 3737318, upload-time = "2026-03-11T06:42:50.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/4c/430c9154284b53b771e6713a18dec4ad0159e4a501a20b222d67c730ced9/primp-1.1.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0c6b9388578ee9d903f30549a792c5f391fdeb9d36b508da2ffb8e13c764954", size = 3881005, upload-time = "2026-03-11T06:43:12.894Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/34/2466ef66386a1b50e6aaf7832f9f603628407bb33342378faf4b38c4aee8/primp-1.1.3-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:09a8bfa870c92c81d76611846ec53b2520845e3ec5f4139f47604986bcf4bc25", size = 3514480, upload-time = "2026-03-11T06:43:06.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/42/ca7a71df6493dd6c1971c0cc3b20b8125e2547eb3bf88b4429715cb6ed81/primp-1.1.3-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ac372cb9959fff690b255fad91c5b3bc948c14065da9fc00ad80d139651515af", size = 3734658, upload-time = "2026-03-11T06:43:47.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/7c/0fb34db619e9935e11140929713c2c7b5323c1e8ba75cad6f0aade51c89d/primp-1.1.3-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3034672a007f04e12b8fe7814c97ea172e8b9c5d45bd7b00cf6e7334fdd4222a", size = 4011898, upload-time = "2026-03-11T06:43:41.121Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/8b/afd1bd8b14f38d58c5ebd0d45fc6b74914956907aa4e981bb2e5231626d3/primp-1.1.3-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a07d5b7d7278dc63452a59f3bf851dc4d1f8ddc2aada7844cbdb68002256e2f4", size = 3910728, upload-time = "2026-03-11T06:43:01.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/9e/1ec3a9678efcbb51e50d7b4886d9195f956c9fd7f4efcff13ccb152248b0/primp-1.1.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08eec2f58abbcc1060032a2af81dabacec87a580a364a75862039f7422ac82e6", size = 4114189, upload-time = "2026-03-11T06:42:47.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/d9/76de611027c0688be188d5a833be45b1e36d9c0c98baefab27bf6336ab9d/primp-1.1.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9716d4cd36db2c175443fe1bbd54045a944fc9c49d01a385af8ada1fe9c948df", size = 4061973, upload-time = "2026-03-11T06:43:37.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/3b/a30a5ea366705d0ece265b12ad089793d644bd5730b18201e3a0a7fa7b5f/primp-1.1.3-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e19daca65dc6df369c33e711fa481ad2afe5d26c5bde926c069b3ab067c4fd45", size = 3747920, upload-time = "2026-03-11T06:43:10.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/46/e3c323221c371cdfe6c2ed971f7a70e3b69f30b561977715c55230bd5fda/primp-1.1.3-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:ee357537712aa486364b0194cf403c5f9eaaa1354e23e9ac8322a22003f31e6b", size = 3861184, upload-time = "2026-03-11T06:43:49.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/7f/babaf00753daad7d80061003d7ae1bdfca64ea94c181cdea8d25c8a7226a/primp-1.1.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:06c53e77ebf6ac00633bc09e7e5a6d1a994592729d399ca8f065451a2574b92e", size = 4364610, upload-time = "2026-03-11T06:42:56.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/48/c7bca8045c681f5f60972c180d2a20582c7a0857b3b07b12e0a0ee062ac4/primp-1.1.3-cp310-abi3-win32.whl", hash = "sha256:4b1ea3693c118bf04a6e05286f0a73637cf6fe5c9fd77fa1e29a01f190adf512", size = 3265160, upload-time = "2026-03-11T06:43:43.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/3e/4a4b8a0f6f15734cded91e85439e68912b2bb8eafe7132420c13c2db8340/primp-1.1.3-cp310-abi3-win_amd64.whl", hash = "sha256:5ea386a4c8c4d8c1021d17182f4ee24dbb6f17c107c4e9ee5500b6372cf08f32", size = 3603953, upload-time = "2026-03-11T06:43:33.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/46/1baf13a7f5fbed6052deb3e4822c69441a8d0fd990fe2a50e4cec802130b/primp-1.1.3-cp310-abi3-win_arm64.whl", hash = "sha256:63c7b1a1ccbcd07213f438375df186f807cdc5214bc2debb055737db9b5078de", size = 3619917, upload-time = "2026-03-11T06:42:44.76Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/0c/a73cbe13f075e7ceaa5172b44ebc6f423713c6b4efe168114993a1710b26/primp-1.1.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:4b3d52f3233134584ef527e7e52f1b371a964ade1df0461f8187100e41d7fa84", size = 3987141, upload-time = "2026-03-11T06:43:24.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/56/b70d7991fb1e07af53706b1f69f78a0b440a7b4b2a2999c44ab44afef1e7/primp-1.1.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b3d947e2c1d15147e8f4736d027b9f3bef518d67da859ead1c54e028ff491bbb", size = 3735665, upload-time = "2026-03-11T06:43:29.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/82/69efc663341c2bab55659ed221903a090e5c80255c2de2acc70f3726a3fc/primp-1.1.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3ed2fee7d4758f6bb873b19a6759f54e0bc453213dad5ba7e52de7582921079", size = 3873695, upload-time = "2026-03-11T06:43:15.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/7e/6b360742019ef8fb4ea036a420eb21b0a58d380ca09c68b075fc103cc043/primp-1.1.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5aa717f256af9e4391fb1c4dc946d99d04652b4c57dad20c3947e839ab26769", size = 3512644, upload-time = "2026-03-11T06:43:08.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/46/51d2ada6d5b53b8496eddf2c80392deab13698987412d0234f88e72390c1/primp-1.1.3-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:17f37fcacd97540f68b06f2b468b111ca7f2b142c48370db7344b522274fc0d6", size = 3733114, upload-time = "2026-03-11T06:43:22.838Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/f5/5f5f5f4bef7e247ec3543e2fbdb670d8db8753a7693baf9c8b9fcf52cd43/primp-1.1.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5f010d0b8ba111dd9a66f814c2cd56332e047c98f45d7714ffbf2b1cec5b073", size = 4005664, upload-time = "2026-03-11T06:43:20.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/bf/99cf4a5f179b3f13b0c2ba4d3ae8f8af19f0084308e76cb79a0cee03c31b/primp-1.1.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2e1e431915e4a7094d589213fc14e955243d93751031d889f4b359fa8ed54298", size = 3895746, upload-time = "2026-03-11T06:43:35.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/75/4c625e1cab37585365b0856ca44f31ad598e92a847d23561f454b7f36fca/primp-1.1.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaffa22dae2f193d899d9f68cca109ea5d16cdf4c901c20cec186de89e7d5db4", size = 4109815, upload-time = "2026-03-11T06:43:04.059Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/72/6197ea78779d359f307be1acc64659896fc960ed91c0bdc6e6e698e423e6/primp-1.1.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f93bee50990884621ef482e8434e87f9fbb4eca6f4d47973c44c5d6393c35679", size = 4050839, upload-time = "2026-03-11T06:43:18.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/b2/cdd565b28bcf7ce555f4decdf89dafd16db8ed3ba8661890d3b9337abe45/primp-1.1.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:399dfb9ad01c3612c9e510a7034ac925af5524cade0961d8a019dedd90a46474", size = 3748397, upload-time = "2026-03-11T06:43:27.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/6e/def3a90821b52589dbe1f57477c2c89bde7a5b26a7c166d7751930c06f98/primp-1.1.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:78ce595bbb9f339e83975efa9db2a81128842fad1a2fdafb78d72fcdc59590fc", size = 3861261, upload-time = "2026-03-11T06:43:39.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/7d/3e610614d6a426502cfc6eccea21ef4557b39177d365df393c994945ca43/primp-1.1.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d709bdf520aa9401c0592b642730b3477c828629f01d2550977b77135b34e8d", size = 4358608, upload-time = "2026-03-11T06:43:45.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/50/eb190cefe5eb05896825a5b3365d5650b9327161329cd1df4f7351b66ba9/primp-1.1.3-cp314-cp314t-win32.whl", hash = "sha256:6fe893eb87156dfb146dd666c7c8754670de82e38af0a27d82a47b7461ec2eea", size = 3259903, upload-time = "2026-03-11T06:42:59.922Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/a8/9e8534bc6d729a667f79b249fcdbf2230b0eb41214e277998cd6be900498/primp-1.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:ced76ef6669f31dc4af25e81e87914310645bcfc0892036bde084dafd6d00c3c", size = 3602569, upload-time = "2026-03-11T06:42:53.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/92/e18be996a01c7fd0e7dd7d198edefe42813cdfe1637bbbc80370ce656f62/primp-1.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:efadef0dfd10e733a254a949abf9ed05c668c28a68aa6513d811c0c6acd54cdb", size = 3611571, upload-time = "2026-03-11T06:43:31.249Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "propcache"
|
||||
|
||||
+467
-3093
File diff suppressed because it is too large.
Load diff
@@ -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.33.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.33.tar.gz | shasum -a 256`.split.first
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.73.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.73.tar.gz | shasum -a 256`.split.first
|
||||
license "MIT"
|
||||
|
||||
depends_on "[email protected]"
|
||||
|
||||
@@ -12,6 +12,8 @@ if TYPE_CHECKING:
|
||||
from .discord import DiscordBot
|
||||
from .slack import SlackBot
|
||||
from .whatsapp import WhatsAppBot
|
||||
from .email import EmailBot
|
||||
from .agentmail import AgentMailBot
|
||||
from .bot import Bot
|
||||
from .botos import BotOS
|
||||
from ._slack_approval import SlackApproval
|
||||
@@ -34,6 +36,12 @@ def __getattr__(name: str):
|
||||
if name == "WhatsAppBot":
|
||||
from .whatsapp import WhatsAppBot
|
||||
return WhatsAppBot
|
||||
if name == "EmailBot":
|
||||
from .email import EmailBot
|
||||
return EmailBot
|
||||
if name == "AgentMailBot":
|
||||
from .agentmail import AgentMailBot
|
||||
return AgentMailBot
|
||||
if name == "Bot":
|
||||
from .bot import Bot
|
||||
return Bot
|
||||
@@ -58,7 +66,7 @@ def __getattr__(name: str):
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
__all__ = [
|
||||
"TelegramBot", "DiscordBot", "SlackBot", "WhatsAppBot",
|
||||
"TelegramBot", "DiscordBot", "SlackBot", "WhatsAppBot", "EmailBot", "AgentMailBot",
|
||||
"Bot", "BotOS",
|
||||
"SlackApproval", "TelegramApproval", "DiscordApproval",
|
||||
"WebhookApproval", "HTTPApproval",
|
||||
|
||||
@@ -24,10 +24,22 @@ class AgentConfigSchema(BaseModel):
|
||||
class ChannelConfigSchema(BaseModel):
|
||||
"""Schema for a single channel configuration."""
|
||||
token: str = ""
|
||||
mode: str = "poll" # poll | ws | webhook | hybrid
|
||||
group_policy: str = "respond_all" # respond_all, mention_only, command_only
|
||||
allowlist: List[str] = Field(default_factory=list)
|
||||
blocklist: List[str] = Field(default_factory=list)
|
||||
webhook_url: Optional[str] = None
|
||||
webhook_port: int = 8080
|
||||
|
||||
@field_validator("mode")
|
||||
@classmethod
|
||||
def validate_mode(cls, v: str) -> str:
|
||||
allowed = {"poll", "ws", "webhook", "hybrid"}
|
||||
if v not in allowed:
|
||||
raise ValueError(
|
||||
f"Invalid mode '{v}'. Must be one of: {', '.join(sorted(allowed))}"
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("token")
|
||||
@classmethod
|
||||
@@ -91,7 +103,7 @@ class BotYamlSchema(BaseModel):
|
||||
"(telegram, discord, slack, whatsapp) to your bot.yaml"
|
||||
)
|
||||
# Validate channel names
|
||||
valid_platforms = {"telegram", "discord", "slack", "whatsapp"}
|
||||
valid_platforms = {"telegram", "discord", "slack", "whatsapp", "email", "agentmail"}
|
||||
for name in self.channels:
|
||||
if name not in valid_platforms:
|
||||
raise ValueError(
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Shared email utilities for PraisonAI bots.
|
||||
|
||||
Contains common email logic used by both EmailBot (IMAP/SMTP) and AgentMailBot (API).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, Set
|
||||
|
||||
# Headers that indicate auto-generated/auto-reply emails
|
||||
AUTO_REPLY_HEADERS: Set[str] = {
|
||||
"auto-submitted",
|
||||
"x-auto-response-suppress",
|
||||
"x-autoreply",
|
||||
"x-autorespond",
|
||||
"x-autogenerated",
|
||||
"precedence", # Often set to "bulk" or "auto_reply"
|
||||
}
|
||||
|
||||
# Sender patterns to never reply to (prevents loops)
|
||||
BLOCKED_SENDERS: Set[str] = {
|
||||
"mailer-daemon",
|
||||
"postmaster",
|
||||
"noreply",
|
||||
"no-reply",
|
||||
"donotreply",
|
||||
"do-not-reply",
|
||||
"bounce",
|
||||
"auto",
|
||||
"daemon",
|
||||
}
|
||||
|
||||
|
||||
def is_auto_reply(headers: Dict[str, str]) -> bool:
|
||||
"""Check if email headers indicate an auto-reply.
|
||||
|
||||
Args:
|
||||
headers: Dictionary of email headers (case-insensitive keys recommended).
|
||||
|
||||
Returns:
|
||||
True if the email appears to be an auto-reply.
|
||||
"""
|
||||
headers_lower = {k.lower(): v for k, v in headers.items()}
|
||||
|
||||
for header in AUTO_REPLY_HEADERS:
|
||||
if header in headers_lower:
|
||||
value = headers_lower[header]
|
||||
# auto-submitted: auto-replied, auto-generated, auto-notified
|
||||
if header == "auto-submitted" and value and value.lower() != "no":
|
||||
return True
|
||||
# precedence: bulk, junk, list, auto_reply
|
||||
if header == "precedence" and value and value.lower() in ("bulk", "junk", "auto_reply"):
|
||||
return True
|
||||
# Other headers: any non-empty value indicates auto-reply
|
||||
if header not in ("auto-submitted", "precedence") and value:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def is_blocked_sender(sender: str) -> bool:
|
||||
"""Check if sender should be blocked (system addresses, no-reply, etc.).
|
||||
|
||||
Args:
|
||||
sender: Email address or display name of sender.
|
||||
|
||||
Returns:
|
||||
True if the sender should not receive replies.
|
||||
"""
|
||||
if not sender:
|
||||
return True
|
||||
|
||||
sender_lower = sender.lower()
|
||||
return any(blocked in sender_lower for blocked in BLOCKED_SENDERS)
|
||||
|
||||
|
||||
def extract_email_address(full_address: str) -> str:
|
||||
"""Extract email address from a full address string.
|
||||
|
||||
Args:
|
||||
full_address: Full address like "John Doe <[email protected]>" or "[email protected]"
|
||||
|
||||
Returns:
|
||||
Just the email address part.
|
||||
"""
|
||||
if not full_address:
|
||||
return ""
|
||||
|
||||
# Handle "Name <email>" format
|
||||
if "<" in full_address and ">" in full_address:
|
||||
start = full_address.index("<") + 1
|
||||
end = full_address.index(">")
|
||||
return full_address[start:end].strip()
|
||||
|
||||
return full_address.strip()
|
||||
|
||||
|
||||
def normalize_subject(subject: str, strip_prefixes: bool = True) -> str:
|
||||
"""Normalize email subject for comparison/threading.
|
||||
|
||||
Args:
|
||||
subject: Original subject line.
|
||||
strip_prefixes: If True, removes Re:, Fwd:, etc.
|
||||
|
||||
Returns:
|
||||
Normalized subject string.
|
||||
"""
|
||||
if not subject:
|
||||
return ""
|
||||
|
||||
result = subject.strip()
|
||||
|
||||
if strip_prefixes:
|
||||
# Remove common reply/forward prefixes
|
||||
prefixes = ("re:", "fwd:", "fw:", "aw:", "sv:", "vs:")
|
||||
while True:
|
||||
lower = result.lower()
|
||||
stripped = False
|
||||
for prefix in prefixes:
|
||||
if lower.startswith(prefix):
|
||||
result = result[len(prefix):].strip()
|
||||
stripped = True
|
||||
break
|
||||
if not stripped:
|
||||
break
|
||||
|
||||
return result
|
||||
@@ -15,6 +15,8 @@ _BUILTIN_PLATFORMS: Dict[str, tuple] = {
|
||||
"discord": ("praisonai.bots.discord", "DiscordBot"),
|
||||
"slack": ("praisonai.bots.slack", "SlackBot"),
|
||||
"whatsapp": ("praisonai.bots.whatsapp", "WhatsAppBot"),
|
||||
"email": ("praisonai.bots.email", "EmailBot"),
|
||||
"agentmail": ("praisonai.bots.agentmail", "AgentMailBot"),
|
||||
}
|
||||
|
||||
# Custom platforms registered at runtime
|
||||
|
||||
@@ -0,0 +1,913 @@
|
||||
"""
|
||||
AgentMail Bot implementation for PraisonAI.
|
||||
|
||||
Provides an agent-native email bot using the AgentMail API for zero-config
|
||||
email functionality. No IMAP/SMTP setup required.
|
||||
|
||||
Requires: pip install agentmail
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING, Union
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from praisonaiagents import Agent
|
||||
|
||||
from praisonai.bots._protocol_mixin import ChatCommandMixin, MessageHookMixin
|
||||
from praisonaiagents.bots import (
|
||||
BotConfig,
|
||||
BotMessage,
|
||||
BotUser,
|
||||
BotChannel,
|
||||
MessageType,
|
||||
ProbeResult,
|
||||
HealthResult,
|
||||
)
|
||||
|
||||
from ._session import BotSessionManager
|
||||
from ._email_utils import is_auto_reply, is_blocked_sender, extract_email_address
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AgentMailBot(ChatCommandMixin, MessageHookMixin):
|
||||
"""Agent-native email bot using AgentMail API.
|
||||
|
||||
Zero-config email for agents — no IMAP/SMTP setup required.
|
||||
Creates dedicated inboxes programmatically via AgentMail API.
|
||||
|
||||
Example:
|
||||
from praisonai.bots import AgentMailBot
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(name="email_assistant")
|
||||
bot = AgentMailBot(
|
||||
token="am_...", # AGENTMAIL_API_KEY
|
||||
agent=agent,
|
||||
)
|
||||
|
||||
await bot.start()
|
||||
print(bot.email_address) # [email protected]
|
||||
|
||||
Environment variables (if not passed directly):
|
||||
AGENTMAIL_API_KEY: API key for AgentMail service
|
||||
AGENTMAIL_INBOX_ID: Optional existing inbox ID to reuse
|
||||
AGENTMAIL_DOMAIN: Optional custom domain for inbox creation
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
token: str,
|
||||
agent: Optional["Agent"] = None,
|
||||
config: Optional[BotConfig] = None,
|
||||
inbox_id: Optional[str] = None,
|
||||
domain: Optional[str] = None,
|
||||
polling_interval: int = 10,
|
||||
mode: Optional[str] = None,
|
||||
webhook_port: Optional[int] = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize AgentMailBot.
|
||||
|
||||
Args:
|
||||
token: AgentMail API key (or set AGENTMAIL_API_KEY env var)
|
||||
agent: PraisonAI agent to handle messages
|
||||
config: Bot configuration
|
||||
inbox_id: Existing inbox ID to reuse (optional)
|
||||
domain: Custom domain for new inbox (optional)
|
||||
polling_interval: Seconds between polling for new messages
|
||||
mode: Event mode — poll (default), ws, webhook, or hybrid
|
||||
webhook_port: Port for webhook server (default: 8080)
|
||||
**kwargs: Additional configuration
|
||||
"""
|
||||
self._token = token or os.getenv("AGENTMAIL_API_KEY", "")
|
||||
self._agent = agent
|
||||
self._inbox_id = inbox_id or os.getenv("AGENTMAIL_INBOX_ID")
|
||||
self._domain = domain or os.getenv("AGENTMAIL_DOMAIN")
|
||||
self._polling_interval = polling_interval
|
||||
|
||||
# Bot state
|
||||
self._is_running = False
|
||||
self._started_at: Optional[float] = None
|
||||
self._bot_user: Optional[BotUser] = None
|
||||
self._email_address: Optional[str] = None
|
||||
self._poll_task: Optional[asyncio.Task] = None
|
||||
self._ws_task: Optional[asyncio.Task] = None
|
||||
self._stop_event = asyncio.Event()
|
||||
|
||||
# AgentMail clients (lazy loaded)
|
||||
self._client: Any = None
|
||||
self._async_client: Any = None
|
||||
|
||||
# Webhook server state
|
||||
self._webhook_runner: Any = None
|
||||
self._webhook_site: Any = None
|
||||
|
||||
# Session manager for conversation state
|
||||
self.config = config or BotConfig()
|
||||
self._session = BotSessionManager(self.config)
|
||||
|
||||
# Resolve mode: explicit param > config > default
|
||||
if mode:
|
||||
self._mode = mode
|
||||
elif self.config.mode != "poll":
|
||||
self._mode = self.config.mode
|
||||
else:
|
||||
self._mode = "poll"
|
||||
|
||||
# Resolve webhook port
|
||||
self._webhook_port = webhook_port or self.config.webhook_port
|
||||
|
||||
# Command and message handlers
|
||||
self._command_handlers: Dict[str, Callable] = {}
|
||||
self._message_handlers: List[Callable] = []
|
||||
|
||||
# Track processed message IDs to avoid duplicates
|
||||
self._processed_ids: set = set()
|
||||
|
||||
@property
|
||||
def platform(self) -> str:
|
||||
"""Platform identifier."""
|
||||
return "agentmail"
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""Whether the bot is currently running."""
|
||||
return self._is_running
|
||||
|
||||
@property
|
||||
def bot_user(self) -> Optional[BotUser]:
|
||||
"""The bot's user identity."""
|
||||
return self._bot_user
|
||||
|
||||
@property
|
||||
def email_address(self) -> Optional[str]:
|
||||
"""The bot's email address."""
|
||||
return self._email_address
|
||||
|
||||
def get_agent(self) -> Optional["Agent"]:
|
||||
"""Get the associated agent."""
|
||||
return self._agent
|
||||
|
||||
def set_agent(self, agent: "Agent") -> None:
|
||||
"""Set the associated agent."""
|
||||
self._agent = agent
|
||||
|
||||
def _get_client(self) -> Any:
|
||||
"""Lazy load sync AgentMail client."""
|
||||
if self._client is None:
|
||||
try:
|
||||
from agentmail import AgentMail
|
||||
self._client = AgentMail(api_key=self._token)
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"AgentMail package not installed. "
|
||||
"Install with: pip install agentmail"
|
||||
)
|
||||
return self._client
|
||||
|
||||
def _get_async_client(self) -> Any:
|
||||
"""Lazy load async AgentMail client (for WS mode)."""
|
||||
if self._async_client is None:
|
||||
try:
|
||||
from agentmail import AsyncAgentMail
|
||||
self._async_client = AsyncAgentMail(api_key=self._token)
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"AgentMail package not installed. "
|
||||
"Install with: pip install agentmail"
|
||||
)
|
||||
return self._async_client
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the AgentMail bot.
|
||||
|
||||
Creates or connects to an inbox and begins receiving messages
|
||||
using the configured mode (poll, ws, webhook, or hybrid).
|
||||
"""
|
||||
if self._is_running:
|
||||
logger.warning("AgentMailBot is already running")
|
||||
return
|
||||
|
||||
if not self._token:
|
||||
raise ValueError(
|
||||
"AgentMail API key required. Set AGENTMAIL_API_KEY env var or pass token parameter."
|
||||
)
|
||||
|
||||
client = self._get_client()
|
||||
|
||||
# Create or get inbox
|
||||
if self._inbox_id:
|
||||
# Use existing inbox — inbox_id IS the email address in AgentMail SDK
|
||||
self._email_address = self._inbox_id
|
||||
logger.info(f"Using existing AgentMail inbox: {self._email_address}")
|
||||
else:
|
||||
# Create new inbox
|
||||
try:
|
||||
inbox_params = {}
|
||||
if self._domain:
|
||||
inbox_params["domain"] = self._domain
|
||||
inbox = client.inboxes.create(**inbox_params)
|
||||
# SDK v0.4.7: inbox_id IS the email address (e.g. "[email protected]")
|
||||
self._inbox_id = inbox.inbox_id
|
||||
self._email_address = inbox.inbox_id
|
||||
logger.info(f"Created AgentMail inbox: {self._email_address}")
|
||||
except Exception as e:
|
||||
raise ConnectionError(f"Failed to create inbox: {e}")
|
||||
|
||||
self._bot_user = BotUser(
|
||||
user_id=self._inbox_id,
|
||||
username=self._email_address,
|
||||
display_name=self._email_address.split("@")[0] if self._email_address else "agent",
|
||||
is_bot=True,
|
||||
)
|
||||
|
||||
self._is_running = True
|
||||
self._started_at = time.time()
|
||||
self._stop_event.clear()
|
||||
|
||||
# Branch on mode
|
||||
if self._mode == "ws":
|
||||
self._ws_task = asyncio.create_task(self._ws_loop())
|
||||
logger.info(f"AgentMailBot started (ws): {self._email_address}")
|
||||
elif self._mode == "webhook":
|
||||
await self._start_webhook_mode()
|
||||
logger.info(f"AgentMailBot started (webhook): {self._email_address}")
|
||||
elif self._mode == "hybrid":
|
||||
self._ws_task = asyncio.create_task(self._ws_loop())
|
||||
self._poll_task = asyncio.create_task(
|
||||
self._poll_loop(interval_override=60)
|
||||
)
|
||||
logger.info(f"AgentMailBot started (hybrid): {self._email_address}")
|
||||
else:
|
||||
# Default: poll mode (existing behaviour — zero breaking change)
|
||||
self._poll_task = asyncio.create_task(self._poll_loop())
|
||||
logger.info(f"AgentMailBot started (poll): {self._email_address}")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the AgentMail bot."""
|
||||
if not self._is_running:
|
||||
return
|
||||
|
||||
self._is_running = False
|
||||
self._stop_event.set()
|
||||
|
||||
# Cancel poll task
|
||||
if self._poll_task:
|
||||
self._poll_task.cancel()
|
||||
try:
|
||||
await self._poll_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._poll_task = None
|
||||
|
||||
# Cancel WS task
|
||||
if self._ws_task:
|
||||
self._ws_task.cancel()
|
||||
try:
|
||||
await self._ws_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._ws_task = None
|
||||
|
||||
# Stop webhook server
|
||||
if self._webhook_site:
|
||||
await self._webhook_site.stop()
|
||||
self._webhook_site = None
|
||||
if self._webhook_runner:
|
||||
await self._webhook_runner.cleanup()
|
||||
self._webhook_runner = None
|
||||
|
||||
logger.info("AgentMailBot stopped")
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# Poll mode (existing behaviour)
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
async def _poll_loop(self, interval_override: Optional[int] = None) -> None:
|
||||
"""Poll for new messages.
|
||||
|
||||
Args:
|
||||
interval_override: Override polling interval (used by hybrid mode
|
||||
to set a slower 60s fallback poll).
|
||||
"""
|
||||
interval = interval_override or self._polling_interval
|
||||
while self._is_running:
|
||||
try:
|
||||
await self._check_new_messages()
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking messages: {e}")
|
||||
|
||||
# Wait for interval or stop signal
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self._stop_event.wait(),
|
||||
timeout=interval
|
||||
)
|
||||
break # Stop event was set
|
||||
except asyncio.TimeoutError:
|
||||
continue # Normal timeout, continue polling
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# WebSocket mode
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
async def _ws_loop(self) -> None:
|
||||
"""WebSocket event loop with auto-reconnect.
|
||||
|
||||
Connects to AgentMail WebSocket API, subscribes to message.received
|
||||
events for this inbox, and processes them via _handle_message.
|
||||
Reconnects with exponential backoff on disconnection.
|
||||
"""
|
||||
backoff = 1 # Start at 1s
|
||||
max_backoff = 60
|
||||
|
||||
while self._is_running:
|
||||
try:
|
||||
await self._ws_connect_and_listen()
|
||||
# If we exit cleanly (stop called), break
|
||||
if not self._is_running:
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
if not self._is_running:
|
||||
break
|
||||
logger.warning(
|
||||
f"WebSocket disconnected: {e}. "
|
||||
f"Reconnecting in {backoff}s..."
|
||||
)
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self._stop_event.wait(),
|
||||
timeout=backoff
|
||||
)
|
||||
break # Stop was requested during backoff
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
backoff = min(backoff * 2, max_backoff)
|
||||
else:
|
||||
# Reset backoff on clean disconnect
|
||||
backoff = 1
|
||||
|
||||
async def _ws_connect_and_listen(self) -> None:
|
||||
"""Single WebSocket connection session."""
|
||||
try:
|
||||
from agentmail import AsyncAgentMail, Subscribe, MessageReceivedEvent
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"AgentMail package not installed. "
|
||||
"Install with: pip install agentmail"
|
||||
)
|
||||
|
||||
async_client = self._get_async_client()
|
||||
|
||||
async with async_client.websockets.connect() as ws:
|
||||
# Subscribe to message.received events for this inbox
|
||||
await ws.send_subscribe(
|
||||
Subscribe(
|
||||
event_types=["message.received"],
|
||||
inbox_ids=[self._inbox_id] if self._inbox_id else None,
|
||||
)
|
||||
)
|
||||
logger.info(f"WebSocket connected, subscribed to {self._inbox_id}")
|
||||
|
||||
async for event in ws:
|
||||
if not self._is_running:
|
||||
break
|
||||
|
||||
if isinstance(event, MessageReceivedEvent):
|
||||
await self._handle_ws_event(event)
|
||||
|
||||
async def _handle_ws_event(self, event: Any) -> None:
|
||||
"""Convert a WebSocket MessageReceivedEvent to BotMessage and handle."""
|
||||
try:
|
||||
msg_id = getattr(event, 'message_id', None)
|
||||
if not msg_id:
|
||||
return
|
||||
|
||||
# Dedup — shared with poll mode
|
||||
if msg_id in self._processed_ids:
|
||||
return
|
||||
self._processed_ids.add(msg_id)
|
||||
|
||||
sender_raw = getattr(event, 'from_', '') or ''
|
||||
sender_email = extract_email_address(sender_raw)
|
||||
|
||||
# Skip auto-replies and blocked senders
|
||||
headers = getattr(event, 'headers', {}) or {}
|
||||
if is_auto_reply(headers):
|
||||
logger.debug(f"Skipping auto-reply from {sender_email}")
|
||||
return
|
||||
if is_blocked_sender(sender_email):
|
||||
logger.debug(f"Skipping blocked sender: {sender_email}")
|
||||
return
|
||||
|
||||
body = getattr(event, 'extracted_text', '') or getattr(event, 'text', '') or ''
|
||||
|
||||
message = BotMessage(
|
||||
message_id=msg_id,
|
||||
content=body,
|
||||
message_type=MessageType.TEXT,
|
||||
sender=BotUser(
|
||||
user_id=sender_email,
|
||||
username=sender_email,
|
||||
display_name=sender_email.split("@")[0] if sender_email else "unknown",
|
||||
),
|
||||
channel=BotChannel(
|
||||
channel_id=self._inbox_id,
|
||||
name=self._email_address or self._inbox_id,
|
||||
),
|
||||
thread_id=getattr(event, 'thread_id', None),
|
||||
metadata={
|
||||
"subject": getattr(event, 'subject', ''),
|
||||
"message_id": msg_id,
|
||||
"in_reply_to": getattr(event, 'in_reply_to', ''),
|
||||
"source": "websocket",
|
||||
},
|
||||
)
|
||||
|
||||
await self._handle_message(message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling WS event: {e}")
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# Webhook mode
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
async def _start_webhook_mode(self) -> None:
|
||||
"""Start an aiohttp webhook server to receive AgentMail events."""
|
||||
try:
|
||||
from aiohttp import web
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"aiohttp required for webhook mode. "
|
||||
"Install with: pip install aiohttp"
|
||||
)
|
||||
|
||||
app = web.Application()
|
||||
webhook_path = self.config.webhook_path or "/webhook"
|
||||
app.router.add_post(webhook_path, self._handle_email_webhook)
|
||||
app.router.add_get("/health", self._handle_health_check)
|
||||
|
||||
self._webhook_runner = web.AppRunner(app)
|
||||
await self._webhook_runner.setup()
|
||||
self._webhook_site = web.TCPSite(
|
||||
self._webhook_runner, "0.0.0.0", self._webhook_port
|
||||
)
|
||||
await self._webhook_site.start()
|
||||
|
||||
logger.info(
|
||||
f"Webhook server listening on 0.0.0.0:{self._webhook_port}{webhook_path}"
|
||||
)
|
||||
|
||||
# Register webhook with AgentMail API
|
||||
if self.config.webhook_url:
|
||||
try:
|
||||
client = self._get_client()
|
||||
webhook_url = self.config.webhook_url.rstrip("/") + webhook_path
|
||||
client.webhooks.create(
|
||||
url=webhook_url,
|
||||
event_types=["message.received"],
|
||||
inbox_ids=[self._inbox_id] if self._inbox_id else None,
|
||||
)
|
||||
logger.info(f"Registered webhook: {webhook_url}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to register webhook with AgentMail: {e}")
|
||||
|
||||
async def _handle_email_webhook(self, request: Any) -> Any:
|
||||
"""Handle incoming webhook POST from AgentMail."""
|
||||
from aiohttp import web
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return web.Response(status=400, text="Invalid JSON")
|
||||
|
||||
# Extract event type
|
||||
event_type = body.get("type", "")
|
||||
if event_type != "message.received":
|
||||
return web.Response(status=200, text="OK")
|
||||
|
||||
# Process asynchronously — return 200 immediately
|
||||
asyncio.create_task(self._process_webhook_payload(body))
|
||||
return web.Response(status=200, text="OK")
|
||||
|
||||
async def _process_webhook_payload(self, body: Dict[str, Any]) -> None:
|
||||
"""Process a webhook payload in the background."""
|
||||
try:
|
||||
data = body.get("data", body)
|
||||
msg_id = data.get("message_id", "")
|
||||
|
||||
if not msg_id:
|
||||
return
|
||||
|
||||
# Dedup
|
||||
if msg_id in self._processed_ids:
|
||||
return
|
||||
self._processed_ids.add(msg_id)
|
||||
|
||||
sender_raw = data.get("from", data.get("from_", ""))
|
||||
sender_email = extract_email_address(sender_raw)
|
||||
|
||||
headers = data.get("headers", {})
|
||||
if is_auto_reply(headers):
|
||||
return
|
||||
if is_blocked_sender(sender_email):
|
||||
return
|
||||
|
||||
body_text = data.get("extracted_text", "") or data.get("text", "")
|
||||
|
||||
message = BotMessage(
|
||||
message_id=msg_id,
|
||||
content=body_text,
|
||||
message_type=MessageType.TEXT,
|
||||
sender=BotUser(
|
||||
user_id=sender_email,
|
||||
username=sender_email,
|
||||
display_name=sender_email.split("@")[0] if sender_email else "unknown",
|
||||
),
|
||||
channel=BotChannel(
|
||||
channel_id=self._inbox_id,
|
||||
name=self._email_address or self._inbox_id,
|
||||
),
|
||||
thread_id=data.get("thread_id"),
|
||||
metadata={
|
||||
"subject": data.get("subject", ""),
|
||||
"message_id": msg_id,
|
||||
"in_reply_to": data.get("in_reply_to", ""),
|
||||
"source": "webhook",
|
||||
},
|
||||
)
|
||||
|
||||
await self._handle_message(message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing webhook payload: {e}")
|
||||
|
||||
async def _handle_health_check(self, request: Any) -> Any:
|
||||
"""Health check endpoint for webhook server."""
|
||||
from aiohttp import web
|
||||
import json
|
||||
|
||||
health = await self.health()
|
||||
return web.Response(
|
||||
status=200 if health.ok else 503,
|
||||
text=json.dumps(health.to_dict()),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
async def _check_new_messages(self) -> None:
|
||||
"""Check for and process new messages."""
|
||||
client = self._get_client()
|
||||
|
||||
try:
|
||||
# SDK v0.4.7: list() returns ListMessagesResponse with .messages list
|
||||
response = client.inboxes.messages.list(self._inbox_id)
|
||||
msg_list = response.messages if hasattr(response, 'messages') else response
|
||||
|
||||
for msg in msg_list:
|
||||
# SDK v0.4.7: Message uses .message_id, not .id
|
||||
msg_id = msg.message_id
|
||||
|
||||
# Skip already processed
|
||||
if msg_id in self._processed_ids:
|
||||
continue
|
||||
|
||||
self._processed_ids.add(msg_id)
|
||||
|
||||
# SDK v0.4.7: Message uses .from_ (not .from_address)
|
||||
sender_raw = getattr(msg, 'from_', '') or ''
|
||||
sender_email = extract_email_address(sender_raw)
|
||||
|
||||
# Skip auto-replies and blocked senders
|
||||
headers = getattr(msg, 'headers', {}) or {}
|
||||
if is_auto_reply(headers):
|
||||
logger.debug(f"Skipping auto-reply from {sender_email}")
|
||||
continue
|
||||
|
||||
if is_blocked_sender(sender_email):
|
||||
logger.debug(f"Skipping blocked sender: {sender_email}")
|
||||
continue
|
||||
|
||||
# Build BotMessage — SDK uses .text / .extracted_text, not .body
|
||||
body = getattr(msg, 'extracted_text', '') or getattr(msg, 'text', '') or ''
|
||||
|
||||
message = BotMessage(
|
||||
message_id=msg_id,
|
||||
content=body,
|
||||
message_type=MessageType.TEXT,
|
||||
sender=BotUser(
|
||||
user_id=sender_email,
|
||||
username=sender_email,
|
||||
display_name=sender_email.split("@")[0] if sender_email else "unknown",
|
||||
),
|
||||
channel=BotChannel(
|
||||
channel_id=self._inbox_id,
|
||||
name=self._email_address or self._inbox_id,
|
||||
),
|
||||
thread_id=getattr(msg, 'thread_id', None),
|
||||
metadata={
|
||||
"subject": getattr(msg, 'subject', ''),
|
||||
"message_id": msg_id,
|
||||
"in_reply_to": getattr(msg, 'in_reply_to', ''),
|
||||
},
|
||||
)
|
||||
|
||||
await self._handle_message(message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"AgentMail API error: {e}")
|
||||
|
||||
async def _handle_message(self, message: BotMessage) -> None:
|
||||
"""Handle an incoming email message."""
|
||||
# Fire message hooks
|
||||
self.fire_message_received(message)
|
||||
|
||||
# Check command prefix in subject
|
||||
subject = message.metadata.get("subject", "")
|
||||
if subject.startswith(self.config.command_prefix):
|
||||
command = subject[len(self.config.command_prefix):].split()[0].lower()
|
||||
if command in self._command_handlers:
|
||||
try:
|
||||
await self._command_handlers[command](message)
|
||||
except Exception as e:
|
||||
logger.error(f"Command handler error: {e}")
|
||||
return
|
||||
|
||||
# Call registered message handlers
|
||||
for handler in self._message_handlers:
|
||||
try:
|
||||
result = handler(message)
|
||||
if asyncio.iscoroutine(result):
|
||||
await result
|
||||
except Exception as e:
|
||||
logger.error(f"Message handler error: {e}")
|
||||
|
||||
# Process with agent if available
|
||||
if self._agent:
|
||||
sender_id = message.sender.user_id if message.sender else "unknown"
|
||||
body = message.content
|
||||
|
||||
try:
|
||||
response = await self._session.chat(
|
||||
self._agent,
|
||||
sender_id,
|
||||
body,
|
||||
)
|
||||
|
||||
if response:
|
||||
# Reply to sender
|
||||
reply_subject = f"Re: {subject}" if subject else "Re: Your message"
|
||||
await self.send_message(
|
||||
channel_id=sender_id,
|
||||
content={"subject": reply_subject, "body": response},
|
||||
reply_to=message.metadata.get("message_id"),
|
||||
thread_id=message.thread_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Agent processing error: {e}")
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
channel_id: str,
|
||||
content: Union[str, Dict[str, Any]],
|
||||
reply_to: Optional[str] = None,
|
||||
thread_id: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> BotMessage:
|
||||
"""Send an email message.
|
||||
|
||||
Args:
|
||||
channel_id: Recipient email address
|
||||
content: Message content (str or {"subject": ..., "body": ...})
|
||||
reply_to: Message-ID to reply to
|
||||
thread_id: Thread ID for conversation tracking
|
||||
|
||||
Returns:
|
||||
The sent message
|
||||
"""
|
||||
# Parse content
|
||||
if isinstance(content, dict):
|
||||
subject = content.get("subject", "Message from PraisonAI")
|
||||
body = content.get("body", "")
|
||||
else:
|
||||
subject = "Message from PraisonAI"
|
||||
body = str(content)
|
||||
|
||||
# Fire sending hook
|
||||
self.fire_message_sending(channel_id, body)
|
||||
|
||||
client = self._get_client()
|
||||
|
||||
# Send via AgentMail API
|
||||
# SDK v0.4.7: uses text= (not body=); reply_to= is Reply-To header
|
||||
# For replying to a message, use messages.reply() method
|
||||
try:
|
||||
if reply_to:
|
||||
# reply_to contains the original message_id — use reply() method
|
||||
result = client.inboxes.messages.reply(
|
||||
self._inbox_id,
|
||||
reply_to, # message_id of original message
|
||||
text=body,
|
||||
subject=subject,
|
||||
)
|
||||
else:
|
||||
result = client.inboxes.messages.send(
|
||||
self._inbox_id,
|
||||
to=channel_id,
|
||||
subject=subject,
|
||||
text=body,
|
||||
)
|
||||
# SendMessageResponse has .message_id
|
||||
message_id = getattr(result, 'message_id', str(uuid.uuid4()))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send email: {e}")
|
||||
raise
|
||||
|
||||
# Build response message
|
||||
bot_message = BotMessage(
|
||||
message_id=message_id,
|
||||
content=body,
|
||||
message_type=MessageType.TEXT,
|
||||
sender=self._bot_user,
|
||||
channel=BotChannel(channel_id=channel_id, name=channel_id),
|
||||
reply_to=reply_to,
|
||||
thread_id=thread_id,
|
||||
metadata={"subject": subject},
|
||||
)
|
||||
|
||||
# Fire sent hook
|
||||
self.fire_message_sent(channel_id, body, message_id)
|
||||
|
||||
return bot_message
|
||||
|
||||
async def edit_message(
|
||||
self,
|
||||
channel_id: str,
|
||||
message_id: str,
|
||||
content: str,
|
||||
**kwargs,
|
||||
) -> BotMessage:
|
||||
"""Edit a message (not supported for email - sends new message)."""
|
||||
logger.warning("Email does not support message editing. Sending new message.")
|
||||
return await self.send_message(channel_id, content, **kwargs)
|
||||
|
||||
async def delete_message(
|
||||
self,
|
||||
channel_id: str,
|
||||
message_id: str,
|
||||
**kwargs,
|
||||
) -> bool:
|
||||
"""Delete a message (not supported for email)."""
|
||||
logger.warning("Email does not support message deletion.")
|
||||
return False
|
||||
|
||||
def on_message(self, handler: Callable) -> Callable:
|
||||
"""Register a message handler.
|
||||
|
||||
Args:
|
||||
handler: Function to call for each message
|
||||
|
||||
Returns:
|
||||
The handler function
|
||||
"""
|
||||
self._message_handlers.append(handler)
|
||||
return handler
|
||||
|
||||
def on_command(self, command: str) -> Callable:
|
||||
"""Register a command handler.
|
||||
|
||||
Args:
|
||||
command: Command name (without prefix)
|
||||
|
||||
Returns:
|
||||
Decorator function
|
||||
"""
|
||||
def decorator(handler: Callable) -> Callable:
|
||||
self._command_handlers[command.lower()] = handler
|
||||
return handler
|
||||
return decorator
|
||||
|
||||
async def create_inbox(
|
||||
self,
|
||||
domain: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a new inbox programmatically.
|
||||
|
||||
Args:
|
||||
domain: Custom domain for the inbox
|
||||
**kwargs: Additional inbox parameters
|
||||
|
||||
Returns:
|
||||
Inbox details including id and email_address
|
||||
"""
|
||||
client = self._get_client()
|
||||
|
||||
params = {}
|
||||
if domain:
|
||||
params["domain"] = domain
|
||||
params.update(kwargs)
|
||||
|
||||
inbox = client.inboxes.create(**params)
|
||||
|
||||
# SDK v0.4.7: inbox_id IS the email address
|
||||
return {
|
||||
"id": inbox.inbox_id,
|
||||
"email_address": inbox.inbox_id,
|
||||
"display_name": getattr(inbox, "display_name", None),
|
||||
}
|
||||
|
||||
async def list_inboxes(self) -> List[Dict[str, Any]]:
|
||||
"""List all inboxes for this API key.
|
||||
|
||||
Returns:
|
||||
List of inbox details
|
||||
"""
|
||||
client = self._get_client()
|
||||
# SDK v0.4.7: list() returns response object with .inboxes list
|
||||
result = client.inboxes.list()
|
||||
inbox_list = result.inboxes if hasattr(result, 'inboxes') else result
|
||||
|
||||
return [
|
||||
{
|
||||
"id": inbox.inbox_id,
|
||||
"email_address": inbox.inbox_id,
|
||||
"display_name": getattr(inbox, "display_name", None),
|
||||
}
|
||||
for inbox in inbox_list
|
||||
]
|
||||
|
||||
async def delete_inbox(self, inbox_id: str) -> bool:
|
||||
"""Delete an inbox.
|
||||
|
||||
Args:
|
||||
inbox_id: ID of inbox to delete
|
||||
|
||||
Returns:
|
||||
True if deleted successfully
|
||||
"""
|
||||
client = self._get_client()
|
||||
|
||||
try:
|
||||
client.inboxes.delete(inbox_id)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete inbox: {e}")
|
||||
return False
|
||||
|
||||
async def probe(self) -> ProbeResult:
|
||||
"""Test AgentMail API connectivity."""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
client = self._get_client()
|
||||
# Test by listing inboxes
|
||||
client.inboxes.list()
|
||||
|
||||
elapsed_ms = (time.time() - start_time) * 1000
|
||||
|
||||
return ProbeResult(
|
||||
ok=True,
|
||||
platform="agentmail",
|
||||
elapsed_ms=elapsed_ms,
|
||||
bot_username=self._email_address,
|
||||
)
|
||||
except ImportError:
|
||||
return ProbeResult(
|
||||
ok=False,
|
||||
platform="agentmail",
|
||||
error="agentmail package not installed",
|
||||
)
|
||||
except Exception as e:
|
||||
return ProbeResult(
|
||||
ok=False,
|
||||
platform="agentmail",
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
async def health(self) -> HealthResult:
|
||||
"""Get health status of the AgentMail bot."""
|
||||
probe_result = await self.probe()
|
||||
|
||||
uptime = 0.0
|
||||
if self._started_at:
|
||||
uptime = time.time() - self._started_at
|
||||
|
||||
return HealthResult(
|
||||
ok=self._is_running and probe_result.ok,
|
||||
platform="agentmail",
|
||||
is_running=self._is_running,
|
||||
uptime_seconds=uptime,
|
||||
probe=probe_result,
|
||||
sessions=self._session.active_count() if hasattr(self._session, 'active_count') else 0,
|
||||
)
|
||||
@@ -45,12 +45,23 @@ _TOKEN_ENV_MAP = {
|
||||
"discord": "DISCORD_BOT_TOKEN",
|
||||
"slack": "SLACK_BOT_TOKEN",
|
||||
"whatsapp": "WHATSAPP_ACCESS_TOKEN",
|
||||
"email": "EMAIL_APP_PASSWORD",
|
||||
"agentmail": "AGENTMAIL_API_KEY",
|
||||
}
|
||||
|
||||
# Slack also needs an app token
|
||||
# Platform-specific extra env vars
|
||||
_EXTRA_ENV_MAP = {
|
||||
"slack": {"app_token": "SLACK_APP_TOKEN"},
|
||||
"whatsapp": {"phone_number_id": "WHATSAPP_PHONE_NUMBER_ID"},
|
||||
"email": {
|
||||
"email_address": "EMAIL_ADDRESS",
|
||||
"imap_server": "EMAIL_IMAP_SERVER",
|
||||
"smtp_server": "EMAIL_SMTP_SERVER",
|
||||
},
|
||||
"agentmail": {
|
||||
"inbox_id": "AGENTMAIL_INBOX_ID",
|
||||
"domain": "AGENTMAIL_DOMAIN",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -170,22 +181,30 @@ class Bot:
|
||||
current_tools = getattr(agent, 'tools', None) or []
|
||||
if not current_tools:
|
||||
try:
|
||||
from praisonaiagents.tools import (
|
||||
schedule_add, schedule_list, schedule_remove,
|
||||
)
|
||||
default_tools = [schedule_add, schedule_list, schedule_remove]
|
||||
|
||||
# Try to add search_web if available
|
||||
from praisonaiagents.tools.profiles import resolve_profiles
|
||||
from praisonaiagents.tools import ToolResolver
|
||||
tool_names = resolve_profiles("web", "schedule", "memory", "learning")
|
||||
resolver = ToolResolver()
|
||||
default_tools = resolver.resolve_many(tool_names)
|
||||
if default_tools:
|
||||
agent.tools = default_tools
|
||||
logger.debug(f"Bot: applied {len(default_tools)} default tools (via profiles) to agent '{getattr(agent, 'name', '?')}'")
|
||||
except Exception:
|
||||
# Fallback: hardcoded imports if profiles unavailable
|
||||
try:
|
||||
from praisonaiagents.tools import search_web
|
||||
default_tools.insert(0, search_web)
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
|
||||
agent.tools = default_tools
|
||||
logger.debug(f"Bot: applied default tools to agent '{getattr(agent, 'name', '?')}'")
|
||||
except ImportError:
|
||||
pass # Tools not available, skip
|
||||
from praisonaiagents.tools import (
|
||||
schedule_add, schedule_list, schedule_remove,
|
||||
)
|
||||
default_tools = [schedule_add, schedule_list, schedule_remove]
|
||||
try:
|
||||
from praisonaiagents.tools import search_web
|
||||
default_tools.insert(0, search_web)
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
agent.tools = default_tools
|
||||
logger.debug(f"Bot: applied default tools (fallback) to agent '{getattr(agent, 'name', '?')}'")
|
||||
except ImportError:
|
||||
pass # Tools not available, skip
|
||||
|
||||
return agent
|
||||
|
||||
|
||||
@@ -82,7 +82,15 @@ class DiscordBot(ChatCommandMixin, MessageHookMixin):
|
||||
self._message_handlers: List[Callable] = []
|
||||
self._command_handlers: Dict[str, Callable] = {}
|
||||
self._started_at: Optional[float] = None
|
||||
self._session: BotSessionManager = BotSessionManager()
|
||||
try:
|
||||
from praisonaiagents.session import get_default_session_store
|
||||
_store = get_default_session_store()
|
||||
except Exception:
|
||||
_store = None
|
||||
self._session: BotSessionManager = BotSessionManager(
|
||||
store=_store,
|
||||
platform="discord",
|
||||
)
|
||||
self._debouncer: InboundDebouncer = InboundDebouncer(
|
||||
debounce_ms=self.config.debounce_ms,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,598 @@
|
||||
"""
|
||||
Email Bot implementation for PraisonAI.
|
||||
|
||||
Provides an email bot runtime with IMAP polling and SMTP sending,
|
||||
command handling, and agent integration.
|
||||
|
||||
Requires: pip install praisonai[email]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import email
|
||||
import email.utils
|
||||
import hashlib
|
||||
import imaplib
|
||||
import logging
|
||||
import os
|
||||
import smtplib
|
||||
import time
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING, Union
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from praisonaiagents import Agent
|
||||
|
||||
from praisonai.bots._protocol_mixin import ChatCommandMixin, MessageHookMixin
|
||||
from praisonaiagents.bots import (
|
||||
BotConfig,
|
||||
BotMessage,
|
||||
BotUser,
|
||||
BotChannel,
|
||||
MessageType,
|
||||
ProbeResult,
|
||||
HealthResult,
|
||||
)
|
||||
|
||||
from ._session import BotSessionManager
|
||||
from ._email_utils import is_auto_reply, is_blocked_sender
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmailBot(ChatCommandMixin, MessageHookMixin):
|
||||
"""Email bot runtime for PraisonAI agents.
|
||||
|
||||
Connects an agent to email via IMAP/SMTP, handling incoming emails
|
||||
as messages and sending replies.
|
||||
|
||||
Example:
|
||||
from praisonai.bots import EmailBot
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(name="email_assistant")
|
||||
bot = EmailBot(
|
||||
token="app_password", # Gmail app password
|
||||
email_address="[email protected]",
|
||||
imap_server="imap.gmail.com",
|
||||
smtp_server="smtp.gmail.com",
|
||||
agent=agent,
|
||||
)
|
||||
|
||||
await bot.start()
|
||||
|
||||
Environment variables (if not passed directly):
|
||||
EMAIL_APP_PASSWORD: App password for authentication
|
||||
EMAIL_ADDRESS: Email address for the bot
|
||||
EMAIL_IMAP_SERVER: IMAP server (default: imap.gmail.com)
|
||||
EMAIL_SMTP_SERVER: SMTP server (default: smtp.gmail.com)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
token: str,
|
||||
agent: Optional["Agent"] = None,
|
||||
config: Optional[BotConfig] = None,
|
||||
email_address: Optional[str] = None,
|
||||
imap_server: Optional[str] = None,
|
||||
smtp_server: Optional[str] = None,
|
||||
imap_port: int = 993,
|
||||
smtp_port: int = 587,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the Email bot.
|
||||
|
||||
Args:
|
||||
token: App password for email authentication
|
||||
agent: Optional agent to handle messages
|
||||
config: Optional bot configuration
|
||||
email_address: Bot's email address (or EMAIL_ADDRESS env var)
|
||||
imap_server: IMAP server hostname (or EMAIL_IMAP_SERVER env var)
|
||||
smtp_server: SMTP server hostname (or EMAIL_SMTP_SERVER env var)
|
||||
imap_port: IMAP port (default: 993 for SSL)
|
||||
smtp_port: SMTP port (default: 587 for STARTTLS)
|
||||
**kwargs: Additional arguments for forward compatibility
|
||||
"""
|
||||
self._extra_kwargs = kwargs
|
||||
self._token = token
|
||||
self._agent = agent
|
||||
self.config = config or BotConfig(token=token)
|
||||
|
||||
# Email-specific config
|
||||
self._email_address = email_address or os.environ.get("EMAIL_ADDRESS", "")
|
||||
self._imap_server = imap_server or os.environ.get("EMAIL_IMAP_SERVER", "imap.gmail.com")
|
||||
self._smtp_server = smtp_server or os.environ.get("EMAIL_SMTP_SERVER", "smtp.gmail.com")
|
||||
self._imap_port = imap_port
|
||||
self._smtp_port = smtp_port
|
||||
|
||||
self._is_running = False
|
||||
self._bot_user: Optional[BotUser] = None
|
||||
self._imap: Optional[imaplib.IMAP4_SSL] = None
|
||||
|
||||
self._message_handlers: List[Callable] = []
|
||||
self._command_handlers: Dict[str, Callable] = {}
|
||||
self._started_at: Optional[float] = None
|
||||
self._processed_uids: set = set() # Track processed email UIDs
|
||||
|
||||
try:
|
||||
from praisonaiagents.session import get_default_session_store
|
||||
_store = get_default_session_store()
|
||||
except Exception:
|
||||
_store = None
|
||||
self._session: BotSessionManager = BotSessionManager(
|
||||
store=_store,
|
||||
platform="email",
|
||||
)
|
||||
|
||||
self._stop_event: Optional[asyncio.Event] = None
|
||||
self._poll_task: Optional[asyncio.Task] = None
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._is_running
|
||||
|
||||
@property
|
||||
def platform(self) -> str:
|
||||
return "email"
|
||||
|
||||
@property
|
||||
def bot_user(self) -> Optional[BotUser]:
|
||||
return self._bot_user
|
||||
|
||||
def set_agent(self, agent: "Agent") -> None:
|
||||
"""Set the agent that handles messages."""
|
||||
self._agent = agent
|
||||
|
||||
def get_agent(self) -> Optional["Agent"]:
|
||||
"""Get the current agent."""
|
||||
return self._agent
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the Email bot (begin IMAP polling)."""
|
||||
if self._is_running:
|
||||
logger.warning("EmailBot already running")
|
||||
return
|
||||
|
||||
if not self._email_address:
|
||||
raise ValueError(
|
||||
"Email address required. Set EMAIL_ADDRESS env var or pass email_address parameter."
|
||||
)
|
||||
|
||||
if not self._token:
|
||||
raise ValueError(
|
||||
"App password required. Set EMAIL_APP_PASSWORD env var or pass token parameter."
|
||||
)
|
||||
|
||||
# Test connection
|
||||
probe = await self.probe()
|
||||
if not probe.ok:
|
||||
raise ConnectionError(f"Failed to connect to email server: {probe.error}")
|
||||
|
||||
self._bot_user = BotUser(
|
||||
user_id=self._email_address,
|
||||
username=self._email_address,
|
||||
display_name=self._email_address.split("@")[0],
|
||||
is_bot=True,
|
||||
)
|
||||
|
||||
self._is_running = True
|
||||
self._started_at = time.time()
|
||||
self._stop_event = asyncio.Event()
|
||||
|
||||
# Start polling loop
|
||||
self._poll_task = asyncio.create_task(self._poll_loop())
|
||||
logger.info(f"EmailBot started for {self._email_address}")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the Email bot."""
|
||||
if not self._is_running:
|
||||
return
|
||||
|
||||
self._is_running = False
|
||||
if self._stop_event:
|
||||
self._stop_event.set()
|
||||
|
||||
if self._poll_task:
|
||||
self._poll_task.cancel()
|
||||
try:
|
||||
await self._poll_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if self._imap:
|
||||
try:
|
||||
self._imap.logout()
|
||||
except Exception:
|
||||
pass
|
||||
self._imap = None
|
||||
|
||||
logger.info("EmailBot stopped")
|
||||
|
||||
async def _poll_loop(self) -> None:
|
||||
"""Main polling loop for checking new emails."""
|
||||
poll_interval = self.config.polling_interval or 30.0 # Default 30s for email
|
||||
|
||||
while self._is_running:
|
||||
try:
|
||||
await self._check_new_emails()
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking emails: {e}")
|
||||
|
||||
# Wait for next poll or stop signal
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self._stop_event.wait(),
|
||||
timeout=poll_interval,
|
||||
)
|
||||
break # Stop event was set
|
||||
except asyncio.TimeoutError:
|
||||
continue # Normal timeout, continue polling
|
||||
|
||||
async def _check_new_emails(self) -> None:
|
||||
"""Check for new unread emails and process them."""
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
try:
|
||||
# Connect to IMAP
|
||||
imap = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: imaplib.IMAP4_SSL(self._imap_server, self._imap_port)
|
||||
)
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: imap.login(self._email_address, self._token)
|
||||
)
|
||||
await loop.run_in_executor(None, lambda: imap.select("INBOX"))
|
||||
|
||||
# Search for unread emails
|
||||
_, message_numbers = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: imap.search(None, "UNSEEN")
|
||||
)
|
||||
|
||||
for num in message_numbers[0].split():
|
||||
if not self._is_running:
|
||||
break
|
||||
|
||||
uid = num.decode()
|
||||
if uid in self._processed_uids:
|
||||
continue
|
||||
|
||||
try:
|
||||
_, msg_data = await loop.run_in_executor(
|
||||
None,
|
||||
lambda n=num: imap.fetch(n, "(RFC822)")
|
||||
)
|
||||
|
||||
if msg_data[0] is None:
|
||||
continue
|
||||
|
||||
raw_email = msg_data[0][1]
|
||||
email_message = email.message_from_bytes(raw_email)
|
||||
|
||||
# Check for auto-reply headers
|
||||
if self._is_auto_reply(email_message):
|
||||
logger.debug(f"Skipping auto-reply email: {uid}")
|
||||
self._processed_uids.add(uid)
|
||||
continue
|
||||
|
||||
# Parse sender
|
||||
from_header = email_message.get("From", "")
|
||||
sender_name, sender_email = email.utils.parseaddr(from_header)
|
||||
|
||||
# Check for blocked senders
|
||||
if self._is_blocked_sender(sender_email):
|
||||
logger.debug(f"Skipping blocked sender: {sender_email}")
|
||||
self._processed_uids.add(uid)
|
||||
continue
|
||||
|
||||
# Extract body
|
||||
body = self._extract_body(email_message)
|
||||
subject = email_message.get("Subject", "(No Subject)")
|
||||
message_id = email_message.get("Message-ID", uid)
|
||||
references = email_message.get("References", "")
|
||||
in_reply_to = email_message.get("In-Reply-To", "")
|
||||
|
||||
# Create BotMessage
|
||||
bot_message = BotMessage(
|
||||
message_id=message_id,
|
||||
content=f"Subject: {subject}\n\n{body}",
|
||||
message_type=MessageType.TEXT,
|
||||
sender=BotUser(
|
||||
user_id=sender_email,
|
||||
username=sender_email,
|
||||
display_name=sender_name or sender_email,
|
||||
),
|
||||
channel=BotChannel(
|
||||
channel_id=sender_email,
|
||||
name=sender_email,
|
||||
channel_type="dm",
|
||||
),
|
||||
reply_to=in_reply_to or None,
|
||||
thread_id=references or message_id,
|
||||
metadata={
|
||||
"subject": subject,
|
||||
"message_id": message_id,
|
||||
"references": references,
|
||||
"in_reply_to": in_reply_to,
|
||||
},
|
||||
)
|
||||
|
||||
self._processed_uids.add(uid)
|
||||
|
||||
# Process message
|
||||
await self._handle_message(bot_message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing email {uid}: {e}")
|
||||
|
||||
await loop.run_in_executor(None, imap.logout)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"IMAP error: {e}")
|
||||
|
||||
def _is_auto_reply(self, msg: email.message.Message) -> bool:
|
||||
"""Check if email is an auto-reply."""
|
||||
headers = {k: v for k, v in msg.items()}
|
||||
return is_auto_reply(headers)
|
||||
|
||||
def _is_blocked_sender(self, sender: str) -> bool:
|
||||
"""Check if sender should be blocked."""
|
||||
return is_blocked_sender(sender)
|
||||
|
||||
def _extract_body(self, msg: email.message.Message) -> str:
|
||||
"""Extract plain text body from email."""
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
content_type = part.get_content_type()
|
||||
if content_type == "text/plain":
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload:
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
return payload.decode(charset, errors="replace")
|
||||
else:
|
||||
payload = msg.get_payload(decode=True)
|
||||
if payload:
|
||||
charset = msg.get_content_charset() or "utf-8"
|
||||
return payload.decode(charset, errors="replace")
|
||||
return ""
|
||||
|
||||
async def _handle_message(self, message: BotMessage) -> None:
|
||||
"""Handle an incoming email message."""
|
||||
# Fire message hooks
|
||||
self.fire_message_received(message)
|
||||
|
||||
# Check command prefix in subject
|
||||
subject = message.metadata.get("subject", "")
|
||||
if subject.startswith(self.config.command_prefix):
|
||||
command = subject[len(self.config.command_prefix):].split()[0].lower()
|
||||
if command in self._command_handlers:
|
||||
try:
|
||||
await self._command_handlers[command](message)
|
||||
except Exception as e:
|
||||
logger.error(f"Command handler error: {e}")
|
||||
return
|
||||
|
||||
# Call registered message handlers
|
||||
for handler in self._message_handlers:
|
||||
try:
|
||||
result = handler(message)
|
||||
if asyncio.iscoroutine(result):
|
||||
await result
|
||||
except Exception as e:
|
||||
logger.error(f"Message handler error: {e}")
|
||||
|
||||
# Process with agent if available
|
||||
if self._agent:
|
||||
try:
|
||||
# Extract just the body for the agent
|
||||
body = message.content
|
||||
if body.startswith("Subject:"):
|
||||
body = body.split("\n\n", 1)[-1]
|
||||
|
||||
response = await self._session.chat(
|
||||
self._agent,
|
||||
message.sender.user_id,
|
||||
body,
|
||||
)
|
||||
|
||||
if response:
|
||||
# Reply to the email
|
||||
await self.send_message(
|
||||
channel_id=message.sender.user_id,
|
||||
content={
|
||||
"subject": f"Re: {message.metadata.get('subject', '')}",
|
||||
"body": response,
|
||||
},
|
||||
reply_to=message.message_id,
|
||||
thread_id=message.thread_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Agent processing error: {e}")
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
channel_id: str,
|
||||
content: Union[str, Dict[str, Any]],
|
||||
reply_to: Optional[str] = None,
|
||||
thread_id: Optional[str] = None,
|
||||
) -> BotMessage:
|
||||
"""Send an email message.
|
||||
|
||||
Args:
|
||||
channel_id: Recipient email address
|
||||
content: Message content (str or {"subject": ..., "body": ...})
|
||||
reply_to: Message-ID to reply to (sets In-Reply-To header)
|
||||
thread_id: References chain (sets References header)
|
||||
|
||||
Returns:
|
||||
The sent message
|
||||
"""
|
||||
# Fire sending hook
|
||||
content_str = content.get("body", "") if isinstance(content, dict) else str(content)
|
||||
self.fire_message_sending(channel_id, content_str)
|
||||
|
||||
# Parse content
|
||||
if isinstance(content, dict):
|
||||
subject = content.get("subject", "Message from PraisonAI")
|
||||
body = content.get("body", "")
|
||||
html = content.get("html")
|
||||
else:
|
||||
subject = "Message from PraisonAI"
|
||||
body = str(content)
|
||||
html = None
|
||||
|
||||
# Build email
|
||||
if html:
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg.attach(MIMEText(body, "plain"))
|
||||
msg.attach(MIMEText(html, "html"))
|
||||
else:
|
||||
msg = MIMEText(body, "plain")
|
||||
|
||||
msg["From"] = self._email_address
|
||||
msg["To"] = channel_id
|
||||
msg["Subject"] = subject
|
||||
|
||||
# Generate unique Message-ID
|
||||
domain = self._email_address.split("@")[-1]
|
||||
message_id = f"<{hashlib.md5(f'{time.time()}{channel_id}'.encode()).hexdigest()}@{domain}>"
|
||||
msg["Message-ID"] = message_id
|
||||
|
||||
# Threading headers
|
||||
if reply_to:
|
||||
msg["In-Reply-To"] = reply_to
|
||||
if thread_id:
|
||||
msg["References"] = thread_id
|
||||
|
||||
# Add auto-reply header to prevent loops
|
||||
msg["X-Auto-Reply"] = "yes"
|
||||
msg["Auto-Submitted"] = "auto-replied"
|
||||
|
||||
# Send via SMTP
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, self._send_smtp, msg)
|
||||
|
||||
# Create BotMessage for return
|
||||
bot_message = BotMessage(
|
||||
message_id=message_id,
|
||||
content=body,
|
||||
message_type=MessageType.TEXT,
|
||||
sender=self._bot_user,
|
||||
channel=BotChannel(channel_id=channel_id, name=channel_id),
|
||||
reply_to=reply_to,
|
||||
thread_id=thread_id,
|
||||
metadata={"subject": subject},
|
||||
)
|
||||
|
||||
# Fire sent hook
|
||||
self.fire_message_sent(channel_id, body, message_id)
|
||||
|
||||
return bot_message
|
||||
|
||||
def _send_smtp(self, msg: MIMEText) -> None:
|
||||
"""Send email via SMTP (sync, runs in executor)."""
|
||||
with smtplib.SMTP(self._smtp_server, self._smtp_port) as server:
|
||||
server.starttls()
|
||||
server.login(self._email_address, self._token)
|
||||
server.send_message(msg)
|
||||
|
||||
async def edit_message(
|
||||
self,
|
||||
channel_id: str,
|
||||
message_id: str,
|
||||
content: Union[str, Dict[str, Any]],
|
||||
) -> BotMessage:
|
||||
"""Edit a message (not supported for email - sends new message)."""
|
||||
logger.warning("Email does not support message editing. Sending new message.")
|
||||
return await self.send_message(channel_id, content)
|
||||
|
||||
async def delete_message(self, channel_id: str, message_id: str) -> bool:
|
||||
"""Delete a message (not supported for email)."""
|
||||
logger.warning("Email does not support message deletion.")
|
||||
return False
|
||||
|
||||
def on_message(self, handler: Callable[[BotMessage], Any]) -> Callable:
|
||||
"""Register a message handler."""
|
||||
self._message_handlers.append(handler)
|
||||
return handler
|
||||
|
||||
def on_command(self, command: str) -> Callable:
|
||||
"""Decorator to register a command handler.
|
||||
|
||||
Commands are detected from email subject lines starting with
|
||||
the command prefix (default: "/").
|
||||
|
||||
Example:
|
||||
@bot.on_command("status")
|
||||
async def handle_status(message):
|
||||
await bot.send_message(message.channel.channel_id, "Bot is running!")
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
self._command_handlers[command.lower()] = func
|
||||
return func
|
||||
return decorator
|
||||
|
||||
async def send_typing(self, channel_id: str) -> None:
|
||||
"""Send typing indicator (no-op for email)."""
|
||||
pass
|
||||
|
||||
async def get_user(self, user_id: str) -> Optional[BotUser]:
|
||||
"""Get user information."""
|
||||
return BotUser(user_id=user_id, username=user_id)
|
||||
|
||||
async def get_channel(self, channel_id: str) -> Optional[BotChannel]:
|
||||
"""Get channel information."""
|
||||
return BotChannel(channel_id=channel_id, name=channel_id, channel_type="dm")
|
||||
|
||||
async def probe(self) -> ProbeResult:
|
||||
"""Test email server connectivity."""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
# Test IMAP
|
||||
imap = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: imaplib.IMAP4_SSL(self._imap_server, self._imap_port)
|
||||
)
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: imap.login(self._email_address, self._token)
|
||||
)
|
||||
await loop.run_in_executor(None, imap.logout)
|
||||
|
||||
elapsed_ms = (time.time() - start_time) * 1000
|
||||
|
||||
return ProbeResult(
|
||||
ok=True,
|
||||
platform="email",
|
||||
elapsed_ms=elapsed_ms,
|
||||
bot_username=self._email_address,
|
||||
)
|
||||
except Exception as e:
|
||||
return ProbeResult(
|
||||
ok=False,
|
||||
platform="email",
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
async def health(self) -> HealthResult:
|
||||
"""Get health status of the email bot."""
|
||||
probe_result = await self.probe()
|
||||
|
||||
uptime = 0.0
|
||||
if self._started_at:
|
||||
uptime = time.time() - self._started_at
|
||||
|
||||
return HealthResult(
|
||||
ok=self._is_running and probe_result.ok,
|
||||
platform="email",
|
||||
is_running=self._is_running,
|
||||
uptime_seconds=uptime,
|
||||
probe=probe_result,
|
||||
sessions=self._session.active_count() if hasattr(self._session, 'active_count') else 0,
|
||||
)
|
||||
@@ -95,7 +95,15 @@ class SlackBot(ChatCommandMixin, MessageHookMixin):
|
||||
self._message_handlers: List[Callable] = []
|
||||
self._command_handlers: Dict[str, Callable] = {}
|
||||
self._started_at: Optional[float] = None
|
||||
self._session: BotSessionManager = BotSessionManager()
|
||||
try:
|
||||
from praisonaiagents.session import get_default_session_store
|
||||
_store = get_default_session_store()
|
||||
except Exception:
|
||||
_store = None
|
||||
self._session: BotSessionManager = BotSessionManager(
|
||||
store=_store,
|
||||
platform="slack",
|
||||
)
|
||||
self._debouncer: InboundDebouncer = InboundDebouncer(
|
||||
debounce_ms=self.config.debounce_ms,
|
||||
)
|
||||
|
||||
@@ -85,7 +85,15 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
|
||||
self._message_handlers: List[Callable] = []
|
||||
self._command_handlers: Dict[str, Callable] = {}
|
||||
self._started_at: Optional[float] = None
|
||||
self._session: BotSessionManager = BotSessionManager()
|
||||
try:
|
||||
from praisonaiagents.session import get_default_session_store
|
||||
_store = get_default_session_store()
|
||||
except Exception:
|
||||
_store = None
|
||||
self._session: BotSessionManager = BotSessionManager(
|
||||
store=_store,
|
||||
platform="telegram",
|
||||
)
|
||||
self._debouncer: InboundDebouncer = InboundDebouncer(
|
||||
debounce_ms=self.config.debounce_ms,
|
||||
)
|
||||
|
||||
@@ -130,7 +130,15 @@ class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
|
||||
self._is_running = False
|
||||
self._started_at: Optional[float] = None
|
||||
self._bot_user: Optional[BotUser] = None
|
||||
self._session_mgr = BotSessionManager()
|
||||
try:
|
||||
from praisonaiagents.session import get_default_session_store
|
||||
_store = get_default_session_store()
|
||||
except Exception:
|
||||
_store = None
|
||||
self._session_mgr = BotSessionManager(
|
||||
store=_store,
|
||||
platform="whatsapp",
|
||||
)
|
||||
self._message_handlers: List[Callable] = []
|
||||
self._runner: Any = None
|
||||
self._site: Any = None
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""PraisonAI Claw — bundled default configuration."""
|
||||
@@ -0,0 +1,112 @@
|
||||
"""PraisonAIUI — Default App (Dashboard with Agents + Feature Explorer).
|
||||
|
||||
Loaded by ``praisonai claw`` → ``aiui run app.py``.
|
||||
All built-in sidebar pages are enabled by default.
|
||||
|
||||
Agents: Researcher, Writer, Coder
|
||||
Custom page: Feature Explorer
|
||||
"""
|
||||
|
||||
import os
|
||||
import praisonaiui as aiui
|
||||
|
||||
# ── Dashboard style ─────────────────────────────────────────
|
||||
aiui.set_style("dashboard")
|
||||
aiui.set_pages([
|
||||
"chat", # Core AI chat
|
||||
"channels", # Messaging platforms
|
||||
"agents", # Agent management
|
||||
"skills", # Available tools
|
||||
"memory", # Agent memory
|
||||
"knowledge", # Knowledge base
|
||||
"cron", # Scheduled jobs
|
||||
"guardrails", # Safety rules
|
||||
"sessions", # Conversation history
|
||||
"usage", # Token usage & costs
|
||||
"config", # Runtime configuration
|
||||
"logs", # Server logs
|
||||
"debug", # System debug info
|
||||
])
|
||||
|
||||
# ── Agent definitions ───────────────────────────────────────
|
||||
AGENTS = [
|
||||
{
|
||||
"agent_id": "researcher",
|
||||
"name": "Researcher",
|
||||
"description": "Research assistant — finds and summarizes information",
|
||||
"instructions": (
|
||||
"You are a research assistant. Provide well-structured answers "
|
||||
"with key facts. Keep responses concise but informative."
|
||||
),
|
||||
"model": os.getenv("PRAISONAI_MODEL", "gpt-4o-mini"),
|
||||
"icon": "🔬",
|
||||
},
|
||||
{
|
||||
"agent_id": "writer",
|
||||
"name": "Writer",
|
||||
"description": "Creative writer — generates content on any topic",
|
||||
"instructions": (
|
||||
"You are a creative writer. Write engaging content in a clear, "
|
||||
"professional style. Keep responses focused and well-organized."
|
||||
),
|
||||
"model": os.getenv("PRAISONAI_MODEL", "gpt-4o-mini"),
|
||||
"icon": "✍️",
|
||||
},
|
||||
{
|
||||
"agent_id": "coder",
|
||||
"name": "Coder",
|
||||
"description": "Coding assistant — Python, JavaScript, and more",
|
||||
"instructions": (
|
||||
"You are a coding assistant. Write clean, well-commented "
|
||||
"code with explanations. Support Python, JavaScript, and "
|
||||
"other popular languages."
|
||||
),
|
||||
"model": os.getenv("PRAISONAI_MODEL", "gpt-4o-mini"),
|
||||
"icon": "💻",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
||||
# ── Custom page: Feature Explorer ───────────────────────────
|
||||
|
||||
@aiui.page(
|
||||
"explorer",
|
||||
title="Explorer",
|
||||
icon="🔬",
|
||||
group="Control",
|
||||
order=55,
|
||||
)
|
||||
async def explorer_page():
|
||||
"""Three-column feature explorer — custom view renders in JS."""
|
||||
return aiui.layout([
|
||||
aiui.text("Feature Explorer loads via custom view module."),
|
||||
])
|
||||
|
||||
|
||||
# ── Register agents in dashboard CRUD ───────────────────────
|
||||
|
||||
def _register_agents_in_dashboard():
|
||||
"""Bridge agents into the dashboard agents_crud feature."""
|
||||
from praisonaiui.features.agents import get_agent_registry
|
||||
|
||||
registry = get_agent_registry()
|
||||
existing = registry.list_all()
|
||||
existing_names = {a.get("name") for a in existing}
|
||||
|
||||
for agent_def in AGENTS:
|
||||
if agent_def["name"] in existing_names:
|
||||
continue
|
||||
registry.create({
|
||||
"name": agent_def["name"],
|
||||
"description": agent_def["description"],
|
||||
"instructions": agent_def["instructions"],
|
||||
"model": agent_def["model"],
|
||||
"icon": agent_def["icon"],
|
||||
})
|
||||
print(f" ✓ Agent: {agent_def['icon']} {agent_def['name']}")
|
||||
|
||||
try:
|
||||
_register_agents_in_dashboard()
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Agent registration failed (non-fatal): {e}")
|
||||
@@ -229,6 +229,7 @@ def register_commands():
|
||||
from .commands.browser import app as browser_app
|
||||
from .commands.plugins import app as plugins_app
|
||||
from .commands.sandbox import app as sandbox_app
|
||||
from .commands.claw import app as claw_app
|
||||
|
||||
# Import TUI and queue commands
|
||||
from .features.tui.debug import create_debug_app as create_tui_debug_app
|
||||
@@ -406,6 +407,7 @@ def register_commands():
|
||||
app.add_typer(browser_app, name="browser", help="Browser control for agent automation")
|
||||
app.add_typer(plugins_app, name="plugins", help="Plugin management and inspection")
|
||||
app.add_typer(sandbox_app, name="sandbox", help="Sandbox container management")
|
||||
app.add_typer(claw_app, name="claw", help="🦞 PraisonAI Dashboard (full UI)")
|
||||
|
||||
# Register standardise command
|
||||
try:
|
||||
|
||||
@@ -355,6 +355,89 @@ def bot_whatsapp(
|
||||
)
|
||||
|
||||
|
||||
@app.command("email")
|
||||
def bot_email(
|
||||
token: Optional[str] = typer.Option(None, "--token", "-t", help="Email app password", envvar="EMAIL_APP_PASSWORD"),
|
||||
email_address: Optional[str] = typer.Option(None, "--email", help="Email address", envvar="EMAIL_ADDRESS"),
|
||||
imap_server: Optional[str] = typer.Option(None, "--imap", help="IMAP server"),
|
||||
smtp_server: Optional[str] = typer.Option(None, "--smtp", help="SMTP server"),
|
||||
agent: Optional[str] = typer.Option(None, "--agent", "-a", help="Agent YAML configuration file"),
|
||||
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
|
||||
tools: Optional[List[str]] = typer.Option(None, "--tools", help="Tools to enable"),
|
||||
memory: bool = typer.Option(False, "--memory", help="Enable memory"),
|
||||
knowledge: bool = typer.Option(False, "--knowledge", help="Enable knowledge/RAG"),
|
||||
web_search: bool = typer.Option(False, "--web", "--web-search", help="Enable web search"),
|
||||
auto_approve: bool = typer.Option(False, "--auto-approve", help="Auto-approve all tool executions"),
|
||||
):
|
||||
"""Start an Email bot (IMAP/SMTP).
|
||||
|
||||
Examples:
|
||||
praisonai bot email --token $EMAIL_APP_PASSWORD --email user@gmail.com
|
||||
praisonai bot email --agent agents.yaml --memory
|
||||
"""
|
||||
from ..features.bots_cli import BotHandler, BotCapabilities
|
||||
|
||||
capabilities = BotCapabilities(
|
||||
tools=tools or [],
|
||||
memory=memory,
|
||||
knowledge=knowledge,
|
||||
web_search=web_search,
|
||||
auto_approve=auto_approve,
|
||||
model=model,
|
||||
)
|
||||
|
||||
handler = BotHandler()
|
||||
handler.start_email(
|
||||
token=token,
|
||||
agent_file=agent,
|
||||
capabilities=capabilities,
|
||||
email_address=email_address,
|
||||
imap_server=imap_server,
|
||||
smtp_server=smtp_server,
|
||||
)
|
||||
|
||||
|
||||
@app.command("agentmail")
|
||||
def bot_agentmail(
|
||||
token: Optional[str] = typer.Option(None, "--token", "-t", help="AgentMail API key", envvar="AGENTMAIL_API_KEY"),
|
||||
inbox_id: Optional[str] = typer.Option(None, "--inbox", help="Existing inbox ID / email address", envvar="AGENTMAIL_INBOX_ID"),
|
||||
domain: Optional[str] = typer.Option(None, "--domain", help="Custom domain for new inboxes", envvar="AGENTMAIL_DOMAIN"),
|
||||
agent: Optional[str] = typer.Option(None, "--agent", "-a", help="Agent YAML configuration file"),
|
||||
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
|
||||
tools: Optional[List[str]] = typer.Option(None, "--tools", help="Tools to enable"),
|
||||
memory: bool = typer.Option(False, "--memory", help="Enable memory"),
|
||||
knowledge: bool = typer.Option(False, "--knowledge", help="Enable knowledge/RAG"),
|
||||
web_search: bool = typer.Option(False, "--web", "--web-search", help="Enable web search"),
|
||||
auto_approve: bool = typer.Option(False, "--auto-approve", help="Auto-approve all tool executions"),
|
||||
):
|
||||
"""Start an AgentMail bot (API-first email for AI agents).
|
||||
|
||||
Examples:
|
||||
praisonai bot agentmail --token $AGENTMAIL_API_KEY
|
||||
praisonai bot agentmail --inbox praison@agentmail.to
|
||||
praisonai bot agentmail --agent agents.yaml --memory
|
||||
"""
|
||||
from ..features.bots_cli import BotHandler, BotCapabilities
|
||||
|
||||
capabilities = BotCapabilities(
|
||||
tools=tools or [],
|
||||
memory=memory,
|
||||
knowledge=knowledge,
|
||||
web_search=web_search,
|
||||
auto_approve=auto_approve,
|
||||
model=model,
|
||||
)
|
||||
|
||||
handler = BotHandler()
|
||||
handler.start_agentmail(
|
||||
token=token,
|
||||
agent_file=agent,
|
||||
capabilities=capabilities,
|
||||
inbox_id=inbox_id,
|
||||
domain=domain,
|
||||
)
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def bot_callback(ctx: typer.Context):
|
||||
"""Show bot help if no subcommand provided."""
|
||||
@@ -369,6 +452,8 @@ Start a bot on any platform with: praisonai bot <platform>
|
||||
[green]discord[/green] Discord Bot API
|
||||
[green]slack[/green] Slack Socket Mode
|
||||
[green]whatsapp[/green] WhatsApp Cloud API or Web mode (QR scan)
|
||||
[green]email[/green] Email via IMAP/SMTP
|
||||
[green]agentmail[/green] AgentMail API (API-first email for AI agents)
|
||||
|
||||
[bold]Capability Options (all platforms):[/bold]
|
||||
[yellow]--agent FILE[/yellow] Agent YAML configuration
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
Claw command — launch the PraisonAI 🦞 Dashboard.
|
||||
|
||||
Usage:
|
||||
praisonai claw # Dashboard on :8082
|
||||
praisonai claw --port 9000 # Custom port
|
||||
praisonai claw --app my.py # Custom app file
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
app = typer.Typer(help="🦞 PraisonAI Dashboard (full UI)")
|
||||
|
||||
CLAW_DIR = Path.home() / ".praisonai" / "claw"
|
||||
DEFAULT_APP = CLAW_DIR / "app.py"
|
||||
|
||||
|
||||
def _ensure_default_app() -> Path:
|
||||
"""Copy bundled default_app.py to ~/.praisonai/claw/app.py if missing."""
|
||||
if DEFAULT_APP.exists():
|
||||
return DEFAULT_APP
|
||||
|
||||
CLAW_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Read the bundled default
|
||||
bundled = Path(__file__).parent.parent.parent / "claw" / "default_app.py"
|
||||
if not bundled.exists():
|
||||
print(f"[red]ERROR: Bundled default_app.py not found at {bundled}[/red]")
|
||||
raise typer.Abort()
|
||||
|
||||
DEFAULT_APP.write_text(bundled.read_text())
|
||||
print(f" ✓ Created default dashboard config: {DEFAULT_APP}")
|
||||
return DEFAULT_APP
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def claw(
|
||||
ctx: typer.Context,
|
||||
port: int = typer.Option(8082, "--port", "-p", help="Port to run dashboard on"),
|
||||
host: str = typer.Option("0.0.0.0", "--host", help="Host to bind to"),
|
||||
app_file: Optional[str] = typer.Option(
|
||||
None, "--app", "-a", help="Custom app.py file (default: ~/.praisonai/claw/app.py)"
|
||||
),
|
||||
reload: bool = typer.Option(False, "--reload", "-r", help="Enable auto-reload"),
|
||||
):
|
||||
"""
|
||||
Launch the PraisonAI 🦞 Dashboard.
|
||||
|
||||
Starts the AIUI dashboard with chat, agents, memory, knowledge,
|
||||
channels, cron, guardrails, and more.
|
||||
|
||||
Examples:
|
||||
praisonai claw
|
||||
praisonai claw --port 9000
|
||||
praisonai claw --app my-dashboard.py
|
||||
"""
|
||||
if ctx.invoked_subcommand is not None:
|
||||
return
|
||||
|
||||
# 1. Check praisonaiui is installed
|
||||
try:
|
||||
import importlib.util
|
||||
if importlib.util.find_spec("praisonaiui") is None:
|
||||
raise ImportError
|
||||
except ImportError:
|
||||
print("\n\033[91mERROR: PraisonAI Dashboard (aiui) is not installed.\033[0m")
|
||||
print('\nInstall with:\n pip install "praisonai[claw]"\n')
|
||||
sys.exit(1)
|
||||
|
||||
# 2. Resolve app file
|
||||
if app_file:
|
||||
resolved = Path(app_file)
|
||||
if not resolved.exists():
|
||||
print(f"\033[91mERROR: App file not found: {app_file}\033[0m")
|
||||
sys.exit(1)
|
||||
else:
|
||||
resolved = _ensure_default_app()
|
||||
|
||||
# 3. Launch via aiui run
|
||||
import subprocess
|
||||
|
||||
cmd = ["aiui", "run", str(resolved), "--port", str(port), "--host", host]
|
||||
if reload:
|
||||
cmd.append("--reload")
|
||||
|
||||
print(f"\n🦞 PraisonAI Dashboard starting at http://{host}:{port}")
|
||||
print(f" App: {resolved}\n")
|
||||
|
||||
try:
|
||||
subprocess.run(cmd, check=True)
|
||||
except FileNotFoundError:
|
||||
# Fallback: python -m praisonaiui.cli
|
||||
cmd = [sys.executable, "-m", "praisonaiui.cli", "run", str(resolved),
|
||||
"--port", str(port), "--host", host]
|
||||
if reload:
|
||||
cmd.append("--reload")
|
||||
subprocess.run(cmd, check=True)
|
||||
except KeyboardInterrupt:
|
||||
print("\n🦞 Dashboard stopped.")
|
||||
@@ -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)
|
||||
Loaded 100 of 139 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user