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 | ||
|
|
ad8ee02b5a | ||
|
|
02b9c64c0a |
No files matched your search
@@ -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.38" \
|
||||
"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.38" \
|
||||
"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.38" \
|
||||
"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
|
||||
@@ -1045,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
|
||||
|
||||
@@ -1322,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()
|
||||
@@ -1335,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(
|
||||
@@ -1354,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. "
|
||||
@@ -2020,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
|
||||
@@ -2047,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,
|
||||
@@ -2074,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
|
||||
@@ -2434,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()
|
||||
|
||||
@@ -2456,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(
|
||||
@@ -2486,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,
|
||||
@@ -2541,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
|
||||
@@ -2622,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
|
||||
@@ -2767,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)
|
||||
@@ -2782,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(
|
||||
@@ -2811,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,
|
||||
@@ -2866,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
|
||||
@@ -2922,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)
|
||||
|
||||
@@ -4151,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
|
||||
@@ -4503,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}")
|
||||
|
||||
@@ -4520,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
|
||||
@@ -4610,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(
|
||||
@@ -4632,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
|
||||
@@ -5542,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
|
||||
@@ -6023,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)
|
||||
|
||||
@@ -6055,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:
|
||||
@@ -6742,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')
|
||||
@@ -7011,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()
|
||||
@@ -7743,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()
|
||||
|
||||
@@ -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
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -24,6 +24,7 @@ if TYPE_CHECKING:
|
||||
from .store import FileScheduleStore
|
||||
from .parser import parse_schedule
|
||||
from .runner import ScheduleRunner
|
||||
from .loop import ScheduleLoop
|
||||
|
||||
_module_cache = {}
|
||||
|
||||
@@ -55,6 +56,11 @@ 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}")
|
||||
|
||||
|
||||
@@ -65,4 +71,5 @@ __all__ = [
|
||||
"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
|
||||
)
|
||||
@@ -60,11 +60,14 @@ class DeliveryTarget:
|
||||
``"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] = {
|
||||
@@ -73,6 +76,8 @@ class DeliveryTarget:
|
||||
}
|
||||
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
|
||||
@@ -81,6 +86,7 @@ class DeliveryTarget:
|
||||
channel=d.get("channel", ""),
|
||||
channel_id=d.get("channel_id", ""),
|
||||
thread_id=d.get("thread_id"),
|
||||
session_id=d.get("session_id"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ def schedule_add(
|
||||
message: str = "",
|
||||
channel: str = "",
|
||||
channel_id: str = "",
|
||||
agent_id: str = "",
|
||||
session_id: str = "",
|
||||
) -> str:
|
||||
"""Add a new scheduled job.
|
||||
|
||||
@@ -59,6 +61,10 @@ def schedule_add(
|
||||
"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.
|
||||
@@ -72,11 +78,17 @@ def schedule_add(
|
||||
delivery = None
|
||||
if channel and channel_id:
|
||||
delivery = DeliveryTarget(
|
||||
channel=channel, channel_id=channel_id,
|
||||
channel=channel,
|
||||
channel_id=channel_id,
|
||||
session_id=session_id or None,
|
||||
)
|
||||
|
||||
job = ScheduleJob(
|
||||
name=name, schedule=sched, message=message, delivery=delivery,
|
||||
name=name,
|
||||
schedule=sched,
|
||||
message=message,
|
||||
agent_id=agent_id or None,
|
||||
delivery=delivery,
|
||||
)
|
||||
|
||||
store = _get_store()
|
||||
@@ -88,7 +100,8 @@ def schedule_add(
|
||||
|
||||
store.add(job)
|
||||
delivery_note = f" → deliver to {channel}:{channel_id}" if delivery else ""
|
||||
return f"Schedule '{name}' added (id: {job.id}, {schedule}{delivery_note})."
|
||||
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:
|
||||
@@ -121,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
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "1.5.38"
|
||||
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()
|
||||
@@ -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,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
|
||||
@@ -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.38"
|
||||
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"
|
||||
|
||||
@@ -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.38.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.38.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.")
|
||||
@@ -42,15 +42,17 @@ def schedule_add_cmd(
|
||||
name: str = typer.Argument(..., help="Schedule name (e.g. 'morning-hello')"),
|
||||
schedule: str = typer.Option(..., "--schedule", "-s", help="When to run: 'hourly', 'daily', '*/30m', 'cron:0 9 * * *', 'at:2026-03-01T09:00', 'in 20 minutes'"),
|
||||
message: str = typer.Option("", "--message", "-m", help="Prompt / reminder text"),
|
||||
agent: str = typer.Option("", "--agent", "-a", help="Agent ID to execute this job (default: first registered agent)"),
|
||||
channel: str = typer.Option("", "--channel", help="Delivery platform: telegram, discord, slack, whatsapp"),
|
||||
channel_id: str = typer.Option("", "--channel-id", help="Target chat/channel ID on the platform"),
|
||||
session_id: str = typer.Option("", "--session-id", help="Session ID to preserve conversation context"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
||||
):
|
||||
"""Add a job to the schedule store (with optional delivery target).
|
||||
|
||||
Examples:
|
||||
praisonai schedule add "morning-hello" -s "cron:0 9 * * *" -m "say hello"
|
||||
praisonai schedule add "tg-reminder" -s daily -m "check email" --channel telegram --channel-id 12345
|
||||
praisonai schedule add "tg-reminder" -s daily -m "check email" --agent support --channel telegram --channel-id 12345
|
||||
"""
|
||||
output = get_output_controller()
|
||||
try:
|
||||
@@ -60,8 +62,10 @@ def schedule_add_cmd(
|
||||
name=name,
|
||||
schedule=schedule,
|
||||
message=message,
|
||||
agent_id=agent,
|
||||
channel=channel,
|
||||
channel_id=channel_id,
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
if json_output:
|
||||
|
||||
@@ -190,12 +190,25 @@ class BotHandler:
|
||||
|
||||
platform = config.get("platform")
|
||||
if not platform:
|
||||
print("Error: 'platform' is required in bot config (telegram, discord, or slack)")
|
||||
return
|
||||
# Also try channels-style config (multi-channel format)
|
||||
channels = config.get("channels")
|
||||
if channels and isinstance(channels, dict):
|
||||
# Pick first channel as platform
|
||||
platform = next(iter(channels))
|
||||
# Merge channel config into top level for token resolution
|
||||
channel_conf = channels[platform] or {}
|
||||
if "token" not in config and channel_conf.get("token"):
|
||||
config["token"] = channel_conf["token"]
|
||||
if "bot_token" not in config and channel_conf.get("bot_token"):
|
||||
config["token"] = channel_conf["bot_token"]
|
||||
else:
|
||||
print("Error: 'platform' or 'channels' is required in bot config")
|
||||
return
|
||||
|
||||
platform = platform.lower().strip()
|
||||
if platform not in ("telegram", "discord", "slack", "whatsapp"):
|
||||
print(f"Error: Unknown platform '{platform}'. Use: telegram, discord, slack, whatsapp")
|
||||
valid_platforms = ("telegram", "discord", "slack", "whatsapp", "email", "agentmail")
|
||||
if platform not in valid_platforms:
|
||||
print(f"Error: Unknown platform '{platform}'. Use: {', '.join(valid_platforms)}")
|
||||
return
|
||||
|
||||
token = resolve_env_vars(config.get("token", ""))
|
||||
@@ -272,6 +285,25 @@ class BotHandler:
|
||||
allowed_groups=wa_allowed_groups,
|
||||
respond_to_all=bool(wa_respond_to_all),
|
||||
)
|
||||
elif platform == "email":
|
||||
self.start_email(
|
||||
token=token or None,
|
||||
agent_file=None,
|
||||
capabilities=capabilities,
|
||||
agent_config_dict=agent_config,
|
||||
email_address=resolve_env_vars(config.get("email_address", "")),
|
||||
imap_server=resolve_env_vars(config.get("imap_server", "")),
|
||||
smtp_server=resolve_env_vars(config.get("smtp_server", "")),
|
||||
)
|
||||
elif platform == "agentmail":
|
||||
self.start_agentmail(
|
||||
token=token or None,
|
||||
agent_file=None,
|
||||
capabilities=capabilities,
|
||||
agent_config_dict=agent_config,
|
||||
inbox_id=resolve_env_vars(config.get("inbox_id", "")),
|
||||
domain=resolve_env_vars(config.get("domain", "")),
|
||||
)
|
||||
|
||||
def start_telegram(
|
||||
self,
|
||||
@@ -522,6 +554,117 @@ class BotHandler:
|
||||
except Exception:
|
||||
pass # neonize Go threads may already be gone
|
||||
|
||||
def start_email(
|
||||
self,
|
||||
token: Optional[str] = None,
|
||||
agent_file: Optional[str] = None,
|
||||
capabilities: Optional[BotCapabilities] = None,
|
||||
agent_config_dict: Optional[Dict[str, Any]] = None,
|
||||
email_address: Optional[str] = None,
|
||||
imap_server: Optional[str] = None,
|
||||
smtp_server: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Start an Email bot (IMAP/SMTP).
|
||||
|
||||
Args:
|
||||
token: Email app password (or EMAIL_APP_PASSWORD env var)
|
||||
agent_file: Optional path to agent config file
|
||||
capabilities: Optional capabilities configuration
|
||||
agent_config_dict: Optional agent config dict from bot YAML
|
||||
email_address: Email address to use
|
||||
imap_server: IMAP server hostname
|
||||
smtp_server: SMTP server hostname
|
||||
"""
|
||||
self._load_dotenv()
|
||||
token = token or os.environ.get("EMAIL_APP_PASSWORD")
|
||||
if not token:
|
||||
print("Error: Email app password required")
|
||||
print("Provide via --token or EMAIL_APP_PASSWORD environment variable")
|
||||
return
|
||||
|
||||
if capabilities and capabilities.auto_approve:
|
||||
os.environ["PRAISONAI_AUTO_APPROVE"] = "true"
|
||||
|
||||
try:
|
||||
from praisonai.bots import EmailBot
|
||||
except ImportError as e:
|
||||
print(f"Error: EmailBot import failed. {e}")
|
||||
return
|
||||
|
||||
agent = self._load_agent(agent_file, capabilities, agent_config_dict=agent_config_dict)
|
||||
bot = EmailBot(
|
||||
token=token,
|
||||
agent=agent,
|
||||
email_address=email_address or None,
|
||||
imap_server=imap_server or None,
|
||||
smtp_server=smtp_server or None,
|
||||
)
|
||||
|
||||
self._print_startup_info("Email (IMAP/SMTP)", capabilities)
|
||||
|
||||
try:
|
||||
asyncio.run(bot.start())
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopping bot...")
|
||||
asyncio.run(bot.stop())
|
||||
|
||||
def start_agentmail(
|
||||
self,
|
||||
token: Optional[str] = None,
|
||||
agent_file: Optional[str] = None,
|
||||
capabilities: Optional[BotCapabilities] = None,
|
||||
agent_config_dict: Optional[Dict[str, Any]] = None,
|
||||
inbox_id: Optional[str] = None,
|
||||
domain: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Start an AgentMail bot (API-first email).
|
||||
|
||||
Args:
|
||||
token: AgentMail API key (or AGENTMAIL_API_KEY env var)
|
||||
agent_file: Optional path to agent config file
|
||||
capabilities: Optional capabilities configuration
|
||||
agent_config_dict: Optional agent config dict from bot YAML
|
||||
inbox_id: Existing inbox ID / email address to reuse
|
||||
domain: Custom domain for new inboxes
|
||||
"""
|
||||
self._load_dotenv()
|
||||
token = token or os.environ.get("AGENTMAIL_API_KEY")
|
||||
if not token:
|
||||
print("Error: AgentMail API key required")
|
||||
print("Provide via --token or AGENTMAIL_API_KEY environment variable")
|
||||
return
|
||||
|
||||
if capabilities and capabilities.auto_approve:
|
||||
os.environ["PRAISONAI_AUTO_APPROVE"] = "true"
|
||||
|
||||
try:
|
||||
from praisonai.bots import AgentMailBot
|
||||
except ImportError as e:
|
||||
print(f"Error: AgentMailBot import failed. {e}")
|
||||
print("Install with: pip install agentmail")
|
||||
return
|
||||
|
||||
inbox_id = inbox_id or os.environ.get("AGENTMAIL_INBOX_ID")
|
||||
domain = domain or os.environ.get("AGENTMAIL_DOMAIN")
|
||||
|
||||
agent = self._load_agent(agent_file, capabilities, agent_config_dict=agent_config_dict)
|
||||
bot = AgentMailBot(
|
||||
token=token,
|
||||
agent=agent,
|
||||
inbox_id=inbox_id,
|
||||
domain=domain,
|
||||
)
|
||||
|
||||
self._print_startup_info("AgentMail", capabilities)
|
||||
if inbox_id:
|
||||
print(f"Inbox: {inbox_id}")
|
||||
|
||||
try:
|
||||
asyncio.run(bot.start())
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopping bot...")
|
||||
asyncio.run(bot.stop())
|
||||
|
||||
def _get_agent_kwargs(self, capabilities: Optional[BotCapabilities]) -> Dict[str, Any]:
|
||||
"""Extract Agent constructor kwargs from BotCapabilities (DRY).
|
||||
|
||||
|
||||
@@ -5,10 +5,25 @@ Provides Model Context Protocol server integration.
|
||||
Usage: praisonai "prompt" --mcp "npx -y @modelcontextprotocol/server-filesystem ."
|
||||
"""
|
||||
|
||||
import os
|
||||
import shlex
|
||||
from typing import Any, Dict, Tuple, List
|
||||
from .base import FlagHandler
|
||||
|
||||
# Executables allowed as MCP server commands from the CLI.
|
||||
# The SDK MCP() class is intentionally unrestricted — developers control their own inputs.
|
||||
ALLOWED_MCP_COMMANDS = {
|
||||
"npx", "npx.cmd", "npx.exe",
|
||||
"node", "node.exe",
|
||||
"python", "python3", "python.exe", "python3.exe",
|
||||
"uvx", "uvx.exe",
|
||||
"uv", "uv.exe",
|
||||
"docker", "docker.exe",
|
||||
"deno", "deno.exe",
|
||||
"bun", "bun.exe",
|
||||
"pipx",
|
||||
}
|
||||
|
||||
|
||||
class MCPHandler(FlagHandler):
|
||||
"""
|
||||
@@ -56,6 +71,9 @@ class MCPHandler(FlagHandler):
|
||||
|
||||
Returns:
|
||||
Tuple of (command, args, env_dict)
|
||||
|
||||
Raises:
|
||||
ValueError: If the command executable is not in the allowed list.
|
||||
"""
|
||||
# Parse command using shell-like splitting
|
||||
parts = shlex.split(command)
|
||||
@@ -65,6 +83,14 @@ class MCPHandler(FlagHandler):
|
||||
cmd = parts[0]
|
||||
args = parts[1:] if len(parts) > 1 else []
|
||||
|
||||
# Validate executable against allowlist
|
||||
basename = os.path.basename(cmd)
|
||||
if basename not in ALLOWED_MCP_COMMANDS:
|
||||
raise ValueError(
|
||||
f"Command '{cmd}' is not in the allowed MCP executables list. "
|
||||
f"Allowed: {', '.join(sorted(ALLOWED_MCP_COMMANDS - {c for c in ALLOWED_MCP_COMMANDS if '.' in c}))}"
|
||||
)
|
||||
|
||||
# Parse environment variables
|
||||
env = {}
|
||||
if env_vars:
|
||||
@@ -94,7 +120,11 @@ class MCPHandler(FlagHandler):
|
||||
|
||||
from praisonaiagents import MCP
|
||||
|
||||
cmd, args, env = self.parse_mcp_command(command, env_vars)
|
||||
try:
|
||||
cmd, args, env = self.parse_mcp_command(command, env_vars)
|
||||
except ValueError as e:
|
||||
self.print_status(str(e), "error")
|
||||
return None
|
||||
|
||||
if not cmd:
|
||||
self.print_status("Invalid MCP command", "error")
|
||||
|
||||
@@ -325,6 +325,10 @@ class PraisonAI:
|
||||
# Update config_list model if --model flag is provided
|
||||
if getattr(args, 'model', None):
|
||||
self.config_list[0]['model'] = args.model
|
||||
# Bridge --model to args.llm so direct prompts also use it
|
||||
# (args.llm is what handle_direct_prompt checks for Agent config)
|
||||
if not getattr(args, 'llm', None):
|
||||
args.llm = args.model
|
||||
|
||||
# Check for piped input from stdin
|
||||
stdin_input = self.read_stdin_if_available()
|
||||
@@ -801,7 +805,7 @@ class PraisonAI:
|
||||
return default_args
|
||||
|
||||
# Define special commands
|
||||
special_commands = ['chat', 'code', 'call', 'realtime', 'train', 'ui', 'context', 'research', 'memory', 'rules', 'workflow', 'hooks', 'knowledge', 'session', 'tools', 'todo', 'docs', 'mcp', 'commit', 'serve', 'schedule', 'skills', 'profile', 'eval', 'agents', 'run', 'thinking', 'compaction', 'output', 'deploy', 'templates', 'recipe', 'endpoints', 'audio', 'embed', 'embedding', 'images', 'moderate', 'files', 'batches', 'vector-stores', 'rerank', 'ocr', 'assistants', 'fine-tuning', 'completions', 'messages', 'guardrails', 'rag', 'videos', 'a2a', 'containers', 'passthrough', 'responses', 'search', 'realtime-api', 'doctor', 'registry', 'package', 'install', 'uninstall', 'acp', 'debug', 'lsp', 'diag', 'browser', 'replay', 'bot', 'gateway', 'sandbox', 'wizard', 'migrate', 'security', 'persistence', 'paths']
|
||||
special_commands = ['chat', 'code', 'call', 'realtime', 'train', 'ui', 'context', 'research', 'memory', 'rules', 'workflow', 'hooks', 'knowledge', 'session', 'tools', 'todo', 'docs', 'mcp', 'commit', 'serve', 'schedule', 'skills', 'profile', 'eval', 'agents', 'run', 'thinking', 'compaction', 'output', 'deploy', 'templates', 'recipe', 'endpoints', 'audio', 'embed', 'embedding', 'images', 'moderate', 'files', 'batches', 'vector-stores', 'rerank', 'ocr', 'assistants', 'fine-tuning', 'completions', 'messages', 'guardrails', 'rag', 'videos', 'a2a', 'containers', 'passthrough', 'responses', 'search', 'realtime-api', 'doctor', 'registry', 'package', 'install', 'uninstall', 'acp', 'debug', 'lsp', 'diag', 'browser', 'replay', 'bot', 'gateway', 'sandbox', 'wizard', 'migrate', 'security', 'persistence', 'paths', 'claw']
|
||||
|
||||
parser = argparse.ArgumentParser(prog="praisonai", description="praisonAI command-line interface")
|
||||
parser.add_argument("--framework", choices=["crewai", "autogen", "praisonai"], help="Specify the framework")
|
||||
@@ -1643,6 +1647,19 @@ class PraisonAI:
|
||||
sys.exit(e.code if e.code else 0)
|
||||
sys.exit(0)
|
||||
|
||||
elif args.command == 'claw':
|
||||
# Claw command - 🦞 PraisonAI Dashboard (full UI)
|
||||
# Routes to Typer CLI for claw command (launches AIUI dashboard)
|
||||
from .app import app as typer_app, register_commands
|
||||
register_commands()
|
||||
import sys as _sys
|
||||
_sys.argv = ['praisonai', 'claw'] + unknown_args
|
||||
try:
|
||||
typer_app()
|
||||
except SystemExit as e:
|
||||
sys.exit(e.code if e.code else 0)
|
||||
sys.exit(0)
|
||||
|
||||
elif args.command == 'registry':
|
||||
# Registry command - manage recipe registry server
|
||||
from .features.registry import RegistryHandler
|
||||
@@ -3504,6 +3521,21 @@ class PraisonAI:
|
||||
|
||||
return issues
|
||||
|
||||
@staticmethod
|
||||
def _clean_commit_message(raw: str) -> str:
|
||||
"""Strip markdown code fences and excess whitespace from LLM-generated commit messages."""
|
||||
msg = raw.strip()
|
||||
# Remove wrapping ```...``` (with optional language tag like ```text)
|
||||
if msg.startswith("```"):
|
||||
lines = msg.split("\n")
|
||||
# Remove first line (```<lang?>) and last line (```) if present
|
||||
if len(lines) >= 2 and lines[-1].strip() == "```":
|
||||
lines = lines[1:-1]
|
||||
elif lines[0].strip().startswith("```"):
|
||||
lines = lines[1:]
|
||||
msg = "\n".join(lines).strip()
|
||||
return msg
|
||||
|
||||
def handle_commit_command(self, args: list):
|
||||
"""
|
||||
Handle AI commit message generation.
|
||||
@@ -3632,10 +3664,12 @@ Be specific about what changed and why.""",
|
||||
Detailed diff:
|
||||
{diff_content}
|
||||
|
||||
Provide ONLY the commit message, no explanations."""
|
||||
Provide ONLY the raw commit message text.
|
||||
Do NOT wrap it in markdown code fences or backticks.
|
||||
Do NOT add any explanations or formatting."""
|
||||
|
||||
response = agent.chat(prompt)
|
||||
commit_message = response.strip()
|
||||
commit_message = self._clean_commit_message(response)
|
||||
|
||||
print("\n[bold green]Suggested commit message:[/bold green]")
|
||||
print(f"[cyan]{commit_message}[/cyan]")
|
||||
@@ -4423,10 +4457,9 @@ Provide ONLY the commit message, no explanations."""
|
||||
else:
|
||||
result = agent.chat(prompt)
|
||||
|
||||
# Display final result
|
||||
# SDK callbacks (interaction, llm_content) handle display —
|
||||
# no explicit editor.output() needed here.
|
||||
output = result.output if hasattr(result, 'output') else str(result)
|
||||
if output:
|
||||
editor.output(output)
|
||||
|
||||
# Show summary
|
||||
elapsed = editor.elapsed_time()
|
||||
|
||||
@@ -57,7 +57,7 @@ class CloudDeployer:
|
||||
file.write("FROM python:3.11-slim\n")
|
||||
file.write("WORKDIR /app\n")
|
||||
file.write("COPY . .\n")
|
||||
file.write("RUN pip install flask praisonai==4.5.38 gunicorn markdown\n")
|
||||
file.write("RUN pip install flask praisonai==4.5.73 gunicorn markdown\n")
|
||||
file.write("EXPOSE 8080\n")
|
||||
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
|
||||
|
||||
|
||||
@@ -865,20 +865,24 @@ class WebSocketGateway:
|
||||
elif not isinstance(raw["channels"], dict) or not raw["channels"]:
|
||||
errors.append("'channels' must be a non-empty dictionary")
|
||||
else:
|
||||
valid_platforms = {"telegram", "discord", "slack", "whatsapp"}
|
||||
valid_platforms = {"telegram", "discord", "slack", "whatsapp", "email", "agentmail"}
|
||||
for cname, cdef in raw["channels"].items():
|
||||
if not isinstance(cdef, dict):
|
||||
errors.append(f"Channel '{cname}' must be a dictionary")
|
||||
continue
|
||||
if cname.lower() not in valid_platforms:
|
||||
# Resolve platform: explicit field takes priority over key
|
||||
platform = cdef.get("platform", cname).lower()
|
||||
if platform not in valid_platforms:
|
||||
logger.warning(
|
||||
f"Channel '{cname}' is not a known platform "
|
||||
f"({', '.join(valid_platforms)})"
|
||||
f"Channel '{cname}' platform '{platform}' is not "
|
||||
f"a known platform ({', '.join(sorted(valid_platforms))})"
|
||||
)
|
||||
# WhatsApp web mode doesn't require a token
|
||||
is_wa_web = (cname.lower() == "whatsapp" and
|
||||
is_wa_web = (platform == "whatsapp" and
|
||||
cdef.get("mode", "cloud").lower().strip() == "web")
|
||||
if not cdef.get("token") and not is_wa_web:
|
||||
# Email/AgentMail use env vars for tokens — not required in YAML
|
||||
is_email_platform = platform in ("email", "agentmail")
|
||||
if not cdef.get("token") and not is_wa_web and not is_email_platform:
|
||||
errors.append(
|
||||
f"Channel '{cname}' missing 'token' "
|
||||
"(use ${{ENV_VAR}} syntax for env vars)"
|
||||
@@ -902,9 +906,15 @@ class WebSocketGateway:
|
||||
|
||||
return _resolve(raw)
|
||||
|
||||
def _create_agents_from_config(self, agents_cfg: Dict[str, Dict[str, Any]]) -> None:
|
||||
"""Create and register Agent instances from the agents section of gateway.yaml.
|
||||
|
||||
def _create_agents_from_config(
|
||||
self,
|
||||
agents_cfg: Dict[str, Dict[str, Any]],
|
||||
*,
|
||||
default_model: Optional[str] = None,
|
||||
guardrails_cfg: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Create and register Agent instances from the agents section of config.
|
||||
|
||||
Supports all agent configuration options including:
|
||||
- tools: List of tool names to resolve via ToolResolver
|
||||
- tool_choice: Tool selection mode ('auto', 'required', 'none')
|
||||
@@ -913,10 +923,19 @@ class WebSocketGateway:
|
||||
- role: Agent role (CrewAI-style)
|
||||
- goal: Agent goal (CrewAI-style)
|
||||
- backstory: Agent backstory (CrewAI-style)
|
||||
- temperature: LLM temperature (optional, uses SDK default)
|
||||
- base_url: Custom LLM endpoint URL (optional)
|
||||
- api_key: API key for LLM provider (optional)
|
||||
|
||||
Args:
|
||||
agents_cfg: The ``agents`` section of the config.
|
||||
default_model: Fallback model when an agent has no ``model`` key
|
||||
(e.g. from the ``provider.model`` section of UI config).
|
||||
guardrails_cfg: Optional ``guardrails.registry`` dict to wire per-agent.
|
||||
"""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# G1: Resolve tool names to callables (same pattern as agents_generator)
|
||||
# Resolve tool names to callables (same pattern as agents_generator)
|
||||
tool_resolver = None
|
||||
try:
|
||||
from praisonai.tool_resolver import ToolResolver
|
||||
@@ -926,15 +945,26 @@ class WebSocketGateway:
|
||||
|
||||
for agent_id, agent_def in agents_cfg.items():
|
||||
instructions = agent_def.get("instructions", "")
|
||||
model = agent_def.get("model", None)
|
||||
# G7: Apply provider.model as fallback when agent has no model
|
||||
model = agent_def.get("model", None) or default_model
|
||||
memory = agent_def.get("memory", False)
|
||||
|
||||
# G4: Support role/goal/backstory for CrewAI-style agents
|
||||
|
||||
# G2: Pass temperature through (optional, SDK uses its own default)
|
||||
temperature = agent_def.get("temperature", None)
|
||||
# G3/G4: Pass base_url and api_key for multi-provider support
|
||||
base_url = agent_def.get("base_url", None)
|
||||
api_key = agent_def.get("api_key", None)
|
||||
|
||||
# Support role/goal/backstory for CrewAI-style agents
|
||||
role = agent_def.get("role", None)
|
||||
goal = agent_def.get("goal", None)
|
||||
backstory = agent_def.get("backstory", None)
|
||||
|
||||
# G1: Resolve tools from YAML config
|
||||
|
||||
# G6: Fall back to system_prompt if instructions is empty
|
||||
if not instructions:
|
||||
instructions = agent_def.get("system_prompt", "") or ""
|
||||
|
||||
# Resolve tools from YAML config
|
||||
agent_tools = []
|
||||
yaml_tool_names = agent_def.get("tools", [])
|
||||
if yaml_tool_names and tool_resolver:
|
||||
@@ -948,16 +978,32 @@ class WebSocketGateway:
|
||||
logger.debug(f"Resolved tool '{tool_name}' for agent '{agent_id}'")
|
||||
else:
|
||||
logger.warning(f"Tool '{tool_name}' not found for agent '{agent_id}'")
|
||||
|
||||
|
||||
# Additional agent options from YAML
|
||||
tool_choice = agent_def.get("tool_choice", None)
|
||||
reflection = agent_def.get("reflection", True) # Default: enable reflection/interactive mode
|
||||
reflection = agent_def.get("reflection", True)
|
||||
allow_delegation = agent_def.get("allow_delegation", False)
|
||||
|
||||
# G5: Wire guardrails if config has a matching agent_name or global rule
|
||||
guardrail_prompt = None
|
||||
if guardrails_cfg:
|
||||
for _gr_id, gr_def in guardrails_cfg.items():
|
||||
gr_agent = gr_def.get("agent_name", "")
|
||||
if not gr_agent or gr_agent == agent_id:
|
||||
guardrail_prompt = gr_def.get("description", None)
|
||||
break # first match wins
|
||||
|
||||
# Compose model as dict when temperature is specified
|
||||
# (Agent accepts temperature via dict model config)
|
||||
model_cfg = model
|
||||
if temperature is not None and model and isinstance(model, str):
|
||||
model_cfg = {"model": model, "temperature": temperature}
|
||||
|
||||
# Use model= (preferred) instead of deprecated llm=
|
||||
agent = Agent(
|
||||
name=agent_id,
|
||||
instructions=instructions,
|
||||
llm=model,
|
||||
model=model_cfg,
|
||||
memory=memory,
|
||||
tools=agent_tools if agent_tools else None,
|
||||
reflection=reflection,
|
||||
@@ -965,14 +1011,21 @@ class WebSocketGateway:
|
||||
role=role,
|
||||
goal=goal,
|
||||
backstory=backstory,
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
guardrails=guardrail_prompt,
|
||||
)
|
||||
|
||||
|
||||
# Store tool_choice for later use in chat()
|
||||
if tool_choice:
|
||||
agent._yaml_tool_choice = tool_choice
|
||||
|
||||
|
||||
self.register_agent(agent, agent_id=agent_id)
|
||||
logger.info(f"Created agent '{agent_id}' (model={model}, tools={len(agent_tools)}, reflection={reflection})")
|
||||
logger.info(
|
||||
f"Created agent '{agent_id}' "
|
||||
f"(model={model}, tools={len(agent_tools)}, "
|
||||
f"reflection={reflection})"
|
||||
)
|
||||
|
||||
def _determine_routing_context(
|
||||
self, channel_type: str, message_metadata: Dict[str, Any]
|
||||
@@ -1022,12 +1075,14 @@ class WebSocketGateway:
|
||||
from praisonaiagents.bots import BotConfig
|
||||
|
||||
for channel_name, ch_cfg in channels_cfg.items():
|
||||
channel_type = channel_name.lower()
|
||||
channel_type = ch_cfg.get("platform", channel_name).lower()
|
||||
token = ch_cfg.get("token", "")
|
||||
# WhatsApp web mode doesn't require a token
|
||||
wa_web_mode = (channel_type == "whatsapp" and
|
||||
ch_cfg.get("mode", "cloud").lower().strip() == "web")
|
||||
if not token and not wa_web_mode:
|
||||
# Email/AgentMail use env vars for tokens — not required in YAML
|
||||
is_email_platform = channel_type in ("email", "agentmail")
|
||||
if not token and not wa_web_mode and not is_email_platform:
|
||||
logger.warning(f"No token for channel '{channel_name}', skipping")
|
||||
continue
|
||||
|
||||
@@ -1092,6 +1147,25 @@ class WebSocketGateway:
|
||||
mode=wa_mode,
|
||||
creds_dir=ch_cfg.get("creds_dir"),
|
||||
)
|
||||
elif channel_type == "email":
|
||||
from praisonai.bots import EmailBot
|
||||
email_token = token or os.environ.get("EMAIL_APP_PASSWORD", "")
|
||||
return EmailBot(
|
||||
token=email_token,
|
||||
agent=agent,
|
||||
email_address=ch_cfg.get("email_address") or os.environ.get("EMAIL_ADDRESS", ""),
|
||||
imap_server=ch_cfg.get("imap_server") or os.environ.get("EMAIL_IMAP_SERVER", ""),
|
||||
smtp_server=ch_cfg.get("smtp_server") or os.environ.get("EMAIL_SMTP_SERVER", ""),
|
||||
)
|
||||
elif channel_type == "agentmail":
|
||||
from praisonai.bots import AgentMailBot
|
||||
am_token = token or os.environ.get("AGENTMAIL_API_KEY", "")
|
||||
return AgentMailBot(
|
||||
token=am_token,
|
||||
agent=agent,
|
||||
inbox_id=ch_cfg.get("inbox_id") or os.environ.get("AGENTMAIL_INBOX_ID", ""),
|
||||
domain=ch_cfg.get("domain") or os.environ.get("AGENTMAIL_DOMAIN", ""),
|
||||
)
|
||||
else:
|
||||
logger.warning(f"Unknown channel type: {channel_type}")
|
||||
return None
|
||||
@@ -1175,6 +1249,7 @@ class WebSocketGateway:
|
||||
from telegram import Update
|
||||
from telegram.ext import (
|
||||
Application,
|
||||
CommandHandler,
|
||||
MessageHandler,
|
||||
filters,
|
||||
)
|
||||
@@ -1216,6 +1291,8 @@ class WebSocketGateway:
|
||||
if not message_text:
|
||||
return
|
||||
|
||||
user_id = str(update.message.from_user.id) if update.message.from_user else "unknown"
|
||||
|
||||
# Determine routing context
|
||||
chat_type = update.message.chat.type if update.message.chat else "private"
|
||||
routing_ctx = gateway._determine_routing_context(
|
||||
@@ -1229,9 +1306,33 @@ class WebSocketGateway:
|
||||
if bot.config.typing_indicator:
|
||||
await update.message.chat.send_action("typing")
|
||||
|
||||
# Ack reaction — show processing indicator
|
||||
ack_ctx = None
|
||||
if bot._ack.enabled:
|
||||
async def _tg_react(emoji, **kw):
|
||||
try:
|
||||
from telegram import ReactionTypeEmoji
|
||||
await app.bot.set_message_reaction(
|
||||
chat_id=update.message.chat_id,
|
||||
message_id=update.message.message_id,
|
||||
reaction=[ReactionTypeEmoji(emoji=emoji)],
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
async def _tg_unreact(emoji, **kw):
|
||||
try:
|
||||
await app.bot.set_message_reaction(
|
||||
chat_id=update.message.chat_id,
|
||||
message_id=update.message.message_id,
|
||||
reaction=[],
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
ack_ctx = await bot._ack.ack(react_fn=_tg_react)
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
response = await loop.run_in_executor(None, agent.chat, message_text)
|
||||
message_text = await bot._debouncer.debounce(user_id, message_text)
|
||||
response = await bot._session.chat(agent, user_id, message_text)
|
||||
if hasattr(bot, '_send_response_with_media'):
|
||||
await bot._send_response_with_media(
|
||||
update.message.chat_id,
|
||||
@@ -1240,6 +1341,9 @@ class WebSocketGateway:
|
||||
)
|
||||
else:
|
||||
await update.message.reply_text(str(response))
|
||||
# Done reaction
|
||||
if ack_ctx:
|
||||
await bot._ack.done(ack_ctx, react_fn=_tg_react, unreact_fn=_tg_unreact)
|
||||
except Exception as e:
|
||||
logger.error(f"Agent error in {name}: {e}")
|
||||
await update.message.reply_text(f"Error: {str(e)}")
|
||||
@@ -1247,7 +1351,27 @@ class WebSocketGateway:
|
||||
async def handle_voice(update: Update, context: Any):
|
||||
await handle_message(update, context)
|
||||
|
||||
async def handle_status(update: Update, context: Any):
|
||||
if not update.message:
|
||||
return
|
||||
await update.message.reply_text(bot._format_status())
|
||||
|
||||
async def handle_new(update: Update, context: Any):
|
||||
if not update.message:
|
||||
return
|
||||
user_id = str(update.message.from_user.id) if update.message.from_user else "unknown"
|
||||
bot._session.reset(user_id)
|
||||
await update.message.reply_text("Session reset. Starting fresh conversation.")
|
||||
|
||||
async def handle_help(update: Update, context: Any):
|
||||
if not update.message:
|
||||
return
|
||||
await update.message.reply_text(bot._format_help())
|
||||
|
||||
# Register handlers
|
||||
app.add_handler(CommandHandler("status", handle_status))
|
||||
app.add_handler(CommandHandler("new", handle_new))
|
||||
app.add_handler(CommandHandler("help", handle_help))
|
||||
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
|
||||
app.add_handler(MessageHandler(filters.VOICE | filters.AUDIO, handle_voice))
|
||||
|
||||
@@ -1310,9 +1434,16 @@ class WebSocketGateway:
|
||||
|
||||
# Recreate agents
|
||||
agents_cfg = cfg.get("agents", {})
|
||||
provider_cfg = cfg.get("provider", {})
|
||||
default_model = provider_cfg.get("model") if provider_cfg else None
|
||||
guardrails_cfg = (cfg.get("guardrails") or {}).get("registry")
|
||||
if agents_cfg:
|
||||
self._agents.clear()
|
||||
self._create_agents_from_config(agents_cfg)
|
||||
self._create_agents_from_config(
|
||||
agents_cfg,
|
||||
default_model=default_model,
|
||||
guardrails_cfg=guardrails_cfg,
|
||||
)
|
||||
|
||||
# Restart channels
|
||||
channels_cfg = cfg.get("channels", {})
|
||||
@@ -1361,8 +1492,15 @@ class WebSocketGateway:
|
||||
|
||||
# Create agents
|
||||
agents_cfg = cfg.get("agents", {})
|
||||
provider_cfg = cfg.get("provider", {})
|
||||
default_model = provider_cfg.get("model") if provider_cfg else None
|
||||
guardrails_cfg = (cfg.get("guardrails") or {}).get("registry")
|
||||
if agents_cfg:
|
||||
self._create_agents_from_config(agents_cfg)
|
||||
self._create_agents_from_config(
|
||||
agents_cfg,
|
||||
default_model=default_model,
|
||||
guardrails_cfg=guardrails_cfg,
|
||||
)
|
||||
|
||||
# Start channels + WebSocket server concurrently
|
||||
channels_cfg = cfg.get("channels", {})
|
||||
|
||||
|
After Width: | Height: | Size: 109 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg width="46" height="46" viewBox="0 0 46 46" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="23" cy="23" r="23" fill="white"/>
|
||||
<path d="M32.73 7h-6.945L38.45 39h6.945L32.73 7ZM12.665 7 0 39h7.082l2.59-6.72h13.25l2.59 6.72h7.082L19.929 7h-7.264Zm-.702 19.337 4.334-11.246 4.334 11.246h-8.668Z" fill="#000000"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 381 B |
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 26.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.0" id="katman_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 600 450" style="enable-background:new 0 0 600 450;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:none;}
|
||||
.st1{fill-rule:evenodd;clip-rule:evenodd;fill:#343B45;}
|
||||
.st2{fill-rule:evenodd;clip-rule:evenodd;fill:#F4981A;}
|
||||
</style>
|
||||
<g id="_x31__stroke">
|
||||
<g id="Amazon_1_">
|
||||
<rect x="161.2" y="86.5" class="st0" width="277.8" height="277.8"/>
|
||||
<g id="Amazon">
|
||||
<path class="st1" d="M315,163.7c-8,0.6-17.2,1.2-26.4,2.4c-14.1,1.9-28.2,4.3-39.8,9.8c-22.7,9.2-38,28.8-38,57.6
|
||||
c0,36.2,23.3,54.6,52.7,54.6c9.8,0,17.8-1.2,25.1-3.1c11.7-3.7,21.5-10.4,33.1-22.7c6.7,9.2,8.6,13.5,20.2,23.3
|
||||
c3.1,1.2,6.1,1.2,8.6-0.6c7.4-6.1,20.3-17.2,27-23.3c3.1-2.5,2.5-6.1,0.6-9.2c-6.7-8.6-13.5-16-13.5-32.5V165
|
||||
c0-23.3,1.9-44.8-15.3-60.7c-14.1-12.9-36.2-17.8-53.4-17.8h-7.4c-31.2,1.8-64.3,15.3-71.7,54c-1.2,4.9,2.5,6.8,4.9,7.4l34.3,4.3
|
||||
c3.7-0.6,5.5-3.7,6.1-6.7c3.1-13.5,14.1-20.2,26.3-21.5h2.5c7.4,0,15.3,3.1,19.6,9.2c4.9,7.4,4.3,17.2,4.3,25.8L315,163.7
|
||||
L315,163.7z M308.2,236.7c-4.3,8.6-11.7,14.1-19.6,16c-1.2,0-3.1,0.6-4.9,0.6c-13.5,0-21.4-10.4-21.4-25.8
|
||||
c0-19.6,11.6-28.8,26.3-33.1c8-1.8,17.2-2.5,26.4-2.5v7.4C315,213.4,315.6,224.4,308.2,236.7z"/>
|
||||
<path class="st2" d="M398.8,311.4c-1.4,0-2.8,0.3-4.1,0.9c-1.5,0.6-3,1.3-4.4,1.9l-2.1,0.9l-2.7,1.1v0
|
||||
c-29.8,12.1-61.1,19.2-90.1,19.8c-1.1,0-2.1,0-3.2,0c-45.6,0-82.8-21.1-120.3-42c-1.3-0.7-2.7-1-4-1c-1.7,0-3.4,0.6-4.7,1.8
|
||||
c-1.3,1.2-2,2.9-2,4.7c0,2.3,1.2,4.4,2.9,5.7c35.2,30.6,73.8,59,125.7,59c1,0,2,0,3.1,0c33-0.7,70.3-11.9,99.3-30.1l0.2-0.1
|
||||
c3.8-2.3,7.6-4.9,11.2-7.7c2.2-1.6,3.8-4.2,3.8-6.9C407.2,314.6,403.2,311.4,398.8,311.4z M439,294.5L439,294.5
|
||||
c-0.1-2.9-0.7-5.1-1.9-6.9l-0.1-0.2l-0.1-0.2c-1.2-1.3-2.4-1.8-3.7-2.4c-3.8-1.5-9.3-2.3-16-2.3c-4.8,0-10.1,0.5-15.4,1.6l0-0.4
|
||||
l-5.3,1.8l-0.1,0l-3,1v0.1c-3.5,1.5-6.8,3.3-9.8,5.5c-1.9,1.4-3.4,3.2-3.5,6.1c0,1.5,0.7,3.3,2,4.3c1.3,1,2.8,1.4,4.1,1.4
|
||||
c0.3,0,0.6,0,0.9-0.1l0.3,0l0.2,0c2.6-0.6,6.4-0.9,10.9-1.6c3.8-0.4,7.9-0.7,11.4-0.7c2.5,0,4.7,0.2,6.3,0.5
|
||||
c0.8,0.2,1.3,0.4,1.6,0.5c0.1,0,0.2,0.1,0.2,0.1c0.1,0.2,0.2,0.8,0.1,1.5c0,2.9-1.2,8.4-2.9,13.7c-1.7,5.3-3.7,10.7-5,14.2
|
||||
c-0.3,0.8-0.5,1.7-0.5,2.7c0,1.4,0.6,3.2,1.8,4.3c1.2,1.1,2.8,1.6,4.1,1.6h0.1c2,0,3.6-0.8,5.1-1.9
|
||||
c13.6-12.2,18.3-31.7,18.5-42.6L439,294.5z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,72 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="52.91666mm"
|
||||
height="15.244921mm"
|
||||
viewBox="0 0 52.91666 15.244921"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
inkscape:version="0.92.1 r15371"
|
||||
sodipodi:docname="Azurey.svg">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="6.5246067"
|
||||
inkscape:cx="67.4659"
|
||||
inkscape:cy="38.024323"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
inkscape:pagecheckerboard="true"
|
||||
fit-margin-top="0"
|
||||
fit-margin-left="0"
|
||||
fit-margin-right="0"
|
||||
fit-margin-bottom="0"
|
||||
inkscape:window-width="930"
|
||||
inkscape:window-height="781"
|
||||
inkscape:window-x="667"
|
||||
inkscape:window-y="117"
|
||||
inkscape:window-maximized="0" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(677.9313,-313.85407)">
|
||||
<g
|
||||
transform="matrix(0.03978217,0,0,0.03978217,-658.51488,317.36166)"
|
||||
id="layer1-1"
|
||||
inkscape:label="Layer 1">
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path21"
|
||||
d="m -259.18627,274.57516 c 63.5635,-11.22863 116.06332,-20.52032 116.66629,-20.64819 l 1.09632,-0.2325 -60.01021,-71.38026 c -33.00559,-39.25915 -60.01018,-71.53249 -60.01018,-71.71853 0,-0.35223 61.96561,-170.992127 62.31396,-171.599207 0.11647,-0.20297 42.28595,72.60148 102.221586,176.482797 56.11284,97.25555 102.3751556,177.4431 102.8051496,178.19452 l 0.78184,1.36624 -190.7173956,-0.0246 -190.71736,-0.0245 115.57,-20.4157 z m 731.37889,-17.26291 c -29.03093,-1.86128 -45.91544,-18.39214 -50.38974,-49.33422 -1.19021,-8.2311 -1.19476,-8.44466 -1.31656,-61.88078 l -0.11789,-51.717227 h 12.84834 12.8483 l 0.10118,50.023887 c 0.0912,45.08514 0.14842,50.37131 0.57946,53.54292 1.74935,12.87168 5.23201,21.5276 11.16124,27.74067 4.74528,4.97242 10.30038,7.8839 17.99011,9.42882 3.62783,0.72884 13.94813,0.7303 17.25569,0.003 7.79588,-1.7156 14.0442,-5.10811 19.55617,-10.61799 6.28249,-6.28012 10.93015,-15.1903 13.17899,-25.26595 l 0.75793,-3.39586 0.0844,-50.44723 0.0844,-50.447217 h 13.11732 13.11735 v 79.304447 79.30444 h -12.98222 -12.98222 v -12.5824 c 0,-8.54933 -0.0937,-12.55118 -0.29222,-12.485 -0.16069,0.0536 -0.82744,1.07357 -1.48166,2.26667 -4.47373,8.15896 -11.92745,15.62029 -20.09501,20.1155 -9.79024,5.38827 -19.60858,7.30422 -33.02333,6.44412 z m 294.66636,-0.12816 c -10.24795,-0.7703 -21.03846,-4.29359 -29.85796,-9.74908 -18.58873,-11.49853 -29.58761,-30.45184 -32.80805,-56.53499 -1.11441,-9.02575 -1.24835,-21.14964 -0.3184,-28.81907 2.07786,-17.13682 8.81185,-33.95976 18.40106,-45.96982 2.45824,-3.07883 8.03495,-8.65553 11.11365,-11.11361 8.31379,-6.637877 18.03843,-11.279617 28.36334,-13.538327 6.01579,-1.31603 16.60968,-1.93394 23.00111,-1.34159 16.05576,1.48805 30.77105,9.04765 40.77267,20.945887 10.1615,12.08847 15.74681,28.98006 16.46007,49.77987 0.11179,3.25966 0.13889,8.97466 0.0603,12.7 l -0.143,6.77333 -56.23278,0.0712 -56.23278,0.0712 v 2.5094 c 0,7.63915 1.85722,16.33991 5.06795,23.74265 2.76877,6.38373 7.53485,13.35462 11.43714,16.72802 8.00131,6.91687 17.79386,11.05701 28.50936,12.05334 3.97318,0.3694 14.09889,-0.0195 18.62666,-0.7152 12.91802,-1.98524 25.19946,-7.052 35.11745,-14.48783 1.16688,-0.87486 2.28357,-1.69223 2.48152,-1.81635 0.28968,-0.18164 0.35814,2.10408 0.35081,11.71222 l -0.009,11.93792 -2.65743,1.64355 c -11.21971,6.93911 -24.07305,11.39481 -37.68211,13.06277 -4.06135,0.49775 -18.96443,0.71949 -23.82142,0.35441 z m 48.93919,-100.68528 c 0,-12.79609 -5.39245,-27.01096 -13.02337,-34.3305 -5.44587,-5.22367 -12.02921,-8.41603 -19.85552,-9.62822 -3.70264,-0.5735 -11.50631,-0.35262 -15.41004,0.43617 -8.25234,1.66748 -15.07811,5.29536 -21.03154,11.17821 -6.26561,6.19133 -10.96323,13.71122 -13.91756,22.27909 -1.06234,3.08091 -2.30488,8.13901 -2.69056,10.95257 l -0.18376,1.34056 h 43.05616 43.05619 z M 53.010852,253.20058 c 0.06587,-0.19403 19.161194,-50.3586 42.434086,-111.47682 l 42.314342,-111.124037 13.59583,-8.5e-4 13.5958,-8.4e-4 1.12805,2.89278 c 3.4472,8.84008 84.71032,219.821587 84.71032,219.931197 0,0.0722 -6.50875,0.13039 -14.46389,0.12923 l -14.46389,-0.003 -11.71222,-31.18282 -11.71222,-31.18281 -47.15885,-5.7e-4 -47.15886,-5.6e-4 -0.40982,1.05833 c -0.22538,0.58208 -5.229376,14.61335 -11.120031,31.18062 l -10.710221,30.12225 -14.494087,0.005 c -11.4727,0.004 -14.469109,-0.0686 -14.374339,-0.34784 z M 189.82928,167.38571 c 0,-0.0494 -7.88187,-21.41719 -17.51527,-47.48389 -18.02243,-48.766297 -19.03061,-51.700447 -20.45454,-59.529577 -0.66771,-3.67122 -1.00556,-3.77324 -1.40188,-0.42333 -0.2833,2.39448 -1.51167,7.75245 -2.45866,10.72445 -0.46988,1.47461 -8.58577,23.74972 -18.03533,49.500237 -9.44954,25.75052 -17.18099,46.92777 -17.18099,47.06055 0,0.13279 17.33551,0.24143 38.52334,0.24143 21.18783,0 38.52333,-0.0404 38.52333,-0.0899 z m 69.70889,82.10639 v -4.06127 l 46.98461,-64.58929 46.98461,-64.5893 -42.53961,-0.14111 -42.53961,-0.14111 -0.0739,-10.795 -0.0739,-10.794997 h 61.52781 61.5278 v 3.64606 3.646057 l -46.99,64.94153 c -25.8445,35.71785 -46.99,65.00162 -46.99,65.07506 0,0.0734 20.8915,0.13352 46.42555,0.13352 h 46.42556 v 10.86555 10.86556 h -65.33447 -65.33445 z m 344.78149,3.8731 c -0.10349,-0.10346 -0.18816,-35.91746 -0.18816,-79.58666 V 94.380023 h 12.84112 12.84111 v 16.368887 c 0,9.00289 0.10947,16.36889 0.2433,16.36889 0.13383,0 0.75881,-1.47368 1.38884,-3.27484 2.88711,-8.2536 7.89393,-15.96572 14.34443,-22.09509 5.81095,-5.521657 12.45235,-8.823637 20.28898,-10.087287 2.20134,-0.35497 4.064,-0.42274 8.46667,-0.30806 5.52924,0.14402 8.42001,0.54819 11.78278,1.64738 l 1.05833,0.34594 v 13.328937 13.32893 l -3.03389,-1.51868 c -5.3592,-2.68267 -10.64714,-3.74429 -17.00389,-3.41375 -4.13707,0.21513 -6.85252,0.74636 -10.16,1.98766 -6.80519,2.55399 -12.32518,7.06198 -16.18228,13.2155 -5.58374,8.90817 -9.56612,20.35873 -10.7407,30.88287 -0.21079,1.88864 -0.33508,17.27019 -0.38459,47.59327 l -0.0731,44.80278 H 617.1582 c -6.95771,0 -12.73506,-0.0847 -12.83852,-0.18816 z M -488.0685,252.80762 c 0,-0.1005 28.27652,-49.18632 62.83671,-109.07964 Line truncated
|
||||
style="fill:#0089d6;fill-opacity:1;stroke-width:0.28222221" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Bedrock</title><defs><linearGradient id="lobe-icons-bedrock-fill" x1="80%" x2="20%" y1="20%" y2="80%"><stop offset="0%" stop-color="#6350FB"></stop><stop offset="50%" stop-color="#3D8FFF"></stop><stop offset="100%" stop-color="#9AD8F8"></stop></linearGradient></defs><path d="M13.05 15.513h3.08c.214 0 .389.177.389.394v1.82a1.704 1.704 0 011.296 1.661c0 .943-.755 1.708-1.685 1.708-.931 0-1.686-.765-1.686-1.708 0-.807.554-1.484 1.297-1.662v-1.425h-2.69v4.663a.395.395 0 01-.188.338l-2.69 1.641a.385.385 0 01-.405-.002l-4.926-3.086a.395.395 0 01-.185-.336V16.3L2.196 14.87A.395.395 0 012 14.555L2 14.528V9.406c0-.14.073-.27.192-.34l2.465-1.462V4.448c0-.129.062-.249.165-.322l.021-.014L9.77 1.058a.385.385 0 01.407 0l2.69 1.675a.395.395 0 01.185.336V7.6h3.856V5.683a1.704 1.704 0 01-1.296-1.662c0-.943.755-1.708 1.685-1.708.931 0 1.685.765 1.685 1.708 0 .807-.553 1.484-1.296 1.662v2.311a.391.391 0 01-.389.394h-4.245v1.806h6.624a1.69 1.69 0 011.64-1.313c.93 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708a1.69 1.69 0 01-1.64-1.314H13.05v1.937h4.953l.915 1.18a1.66 1.66 0 01.84-.227c.931 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708-.93 0-1.685-.765-1.685-1.708 0-.346.102-.668.276-.937l-.724-.935H13.05v1.806zM9.973 1.856L7.93 3.122V6.09h-.778V3.604L5.435 4.669v2.945l2.11 1.36L9.712 7.61V5.334h.778V7.83c0 .136-.07.263-.184.335L7.963 9.638v2.081l1.422 1.009-.446.646-1.406-.998-1.53 1.005-.423-.66 1.605-1.055v-1.99L5.038 8.29l-2.26 1.34v1.676l1.972-1.189.398.677-2.37 1.429V14.3l2.166 1.258 2.27-1.368.397.677-2.176 1.311V19.3l1.876 1.175 2.365-1.426.398.678-2.017 1.216 1.918 1.201 2.298-1.403v-5.78l-4.758 2.893-.4-.675 5.158-3.136V3.289L9.972 1.856zM16.13 18.47a.913.913 0 00-.908.92c0 .507.406.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zm3.63-3.81a.913.913 0 00-.908.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92zm1.555-4.99a.913.913 0 00-.908.92c0 .507.407.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zM17.296 3.1a.913.913 0 00-.907.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92z" fill="url(#lobe-icons-bedrock-fill)" fill-rule="nonzero"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,89 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 26.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.0" id="katman_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 800 600" style="enable-background:new 0 0 800 600;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#F05A28;}
|
||||
.st1{fill-rule:evenodd;clip-rule:evenodd;fill:#231F20;}
|
||||
</style>
|
||||
<g id="Contact">
|
||||
<g id="Contact-us" transform="translate(-234.000000, -1114.000000)">
|
||||
<g id="map" transform="translate(-6.000000, 1027.000000)">
|
||||
<g id="Contact-box" transform="translate(190.000000, 36.000000)">
|
||||
<g id="Group-26" transform="translate(50.000000, 51.000000)">
|
||||
<g id="Group-3">
|
||||
<path id="Fill-1" class="st0" d="M220.9,421c-17,0-33.1-3.4-47.8-9.5c-22-9.2-40.8-24.6-54.1-44c-13.3-19.4-21-42.7-21-67.9
|
||||
c0-16.8,3.4-32.7,9.7-47.3c9.3-21.8,24.9-40.3,44.5-53.4c19.6-13.1,43.2-20.7,68.7-20.7v-18.3c-19.5,0-38.1,3.9-55.1,11
|
||||
c-25.4,10.6-47,28.3-62.2,50.6c-15.3,22.3-24.2,49.2-24.2,78.1c0,19.3,4,37.7,11.1,54.4c10.7,25.1,28.7,46.4,51.2,61.5
|
||||
c22.6,15.1,49.8,23.9,79.1,23.9V421z"/>
|
||||
<path id="Fill-4" class="st0" d="M157.9,374.1c-11.5-9.6-20.1-21.2-25.9-33.9c-5.8-12.7-8.8-26.4-8.8-40.2
|
||||
c0-11,1.9-22,5.6-32.5c3.8-10.5,9.4-20.5,17.1-29.6c9.6-11.4,21.3-20,34-25.8c12.7-5.8,26.6-8.7,40.4-8.7
|
||||
c11,0,22.1,1.9,32.6,5.6c10.6,3.8,20.6,9.4,29.7,17l11.9-14.1c-10.8-9-22.8-15.8-35.4-20.2c-12.6-4.5-25.7-6.7-38.8-6.7
|
||||
c-16.5,0-32.9,3.5-48.1,10.4c-15.2,6.9-29.1,17.2-40.5,30.7c-9.1,10.8-15.8,22.7-20.3,35.2c-4.5,12.5-6.7,25.6-6.7,38.7
|
||||
c0,16.4,3.5,32.8,10.4,47.9c6.9,15.1,17.3,29,30.9,40.3L157.9,374.1z"/>
|
||||
<path id="Fill-6" class="st0" d="M186.4,362.2c-12.1-6.4-21.6-15.7-28.1-26.6c-6.5-10.9-9.9-23.5-9.9-36.2
|
||||
c0-11.2,2.6-22.5,8.3-33c6.4-12.1,15.8-21.5,26.8-27.9c11-6.5,23.6-9.9,36.4-9.9c11.2,0,22.6,2.6,33.2,8.2l8.6-16.3
|
||||
c-13.3-7-27.7-10.4-41.9-10.3c-16.1,0-32,4.3-45.8,12.4c-13.8,8.1-25.7,20.1-33.7,35.2c-7,13.3-10.4,27.6-10.4,41.6
|
||||
c0,16,4.3,31.8,12.5,45.5c8.2,13.8,20.2,25.5,35.4,33.5L186.4,362.2z"/>
|
||||
<path id="Fill-8" class="st0" d="M221,344.6c-6.3,0-12.3-1.3-17.7-3.6c-8.2-3.4-15.1-9.2-20-16.5c-4.9-7.3-7.8-16-7.8-25.4
|
||||
c0-6.3,1.3-12.3,3.6-17.7c3.4-8.1,9.2-15.1,16.5-20c7.3-4.9,16-7.8,25.4-7.8v-18.4c-8.8,0-17.2,1.8-24.9,5
|
||||
c-11.5,4.9-21.2,12.9-28.1,23.1C161,273.6,157,286,157,299.2c0,8.8,1.8,17.2,5,24.9c4.9,11.5,13,21.2,23.2,28.1
|
||||
C195.4,359,207.7,363,221,363V344.6z"/>
|
||||
</g>
|
||||
<g id="Group" transform="translate(22.000000, 13.000000)">
|
||||
<path id="Fill-10" class="st1" d="M214,271.6c-2.1-2.2-4.4-4-6.7-5.3c-2.3-1.3-4.7-2-7.2-2c-3.4,0-6.3,0.6-9,1.8
|
||||
c-2.6,1.2-4.9,2.8-6.8,4.9c-1.9,2-3.3,4.4-4.3,7c-1,2.6-1.4,5.4-1.4,8.2c0,2.8,0.5,5.6,1.4,8.2c1,2.6,2.4,5,4.3,7
|
||||
c1.9,2,4.1,3.7,6.8,4.9c2.6,1.2,5.6,1.8,9,1.8c2.8,0,5.5-0.6,7.9-1.7c2.4-1.2,4.5-2.9,6.2-5.1l12.2,13.1
|
||||
c-1.8,1.8-3.9,3.4-6.3,4.7c-2.4,1.3-4.8,2.4-7.2,3.2s-4.8,1.4-7,1.7c-2.2,0.4-4.2,0.5-5.8,0.5c-5.5,0-10.7-0.9-15.5-2.7
|
||||
c-4.9-1.8-9.1-4.4-12.6-7.8c-3.6-3.3-6.4-7.4-8.5-12.1c-2.1-4.7-3.1-10-3.1-15.7c0-5.8,1-11,3.1-15.7
|
||||
c2.1-4.7,4.9-8.7,8.5-12.1c3.6-3.3,7.8-5.9,12.6-7.8c4.9-1.8,10.1-2.7,15.5-2.7c4.7,0,9.4,0.9,14.1,2.7
|
||||
c4.7,1.8,8.9,4.6,12.4,8.4L214,271.6z"/>
|
||||
<path id="Fill-12" class="st1" d="M280.4,278.9c-0.1-5.4-1.8-9.6-5-12.7c-3.3-3.1-7.8-4.6-13.6-4.6c-5.5,0-9.8,1.6-13,4.7
|
||||
c-3.2,3.1-5.2,7.4-5.9,12.6H280.4z M243,292.6c0.6,5.5,2.7,9.7,6.4,12.8c3.7,3,8.1,4.6,13.3,4.6c4.6,0,8.4-0.9,11.5-2.8
|
||||
c3.1-1.9,5.8-4.2,8.2-7.1l13.1,9.9c-4.3,5.3-9,9-14.3,11.3c-5.3,2.2-10.8,3.3-16.6,3.3c-5.5,0-10.7-0.9-15.5-2.7
|
||||
c-4.9-1.8-9.1-4.4-12.6-7.8c-3.6-3.3-6.4-7.4-8.5-12.1c-2.1-4.7-3.1-10-3.1-15.7c0-5.8,1-11,3.1-15.7
|
||||
c2.1-4.7,4.9-8.7,8.5-12.1c3.6-3.3,7.8-5.9,12.6-7.8c4.9-1.8,10.1-2.7,15.5-2.7c5.1,0,9.7,0.9,13.9,2.7
|
||||
c4.2,1.8,7.8,4.3,10.8,7.7c3,3.3,5.3,7.5,7,12.4c1.7,4.9,2.5,10.6,2.5,17v5H243z"/>
|
||||
<path id="Fill-14" class="st1" d="M306.5,249.7h18.3v11.5h0.3c2-4.3,4.9-7.5,8.7-9.9c3.8-2.3,8.1-3.5,12.9-3.5
|
||||
c1.1,0,2.2,0.1,3.3,0.3c1.1,0.2,2.2,0.5,3.3,0.8v17.6c-1.5-0.4-3-0.7-4.5-1c-1.5-0.3-2.9-0.4-4.3-0.4c-4.3,0-7.7,0.8-10.3,2.4
|
||||
c-2.6,1.6-4.6,3.4-5.9,5.4c-1.4,2-2.3,4.1-2.7,6.1c-0.5,2-0.7,3.5-0.7,4.6v39h-18.3V249.7z"/>
|
||||
<path id="Fill-16" class="st1" d="M409,278.9c-0.1-5.4-1.8-9.6-5-12.7c-3.3-3.1-7.8-4.6-13.6-4.6c-5.5,0-9.8,1.6-13,4.7
|
||||
c-3.2,3.1-5.2,7.4-5.9,12.6H409z M371.6,292.6c0.6,5.5,2.7,9.7,6.4,12.8c3.7,3,8.1,4.6,13.3,4.6c4.6,0,8.4-0.9,11.5-2.8
|
||||
c3.1-1.9,5.8-4.2,8.2-7.1l13.1,9.9c-4.3,5.3-9,9-14.3,11.3c-5.3,2.2-10.8,3.3-16.6,3.3c-5.5,0-10.7-0.9-15.5-2.7
|
||||
c-4.9-1.8-9.1-4.4-12.6-7.8c-3.6-3.3-6.4-7.4-8.5-12.1c-2.1-4.7-3.1-10-3.1-15.7c0-5.8,1-11,3.1-15.7
|
||||
c2.1-4.7,4.9-8.7,8.5-12.1c3.6-3.3,7.8-5.9,12.6-7.8c4.9-1.8,10.1-2.7,15.5-2.7c5.1,0,9.7,0.9,13.9,2.7
|
||||
c4.2,1.8,7.8,4.3,10.8,7.7c3,3.3,5.3,7.5,7,12.4c1.7,4.9,2.5,10.6,2.5,17v5H371.6z"/>
|
||||
<path id="Fill-18" class="st1" d="M494.6,286.2c0-2.8-0.5-5.6-1.5-8.2c-1-2.6-2.4-5-4.3-7c-1.9-2-4.2-3.7-6.9-4.9
|
||||
c-2.7-1.2-5.7-1.8-9.1-1.8c-3.4,0-6.4,0.6-9.1,1.8c-2.7,1.2-5,2.8-6.9,4.9c-1.9,2-3.3,4.4-4.3,7c-1,2.6-1.5,5.4-1.5,8.2
|
||||
c0,2.8,0.5,5.6,1.5,8.2c1,2.6,2.4,5,4.3,7c1.9,2,4.2,3.7,6.9,4.9c2.7,1.2,5.7,1.8,9.1,1.8c3.4,0,6.4-0.6,9.1-1.8
|
||||
c2.7-1.2,5-2.8,6.9-4.9c1.9-2,3.3-4.4,4.3-7C494.1,291.8,494.6,289,494.6,286.2L494.6,286.2z M433.2,207.6h18.5v51.3h0.5
|
||||
c0.9-1.2,2.1-2.5,3.5-3.7c1.4-1.3,3.2-2.5,5.2-3.6c2.1-1.1,4.4-2,7.1-2.7c2.7-0.7,5.8-1.1,9.3-1.1c5.2,0,10.1,1,14.5,3
|
||||
c4.4,2,8.2,4.7,11.3,8.1c3.1,3.5,5.6,7.5,7.3,12.2c1.7,4.7,2.6,9.7,2.6,15.1c0,5.4-0.8,10.4-2.5,15.1
|
||||
c-1.6,4.7-4.1,8.7-7.2,12.2c-3.2,3.5-7,6.2-11.6,8.1c-4.5,2-9.6,3-15.3,3c-5.2,0-10.1-1-14.7-3c-4.5-2-8.1-5.3-10.8-9.7h-0.3
|
||||
v11h-17.6V207.6z"/>
|
||||
<path id="Fill-20" class="st1" d="M520.9,249.7h18.3v11.5h0.3c2-4.3,4.9-7.5,8.7-9.9c3.8-2.3,8.1-3.5,12.9-3.5
|
||||
c1.1,0,2.2,0.1,3.3,0.3c1.1,0.2,2.2,0.5,3.3,0.8v17.6c-1.5-0.4-3-0.7-4.5-1c-1.5-0.3-2.9-0.4-4.3-0.4c-4.3,0-7.7,0.8-10.3,2.4
|
||||
c-2.6,1.6-4.6,3.4-5.9,5.4c-1.4,2-2.3,4.1-2.7,6.1c-0.5,2-0.7,3.5-0.7,4.6v39h-18.3V249.7z"/>
|
||||
<path id="Fill-22" class="st1" d="M616,290h-3.9c-2.6,0-5.5,0.1-8.7,0.3c-3.2,0.2-6.2,0.7-9.1,1.4c-2.8,0.8-5.2,1.9-7.2,3.3
|
||||
c-2,1.5-2.9,3.5-2.9,6.2c0,1.7,0.4,3.2,1.2,4.3c0.8,1.2,1.8,2.2,3,3c1.2,0.8,2.6,1.4,4.2,1.8c1.5,0.4,3.1,0.5,4.6,0.5
|
||||
c6.4,0,11.1-1.5,14.2-4.5c3-3,4.6-7.1,4.6-12.2V290z M617.1,312.7h-0.5c-2.7,4.2-6.1,7.2-10.2,9.1c-4.1,1.9-8.7,2.8-13.6,2.8
|
||||
c-3.4,0-6.7-0.5-10-1.4s-6.1-2.3-8.7-4.1c-2.5-1.8-4.6-4.1-6.1-6.8s-2.3-5.9-2.3-9.6c0-4,0.7-7.3,2.2-10.1
|
||||
c1.4-2.8,3.4-5.1,5.8-7c2.4-1.9,5.2-3.4,8.4-4.5c3.2-1.1,6.5-2,10-2.5c3.5-0.6,6.9-0.9,10.5-1.1c3.5-0.2,6.8-0.2,9.9-0.2h4.6
|
||||
v-2c0-4.6-1.6-8-4.8-10.3c-3.2-2.3-7.3-3.4-12.2-3.4c-3.9,0-7.6,0.7-11,2.1c-3.4,1.4-6.4,3.2-8.8,5.6l-9.8-9.6
|
||||
c4.1-4.2,9-7.1,14.5-9c5.5-1.8,11.2-2.7,17.1-2.7c5.3,0,9.7,0.6,13.3,1.7c3.6,1.2,6.6,2.7,9,4.5c2.4,1.8,4.2,3.9,5.5,6.3
|
||||
c1.3,2.4,2.2,4.8,2.8,7.2c0.6,2.4,0.9,4.8,1,7.1c0.1,2.3,0.2,4.3,0.2,6v42h-16.7V312.7z"/>
|
||||
<path id="Fill-24" class="st1" d="M683.6,269.9c-3.6-5-8.4-7.5-14.4-7.5c-2.5,0-4.9,0.6-7.2,1.8c-2.4,1.2-3.5,3.2-3.5,5.9
|
||||
c0,2.2,1,3.9,2.9,4.9c1.9,1,4.4,1.9,7.4,2.6c3,0.7,6.2,1.4,9.6,2.2c3.4,0.8,6.6,1.9,9.6,3.5c3,1.6,5.4,3.7,7.4,6.5
|
||||
c1.9,2.7,2.9,6.5,2.9,11.3c0,4.4-0.9,8-2.8,11c-1.9,3-4.3,5.4-7.4,7.2c-3,1.8-6.4,3.1-10.2,4c-3.8,0.8-7.6,1.2-11.3,1.2
|
||||
c-5.7,0-11-0.8-15.8-2.4c-4.8-1.6-9.1-4.6-12.9-8.8l12.3-11.4c2.4,2.6,4.9,4.8,7.6,6.5c2.7,1.7,6,2.5,9.9,2.5
|
||||
c1.3,0,2.7-0.2,4.1-0.5c1.4-0.3,2.8-0.8,4-1.5c1.2-0.7,2.2-1.6,3-2.7c0.8-1.1,1.1-2.3,1.1-3.7c0-2.5-1-4.4-2.9-5.6
|
||||
c-1.9-1.2-4.4-2.2-7.4-3c-3-0.8-6.2-1.5-9.6-2.1c-3.4-0.7-6.6-1.7-9.6-3.2c-3-1.5-5.4-3.5-7.4-6.2c-1.9-2.6-2.9-6.3-2.9-11
|
||||
c0-4.1,0.8-7.6,2.5-10.6c1.7-3,3.9-5.4,6.7-7.4c2.8-1.9,5.9-3.3,9.5-4.3c3.6-0.9,7.2-1.4,10.9-1.4c4.9,0,9.8,0.8,14.6,2.5
|
||||
c4.8,1.7,8.7,4.5,11.7,8.6L683.6,269.9z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="enable-background:new 0 0 75 75" viewBox="0 0 75 75" width="75" height="75" ><path d="M24.3 44.7c2 0 6-.1 11.6-2.4 6.5-2.7 19.3-7.5 28.6-12.5 6.5-3.5 9.3-8.1 9.3-14.3C73.8 7 66.9 0 58.3 0h-36C10 0 0 10 0 22.3s9.4 22.4 24.3 22.4z" style="fill-rule:evenodd;clip-rule:evenodd;fill:#39594d"/><path d="M30.4 60c0-6 3.6-11.5 9.2-13.8l11.3-4.7C62.4 36.8 75 45.2 75 57.6 75 67.2 67.2 75 57.6 75H45.3c-8.2 0-14.9-6.7-14.9-15z" style="fill-rule:evenodd;clip-rule:evenodd;fill:#d18ee2"/><path d="M12.9 47.6C5.8 47.6 0 53.4 0 60.5v1.7C0 69.2 5.8 75 12.9 75c7.1 0 12.9-5.8 12.9-12.9v-1.7c-.1-7-5.8-12.8-12.9-12.8z" style="fill:#ff7759"/></svg>
|
||||
|
After Width: | Height: | Size: 742 B |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DBRX</title><path d="M21.821 9.894l-9.81 5.595L1.505 9.511 1 9.787v4.34l11.01 6.256 9.811-5.574v2.297l-9.81 5.596-10.506-5.979L1 17v.745L12.01 24 23 17.745v-4.34l-.505-.277-10.484 5.957-9.832-5.574v-2.298l9.832 5.574L23 10.532V6.255l-.547-.319-10.442 5.936-9.327-5.276 9.327-5.298 7.663 4.362.673-.383v-.532L12.011 0 1 6.255v.681l11.01 6.255 9.811-5.595z" fill="#EE3D2C" fill-rule="nonzero"></path></svg>
|
||||
|
After Width: | Height: | Size: 528 B |
|
After Width: | Height: | Size: 6.8 KiB |
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 25.4.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 292.6 215.3" style="enable-background:new 0 0 292.6 215.3;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#566AB2;}
|
||||
</style>
|
||||
<path class="st0" d="M191.3,123.7c-2.4,1-4.9,1.8-7.2,1.9c-3.6,0.2-7.6-1.3-9.7-3.1c-3.3-2.8-5.7-4.4-6.7-9.2
|
||||
c-0.4-2.1-0.2-5.3,0.2-7.2c0.9-4-0.1-6.5-2.9-8.9c-2.3-1.9-5.2-2.4-8.4-2.4s-2.3-0.5-3.1-1c-1.3-0.7-2.4-2.3-1.4-4.4
|
||||
c0.3-0.7,2-2.3,2.3-2.5c4.3-2.5,9.4-1.7,14,0.2c4.3,1.7,7.5,5,12.2,9.5c4.8,5.5,5.6,7,8.4,11.1c2.1,3.2,4.1,6.6,5.4,10.4
|
||||
C195.2,120.5,194.2,122.4,191.3,123.7L191.3,123.7z M153.4,104.3c0-2.1,1.7-3.7,3.8-3.7s0.9,0.1,1.3,0.2c0.5,0.2,1,0.5,1.4,0.9
|
||||
c0.7,0.7,1.1,1.6,1.1,2.6c0,2.1-1.7,3.8-3.8,3.8s-3.7-1.7-3.7-3.8H153.4z M141.2,182.8c-25.5-20-37.8-26.6-42.9-26.3
|
||||
c-4.8,0.3-3.9,5.7-2.8,9.3c1.1,3.5,2.5,5.9,4.5,9c1.4,2,2.3,5.1-1.4,7.3c-8.2,5.1-22.5-1.7-23.1-2c-16.6-9.8-30.5-22.7-40.2-40.3
|
||||
c-9.5-17-14.9-35.2-15.8-54.6c-0.2-4.7,1.1-6.4,5.8-7.2c6.2-1.1,12.5-1.4,18.7-0.5c26,3.8,48.1,15.4,66.7,33.8
|
||||
c10.6,10.5,18.6,23,26.8,35.2c8.8,13,18.2,25.4,30.2,35.5c4.3,3.6,7.6,6.3,10.9,8.2c-9.8,1.1-26.1,1.3-37.2-7.5L141.2,182.8z
|
||||
M289.5,18c-3.1-1.5-4.4,1.4-6.3,2.8c-0.6,0.5-1.1,1.1-1.7,1.7c-4.5,4.8-9.8,8-16.8,7.6c-10.1-0.6-18.7,2.6-26.4,10.4
|
||||
c-1.6-9.5-7-15.2-15.2-18.9c-4.3-1.9-8.6-3.8-11.6-7.9c-2.1-2.9-2.7-6.2-3.7-9.4c-0.7-2-1.3-3.9-3.6-4.3c-2.4-0.4-3.4,1.7-4.3,3.4
|
||||
c-3.8,7-5.3,14.6-5.2,22.4c0.3,17.5,7.7,31.5,22.4,41.4c1.7,1.1,2.1,2.3,1.6,3.9c-1,3.4-2.2,6.7-3.3,10.1c-0.7,2.2-1.7,2.7-4,1.7
|
||||
c-8.1-3.4-15-8.4-21.2-14.4c-10.4-10.1-19.9-21.2-31.6-30c-2.8-2.1-5.5-4-8.4-5.7c-12-11.7,1.6-21.3,4.7-22.4
|
||||
c3.3-1.2,1.2-5.3-9.5-5.2c-10.6,0-20.3,3.6-32.8,8.4c-1.8,0.7-3.7,1.2-5.7,1.7c-11.3-2.1-22.9-2.6-35.1-1.2
|
||||
c-23,2.5-41.4,13.4-54.8,32C1,68.3-2.8,93.6,1.9,120c4.9,27.8,19.1,50.9,41,68.9c22.6,18.7,48.7,27.8,78.5,26.1
|
||||
c18.1-1,38.2-3.5,60.9-22.7c5.7,2.8,11.7,4,21.7,4.8c7.7,0.7,15.1-0.4,20.8-1.5c9-1.9,8.4-10.2,5.1-11.7
|
||||
c-26.3-12.3-20.5-7.3-25.7-11.3c13.3-15.8,33.5-32.2,41.3-85.4c0.6-4.2,0.1-6.9,0-10.3c0-2.1,0.4-2.9,2.8-3.1
|
||||
c6.6-0.8,13-2.6,18.8-5.8c17-9.3,23.9-24.6,25.5-42.9c0.2-2.8,0-5.7-3-7.2L289.5,18z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Fireworks</title><path clip-rule="evenodd" d="M14.8 5l-2.801 6.795L9.195 5H7.397l3.072 7.428a1.64 1.64 0 003.038.002L16.598 5H14.8zm1.196 10.352l5.124-5.244-.699-1.669-5.596 5.739a1.664 1.664 0 00-.343 1.807 1.642 1.642 0 001.516 1.012L16 17l8-.02-.699-1.669-7.303.041h-.002zM2.88 10.104l.699-1.669 5.596 5.739c.468.479.603 1.189.343 1.807a1.643 1.643 0 01-1.516 1.012l-8-.018-.002.002.699-1.669 7.303.042-5.122-5.246z" fill="#5019C5" fill-rule="evenodd"></path></svg>
|
||||
|
After Width: | Height: | Size: 592 B |
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none"><path fill="#4285F4" d="M14.9 8.161c0-.476-.039-.954-.121-1.422h-6.64v2.695h3.802a3.24 3.24 0 01-1.407 2.127v1.75h2.269c1.332-1.22 2.097-3.02 2.097-5.15z"/><path fill="#34A853" d="M8.14 15c1.898 0 3.499-.62 4.665-1.69l-2.268-1.749c-.631.427-1.446.669-2.395.669-1.836 0-3.393-1.232-3.952-2.888H1.85v1.803A7.044 7.044 0 008.14 15z"/><path fill="#FBBC04" d="M4.187 9.342a4.17 4.17 0 010-2.68V4.859H1.849a6.97 6.97 0 000 6.286l2.338-1.803z"/><path fill="#EA4335" d="M8.14 3.77a3.837 3.837 0 012.7 1.05l2.01-1.999a6.786 6.786 0 00-4.71-1.82 7.042 7.042 0 00-6.29 3.858L4.186 6.66c.556-1.658 2.116-2.89 3.952-2.89z"/></svg>
|
||||
|
After Width: | Height: | Size: 728 B |
@@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 26.3 26.3"><defs><style>.cls-1{fill:#f05237;}.cls-2{fill:#fff;}</style></defs><g id="Layer_2" data-name="Layer 2"><g id="Content"><circle class="cls-1" cx="13.15" cy="13.15" r="13.15"/><path class="cls-2" d="M13.17,6.88a4.43,4.43,0,0,0,0,8.85h1.45V14.07H13.17a2.77,2.77,0,1,1,2.77-2.76v4.07a2.74,2.74,0,0,1-4.67,2L10.1,18.51a4.37,4.37,0,0,0,3.07,1.29h.06a4.42,4.42,0,0,0,4.36-4.4V11.2a4.43,4.43,0,0,0-4.42-4.32"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 619 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 490.16 411.7"><defs><style>.cls-1{fill:url(#未命名的渐变_6);}</style><linearGradient id="未命名的渐变_6" y1="205.85" x2="490.16" y2="205.85" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#e4177f"/><stop offset="0.5" stop-color="#e73562"/><stop offset="1" stop-color="#e94e4a"/></linearGradient></defs><title>资源 2</title><g id="图层_2" data-name="图层 2"><g id="图层_1-2" data-name="图层 1"><path class="cls-1" d="M233.45,40.81a17.55,17.55,0,1,0-35.1,0V331.56a40.82,40.82,0,0,1-81.63,0V145a17.55,17.55,0,1,0-35.09,0v79.06a40.82,40.82,0,0,1-81.63,0V195.42a11.63,11.63,0,0,1,23.26,0v28.66a17.55,17.55,0,0,0,35.1,0V145A40.82,40.82,0,0,1,140,145V331.56a17.55,17.55,0,0,0,35.1,0V217.5h0V40.81a40.81,40.81,0,1,1,81.62,0V281.56a11.63,11.63,0,1,1-23.26,0Zm215.9,63.4A40.86,40.86,0,0,0,408.53,145V300.85a17.55,17.55,0,0,1-35.09,0v-260a40.82,40.82,0,0,0-81.63,0V370.89a17.55,17.55,0,0,1-35.1,0V330a11.63,11.63,0,1,0-23.26,0v40.86a40.81,40.81,0,0,0,81.62,0V40.81a17.55,17.55,0,0,1,35.1,0v260a40.82,40.82,0,0,0,81.63,0V145a17.55,17.55,0,1,1,35.1,0V281.56a11.63,11.63,0,0,0,23.26,0V145A40.85,40.85,0,0,0,449.35,104.21Z"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Mistral</title><path d="M3.428 3.4h3.429v3.428H3.428V3.4zm13.714 0h3.43v3.428h-3.43V3.4z" fill="gold"></path><path d="M3.428 6.828h6.857v3.429H3.429V6.828zm10.286 0h6.857v3.429h-6.857V6.828z" fill="#FFAF00"></path><path d="M3.428 10.258h17.144v3.428H3.428v-3.428z" fill="#FF8205"></path><path d="M3.428 13.686h3.429v3.428H3.428v-3.428zm6.858 0h3.429v3.428h-3.429v-3.428zm6.856 0h3.43v3.428h-3.43v-3.428z" fill="#FA500F"></path><path d="M0 17.114h10.286v3.429H0v-3.429zm13.714 0H24v3.429H13.714v-3.429z" fill="#E10500"></path></svg>
|
||||
|
After Width: | Height: | Size: 655 B |