mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-12 18:47:44 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85ae215025 | ||
|
|
056e4f5baa | ||
|
|
8f06d18e5a | ||
|
|
f8c6f32d00 | ||
|
|
69ff8e7f03 | ||
|
|
feaa81b68f | ||
|
|
39406e904f | ||
|
|
2bb37b1076 | ||
|
|
cc36ac1cf8 | ||
|
|
a041a76f0d | ||
|
|
f77cca9931 | ||
|
|
ef176298b9 | ||
|
|
31f5cbb1cf | ||
|
|
85ec6cba90 | ||
|
|
c77259eda7 | ||
|
|
e69e726ca6 | ||
|
|
3130ae4e19 | ||
|
|
c872be20ed | ||
|
|
4548783a40 | ||
|
|
5b1c39d496 | ||
|
|
1e3933f5f3 | ||
|
|
203ebe4960 | ||
|
|
e144d840bb | ||
|
|
47bff65413 | ||
|
|
66bd9ee2a9 | ||
|
|
1dd5d7e706 | ||
|
|
fee74fab53 | ||
|
|
003f9371a1 | ||
|
|
7234cd5682 | ||
|
|
4373fb3adb | ||
|
|
e87dd8e37a | ||
|
|
872cd87638 | ||
|
|
8ea0065f83 | ||
|
|
e922eb1e64 | ||
|
|
e364aa7519 | ||
|
|
e67a11db1f | ||
|
|
9071af74ea | ||
|
|
7821916c56 | ||
|
|
dd1b9a7f1f | ||
|
|
3b006be069 | ||
|
|
4d80c60ecb | ||
|
|
0cdfde2c04 | ||
|
|
98e2fcccf6 | ||
|
|
1a42ca92d3 | ||
|
|
c75d087591 | ||
|
|
2f6f496e5d | ||
|
|
42ab69c142 | ||
|
|
db62f4368c | ||
|
|
fd3daa27fa | ||
|
|
875da3dfe0 | ||
|
|
620e21b599 | ||
|
|
79ad6b621f | ||
|
|
cbe57c9d52 | ||
|
|
fa264e1314 | ||
|
|
e71e73cca4 | ||
|
|
9861520be6 | ||
|
|
710624bd69 | ||
|
|
a35a6690c4 | ||
|
|
1b9d569de8 | ||
|
|
956e4eeb91 | ||
|
|
654e1e2aec | ||
|
|
2870aa9861 | ||
|
|
7bec22ce77 | ||
|
|
520a46019a | ||
|
|
7ee1c642d4 | ||
|
|
397f499dda | ||
|
|
d4dac28434 | ||
|
|
dbca6e0047 | ||
|
|
ed3da59b2c | ||
|
|
55ab4acf8d | ||
|
|
e0c1301965 | ||
|
|
79c4002e3c | ||
|
|
2e5a8c5fe5 | ||
|
|
99c811da21 | ||
|
|
0fb773adcd | ||
|
|
d95d3854db | ||
|
|
1383db2fba | ||
|
|
75adcfed68 | ||
|
|
3793ef0387 | ||
|
|
bf3b7f8e9d | ||
|
|
a4da8c6ab6 | ||
|
|
f59ba1b83d | ||
|
|
8c88766900 | ||
|
|
a6eaa854fe | ||
|
|
e36911e712 | ||
|
|
c3a6a39ee1 | ||
|
|
15146ff6a7 | ||
|
|
0d0c2fa762 | ||
|
|
c9010fca9f | ||
|
|
7a36352196 | ||
|
|
6ae4ebedcb | ||
|
|
3377347087 | ||
|
|
f47aa248ea | ||
|
|
0379e9f196 | ||
|
|
c841d46ae9 | ||
|
|
cd6fdd0107 | ||
|
|
d840407b12 | ||
|
|
b99ca3f0ae | ||
|
|
23c48f6cd5 | ||
|
|
d0fa48b110 | ||
|
|
0d2c1bd961 | ||
|
|
ef6b1a8913 | ||
|
|
dc9c250adb | ||
|
|
ad8ee02b5a | ||
|
|
02b9c64c0a |
No files matched your search
+3
-1
@@ -93,4 +93,6 @@ Cargo.lock
|
||||
**/*.rs.bk
|
||||
*.pdb
|
||||
.windsurf
|
||||
.agent
|
||||
.agent
|
||||
|
||||
src/praisonai/build
|
||||
@@ -55,7 +55,7 @@ Methods:
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">display_name</a>() -> str</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">ephemeral</a>()</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">execute</a>(task, context = None)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">execute_tool</a>(function_name, arguments)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">execute_tool</a>(function_name, arguments, tool_call_id = None)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">execute_tool_async</a>(function_name: str, arguments: Dict[str, Any]) -> Any</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">from_template</a>(uri: str, config: Optional[Dict[str, Any]] = None, offline: bool = False, **kwargs) -> 'Agent'</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">generate_task</a>() -> 'Task'</code>
|
||||
@@ -381,7 +381,7 @@ from praisonaiagents import GuardrailAction, GuardrailConfig, LLMGuardrail
|
||||
Methods:
|
||||
|
||||
* <code title="class GuardrailConfig">GuardrailConfig.<a href="./src/praisonai-agents/praisonaiagents/config/feature_configs.py">to_dict</a>() -> Dict[str, Any]</code>
|
||||
* <code title="class LLMGuardrail">LLMGuardrail.<a href="./src/praisonai-agents/praisonaiagents/guardrails/llm_guardrail.py">__call__</a>(task_output: TaskOutput) -> Tuple[bool, Union[str, TaskOutput]]</code>
|
||||
* <code title="class LLMGuardrail">LLMGuardrail.<a href="./src/praisonai-agents/praisonaiagents/guardrails/llm_guardrail.py">__call__</a>(task_output) -> Tuple[bool, Union[str, 'TaskOutput']]</code>
|
||||
|
||||
# Planning
|
||||
|
||||
@@ -784,8 +784,10 @@ Methods:
|
||||
* <code title="cli">praisonai benchmark profile <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
|
||||
* <code title="cli">praisonai benchmark sdk <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
|
||||
* <code title="cli">praisonai benchmark workflow <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot agentmail <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot bot-callback <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot discord <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot email <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot slack <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot start <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot telegram <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
@@ -802,6 +804,7 @@ Methods:
|
||||
* <code title="cli">praisonai call call-main <a href="./src/praisonai/praisonai/cli/commands/call.py">--help</a></code>
|
||||
* <code title="cli">praisonai chat <a href="./src/praisonai/praisonai/cli/main.py">--help</a></code>
|
||||
* <code title="cli">praisonai chat chat-main <a href="./src/praisonai/praisonai/cli/commands/chat.py">--help</a></code>
|
||||
* <code title="cli">praisonai claw claw <a href="./src/praisonai/praisonai/cli/commands/claw.py">--help</a></code>
|
||||
* <code title="cli">praisonai code <a href="./src/praisonai/praisonai/cli/main.py">--help</a></code>
|
||||
* <code title="cli">praisonai code code-main <a href="./src/praisonai/praisonai/cli/commands/code.py">--help</a></code>
|
||||
* <code title="cli">praisonai commit commit-main <a href="./src/praisonai/praisonai/cli/commands/commit.py">--help</a></code>
|
||||
@@ -868,7 +871,9 @@ Methods:
|
||||
* <code title="cli">praisonai examples run <a href="./src/praisonai/praisonai/cli/commands/examples.py">--help</a></code>
|
||||
* <code title="cli">praisonai examples run-all <a href="./src/praisonai/praisonai/cli/commands/examples.py">--help</a></code>
|
||||
* <code title="cli">praisonai examples stats <a href="./src/praisonai/praisonai/cli/commands/examples.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway channels <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway gateway-callback <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway send <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway start <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway status <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai hooks add <a href="./src/praisonai/praisonai/cli/commands/hooks.py">--help</a></code>
|
||||
@@ -906,6 +911,7 @@ Methods:
|
||||
* <code title="cli">praisonai package install <a href="./src/praisonai/praisonai/cli/commands/package.py">--help</a></code>
|
||||
* <code title="cli">praisonai package list <a href="./src/praisonai/praisonai/cli/commands/package.py">--help</a></code>
|
||||
* <code title="cli">praisonai package uninstall <a href="./src/praisonai/praisonai/cli/commands/package.py">--help</a></code>
|
||||
* <code title="cli">praisonai paths show <a href="./src/praisonai/praisonai/cli/commands/paths.py">--help</a></code>
|
||||
* <code title="cli">praisonai plugins create <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
|
||||
* <code title="cli">praisonai plugins disable <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
|
||||
* <code title="cli">praisonai plugins discover <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
|
||||
@@ -963,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.30" \
|
||||
"praisonai>=4.5.80" \
|
||||
"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.30" \
|
||||
"praisonai>=4.5.80" \
|
||||
"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.30" \
|
||||
"praisonai>=4.5.80" \
|
||||
"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
|
||||
|
||||
|
||||
@@ -23,12 +23,22 @@ def main():
|
||||
@register_tool("custom.calculate")
|
||||
def calculate(expression: str) -> str:
|
||||
"""Safely evaluate a mathematical expression."""
|
||||
import ast
|
||||
import operator
|
||||
try:
|
||||
# Only allow safe math operations
|
||||
allowed = set("0123456789+-*/.(). ")
|
||||
if not all(c in allowed for c in expression):
|
||||
return "Error: Invalid characters in expression"
|
||||
result = eval(expression) # Safe due to character filtering
|
||||
# Use ast.parse for safe evaluation of math expressions
|
||||
tree = ast.parse(expression, mode='eval')
|
||||
# Only allow numeric literals and basic math operators
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, (ast.Expression, ast.BinOp, ast.UnaryOp,
|
||||
ast.Constant, ast.Add, ast.Sub, ast.Mult,
|
||||
ast.Div, ast.USub, ast.UAdd)):
|
||||
return "Error: Only basic math operations are allowed"
|
||||
result = eval(compile(tree, '<expr>', 'eval'), {"__builtins__": {}})
|
||||
return f"Result: {result}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@@ -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)
|
||||
@@ -44,12 +44,20 @@ def main():
|
||||
|
||||
def calculate(expression: str) -> str:
|
||||
"""Calculate a math expression safely."""
|
||||
import ast
|
||||
try:
|
||||
# Safe eval for basic math
|
||||
allowed = set("0123456789+-*/.() ")
|
||||
if all(c in allowed for c in expression):
|
||||
return str(eval(expression))
|
||||
return "Error: Invalid expression"
|
||||
if not all(c in allowed for c in expression):
|
||||
return "Error: Invalid expression"
|
||||
# Use ast.parse to validate expression safety
|
||||
tree = ast.parse(expression, mode='eval')
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, (ast.Expression, ast.BinOp, ast.UnaryOp,
|
||||
ast.Constant, ast.Add, ast.Sub, ast.Mult,
|
||||
ast.Div, ast.USub, ast.UAdd)):
|
||||
return "Error: Only basic math operations are allowed"
|
||||
return str(eval(compile(tree, '<expr>', 'eval'), {"__builtins__": {}}))
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
@@ -135,9 +135,30 @@ def main():
|
||||
return {"error": str(e)}
|
||||
|
||||
# MCP endpoints
|
||||
def safe_calculate(expression: str) -> str:
|
||||
"""Safely evaluate a mathematical expression using AST."""
|
||||
import ast
|
||||
try:
|
||||
# Only allow safe math characters
|
||||
allowed = set("0123456789+-*/.() ")
|
||||
if not all(c in allowed for c in expression):
|
||||
return "Error: Invalid characters in expression"
|
||||
# Use ast.parse for safe evaluation
|
||||
tree = ast.parse(expression, mode='eval')
|
||||
# Only allow numeric literals and basic math operators
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, (ast.Expression, ast.BinOp, ast.UnaryOp,
|
||||
ast.Constant, ast.Add, ast.Sub, ast.Mult,
|
||||
ast.Div, ast.USub, ast.UAdd)):
|
||||
return "Error: Only basic math operations are allowed"
|
||||
result = eval(compile(tree, '<expr>', 'eval'), {"__builtins__": {}})
|
||||
return str(result)
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
mcp_tools = {
|
||||
"search": {"description": "Search the web", "func": lambda q: f"Results for: {q}"},
|
||||
"calculate": {"description": "Calculate expression", "func": lambda e: str(eval(e)) if e.replace(" ", "").replace("+", "").replace("-", "").replace("*", "").replace("/", "").replace(".", "").isdigit() or True else "Error"},
|
||||
"calculate": {"description": "Calculate expression", "func": safe_calculate},
|
||||
}
|
||||
|
||||
@app.get("/mcp/tools")
|
||||
|
||||
@@ -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
|
||||
@@ -93,4 +93,5 @@ chroma_db/
|
||||
|
||||
.praisonai
|
||||
.windsurf
|
||||
.agent
|
||||
.agent
|
||||
.sfdx
|
||||
@@ -1,3 +1,5 @@
|
||||
import os
|
||||
|
||||
from praisonaiagents import Agent, Task, AgentTeam
|
||||
from praisonaiagents.memory import Memory
|
||||
from praisonaiagents.agent import ContextAgent
|
||||
@@ -10,7 +12,7 @@ context_output = context_agent.start("https://github.com/MervinPraison/PraisonAI
|
||||
mongodb_memory_config = {
|
||||
"provider": "mongodb",
|
||||
"config": {
|
||||
"connection_string": "mongodb+srv://username:[email protected]/?retryWrites=true&w=majority&appName=Cluster2",
|
||||
"connection_string": os.environ.get("MONGODB_CONNECTION_STRING", "mongodb+srv://localhost:27017/?retryWrites=true&w=majority"),
|
||||
"database": "praisonai_memory",
|
||||
"use_vector_search": True,
|
||||
"max_pool_size": 50,
|
||||
@@ -49,7 +51,7 @@ print("\n=== MEMORY VALIDATION ===")
|
||||
|
||||
try:
|
||||
# Check MongoDB connection and collections
|
||||
client = pymongo.MongoClient("mongodb+srv://username:[email protected]/?retryWrites=true&w=majority&appName=Cluster2")
|
||||
client = pymongo.MongoClient(os.environ.get("MONGODB_CONNECTION_STRING", "mongodb+srv://localhost:27017/?retryWrites=true&w=majority"))
|
||||
db = client["praisonai_memory"]
|
||||
collections = db.list_collection_names()
|
||||
print(f"Available collections: {collections}")
|
||||
|
||||
@@ -344,6 +344,14 @@ _LAZY_IMPORTS = {
|
||||
'ObservabilityEventType': ('praisonaiagents.escalation.observability', 'EventType'),
|
||||
'DoomLoopDetector': ('praisonaiagents.escalation.doom_loop', 'DoomLoopDetector'),
|
||||
'MemoryBackend': ('praisonaiagents.config.feature_configs', 'MemoryBackend'),
|
||||
'LearnConfig': ('praisonaiagents.config.feature_configs', 'LearnConfig'),
|
||||
'LearnScope': ('praisonaiagents.config.feature_configs', 'LearnScope'),
|
||||
'LearnMode': ('praisonaiagents.config.feature_configs', 'LearnMode'),
|
||||
'LearnBackend': ('praisonaiagents.config.feature_configs', 'LearnBackend'),
|
||||
'LearnProtocol': ('praisonaiagents.memory.learn.protocols', 'LearnProtocol'),
|
||||
'AsyncLearnProtocol': ('praisonaiagents.memory.learn.protocols', 'AsyncLearnProtocol'),
|
||||
'LearnManagerProtocol': ('praisonaiagents.memory.learn.protocols', 'LearnManagerProtocol'),
|
||||
'LearnManager': ('praisonaiagents.memory.learn', 'LearnManager'),
|
||||
'ChunkingStrategy': ('praisonaiagents.config.feature_configs', 'ChunkingStrategy'),
|
||||
'GuardrailAction': ('praisonaiagents.config.feature_configs', 'GuardrailAction'),
|
||||
'WebSearchProvider': ('praisonaiagents.config.feature_configs', 'WebSearchProvider'),
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -73,6 +73,17 @@ _ESCALATION_TO_AUTONOMY_SIGNAL = {
|
||||
_AUTONOMY_TO_ESCALATION_SIGNAL = {v: k for k, v in _ESCALATION_TO_AUTONOMY_SIGNAL.items()}
|
||||
|
||||
|
||||
# Valid autonomy modes
|
||||
VALID_AUTONOMY_MODES = {"caller", "iterative"}
|
||||
|
||||
# Smart default mode based on level
|
||||
_LEVEL_TO_DEFAULT_MODE = {
|
||||
"suggest": "caller",
|
||||
"auto_edit": "caller",
|
||||
"full_auto": "iterative",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AutonomyConfig:
|
||||
"""Configuration for Agent autonomy features.
|
||||
@@ -83,6 +94,10 @@ class AutonomyConfig:
|
||||
Attributes:
|
||||
enabled: Whether autonomy is enabled
|
||||
level: Autonomy level (suggest, auto_edit, full_auto)
|
||||
mode: Execution mode — "caller" (single chat call, wrapper-equivalent)
|
||||
or "iterative" (internal loop with completion detection).
|
||||
Defaults based on level: suggest/auto_edit → "caller",
|
||||
full_auto → "iterative".
|
||||
max_iterations: Maximum iterations before stopping
|
||||
doom_loop_threshold: Number of repeated actions to trigger doom loop
|
||||
auto_escalate: Whether to automatically escalate complexity
|
||||
@@ -99,18 +114,21 @@ class AutonomyConfig:
|
||||
|
||||
Usage::
|
||||
|
||||
# Simple — full_auto auto-enables tracking + sandbox
|
||||
# Simple — default caller mode (wrapper-equivalent, no wasteful loop)
|
||||
Agent(autonomy=True) # level=suggest, mode=caller
|
||||
|
||||
# Full auto — iterative loop with completion detection
|
||||
Agent(autonomy="full_auto")
|
||||
|
||||
# Advanced — explicit config
|
||||
# Ralph Loop — explicit iterative mode with completion promise
|
||||
Agent(autonomy=AutonomyConfig(
|
||||
level="full_auto",
|
||||
track_changes=True,
|
||||
sandbox=SandboxConfig.native(writable_paths=["./src"]),
|
||||
mode="iterative",
|
||||
completion_promise="COMPLETE",
|
||||
))
|
||||
"""
|
||||
enabled: bool = True
|
||||
level: str = "suggest"
|
||||
mode: Optional[str] = None # None = smart default based on level
|
||||
max_iterations: int = 20
|
||||
doom_loop_threshold: int = 3
|
||||
auto_escalate: bool = True
|
||||
@@ -131,6 +149,14 @@ class AutonomyConfig:
|
||||
f"Invalid autonomy level: {self.level!r}. "
|
||||
f"Must be one of {sorted(VALID_AUTONOMY_LEVELS)}"
|
||||
)
|
||||
# Smart default mode based on level
|
||||
if self.mode is None:
|
||||
self.mode = _LEVEL_TO_DEFAULT_MODE.get(self.level, "caller")
|
||||
elif self.mode not in VALID_AUTONOMY_MODES:
|
||||
raise ValueError(
|
||||
f"Invalid autonomy mode: {self.mode!r}. "
|
||||
f"Must be one of {sorted(VALID_AUTONOMY_MODES)}"
|
||||
)
|
||||
# Auto-enable track_changes for full_auto if not explicitly set
|
||||
if self.track_changes is None:
|
||||
self.track_changes = (self.level == "full_auto")
|
||||
@@ -156,6 +182,7 @@ class AutonomyConfig:
|
||||
return cls(
|
||||
enabled=data.get("enabled", True),
|
||||
level=level,
|
||||
mode=data.get("mode"), # None = smart default in __post_init__
|
||||
max_iterations=data.get("max_iterations", 20),
|
||||
doom_loop_threshold=data.get("doom_loop_threshold", 3),
|
||||
auto_escalate=data.get("auto_escalate", True),
|
||||
@@ -378,6 +405,14 @@ class DoomLoopTracker:
|
||||
"""
|
||||
self._delegate.mark_progress(marker)
|
||||
|
||||
def record_response(self, text: str) -> None:
|
||||
"""Record model response text for content streaming loop detection.
|
||||
|
||||
Args:
|
||||
text: The model's response text
|
||||
"""
|
||||
self._delegate.record_response(text)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset the tracker completely."""
|
||||
self._delegate.start_session()
|
||||
|
||||
@@ -220,6 +220,7 @@ class AgentTeam:
|
||||
web: Optional[Any] = None, # Union[bool, WebConfig] - web search/fetch
|
||||
reflection: Optional[Any] = None, # Union[bool, ReflectionConfig] - self-reflection
|
||||
caching: Optional[Any] = None, # Union[bool, CachingConfig] - caching
|
||||
learn: Optional[Any] = None, # Union[bool, LearnConfig] - continuous learning
|
||||
):
|
||||
"""
|
||||
Initialize AgentManager with consolidated feature parameters.
|
||||
@@ -243,8 +244,10 @@ class AgentTeam:
|
||||
guardrails: Guardrails configuration (bool | Callable | GuardrailConfig)
|
||||
web: Web search/fetch configuration (bool | WebConfig)
|
||||
reflection: Self-reflection configuration (bool | ReflectionConfig)
|
||||
learn: Continuous learning configuration (bool | LearnConfig)
|
||||
"""
|
||||
# Store new params for propagation to agents
|
||||
self._learn = learn
|
||||
self.llm = llm # Store default LLM for API consistency
|
||||
self._autonomy = autonomy
|
||||
self._knowledge = knowledge
|
||||
|
||||
@@ -319,3 +319,106 @@ class BackgroundRunner:
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._cleanup_task = None
|
||||
|
||||
# ── Sync-friendly wrappers ──────────────────────────────────────────
|
||||
|
||||
def submit_sync(
|
||||
self,
|
||||
func: Callable,
|
||||
args: tuple = (),
|
||||
kwargs: Optional[Dict[str, Any]] = None,
|
||||
name: Optional[str] = None,
|
||||
timeout: Optional[float] = None,
|
||||
on_complete: Optional[Callable[[BackgroundTask], None]] = None,
|
||||
) -> BackgroundTask:
|
||||
"""Submit a task from synchronous code.
|
||||
|
||||
Creates a background event loop (once) and schedules the task
|
||||
there. Returns the ``BackgroundTask`` immediately so callers
|
||||
can poll ``task.status`` / ``task.result`` without blocking.
|
||||
|
||||
Args:
|
||||
func: Function to execute (sync or async)
|
||||
args: Positional arguments for *func*
|
||||
kwargs: Keyword arguments for *func*
|
||||
name: Optional human-readable task name
|
||||
timeout: Optional timeout in seconds
|
||||
on_complete: Callback when task completes
|
||||
|
||||
Returns:
|
||||
BackgroundTask for tracking
|
||||
"""
|
||||
loop = _get_bg_loop()
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self.submit(
|
||||
func, args=args, kwargs=kwargs,
|
||||
name=name, timeout=timeout, on_complete=on_complete,
|
||||
),
|
||||
loop,
|
||||
)
|
||||
# Block briefly to get the BackgroundTask object (not the task result).
|
||||
return future.result(timeout=5)
|
||||
|
||||
def submit_agent_sync(
|
||||
self,
|
||||
agent: Any,
|
||||
prompt: str,
|
||||
name: Optional[str] = None,
|
||||
timeout: Optional[float] = None,
|
||||
on_complete: Optional[Callable[[BackgroundTask], None]] = None,
|
||||
) -> BackgroundTask:
|
||||
"""Submit an agent task from synchronous code.
|
||||
|
||||
Convenience wrapper around :meth:`submit_sync` that resolves
|
||||
the agent's callable automatically.
|
||||
|
||||
Args:
|
||||
agent: Agent instance (must have ``start``, ``chat``, or ``run``)
|
||||
prompt: Prompt to send to the agent
|
||||
name: Optional task name
|
||||
timeout: Optional timeout in seconds
|
||||
on_complete: Callback when task completes
|
||||
|
||||
Returns:
|
||||
BackgroundTask for tracking
|
||||
"""
|
||||
loop = _get_bg_loop()
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self.submit_agent(
|
||||
agent, prompt,
|
||||
name=name, timeout=timeout, on_complete=on_complete,
|
||||
),
|
||||
loop,
|
||||
)
|
||||
return future.result(timeout=5)
|
||||
|
||||
|
||||
# ── Module-level background event loop (lazy, daemon) ───────────────────
|
||||
|
||||
import threading as _threading
|
||||
|
||||
_bg_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
_bg_lock = _threading.Lock()
|
||||
|
||||
|
||||
def _get_bg_loop() -> asyncio.AbstractEventLoop:
|
||||
"""Return a running event loop on a background daemon thread.
|
||||
|
||||
Created once on first call; reused thereafter. The thread is a
|
||||
daemon so it won't prevent process exit.
|
||||
"""
|
||||
global _bg_loop
|
||||
if _bg_loop is not None and _bg_loop.is_running():
|
||||
return _bg_loop
|
||||
|
||||
with _bg_lock:
|
||||
# Double-check after acquiring lock.
|
||||
if _bg_loop is not None and _bg_loop.is_running():
|
||||
return _bg_loop
|
||||
|
||||
_bg_loop = asyncio.new_event_loop()
|
||||
|
||||
t = _threading.Thread(target=_bg_loop.run_forever, daemon=True)
|
||||
t.start()
|
||||
|
||||
return _bg_loop
|
||||
@@ -22,6 +22,8 @@ if TYPE_CHECKING:
|
||||
ChatCommandProtocol,
|
||||
ProbeResult,
|
||||
HealthResult,
|
||||
EmailProtocol,
|
||||
EmailInbox,
|
||||
)
|
||||
from .protocols import (
|
||||
BotProtocol,
|
||||
@@ -37,6 +39,8 @@ from .protocols import (
|
||||
BotOSProtocol,
|
||||
ProbeResult,
|
||||
HealthResult,
|
||||
EmailProtocol,
|
||||
EmailInbox,
|
||||
)
|
||||
from .config import BotConfig, BotOSConfig
|
||||
|
||||
@@ -56,4 +60,6 @@ __all__ = [
|
||||
"BotOSConfig",
|
||||
"ProbeResult",
|
||||
"HealthResult",
|
||||
"EmailProtocol",
|
||||
"EmailInbox",
|
||||
]
|
||||
@@ -29,8 +29,10 @@ class BotConfig:
|
||||
"""
|
||||
|
||||
token: str = ""
|
||||
mode: str = "poll" # poll | ws | webhook | hybrid
|
||||
webhook_url: Optional[str] = None
|
||||
webhook_path: str = "/webhook"
|
||||
webhook_port: int = 8080
|
||||
polling_interval: float = 1.0
|
||||
allowed_users: List[str] = field(default_factory=list)
|
||||
allowed_channels: List[str] = field(default_factory=list)
|
||||
@@ -47,7 +49,12 @@ class BotConfig:
|
||||
group_policy: str = "mention_only" # respond_all, mention_only, command_only
|
||||
|
||||
# Default tools enabled for all bots
|
||||
default_tools: List[str] = field(default_factory=lambda: ["execute_command", "search_web", "schedule_add", "schedule_list", "schedule_remove"])
|
||||
default_tools: List[str] = field(default_factory=lambda: [
|
||||
"execute_command", "search_web", "web_crawl",
|
||||
"schedule_add", "schedule_list", "schedule_remove",
|
||||
"store_memory", "search_memory",
|
||||
"store_learning", "search_learning",
|
||||
])
|
||||
|
||||
# Auto-approve tool calls (useful for trusted environments)
|
||||
auto_approve_tools: bool = False # If True, skip confirmation for tool execution
|
||||
@@ -72,8 +79,10 @@ class BotConfig:
|
||||
"""Convert to dictionary (hides sensitive data)."""
|
||||
return {
|
||||
"token": "***" if self.token else None,
|
||||
"mode": self.mode,
|
||||
"webhook_url": self.webhook_url,
|
||||
"webhook_path": self.webhook_path,
|
||||
"webhook_port": self.webhook_port,
|
||||
"polling_interval": self.polling_interval,
|
||||
"allowed_users": self.allowed_users,
|
||||
"allowed_channels": self.allowed_channels,
|
||||
@@ -97,7 +106,17 @@ class BotConfig:
|
||||
@property
|
||||
def is_webhook_mode(self) -> bool:
|
||||
"""Whether bot is configured for webhook mode."""
|
||||
return bool(self.webhook_url)
|
||||
return self.mode == "webhook" or bool(self.webhook_url)
|
||||
|
||||
@property
|
||||
def is_ws_mode(self) -> bool:
|
||||
"""Whether bot is configured for WebSocket mode."""
|
||||
return self.mode == "ws"
|
||||
|
||||
@property
|
||||
def is_hybrid_mode(self) -> bool:
|
||||
"""Whether bot is configured for hybrid mode (WS + slow poll)."""
|
||||
return self.mode == "hybrid"
|
||||
|
||||
def is_user_allowed(self, user_id: str) -> bool:
|
||||
"""Check if a user is allowed to interact with the bot."""
|
||||
|
||||
@@ -613,6 +613,96 @@ class ChatCommandProtocol(Protocol):
|
||||
...
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# EmailProtocol — email-specific bot capabilities
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmailInbox:
|
||||
"""Information about an email inbox.
|
||||
|
||||
Attributes:
|
||||
id: Unique inbox identifier
|
||||
email_address: The inbox's email address
|
||||
domain: Domain of the inbox
|
||||
created_at: When the inbox was created (ISO format)
|
||||
"""
|
||||
|
||||
id: str
|
||||
email_address: str
|
||||
domain: str = ""
|
||||
created_at: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"email_address": self.email_address,
|
||||
"domain": self.domain,
|
||||
"created_at": self.created_at,
|
||||
}
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class EmailProtocol(Protocol):
|
||||
"""Protocol for email bots with inbox lifecycle management.
|
||||
|
||||
Extends BotProtocol with email-specific capabilities like
|
||||
programmatic inbox creation, listing, and deletion.
|
||||
|
||||
This protocol is optional — only AgentMail-style bots that
|
||||
support API-based inbox management need to implement it.
|
||||
Traditional IMAP/SMTP bots (EmailBot) do not implement this.
|
||||
|
||||
Example:
|
||||
# Check if a bot supports inbox management
|
||||
if isinstance(bot, EmailProtocol):
|
||||
inbox = await bot.create_inbox(domain="mycompany.com")
|
||||
print(f"Created: {inbox['email_address']}")
|
||||
"""
|
||||
|
||||
@property
|
||||
def email_address(self) -> Optional[str]:
|
||||
"""The bot's current email address."""
|
||||
...
|
||||
|
||||
async def create_inbox(
|
||||
self,
|
||||
domain: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a new email inbox programmatically.
|
||||
|
||||
Args:
|
||||
domain: Optional custom domain for the inbox
|
||||
**kwargs: Additional provider-specific parameters
|
||||
|
||||
Returns:
|
||||
Dict with at least 'id' and 'email_address' keys
|
||||
"""
|
||||
...
|
||||
|
||||
async def list_inboxes(self) -> List[Dict[str, Any]]:
|
||||
"""List all inboxes accessible to this bot.
|
||||
|
||||
Returns:
|
||||
List of inbox dicts with 'id' and 'email_address' keys
|
||||
"""
|
||||
...
|
||||
|
||||
async def delete_inbox(self, inbox_id: str) -> bool:
|
||||
"""Delete an inbox.
|
||||
|
||||
Args:
|
||||
inbox_id: ID of the inbox to delete
|
||||
|
||||
Returns:
|
||||
True if deleted successfully
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# BotOS Protocol — multi-platform bot orchestration
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
@@ -57,6 +57,34 @@ class LearnScope(str, Enum):
|
||||
SHARED = "shared" # Shared with all agents
|
||||
|
||||
|
||||
class LearnMode(str, Enum):
|
||||
"""Learning mode for automatic learning extraction.
|
||||
|
||||
DISABLED: No automatic learning (manual capture only)
|
||||
AGENTIC: Agent autonomously extracts and stores learnings after each conversation
|
||||
PROPOSE: (Future) Agent proposes learnings for user approval before storing.
|
||||
Note: PROPOSE mode is defined but not yet implemented. Using it will
|
||||
behave the same as DISABLED until the approval workflow is added.
|
||||
"""
|
||||
DISABLED = "disabled"
|
||||
AGENTIC = "agentic"
|
||||
PROPOSE = "propose"
|
||||
|
||||
|
||||
class LearnBackend(str, Enum):
|
||||
"""Storage backend for learning data.
|
||||
|
||||
FILE: JSON files (default, zero dependencies)
|
||||
SQLITE: SQLite database
|
||||
REDIS: Redis (requires redis package)
|
||||
MONGODB: MongoDB (requires pymongo)
|
||||
"""
|
||||
FILE = "file"
|
||||
SQLITE = "sqlite"
|
||||
REDIS = "redis"
|
||||
MONGODB = "mongodb"
|
||||
|
||||
|
||||
@dataclass
|
||||
class LearnConfig:
|
||||
"""
|
||||
@@ -66,16 +94,21 @@ class LearnConfig:
|
||||
to improve future responses. All learning data is stored within the memory system.
|
||||
|
||||
Usage:
|
||||
# Simple enable
|
||||
Agent(memory=MemoryConfig(learn=True))
|
||||
# Simple enable (top-level param)
|
||||
Agent(learn=True)
|
||||
|
||||
# With specific capabilities
|
||||
Agent(memory=MemoryConfig(
|
||||
learn=LearnConfig(
|
||||
persona=True, # User preferences
|
||||
insights=True, # Observations
|
||||
patterns=True, # Reusable knowledge
|
||||
)
|
||||
Agent(learn=LearnConfig(
|
||||
persona=True, # User preferences
|
||||
insights=True, # Observations
|
||||
patterns=True, # Reusable knowledge
|
||||
mode="agentic", # Auto-extract learnings
|
||||
))
|
||||
|
||||
# With database backend
|
||||
Agent(learn=LearnConfig(
|
||||
backend="sqlite",
|
||||
db_url="sqlite:///learn.db",
|
||||
))
|
||||
"""
|
||||
# Learning capabilities
|
||||
@@ -87,15 +120,19 @@ class LearnConfig:
|
||||
feedback: bool = False # Outcome signals
|
||||
improvements: bool = False # Self-improvement proposals
|
||||
|
||||
# Learning mode
|
||||
mode: Union[str, LearnMode] = LearnMode.DISABLED # How to extract learnings
|
||||
|
||||
# Scope configuration
|
||||
scope: Union[str, LearnScope] = LearnScope.PRIVATE
|
||||
|
||||
# Storage configuration
|
||||
store_path: Optional[str] = None # Custom storage path
|
||||
# Storage backend configuration
|
||||
backend: Union[str, LearnBackend] = LearnBackend.FILE # Storage backend
|
||||
db_url: Optional[str] = None # Database connection URL (for non-file backends)
|
||||
store_path: Optional[str] = None # Custom storage path (for file backend)
|
||||
|
||||
# Maintenance settings
|
||||
auto_consolidate: bool = True # Auto-consolidate learnings
|
||||
retention_days: Optional[int] = None # Days to retain (None = forever)
|
||||
# LLM for learning extraction
|
||||
llm: Optional[str] = None # LLM model for extracting learnings (defaults to agent's LLM)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
@@ -107,10 +144,12 @@ class LearnConfig:
|
||||
"decisions": self.decisions,
|
||||
"feedback": self.feedback,
|
||||
"improvements": self.improvements,
|
||||
"mode": self.mode.value if isinstance(self.mode, LearnMode) else self.mode,
|
||||
"scope": self.scope.value if isinstance(self.scope, LearnScope) else self.scope,
|
||||
"backend": self.backend.value if isinstance(self.backend, LearnBackend) else self.backend,
|
||||
"db_url": self.db_url,
|
||||
"store_path": self.store_path,
|
||||
"auto_consolidate": self.auto_consolidate,
|
||||
"retention_days": self.retention_days,
|
||||
"llm": self.llm,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -10,13 +10,25 @@ from .models import BudgetAllocation
|
||||
|
||||
# Model token limits (context window sizes)
|
||||
MODEL_LIMITS: Dict[str, int] = {
|
||||
# OpenAI
|
||||
# OpenAI — gpt-5 family (1M context window)
|
||||
"gpt-5": 1047576,
|
||||
"gpt-5-mini": 1047576,
|
||||
"gpt-5-nano": 1047576,
|
||||
# OpenAI — gpt-4.1 family (1M context window)
|
||||
"gpt-4.1": 1047576,
|
||||
"gpt-4.1-mini": 1047576,
|
||||
"gpt-4.1-nano": 1047576,
|
||||
# OpenAI — gpt-4o family
|
||||
"gpt-4o": 128000,
|
||||
"gpt-4o-mini": 128000,
|
||||
"gpt-4-turbo": 128000,
|
||||
"gpt-4": 8192,
|
||||
"gpt-3.5-turbo": 16385,
|
||||
"gpt-3.5-turbo-16k": 16385,
|
||||
# OpenAI — o-series
|
||||
"o3-mini": 200000,
|
||||
"o3": 200000,
|
||||
"o4-mini": 200000,
|
||||
# Anthropic
|
||||
"claude-3-5-sonnet": 200000,
|
||||
"claude-3-5-haiku": 200000,
|
||||
@@ -33,11 +45,15 @@ MODEL_LIMITS: Dict[str, int] = {
|
||||
|
||||
# Default output reserves by model family
|
||||
OUTPUT_RESERVES: Dict[str, int] = {
|
||||
"gpt-5": 32768,
|
||||
"gpt-4.1": 32768,
|
||||
"gpt-4o": 16384,
|
||||
"gpt-4o-mini": 16384,
|
||||
"gpt-4-turbo": 4096,
|
||||
"gpt-4": 4096,
|
||||
"gpt-3.5-turbo": 4096,
|
||||
"o3": 100000,
|
||||
"o4-mini": 100000,
|
||||
"claude-3": 8192,
|
||||
"gemini": 8192,
|
||||
"default": 8000,
|
||||
|
||||
@@ -99,7 +99,6 @@ def test_db_hook_lifecycle():
|
||||
instructions="Test agent",
|
||||
db=mock_db,
|
||||
session_id="test-session-001",
|
||||
verbose=False
|
||||
)
|
||||
|
||||
# Verify db is stored
|
||||
@@ -125,7 +124,6 @@ def test_db_none_unchanged_behavior():
|
||||
agent = Agent(
|
||||
name="TestAgent",
|
||||
instructions="Test agent",
|
||||
verbose=False
|
||||
)
|
||||
|
||||
# Verify db is None
|
||||
@@ -157,7 +155,6 @@ def test_session_id_auto_generation():
|
||||
instructions="Test agent",
|
||||
db=mock_db,
|
||||
# No session_id provided
|
||||
verbose=False
|
||||
)
|
||||
|
||||
# Trigger db initialization
|
||||
|
||||
@@ -22,6 +22,7 @@ class DoomLoopType(Enum):
|
||||
NO_PROGRESS = "no_progress" # No meaningful progress
|
||||
CIRCULAR_PLAN = "circular_plan" # Plan loops back to start
|
||||
RESOURCE_EXHAUSTION = "resource_exhaustion" # Budget exceeded
|
||||
REPEATED_OUTPUT = "repeated_output" # Same output text repeated (content chanting)
|
||||
|
||||
|
||||
class RecoveryAction(Enum):
|
||||
@@ -58,6 +59,10 @@ class DoomLoopConfig:
|
||||
initial_backoff: float = 1.0 # Initial backoff in seconds
|
||||
backoff_multiplier: float = 2.0 # Backoff multiplier
|
||||
max_backoff: float = 30.0 # Maximum backoff
|
||||
|
||||
# Content streaming detection
|
||||
max_repeated_chunks: int = 8 # Max identical output chunks before flagging
|
||||
content_chunk_size: int = 50 # Sliding window chunk size (chars)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -116,6 +121,7 @@ class DoomLoopDetector:
|
||||
self._start_time: Optional[float] = None
|
||||
self._progress_markers: List[str] = []
|
||||
self._current_backoff: float = self.config.initial_backoff
|
||||
self._content_chunk_counts: Dict[str, int] = {} # hash -> count
|
||||
|
||||
def start_session(self):
|
||||
"""Start a new detection session."""
|
||||
@@ -125,6 +131,7 @@ class DoomLoopDetector:
|
||||
self._recovery_attempts = 0
|
||||
self._progress_markers.clear()
|
||||
self._current_backoff = self.config.initial_backoff
|
||||
self._content_chunk_counts.clear()
|
||||
|
||||
def record_action(
|
||||
self,
|
||||
@@ -162,6 +169,25 @@ class DoomLoopDetector:
|
||||
if self.is_doom_loop():
|
||||
self._handle_loop_detection()
|
||||
|
||||
def record_response(self, text: str):
|
||||
"""
|
||||
Record model response text for content streaming loop detection.
|
||||
|
||||
Hashes sliding window chunks and tracks repetition counts.
|
||||
Inspired by gemini-cli's sliding window approach.
|
||||
|
||||
Args:
|
||||
text: The model's response text
|
||||
"""
|
||||
if not text or len(text) < self.config.content_chunk_size:
|
||||
return
|
||||
|
||||
chunk_size = self.config.content_chunk_size
|
||||
for i in range(0, len(text) - chunk_size + 1, chunk_size):
|
||||
chunk = text[i:i + chunk_size]
|
||||
chunk_hash = hashlib.md5(chunk.encode()).hexdigest()[:16]
|
||||
self._content_chunk_counts[chunk_hash] = self._content_chunk_counts.get(chunk_hash, 0) + 1
|
||||
|
||||
def mark_progress(self, marker: str):
|
||||
"""
|
||||
Mark meaningful progress.
|
||||
@@ -178,6 +204,10 @@ class DoomLoopDetector:
|
||||
Returns:
|
||||
True if doom loop detected
|
||||
"""
|
||||
# Content streaming loop can occur even without recorded actions
|
||||
if self._check_content_loop():
|
||||
return True
|
||||
|
||||
if len(self._actions) < 2:
|
||||
return False
|
||||
|
||||
@@ -201,6 +231,10 @@ class DoomLoopDetector:
|
||||
if self._check_resource_exhaustion():
|
||||
return True
|
||||
|
||||
# Check for content streaming loops (repeated output text)
|
||||
if self._check_content_loop():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_loop_type(self) -> Optional[DoomLoopType]:
|
||||
@@ -215,6 +249,8 @@ class DoomLoopDetector:
|
||||
return DoomLoopType.NO_PROGRESS
|
||||
if self._check_resource_exhaustion():
|
||||
return DoomLoopType.RESOURCE_EXHAUSTION
|
||||
if self._check_content_loop():
|
||||
return DoomLoopType.REPEATED_OUTPUT
|
||||
return None
|
||||
|
||||
def get_loop_event(self) -> Optional[DoomLoopEvent]:
|
||||
@@ -371,6 +407,19 @@ class DoomLoopDetector:
|
||||
elapsed = time.time() - self._start_time
|
||||
return elapsed > self.config.max_total_time
|
||||
|
||||
def _check_content_loop(self) -> bool:
|
||||
"""Check for repeated output text (content streaming loop).
|
||||
|
||||
Uses sliding window chunk hashing. If any chunk hash appears
|
||||
more than max_repeated_chunks times, it indicates the model
|
||||
is generating repetitive output (chanting).
|
||||
"""
|
||||
if not self._content_chunk_counts:
|
||||
return False
|
||||
|
||||
threshold = self.config.max_repeated_chunks
|
||||
return any(count >= threshold for count in self._content_chunk_counts.values())
|
||||
|
||||
def _determine_recovery_action(self, loop_type: DoomLoopType) -> RecoveryAction:
|
||||
"""Determine appropriate recovery action."""
|
||||
# If max recovery attempts reached, abort
|
||||
@@ -408,6 +457,9 @@ class DoomLoopDetector:
|
||||
DoomLoopType.RESOURCE_EXHAUSTION: (
|
||||
f"Exceeded time limit of {self.config.max_total_time}s"
|
||||
),
|
||||
DoomLoopType.REPEATED_OUTPUT: (
|
||||
f"Model output repeating — {self.config.max_repeated_chunks}+ identical chunks detected"
|
||||
),
|
||||
}
|
||||
return descriptions.get(loop_type, "Unknown loop type")
|
||||
|
||||
|
||||
@@ -67,16 +67,22 @@ class LLMGuardrail:
|
||||
self.logger.warning(f"Unknown LLM type: {type(llm)}, treating as-is")
|
||||
return llm
|
||||
|
||||
def __call__(self, task_output: TaskOutput) -> Tuple[bool, Union[str, TaskOutput]]:
|
||||
def __call__(self, task_output) -> Tuple[bool, Union[str, "TaskOutput"]]:
|
||||
"""Validate the task output using the LLM.
|
||||
|
||||
Args:
|
||||
task_output: The task output to validate
|
||||
task_output: The task output to validate (TaskOutput or plain str)
|
||||
|
||||
Returns:
|
||||
Tuple of (success, result) where result is the output or error message
|
||||
"""
|
||||
try:
|
||||
# Accept plain str for convenience (e.g. chat-input screening)
|
||||
if isinstance(task_output, str):
|
||||
raw_text = task_output
|
||||
else:
|
||||
raw_text = task_output.raw
|
||||
|
||||
if not self.llm:
|
||||
self.logger.warning("No LLM provided for guardrail validation")
|
||||
return True, task_output
|
||||
@@ -88,7 +94,7 @@ You are a quality assurance validator. Your task is to evaluate the following ou
|
||||
Validation Criteria: {self.description}
|
||||
|
||||
Output to Validate:
|
||||
{task_output.raw}
|
||||
{raw_text}
|
||||
|
||||
Please evaluate if this output meets the criteria. Respond with:
|
||||
1. "PASS" if the output meets all criteria
|
||||
|
||||
@@ -20,34 +20,59 @@ class Chunking:
|
||||
@cached_property
|
||||
def SUPPORTED_CHUNKERS(self) -> Dict[str, Any]:
|
||||
"""Lazy load chunker classes."""
|
||||
chunkers = {}
|
||||
try:
|
||||
from chonkie import (
|
||||
TokenChunker,
|
||||
SentenceChunker,
|
||||
SemanticChunker,
|
||||
SDPMChunker,
|
||||
LateChunker,
|
||||
RecursiveChunker,
|
||||
CodeChunker,
|
||||
NeuralChunker,
|
||||
SlumberChunker
|
||||
)
|
||||
from chonkie import TokenChunker
|
||||
chunkers['token'] = TokenChunker
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from chonkie import SentenceChunker
|
||||
chunkers['sentence'] = SentenceChunker
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from chonkie import SemanticChunker
|
||||
chunkers['semantic'] = SemanticChunker
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from chonkie import SDPMChunker
|
||||
chunkers['sdpm'] = SDPMChunker
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from chonkie import LateChunker
|
||||
chunkers['late'] = LateChunker
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from chonkie import RecursiveChunker
|
||||
chunkers['recursive'] = RecursiveChunker
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from chonkie import CodeChunker
|
||||
chunkers['code'] = CodeChunker
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from chonkie import NeuralChunker
|
||||
chunkers['neural'] = NeuralChunker
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from chonkie import SlumberChunker
|
||||
chunkers['slumber'] = SlumberChunker
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if not chunkers:
|
||||
raise ImportError(
|
||||
"chonkie package not found. Please install it using: pip install 'praisonaiagents[knowledge]'"
|
||||
)
|
||||
|
||||
return {
|
||||
'token': TokenChunker,
|
||||
'sentence': SentenceChunker,
|
||||
'semantic': SemanticChunker,
|
||||
'sdpm': SDPMChunker,
|
||||
'late': LateChunker,
|
||||
'recursive': RecursiveChunker,
|
||||
'code': CodeChunker,
|
||||
'neural': NeuralChunker,
|
||||
'slumber': SlumberChunker
|
||||
}
|
||||
|
||||
return chunkers
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -116,7 +141,18 @@ class Chunking:
|
||||
if self._chunker is None:
|
||||
chunker_cls = self.SUPPORTED_CHUNKERS[self.chunker_type]
|
||||
common_params = self._get_chunker_params()
|
||||
self._chunker = chunker_cls(**common_params)
|
||||
try:
|
||||
self._chunker = chunker_cls(**common_params)
|
||||
except TypeError:
|
||||
# Chonkie API may have changed — retry with just chunk_size
|
||||
fallback_params = {'chunk_size': self.chunk_size}
|
||||
if 'embedding_model' in common_params:
|
||||
fallback_params['embedding_model'] = common_params['embedding_model']
|
||||
try:
|
||||
self._chunker = chunker_cls(**fallback_params)
|
||||
except TypeError:
|
||||
# Last resort — no params
|
||||
self._chunker = chunker_cls()
|
||||
|
||||
return self._chunker
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import os
|
||||
import logging
|
||||
import uuid
|
||||
import time
|
||||
from datetime import datetime
|
||||
from .chunking import Chunking
|
||||
from functools import cached_property
|
||||
@@ -414,8 +412,9 @@ class Knowledge:
|
||||
|
||||
@cached_property
|
||||
def config(self):
|
||||
# Generate unique collection name for each instance (only if not provided in config)
|
||||
default_collection = f"test_{int(time.time())}_{str(uuid.uuid4())[:8]}"
|
||||
# Use deterministic collection name for persistence across restarts
|
||||
# Only generate unique name if explicitly requested via config
|
||||
default_collection = "praisonai_knowledge"
|
||||
from ..paths import get_project_data_dir
|
||||
persist_dir = str(get_project_data_dir())
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ class LLM:
|
||||
MODEL_WINDOWS = {
|
||||
# OpenAI
|
||||
"gpt-4": 6144, # 8,192 actual
|
||||
"gpt-4o-mini": 96000, # 128,000 actual
|
||||
"gpt-4o": 96000, # 128,000 actual
|
||||
"gpt-4o-mini": 96000, # 128,000 actual
|
||||
"gpt-4-turbo": 96000, # 128,000 actual
|
||||
"o1-preview": 96000, # 128,000 actual
|
||||
@@ -1685,6 +1685,167 @@ Now provide your final answer using this result. Summarize the information natur
|
||||
# Initial callback is in agent._chat_completion, so this triggers for all loop iterations
|
||||
_get_display_functions()['execute_sync_callback']('llm_start', model=self.model, agent_name=agent_name)
|
||||
|
||||
# ── Responses API path ──────────────────────────────────
|
||||
# OpenAI models use the Responses API which returns text
|
||||
# and tool calls as separate output items — no content:null.
|
||||
if self._supports_responses_api():
|
||||
responses_params = self._build_responses_params(
|
||||
messages=messages,
|
||||
tools=formatted_tools,
|
||||
temperature=temperature,
|
||||
output_json=output_json,
|
||||
output_pydantic=output_pydantic,
|
||||
**{k: v for k, v in kwargs.items() if k != 'reasoning_steps'},
|
||||
)
|
||||
|
||||
if stream:
|
||||
response_text, tool_calls = self._stream_responses_api(
|
||||
responses_params,
|
||||
verbose=verbose,
|
||||
stream_callback=stream_callback,
|
||||
emit_events=emit_events,
|
||||
current_time=current_time,
|
||||
)
|
||||
else:
|
||||
resp = self._call_responses_api(**responses_params)
|
||||
response_text, tool_calls, _reasoning = self._extract_from_responses_output(resp)
|
||||
|
||||
# Track token usage
|
||||
if self.metrics and hasattr(resp, 'usage') and resp.usage:
|
||||
usage = resp.usage
|
||||
tokens_in = getattr(usage, 'input_tokens', 0) or 0
|
||||
tokens_out = getattr(usage, 'output_tokens', 0) or 0
|
||||
# Emit llm_end with metrics
|
||||
llm_latency_ms = (time.time() - current_time) * 1000
|
||||
_get_display_functions()['execute_sync_callback'](
|
||||
'llm_end', model=self.model,
|
||||
tokens_in=tokens_in, tokens_out=tokens_out,
|
||||
cost=None, latency_ms=llm_latency_ms,
|
||||
)
|
||||
|
||||
# Emit reasoning if present
|
||||
if _emit and _reasoning:
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.DELTA_TEXT,
|
||||
timestamp=time.perf_counter(),
|
||||
content=_reasoning, is_reasoning=True,
|
||||
))
|
||||
# Emit text content as stream event
|
||||
if _emit and response_text:
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.DELTA_TEXT,
|
||||
timestamp=time.perf_counter(),
|
||||
content=response_text, is_reasoning=False,
|
||||
))
|
||||
|
||||
# Build a Chat-Completions-compatible final_response
|
||||
# so all downstream code (display, callbacks) works unchanged
|
||||
final_response = {
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": response_text,
|
||||
"tool_calls": tool_calls,
|
||||
}
|
||||
}]
|
||||
}
|
||||
|
||||
# Display / callback handling (same as Chat Completions path)
|
||||
if verbose and not interaction_displayed:
|
||||
_get_display_functions()['display_interaction'](
|
||||
original_prompt, response_text,
|
||||
markdown=markdown,
|
||||
generation_time=time.time() - current_time,
|
||||
console=self.console,
|
||||
agent_name=agent_name, agent_role=agent_role,
|
||||
agent_tools=agent_tools, task_name=task_name,
|
||||
task_description=task_description, task_id=task_id,
|
||||
)
|
||||
interaction_displayed = True
|
||||
callback_executed = True
|
||||
elif not callback_executed:
|
||||
_get_display_functions()['execute_sync_callback'](
|
||||
'interaction',
|
||||
message=original_prompt, response=response_text,
|
||||
markdown=markdown,
|
||||
generation_time=time.time() - current_time,
|
||||
agent_name=agent_name, agent_role=agent_role,
|
||||
agent_tools=agent_tools, task_name=task_name,
|
||||
task_description=task_description, task_id=task_id,
|
||||
)
|
||||
callback_executed = True
|
||||
|
||||
# Emit llm_content for intermediate narrative display
|
||||
if response_text and response_text.strip():
|
||||
_get_display_functions()['execute_sync_callback'](
|
||||
'llm_content',
|
||||
content=response_text.strip(),
|
||||
agent_name=agent_name,
|
||||
)
|
||||
|
||||
# ── Handle tool calls ───────────────────────────────
|
||||
if tool_calls and execute_tool_fn:
|
||||
serializable_tool_calls = self._serialize_tool_calls(tool_calls)
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": response_text,
|
||||
"tool_calls": serializable_tool_calls,
|
||||
})
|
||||
|
||||
tool_results = []
|
||||
for tool_call in tool_calls:
|
||||
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call)
|
||||
|
||||
logging.debug(f"[RESPONSES_API] Executing tool {function_name} with args: {arguments}")
|
||||
tool_result = execute_tool_fn(function_name, arguments, tool_call_id=tool_call_id)
|
||||
tool_results.append(tool_result)
|
||||
accumulated_tool_results.append(tool_result)
|
||||
|
||||
if verbose:
|
||||
display_message = f"Agent {agent_name} called function '{function_name}' with arguments: {arguments}\n"
|
||||
display_message += f"Function returned: {tool_result}" if tool_result else "Function returned no output"
|
||||
_get_display_functions()['display_tool_call'](display_message, console=self.console)
|
||||
|
||||
result_str = json.dumps(tool_result) if tool_result else "empty"
|
||||
_get_display_functions()['execute_sync_callback'](
|
||||
'tool_call',
|
||||
message=f"Calling function: {function_name}",
|
||||
tool_name=function_name,
|
||||
tool_input=arguments,
|
||||
tool_output=result_str[:200] if result_str else None,
|
||||
)
|
||||
|
||||
# Append tool result as standard tool message
|
||||
if tool_result is None:
|
||||
content = "Function returned an empty output"
|
||||
elif isinstance(tool_result, dict) and 'error' in tool_result:
|
||||
content = f"Error: {tool_result.get('error', 'Unknown error')}. Please inform the user."
|
||||
elif isinstance(tool_result, list) and tool_result and isinstance(tool_result[0], dict) and 'error' in tool_result[0]:
|
||||
content = f"Error: {tool_result[0].get('error', 'Unknown error')}. Please inform the user."
|
||||
else:
|
||||
content = json.dumps(tool_result)
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_id,
|
||||
"content": content,
|
||||
})
|
||||
|
||||
# Safety: break after 5 iterations
|
||||
if iteration_count >= 5:
|
||||
final_response_text = response_text.strip() if response_text else "Task completed."
|
||||
break
|
||||
|
||||
iteration_count += 1
|
||||
response_text = ""
|
||||
continue
|
||||
else:
|
||||
# No tool calls — we have a final answer
|
||||
if iteration_count > 0:
|
||||
final_response_text = response_text.strip() if response_text else ""
|
||||
else:
|
||||
final_response_text = response_text.strip() if response_text else ""
|
||||
break
|
||||
|
||||
# ── Chat Completions path (unchanged) ───────────────────
|
||||
# If reasoning_steps is True, do a single non-streaming call
|
||||
if reasoning_steps:
|
||||
resp = litellm.completion(
|
||||
@@ -2069,18 +2230,59 @@ Now provide your final answer using this result. Summarize the information natur
|
||||
}]
|
||||
}
|
||||
else:
|
||||
# For non-streaming + non-verbose: no display_generating (per user requirements)
|
||||
final_response = litellm.completion(
|
||||
**self._build_completion_params(
|
||||
messages=messages,
|
||||
tools=formatted_tools,
|
||||
temperature=temperature,
|
||||
stream=False,
|
||||
output_json=output_json,
|
||||
output_pydantic=output_pydantic,
|
||||
**kwargs
|
||||
# For non-streaming + non-verbose path
|
||||
# When editor output is enabled, use silent streaming
|
||||
# to capture intermediate text (OpenAI returns content:null
|
||||
# on tool-call turns in non-streaming mode, but streaming
|
||||
# delivers text tokens before tool call tokens).
|
||||
_use_silent_stream = False
|
||||
try:
|
||||
from ..output.editor import is_editor_output_enabled
|
||||
_use_silent_stream = is_editor_output_enabled()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if _use_silent_stream:
|
||||
# Silent streaming: no display, just accumulate
|
||||
response_text = ""
|
||||
tool_calls = []
|
||||
for chunk in litellm.completion(
|
||||
**self._build_completion_params(
|
||||
messages=messages,
|
||||
tools=formatted_tools,
|
||||
temperature=temperature,
|
||||
stream=True,
|
||||
output_json=output_json,
|
||||
output_pydantic=output_pydantic,
|
||||
**kwargs
|
||||
)
|
||||
):
|
||||
if chunk and chunk.choices and chunk.choices[0].delta:
|
||||
delta = chunk.choices[0].delta
|
||||
response_text, tool_calls = self._process_stream_delta(
|
||||
delta, response_text, tool_calls, formatted_tools
|
||||
)
|
||||
final_response = {
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": response_text,
|
||||
"tool_calls": tool_calls if tool_calls else None
|
||||
}
|
||||
}]
|
||||
}
|
||||
else:
|
||||
# Standard non-streaming path
|
||||
final_response = litellm.completion(
|
||||
**self._build_completion_params(
|
||||
messages=messages,
|
||||
tools=formatted_tools,
|
||||
temperature=temperature,
|
||||
stream=False,
|
||||
output_json=output_json,
|
||||
output_pydantic=output_pydantic,
|
||||
**kwargs
|
||||
)
|
||||
)
|
||||
)
|
||||
# Handle None content from Gemini
|
||||
response_content = final_response["choices"][0]["message"].get("content")
|
||||
response_text = response_content if response_content is not None else ""
|
||||
@@ -2271,6 +2473,16 @@ Now provide your final answer using this result. Summarize the information natur
|
||||
# Reset repair count on successful tool call
|
||||
self._current_repair_count = 0
|
||||
|
||||
# Emit intermediate LLM narrative for display
|
||||
# This fires for ALL responses with text, not just tool-call turns.
|
||||
# Competitors (Gemini CLI, Codex, OpenCode) display this inline.
|
||||
if response_text and response_text.strip():
|
||||
_get_display_functions()['execute_sync_callback'](
|
||||
'llm_content',
|
||||
content=response_text.strip(),
|
||||
agent_name=agent_name,
|
||||
)
|
||||
|
||||
# Handle tool calls - Sequential tool calling logic
|
||||
if tool_calls and execute_tool_fn:
|
||||
# Convert tool_calls to a serializable format for all providers
|
||||
@@ -2313,7 +2525,7 @@ Now provide your final answer using this result. Summarize the information natur
|
||||
arguments = self._validate_and_filter_ollama_arguments(function_name, arguments, tools)
|
||||
|
||||
logging.debug(f"[TOOL_EXEC_DEBUG] About to execute tool {function_name} with args: {arguments}")
|
||||
tool_result = execute_tool_fn(function_name, arguments)
|
||||
tool_result = execute_tool_fn(function_name, arguments, tool_call_id=tool_call_id)
|
||||
logging.debug(f"[TOOL_EXEC_DEBUG] Tool execution result: {tool_result}")
|
||||
tool_results.append(tool_result) # Store the result
|
||||
accumulated_tool_results.append(tool_result) # Accumulate across iterations
|
||||
@@ -2959,6 +3171,15 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
logging.error(f"Non-recoverable stream iterator error: {iterator_error}")
|
||||
raise iterator_error
|
||||
|
||||
# Emit intermediate LLM narrative for display
|
||||
# (parity with non-streaming path above)
|
||||
if response_text and response_text.strip():
|
||||
_get_display_functions()['execute_sync_callback'](
|
||||
'llm_content',
|
||||
content=response_text.strip(),
|
||||
agent_name=agent_name,
|
||||
)
|
||||
|
||||
# After streaming completes, handle tool calls if present
|
||||
if tool_calls and execute_tool_fn:
|
||||
# Add assistant message with tool calls to conversation
|
||||
@@ -2981,8 +3202,8 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call, is_ollama)
|
||||
|
||||
try:
|
||||
# Execute the tool
|
||||
tool_result = execute_tool_fn(function_name, arguments)
|
||||
# Execute the tool (pass tool_call_id for event correlation)
|
||||
tool_result = execute_tool_fn(function_name, arguments, tool_call_id=tool_call_id)
|
||||
|
||||
# Add tool result to messages
|
||||
tool_message = self._create_tool_message(function_name, tool_result, tool_call_id, is_ollama)
|
||||
@@ -3197,6 +3418,104 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
reasoning_content = None
|
||||
tool_calls = []
|
||||
|
||||
# ── Responses API path (async) ──────────────────────────
|
||||
if self._supports_responses_api():
|
||||
responses_params = self._build_responses_params(
|
||||
messages=messages,
|
||||
tools=formatted_tools,
|
||||
temperature=temperature,
|
||||
output_json=output_json,
|
||||
output_pydantic=output_pydantic,
|
||||
**{k: v for k, v in kwargs.items() if k != 'reasoning_steps'},
|
||||
)
|
||||
current_time = time.time()
|
||||
|
||||
if stream:
|
||||
response_text, tool_calls = await self._stream_responses_api_async(
|
||||
responses_params,
|
||||
stream_callback=kwargs.get('stream_callback'),
|
||||
emit_events=kwargs.get('emit_events', False),
|
||||
)
|
||||
else:
|
||||
resp = await self._call_responses_api_async(**responses_params)
|
||||
response_text, tool_calls, _reasoning = self._extract_from_responses_output(resp)
|
||||
|
||||
# Build Chat-Completions-compatible final_response
|
||||
final_response = {
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": response_text,
|
||||
"tool_calls": tool_calls,
|
||||
}
|
||||
}]
|
||||
}
|
||||
|
||||
# Display / callback handling
|
||||
if verbose and not interaction_displayed:
|
||||
_get_display_functions()['display_interaction'](
|
||||
original_prompt, response_text,
|
||||
markdown=markdown,
|
||||
generation_time=time.time() - current_time,
|
||||
console=self.console,
|
||||
agent_name=agent_name, agent_role=agent_role,
|
||||
agent_tools=agent_tools, task_name=task_name,
|
||||
task_description=task_description, task_id=task_id,
|
||||
)
|
||||
interaction_displayed = True
|
||||
callback_executed = True
|
||||
|
||||
# Emit llm_content for narrative display
|
||||
if response_text and response_text.strip():
|
||||
_get_display_functions()['execute_sync_callback'](
|
||||
'llm_content',
|
||||
content=response_text.strip(),
|
||||
agent_name=agent_name,
|
||||
)
|
||||
|
||||
# ── Handle tool calls ────────────────────────────
|
||||
if tool_calls and execute_tool_fn:
|
||||
serializable_tool_calls = self._serialize_tool_calls(tool_calls)
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": response_text,
|
||||
"tool_calls": serializable_tool_calls,
|
||||
})
|
||||
|
||||
for tool_call in tool_calls:
|
||||
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call)
|
||||
logging.debug(f"[RESPONSES_API_ASYNC] Executing tool {function_name}")
|
||||
tool_result = execute_tool_fn(function_name, arguments, tool_call_id=tool_call_id)
|
||||
accumulated_tool_results.append(tool_result)
|
||||
|
||||
if verbose:
|
||||
display_message = f"Agent {agent_name} called function '{function_name}' with arguments: {arguments}\n"
|
||||
display_message += f"Function returned: {tool_result}" if tool_result else "Function returned no output"
|
||||
_get_display_functions()['display_tool_call'](display_message, console=self.console)
|
||||
|
||||
# Append tool result
|
||||
if tool_result is None:
|
||||
content = "Function returned an empty output"
|
||||
elif isinstance(tool_result, dict) and 'error' in tool_result:
|
||||
content = f"Error: {tool_result.get('error', 'Unknown error')}. Please inform the user."
|
||||
else:
|
||||
content = json.dumps(tool_result)
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_id,
|
||||
"content": content,
|
||||
})
|
||||
|
||||
if iteration_count >= 5:
|
||||
final_response_text = response_text.strip() if response_text else "Task completed."
|
||||
break
|
||||
|
||||
iteration_count += 1
|
||||
continue
|
||||
else:
|
||||
final_response_text = response_text.strip() if response_text else ""
|
||||
break
|
||||
|
||||
# ── Chat Completions path (unchanged) ───────────────
|
||||
if reasoning_steps and iteration_count == 0:
|
||||
# Non-streaming call to capture reasoning
|
||||
resp = await litellm.acompletion(
|
||||
@@ -4069,6 +4388,501 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
return params
|
||||
|
||||
# ── Responses API support ───────────────────────────────────────────
|
||||
# These methods add a Responses API call path alongside the existing
|
||||
# Chat Completions path. Model detection gates which path is used.
|
||||
|
||||
def _supports_responses_api(self) -> bool:
|
||||
"""
|
||||
Check if the current model supports the OpenAI Responses API.
|
||||
|
||||
Returns True for OpenAI models that support the Responses API
|
||||
(gpt-4o, gpt-4-turbo, o1, o3, chatgpt-4o, etc.).
|
||||
Returns False for Anthropic, Gemini, Ollama, local, and all other models.
|
||||
"""
|
||||
if not self.model:
|
||||
return False
|
||||
model_lower = self.model.lower()
|
||||
|
||||
# Exclude non-OpenAI providers that happen to use similar model names
|
||||
if self._is_ollama_provider():
|
||||
return False
|
||||
if self._is_gemini_model():
|
||||
return False
|
||||
if self._is_anthropic_model():
|
||||
return False
|
||||
|
||||
# Explicit prefix check for OpenAI & Azure OpenAI
|
||||
# Note: Azure models often start with the deployment name, but LiteLLM
|
||||
# normalises them to "azure/<model>" – the prefixes below handle both.
|
||||
_openai_prefixes = (
|
||||
"gpt-4o", "gpt-4-turbo", "gpt-4.1", "gpt-4.5",
|
||||
"o1", "o3", "o4",
|
||||
"chatgpt-4o",
|
||||
"azure/gpt-4o", "azure/gpt-4-turbo", "azure/gpt-4.1",
|
||||
"azure/o1", "azure/o3", "azure/o4",
|
||||
)
|
||||
# Match on prefix (gpt-4o, gpt-4o-mini, gpt-4o-2024-08-06, etc.)
|
||||
for prefix in _openai_prefixes:
|
||||
if model_lower.startswith(prefix):
|
||||
return True
|
||||
|
||||
# Any model routed through openai/ prefix
|
||||
if model_lower.startswith("openai/"):
|
||||
remainder = model_lower[len("openai/"):]
|
||||
for prefix in _openai_prefixes:
|
||||
if remainder.startswith(prefix):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _build_responses_params(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
tools: Optional[List[Dict]] = None,
|
||||
temperature: Optional[float] = None,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Build parameters for ``litellm.responses()`` from the Chat Completions-
|
||||
style message list that PraisonAI already constructs.
|
||||
|
||||
Mapping:
|
||||
- system message → ``instructions``
|
||||
- remaining messages → ``input`` (list of role/content items)
|
||||
- tools → ``tools`` (Responses API uses same function schema)
|
||||
- temperature, max_tokens, etc. → direct pass-through
|
||||
"""
|
||||
params: Dict[str, Any] = {"model": self.model}
|
||||
|
||||
# ── Extract system instructions ─────────────────────────────────
|
||||
instructions = None
|
||||
input_items: List[Dict[str, Any]] = []
|
||||
for msg in messages:
|
||||
role = msg.get("role", "")
|
||||
if role in ("system", "developer"):
|
||||
# Accumulate system / developer messages as instructions
|
||||
content = msg.get("content", "")
|
||||
if instructions is None:
|
||||
instructions = content
|
||||
else:
|
||||
instructions += "\n" + content
|
||||
else:
|
||||
# user / assistant / tool messages become input items
|
||||
# Special handling for Chat Completions→Responses API format:
|
||||
if role == "assistant" and msg.get("tool_calls"):
|
||||
# Assistant message with tool_calls → emit text (if any)
|
||||
# then emit function_call items for each tool call
|
||||
content = msg.get("content")
|
||||
if content and content.strip():
|
||||
input_items.append({"role": "assistant", "content": content})
|
||||
for tc in msg["tool_calls"]:
|
||||
fn = tc.get("function", tc) if isinstance(tc, dict) else tc
|
||||
fn_name = fn.get("name", "") if isinstance(fn, dict) else getattr(fn, "name", "")
|
||||
fn_args = fn.get("arguments", "{}") if isinstance(fn, dict) else getattr(fn, "arguments", "{}")
|
||||
tc_id = tc.get("id", "") if isinstance(tc, dict) else getattr(tc, "id", "")
|
||||
input_items.append({
|
||||
"type": "function_call",
|
||||
"call_id": tc_id,
|
||||
"name": fn_name,
|
||||
"arguments": fn_args if isinstance(fn_args, str) else json.dumps(fn_args),
|
||||
})
|
||||
elif role == "tool":
|
||||
# Tool result → function_call_output
|
||||
input_items.append({
|
||||
"type": "function_call_output",
|
||||
"call_id": msg.get("tool_call_id", ""),
|
||||
"output": msg.get("content", ""),
|
||||
})
|
||||
else:
|
||||
input_items.append(msg)
|
||||
|
||||
if instructions:
|
||||
params["instructions"] = instructions
|
||||
params["input"] = input_items
|
||||
|
||||
# ── Tools ───────────────────────────────────────────────────────
|
||||
if tools:
|
||||
# Responses API tool format differs from Chat Completions:
|
||||
# Chat Completions: {"type": "function", "function": {"name": ..., "parameters": ...}}
|
||||
# Responses API: {"type": "function", "name": ..., "parameters": ...}
|
||||
# Transform Chat Completions format → Responses API format
|
||||
responses_tools = []
|
||||
for tool in tools:
|
||||
if isinstance(tool, dict) and "function" in tool:
|
||||
fn = tool["function"]
|
||||
responses_tool = {
|
||||
"type": "function",
|
||||
"name": fn.get("name", ""),
|
||||
}
|
||||
if "description" in fn:
|
||||
responses_tool["description"] = fn["description"]
|
||||
if "parameters" in fn:
|
||||
responses_tool["parameters"] = fn["parameters"]
|
||||
if fn.get("strict") is not None:
|
||||
responses_tool["strict"] = fn["strict"]
|
||||
responses_tools.append(responses_tool)
|
||||
else:
|
||||
# Already in Responses API format or unknown — pass through
|
||||
responses_tools.append(tool)
|
||||
params["tools"] = responses_tools
|
||||
if "tool_choice" not in kwargs:
|
||||
params["tool_choice"] = "auto"
|
||||
|
||||
# ── Scalar params ───────────────────────────────────────────────
|
||||
if temperature is not None:
|
||||
params["temperature"] = temperature
|
||||
if self.max_tokens:
|
||||
params["max_output_tokens"] = self.max_tokens
|
||||
if self.top_p:
|
||||
params["top_p"] = self.top_p
|
||||
if self.base_url:
|
||||
params["base_url"] = self.base_url
|
||||
if self.api_key:
|
||||
params["api_key"] = self.api_key
|
||||
if self.timeout:
|
||||
params["timeout"] = self.timeout
|
||||
|
||||
# ── Structured output ───────────────────────────────────────────
|
||||
output_json = kwargs.pop("output_json", None)
|
||||
output_pydantic = kwargs.pop("output_pydantic", None)
|
||||
schema_model = output_json or output_pydantic
|
||||
if schema_model:
|
||||
from .model_capabilities import supports_structured_outputs
|
||||
if supports_structured_outputs(self.model):
|
||||
if hasattr(schema_model, 'model_json_schema'):
|
||||
schema = schema_model.model_json_schema()
|
||||
params['text'] = {
|
||||
"format": {
|
||||
"type": "json_schema",
|
||||
"name": getattr(schema_model, '__name__', 'response'),
|
||||
"schema": schema,
|
||||
"strict": True,
|
||||
}
|
||||
}
|
||||
|
||||
# ── Pass through remaining kwargs (stream, etc.) ────────────────
|
||||
# Filter out internal PraisonAI params that are not Responses API params
|
||||
_internal = {
|
||||
'output_json', 'output_pydantic',
|
||||
'reflection', 'self_reflect', 'max_reflect', 'min_reflect', 'reflect_llm',
|
||||
'verbose', 'markdown',
|
||||
'agent_name', 'agent_role', 'agent_tools',
|
||||
'task_name', 'task_description', 'task_id',
|
||||
'execute_tool_fn', 'stream_callback', 'emit_events',
|
||||
'console', 'reasoning_steps',
|
||||
}
|
||||
for k, v in kwargs.items():
|
||||
if k not in _internal and v is not None:
|
||||
params[k] = v
|
||||
|
||||
return params
|
||||
|
||||
def _call_responses_api(self, **params) -> Any:
|
||||
"""
|
||||
Call ``litellm.responses()`` synchronously with retry support.
|
||||
Returns a ``ResponsesAPIResponse`` object.
|
||||
"""
|
||||
import litellm
|
||||
return self._call_with_retry(litellm.responses, **params)
|
||||
|
||||
async def _call_responses_api_async(self, **params) -> Any:
|
||||
"""
|
||||
Call ``litellm.aresponses()`` asynchronously with retry support.
|
||||
Returns a ``ResponsesAPIResponse`` object.
|
||||
"""
|
||||
import litellm
|
||||
return await self._call_with_retry_async(litellm.aresponses, **params)
|
||||
|
||||
def _extract_from_responses_output(self, response) -> tuple:
|
||||
"""
|
||||
Parse a ``ResponsesAPIResponse`` into the same (text, tool_calls,
|
||||
reasoning_content) tuple that the Chat Completions path produces.
|
||||
|
||||
The Responses API returns an ``output`` list where:
|
||||
- items with type="message" contain text in content[].text
|
||||
- items with type="function_call" contain tool call info
|
||||
Unlike Chat Completions, text and tool calls are *always* separate
|
||||
items, so ``content`` is never null when text is present.
|
||||
"""
|
||||
response_text = ""
|
||||
tool_calls: List[Dict[str, Any]] = []
|
||||
reasoning_content = None
|
||||
|
||||
output_items = getattr(response, 'output', None) or []
|
||||
for item in output_items:
|
||||
# Handle both dict and object access
|
||||
if isinstance(item, dict):
|
||||
item_type = item.get("type", "")
|
||||
else:
|
||||
item_type = getattr(item, "type", "")
|
||||
|
||||
if item_type == "message":
|
||||
# Extract text from content blocks
|
||||
if isinstance(item, dict):
|
||||
content_list = item.get("content", [])
|
||||
else:
|
||||
content_list = getattr(item, "content", [])
|
||||
for content_block in content_list:
|
||||
if isinstance(content_block, dict):
|
||||
block_type = content_block.get("type", "")
|
||||
block_text = content_block.get("text", "") or ""
|
||||
else:
|
||||
block_type = getattr(content_block, "type", "")
|
||||
block_text = getattr(content_block, "text", "") or ""
|
||||
|
||||
if block_type == "output_text":
|
||||
response_text += block_text
|
||||
elif block_type == "reasoning":
|
||||
reasoning_content = (reasoning_content or "") + block_text
|
||||
|
||||
elif item_type == "function_call":
|
||||
# Extract tool call information
|
||||
if isinstance(item, dict):
|
||||
call_id = item.get("call_id", item.get("id", f"tool_{len(tool_calls)}"))
|
||||
fn_name = item.get("name", "")
|
||||
fn_args = item.get("arguments", "{}")
|
||||
else:
|
||||
call_id = getattr(item, "call_id", None) or getattr(item, "id", f"tool_{len(tool_calls)}")
|
||||
fn_name = getattr(item, "name", "")
|
||||
fn_args = getattr(item, "arguments", "{}")
|
||||
|
||||
tool_calls.append({
|
||||
"id": call_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": fn_name,
|
||||
"arguments": fn_args if isinstance(fn_args, str) else json.dumps(fn_args),
|
||||
}
|
||||
})
|
||||
|
||||
elif item_type == "reasoning":
|
||||
# Some models return top-level reasoning items
|
||||
if isinstance(item, dict):
|
||||
reasoning_text = item.get("summary", "") or item.get("text", "")
|
||||
else:
|
||||
reasoning_text = getattr(item, "summary", "") or getattr(item, "text", "")
|
||||
if reasoning_text:
|
||||
reasoning_content = (reasoning_content or "") + str(reasoning_text)
|
||||
|
||||
return response_text, tool_calls if tool_calls else None, reasoning_content
|
||||
|
||||
def _stream_responses_api(
|
||||
self,
|
||||
params: Dict[str, Any],
|
||||
verbose: bool = True,
|
||||
stream_callback: Optional[Callable] = None,
|
||||
emit_events: bool = False,
|
||||
current_time: Optional[float] = None,
|
||||
) -> tuple:
|
||||
"""
|
||||
Stream a Responses API call, accumulating text and tool calls.
|
||||
|
||||
Returns the same (response_text, tool_calls) tuple as the Chat
|
||||
Completions streaming path. Emits StreamEvents when configured.
|
||||
"""
|
||||
import litellm
|
||||
|
||||
params["stream"] = True
|
||||
response_text = ""
|
||||
# tool_calls accumulator: {output_index: {"id":..., "name":..., "arguments":...}}
|
||||
_pending_tools: Dict[int, Dict[str, Any]] = {}
|
||||
_emit = emit_events and stream_callback is not None
|
||||
|
||||
if _emit:
|
||||
from ..streaming.events import StreamEvent, StreamEventType
|
||||
|
||||
for event in litellm.responses(**params):
|
||||
# Access event type — handle both object and dict
|
||||
if isinstance(event, dict):
|
||||
evt_type = event.get("type", "")
|
||||
else:
|
||||
evt_type = getattr(event, "type", "")
|
||||
|
||||
# ── Text delta ──────────────────────────────────────────
|
||||
if evt_type == "response.output_text.delta":
|
||||
delta_text = (event.get("delta", "") if isinstance(event, dict)
|
||||
else getattr(event, "delta", ""))
|
||||
response_text += delta_text
|
||||
if _emit and delta_text:
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.DELTA_TEXT,
|
||||
timestamp=time.perf_counter(),
|
||||
content=delta_text,
|
||||
is_reasoning=False,
|
||||
))
|
||||
|
||||
# ── Reasoning delta ─────────────────────────────────────
|
||||
elif evt_type == "response.reasoning_summary_text.delta":
|
||||
delta_text = (event.get("delta", "") if isinstance(event, dict)
|
||||
else getattr(event, "delta", ""))
|
||||
if _emit and delta_text:
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.DELTA_TEXT,
|
||||
timestamp=time.perf_counter(),
|
||||
content=delta_text,
|
||||
is_reasoning=True,
|
||||
))
|
||||
|
||||
# ── Function call arguments delta ───────────────────────
|
||||
elif evt_type == "response.function_call_arguments.delta":
|
||||
idx = (event.get("output_index", 0) if isinstance(event, dict)
|
||||
else getattr(event, "output_index", 0))
|
||||
delta_args = (event.get("delta", "") if isinstance(event, dict)
|
||||
else getattr(event, "delta", ""))
|
||||
if idx not in _pending_tools:
|
||||
item_id = (event.get("item_id", f"tool_{idx}") if isinstance(event, dict)
|
||||
else getattr(event, "item_id", f"tool_{idx}"))
|
||||
_pending_tools[idx] = {
|
||||
"id": item_id,
|
||||
"name": "",
|
||||
"arguments": "",
|
||||
}
|
||||
_pending_tools[idx]["arguments"] += delta_args
|
||||
|
||||
# ── Output item done — finalise tool call metadata ──────
|
||||
elif evt_type == "response.output_item.done":
|
||||
if isinstance(event, dict):
|
||||
item = event.get("item", {})
|
||||
idx = event.get("output_index", 0)
|
||||
else:
|
||||
item = getattr(event, "item", None) or {}
|
||||
idx = getattr(event, "output_index", 0)
|
||||
|
||||
if isinstance(item, dict):
|
||||
i_type = item.get("type", "")
|
||||
else:
|
||||
i_type = getattr(item, "type", "")
|
||||
|
||||
if i_type == "function_call":
|
||||
if isinstance(item, dict):
|
||||
call_id = item.get("call_id", item.get("id", f"tool_{idx}"))
|
||||
fn_name = item.get("name", "")
|
||||
fn_args = item.get("arguments", "")
|
||||
else:
|
||||
call_id = getattr(item, "call_id", None) or getattr(item, "id", f"tool_{idx}")
|
||||
fn_name = getattr(item, "name", "")
|
||||
fn_args = getattr(item, "arguments", "")
|
||||
|
||||
if idx in _pending_tools:
|
||||
_pending_tools[idx]["id"] = call_id
|
||||
_pending_tools[idx]["name"] = fn_name
|
||||
# Prefer the accumulated delta args if available, else use final
|
||||
if not _pending_tools[idx]["arguments"]:
|
||||
_pending_tools[idx]["arguments"] = fn_args
|
||||
else:
|
||||
_pending_tools[idx] = {
|
||||
"id": call_id,
|
||||
"name": fn_name,
|
||||
"arguments": fn_args,
|
||||
}
|
||||
|
||||
# ── Build tool_calls list in output_index order ─────────────
|
||||
tool_calls: List[Dict[str, Any]] = []
|
||||
for idx in sorted(_pending_tools.keys()):
|
||||
tc = _pending_tools[idx]
|
||||
tool_calls.append({
|
||||
"id": tc["id"],
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tc["name"],
|
||||
"arguments": tc["arguments"],
|
||||
},
|
||||
})
|
||||
|
||||
return response_text, tool_calls if tool_calls else None
|
||||
|
||||
async def _stream_responses_api_async(
|
||||
self,
|
||||
params: Dict[str, Any],
|
||||
stream_callback: Optional[Callable] = None,
|
||||
emit_events: bool = False,
|
||||
) -> tuple:
|
||||
"""
|
||||
Async streaming for Responses API.
|
||||
Returns (response_text, tool_calls).
|
||||
"""
|
||||
import litellm
|
||||
|
||||
params["stream"] = True
|
||||
response_text = ""
|
||||
_pending_tools: Dict[int, Dict[str, Any]] = {}
|
||||
_emit = emit_events and stream_callback is not None
|
||||
|
||||
if _emit:
|
||||
from ..streaming.events import StreamEvent, StreamEventType
|
||||
|
||||
async for event in await litellm.aresponses(**params):
|
||||
if isinstance(event, dict):
|
||||
evt_type = event.get("type", "")
|
||||
else:
|
||||
evt_type = getattr(event, "type", "")
|
||||
|
||||
if evt_type == "response.output_text.delta":
|
||||
delta_text = (event.get("delta", "") if isinstance(event, dict)
|
||||
else getattr(event, "delta", ""))
|
||||
response_text += delta_text
|
||||
if _emit and delta_text:
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.DELTA_TEXT,
|
||||
timestamp=time.perf_counter(),
|
||||
content=delta_text,
|
||||
is_reasoning=False,
|
||||
))
|
||||
|
||||
elif evt_type == "response.function_call_arguments.delta":
|
||||
idx = (event.get("output_index", 0) if isinstance(event, dict)
|
||||
else getattr(event, "output_index", 0))
|
||||
delta_args = (event.get("delta", "") if isinstance(event, dict)
|
||||
else getattr(event, "delta", ""))
|
||||
if idx not in _pending_tools:
|
||||
item_id = (event.get("item_id", f"tool_{idx}") if isinstance(event, dict)
|
||||
else getattr(event, "item_id", f"tool_{idx}"))
|
||||
_pending_tools[idx] = {"id": item_id, "name": "", "arguments": ""}
|
||||
_pending_tools[idx]["arguments"] += delta_args
|
||||
|
||||
elif evt_type == "response.output_item.done":
|
||||
if isinstance(event, dict):
|
||||
item = event.get("item", {})
|
||||
idx = event.get("output_index", 0)
|
||||
else:
|
||||
item = getattr(event, "item", None) or {}
|
||||
idx = getattr(event, "output_index", 0)
|
||||
|
||||
if isinstance(item, dict):
|
||||
i_type = item.get("type", "")
|
||||
else:
|
||||
i_type = getattr(item, "type", "")
|
||||
|
||||
if i_type == "function_call":
|
||||
if isinstance(item, dict):
|
||||
call_id = item.get("call_id", item.get("id", f"tool_{idx}"))
|
||||
fn_name = item.get("name", "")
|
||||
fn_args = item.get("arguments", "")
|
||||
else:
|
||||
call_id = getattr(item, "call_id", None) or getattr(item, "id", f"tool_{idx}")
|
||||
fn_name = getattr(item, "name", "")
|
||||
fn_args = getattr(item, "arguments", "")
|
||||
|
||||
if idx in _pending_tools:
|
||||
_pending_tools[idx]["id"] = call_id
|
||||
_pending_tools[idx]["name"] = fn_name
|
||||
if not _pending_tools[idx]["arguments"]:
|
||||
_pending_tools[idx]["arguments"] = fn_args
|
||||
else:
|
||||
_pending_tools[idx] = {"id": call_id, "name": fn_name, "arguments": fn_args}
|
||||
|
||||
tool_calls: List[Dict[str, Any]] = []
|
||||
for idx in sorted(_pending_tools.keys()):
|
||||
tc = _pending_tools[idx]
|
||||
tool_calls.append({
|
||||
"id": tc["id"],
|
||||
"type": "function",
|
||||
"function": {"name": tc["name"], "arguments": tc["arguments"]},
|
||||
})
|
||||
|
||||
return response_text, tool_calls if tool_calls else None
|
||||
|
||||
def _prepare_response_logging(self, temperature: float, stream: bool, verbose: bool, markdown: bool, **kwargs) -> Optional[Dict[str, Any]]:
|
||||
"""Prepare debug logging information for response methods"""
|
||||
if logging.getLogger().getEffectiveLevel() == logging.DEBUG:
|
||||
|
||||
@@ -539,6 +539,229 @@ class OpenAIClient:
|
||||
|
||||
return result
|
||||
|
||||
# ─── Responses API helpers ──────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _supports_responses_api(model: str) -> bool:
|
||||
"""
|
||||
Returns True for OpenAI models that support the Responses API.
|
||||
Mirrors LLM._supports_responses_api but works as a static method
|
||||
on a plain model string.
|
||||
"""
|
||||
if not model:
|
||||
return False
|
||||
model_lower = model.lower()
|
||||
|
||||
# Exclude non-OpenAI providers
|
||||
_non_openai = (
|
||||
"ollama/", "ollama_chat/", "gemini/", "vertex_ai/",
|
||||
"anthropic/", "claude", "deepseek/",
|
||||
)
|
||||
for prefix in _non_openai:
|
||||
if model_lower.startswith(prefix):
|
||||
return False
|
||||
|
||||
_openai_prefixes = (
|
||||
"gpt-4o", "gpt-4-turbo", "gpt-4.1", "gpt-4.5",
|
||||
"gpt-5",
|
||||
"o1", "o3", "o4",
|
||||
"chatgpt-4o",
|
||||
"azure/gpt-4o", "azure/gpt-4-turbo", "azure/gpt-4.1",
|
||||
"azure/gpt-5",
|
||||
"azure/o1", "azure/o3", "azure/o4",
|
||||
)
|
||||
for prefix in _openai_prefixes:
|
||||
if model_lower.startswith(prefix):
|
||||
return True
|
||||
|
||||
# openai/ prefix
|
||||
if model_lower.startswith("openai/"):
|
||||
remainder = model_lower[len("openai/"):]
|
||||
for prefix in _openai_prefixes:
|
||||
if remainder.startswith(prefix):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _use_responses_api(self, model: str) -> bool:
|
||||
"""
|
||||
Returns True if Responses API should be used for this call.
|
||||
Only returns True when:
|
||||
1. The model supports Responses API (OpenAI model)
|
||||
2. No custom base_url is set (not a local server)
|
||||
3. The openai SDK has the 'responses' attribute
|
||||
"""
|
||||
if self.base_url:
|
||||
return False
|
||||
if not self._supports_responses_api(model):
|
||||
return False
|
||||
try:
|
||||
return hasattr(self.sync_client, 'responses')
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _build_responses_input(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
model: str,
|
||||
temperature: float = 1.0,
|
||||
tools: Optional[List[Dict]] = None,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Transform Chat Completions-style messages and tools into
|
||||
Responses API parameters.
|
||||
|
||||
Mapping:
|
||||
- system message → ``instructions``
|
||||
- remaining msgs → ``input`` (list of role/content items)
|
||||
- tools are flattened from Chat Completions nested format
|
||||
"""
|
||||
params: Dict[str, Any] = {"model": model}
|
||||
|
||||
# ── Extract system instructions ──────────────────────────────────
|
||||
instructions = None
|
||||
input_items: List[Dict[str, Any]] = []
|
||||
for msg in messages:
|
||||
role = msg.get("role", "")
|
||||
if role in ("system", "developer"):
|
||||
content = msg.get("content", "")
|
||||
if instructions is None:
|
||||
instructions = content
|
||||
else:
|
||||
instructions += "\n" + content
|
||||
else:
|
||||
# Handle Chat Completions → Responses API format transforms
|
||||
if role == "assistant" and msg.get("tool_calls"):
|
||||
content = msg.get("content")
|
||||
if content and content.strip():
|
||||
input_items.append({"role": "assistant", "content": content})
|
||||
for tc in msg["tool_calls"]:
|
||||
fn = tc.get("function", tc) if isinstance(tc, dict) else tc
|
||||
fn_name = fn.get("name", "") if isinstance(fn, dict) else getattr(fn, "name", "")
|
||||
fn_args = fn.get("arguments", "{}") if isinstance(fn, dict) else getattr(fn, "arguments", "{}")
|
||||
tc_id = tc.get("id", "") if isinstance(tc, dict) else getattr(tc, "id", "")
|
||||
# Skip items with empty name — API rejects them
|
||||
if not fn_name:
|
||||
continue
|
||||
input_items.append({
|
||||
"type": "function_call",
|
||||
"call_id": tc_id,
|
||||
"name": fn_name,
|
||||
"arguments": fn_args if isinstance(fn_args, str) else json.dumps(fn_args),
|
||||
})
|
||||
elif role == "tool":
|
||||
input_items.append({
|
||||
"type": "function_call_output",
|
||||
"call_id": msg.get("tool_call_id", ""),
|
||||
"output": msg.get("content", ""),
|
||||
})
|
||||
else:
|
||||
input_items.append(msg)
|
||||
|
||||
if instructions:
|
||||
params["instructions"] = instructions
|
||||
params["input"] = input_items
|
||||
|
||||
# ── Transform tools to Responses API format ──────────────────────
|
||||
if tools:
|
||||
responses_tools = []
|
||||
for tool in tools:
|
||||
if isinstance(tool, dict) and "function" in tool:
|
||||
fn = tool["function"]
|
||||
responses_tool = {
|
||||
"type": "function",
|
||||
"name": fn.get("name", ""),
|
||||
}
|
||||
if "description" in fn:
|
||||
responses_tool["description"] = fn["description"]
|
||||
if "parameters" in fn:
|
||||
responses_tool["parameters"] = fn["parameters"]
|
||||
if fn.get("strict") is not None:
|
||||
responses_tool["strict"] = fn["strict"]
|
||||
responses_tools.append(responses_tool)
|
||||
else:
|
||||
responses_tools.append(tool)
|
||||
params["tools"] = responses_tools
|
||||
params["tool_choice"] = kwargs.pop("tool_choice", "auto")
|
||||
|
||||
# ── Scalar params ────────────────────────────────────────────────
|
||||
if temperature is not None:
|
||||
params["temperature"] = temperature
|
||||
|
||||
return params
|
||||
|
||||
def _responses_to_chat_completion(self, response) -> ChatCompletion:
|
||||
"""
|
||||
Wrap a Responses API response into a ChatCompletion dataclass
|
||||
so that all downstream code (tool loop, display, callbacks)
|
||||
works without modification.
|
||||
"""
|
||||
response_text = ""
|
||||
tool_calls_list: List[ToolCall] = []
|
||||
|
||||
output_items = getattr(response, 'output', None) or []
|
||||
for item in output_items:
|
||||
item_type = getattr(item, "type", "") if not isinstance(item, dict) else item.get("type", "")
|
||||
|
||||
if item_type == "message":
|
||||
content_list = getattr(item, "content", []) if not isinstance(item, dict) else item.get("content", [])
|
||||
for content_block in content_list:
|
||||
if isinstance(content_block, dict):
|
||||
block_type = content_block.get("type", "")
|
||||
block_text = content_block.get("text", "") or ""
|
||||
else:
|
||||
block_type = getattr(content_block, "type", "")
|
||||
block_text = getattr(content_block, "text", "") or ""
|
||||
if block_type == "output_text":
|
||||
response_text += block_text
|
||||
|
||||
elif item_type == "function_call":
|
||||
if isinstance(item, dict):
|
||||
call_id = item.get("call_id", item.get("id", f"tool_{len(tool_calls_list)}"))
|
||||
fn_name = item.get("name", "")
|
||||
fn_args = item.get("arguments", "{}")
|
||||
else:
|
||||
call_id = getattr(item, "call_id", None) or getattr(item, "id", f"tool_{len(tool_calls_list)}")
|
||||
fn_name = getattr(item, "name", "")
|
||||
fn_args = getattr(item, "arguments", "{}")
|
||||
tool_calls_list.append(ToolCall(
|
||||
id=call_id,
|
||||
type="function",
|
||||
function={
|
||||
"name": fn_name,
|
||||
"arguments": fn_args if isinstance(fn_args, str) else json.dumps(fn_args),
|
||||
}
|
||||
))
|
||||
|
||||
# Build usage
|
||||
raw_usage = getattr(response, 'usage', None)
|
||||
usage = None
|
||||
if raw_usage:
|
||||
usage = CompletionUsage(
|
||||
prompt_tokens=getattr(raw_usage, 'input_tokens', 0),
|
||||
completion_tokens=getattr(raw_usage, 'output_tokens', 0),
|
||||
total_tokens=getattr(raw_usage, 'total_tokens', 0),
|
||||
)
|
||||
|
||||
message = ChatCompletionMessage(
|
||||
content=response_text if response_text else None,
|
||||
role="assistant",
|
||||
tool_calls=tool_calls_list if tool_calls_list else None,
|
||||
)
|
||||
choice = Choice(
|
||||
finish_reason="tool_calls" if tool_calls_list else "stop",
|
||||
index=0,
|
||||
message=message,
|
||||
)
|
||||
return ChatCompletion(
|
||||
id=getattr(response, 'id', 'resp_unknown'),
|
||||
choices=[choice],
|
||||
created=int(time.time()),
|
||||
model=getattr(response, 'model', ''),
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
def _generate_tool_definition(self, func: Callable) -> Optional[Dict]:
|
||||
"""Generate a tool definition from a callable function."""
|
||||
try:
|
||||
@@ -675,6 +898,44 @@ class OpenAIClient:
|
||||
metadata={"model": model, "message_count": len(messages)}
|
||||
))
|
||||
|
||||
# ── Responses API path ───────────────────────────────────────
|
||||
if self._use_responses_api(model):
|
||||
try:
|
||||
resp_params = self._build_responses_input(
|
||||
messages, model, temperature, tools, **kwargs
|
||||
)
|
||||
raw = self.sync_client.responses.create(**resp_params)
|
||||
final_response = self._responses_to_chat_completion(raw)
|
||||
|
||||
# Display the response text if display_fn provided
|
||||
response_text = final_response.choices[0].message.content or ""
|
||||
if display_fn and response_text:
|
||||
from rich.markdown import Markdown
|
||||
console.print(Markdown(response_text))
|
||||
|
||||
# Emit stream events for compatibility
|
||||
if _emit:
|
||||
now = time.perf_counter()
|
||||
if response_text:
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.FIRST_TOKEN,
|
||||
timestamp=now, content=response_text[:50]
|
||||
))
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.LAST_TOKEN,
|
||||
timestamp=now
|
||||
))
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.STREAM_END,
|
||||
timestamp=now, metadata={"responses_api": True}
|
||||
))
|
||||
|
||||
return final_response
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Responses API streaming failed, falling back: {e}")
|
||||
# Fall through to Chat Completions streaming
|
||||
|
||||
# ── Chat Completions streaming path ─────────────────────────
|
||||
# Create the response stream
|
||||
response_stream = self.sync_client.chat.completions.create(
|
||||
model=model,
|
||||
@@ -897,6 +1158,49 @@ class OpenAIClient:
|
||||
else:
|
||||
stream_callback(event)
|
||||
|
||||
# ── Responses API path ───────────────────────────────────────
|
||||
if self._use_responses_api(model):
|
||||
try:
|
||||
resp_params = self._build_responses_input(
|
||||
messages, model, temperature, tools, **kwargs
|
||||
)
|
||||
raw = await self.async_client.responses.create(**resp_params)
|
||||
final_response = self._responses_to_chat_completion(raw)
|
||||
|
||||
# Display the response text if display_fn provided
|
||||
response_text = final_response.choices[0].message.content or ""
|
||||
if display_fn and response_text:
|
||||
from rich.markdown import Markdown
|
||||
console.print(Markdown(response_text))
|
||||
|
||||
# Emit stream events for compatibility
|
||||
if _emit:
|
||||
now = time.perf_counter()
|
||||
async def _emit_event(evt):
|
||||
if asyncio.iscoroutinefunction(stream_callback):
|
||||
await stream_callback(evt)
|
||||
else:
|
||||
stream_callback(evt)
|
||||
if response_text:
|
||||
await _emit_event(StreamEvent(
|
||||
type=StreamEventType.FIRST_TOKEN,
|
||||
timestamp=now, content=response_text[:50]
|
||||
))
|
||||
await _emit_event(StreamEvent(
|
||||
type=StreamEventType.LAST_TOKEN,
|
||||
timestamp=now
|
||||
))
|
||||
await _emit_event(StreamEvent(
|
||||
type=StreamEventType.STREAM_END,
|
||||
timestamp=now, metadata={"responses_api": True}
|
||||
))
|
||||
|
||||
return final_response
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Responses API async streaming failed, falling back: {e}")
|
||||
# Fall through to Chat Completions streaming
|
||||
|
||||
# ── Chat Completions streaming path ─────────────────────────
|
||||
# Create the response stream
|
||||
response_stream = await self.async_client.chat.completions.create(
|
||||
model=model,
|
||||
@@ -1078,6 +1382,20 @@ class OpenAIClient:
|
||||
Returns:
|
||||
ChatCompletion object or stream iterator
|
||||
"""
|
||||
# ── Responses API path (non-streaming only) ────────────────────
|
||||
if not stream and self._use_responses_api(model):
|
||||
try:
|
||||
resp_params = self._build_responses_input(
|
||||
messages, model, temperature, tools,
|
||||
tool_choice=tool_choice, **kwargs
|
||||
)
|
||||
raw = self.sync_client.responses.create(**resp_params)
|
||||
return self._responses_to_chat_completion(raw)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Responses API failed, falling back to Chat Completions: {e}")
|
||||
# Fall through to Chat Completions
|
||||
|
||||
# ── Chat Completions path ─────────────────────────────────────
|
||||
params = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
@@ -1123,6 +1441,20 @@ class OpenAIClient:
|
||||
Returns:
|
||||
ChatCompletion object or async stream iterator
|
||||
"""
|
||||
# ── Responses API path (non-streaming only) ────────────────────
|
||||
if not stream and self._use_responses_api(model):
|
||||
try:
|
||||
resp_params = self._build_responses_input(
|
||||
messages, model, temperature, tools,
|
||||
tool_choice=tool_choice, **kwargs
|
||||
)
|
||||
raw = await self.async_client.responses.create(**resp_params)
|
||||
return self._responses_to_chat_completion(raw)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Responses API failed, falling back to Chat Completions: {e}")
|
||||
# Fall through to Chat Completions
|
||||
|
||||
# ── Chat Completions path ─────────────────────────────────────
|
||||
params = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
@@ -1308,6 +1640,19 @@ class OpenAIClient:
|
||||
# Check for tool calls
|
||||
tool_calls = getattr(final_response.choices[0].message, 'tool_calls', None)
|
||||
|
||||
# Emit llm_content for intermediate narrative display
|
||||
# (gpt-4.1+ models produce text alongside tool calls)
|
||||
response_content = getattr(final_response.choices[0].message, 'content', None)
|
||||
if response_content and response_content.strip() and tool_calls:
|
||||
try:
|
||||
execute_sync_callback(
|
||||
'llm_content',
|
||||
content=response_content.strip(),
|
||||
agent_name=None,
|
||||
)
|
||||
except Exception:
|
||||
pass # Narrative display is optional
|
||||
|
||||
if tool_calls and execute_tool_fn:
|
||||
# Convert ToolCall dataclass objects to dict for JSON serialization
|
||||
serializable_tool_calls = []
|
||||
@@ -1341,8 +1686,9 @@ class OpenAIClient:
|
||||
# Always trigger callback for tool call tracking (even when verbose=False)
|
||||
display_tool_call_fn = _get_display_tool_call()
|
||||
|
||||
# Execute the tool
|
||||
tool_result = execute_tool_fn(function_name, arguments)
|
||||
# Execute the tool (pass tool_call_id for event correlation)
|
||||
_tool_call_id = tool_call.id if hasattr(tool_call, 'id') else tool_call.get('id')
|
||||
tool_result = execute_tool_fn(function_name, arguments, tool_call_id=_tool_call_id)
|
||||
results_str = json.dumps(tool_result) if tool_result else "Function returned an empty output"
|
||||
|
||||
# Trigger callback with structured parameters for status output
|
||||
@@ -1492,6 +1838,20 @@ class OpenAIClient:
|
||||
# Check for tool calls
|
||||
tool_calls = getattr(final_response.choices[0].message, 'tool_calls', None)
|
||||
|
||||
# Emit llm_content for intermediate narrative display
|
||||
# (gpt-4.1+ models produce text alongside tool calls)
|
||||
response_content = getattr(final_response.choices[0].message, 'content', None)
|
||||
if response_content and response_content.strip() and tool_calls:
|
||||
try:
|
||||
from ..main import execute_sync_callback as _esc
|
||||
_esc(
|
||||
'llm_content',
|
||||
content=response_content.strip(),
|
||||
agent_name=None,
|
||||
)
|
||||
except Exception:
|
||||
pass # Narrative display is optional
|
||||
|
||||
if tool_calls and execute_tool_fn:
|
||||
# Convert ToolCall dataclass objects to dict for JSON serialization
|
||||
serializable_tool_calls = []
|
||||
@@ -1529,15 +1889,16 @@ class OpenAIClient:
|
||||
if verbose and console:
|
||||
console.print(f"[dim]Arguments:[/dim] {arguments}")
|
||||
|
||||
# Execute the tool (async)
|
||||
# Execute the tool (async) - pass tool_call_id for event correlation
|
||||
_tool_call_id = tool_call.id if hasattr(tool_call, 'id') else tool_call.get('id')
|
||||
if asyncio.iscoroutinefunction(execute_tool_fn):
|
||||
tool_result = await execute_tool_fn(function_name, arguments)
|
||||
tool_result = await execute_tool_fn(function_name, arguments, tool_call_id=_tool_call_id)
|
||||
else:
|
||||
# Run sync function in executor
|
||||
loop = asyncio.get_event_loop()
|
||||
tool_result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: execute_tool_fn(function_name, arguments)
|
||||
lambda fn=function_name, args=arguments, tcid=_tool_call_id: execute_tool_fn(fn, args, tool_call_id=tcid)
|
||||
)
|
||||
|
||||
results_str = json.dumps(tool_result) if tool_result else "Function returned an empty output"
|
||||
@@ -1718,9 +2079,10 @@ class OpenAIClient:
|
||||
if verbose:
|
||||
yield f"\n[Calling function: {function_name}]"
|
||||
|
||||
# Execute the tool with error handling
|
||||
# Execute the tool with error handling (pass tool_call_id for event correlation)
|
||||
_tool_call_id = tool_call.id if hasattr(tool_call, 'id') else tool_call.get('id')
|
||||
try:
|
||||
tool_result = execute_tool_fn(function_name, arguments)
|
||||
tool_result = execute_tool_fn(function_name, arguments, tool_call_id=_tool_call_id)
|
||||
results_str = json.dumps(tool_result) if tool_result else "Function returned an empty output"
|
||||
except Exception as e:
|
||||
results_str = f"Error executing function: {str(e)}"
|
||||
|
||||
@@ -112,7 +112,7 @@ __all__ = [
|
||||
# - autonomy_complete: {completion_reason, iterations, duration_seconds}
|
||||
# - retry: {attempt, max_attempts, error, retry_in_seconds}
|
||||
SUPPORTED_CALLBACK_TYPES = [
|
||||
'tool_call', 'interaction', 'error', 'llm_start', 'llm_end',
|
||||
'tool_call', 'interaction', 'error', 'llm_start', 'llm_end', 'llm_content',
|
||||
'autonomy_iteration', 'autonomy_stage_change', 'autonomy_doom_loop', 'autonomy_complete',
|
||||
'retry',
|
||||
]
|
||||
|
||||
@@ -245,7 +245,7 @@ class AutoMemory:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
memory: "FileMemory",
|
||||
memory: Any,
|
||||
enabled: bool = True,
|
||||
extractor: Optional[AutoMemoryExtractor] = None,
|
||||
min_importance: float = 0.6,
|
||||
|
||||
@@ -1066,6 +1066,55 @@ class FileMemory:
|
||||
"storage_path": str(self.user_path)
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# MemoryProtocol Compliance Aliases
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def store_short_term(
|
||||
self,
|
||||
text: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
**kwargs
|
||||
) -> str:
|
||||
"""Protocol-compliant alias for add_short_term."""
|
||||
return self.add_short_term(text, metadata)
|
||||
|
||||
def store_long_term(
|
||||
self,
|
||||
text: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
**kwargs
|
||||
) -> str:
|
||||
"""Protocol-compliant alias for add_long_term."""
|
||||
return self.add_long_term(text, metadata)
|
||||
|
||||
def search_short_term(
|
||||
self,
|
||||
query: str,
|
||||
limit: int = 5,
|
||||
**kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Protocol-compliant alias for search (short_term only)."""
|
||||
return self.search(query, memory_types=["short_term"], limit=limit)
|
||||
|
||||
def search_long_term(
|
||||
self,
|
||||
query: str,
|
||||
limit: int = 5,
|
||||
**kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Protocol-compliant alias for search (long_term only)."""
|
||||
return self.search(query, memory_types=["long_term"], limit=limit)
|
||||
|
||||
def get_all_memories(self, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""Get all memories from short-term and long-term stores."""
|
||||
all_memories = []
|
||||
for item in self._short_term:
|
||||
all_memories.append({"type": "short_term", **item.to_dict()})
|
||||
for item in self._long_term:
|
||||
all_memories.append({"type": "long_term", **item.to_dict()})
|
||||
return all_memories
|
||||
|
||||
def export(self) -> Dict[str, Any]:
|
||||
"""Export all memory data."""
|
||||
return {
|
||||
|
||||
@@ -5,23 +5,30 @@ Provides learning capabilities for agents to capture patterns, preferences,
|
||||
and insights from interactions to improve future responses.
|
||||
|
||||
Usage:
|
||||
from praisonaiagents import Agent, MemoryConfig, LearnConfig
|
||||
from praisonaiagents import Agent, LearnConfig
|
||||
|
||||
# Simple enable
|
||||
agent = Agent(memory=MemoryConfig(learn=True))
|
||||
# Simple enable (top-level param)
|
||||
agent = Agent(learn=True)
|
||||
|
||||
# With specific capabilities
|
||||
agent = Agent(memory=MemoryConfig(
|
||||
learn=LearnConfig(
|
||||
persona=True,
|
||||
insights=True,
|
||||
patterns=True,
|
||||
)
|
||||
# With specific capabilities and auto-learning
|
||||
agent = Agent(learn=LearnConfig(
|
||||
persona=True,
|
||||
insights=True,
|
||||
patterns=True,
|
||||
mode="agentic", # Auto-extract learnings
|
||||
))
|
||||
|
||||
# With database backend
|
||||
agent = Agent(learn=LearnConfig(
|
||||
backend="sqlite",
|
||||
db_url="sqlite:///learn.db",
|
||||
))
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
# Manager
|
||||
"LearnManager",
|
||||
# Stores
|
||||
"PersonaStore",
|
||||
"InsightStore",
|
||||
"ThreadStore",
|
||||
@@ -29,14 +36,22 @@ __all__ = [
|
||||
"DecisionStore",
|
||||
"FeedbackStore",
|
||||
"ImprovementStore",
|
||||
# Protocols
|
||||
"LearnMode",
|
||||
"LearnProtocol",
|
||||
"AsyncLearnProtocol",
|
||||
"LearnManagerProtocol",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy loading for learn module components."""
|
||||
# Manager
|
||||
if name == "LearnManager":
|
||||
from .manager import LearnManager
|
||||
return LearnManager
|
||||
|
||||
# Stores
|
||||
if name == "PersonaStore":
|
||||
from .stores import PersonaStore
|
||||
return PersonaStore
|
||||
@@ -58,4 +73,19 @@ def __getattr__(name: str):
|
||||
if name == "ImprovementStore":
|
||||
from .stores import ImprovementStore
|
||||
return ImprovementStore
|
||||
|
||||
# Protocols
|
||||
if name == "LearnMode":
|
||||
from .protocols import LearnMode
|
||||
return LearnMode
|
||||
if name == "LearnProtocol":
|
||||
from .protocols import LearnProtocol
|
||||
return LearnProtocol
|
||||
if name == "AsyncLearnProtocol":
|
||||
from .protocols import AsyncLearnProtocol
|
||||
return AsyncLearnProtocol
|
||||
if name == "LearnManagerProtocol":
|
||||
from .protocols import LearnManagerProtocol
|
||||
return LearnManagerProtocol
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -6,8 +6,9 @@ capturing and retrieving learned information.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
import logging
|
||||
|
||||
from ...config.feature_configs import LearnConfig, LearnScope
|
||||
from ...config.feature_configs import LearnConfig, LearnScope, LearnBackend
|
||||
from .stores import (
|
||||
PersonaStore,
|
||||
InsightStore,
|
||||
@@ -43,65 +44,163 @@ class LearnManager:
|
||||
config: Optional[LearnConfig] = None,
|
||||
user_id: Optional[str] = None,
|
||||
store_path: Optional[str] = None,
|
||||
custom_stores: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
"""
|
||||
Initialize LearnManager.
|
||||
|
||||
Args:
|
||||
config: LearnConfig for enabling/disabling learning capabilities
|
||||
user_id: User identifier for scoping learnings
|
||||
store_path: Custom path for storing learning data
|
||||
custom_stores: Optional dict of custom store implementations.
|
||||
Keys are store names (e.g., "domain", "skills").
|
||||
Values must implement LearnProtocol (add, search, list_all, etc.)
|
||||
|
||||
Example:
|
||||
```python
|
||||
class MyDomainStore:
|
||||
def add(self, content, metadata=None): ...
|
||||
def search(self, query, limit=10): ...
|
||||
def list_all(self, limit=100): ...
|
||||
def get(self, entry_id): ...
|
||||
def update(self, entry_id, content, metadata=None): ...
|
||||
def delete(self, entry_id): ...
|
||||
def clear(self): ...
|
||||
|
||||
manager = LearnManager(
|
||||
config=LearnConfig(persona=True),
|
||||
custom_stores={"domain": MyDomainStore()}
|
||||
)
|
||||
```
|
||||
"""
|
||||
self.config = config or LearnConfig()
|
||||
self.user_id = user_id or "default"
|
||||
self.store_path = store_path or self.config.store_path
|
||||
|
||||
# Resolve backend from config
|
||||
self._backend = self._create_backend()
|
||||
if self._backend is not None:
|
||||
logging.info(
|
||||
f"LearnManager using {self.config.backend} backend"
|
||||
)
|
||||
|
||||
scope = self.config.scope
|
||||
if isinstance(scope, LearnScope):
|
||||
scope = scope.value
|
||||
|
||||
self._stores: Dict[str, Any] = {}
|
||||
self._custom_stores = custom_stores or {}
|
||||
|
||||
if self.config.persona:
|
||||
# Built-in stores are added first, custom stores can override them
|
||||
if self.config.persona and "persona" not in self._custom_stores:
|
||||
self._stores["persona"] = PersonaStore(
|
||||
store_path=self._get_store_path("persona"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
)
|
||||
|
||||
if self.config.insights:
|
||||
if self.config.insights and "insights" not in self._custom_stores:
|
||||
self._stores["insights"] = InsightStore(
|
||||
store_path=self._get_store_path("insights"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
)
|
||||
|
||||
if self.config.thread:
|
||||
if self.config.thread and "threads" not in self._custom_stores:
|
||||
self._stores["threads"] = ThreadStore(
|
||||
store_path=self._get_store_path("threads"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
)
|
||||
|
||||
if self.config.patterns:
|
||||
if self.config.patterns and "patterns" not in self._custom_stores:
|
||||
self._stores["patterns"] = PatternStore(
|
||||
store_path=self._get_store_path("patterns"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
)
|
||||
|
||||
if self.config.decisions:
|
||||
if self.config.decisions and "decisions" not in self._custom_stores:
|
||||
self._stores["decisions"] = DecisionStore(
|
||||
store_path=self._get_store_path("decisions"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
)
|
||||
|
||||
if self.config.feedback:
|
||||
if self.config.feedback and "feedback" not in self._custom_stores:
|
||||
self._stores["feedback"] = FeedbackStore(
|
||||
store_path=self._get_store_path("feedback"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
)
|
||||
|
||||
if self.config.improvements:
|
||||
if self.config.improvements and "improvements" not in self._custom_stores:
|
||||
self._stores["improvements"] = ImprovementStore(
|
||||
store_path=self._get_store_path("improvements"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
)
|
||||
|
||||
# Add custom stores last (they override built-in stores)
|
||||
if self._custom_stores:
|
||||
for name, store in self._custom_stores.items():
|
||||
self._stores[name] = store
|
||||
|
||||
# PROPOSE mode: pending learnings queue
|
||||
self._pending: List[LearnEntry] = []
|
||||
|
||||
def _create_backend(self) -> Any:
|
||||
"""Create a storage backend from config.
|
||||
|
||||
Returns backend instance or None for FILE (default) backend.
|
||||
Falls back to None (FILE) on error.
|
||||
"""
|
||||
backend = self.config.backend
|
||||
backend_str = backend.value if isinstance(backend, LearnBackend) else str(backend)
|
||||
|
||||
if backend_str == "file":
|
||||
return None # FILE uses BaseJSONStore default
|
||||
|
||||
if backend_str == "sqlite":
|
||||
try:
|
||||
from ...storage.backends import SQLiteBackend
|
||||
db_path = self.config.db_url or f"{self.store_path or 'learn'}/learn.db"
|
||||
# Strip sqlite:/// prefix if present
|
||||
if db_path.startswith("sqlite:///"):
|
||||
db_path = db_path[len("sqlite:///"):]
|
||||
return SQLiteBackend(db_path=db_path, table_name="learn_store")
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to create SQLite backend: {e}. Falling back to FILE.")
|
||||
return None
|
||||
|
||||
if backend_str == "redis":
|
||||
try:
|
||||
from ...storage.backends import RedisBackend
|
||||
url = self.config.db_url or "redis://localhost:6379"
|
||||
return RedisBackend(url=url, prefix="praison:learn:")
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to create Redis backend: {e}. Falling back to FILE.")
|
||||
return None
|
||||
|
||||
if backend_str == "mongodb":
|
||||
try:
|
||||
from ...storage.backends import MongoDBBackend
|
||||
url = self.config.db_url or "mongodb://localhost:27017/"
|
||||
return MongoDBBackend(url=url, collection="praison_learn")
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to create MongoDB backend: {e}. Falling back to FILE.")
|
||||
return None
|
||||
|
||||
logging.warning(f"Unknown learn backend '{backend_str}'. Falling back to FILE.")
|
||||
return None
|
||||
|
||||
def _get_store_path(self, store_name: str) -> Optional[str]:
|
||||
"""Get path for a specific store."""
|
||||
@@ -216,6 +315,88 @@ class LearnManager:
|
||||
for store_name, store in self._stores.items()
|
||||
}
|
||||
|
||||
@property
|
||||
def pending_learnings(self) -> List[LearnEntry]:
|
||||
"""Get list of pending learnings awaiting approval (PROPOSE mode)."""
|
||||
return list(self._pending)
|
||||
|
||||
def add_pending(
|
||||
self,
|
||||
content: str,
|
||||
category: str = "persona",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> LearnEntry:
|
||||
"""Add a learning to the pending queue for user approval.
|
||||
|
||||
Args:
|
||||
content: The learning content
|
||||
category: Store category (persona, insights, patterns, etc.)
|
||||
metadata: Optional metadata
|
||||
|
||||
Returns:
|
||||
The pending LearnEntry
|
||||
"""
|
||||
from datetime import datetime
|
||||
entry_id = f"pending_{category}_{len(self._pending)}_{datetime.utcnow().timestamp()}"
|
||||
entry = LearnEntry(
|
||||
id=entry_id,
|
||||
content=content,
|
||||
metadata={**(metadata or {}), "category": category, "status": "pending"},
|
||||
)
|
||||
self._pending.append(entry)
|
||||
return entry
|
||||
|
||||
def approve_learning(self, entry_id: str) -> bool:
|
||||
"""Approve a pending learning — move it to the appropriate store.
|
||||
|
||||
Args:
|
||||
entry_id: ID of the pending learning to approve
|
||||
|
||||
Returns:
|
||||
True if approved and stored, False if not found
|
||||
"""
|
||||
for i, entry in enumerate(self._pending):
|
||||
if entry.id == entry_id:
|
||||
category = entry.metadata.get("category", "persona")
|
||||
# Route to appropriate store
|
||||
if category in self._stores:
|
||||
self._stores[category].add(entry.content, entry.metadata)
|
||||
elif "persona" in self._stores:
|
||||
self._stores["persona"].add(entry.content, entry.metadata)
|
||||
self._pending.pop(i)
|
||||
return True
|
||||
return False
|
||||
|
||||
def reject_learning(self, entry_id: str) -> bool:
|
||||
"""Reject a pending learning — discard it.
|
||||
|
||||
Args:
|
||||
entry_id: ID of the pending learning to reject
|
||||
|
||||
Returns:
|
||||
True if found and rejected, False if not found
|
||||
"""
|
||||
for i, entry in enumerate(self._pending):
|
||||
if entry.id == entry_id:
|
||||
self._pending.pop(i)
|
||||
return True
|
||||
return False
|
||||
|
||||
def approve_all_learnings(self) -> int:
|
||||
"""Approve all pending learnings at once.
|
||||
|
||||
Returns:
|
||||
Number of learnings approved
|
||||
"""
|
||||
count = 0
|
||||
while self._pending:
|
||||
entry = self._pending[0]
|
||||
if self.approve_learning(entry.id):
|
||||
count += 1
|
||||
else:
|
||||
break # Safety
|
||||
return count
|
||||
|
||||
def to_system_prompt_context(self) -> str:
|
||||
"""
|
||||
Generate context suitable for injection into system prompt.
|
||||
@@ -275,3 +456,211 @@ class LearnManager:
|
||||
# Note: threads are session-specific and not included in system prompt
|
||||
|
||||
return "\n".join(parts) if parts else ""
|
||||
|
||||
def process_conversation(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
llm: Optional[str] = None,
|
||||
extract_only: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Process a conversation to extract learnings automatically.
|
||||
|
||||
Uses LLM to analyze the conversation and extract:
|
||||
- User preferences (persona)
|
||||
- Insights and observations
|
||||
- Patterns in user behavior
|
||||
|
||||
Args:
|
||||
messages: Conversation messages (list of {"role": ..., "content": ...})
|
||||
llm: Optional LLM model to use for extraction (defaults to gpt-4o-mini)
|
||||
|
||||
Returns:
|
||||
Dictionary with extracted learnings:
|
||||
{
|
||||
"persona": [...],
|
||||
"insights": [...],
|
||||
"patterns": [...],
|
||||
"stored": {"persona": N, "insights": N, "patterns": N}
|
||||
}
|
||||
"""
|
||||
if not messages:
|
||||
return {"persona": [], "insights": [], "patterns": [], "stored": {}}
|
||||
|
||||
# Build conversation text
|
||||
conversation_text = "\n".join([
|
||||
f"{msg.get('role', 'unknown')}: {msg.get('content', '')}"
|
||||
for msg in messages
|
||||
])
|
||||
|
||||
# Use LLM to extract learnings
|
||||
extraction_prompt = f"""Analyze this conversation and extract learnings.
|
||||
|
||||
CONVERSATION:
|
||||
{conversation_text}
|
||||
|
||||
Extract the following (if present):
|
||||
1. USER PREFERENCES: Things the user likes, dislikes, prefers, or their style
|
||||
2. INSIGHTS: Observations about the user's domain, work, or context
|
||||
3. PATTERNS: Recurring behaviors or request patterns
|
||||
|
||||
Return JSON:
|
||||
{{
|
||||
"persona": ["preference 1", "preference 2"],
|
||||
"insights": ["insight 1", "insight 2"],
|
||||
"patterns": ["pattern 1", "pattern 2"]
|
||||
}}
|
||||
|
||||
Only include items that are clearly evident from the conversation.
|
||||
Return empty arrays if nothing is found for a category."""
|
||||
|
||||
try:
|
||||
# Lazy import to avoid circular dependency
|
||||
from ...llm import LLM
|
||||
import json
|
||||
|
||||
model = llm or "gpt-4o-mini"
|
||||
llm_instance = LLM(model=model)
|
||||
response = llm_instance.get_response(
|
||||
prompt=extraction_prompt,
|
||||
output_json=True,
|
||||
)
|
||||
|
||||
# Parse response - handle both string and dict responses
|
||||
if isinstance(response, str):
|
||||
extracted = json.loads(response)
|
||||
elif isinstance(response, dict):
|
||||
extracted = response
|
||||
else:
|
||||
extracted = {"persona": [], "insights": [], "patterns": []}
|
||||
|
||||
# Store extracted learnings (unless extract_only)
|
||||
stored = {"persona": 0, "insights": 0, "patterns": 0}
|
||||
|
||||
if not extract_only:
|
||||
for preference in extracted.get("persona", []):
|
||||
if preference and "persona" in self._stores:
|
||||
self.capture_persona(preference)
|
||||
stored["persona"] += 1
|
||||
|
||||
for insight in extracted.get("insights", []):
|
||||
if insight and "insights" in self._stores:
|
||||
self.capture_insight(insight, source="auto_extraction")
|
||||
stored["insights"] += 1
|
||||
|
||||
for pattern in extracted.get("patterns", []):
|
||||
if pattern and "patterns" in self._stores:
|
||||
self.capture_pattern(pattern, pattern_type="auto_extracted")
|
||||
stored["patterns"] += 1
|
||||
|
||||
return {
|
||||
"persona": extracted.get("persona", []),
|
||||
"insights": extracted.get("insights", []),
|
||||
"patterns": extracted.get("patterns", []),
|
||||
"stored": stored,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.warning(f"Failed to extract learnings: {e}")
|
||||
return {"persona": [], "insights": [], "patterns": [], "stored": {}, "error": str(e)}
|
||||
|
||||
async def aprocess_conversation(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
llm: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Async version of process_conversation.
|
||||
|
||||
Args:
|
||||
messages: Conversation messages
|
||||
llm: Optional LLM model
|
||||
|
||||
Returns:
|
||||
Dictionary with extracted learnings
|
||||
"""
|
||||
if not messages:
|
||||
return {"persona": [], "insights": [], "patterns": [], "stored": {}}
|
||||
|
||||
# Build conversation text
|
||||
conversation_text = "\n".join([
|
||||
f"{msg.get('role', 'unknown')}: {msg.get('content', '')}"
|
||||
for msg in messages
|
||||
])
|
||||
|
||||
extraction_prompt = f"""Analyze this conversation and extract learnings.
|
||||
|
||||
CONVERSATION:
|
||||
{conversation_text}
|
||||
|
||||
Extract the following (if present):
|
||||
1. USER PREFERENCES: Things the user likes, dislikes, prefers, or their style
|
||||
2. INSIGHTS: Observations about the user's domain, work, or context
|
||||
3. PATTERNS: Recurring behaviors or request patterns
|
||||
|
||||
Return JSON:
|
||||
{{
|
||||
"persona": ["preference 1", "preference 2"],
|
||||
"insights": ["insight 1", "insight 2"],
|
||||
"patterns": ["pattern 1", "pattern 2"]
|
||||
}}
|
||||
|
||||
Only include items that are clearly evident from the conversation.
|
||||
Return empty arrays if nothing is found for a category."""
|
||||
|
||||
try:
|
||||
# Lazy import to avoid circular dependency
|
||||
from ...llm import LLM
|
||||
import json
|
||||
import asyncio
|
||||
|
||||
model = llm or "gpt-4o-mini"
|
||||
llm_instance = LLM(model=model)
|
||||
|
||||
# Run sync LLM call in executor for async compatibility
|
||||
loop = asyncio.get_event_loop()
|
||||
response = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: llm_instance.get_response(
|
||||
prompt=extraction_prompt,
|
||||
output_json=True,
|
||||
)
|
||||
)
|
||||
|
||||
# Parse response - handle both string and dict responses
|
||||
if isinstance(response, str):
|
||||
extracted = json.loads(response)
|
||||
elif isinstance(response, dict):
|
||||
extracted = response
|
||||
else:
|
||||
extracted = {"persona": [], "insights": [], "patterns": []}
|
||||
|
||||
stored = {"persona": 0, "insights": 0, "patterns": 0}
|
||||
|
||||
for preference in extracted.get("persona", []):
|
||||
if preference and "persona" in self._stores:
|
||||
self.capture_persona(preference)
|
||||
stored["persona"] += 1
|
||||
|
||||
for insight in extracted.get("insights", []):
|
||||
if insight and "insights" in self._stores:
|
||||
self.capture_insight(insight, source="auto_extraction")
|
||||
stored["insights"] += 1
|
||||
|
||||
for pattern in extracted.get("patterns", []):
|
||||
if pattern and "patterns" in self._stores:
|
||||
self.capture_pattern(pattern, pattern_type="auto_extracted")
|
||||
stored["patterns"] += 1
|
||||
|
||||
return {
|
||||
"persona": extracted.get("persona", []),
|
||||
"insights": extracted.get("insights", []),
|
||||
"patterns": extracted.get("patterns", []),
|
||||
"stored": stored,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.warning(f"Failed to extract learnings: {e}")
|
||||
return {"persona": [], "insights": [], "patterns": [], "stored": {}, "error": str(e)}
|
||||
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
Learn Protocol Definitions.
|
||||
|
||||
Provides Protocol interfaces for learning implementations.
|
||||
Enables custom learning backends and extensibility.
|
||||
"""
|
||||
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
|
||||
|
||||
# Re-export LearnMode from feature_configs (canonical location)
|
||||
# This avoids duplication while maintaining backward compatibility
|
||||
from ...config.feature_configs import LearnMode
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class LearnProtocol(Protocol):
|
||||
"""
|
||||
Protocol for learning store implementations.
|
||||
|
||||
Enables custom backends (file, SQLite, Redis, MongoDB, etc.)
|
||||
while maintaining a consistent interface.
|
||||
|
||||
Example:
|
||||
```python
|
||||
class RedisLearnStore:
|
||||
def add(self, content: str, metadata=None) -> Dict[str, Any]:
|
||||
# Store in Redis
|
||||
return {"id": "...", "content": content}
|
||||
|
||||
def search(self, query: str, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
# Search Redis
|
||||
return []
|
||||
|
||||
def list_all(self, limit: int = 100) -> List[Dict[str, Any]]:
|
||||
return []
|
||||
|
||||
def delete(self, entry_id: str) -> bool:
|
||||
return True
|
||||
|
||||
def clear(self) -> int:
|
||||
return 0
|
||||
```
|
||||
"""
|
||||
|
||||
def add(
|
||||
self,
|
||||
content: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Add a new entry to the store.
|
||||
|
||||
Args:
|
||||
content: The content to store
|
||||
metadata: Optional metadata dictionary
|
||||
|
||||
Returns:
|
||||
Dictionary with entry details including 'id'
|
||||
"""
|
||||
...
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
limit: int = 10,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search entries by query.
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
limit: Maximum results to return
|
||||
|
||||
Returns:
|
||||
List of matching entries
|
||||
"""
|
||||
...
|
||||
|
||||
def list_all(
|
||||
self,
|
||||
limit: int = 100,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List all entries.
|
||||
|
||||
Args:
|
||||
limit: Maximum entries to return
|
||||
|
||||
Returns:
|
||||
List of all entries (most recent first)
|
||||
"""
|
||||
...
|
||||
|
||||
def get(
|
||||
self,
|
||||
entry_id: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get entry by ID.
|
||||
|
||||
Args:
|
||||
entry_id: Entry identifier
|
||||
|
||||
Returns:
|
||||
Entry dict or None if not found
|
||||
"""
|
||||
...
|
||||
|
||||
def update(
|
||||
self,
|
||||
entry_id: str,
|
||||
content: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Update an existing entry.
|
||||
|
||||
Args:
|
||||
entry_id: Entry identifier
|
||||
content: New content
|
||||
metadata: Optional metadata to merge
|
||||
|
||||
Returns:
|
||||
Updated entry or None if not found
|
||||
"""
|
||||
...
|
||||
|
||||
def delete(
|
||||
self,
|
||||
entry_id: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Delete an entry.
|
||||
|
||||
Args:
|
||||
entry_id: Entry identifier
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
...
|
||||
|
||||
def clear(self) -> int:
|
||||
"""
|
||||
Clear all entries.
|
||||
|
||||
Returns:
|
||||
Number of entries cleared
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AsyncLearnProtocol(Protocol):
|
||||
"""
|
||||
Async Protocol for learning store implementations.
|
||||
|
||||
For backends that support async operations.
|
||||
"""
|
||||
|
||||
async def aadd(
|
||||
self,
|
||||
content: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Async add entry."""
|
||||
...
|
||||
|
||||
async def asearch(
|
||||
self,
|
||||
query: str,
|
||||
limit: int = 10,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Async search entries."""
|
||||
...
|
||||
|
||||
async def alist_all(
|
||||
self,
|
||||
limit: int = 100,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Async list all entries."""
|
||||
...
|
||||
|
||||
async def aget(
|
||||
self,
|
||||
entry_id: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Async get entry by ID."""
|
||||
...
|
||||
|
||||
async def aupdate(
|
||||
self,
|
||||
entry_id: str,
|
||||
content: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Async update entry."""
|
||||
...
|
||||
|
||||
async def adelete(
|
||||
self,
|
||||
entry_id: str,
|
||||
) -> bool:
|
||||
"""Async delete entry."""
|
||||
...
|
||||
|
||||
async def aclear(self) -> int:
|
||||
"""Async clear all entries."""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class LearnManagerProtocol(Protocol):
|
||||
"""
|
||||
Protocol for LearnManager implementations.
|
||||
|
||||
Defines the interface for managing learning across multiple stores.
|
||||
"""
|
||||
|
||||
def capture_persona(self, content: str, category: str = "general") -> Optional[Dict[str, Any]]:
|
||||
"""Capture user preference or profile information."""
|
||||
...
|
||||
|
||||
def capture_insight(self, content: str, source: str = "interaction") -> Optional[Dict[str, Any]]:
|
||||
"""Capture an observation or learning."""
|
||||
...
|
||||
|
||||
def capture_pattern(self, pattern: str, pattern_type: str = "general") -> Optional[Dict[str, Any]]:
|
||||
"""Capture a reusable knowledge pattern."""
|
||||
...
|
||||
|
||||
def get_learning_context(self, limit_per_store: int = 5) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Get learning context from all enabled stores."""
|
||||
...
|
||||
|
||||
def search(self, query: str, limit: int = 10) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Search across all enabled stores."""
|
||||
...
|
||||
|
||||
def to_system_prompt_context(self) -> str:
|
||||
"""Generate context for system prompt injection."""
|
||||
...
|
||||
|
||||
def process_conversation(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
llm: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Process a conversation to extract learnings automatically.
|
||||
|
||||
Args:
|
||||
messages: Conversation messages
|
||||
llm: Optional LLM model to use for extraction
|
||||
|
||||
Returns:
|
||||
Dictionary with extracted learnings
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
__all__ = [
|
||||
'LearnMode',
|
||||
'LearnProtocol',
|
||||
'AsyncLearnProtocol',
|
||||
'LearnManagerProtocol',
|
||||
]
|
||||
@@ -73,12 +73,23 @@ class BaseStore(ABC):
|
||||
self._backend = backend
|
||||
self.store_path = store_path or self._default_path()
|
||||
self._entries: Dict[str, LearnEntry] = {}
|
||||
self._was_updated: bool = False
|
||||
|
||||
# DRY: Use BaseJSONStore for thread-safe storage
|
||||
self._store = BaseJSONStore(
|
||||
storage_path=self.store_path,
|
||||
backend=backend,
|
||||
)
|
||||
# Fall back to FILE backend if connection fails (e.g., Redis unavailable)
|
||||
try:
|
||||
self._store = BaseJSONStore(
|
||||
storage_path=self.store_path,
|
||||
backend=backend,
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.warning(f"Failed to connect to backend: {e}. Falling back to FILE.")
|
||||
self._backend = None
|
||||
self._store = BaseJSONStore(
|
||||
storage_path=self.store_path,
|
||||
backend=None,
|
||||
)
|
||||
self._load()
|
||||
|
||||
@property
|
||||
@@ -87,6 +98,15 @@ class BaseStore(ABC):
|
||||
"""Name of the store (used for file naming)."""
|
||||
pass
|
||||
|
||||
@property
|
||||
def was_updated(self) -> bool:
|
||||
"""Whether this store has been modified since last reset."""
|
||||
return self._was_updated
|
||||
|
||||
def reset_updated(self) -> None:
|
||||
"""Reset the was_updated flag."""
|
||||
self._was_updated = False
|
||||
|
||||
def _default_path(self) -> str:
|
||||
"""Default storage path (uses centralized paths - DRY)."""
|
||||
from ...paths import get_learn_dir, ensure_dir
|
||||
@@ -106,7 +126,24 @@ class BaseStore(ABC):
|
||||
self._store.save({k: v.to_dict() for k, v in self._entries.items()})
|
||||
|
||||
def add(self, content: str, metadata: Optional[Dict[str, Any]] = None) -> LearnEntry:
|
||||
"""Add a new entry."""
|
||||
"""Add a new entry with deduplication.
|
||||
|
||||
Checks for exact content matches to prevent duplicate learnings.
|
||||
If a duplicate is found, returns the existing entry instead of creating a new one.
|
||||
"""
|
||||
# Deduplication: Check for exact content match
|
||||
content_normalized = content.strip().lower()
|
||||
for existing_entry in self._entries.values():
|
||||
if existing_entry.content.strip().lower() == content_normalized:
|
||||
# Update metadata if provided, but don't create duplicate
|
||||
if metadata:
|
||||
existing_entry.metadata.update(metadata)
|
||||
existing_entry.updated_at = datetime.utcnow().isoformat()
|
||||
self._save()
|
||||
self._was_updated = True
|
||||
return existing_entry
|
||||
|
||||
# No duplicate found, create new entry
|
||||
entry_id = f"{self.store_name}_{len(self._entries)}_{datetime.utcnow().timestamp()}"
|
||||
entry = LearnEntry(
|
||||
id=entry_id,
|
||||
@@ -115,6 +152,7 @@ class BaseStore(ABC):
|
||||
)
|
||||
self._entries[entry_id] = entry
|
||||
self._save()
|
||||
self._was_updated = True
|
||||
return entry
|
||||
|
||||
def get(self, entry_id: str) -> Optional[LearnEntry]:
|
||||
@@ -146,6 +184,7 @@ class BaseStore(ABC):
|
||||
entry.metadata.update(metadata)
|
||||
entry.updated_at = datetime.utcnow().isoformat()
|
||||
self._save()
|
||||
self._was_updated = True
|
||||
return entry
|
||||
|
||||
def delete(self, entry_id: str) -> bool:
|
||||
@@ -153,6 +192,7 @@ class BaseStore(ABC):
|
||||
if entry_id in self._entries:
|
||||
del self._entries[entry_id]
|
||||
self._save()
|
||||
self._was_updated = True
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -161,6 +201,7 @@ class BaseStore(ABC):
|
||||
count = len(self._entries)
|
||||
self._entries = {}
|
||||
self._save()
|
||||
self._was_updated = True
|
||||
return count
|
||||
|
||||
|
||||
|
||||
@@ -40,6 +40,9 @@ class MemoryProtocol(Protocol):
|
||||
|
||||
def search_long_term(self, query: str, limit: int = 5, **kwargs):
|
||||
return self._data[:limit]
|
||||
|
||||
def get_all_memories(self, **kwargs):
|
||||
return self._data
|
||||
|
||||
# Use in agent
|
||||
memory: MemoryProtocol = InMemoryStore()
|
||||
@@ -122,6 +125,15 @@ class MemoryProtocol(Protocol):
|
||||
"""
|
||||
...
|
||||
|
||||
def get_all_memories(self, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all memories from all backends.
|
||||
|
||||
Returns:
|
||||
List of all memory entries (short-term and long-term combined)
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ResettableMemoryProtocol(MemoryProtocol, Protocol):
|
||||
|
||||
@@ -361,6 +361,16 @@ def get_project_prp_dir(project_path: Optional[Union[str, Path]] = None) -> Path
|
||||
return get_project_data_dir(project_path) / "prp"
|
||||
|
||||
|
||||
def get_config_path() -> Path:
|
||||
"""
|
||||
Get path to the main config.yaml file.
|
||||
|
||||
Returns:
|
||||
Path to ~/.praisonai/config.yaml
|
||||
"""
|
||||
return get_data_dir() / "config.yaml"
|
||||
|
||||
|
||||
def get_schedules_dir() -> Path:
|
||||
"""
|
||||
Get schedules directory.
|
||||
|
||||
@@ -14,16 +14,19 @@ Usage:
|
||||
)
|
||||
agent.start("Remind me to check email every morning at 7am")
|
||||
|
||||
Default storage: ~/.praisonai/schedules/jobs.json
|
||||
Default storage: ~/.praisonai/config.yaml (under ``schedules`` key)
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .models import Schedule, ScheduleJob
|
||||
from .models import Schedule, ScheduleJob, DeliveryTarget, RunRecord
|
||||
from .store import FileScheduleStore
|
||||
from .config_store import ConfigYamlScheduleStore
|
||||
from .parser import parse_schedule
|
||||
from .runner import ScheduleRunner
|
||||
from .loop import ScheduleLoop
|
||||
from .protocols import ScheduleStoreProtocol
|
||||
|
||||
_module_cache = {}
|
||||
|
||||
@@ -33,10 +36,12 @@ def __getattr__(name: str):
|
||||
if name in _module_cache:
|
||||
return _module_cache[name]
|
||||
|
||||
if name in ("Schedule", "ScheduleJob"):
|
||||
from .models import Schedule, ScheduleJob
|
||||
if name in ("Schedule", "ScheduleJob", "DeliveryTarget", "RunRecord"):
|
||||
from .models import Schedule, ScheduleJob, DeliveryTarget, RunRecord
|
||||
_module_cache["Schedule"] = Schedule
|
||||
_module_cache["ScheduleJob"] = ScheduleJob
|
||||
_module_cache["DeliveryTarget"] = DeliveryTarget
|
||||
_module_cache["RunRecord"] = RunRecord
|
||||
return _module_cache[name]
|
||||
|
||||
if name == "FileScheduleStore":
|
||||
@@ -54,13 +59,33 @@ def __getattr__(name: str):
|
||||
_module_cache[name] = ScheduleRunner
|
||||
return ScheduleRunner
|
||||
|
||||
if name == "ScheduleLoop":
|
||||
from .loop import ScheduleLoop
|
||||
_module_cache[name] = ScheduleLoop
|
||||
return ScheduleLoop
|
||||
|
||||
if name == "ConfigYamlScheduleStore":
|
||||
from .config_store import ConfigYamlScheduleStore
|
||||
_module_cache[name] = ConfigYamlScheduleStore
|
||||
return ConfigYamlScheduleStore
|
||||
|
||||
if name == "ScheduleStoreProtocol":
|
||||
from .protocols import ScheduleStoreProtocol
|
||||
_module_cache[name] = ScheduleStoreProtocol
|
||||
return ScheduleStoreProtocol
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Schedule",
|
||||
"ScheduleJob",
|
||||
"DeliveryTarget",
|
||||
"RunRecord",
|
||||
"FileScheduleStore",
|
||||
"ConfigYamlScheduleStore",
|
||||
"parse_schedule",
|
||||
"ScheduleRunner",
|
||||
"ScheduleLoop",
|
||||
"ScheduleStoreProtocol",
|
||||
]
|
||||
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
Config YAML-based schedule store for PraisonAI Agents.
|
||||
|
||||
Persists scheduled jobs to ``~/.praisonai/config.yaml`` under
|
||||
the ``schedules`` top-level key. Thread-safe, preserves all
|
||||
other config.yaml content.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from .models import ScheduleJob, RunRecord
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_HISTORY_FILE = "run_history.yaml"
|
||||
_MAX_HISTORY = 200
|
||||
|
||||
|
||||
class ConfigYamlScheduleStore:
|
||||
"""CRUD store backed by ``config.yaml``.
|
||||
|
||||
Schedules are stored as a dict under the ``schedules`` key,
|
||||
alongside existing keys like ``agents``, ``server``, etc.
|
||||
|
||||
Thread-safe for multi-agent scenarios.
|
||||
"""
|
||||
|
||||
def __init__(self, config_path: Optional[str] = None, max_history: int = _MAX_HISTORY):
|
||||
if config_path is None:
|
||||
from ..paths import get_data_dir
|
||||
config_path = str(get_data_dir() / "config.yaml")
|
||||
self._path = config_path
|
||||
self._history_path = os.path.join(os.path.dirname(config_path), _HISTORY_FILE)
|
||||
self._max_history = max_history
|
||||
self._lock = threading.RLock()
|
||||
self._jobs: Dict[str, ScheduleJob] = {}
|
||||
self._history: List[RunRecord] = []
|
||||
self._load()
|
||||
self._load_history()
|
||||
|
||||
# ── public API ───────────────────────────────────────────────────
|
||||
|
||||
def add(self, job: ScheduleJob) -> None:
|
||||
"""Add a job. Raises ``ValueError`` if id already exists."""
|
||||
with self._lock:
|
||||
if job.id in self._jobs:
|
||||
raise ValueError(f"Job '{job.id}' already exists")
|
||||
self._jobs[job.id] = job
|
||||
self._save()
|
||||
|
||||
def get(self, job_id: str) -> Optional[ScheduleJob]:
|
||||
with self._lock:
|
||||
return self._jobs.get(job_id)
|
||||
|
||||
def get_by_name(self, name: str) -> Optional[ScheduleJob]:
|
||||
with self._lock:
|
||||
for job in self._jobs.values():
|
||||
if job.name == name:
|
||||
return job
|
||||
return None
|
||||
|
||||
def list(self, agent_id: Optional[str] = None) -> List[ScheduleJob]:
|
||||
with self._lock:
|
||||
jobs = list(self._jobs.values())
|
||||
if agent_id is not None:
|
||||
jobs = [j for j in jobs if j.agent_id == agent_id]
|
||||
return jobs
|
||||
|
||||
def update(self, job: ScheduleJob) -> None:
|
||||
with self._lock:
|
||||
self._jobs[job.id] = job
|
||||
self._save()
|
||||
|
||||
def remove(self, job_id: str) -> bool:
|
||||
with self._lock:
|
||||
if job_id in self._jobs:
|
||||
del self._jobs[job_id]
|
||||
self._save()
|
||||
return True
|
||||
return False
|
||||
|
||||
def remove_by_name(self, name: str) -> bool:
|
||||
with self._lock:
|
||||
for jid, job in list(self._jobs.items()):
|
||||
if job.name == name:
|
||||
del self._jobs[jid]
|
||||
self._save()
|
||||
return True
|
||||
return False
|
||||
|
||||
# ── execution history ─────────────────────────────────────────────
|
||||
|
||||
def log_run(
|
||||
self,
|
||||
job_id: str,
|
||||
status: str,
|
||||
result: Optional[str] = None,
|
||||
error: Optional[str] = None,
|
||||
duration: float = 0.0,
|
||||
delivered: bool = False,
|
||||
job_name: str = "",
|
||||
) -> None:
|
||||
"""Log an execution run for a job."""
|
||||
record = RunRecord(
|
||||
job_id=job_id,
|
||||
job_name=job_name,
|
||||
status=status,
|
||||
result=result[:2000] if result and len(result) > 2000 else result,
|
||||
error=error,
|
||||
duration=duration,
|
||||
delivered=delivered,
|
||||
timestamp=time.time(),
|
||||
)
|
||||
with self._lock:
|
||||
self._history.insert(0, record)
|
||||
# Prune to max_history
|
||||
if len(self._history) > self._max_history:
|
||||
self._history = self._history[:self._max_history]
|
||||
self._save_history()
|
||||
|
||||
def get_history(
|
||||
self,
|
||||
job_id: Optional[str] = None,
|
||||
limit: int = 200,
|
||||
) -> List[RunRecord]:
|
||||
"""Get execution history records, newest first."""
|
||||
with self._lock:
|
||||
records = list(self._history)
|
||||
if job_id is not None:
|
||||
records = [r for r in records if r.job_id == job_id]
|
||||
return records[:limit]
|
||||
|
||||
# ── persistence ──────────────────────────────────────────────────
|
||||
|
||||
def _load(self) -> None:
|
||||
"""Load schedule jobs from config.yaml's ``schedules`` section."""
|
||||
if not os.path.exists(self._path):
|
||||
return
|
||||
try:
|
||||
import yaml
|
||||
with open(self._path, "r") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
schedules = data.get("schedules", {})
|
||||
if isinstance(schedules, dict):
|
||||
for job_id, job_data in schedules.items():
|
||||
if isinstance(job_data, dict) and job_data.get("name"):
|
||||
job_data.setdefault("id", job_id)
|
||||
job = ScheduleJob.from_dict(job_data)
|
||||
self._jobs[job.id] = job
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load schedules from %s: %s", self._path, e)
|
||||
|
||||
def _save(self) -> None:
|
||||
"""Write schedule jobs into config.yaml's ``schedules`` key.
|
||||
|
||||
Preserves all other top-level keys (agents, server, etc.).
|
||||
"""
|
||||
try:
|
||||
import yaml
|
||||
|
||||
# Read existing config
|
||||
data: dict = {}
|
||||
if os.path.exists(self._path):
|
||||
with open(self._path, "r") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
# Update only the schedules section
|
||||
schedules = {}
|
||||
for job in self._jobs.values():
|
||||
schedules[job.id] = job.to_dict()
|
||||
data["schedules"] = schedules
|
||||
|
||||
# Atomic write
|
||||
os.makedirs(os.path.dirname(self._path), exist_ok=True)
|
||||
tmp = self._path + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
os.replace(tmp, self._path)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to save schedules to %s: %s", self._path, e)
|
||||
|
||||
def _load_history(self) -> None:
|
||||
"""Load execution history from run_history.yaml."""
|
||||
if not os.path.exists(self._history_path):
|
||||
return
|
||||
try:
|
||||
import yaml
|
||||
with open(self._history_path, "r") as f:
|
||||
data = yaml.safe_load(f) or []
|
||||
if isinstance(data, list):
|
||||
for d in data:
|
||||
if isinstance(d, dict):
|
||||
record = RunRecord.from_dict(d)
|
||||
self._history.append(record)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load history from %s: %s", self._history_path, e)
|
||||
|
||||
def _save_history(self) -> None:
|
||||
"""Save execution history to run_history.yaml."""
|
||||
try:
|
||||
import yaml
|
||||
os.makedirs(os.path.dirname(self._history_path), exist_ok=True)
|
||||
data = [r.to_dict() for r in self._history]
|
||||
tmp = self._history_path + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
os.replace(tmp, self._history_path)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to save history to %s: %s", self._history_path, e)
|
||||
|
||||
# ── migration ────────────────────────────────────────────────────
|
||||
|
||||
def migrate_from_json(self, json_path: Optional[str] = None) -> int:
|
||||
"""Import jobs from a legacy ``jobs.json`` file.
|
||||
|
||||
Args:
|
||||
json_path: Path to ``jobs.json``. Defaults to
|
||||
``~/.praisonai/schedules/jobs.json``.
|
||||
|
||||
Returns:
|
||||
Number of jobs migrated.
|
||||
"""
|
||||
import json
|
||||
|
||||
if json_path is None:
|
||||
from ..paths import get_schedules_dir
|
||||
json_path = str(get_schedules_dir() / "jobs.json")
|
||||
|
||||
if not os.path.exists(json_path):
|
||||
return 0
|
||||
|
||||
try:
|
||||
with open(json_path, "r") as f:
|
||||
items = json.load(f)
|
||||
if not isinstance(items, list):
|
||||
return 0
|
||||
count = 0
|
||||
with self._lock:
|
||||
for d in items:
|
||||
job = ScheduleJob.from_dict(d)
|
||||
if job.id not in self._jobs:
|
||||
self._jobs[job.id] = job
|
||||
count += 1
|
||||
if count:
|
||||
self._save()
|
||||
logger.info(
|
||||
"Migrated %d schedule(s) from %s into config.yaml",
|
||||
count, json_path,
|
||||
)
|
||||
return count
|
||||
except Exception as e:
|
||||
logger.warning("Failed to migrate from %s: %s", json_path, e)
|
||||
return 0
|
||||
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
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 .protocols import ScheduleStoreProtocol
|
||||
|
||||
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[ScheduleStoreProtocol] = 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).
|
||||
"""
|
||||
if store is None:
|
||||
from .config_store import ConfigYamlScheduleStore
|
||||
store = ConfigYamlScheduleStore()
|
||||
self._on_trigger = on_trigger
|
||||
self._store = store
|
||||
self._runner = ScheduleRunner(self._store)
|
||||
self._tick = tick_seconds
|
||||
self._stop_event = threading.Event()
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
|
||||
# ── Public API ──────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""Whether the loop thread is alive."""
|
||||
return self._thread is not None and self._thread.is_alive()
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the tick loop on a daemon thread.
|
||||
|
||||
Calling ``start()`` when already running is a no-op.
|
||||
"""
|
||||
if self.is_running:
|
||||
return
|
||||
|
||||
self._stop_event.clear()
|
||||
self._thread = threading.Thread(
|
||||
target=self._run, name="schedule-loop", daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
logger.info("ScheduleLoop started (tick=%ss)", self._tick)
|
||||
|
||||
def stop(self, timeout: float = 5.0) -> None:
|
||||
"""Signal the loop to stop and wait for the thread to exit.
|
||||
|
||||
Args:
|
||||
timeout: Max seconds to wait for the thread to join.
|
||||
"""
|
||||
self._stop_event.set()
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=timeout)
|
||||
self._thread = None
|
||||
logger.info("ScheduleLoop stopped")
|
||||
|
||||
# ── Internal ────────────────────────────────────────────────────────
|
||||
|
||||
def _run(self) -> None:
|
||||
"""Loop body executed on the daemon thread."""
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
due = self._runner.get_due_jobs()
|
||||
for job in due:
|
||||
try:
|
||||
self._on_trigger(job)
|
||||
# Update last_run_at so the job won't re-fire
|
||||
# until the next interval elapses.
|
||||
job.last_run_at = time.time()
|
||||
self._store.update(job)
|
||||
self._emit_hook(job)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Error triggering schedule job %s (%s)",
|
||||
job.name, job.id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("ScheduleLoop tick error")
|
||||
|
||||
# Sleep in small increments so stop() is responsive.
|
||||
self._stop_event.wait(timeout=self._tick)
|
||||
|
||||
def _emit_hook(self, job) -> None:
|
||||
"""Best-effort emit of SCHEDULE_TRIGGER notification.
|
||||
|
||||
Logs the trigger event. Hook-aware callers (e.g. bots) can
|
||||
wire a proper ``HookRunner`` externally via the ``on_trigger``
|
||||
callback.
|
||||
"""
|
||||
logger.debug(
|
||||
"SCHEDULE_TRIGGER: job=%s id=%s", job.name, job.id
|
||||
)
|
||||
@@ -51,6 +51,95 @@ class Schedule:
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeliveryTarget:
|
||||
"""Where to deliver the result when a scheduled job fires.
|
||||
|
||||
Attributes:
|
||||
channel: Platform name (``"telegram"``, ``"discord"``,
|
||||
``"slack"``, ``"whatsapp"``).
|
||||
channel_id: Platform-specific chat / channel / group ID.
|
||||
thread_id: Optional thread ID for threaded delivery.
|
||||
session_id: Optional session ID to preserve conversation
|
||||
context when the cron fires.
|
||||
"""
|
||||
|
||||
channel: str = ""
|
||||
channel_id: str = ""
|
||||
thread_id: Optional[str] = None
|
||||
session_id: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
d: Dict[str, Any] = {
|
||||
"channel": self.channel,
|
||||
"channel_id": self.channel_id,
|
||||
}
|
||||
if self.thread_id is not None:
|
||||
d["thread_id"] = self.thread_id
|
||||
if self.session_id is not None:
|
||||
d["session_id"] = self.session_id
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Dict[str, Any]) -> "DeliveryTarget":
|
||||
return cls(
|
||||
channel=d.get("channel", ""),
|
||||
channel_id=d.get("channel_id", ""),
|
||||
thread_id=d.get("thread_id"),
|
||||
session_id=d.get("session_id"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunRecord:
|
||||
"""A single execution record for a scheduled job.
|
||||
|
||||
Attributes:
|
||||
job_id: ID of the job that was executed.
|
||||
job_name: Human-readable name of the job.
|
||||
status: Execution status (``"succeeded"``, ``"failed"``, ``"skipped"``).
|
||||
result: Agent response text (truncated if very long).
|
||||
error: Error message if status is ``"failed"``.
|
||||
duration: Wall-clock seconds for execution.
|
||||
delivered: Whether result was delivered to a channel bot.
|
||||
timestamp: Epoch timestamp of execution.
|
||||
"""
|
||||
|
||||
job_id: str
|
||||
job_name: str = ""
|
||||
status: Literal["succeeded", "failed", "skipped"] = "succeeded"
|
||||
result: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
duration: float = 0.0
|
||||
delivered: bool = False
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"job_id": self.job_id,
|
||||
"job_name": self.job_name,
|
||||
"status": self.status,
|
||||
"result": self.result,
|
||||
"error": self.error,
|
||||
"duration": self.duration,
|
||||
"delivered": self.delivered,
|
||||
"timestamp": self.timestamp,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Dict[str, Any]) -> "RunRecord":
|
||||
return cls(
|
||||
job_id=d.get("job_id", ""),
|
||||
job_name=d.get("job_name", ""),
|
||||
status=d.get("status", "succeeded"),
|
||||
result=d.get("result"),
|
||||
error=d.get("error"),
|
||||
duration=d.get("duration", 0.0),
|
||||
delivered=d.get("delivered", False),
|
||||
timestamp=d.get("timestamp", time.time()),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScheduleJob:
|
||||
"""A persisted scheduled job.
|
||||
@@ -67,6 +156,8 @@ class ScheduleJob:
|
||||
delete_after_run: Auto-remove after first execution (one-shot).
|
||||
created_at: Epoch timestamp.
|
||||
last_run_at: Epoch timestamp of most recent execution.
|
||||
delivery: Optional delivery target for routing results back
|
||||
to a channel bot (e.g. Telegram chat).
|
||||
"""
|
||||
|
||||
name: str = ""
|
||||
@@ -79,11 +170,12 @@ class ScheduleJob:
|
||||
delete_after_run: bool = False
|
||||
created_at: float = field(default_factory=time.time)
|
||||
last_run_at: Optional[float] = None
|
||||
delivery: Optional[DeliveryTarget] = None
|
||||
|
||||
# ── serialisation ────────────────────────────────────────────────
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
d: Dict[str, Any] = {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"schedule": self.schedule.to_dict(),
|
||||
@@ -95,10 +187,14 @@ class ScheduleJob:
|
||||
"created_at": self.created_at,
|
||||
"last_run_at": self.last_run_at,
|
||||
}
|
||||
if self.delivery is not None:
|
||||
d["delivery"] = self.delivery.to_dict()
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Dict[str, Any]) -> "ScheduleJob":
|
||||
sched_data = d.get("schedule", {})
|
||||
delivery_data = d.get("delivery")
|
||||
return cls(
|
||||
id=d.get("id", uuid.uuid4().hex[:12]),
|
||||
name=d.get("name", ""),
|
||||
@@ -110,4 +206,5 @@ class ScheduleJob:
|
||||
delete_after_run=d.get("delete_after_run", False),
|
||||
created_at=d.get("created_at", time.time()),
|
||||
last_run_at=d.get("last_run_at"),
|
||||
delivery=DeliveryTarget.from_dict(delivery_data) if isinstance(delivery_data, dict) else None,
|
||||
)
|
||||
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
Schedule Store Protocol for PraisonAI Agents.
|
||||
|
||||
Defines the interface for schedule persistence backends.
|
||||
Any object implementing these methods can be used as a schedule store.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, List, Optional, Protocol, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ScheduleStoreProtocol(Protocol):
|
||||
"""Protocol for schedule persistence backends.
|
||||
|
||||
Implementations:
|
||||
- ``ConfigYamlScheduleStore`` — YAML config (``~/.praisonai/config.yaml``) [default]
|
||||
- ``FileScheduleStore`` — JSON file (``~/.praisonai/schedules/jobs.json``) [legacy]
|
||||
- ``_InMemoryScheduleStore`` (in PraisonAIUI) — YAML config.yaml
|
||||
|
||||
Any store that implements these methods can be passed to
|
||||
``ScheduleRunner`` and ``ScheduleLoop``.
|
||||
"""
|
||||
|
||||
def add(self, job: Any) -> None:
|
||||
"""Add a job. Raises ``ValueError`` if id already exists."""
|
||||
...
|
||||
|
||||
def get(self, job_id: str) -> Optional[Any]:
|
||||
"""Get a job by its unique ID."""
|
||||
...
|
||||
|
||||
def list(self, agent_id: Optional[str] = None) -> List[Any]:
|
||||
"""List all jobs, optionally filtered by agent_id."""
|
||||
...
|
||||
|
||||
def update(self, job: Any) -> None:
|
||||
"""Update an existing job."""
|
||||
...
|
||||
|
||||
def remove(self, job_id: str) -> bool:
|
||||
"""Remove a job by ID. Returns True if found and removed."""
|
||||
...
|
||||
|
||||
def get_by_name(self, name: str) -> Optional[Any]:
|
||||
"""Get a job by its human-readable name."""
|
||||
...
|
||||
|
||||
def remove_by_name(self, name: str) -> bool:
|
||||
"""Remove a job by name. Returns True if found and removed."""
|
||||
...
|
||||
|
||||
# ── Execution History (optional) ─────────────────────────────────
|
||||
|
||||
def log_run(
|
||||
self,
|
||||
job_id: str,
|
||||
status: str,
|
||||
result: Optional[str] = None,
|
||||
error: Optional[str] = None,
|
||||
duration: float = 0.0,
|
||||
delivered: bool = False,
|
||||
job_name: str = "",
|
||||
) -> None:
|
||||
"""Log an execution run for a job.
|
||||
|
||||
Args:
|
||||
job_id: ID of the executed job.
|
||||
status: Execution status (``"succeeded"``, ``"failed"``, ``"skipped"``).
|
||||
result: Agent response text (may be truncated).
|
||||
error: Error message if status is ``"failed"``.
|
||||
duration: Wall-clock seconds for execution.
|
||||
delivered: Whether result was delivered to a channel bot.
|
||||
job_name: Human-readable job name for display.
|
||||
|
||||
Note:
|
||||
This method is optional. Stores that don't support history
|
||||
may implement it as a no-op. Callers should use ``hasattr()``
|
||||
to check for support before calling.
|
||||
"""
|
||||
...
|
||||
|
||||
def get_history(
|
||||
self,
|
||||
job_id: Optional[str] = None,
|
||||
limit: int = 200,
|
||||
) -> List[Any]:
|
||||
"""Get execution history records.
|
||||
|
||||
Args:
|
||||
job_id: Optional filter by job ID.
|
||||
limit: Maximum number of records to return (default 200).
|
||||
|
||||
Returns:
|
||||
List of ``RunRecord`` instances, newest first.
|
||||
|
||||
Note:
|
||||
This method is optional. Stores that don't support history
|
||||
may return an empty list. Callers should use ``hasattr()``
|
||||
to check for support before calling.
|
||||
"""
|
||||
...
|
||||
@@ -8,10 +8,10 @@ payload is the caller's responsibility (keeping the runner lightweight).
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
|
||||
from .models import ScheduleJob
|
||||
from .store import FileScheduleStore
|
||||
from .protocols import ScheduleStoreProtocol
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -23,7 +23,7 @@ class ScheduleRunner:
|
||||
and when to call ``get_due_jobs()``.
|
||||
"""
|
||||
|
||||
def __init__(self, store: FileScheduleStore):
|
||||
def __init__(self, store: ScheduleStoreProtocol):
|
||||
self._store = store
|
||||
|
||||
# ── public ───────────────────────────────────────────────────────
|
||||
@@ -39,9 +39,39 @@ class ScheduleRunner:
|
||||
due.append(job)
|
||||
return due
|
||||
|
||||
def mark_run(self, job: ScheduleJob) -> None:
|
||||
"""Update ``last_run_at`` and optionally delete one-shot jobs."""
|
||||
def mark_run(
|
||||
self,
|
||||
job: ScheduleJob,
|
||||
status: str = "succeeded",
|
||||
result: Optional[str] = None,
|
||||
error: Optional[str] = None,
|
||||
duration: float = 0.0,
|
||||
delivered: bool = False,
|
||||
) -> None:
|
||||
"""Update ``last_run_at``, log history, and optionally delete one-shot jobs.
|
||||
|
||||
Args:
|
||||
job: The job that was executed.
|
||||
status: Execution status (``"succeeded"``, ``"failed"``, ``"skipped"``).
|
||||
result: Agent response text.
|
||||
error: Error message if status is ``"failed"``.
|
||||
duration: Wall-clock seconds for execution.
|
||||
delivered: Whether result was delivered to a channel bot.
|
||||
"""
|
||||
job.last_run_at = time.time()
|
||||
|
||||
# Log execution history if the store supports it
|
||||
if hasattr(self._store, "log_run"):
|
||||
self._store.log_run(
|
||||
job_id=job.id,
|
||||
status=status,
|
||||
result=result,
|
||||
error=error,
|
||||
duration=duration,
|
||||
delivered=delivered,
|
||||
job_name=job.name,
|
||||
)
|
||||
|
||||
if job.delete_after_run:
|
||||
self._store.remove(job.id)
|
||||
else:
|
||||
|
||||
@@ -9,15 +9,18 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from .models import ScheduleJob
|
||||
from .models import ScheduleJob, RunRecord
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from ..paths import get_schedules_dir as _get_schedules_dir
|
||||
_DEFAULT_DIR = str(_get_schedules_dir())
|
||||
_JOBS_FILE = "jobs.json"
|
||||
_HISTORY_FILE = "history.json"
|
||||
_MAX_HISTORY = 200
|
||||
|
||||
|
||||
class FileScheduleStore:
|
||||
@@ -26,12 +29,16 @@ class FileScheduleStore:
|
||||
Thread-safe for multi-agent scenarios.
|
||||
"""
|
||||
|
||||
def __init__(self, store_dir: Optional[str] = None):
|
||||
def __init__(self, store_dir: Optional[str] = None, max_history: int = _MAX_HISTORY):
|
||||
self._dir = store_dir or _DEFAULT_DIR
|
||||
self._path = os.path.join(self._dir, _JOBS_FILE)
|
||||
self._history_path = os.path.join(self._dir, _HISTORY_FILE)
|
||||
self._max_history = max_history
|
||||
self._lock = threading.RLock()
|
||||
self._jobs: Dict[str, ScheduleJob] = {}
|
||||
self._history: List[RunRecord] = []
|
||||
self._load()
|
||||
self._load_history()
|
||||
|
||||
# ── public API ───────────────────────────────────────────────────
|
||||
|
||||
@@ -83,6 +90,48 @@ class FileScheduleStore:
|
||||
return True
|
||||
return False
|
||||
|
||||
# ── execution history ─────────────────────────────────────────────
|
||||
|
||||
def log_run(
|
||||
self,
|
||||
job_id: str,
|
||||
status: str,
|
||||
result: Optional[str] = None,
|
||||
error: Optional[str] = None,
|
||||
duration: float = 0.0,
|
||||
delivered: bool = False,
|
||||
job_name: str = "",
|
||||
) -> None:
|
||||
"""Log an execution run for a job."""
|
||||
record = RunRecord(
|
||||
job_id=job_id,
|
||||
job_name=job_name,
|
||||
status=status,
|
||||
result=result[:2000] if result and len(result) > 2000 else result,
|
||||
error=error,
|
||||
duration=duration,
|
||||
delivered=delivered,
|
||||
timestamp=time.time(),
|
||||
)
|
||||
with self._lock:
|
||||
self._history.insert(0, record)
|
||||
# Prune to max_history
|
||||
if len(self._history) > self._max_history:
|
||||
self._history = self._history[:self._max_history]
|
||||
self._save_history()
|
||||
|
||||
def get_history(
|
||||
self,
|
||||
job_id: Optional[str] = None,
|
||||
limit: int = 200,
|
||||
) -> List[RunRecord]:
|
||||
"""Get execution history records, newest first."""
|
||||
with self._lock:
|
||||
records = list(self._history)
|
||||
if job_id is not None:
|
||||
records = [r for r in records if r.job_id == job_id]
|
||||
return records[:limit]
|
||||
|
||||
# ── persistence ──────────────────────────────────────────────────
|
||||
|
||||
def _load(self) -> None:
|
||||
@@ -107,3 +156,28 @@ class FileScheduleStore:
|
||||
os.replace(tmp, self._path)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to save schedule store to %s: %s", self._path, e)
|
||||
|
||||
def _load_history(self) -> None:
|
||||
"""Load execution history from history.json."""
|
||||
if not os.path.exists(self._history_path):
|
||||
return
|
||||
try:
|
||||
with open(self._history_path, "r") as f:
|
||||
data = json.load(f)
|
||||
for d in data:
|
||||
record = RunRecord.from_dict(d)
|
||||
self._history.append(record)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load history from %s: %s", self._history_path, e)
|
||||
|
||||
def _save_history(self) -> None:
|
||||
"""Save execution history to history.json."""
|
||||
try:
|
||||
os.makedirs(self._dir, exist_ok=True)
|
||||
data = [r.to_dict() for r in self._history]
|
||||
tmp = self._history_path + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
os.replace(tmp, self._history_path)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to save history to %s: %s", self._history_path, e)
|
||||
@@ -329,7 +329,11 @@ class Session:
|
||||
|
||||
def _save_agent_chat_histories(self) -> None:
|
||||
"""
|
||||
Save all agent chat histories to memory.
|
||||
Save all agent chat histories.
|
||||
|
||||
G-2 FIX: Routes to SessionStore instead of Memory.store_short_term() to
|
||||
maintain clean separation between conversation history (SessionStore)
|
||||
and semantic memory (Memory). Falls back to Memory for backward compat.
|
||||
"""
|
||||
if self.is_remote:
|
||||
return
|
||||
@@ -346,18 +350,37 @@ class Session:
|
||||
chat_history = agent_data.get("chat_history")
|
||||
|
||||
if chat_history is not None:
|
||||
# Save to memory
|
||||
history_text = f"Agent chat history for {agent_key}"
|
||||
self.memory.store_short_term(
|
||||
text=history_text,
|
||||
metadata={
|
||||
"type": "agent_chat_history",
|
||||
"session_id": self.session_id,
|
||||
"user_id": self.user_id,
|
||||
"agent_key": agent_key,
|
||||
"chat_history": chat_history
|
||||
}
|
||||
)
|
||||
# G-2 FIX: Try SessionStore first for clean separation
|
||||
session_store = None
|
||||
try:
|
||||
from .session import get_default_session_store
|
||||
session_store = get_default_session_store()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if session_store is not None:
|
||||
# Use SessionStore for conversation history
|
||||
session_id = f"{self.session_id}_{agent_key}"
|
||||
for msg in chat_history:
|
||||
if isinstance(msg, dict):
|
||||
session_store.add_message(
|
||||
session_id,
|
||||
role=msg.get("role", "user"),
|
||||
content=msg.get("content", ""),
|
||||
)
|
||||
else:
|
||||
# Fallback to Memory.store_short_term() for backward compatibility
|
||||
history_text = f"Agent chat history for {agent_key}"
|
||||
self.memory.store_short_term(
|
||||
text=history_text,
|
||||
metadata={
|
||||
"type": "agent_chat_history",
|
||||
"session_id": self.session_id,
|
||||
"user_id": self.user_id,
|
||||
"agent_key": agent_key,
|
||||
"chat_history": chat_history
|
||||
}
|
||||
)
|
||||
|
||||
def get_state(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a specific state value"""
|
||||
|
||||
@@ -311,7 +311,12 @@ class Session:
|
||||
}
|
||||
|
||||
def _save_agent_chat_histories(self) -> None:
|
||||
"""Save all agent chat histories to memory."""
|
||||
"""Save all agent chat histories.
|
||||
|
||||
G-2 FIX: Routes to SessionStore instead of Memory.store_short_term() to
|
||||
maintain clean separation between conversation history (SessionStore)
|
||||
and semantic memory (Memory). Falls back to Memory for backward compat.
|
||||
"""
|
||||
if self.is_remote:
|
||||
return
|
||||
|
||||
@@ -326,17 +331,37 @@ class Session:
|
||||
chat_history = agent_data.get("chat_history")
|
||||
|
||||
if chat_history is not None:
|
||||
history_text = f"Agent chat history for {agent_key}"
|
||||
self.memory.store_short_term(
|
||||
text=history_text,
|
||||
metadata={
|
||||
"type": "agent_chat_history",
|
||||
"session_id": self.session_id,
|
||||
"user_id": self.user_id,
|
||||
"agent_key": agent_key,
|
||||
"chat_history": chat_history
|
||||
}
|
||||
)
|
||||
# G-2 FIX: Try SessionStore first for clean separation
|
||||
session_store = None
|
||||
try:
|
||||
from . import get_default_session_store
|
||||
session_store = get_default_session_store()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if session_store is not None:
|
||||
# Use SessionStore for conversation history
|
||||
session_id = f"{self.session_id}_{agent_key}"
|
||||
for msg in chat_history:
|
||||
if isinstance(msg, dict):
|
||||
session_store.add_message(
|
||||
session_id,
|
||||
role=msg.get("role", "user"),
|
||||
content=msg.get("content", ""),
|
||||
)
|
||||
else:
|
||||
# Fallback to Memory.store_short_term() for backward compatibility
|
||||
history_text = f"Agent chat history for {agent_key}"
|
||||
self.memory.store_short_term(
|
||||
text=history_text,
|
||||
metadata={
|
||||
"type": "agent_chat_history",
|
||||
"session_id": self.session_id,
|
||||
"user_id": self.user_id,
|
||||
"agent_key": agent_key,
|
||||
"chat_history": chat_history
|
||||
}
|
||||
)
|
||||
|
||||
def get_state(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a specific state value"""
|
||||
|
||||
@@ -29,6 +29,7 @@ __all__ = [
|
||||
"FileBackend",
|
||||
"SQLiteBackend",
|
||||
"RedisBackend",
|
||||
"MongoDBBackend",
|
||||
"get_backend",
|
||||
]
|
||||
|
||||
@@ -47,6 +48,7 @@ _LAZY_IMPORTS = {
|
||||
"FileBackend": ("backends", "FileBackend"),
|
||||
"SQLiteBackend": ("backends", "SQLiteBackend"),
|
||||
"RedisBackend": ("backends", "RedisBackend"),
|
||||
"MongoDBBackend": ("backends", "MongoDBBackend"),
|
||||
"get_backend": ("backends", "get_backend"),
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ DRY: All backends implement StorageBackendProtocol for consistent interface.
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
import tempfile
|
||||
import logging
|
||||
@@ -456,6 +457,114 @@ class RedisBackend:
|
||||
self._client = None
|
||||
|
||||
|
||||
class MongoDBBackend:
|
||||
"""
|
||||
MongoDB-based storage backend.
|
||||
|
||||
Uses MongoDB for document-oriented data storage.
|
||||
Requires the `pymongo` package (optional dependency).
|
||||
|
||||
Example:
|
||||
```python
|
||||
backend = MongoDBBackend(url="mongodb://localhost:27017/")
|
||||
backend.save("session_123", {"messages": []})
|
||||
data = backend.load("session_123")
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str = "mongodb://localhost:27017/",
|
||||
database: str = "praisonai",
|
||||
collection: str = "praison_storage",
|
||||
max_pool_size: int = 50,
|
||||
):
|
||||
self.url = url
|
||||
self.database = database
|
||||
self.collection_name = collection
|
||||
self.max_pool_size = max_pool_size
|
||||
self._client = None
|
||||
self._collection = None
|
||||
|
||||
def _get_collection(self):
|
||||
"""Lazy initialize MongoDB client and collection."""
|
||||
if self._collection is None:
|
||||
try:
|
||||
import pymongo
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"MongoDB backend requires the 'pymongo' package. "
|
||||
"Install with: pip install pymongo"
|
||||
)
|
||||
self._client = pymongo.MongoClient(
|
||||
self.url,
|
||||
maxPoolSize=self.max_pool_size,
|
||||
retryWrites=True,
|
||||
retryReads=True,
|
||||
serverSelectionTimeoutMS=5000, # 5 second timeout for connection
|
||||
)
|
||||
db = self._client[self.database]
|
||||
self._collection = db[self.collection_name]
|
||||
return self._collection
|
||||
|
||||
def save(self, key: str, data: Dict[str, Any]) -> None:
|
||||
"""Save data with the given key (upsert)."""
|
||||
collection = self._get_collection()
|
||||
json_data = json.dumps(data, default=str, ensure_ascii=False)
|
||||
collection.replace_one(
|
||||
{"_id": key},
|
||||
{"_id": key, "data": json_data, "updated_at": time.time()},
|
||||
upsert=True,
|
||||
)
|
||||
|
||||
def load(self, key: str) -> Optional[Dict[str, Any]]:
|
||||
"""Load data by key."""
|
||||
collection = self._get_collection()
|
||||
doc = collection.find_one({"_id": key})
|
||||
if doc and "data" in doc:
|
||||
try:
|
||||
return json.loads(doc["data"])
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return None
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
"""Delete data by key."""
|
||||
collection = self._get_collection()
|
||||
result = collection.delete_one({"_id": key})
|
||||
return result.deleted_count > 0
|
||||
|
||||
def list_keys(self, prefix: str = "") -> List[str]:
|
||||
"""List all keys, optionally filtered by prefix."""
|
||||
collection = self._get_collection()
|
||||
if prefix:
|
||||
cursor = collection.find(
|
||||
{"_id": {"$regex": f"^{prefix}"}},
|
||||
{"_id": 1}
|
||||
)
|
||||
else:
|
||||
cursor = collection.find({}, {"_id": 1})
|
||||
return sorted([doc["_id"] for doc in cursor])
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
"""Check if a key exists."""
|
||||
collection = self._get_collection()
|
||||
return collection.count_documents({"_id": key}, limit=1) > 0
|
||||
|
||||
def clear(self) -> int:
|
||||
"""Clear all data. Returns number of items deleted."""
|
||||
collection = self._get_collection()
|
||||
result = collection.delete_many({})
|
||||
return result.deleted_count
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the MongoDB connection."""
|
||||
if self._client:
|
||||
self._client.close()
|
||||
self._client = None
|
||||
self._collection = None
|
||||
|
||||
|
||||
def get_backend(
|
||||
backend_type: str = "file",
|
||||
**kwargs
|
||||
@@ -488,6 +597,8 @@ def get_backend(
|
||||
return SQLiteBackend(**kwargs)
|
||||
elif backend_type == "redis":
|
||||
return RedisBackend(**kwargs)
|
||||
elif backend_type == "mongodb":
|
||||
return MongoDBBackend(**kwargs)
|
||||
else:
|
||||
raise ValueError(f"Unknown backend type: {backend_type}")
|
||||
|
||||
@@ -496,5 +607,6 @@ __all__ = [
|
||||
'FileBackend',
|
||||
'SQLiteBackend',
|
||||
'RedisBackend',
|
||||
'MongoDBBackend',
|
||||
'get_backend',
|
||||
]
|
||||
@@ -25,7 +25,9 @@ class StreamEventType(Enum):
|
||||
FIRST_TOKEN = "first_token" # First content delta received (TTFT marker)
|
||||
DELTA_TEXT = "delta_text" # Text content delta
|
||||
DELTA_TOOL_CALL = "delta_tool_call" # Tool call delta (function name, args chunk)
|
||||
TOOL_CALL_END = "tool_call_end" # Tool call complete
|
||||
TOOL_CALL_END = "tool_call_end" # Tool call complete (stream end marker)
|
||||
TOOL_CALL_START = "tool_call_start" # Tool execution starting (complete parsed args)
|
||||
TOOL_CALL_RESULT = "tool_call_result" # Tool execution completed (with result)
|
||||
LAST_TOKEN = "last_token" # Final content delta
|
||||
STREAM_END = "stream_end" # Stream completed successfully
|
||||
ERROR = "error" # Error during streaming
|
||||
|
||||
@@ -138,9 +138,9 @@ TOOL_MAPPINGS = {
|
||||
'get_available_providers': ('.web_search', None),
|
||||
|
||||
# Unified Web Crawl (auto-fallback across providers)
|
||||
'web_crawl': ('.web_crawl', None),
|
||||
'crawl_web': ('.web_crawl', None), # Alias
|
||||
'get_available_crawl_providers': ('.web_crawl', None),
|
||||
'web_crawl': ('.web_crawl_tools', None),
|
||||
'crawl_web': ('.web_crawl_tools', None), # Alias
|
||||
'get_available_crawl_providers': ('.web_crawl_tools', None),
|
||||
|
||||
# Skill Tools (for Agent Skills script execution)
|
||||
'run_skill_script': ('.skill_tools', None),
|
||||
@@ -163,6 +163,26 @@ TOOL_MAPPINGS = {
|
||||
'is_ast_grep_available': ('.ast_grep_tool', None),
|
||||
'get_ast_grep_tools': ('.ast_grep_tool', None),
|
||||
'ast_grep_tools': ('.ast_grep_tool', None),
|
||||
|
||||
# Memory Tools (active memory store/search for agents)
|
||||
'store_memory': ('.memory', None),
|
||||
'search_memory': ('.memory', None),
|
||||
|
||||
# Learning Tools (active categorized knowledge store/search)
|
||||
'store_learning': ('.learning', None),
|
||||
'search_learning': ('.learning', None),
|
||||
|
||||
# Email Tools (AgentMail-based send/read/list/reply/create)
|
||||
'send_email': ('.email_tools', None),
|
||||
'list_emails': ('.email_tools', None),
|
||||
'read_email': ('.email_tools', None),
|
||||
'reply_email': ('.email_tools', None),
|
||||
'list_inboxes': ('.email_tools', None),
|
||||
'create_inbox': ('.email_tools', None),
|
||||
# Email Tools (SMTP/IMAP-based — direct mailbox credentials)
|
||||
'smtp_send_email': ('.email_tools', None),
|
||||
'smtp_read_inbox': ('.email_tools', None),
|
||||
'email_tools': ('.email_tools', None),
|
||||
}
|
||||
|
||||
_instances = {} # Cache for class instances
|
||||
@@ -221,10 +241,14 @@ def __getattr__(name: str) -> Any:
|
||||
'web_crawl', 'crawl_web', 'get_available_crawl_providers',
|
||||
'run_skill_script', 'read_skill_file', 'list_skill_scripts', 'create_skill_tools',
|
||||
'schedule_add', 'schedule_list', 'schedule_remove',
|
||||
'ast_grep_search', 'ast_grep_rewrite', 'ast_grep_scan', 'is_ast_grep_available', 'get_ast_grep_tools'
|
||||
'ast_grep_search', 'ast_grep_rewrite', 'ast_grep_scan', 'is_ast_grep_available', 'get_ast_grep_tools',
|
||||
'store_memory', 'search_memory',
|
||||
'store_learning', 'search_learning',
|
||||
'send_email', 'list_emails', 'read_email', 'reply_email', 'list_inboxes', 'create_inbox',
|
||||
'smtp_send_email', 'smtp_read_inbox'
|
||||
]:
|
||||
return getattr(module, name)
|
||||
if name in ['file_tools', 'spider_tools', 'python_tools', 'shell_tools', 'cot_tools', 'tavily_tools', 'youdotcom_tools', 'exa_tools', 'crawl4ai_tools', 'skill_tools', 'schedule_tools', 'ast_grep_tools']:
|
||||
if name in ['file_tools', 'spider_tools', 'python_tools', 'shell_tools', 'cot_tools', 'tavily_tools', 'youdotcom_tools', 'exa_tools', 'crawl4ai_tools', 'skill_tools', 'schedule_tools', 'ast_grep_tools', 'email_tools']:
|
||||
return module # Returns the callable module
|
||||
return getattr(module, name)
|
||||
else:
|
||||
|
||||
@@ -29,17 +29,42 @@ from importlib import util
|
||||
|
||||
|
||||
def _check_crawl4ai_available() -> tuple[bool, Optional[str]]:
|
||||
"""Check if Crawl4AI is available.
|
||||
|
||||
"""Check if Crawl4AI and its Playwright browser binaries are available.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_available, error_message)
|
||||
"""
|
||||
if util.find_spec("crawl4ai") is None:
|
||||
return False, "crawl4ai package is not installed. Install it with: pip install crawl4ai && crawl4ai-setup"
|
||||
|
||||
return False, (
|
||||
"crawl4ai package is not installed. "
|
||||
"Install it with: pip install 'praisonaiagents[crawl]'"
|
||||
)
|
||||
|
||||
# Check that Playwright browser binaries have been downloaded.
|
||||
# `playwright install` is a post-install OS step that pip never runs.
|
||||
# We check the standard ms-playwright cache directory directly — no
|
||||
# API calls, no process launches, no private internals.
|
||||
try:
|
||||
import os, pathlib
|
||||
ms_pw = pathlib.Path(os.environ.get(
|
||||
"PLAYWRIGHT_BROWSERS_PATH",
|
||||
pathlib.Path.home() / ".cache" / "ms-playwright"
|
||||
))
|
||||
if ms_pw.exists() and not list(ms_pw.glob("chromium-*")):
|
||||
return False, (
|
||||
"Playwright browsers are not installed.\n"
|
||||
"Run this command once to download them:\n"
|
||||
" playwright install chromium\n"
|
||||
"Or for Docker/CI: playwright install chromium --with-deps"
|
||||
)
|
||||
# If ms_pw doesn't exist yet, skip — _get_crawler will catch it.
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
|
||||
class Crawl4AITools:
|
||||
"""Crawl4AI web crawling and extraction tools.
|
||||
|
||||
@@ -81,9 +106,9 @@ class Crawl4AITools:
|
||||
is_available, error = _check_crawl4ai_available()
|
||||
if not is_available:
|
||||
raise ImportError(error)
|
||||
|
||||
|
||||
from crawl4ai import AsyncWebCrawler, BrowserConfig
|
||||
|
||||
|
||||
if self._crawler is None:
|
||||
browser_config = BrowserConfig(
|
||||
headless=self.headless,
|
||||
@@ -91,9 +116,22 @@ class Crawl4AITools:
|
||||
browser_type=self.browser_type
|
||||
)
|
||||
self._crawler = AsyncWebCrawler(config=browser_config)
|
||||
await self._crawler.start()
|
||||
|
||||
try:
|
||||
await self._crawler.start()
|
||||
except Exception as e:
|
||||
self._crawler = None
|
||||
err_str = str(e)
|
||||
if "Executable doesn't exist" in err_str or "playwright install" in err_str:
|
||||
raise RuntimeError(
|
||||
"Playwright browser not found. Run:\n"
|
||||
" playwright install chromium\n"
|
||||
"Then retry. For Docker/CI use:\n"
|
||||
" playwright install chromium --with-deps"
|
||||
) from None
|
||||
raise
|
||||
|
||||
return self._crawler
|
||||
|
||||
|
||||
async def close(self):
|
||||
"""Close the crawler and release resources."""
|
||||
@@ -571,7 +609,7 @@ def crawl4ai_sync(
|
||||
Returns:
|
||||
Dict with crawl results
|
||||
"""
|
||||
return asyncio.get_event_loop().run_until_complete(
|
||||
return asyncio.run(
|
||||
crawl4ai(url, css_selector, js_code, wait_for)
|
||||
)
|
||||
|
||||
@@ -591,7 +629,7 @@ def crawl4ai_extract_sync(
|
||||
Returns:
|
||||
Dict with extracted data
|
||||
"""
|
||||
return asyncio.get_event_loop().run_until_complete(
|
||||
return asyncio.run(
|
||||
crawl4ai_extract(url, schema, js_code)
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -286,12 +286,26 @@ class FileTools:
|
||||
}
|
||||
|
||||
try:
|
||||
# Validate URL to prevent SSRF
|
||||
from urllib.parse import urlparse
|
||||
parsed_url = urlparse(url)
|
||||
if parsed_url.scheme not in ("http", "https"):
|
||||
return {"success": False, "path": "", "size": 0, "error": f"Unsupported URL scheme: {parsed_url.scheme}"}
|
||||
import ipaddress, socket
|
||||
try:
|
||||
hostname = parsed_url.hostname or ""
|
||||
ip = ipaddress.ip_address(socket.gethostbyname(hostname))
|
||||
if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local:
|
||||
return {"success": False, "path": "", "size": 0, "error": f"URL resolves to private/reserved IP: {ip}"}
|
||||
except (socket.gaierror, ValueError):
|
||||
pass # Let the request handle DNS resolution errors
|
||||
|
||||
# Validate destination path
|
||||
safe_path = FileTools._validate_path(destination)
|
||||
|
||||
|
||||
# Create directory if needed
|
||||
os.makedirs(os.path.dirname(safe_path) or ".", exist_ok=True)
|
||||
|
||||
|
||||
# Download file
|
||||
with httpx.stream("GET", url, timeout=timeout, follow_redirects=True) as response:
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -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)",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -25,12 +25,28 @@ logger = logging.getLogger(__name__)
|
||||
_store_instance = None
|
||||
|
||||
|
||||
def set_store(store):
|
||||
"""Inject an external schedule store (e.g., config.yaml-backed).
|
||||
|
||||
When PraisonAIUI is running, it calls this at startup to redirect
|
||||
all agent schedule_add/list/remove operations to the unified
|
||||
``config.yaml`` instead of the default ``jobs.json``.
|
||||
"""
|
||||
global _store_instance
|
||||
_store_instance = store
|
||||
|
||||
|
||||
def _get_store():
|
||||
"""Return (or create) the global ``FileScheduleStore``."""
|
||||
"""Return (or create) the global schedule store.
|
||||
|
||||
Uses ``ConfigYamlScheduleStore`` (config.yaml) by default.
|
||||
Automatically migrates any existing ``jobs.json`` data on first use.
|
||||
"""
|
||||
global _store_instance
|
||||
if _store_instance is None:
|
||||
from ..scheduler.store import FileScheduleStore
|
||||
_store_instance = FileScheduleStore()
|
||||
from ..scheduler.config_store import ConfigYamlScheduleStore
|
||||
_store_instance = ConfigYamlScheduleStore()
|
||||
_store_instance.migrate_from_json()
|
||||
return _store_instance
|
||||
|
||||
|
||||
@@ -41,6 +57,10 @@ def schedule_add(
|
||||
name: str,
|
||||
schedule: str,
|
||||
message: str = "",
|
||||
channel: str = "",
|
||||
channel_id: str = "",
|
||||
agent_id: str = "",
|
||||
session_id: str = "",
|
||||
) -> str:
|
||||
"""Add a new scheduled job.
|
||||
|
||||
@@ -53,16 +73,39 @@ def schedule_add(
|
||||
- "at:2026-03-01T09:00:00" (one-shot ISO timestamp)
|
||||
- "in 20 minutes" (relative one-shot)
|
||||
message: The prompt or reminder text to deliver when triggered.
|
||||
channel: Delivery platform ("telegram", "discord", "slack",
|
||||
"whatsapp"). If set with channel_id, the result will
|
||||
be sent back to that chat when the job fires.
|
||||
channel_id: Target chat/channel/group ID on the platform.
|
||||
agent_id: Which agent should execute this job. If empty, the
|
||||
executor uses its default agent.
|
||||
session_id: Optional session ID to preserve conversation context.
|
||||
If set, the agent will have access to prior chat history.
|
||||
|
||||
Returns:
|
||||
Confirmation string with the job id.
|
||||
"""
|
||||
try:
|
||||
from ..scheduler.parser import parse_schedule
|
||||
from ..scheduler.models import ScheduleJob
|
||||
from ..scheduler.models import ScheduleJob, DeliveryTarget
|
||||
|
||||
sched = parse_schedule(schedule)
|
||||
job = ScheduleJob(name=name, schedule=sched, message=message)
|
||||
|
||||
delivery = None
|
||||
if channel and channel_id:
|
||||
delivery = DeliveryTarget(
|
||||
channel=channel,
|
||||
channel_id=channel_id,
|
||||
session_id=session_id or None,
|
||||
)
|
||||
|
||||
job = ScheduleJob(
|
||||
name=name,
|
||||
schedule=sched,
|
||||
message=message,
|
||||
agent_id=agent_id or None,
|
||||
delivery=delivery,
|
||||
)
|
||||
|
||||
store = _get_store()
|
||||
|
||||
@@ -72,7 +115,9 @@ def schedule_add(
|
||||
return f"A schedule named '{name}' already exists (id: {existing.id}). Remove it first or choose a different name."
|
||||
|
||||
store.add(job)
|
||||
return f"Schedule '{name}' added (id: {job.id}, {schedule})."
|
||||
delivery_note = f" → deliver to {channel}:{channel_id}" if delivery else ""
|
||||
agent_note = f" agent={agent_id}" if agent_id else ""
|
||||
return f"Schedule '{name}' added (id: {job.id}, {schedule}{agent_note}{delivery_note})."
|
||||
except ValueError as e:
|
||||
return f"Error adding schedule: {e}"
|
||||
except Exception as e:
|
||||
@@ -105,10 +150,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
|
||||
|
||||
+6
-1
@@ -206,7 +206,12 @@ def web_crawl(
|
||||
"""
|
||||
# Normalize to list
|
||||
single_url = isinstance(urls, str)
|
||||
url_list = [urls] if single_url else urls
|
||||
if single_url and ',' in urls:
|
||||
# LLM may pass comma-separated URLs as a single string
|
||||
url_list = [u.strip() for u in urls.split(',') if u.strip()]
|
||||
single_url = len(url_list) == 1
|
||||
else:
|
||||
url_list = [urls] if single_url else urls
|
||||
|
||||
# Get available providers
|
||||
available = _get_available_crawl_providers()
|
||||
@@ -4,6 +4,7 @@ A2A - Main A2A Interface Class
|
||||
Exposes PraisonAI Agents via the A2A (Agent2Agent) protocol.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import List, Optional, TYPE_CHECKING
|
||||
|
||||
@@ -48,6 +49,9 @@ class A2A:
|
||||
version: Version string for the agent
|
||||
prefix: URL prefix for the router
|
||||
tags: OpenAPI tags for the router
|
||||
auth_token: Optional bearer token for authentication on POST /a2a.
|
||||
When set, requests must include Authorization: Bearer <token>.
|
||||
Discovery (/.well-known/agent.json) remains public per spec.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -60,6 +64,7 @@ class A2A:
|
||||
version: str = "1.0.0",
|
||||
prefix: str = "",
|
||||
tags: Optional[List[str]] = None,
|
||||
auth_token: Optional[str] = None,
|
||||
):
|
||||
if agent is None and agents is None:
|
||||
raise ValueError("A2A requires an agent or agents instance")
|
||||
@@ -70,6 +75,7 @@ class A2A:
|
||||
self.version = version
|
||||
self.prefix = prefix
|
||||
self.tags = tags or ["A2A"]
|
||||
self.auth_token = auth_token
|
||||
|
||||
# Set name from agent if not provided
|
||||
if name:
|
||||
@@ -112,6 +118,25 @@ class A2A:
|
||||
)
|
||||
return self._agent_card
|
||||
|
||||
def serve(self, host: str = "0.0.0.0", port: int = 8000):
|
||||
"""
|
||||
Start a standalone A2A server.
|
||||
|
||||
Convenience method that creates a FastAPI app, mounts the A2A
|
||||
router, and runs it with uvicorn. For production use,
|
||||
create your own FastAPI app and use get_router().
|
||||
|
||||
Args:
|
||||
host: Host to bind to (default: 0.0.0.0)
|
||||
port: Port to bind to (default: 8000)
|
||||
"""
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI(title=self.name, version=self.version)
|
||||
app.include_router(self.get_router())
|
||||
uvicorn.run(app, host=host, port=port)
|
||||
|
||||
def get_router(self) -> "APIRouter":
|
||||
"""
|
||||
Get the FastAPI router for this A2A instance.
|
||||
@@ -125,11 +150,22 @@ class A2A:
|
||||
|
||||
def _create_router(self) -> "APIRouter":
|
||||
"""Create FastAPI router with A2A endpoints."""
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi import APIRouter, Header, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
router = APIRouter(prefix=self.prefix, tags=self.tags)
|
||||
|
||||
# Auth dependency — only applied to POST /a2a, not discovery endpoints
|
||||
async def _verify_auth(authorization: Optional[str] = Header(None)):
|
||||
"""Verify bearer token if auth_token is configured."""
|
||||
if self.auth_token is None:
|
||||
return # No auth configured — open access
|
||||
if authorization is None:
|
||||
raise HTTPException(status_code=401, detail="Authorization required")
|
||||
parts = authorization.split(" ", 1)
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer" or parts[1] != self.auth_token:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
|
||||
# Agent Card endpoint (well-known URI)
|
||||
@router.get("/.well-known/agent.json")
|
||||
async def get_agent_card():
|
||||
@@ -147,8 +183,250 @@ class A2A:
|
||||
"version": self.version,
|
||||
}
|
||||
|
||||
# TODO: Add JSON-RPC endpoint for message/send
|
||||
# TODO: Add streaming endpoint for message/stream
|
||||
# TODO: Add task management endpoints
|
||||
# JSON-RPC 2.0 endpoint for A2A protocol
|
||||
# JSON-RPC endpoint — auth-protected when auth_token is set
|
||||
from fastapi import Depends
|
||||
_a2a_deps = [Depends(_verify_auth)] if self.auth_token else []
|
||||
@router.post("/a2a", dependencies=_a2a_deps)
|
||||
async def handle_jsonrpc(request: Request):
|
||||
"""
|
||||
Handle A2A JSON-RPC 2.0 requests.
|
||||
|
||||
Supported methods:
|
||||
- message/send: Send a message and get a response
|
||||
- message/stream: Send a message and stream the response
|
||||
- tasks/get: Get task status and history
|
||||
- tasks/cancel: Cancel a running task
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return _jsonrpc_error(None, -32700, "Parse error: invalid JSON")
|
||||
|
||||
# Validate JSON-RPC format
|
||||
if body.get("jsonrpc") != "2.0":
|
||||
return _jsonrpc_error(
|
||||
body.get("id"), -32600, "Invalid Request: jsonrpc must be '2.0'"
|
||||
)
|
||||
|
||||
request_id = body.get("id")
|
||||
method = body.get("method")
|
||||
params = body.get("params", {})
|
||||
|
||||
# Route to handler
|
||||
if method == "message/send":
|
||||
return await self._handle_message_send(request_id, params)
|
||||
elif method == "message/stream":
|
||||
return await self._handle_message_stream(request_id, params)
|
||||
elif method == "tasks/get":
|
||||
return self._handle_tasks_get(request_id, params)
|
||||
elif method == "tasks/cancel":
|
||||
return self._handle_tasks_cancel(request_id, params)
|
||||
else:
|
||||
return _jsonrpc_error(
|
||||
request_id, -32601, f"Method not found: {method}"
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
async def _handle_message_send(self, request_id, params: dict):
|
||||
"""Handle message/send — create task, run agent, return result."""
|
||||
from fastapi.responses import JSONResponse
|
||||
from praisonaiagents.ui.a2a.types import (
|
||||
Message, TextPart, TaskState,
|
||||
)
|
||||
from praisonaiagents.ui.a2a.conversion import (
|
||||
extract_user_input,
|
||||
praisonai_to_a2a_message,
|
||||
create_artifact,
|
||||
)
|
||||
|
||||
# Validate params
|
||||
msg_data = params.get("message")
|
||||
if not msg_data:
|
||||
return _jsonrpc_error(request_id, -32602, "Invalid params: 'message' required")
|
||||
|
||||
# Parse the incoming message
|
||||
try:
|
||||
message = _parse_message(msg_data)
|
||||
except Exception as e:
|
||||
return _jsonrpc_error(request_id, -32602, f"Invalid params: {e}")
|
||||
|
||||
# Create task in store
|
||||
task = self.task_store.create_task(message)
|
||||
|
||||
# Update to working
|
||||
self.task_store.update_status(task.id, TaskState.WORKING)
|
||||
|
||||
try:
|
||||
# Extract user input text
|
||||
user_input = extract_user_input([message])
|
||||
|
||||
# Run agent or agents (offload sync call to thread pool)
|
||||
if self.agent:
|
||||
response = await asyncio.to_thread(self.agent.chat, user_input)
|
||||
elif self.agents:
|
||||
response = await asyncio.to_thread(self.agents.start, user_input)
|
||||
else:
|
||||
raise RuntimeError("No agent or agents configured")
|
||||
|
||||
# Create response message and artifact
|
||||
response_msg = praisonai_to_a2a_message(
|
||||
str(response), task_id=task.id
|
||||
)
|
||||
artifact = create_artifact(str(response))
|
||||
|
||||
# Update task store
|
||||
self.task_store.add_to_history(task.id, response_msg)
|
||||
self.task_store.add_artifact(task.id, artifact)
|
||||
updated_task = self.task_store.update_status(task.id, TaskState.COMPLETED)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Agent error in message/send: {e}")
|
||||
updated_task = self.task_store.update_status(task.id, TaskState.FAILED)
|
||||
|
||||
# Return JSON-RPC response
|
||||
return JSONResponse(content={
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": updated_task.model_dump(by_alias=True, exclude_none=True),
|
||||
})
|
||||
|
||||
async def _handle_message_stream(self, request_id, params: dict):
|
||||
"""Handle message/stream — create task, stream agent response as SSE."""
|
||||
from fastapi.responses import StreamingResponse
|
||||
from praisonaiagents.ui.a2a.types import TaskState
|
||||
from praisonaiagents.ui.a2a.streaming import stream_agent_response
|
||||
from praisonaiagents.ui.a2a.conversion import extract_user_input
|
||||
|
||||
# Validate params
|
||||
msg_data = params.get("message")
|
||||
if not msg_data:
|
||||
return _jsonrpc_error(request_id, -32602, "Invalid params: 'message' required")
|
||||
|
||||
# Parse the incoming message
|
||||
try:
|
||||
message = _parse_message(msg_data)
|
||||
except Exception as e:
|
||||
return _jsonrpc_error(request_id, -32602, f"Invalid params: {e}")
|
||||
|
||||
# Create task in store
|
||||
task = self.task_store.create_task(message)
|
||||
|
||||
# Extract user input
|
||||
user_input = extract_user_input([message])
|
||||
|
||||
# Determine which entity to stream from
|
||||
stream_entity = self.agent if self.agent else self.agents
|
||||
|
||||
# Stream the response using existing streaming helper
|
||||
return StreamingResponse(
|
||||
stream_agent_response(
|
||||
agent=stream_entity,
|
||||
user_input=user_input,
|
||||
task_id=task.id,
|
||||
context_id=task.context_id,
|
||||
),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
def _handle_tasks_get(self, request_id, params: dict):
|
||||
"""Handle tasks/get — return existing task."""
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
task_id = params.get("id")
|
||||
if not task_id:
|
||||
return _jsonrpc_error(request_id, -32602, "Invalid params: 'id' required")
|
||||
|
||||
task = self.task_store.get_task(task_id)
|
||||
if not task:
|
||||
return _jsonrpc_error(request_id, -32000, f"Task not found: {task_id}")
|
||||
|
||||
return JSONResponse(content={
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": task.model_dump(by_alias=True, exclude_none=True),
|
||||
})
|
||||
|
||||
def _handle_tasks_cancel(self, request_id, params: dict):
|
||||
"""Handle tasks/cancel — cancel existing task."""
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
task_id = params.get("id")
|
||||
if not task_id:
|
||||
return _jsonrpc_error(request_id, -32602, "Invalid params: 'id' required")
|
||||
|
||||
task = self.task_store.cancel_task(task_id)
|
||||
if not task:
|
||||
return _jsonrpc_error(request_id, -32000, f"Task not found: {task_id}")
|
||||
|
||||
return JSONResponse(content={
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": task.model_dump(by_alias=True, exclude_none=True),
|
||||
})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helpers (module-level, no class dependency)
|
||||
# =============================================================================
|
||||
|
||||
def _jsonrpc_error(request_id, code: int, message: str):
|
||||
"""Create a JSON-RPC 2.0 error response."""
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
return JSONResponse(content={
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"error": {"code": code, "message": message},
|
||||
})
|
||||
|
||||
|
||||
def _parse_message(msg_data: dict):
|
||||
"""Parse a raw dict into an A2A Message object.
|
||||
|
||||
Supports all A2A v1 Part types:
|
||||
- text: TextPart
|
||||
- url/fileWithUri: FilePart (file reference)
|
||||
- data: DataPart (structured JSON)
|
||||
"""
|
||||
from praisonaiagents.ui.a2a.types import (
|
||||
Message, TextPart, FilePart, DataPart, Role,
|
||||
)
|
||||
|
||||
parts = []
|
||||
for part_data in msg_data.get("parts", []):
|
||||
if "text" in part_data:
|
||||
parts.append(TextPart(
|
||||
text=part_data["text"],
|
||||
metadata=part_data.get("metadata"),
|
||||
))
|
||||
elif "url" in part_data or "fileWithUri" in part_data:
|
||||
uri = part_data.get("url") or part_data.get("fileWithUri")
|
||||
parts.append(FilePart(
|
||||
file_uri=uri,
|
||||
media_type=part_data.get("mediaType") or part_data.get("media_type"),
|
||||
name=part_data.get("filename") or part_data.get("name"),
|
||||
metadata=part_data.get("metadata"),
|
||||
))
|
||||
elif "data" in part_data:
|
||||
parts.append(DataPart(
|
||||
data=part_data["data"],
|
||||
metadata=part_data.get("metadata"),
|
||||
))
|
||||
|
||||
if not parts:
|
||||
raise ValueError("Message must contain at least one part")
|
||||
|
||||
return Message(
|
||||
message_id=msg_data.get("messageId", "msg-auto"),
|
||||
role=msg_data.get("role", "user"),
|
||||
parts=parts,
|
||||
context_id=msg_data.get("contextId"),
|
||||
task_id=msg_data.get("taskId"),
|
||||
)
|
||||
@@ -4,6 +4,7 @@ A2A Message Conversion
|
||||
Convert between A2A protocol messages and PraisonAI internal format.
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -11,6 +12,8 @@ from praisonaiagents.ui.a2a.types import (
|
||||
Message,
|
||||
Role,
|
||||
TextPart,
|
||||
FilePart,
|
||||
DataPart,
|
||||
Artifact,
|
||||
)
|
||||
|
||||
@@ -19,6 +22,14 @@ def a2a_to_praisonai_messages(messages: List[Message]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Convert A2A messages to PraisonAI/OpenAI chat format.
|
||||
|
||||
Handles all A2A Part types:
|
||||
- TextPart → {"type": "text", "text": ...}
|
||||
- FilePart → {"type": "image_url", "image_url": {"url": ...}}
|
||||
- DataPart → {"type": "text", "text": json.dumps(data)}
|
||||
|
||||
Returns multimodal content list when non-text parts present,
|
||||
plain string content when text-only.
|
||||
|
||||
Args:
|
||||
messages: List of A2A Message objects
|
||||
|
||||
@@ -31,15 +42,33 @@ def a2a_to_praisonai_messages(messages: List[Message]) -> List[Dict[str, Any]]:
|
||||
# Map A2A role to OpenAI role
|
||||
role = "user" if msg.role == Role.USER else "assistant"
|
||||
|
||||
# Combine all text parts
|
||||
content_parts = []
|
||||
has_non_text = False
|
||||
|
||||
for part in msg.parts:
|
||||
if isinstance(part, TextPart):
|
||||
content_parts.append(part.text)
|
||||
content_parts.append({"type": "text", "text": part.text})
|
||||
elif isinstance(part, FilePart) and part.file_uri:
|
||||
has_non_text = True
|
||||
content_parts.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": part.file_uri},
|
||||
})
|
||||
elif isinstance(part, DataPart):
|
||||
has_non_text = True
|
||||
content_parts.append({
|
||||
"type": "text",
|
||||
"text": json.dumps(part.data),
|
||||
})
|
||||
elif hasattr(part, 'text'):
|
||||
content_parts.append(part.text)
|
||||
content_parts.append({"type": "text", "text": part.text})
|
||||
|
||||
content = " ".join(content_parts)
|
||||
# Use multimodal list format when non-text parts present,
|
||||
# plain string otherwise (backwards compat)
|
||||
if has_non_text:
|
||||
content = content_parts
|
||||
else:
|
||||
content = " ".join(p["text"] for p in content_parts)
|
||||
|
||||
result.append({
|
||||
"role": role,
|
||||
@@ -110,6 +139,9 @@ def extract_user_input(messages: List[Message]) -> str:
|
||||
"""
|
||||
Extract the last user input from a list of messages.
|
||||
|
||||
Handles all Part types: TextPart text is included directly,
|
||||
FilePart URIs noted, DataPart serialized to JSON.
|
||||
|
||||
Args:
|
||||
messages: List of A2A Message objects
|
||||
|
||||
@@ -119,13 +151,17 @@ def extract_user_input(messages: List[Message]) -> str:
|
||||
# Find the last user message
|
||||
for msg in reversed(messages):
|
||||
if msg.role == Role.USER:
|
||||
# Combine text parts
|
||||
content_parts = []
|
||||
for part in msg.parts:
|
||||
if isinstance(part, TextPart):
|
||||
content_parts.append(part.text)
|
||||
elif isinstance(part, FilePart) and part.file_uri:
|
||||
content_parts.append(f"[file: {part.file_uri}]")
|
||||
elif isinstance(part, DataPart):
|
||||
content_parts.append(json.dumps(part.data))
|
||||
elif hasattr(part, 'text'):
|
||||
content_parts.append(part.text)
|
||||
return " ".join(content_parts)
|
||||
|
||||
return ""
|
||||
|
||||
@@ -569,6 +569,7 @@ class AgentFlow:
|
||||
reflection: Optional[Any] = None # Union[bool, ReflectionConfig] - self-reflection
|
||||
execution: Optional[Any] = None # Union[str, ExecutionConfig] - execution control
|
||||
caching: Optional[Any] = None # Union[bool, CachingConfig] - caching
|
||||
learn: Optional[Any] = None # Union[bool, LearnConfig] - continuous learning
|
||||
|
||||
# ============================================================
|
||||
# ROBUSTNESS PARAMS (debugging & audit trail)
|
||||
@@ -1901,14 +1902,46 @@ Create a brief execution plan (2-3 sentences) describing how to best accomplish
|
||||
# Only pass output_config if it has values
|
||||
output_json_model = output_config.get('json_model') if output_config else None
|
||||
output_pydantic_model = output_config.get('pydantic_model') if output_config else None
|
||||
return Task(
|
||||
# Extract remaining YAML step-level attributes (previously dead stores)
|
||||
yaml_guardrail = getattr(step, '_yaml_guardrail', None)
|
||||
yaml_max_retries = getattr(step, '_yaml_max_retries', None)
|
||||
yaml_expected_output = getattr(step, '_yaml_expected_output', None)
|
||||
yaml_output_file = getattr(step, '_yaml_output_file', None)
|
||||
yaml_skip_on_failure = getattr(step, '_yaml_skip_on_failure', None)
|
||||
yaml_retry_delay = getattr(step, '_yaml_retry_delay', None)
|
||||
yaml_create_directory = getattr(step, '_yaml_create_directory', None)
|
||||
yaml_callback = getattr(step, '_yaml_callback', None)
|
||||
yaml_async_execution = getattr(step, '_yaml_async_execution', None)
|
||||
|
||||
# Build Task kwargs — only include YAML overrides when present
|
||||
task_kwargs = dict(
|
||||
name=yaml_step_name or getattr(step, 'name', f'agent_{index+1}'),
|
||||
agent=step,
|
||||
tools=agent_tools,
|
||||
action=action,
|
||||
output_json=output_json_model,
|
||||
output_pydantic=output_pydantic_model
|
||||
output_pydantic=output_pydantic_model,
|
||||
)
|
||||
if yaml_guardrail is not None:
|
||||
task_kwargs['guardrails'] = yaml_guardrail
|
||||
if yaml_max_retries is not None:
|
||||
task_kwargs['max_retries'] = yaml_max_retries
|
||||
if yaml_expected_output is not None:
|
||||
task_kwargs['expected_output'] = yaml_expected_output
|
||||
if yaml_output_file is not None:
|
||||
task_kwargs['output_file'] = yaml_output_file
|
||||
if yaml_skip_on_failure is not None:
|
||||
task_kwargs['skip_on_failure'] = yaml_skip_on_failure
|
||||
if yaml_retry_delay is not None:
|
||||
task_kwargs['retry_delay'] = yaml_retry_delay
|
||||
if yaml_create_directory is not None:
|
||||
task_kwargs['create_directory'] = yaml_create_directory
|
||||
if yaml_callback is not None:
|
||||
task_kwargs['callback'] = yaml_callback
|
||||
if yaml_async_execution is not None:
|
||||
task_kwargs['async_execution'] = yaml_async_execution
|
||||
|
||||
return Task(**task_kwargs)
|
||||
elif callable(step):
|
||||
return Task(
|
||||
name=getattr(step, '__name__', f'step_{index+1}'),
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "1.5.30"
|
||||
version = "1.5.80"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -88,6 +88,18 @@ autonomy = [
|
||||
"ast-grep-cli>=0.30.0"
|
||||
]
|
||||
|
||||
# Web search (DuckDuckGo — zero-config, no API key required)
|
||||
search = [
|
||||
"ddgs>=9.0.0",
|
||||
]
|
||||
|
||||
# Web crawling (Crawl4AI + Playwright browser)
|
||||
# NOTE: After installing this extra, run: playwright install chromium
|
||||
crawl = [
|
||||
"crawl4ai>=0.4.0",
|
||||
"playwright>=1.47.0",
|
||||
]
|
||||
|
||||
# Combined features
|
||||
all = [
|
||||
"praisonaiagents[memory]",
|
||||
@@ -98,7 +110,9 @@ all = [
|
||||
"praisonaiagents[api]",
|
||||
"praisonaiagents[telemetry]",
|
||||
"praisonaiagents[mongodb]",
|
||||
"praisonaiagents[auth]"
|
||||
"praisonaiagents[auth]",
|
||||
"praisonaiagents[search]",
|
||||
"praisonaiagents[crawl]"
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
"""
|
||||
Tests for Main A2A Class
|
||||
Tests for A2A Class and JSON-RPC Endpoints
|
||||
|
||||
TDD: Write tests first, then implement A2A class.
|
||||
TDD: Tests written first, then implementation.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Existing Tests — A2A Class
|
||||
# ============================================================================
|
||||
|
||||
class TestA2AClass:
|
||||
"""Tests for A2A main class."""
|
||||
@@ -133,3 +139,683 @@ class TestA2AWithTools:
|
||||
|
||||
assert card.skills is not None
|
||||
assert len(card.skills) >= 1
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# NEW: JSON-RPC Endpoint Tests
|
||||
# ============================================================================
|
||||
|
||||
def _make_app():
|
||||
"""Create a test FastAPI app with mocked agent."""
|
||||
from fastapi import FastAPI
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.ui.a2a import A2A
|
||||
|
||||
agent = Agent(name="JSON-RPC Test", role="Tester", goal="Test")
|
||||
a2a = A2A(agent=agent, url="http://localhost:8000/a2a")
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(a2a.get_router())
|
||||
|
||||
return app, a2a
|
||||
|
||||
|
||||
class TestA2AJsonRpc:
|
||||
"""Tests for A2A JSON-RPC endpoint (POST /a2a)."""
|
||||
|
||||
def test_post_a2a_invalid_jsonrpc(self):
|
||||
"""Missing jsonrpc field → -32600 Invalid Request."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/a2a", json={
|
||||
"method": "message/send",
|
||||
"id": "1",
|
||||
"params": {}
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "error" in data
|
||||
assert data["error"]["code"] == -32600
|
||||
|
||||
def test_post_a2a_unknown_method(self):
|
||||
"""Unknown method → -32601 Method not found."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/a2a", json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "unknown/method",
|
||||
"id": "1",
|
||||
"params": {}
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "error" in data
|
||||
assert data["error"]["code"] == -32601
|
||||
|
||||
def test_post_a2a_message_send(self):
|
||||
"""Valid message/send → Task result with completed status."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, a2a = _make_app()
|
||||
# Mock agent.chat to avoid LLM call
|
||||
a2a.agent.chat = MagicMock(return_value="Hello from agent!")
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/a2a", json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "req-1",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "msg-1",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Hello"}]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["jsonrpc"] == "2.0"
|
||||
assert data["id"] == "req-1"
|
||||
assert "result" in data
|
||||
result = data["result"]
|
||||
assert "id" in result
|
||||
assert result["status"]["state"] == "completed"
|
||||
assert result["artifacts"] is not None
|
||||
assert len(result["artifacts"]) > 0
|
||||
|
||||
def test_post_a2a_message_send_creates_task_in_store(self):
|
||||
"""message/send should create a task in TaskStore."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, a2a = _make_app()
|
||||
a2a.agent.chat = MagicMock(return_value="OK")
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
# Store should be empty initially
|
||||
assert len(a2a.task_store.list_tasks()) == 0
|
||||
|
||||
client.post("/a2a", json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "1",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "m1",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Hi"}]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
# Store should have one task
|
||||
assert len(a2a.task_store.list_tasks()) == 1
|
||||
|
||||
def test_post_a2a_message_send_agent_error(self):
|
||||
"""Agent throws → -32603 Internal error."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, a2a = _make_app()
|
||||
a2a.agent.chat = MagicMock(side_effect=RuntimeError("LLM failed"))
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/a2a", json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "err-1",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "m1",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Hi"}]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
# Should return a task in failed state, not a JSON-RPC error
|
||||
assert "result" in data
|
||||
assert data["result"]["status"]["state"] == "failed"
|
||||
|
||||
def test_post_a2a_tasks_get(self):
|
||||
"""tasks/get → returns existing task."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, a2a = _make_app()
|
||||
a2a.agent.chat = MagicMock(return_value="Done")
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
# First, create a task via message/send
|
||||
send_resp = client.post("/a2a", json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "1",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "m1",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Hi"}]
|
||||
}
|
||||
}
|
||||
})
|
||||
task_id = send_resp.json()["result"]["id"]
|
||||
|
||||
# Now get the task
|
||||
get_resp = client.post("/a2a", json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "tasks/get",
|
||||
"id": "2",
|
||||
"params": {"id": task_id}
|
||||
})
|
||||
|
||||
assert get_resp.status_code == 200
|
||||
data = get_resp.json()
|
||||
assert "result" in data
|
||||
assert data["result"]["id"] == task_id
|
||||
|
||||
def test_post_a2a_tasks_get_not_found(self):
|
||||
"""tasks/get with nonexistent id → -32000 Task not found."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/a2a", json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "tasks/get",
|
||||
"id": "1",
|
||||
"params": {"id": "nonexistent"}
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "error" in data
|
||||
assert data["error"]["code"] == -32000
|
||||
|
||||
def test_post_a2a_tasks_cancel(self):
|
||||
"""tasks/cancel → cancels existing task."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, a2a = _make_app()
|
||||
a2a.agent.chat = MagicMock(return_value="Done")
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
# Create a task
|
||||
send_resp = client.post("/a2a", json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "1",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "m1",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Hi"}]
|
||||
}
|
||||
}
|
||||
})
|
||||
task_id = send_resp.json()["result"]["id"]
|
||||
|
||||
# Cancel it
|
||||
cancel_resp = client.post("/a2a", json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "tasks/cancel",
|
||||
"id": "2",
|
||||
"params": {"id": task_id}
|
||||
})
|
||||
|
||||
assert cancel_resp.status_code == 200
|
||||
data = cancel_resp.json()
|
||||
assert "result" in data
|
||||
assert data["result"]["status"]["state"] == "cancelled"
|
||||
|
||||
def test_post_a2a_message_stream(self):
|
||||
"""message/stream → returns SSE streaming response."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, a2a = _make_app()
|
||||
a2a.agent.chat = MagicMock(return_value="Streamed response")
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/a2a", json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/stream",
|
||||
"id": "stream-1",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "m1",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Hello stream"}]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
# SSE response should contain event data
|
||||
text = response.text
|
||||
assert "event:" in text
|
||||
assert "data:" in text
|
||||
|
||||
def test_post_a2a_missing_params(self):
|
||||
"""message/send without params.message → -32602 Invalid params."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/a2a", json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "1",
|
||||
"params": {}
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "error" in data
|
||||
assert data["error"]["code"] == -32602
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# G11: AgentTeam Routing Tests
|
||||
# ============================================================================
|
||||
|
||||
def _make_team_app():
|
||||
"""Create test app with mocked AgentTeam (agents=)."""
|
||||
from fastapi import FastAPI
|
||||
from praisonaiagents.ui.a2a import A2A
|
||||
|
||||
# Mock an AgentTeam with .start() method
|
||||
mock_team = MagicMock()
|
||||
mock_team.name = "Test Team"
|
||||
mock_team.start = MagicMock(return_value="Team result")
|
||||
|
||||
a2a = A2A(agents=mock_team, name="Team A2A")
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(a2a.get_router())
|
||||
|
||||
return app, a2a, mock_team
|
||||
|
||||
|
||||
class TestA2AAgentTeamRouting:
|
||||
"""G11: Tests for AgentTeam routing via agents= param."""
|
||||
|
||||
def test_a2a_agents_message_send(self):
|
||||
"""message/send with agents= should call team.start()."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, a2a, mock_team = _make_team_app()
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/a2a", json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "team-1",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "m1",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Research AI"}]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "result" in data
|
||||
assert data["result"]["status"]["state"] == "completed"
|
||||
# Verify team.start() was called, not agent.chat()
|
||||
mock_team.start.assert_called_once()
|
||||
|
||||
def test_a2a_agents_message_stream(self):
|
||||
"""message/stream with agents= should work."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, a2a, mock_team = _make_team_app()
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/a2a", json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/stream",
|
||||
"id": "team-s1",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "m1",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Stream this"}]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
text = response.text
|
||||
assert "event:" in text
|
||||
|
||||
def test_a2a_agent_takes_priority(self):
|
||||
"""When both agent= and agents= provided, agent takes priority."""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.ui.a2a import A2A
|
||||
|
||||
agent = Agent(name="Priority", role="Tester", goal="Test")
|
||||
mock_team = MagicMock()
|
||||
mock_team.name = "Team"
|
||||
|
||||
a2a = A2A(agent=agent, agents=mock_team)
|
||||
a2a.agent.chat = MagicMock(return_value="Agent wins")
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(a2a.get_router())
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/a2a", json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "pri-1",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "m1",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Who responds?"}]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "result" in data
|
||||
# agent.chat should be called, not team.start
|
||||
a2a.agent.chat.assert_called_once()
|
||||
mock_team.start.assert_not_called()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# G9: serve() Convenience Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestA2AServe:
|
||||
"""G9: Tests for A2A.serve() convenience method."""
|
||||
|
||||
def test_a2a_serve_method_exists(self):
|
||||
"""A2A should have a serve() method."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.ui.a2a import A2A
|
||||
|
||||
agent = Agent(name="Serve", role="Helper", goal="Help")
|
||||
a2a = A2A(agent=agent)
|
||||
|
||||
assert hasattr(a2a, 'serve')
|
||||
assert callable(a2a.serve)
|
||||
|
||||
def test_a2a_serve_creates_app(self):
|
||||
"""serve() should create a FastAPI app and call uvicorn.run."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.ui.a2a import A2A
|
||||
|
||||
agent = Agent(name="Serve", role="Helper", goal="Help")
|
||||
a2a = A2A(agent=agent)
|
||||
|
||||
with patch("uvicorn.run") as mock_run:
|
||||
a2a.serve(host="127.0.0.1", port=9999)
|
||||
mock_run.assert_called_once()
|
||||
# Verify the app passed to uvicorn is a FastAPI instance
|
||||
call_args = mock_run.call_args
|
||||
assert call_args[1].get("host") == "127.0.0.1"
|
||||
assert call_args[1].get("port") == 9999
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# G8: Auth Token Tests
|
||||
# ============================================================================
|
||||
|
||||
def _make_auth_app(auth_token=None):
|
||||
"""Create test app with optional auth_token."""
|
||||
from fastapi import FastAPI
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.ui.a2a import A2A
|
||||
|
||||
agent = Agent(name="Auth Test", role="Tester", goal="Test")
|
||||
a2a = A2A(agent=agent, auth_token=auth_token)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(a2a.get_router())
|
||||
|
||||
return app, a2a
|
||||
|
||||
|
||||
class TestA2AAuth:
|
||||
"""G8: Tests for auth_token support."""
|
||||
|
||||
def test_a2a_auth_valid_token(self):
|
||||
"""Valid bearer token → 200."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, a2a = _make_auth_app(auth_token="sk-secret")
|
||||
a2a.agent.chat = MagicMock(return_value="Authed")
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/a2a",
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "auth-1",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "m1",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Hello"}]
|
||||
}
|
||||
}
|
||||
},
|
||||
headers={"Authorization": "Bearer sk-secret"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "result" in data
|
||||
|
||||
def test_a2a_auth_invalid_token(self):
|
||||
"""Wrong bearer token → 401."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _ = _make_auth_app(auth_token="sk-secret")
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/a2a",
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "auth-2",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "m1",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Hello"}]
|
||||
}
|
||||
}
|
||||
},
|
||||
headers={"Authorization": "Bearer wrong-token"}
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_a2a_auth_missing_header(self):
|
||||
"""No Authorization header → 401."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _ = _make_auth_app(auth_token="sk-secret")
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/a2a", json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "auth-3",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "m1",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Hello"}]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_a2a_no_auth_configured(self):
|
||||
"""No auth_token set → open access (200)."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, a2a = _make_auth_app(auth_token=None)
|
||||
a2a.agent.chat = MagicMock(return_value="Open")
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/a2a", json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "no-auth-1",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "m1",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Hello"}]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "result" in data
|
||||
|
||||
def test_a2a_auth_on_discovery_endpoint(self):
|
||||
"""Agent card endpoint should remain open even with auth."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _ = _make_auth_app(auth_token="sk-secret")
|
||||
client = TestClient(app)
|
||||
|
||||
# Discovery endpoints should be public per A2A spec
|
||||
response = client.get("/.well-known/agent.json")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# G13: Part Conversion Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestA2APartConversion:
|
||||
"""G13: Tests for url/data part handling in _parse_message and conversion."""
|
||||
|
||||
def test_parse_message_with_url_part(self):
|
||||
"""Parts with 'url' field should be parsed."""
|
||||
from praisonaiagents.ui.a2a.a2a import _parse_message
|
||||
|
||||
msg = _parse_message({
|
||||
"messageId": "m1",
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"url": "https://example.com/img.png", "mediaType": "image/png"}
|
||||
]
|
||||
})
|
||||
|
||||
assert len(msg.parts) == 1
|
||||
|
||||
def test_parse_message_with_data_part(self):
|
||||
"""Parts with 'data' field should be parsed."""
|
||||
from praisonaiagents.ui.a2a.a2a import _parse_message
|
||||
|
||||
msg = _parse_message({
|
||||
"messageId": "m1",
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"data": {"key": "value"}}
|
||||
]
|
||||
})
|
||||
|
||||
assert len(msg.parts) == 1
|
||||
|
||||
def test_parse_message_mixed_parts(self):
|
||||
"""Mixed text + url + data parts should all be parsed."""
|
||||
from praisonaiagents.ui.a2a.a2a import _parse_message
|
||||
|
||||
msg = _parse_message({
|
||||
"messageId": "m1",
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"text": "Hello"},
|
||||
{"url": "https://example.com/doc.pdf", "mediaType": "application/pdf"},
|
||||
{"data": {"x": 1}}
|
||||
]
|
||||
})
|
||||
|
||||
assert len(msg.parts) == 3
|
||||
|
||||
def test_conversion_url_part_to_openai(self):
|
||||
"""FilePart with url should convert to OpenAI image_url format."""
|
||||
from praisonaiagents.ui.a2a.conversion import a2a_to_praisonai_messages
|
||||
from praisonaiagents.ui.a2a.types import Message, FilePart, Role
|
||||
|
||||
msg = Message(
|
||||
message_id="m1",
|
||||
role=Role.USER,
|
||||
parts=[FilePart(file_uri="https://example.com/img.png", media_type="image/png")],
|
||||
)
|
||||
|
||||
result = a2a_to_praisonai_messages([msg])
|
||||
assert len(result) == 1
|
||||
content = result[0]["content"]
|
||||
# Should contain image_url reference
|
||||
assert isinstance(content, list)
|
||||
assert any(p.get("type") == "image_url" for p in content)
|
||||
|
||||
def test_conversion_data_part_to_openai(self):
|
||||
"""DataPart should convert to text with JSON content."""
|
||||
from praisonaiagents.ui.a2a.conversion import a2a_to_praisonai_messages
|
||||
from praisonaiagents.ui.a2a.types import Message, DataPart, Role
|
||||
|
||||
msg = Message(
|
||||
message_id="m1",
|
||||
role=Role.USER,
|
||||
parts=[DataPart(data={"key": "value"})],
|
||||
)
|
||||
|
||||
result = a2a_to_praisonai_messages([msg])
|
||||
assert len(result) == 1
|
||||
content = result[0]["content"]
|
||||
assert isinstance(content, list)
|
||||
assert any("key" in str(p) for p in content)
|
||||
|
||||
def test_extract_user_input_with_url_parts(self):
|
||||
"""extract_user_input should handle non-text parts gracefully."""
|
||||
from praisonaiagents.ui.a2a.conversion import extract_user_input
|
||||
from praisonaiagents.ui.a2a.types import Message, FilePart, TextPart, Role
|
||||
|
||||
msg = Message(
|
||||
message_id="m1",
|
||||
role=Role.USER,
|
||||
parts=[
|
||||
TextPart(text="Please analyze"),
|
||||
FilePart(file_uri="https://example.com/img.png"),
|
||||
],
|
||||
)
|
||||
|
||||
result = extract_user_input([msg])
|
||||
assert "analyze" in result
|
||||
@@ -0,0 +1,2 @@
|
||||
# Auto-generated profile results
|
||||
*.jsonl
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Complex 10-step task comparison: wrapper vs caller vs iterative.
|
||||
|
||||
Tests the tool_completion fix with a realistic multi-tool prompt that requires:
|
||||
1. Web search (3x for frameworks)
|
||||
2. File creation (Python dict)
|
||||
3. Code execution (verify dict)
|
||||
4. File write (markdown report)
|
||||
5. File read (verify report)
|
||||
6. List files (confirm both exist)
|
||||
7. System info
|
||||
8. Code analysis
|
||||
9. Code formatting
|
||||
10. Web search (Python 3.13)
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
import os
|
||||
|
||||
# Ensure local package is used
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from praisonaiagents import Agent
|
||||
|
||||
COMPLEX_PROMPT = """Research the top 3 Python web frameworks (Django, FastAPI, Flask), then:
|
||||
1) Search the web for each framework's latest version and key features
|
||||
2) Create a file called /tmp/framework_comparison.py that contains a Python dictionary with the comparison data
|
||||
3) Execute the code to verify the dictionary is valid
|
||||
4) Write a markdown report to /tmp/framework_report.md summarizing your findings in a table format
|
||||
5) Read back the report file to verify it was written correctly
|
||||
6) List the files in /tmp to confirm both files exist
|
||||
7) Get system info to note what OS this report was generated on
|
||||
8) Analyze the Python code you wrote for any issues
|
||||
9) Format the Python code properly
|
||||
10) Finally search for any recent news about Python 3.13 features"""
|
||||
|
||||
|
||||
def run_test(name, agent, prompt):
|
||||
"""Run agent and return results dict."""
|
||||
print(f"\n{'='*70}")
|
||||
print(f"TEST: {name}")
|
||||
print(f"{'='*70}\n")
|
||||
|
||||
start = time.time()
|
||||
try:
|
||||
result = agent.start(prompt)
|
||||
except Exception as e:
|
||||
return {
|
||||
"name": name,
|
||||
"duration": time.time() - start,
|
||||
"error": str(e),
|
||||
"success": False,
|
||||
}
|
||||
|
||||
duration = time.time() - start
|
||||
output = result.output if hasattr(result, "output") else str(result)
|
||||
reason = getattr(result, "completion_reason", None)
|
||||
iters = getattr(result, "iterations", None)
|
||||
success = getattr(result, "success", True)
|
||||
|
||||
print(f"\n--- {name} RESULTS ---")
|
||||
print(f"Duration: {duration:.1f}s")
|
||||
if reason:
|
||||
print(f"Completion reason: {reason}")
|
||||
if iters:
|
||||
print(f"Iterations: {iters}")
|
||||
print(f"Response length: {len(output or '')} chars")
|
||||
print(f"Success: {success}")
|
||||
if output:
|
||||
print(f"\nFirst 600 chars of response:")
|
||||
print(output[:600])
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"duration": duration,
|
||||
"reason": reason,
|
||||
"iterations": iters,
|
||||
"length": len(output or ""),
|
||||
"success": success,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
mode = sys.argv[1] if len(sys.argv) > 1 else "all"
|
||||
results = []
|
||||
|
||||
if mode in ("wrapper", "all"):
|
||||
os.environ["PRAISONAI_AUTO_APPROVE"] = "true"
|
||||
agent = Agent(
|
||||
name="wrapper_agent",
|
||||
instructions="You are a helpful research assistant.",
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
results.append(run_test("wrapper", agent, COMPLEX_PROMPT))
|
||||
os.environ.pop("PRAISONAI_AUTO_APPROVE", None)
|
||||
|
||||
if mode in ("caller", "all"):
|
||||
agent = Agent(
|
||||
name="caller_agent",
|
||||
instructions="You are a helpful research assistant.",
|
||||
llm="gpt-4o-mini",
|
||||
autonomy={
|
||||
"level": "full_auto",
|
||||
"mode": "caller", # Single chat(), no loop
|
||||
},
|
||||
)
|
||||
results.append(run_test("caller", agent, COMPLEX_PROMPT))
|
||||
|
||||
if mode in ("iterative", "all"):
|
||||
agent = Agent(
|
||||
name="iterative_agent",
|
||||
instructions="You are a helpful research assistant.",
|
||||
llm="gpt-4o-mini",
|
||||
autonomy={
|
||||
"level": "full_auto",
|
||||
"mode": "iterative",
|
||||
"max_iterations": 5,
|
||||
},
|
||||
)
|
||||
results.append(run_test("iterative", agent, COMPLEX_PROMPT))
|
||||
|
||||
# Summary
|
||||
print(f"\n{'='*70}")
|
||||
print("COMPARISON SUMMARY")
|
||||
print(f"{'='*70}")
|
||||
for r in results:
|
||||
parts = [f"{r['name']:15s}: {r['duration']:6.1f}s"]
|
||||
if r.get("reason"):
|
||||
parts.append(f"reason={r['reason']}")
|
||||
if r.get("iterations"):
|
||||
parts.append(f"iters={r['iterations']}")
|
||||
parts.append(f"len={r['length']}")
|
||||
parts.append(f"ok={r['success']}")
|
||||
print(" " + " | ".join(parts))
|
||||
|
||||
# Check if /tmp files were created
|
||||
print(f"\n--- File Creation Check ---")
|
||||
for f in ["/tmp/framework_comparison.py", "/tmp/framework_report.md"]:
|
||||
exists = os.path.exists(f)
|
||||
size = os.path.getsize(f) if exists else 0
|
||||
print(f" {f}: {'EXISTS' if exists else 'MISSING'} ({size} bytes)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Root-Cause Diagnosis: WHY does iterative mode loop / repeat?
|
||||
|
||||
Uses only LOW-RISK tools (internet_search, get_system_info, list_files)
|
||||
to avoid approval gates and isolate the looping behavior.
|
||||
"""
|
||||
import time
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.tools import list_files, get_system_info, internet_search
|
||||
|
||||
# Simpler task using only low-risk tools
|
||||
TASK = (
|
||||
"Do these 3 things: "
|
||||
"1) Search the web for 'Python 3.13 new features', "
|
||||
"2) Get system info to identify the current OS, "
|
||||
"3) List files in /tmp. "
|
||||
"After completing all 3 tasks, summarize the results."
|
||||
)
|
||||
|
||||
TOOLS = [list_files, get_system_info, internet_search]
|
||||
|
||||
|
||||
def run_test(name, agent, task):
|
||||
"""Run a test and print results."""
|
||||
print(f"\n{'='*70}")
|
||||
print(f"TEST: {name}")
|
||||
print(f"{'='*70}")
|
||||
|
||||
start = time.time()
|
||||
result = agent.start(task)
|
||||
elapsed = time.time() - start
|
||||
|
||||
if hasattr(result, 'output'):
|
||||
# AutonomyResult
|
||||
print(f"\n--- {name} RESULTS ---")
|
||||
print(f"Duration: {elapsed:.1f}s")
|
||||
print(f"Completion reason: {result.completion_reason}")
|
||||
print(f"Iterations: {result.iterations}")
|
||||
print(f"Response length: {len(result.output or '')} chars")
|
||||
print(f"Success: {result.success}")
|
||||
out = result.output or ""
|
||||
else:
|
||||
out = str(result) if result else ""
|
||||
print(f"\n--- {name} RESULTS ---")
|
||||
print(f"Duration: {elapsed:.1f}s")
|
||||
print(f"Response length: {len(out)} chars")
|
||||
|
||||
# Show first 600 chars
|
||||
print(f"\nFirst 600 chars of response:")
|
||||
print(out[:600])
|
||||
return elapsed, result
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("test", choices=["wrapper", "iterative", "caller", "all"], default="all", nargs="?")
|
||||
args = parser.parse_args()
|
||||
|
||||
results = {}
|
||||
|
||||
if args.test in ("wrapper", "all"):
|
||||
agent = Agent(
|
||||
name="WrapperTest",
|
||||
instructions="You are a helpful AI assistant. Complete the task and stop.",
|
||||
tools=TOOLS,
|
||||
)
|
||||
results["wrapper"] = run_test("WRAPPER (no autonomy)", agent, TASK)
|
||||
|
||||
if args.test in ("caller", "all"):
|
||||
agent = Agent(
|
||||
name="CallerTest",
|
||||
instructions="You are a helpful AI assistant. Complete the task and stop.",
|
||||
tools=TOOLS,
|
||||
autonomy={"mode": "caller"},
|
||||
)
|
||||
results["caller"] = run_test("CALLER (autonomy=caller)", agent, TASK)
|
||||
|
||||
if args.test in ("iterative", "all"):
|
||||
agent = Agent(
|
||||
name="IterativeTest",
|
||||
instructions="You are a helpful AI assistant. Complete the task and stop.",
|
||||
tools=TOOLS,
|
||||
autonomy={
|
||||
"mode": "iterative",
|
||||
"max_iterations": 5,
|
||||
"clear_context": False,
|
||||
},
|
||||
)
|
||||
results["iterative"] = run_test("ITERATIVE (autonomy=iterative, max=5)", agent, TASK)
|
||||
|
||||
# Summary
|
||||
if len(results) > 1:
|
||||
print(f"\n{'='*70}")
|
||||
print("COMPARISON SUMMARY")
|
||||
print(f"{'='*70}")
|
||||
for name, (elapsed, result) in results.items():
|
||||
if hasattr(result, 'output'):
|
||||
print(f" {name:15s}: {elapsed:6.1f}s | reason={result.completion_reason} | iters={result.iterations} | len={len(result.output or '')}")
|
||||
else:
|
||||
print(f" {name:15s}: {elapsed:6.1f}s | len={len(str(result))}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,464 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Deep performance profiling integration test.
|
||||
|
||||
Runs cProfile-wrapped Agent prompts and stores full function-level
|
||||
breakdown as JSONL for regression tracking. Each run appends a
|
||||
timestamped record so you can compare performance over time.
|
||||
|
||||
Usage:
|
||||
# Run with pytest (records stored automatically)
|
||||
pytest tests/integration/test_performance_profile.py -v -s
|
||||
|
||||
# Run directly (more detailed output)
|
||||
python tests/integration/test_performance_profile.py
|
||||
|
||||
# Compare recent results
|
||||
python tests/integration/test_performance_profile.py --compare
|
||||
|
||||
# Profile a specific model
|
||||
python tests/integration/test_performance_profile.py --model gpt-5-nano
|
||||
|
||||
# Compare two models
|
||||
PROFILE_MODELS=gpt-5-nano,gpt-4o-mini python tests/integration/test_performance_profile.py
|
||||
|
||||
Results are stored in:
|
||||
tests/integration/profile_results/performance_history.jsonl
|
||||
"""
|
||||
|
||||
import cProfile
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import pstats
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from statistics import mean, median, stdev
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
RESULTS_DIR = Path(__file__).parent / "profile_results"
|
||||
HISTORY_FILE = RESULTS_DIR / "performance_history.jsonl"
|
||||
|
||||
# Default model — override via PROFILE_MODEL env var
|
||||
DEFAULT_MODEL = os.environ.get("PROFILE_MODEL", "gpt-4o-mini")
|
||||
|
||||
# Number of top functions to record
|
||||
TOP_FUNCTIONS = 30
|
||||
|
||||
# Prompts
|
||||
SIMPLE_PROMPT = "What is 2+2? Reply with just the number."
|
||||
COMPLEX_PROMPT = (
|
||||
"List the top 3 programming languages by popularity in 2024. "
|
||||
"For each, give the name and one key strength. Keep it under 100 words."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _ensure_results_dir():
|
||||
"""Create results directory if it doesn't exist."""
|
||||
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
gitignore = RESULTS_DIR / ".gitignore"
|
||||
if not gitignore.exists():
|
||||
gitignore.write_text("# Auto-generated profile results\n*.jsonl\n*.txt\n")
|
||||
|
||||
|
||||
def _get_version() -> str:
|
||||
"""Get the current praisonaiagents version."""
|
||||
try:
|
||||
from praisonaiagents import __version__
|
||||
return __version__
|
||||
except (ImportError, AttributeError):
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _extract_function_stats(profiler: cProfile.Profile, top_n: int = TOP_FUNCTIONS) -> List[Dict]:
|
||||
"""Extract top function stats from a cProfile.Profile instance."""
|
||||
stats = pstats.Stats(profiler, stream=StringIO())
|
||||
stats.sort_stats("cumulative")
|
||||
|
||||
function_stats = []
|
||||
for (filename, line, name), (cc, nc, tt, ct, callers) in stats.stats.items():
|
||||
function_stats.append({
|
||||
"name": name,
|
||||
"file": os.path.basename(filename) if filename else "",
|
||||
"line": line,
|
||||
"calls": nc,
|
||||
"total_ms": round(tt * 1000, 2),
|
||||
"cumulative_ms": round(ct * 1000, 2),
|
||||
})
|
||||
|
||||
function_stats.sort(key=lambda x: x["cumulative_ms"], reverse=True)
|
||||
return function_stats[:top_n]
|
||||
|
||||
|
||||
def _extract_category_breakdown(function_stats: List[Dict]) -> Dict[str, float]:
|
||||
"""Categorize function time into buckets: network, imports, framework, other."""
|
||||
categories = {
|
||||
"network_ms": 0.0,
|
||||
"imports_ms": 0.0,
|
||||
"framework_ms": 0.0,
|
||||
"other_ms": 0.0,
|
||||
}
|
||||
|
||||
seen = set()
|
||||
for fn in function_stats:
|
||||
key = (fn["name"], fn["file"])
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
|
||||
name = fn["name"]
|
||||
file = fn["file"]
|
||||
cum = fn["cumulative_ms"]
|
||||
|
||||
# Network: SSL, socket, recv, send
|
||||
if name in ("recv", "send", "connect") or "ssl" in file.lower() or "_socket" in file:
|
||||
categories["network_ms"] = max(categories["network_ms"], cum)
|
||||
# Imports
|
||||
elif name in ("_find_and_load", "_find_and_load_unlocked", "_load_unlocked",
|
||||
"exec_module", "_call_with_frames_removed") or "importlib" in file:
|
||||
categories["imports_ms"] = max(categories["imports_ms"], cum)
|
||||
# Framework
|
||||
elif "praisonai" in file.lower() or name in ("chat", "get_response", "start",
|
||||
"_chat_impl", "create_completion"):
|
||||
categories["framework_ms"] = max(categories["framework_ms"], cum)
|
||||
|
||||
return categories
|
||||
|
||||
|
||||
def _run_deep_profile(
|
||||
prompt: str,
|
||||
model: str = DEFAULT_MODEL,
|
||||
runs: int = 3,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Run a cProfile-wrapped Agent execution (deep profile).
|
||||
|
||||
Captures:
|
||||
- Wall-clock timing for agent init and execution phases
|
||||
- cProfile function-level stats (top N by cumulative time)
|
||||
- Category breakdown (network, imports, framework)
|
||||
"""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
all_runs = []
|
||||
|
||||
for i in range(runs):
|
||||
# --- Phase 1: Agent init (timed but not cProfiled) ---
|
||||
init_start = time.perf_counter()
|
||||
agent = Agent(
|
||||
instructions="Be concise.",
|
||||
llm={"model": model},
|
||||
output="silent",
|
||||
)
|
||||
init_ms = (time.perf_counter() - init_start) * 1000
|
||||
|
||||
# --- Phase 2: Execution with cProfile deep profiling ---
|
||||
profiler = cProfile.Profile()
|
||||
exec_start = time.perf_counter()
|
||||
|
||||
profiler.enable()
|
||||
try:
|
||||
result = agent.start(prompt)
|
||||
finally:
|
||||
profiler.disable()
|
||||
|
||||
exec_ms = (time.perf_counter() - exec_start) * 1000
|
||||
total_ms = init_ms + exec_ms
|
||||
|
||||
# --- Extract deep profile data ---
|
||||
top_functions = _extract_function_stats(profiler)
|
||||
categories = _extract_category_breakdown(top_functions)
|
||||
|
||||
all_runs.append({
|
||||
"run": i + 1,
|
||||
"init_ms": round(init_ms, 2),
|
||||
"execution_ms": round(exec_ms, 2),
|
||||
"total_ms": round(total_ms, 2),
|
||||
"response_length": len(str(result)) if result else 0,
|
||||
"top_functions": top_functions,
|
||||
"categories": categories,
|
||||
})
|
||||
|
||||
# --- Aggregate ---
|
||||
exec_times = [r["execution_ms"] for r in all_runs]
|
||||
init_times = [r["init_ms"] for r in all_runs]
|
||||
total_times = [r["total_ms"] for r in all_runs]
|
||||
|
||||
# Average the category breakdowns
|
||||
avg_categories = {}
|
||||
for key in ["network_ms", "imports_ms", "framework_ms"]:
|
||||
vals = [r["categories"].get(key, 0) for r in all_runs]
|
||||
avg_categories[key] = round(mean(vals), 2)
|
||||
|
||||
return {
|
||||
"prompt_label": prompt[:60] + "..." if len(prompt) > 60 else prompt,
|
||||
"model": model,
|
||||
"runs": runs,
|
||||
"profile_type": "deep",
|
||||
"individual_runs": all_runs,
|
||||
"execution_ms": {
|
||||
"mean": round(mean(exec_times), 2),
|
||||
"median": round(median(exec_times), 2),
|
||||
"stdev": round(stdev(exec_times), 2) if len(exec_times) > 1 else 0,
|
||||
"min": round(min(exec_times), 2),
|
||||
"max": round(max(exec_times), 2),
|
||||
},
|
||||
"init_ms": {
|
||||
"mean": round(mean(init_times), 2),
|
||||
"median": round(median(init_times), 2),
|
||||
},
|
||||
"total_ms": {
|
||||
"mean": round(mean(total_times), 2),
|
||||
"median": round(median(total_times), 2),
|
||||
},
|
||||
"avg_categories": avg_categories,
|
||||
# Store the top functions from the last run for display
|
||||
"top_functions_snapshot": all_runs[-1]["top_functions"][:20],
|
||||
}
|
||||
|
||||
|
||||
def _save_result(record: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Append a profiling record to the JSONL history file."""
|
||||
_ensure_results_dir()
|
||||
|
||||
record["timestamp"] = datetime.now(timezone.utc).isoformat()
|
||||
record["version"] = _get_version()
|
||||
record["python"] = platform.python_version()
|
||||
record["platform"] = platform.system()
|
||||
record["machine"] = platform.machine()
|
||||
|
||||
with open(HISTORY_FILE, "a") as f:
|
||||
f.write(json.dumps(record, default=str) + "\n")
|
||||
|
||||
return record
|
||||
|
||||
|
||||
def _load_history() -> List[Dict[str, Any]]:
|
||||
"""Load all historical profiling records."""
|
||||
if not HISTORY_FILE.exists():
|
||||
return []
|
||||
records = []
|
||||
for line in HISTORY_FILE.read_text().strip().split("\n"):
|
||||
if line.strip():
|
||||
try:
|
||||
records.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return records
|
||||
|
||||
|
||||
def _print_deep_report(label: str, result: Dict, history: List[Dict], model: str):
|
||||
"""Print a deep profile report with function-level breakdown."""
|
||||
# Filter matching history
|
||||
matching = [
|
||||
r for r in history
|
||||
if r.get("model") == model
|
||||
and r.get("prompt_label") == result.get("prompt_label")
|
||||
and r.get("profile_type") == "deep"
|
||||
]
|
||||
|
||||
print(f"\n{'=' * 70}")
|
||||
print(f" DEEP PROFILE: {label}")
|
||||
print(f"{'=' * 70}")
|
||||
print(f" Model: {model}")
|
||||
print(f" Runs: {result['runs']}")
|
||||
print(f" Exec: {result['execution_ms']['mean']:.0f}ms "
|
||||
f"(±{result['execution_ms']['stdev']:.0f}ms)")
|
||||
print(f" Init: {result['init_ms']['mean']:.1f}ms")
|
||||
print(f" Total: {result['total_ms']['mean']:.0f}ms")
|
||||
|
||||
# Category breakdown
|
||||
cats = result.get("avg_categories", {})
|
||||
print(f"\n Category Breakdown:")
|
||||
print(f" Network (SSL/recv): {cats.get('network_ms', 0):.0f}ms")
|
||||
print(f" Imports: {cats.get('imports_ms', 0):.0f}ms")
|
||||
print(f" Framework: {cats.get('framework_ms', 0):.0f}ms")
|
||||
|
||||
# Top functions
|
||||
top_fns = result.get("top_functions_snapshot", [])
|
||||
if top_fns:
|
||||
print(f"\n Top Functions by Cumulative Time:")
|
||||
print(f" {'Function':<45} {'Calls':>6} {'Cumul (ms)':>12}")
|
||||
print(f" {'-' * 65}")
|
||||
for fn in top_fns[:15]:
|
||||
name = fn["name"][:44]
|
||||
print(f" {name:<45} {fn['calls']:>6} {fn['cumulative_ms']:>11.2f}")
|
||||
|
||||
# Historical comparison
|
||||
if matching:
|
||||
prev = matching[-1]
|
||||
prev_mean = prev["execution_ms"]["mean"]
|
||||
curr_mean = result["execution_ms"]["mean"]
|
||||
delta = curr_mean - prev_mean
|
||||
pct = (delta / prev_mean * 100) if prev_mean > 0 else 0
|
||||
|
||||
emoji = "🟢" if delta <= 0 else ("🔴" if pct > 20 else "🟡")
|
||||
print(f"\n {emoji} vs previous ({prev.get('timestamp', '?')[:10]}):")
|
||||
print(f" Previous: {prev_mean:.0f}ms → Current: {curr_mean:.0f}ms "
|
||||
f"({delta:+.0f}ms, {pct:+.1f}%)")
|
||||
|
||||
if len(matching) >= 3:
|
||||
all_means = [r["execution_ms"]["mean"] for r in matching[-10:]]
|
||||
print(f"\n 📊 Last {len(all_means)} runs: "
|
||||
f"avg={mean(all_means):.0f}ms, "
|
||||
f"range={min(all_means):.0f}-{max(all_means):.0f}ms")
|
||||
else:
|
||||
print(f"\n ℹ️ First deep profile — no history to compare")
|
||||
|
||||
print()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pytest Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not os.environ.get("OPENAI_API_KEY"),
|
||||
reason="OPENAI_API_KEY required for live profiling"
|
||||
)
|
||||
class TestDeepPerformanceProfile:
|
||||
"""Live deep performance profiling tests with result storage."""
|
||||
|
||||
def test_simple_prompt_deep_profile(self):
|
||||
"""Deep profile a simple prompt and store results."""
|
||||
model = DEFAULT_MODEL
|
||||
result = _run_deep_profile(SIMPLE_PROMPT, model=model, runs=3)
|
||||
_save_result({"test": "simple_prompt", **result})
|
||||
|
||||
history = _load_history()
|
||||
_print_deep_report("Simple Prompt", result, history, model)
|
||||
|
||||
# Sanity checks
|
||||
assert result["execution_ms"]["mean"] > 0, "Execution should take time"
|
||||
assert result["init_ms"]["mean"] < 2000, "Init should be under 2s"
|
||||
assert result.get("top_functions_snapshot"), "Should have function stats"
|
||||
assert all(r["response_length"] > 0 for r in result["individual_runs"]), \
|
||||
"All runs should produce output"
|
||||
|
||||
def test_complex_prompt_deep_profile(self):
|
||||
"""Deep profile a complex prompt and store results."""
|
||||
model = DEFAULT_MODEL
|
||||
result = _run_deep_profile(COMPLEX_PROMPT, model=model, runs=3)
|
||||
_save_result({"test": "complex_prompt", **result})
|
||||
|
||||
history = _load_history()
|
||||
_print_deep_report("Complex Prompt", result, history, model)
|
||||
|
||||
assert result["execution_ms"]["mean"] > 0
|
||||
assert result.get("top_functions_snapshot")
|
||||
|
||||
def test_multi_model_deep_comparison(self):
|
||||
"""Deep profile across multiple models (set PROFILE_MODELS env var)."""
|
||||
models = os.environ.get("PROFILE_MODELS", DEFAULT_MODEL).split(",")
|
||||
models = [m.strip() for m in models if m.strip()]
|
||||
|
||||
for model in models:
|
||||
result = _run_deep_profile(SIMPLE_PROMPT, model=model, runs=2)
|
||||
_save_result({"test": "model_comparison", **result})
|
||||
|
||||
history = _load_history()
|
||||
_print_deep_report(f"Model: {model}", result, history, model)
|
||||
|
||||
assert result["execution_ms"]["mean"] > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI: Direct execution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
"""Run deep profiles directly and show comparison."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Deep performance profiling tool")
|
||||
parser.add_argument("--compare", action="store_true",
|
||||
help="Show comparison of historical results only")
|
||||
parser.add_argument("--model", default=DEFAULT_MODEL,
|
||||
help=f"Model to profile (default: {DEFAULT_MODEL})")
|
||||
parser.add_argument("--runs", type=int, default=3,
|
||||
help="Number of runs per prompt (default: 3)")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.compare:
|
||||
history = _load_history()
|
||||
if not history:
|
||||
print("No history found. Run profiles first.")
|
||||
return
|
||||
|
||||
# Group by test + model
|
||||
groups: Dict[str, List] = {}
|
||||
for r in history:
|
||||
if r.get("profile_type") != "deep":
|
||||
continue
|
||||
key = f"{r.get('test', 'unknown')}|{r.get('model', 'unknown')}"
|
||||
groups.setdefault(key, []).append(r)
|
||||
|
||||
for key, records in groups.items():
|
||||
test_name, model = key.split("|")
|
||||
print(f"\n{'=' * 70}")
|
||||
print(f" {test_name} ({model}) — Deep Profile History")
|
||||
print(f"{'=' * 70}")
|
||||
print(f" {'Date':<22} {'Exec':>8} {'Init':>8} "
|
||||
f"{'Network':>10} {'Imports':>10} Ver")
|
||||
print(f" {'─' * 70}")
|
||||
|
||||
for r in records[-10:]:
|
||||
ts = r.get("timestamp", "")[:19]
|
||||
ex = r.get("execution_ms", {})
|
||||
cats = r.get("avg_categories", {})
|
||||
ver = r.get("version", "?")
|
||||
print(f" {ts:<22} {ex.get('mean', 0):>7.0f}ms"
|
||||
f" {r.get('init_ms', {}).get('mean', 0):>7.1f}ms"
|
||||
f" {cats.get('network_ms', 0):>9.0f}ms"
|
||||
f" {cats.get('imports_ms', 0):>9.0f}ms"
|
||||
f" {ver}")
|
||||
|
||||
# Trend
|
||||
if len(records) >= 2:
|
||||
first_mean = records[0]["execution_ms"]["mean"]
|
||||
last_mean = records[-1]["execution_ms"]["mean"]
|
||||
delta = last_mean - first_mean
|
||||
pct = (delta / first_mean * 100) if first_mean > 0 else 0
|
||||
emoji = "📈" if delta > 0 else "📉"
|
||||
print(f"\n {emoji} Trend: {delta:+.0f}ms ({pct:+.1f}%) "
|
||||
f"over {len(records)} runs")
|
||||
return
|
||||
|
||||
# Run deep profiles
|
||||
print("=" * 70)
|
||||
print(" DEEP PERFORMANCE PROFILING")
|
||||
print(f" Model: {args.model} | Runs: {args.runs} | cProfile: ON")
|
||||
print("=" * 70)
|
||||
|
||||
prompts = {
|
||||
"simple_prompt": SIMPLE_PROMPT,
|
||||
"complex_prompt": COMPLEX_PROMPT,
|
||||
}
|
||||
|
||||
for test_name, prompt in prompts.items():
|
||||
print(f"\n▸ Running deep profile: {test_name}...")
|
||||
result = _run_deep_profile(prompt, model=args.model, runs=args.runs)
|
||||
_save_result({"test": test_name, **result})
|
||||
|
||||
history = _load_history()
|
||||
_print_deep_report(test_name.replace("_", " ").title(), result, history, args.model)
|
||||
|
||||
print(f"\n✅ Deep profile results stored in: {HISTORY_FILE}")
|
||||
print(" Run with --compare to see trends over time.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -57,7 +57,6 @@ class TestAgentRealAPI:
|
||||
name="TestAgent",
|
||||
instructions="You are a helpful assistant. Keep responses very brief.",
|
||||
llm="gpt-4o-mini",
|
||||
verbose=False
|
||||
)
|
||||
|
||||
response = agent.chat("Say 'hello' and nothing else.")
|
||||
@@ -82,7 +81,6 @@ class TestAgentRealAPI:
|
||||
instructions="You are a math assistant. Use the add_numbers tool when asked to add.",
|
||||
llm="gpt-4o-mini",
|
||||
tools=[add_numbers],
|
||||
verbose=False
|
||||
)
|
||||
|
||||
response = agent.chat("What is 5 + 3? Use the add_numbers tool.")
|
||||
@@ -100,7 +98,6 @@ class TestAgentRealAPI:
|
||||
name="HistoryAgent",
|
||||
instructions="You are a helpful assistant. Remember what the user tells you.",
|
||||
llm="gpt-4o-mini",
|
||||
verbose=False
|
||||
)
|
||||
|
||||
# First message
|
||||
@@ -189,7 +186,6 @@ class TestAnthropicAPI:
|
||||
name="ClaudeAgent",
|
||||
instructions="You are a helpful assistant. Keep responses very brief.",
|
||||
llm="claude-3-5-sonnet-20241022",
|
||||
verbose=False
|
||||
)
|
||||
|
||||
response = agent.chat("Say 'hello' and nothing else.")
|
||||
@@ -223,7 +219,6 @@ class TestGoogleAPI:
|
||||
name="GeminiAgent",
|
||||
instructions="You are a helpful assistant. Keep responses very brief.",
|
||||
llm="gemini/gemini-2.0-flash",
|
||||
verbose=False
|
||||
)
|
||||
|
||||
response = agent.chat("Say 'hello' and nothing else.")
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
"""
|
||||
Integration tests for the OpenAI Responses API implementation.
|
||||
|
||||
These tests verify that the Responses API is correctly wired into the LLM class
|
||||
and that agents work end-to-end with both the Responses API path (Path P1) and
|
||||
the Chat Completions path (P2 / fallback).
|
||||
|
||||
Usage:
|
||||
RUN_REAL_KEY_TESTS=1 pytest tests/integration/test_responses_api.py -v -s
|
||||
|
||||
Required environment variables:
|
||||
- OPENAI_API_KEY: OpenAI API key
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import pytest
|
||||
|
||||
# Note: Only real API tests are gated by RUN_REAL_KEY_TESTS.
|
||||
# Unit-level tests (model detection, param building, extraction) always run.
|
||||
_skip_real = pytest.mark.skipif(
|
||||
os.environ.get('RUN_REAL_KEY_TESTS', '').lower() not in ('1', 'true', 'yes'),
|
||||
reason="Real API key tests disabled. Set RUN_REAL_KEY_TESTS=1 to enable."
|
||||
)
|
||||
|
||||
|
||||
def clear_modules():
|
||||
"""Clear all praisonai and litellm related modules from cache."""
|
||||
to_remove = [m for m in list(sys.modules.keys())
|
||||
if 'praison' in m or 'litellm' in m]
|
||||
for mod in to_remove:
|
||||
del sys.modules[mod]
|
||||
|
||||
|
||||
# ── Unit-level checks (no API key needed for these) ────────────────────
|
||||
|
||||
class TestResponsesAPIModelDetection:
|
||||
"""Verify _supports_responses_api() returns correct values."""
|
||||
|
||||
def test_openai_models_detected(self):
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
|
||||
positive_models = [
|
||||
"gpt-4o", "gpt-4o-mini", "gpt-4o-2024-08-06",
|
||||
"gpt-4-turbo", "gpt-4-turbo-preview",
|
||||
"gpt-4.1", "gpt-4.5-preview",
|
||||
"o1-preview", "o1-mini", "o3-mini", "o4-mini",
|
||||
"chatgpt-4o-latest",
|
||||
"azure/gpt-4o", "azure/o3-mini",
|
||||
"openai/gpt-4o",
|
||||
]
|
||||
for model in positive_models:
|
||||
llm = LLM(model=model)
|
||||
assert llm._supports_responses_api(), f"{model} should support Responses API"
|
||||
|
||||
def test_non_openai_models_rejected(self):
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
|
||||
negative_models = [
|
||||
"claude-3-opus-20240229", "claude-3-5-sonnet-20241022",
|
||||
"anthropic/claude-3-haiku",
|
||||
"ollama/llama3",
|
||||
"gemini/gemini-pro", "gemini/gemini-1.5-flash",
|
||||
"groq/llama-3.1-70b-versatile",
|
||||
"together_ai/mistral-7b",
|
||||
"deepseek/deepseek-chat",
|
||||
"gpt-3.5-turbo", # Too old for Responses API
|
||||
]
|
||||
for model in negative_models:
|
||||
llm = LLM(model=model)
|
||||
assert not llm._supports_responses_api(), f"{model} should NOT support Responses API"
|
||||
|
||||
|
||||
class TestResponsesAPIParamBuilder:
|
||||
"""Verify _build_responses_params() correctly translates Chat Completions messages."""
|
||||
|
||||
def test_system_to_instructions(self):
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
|
||||
llm = LLM(model="gpt-4o")
|
||||
messages = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Hello!"},
|
||||
]
|
||||
params = llm._build_responses_params(messages=messages, temperature=0.7)
|
||||
|
||||
assert params["model"] == "gpt-4o"
|
||||
assert params["instructions"] == "You are a helpful assistant."
|
||||
assert len(params["input"]) == 1
|
||||
assert params["input"][0]["role"] == "user"
|
||||
assert params["temperature"] == 0.7
|
||||
|
||||
def test_tools_transformed_to_responses_format(self):
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
|
||||
llm = LLM(model="gpt-4o")
|
||||
# Chat Completions format: nested under "function"
|
||||
tools = [{"type": "function", "function": {"name": "add", "description": "Add nums", "parameters": {}}}]
|
||||
params = llm._build_responses_params(messages=[{"role": "user", "content": "hi"}], tools=tools)
|
||||
|
||||
# Should be transformed to Responses API format: flattened
|
||||
assert params["tools"] == [{"type": "function", "name": "add", "description": "Add nums", "parameters": {}}]
|
||||
assert params["tool_choice"] == "auto"
|
||||
|
||||
|
||||
class TestResponsesAPIOutputExtraction:
|
||||
"""Verify _extract_from_responses_output() correctly parses output items."""
|
||||
|
||||
def _make_mock_response(self, output_items):
|
||||
class MockResponse:
|
||||
def __init__(self, output):
|
||||
self.output = output
|
||||
return MockResponse(output_items)
|
||||
|
||||
def test_text_only(self):
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
|
||||
llm = LLM(model="gpt-4o")
|
||||
resp = self._make_mock_response([
|
||||
{"type": "message", "content": [{"type": "output_text", "text": "Hello!"}]}
|
||||
])
|
||||
text, tool_calls, reasoning = llm._extract_from_responses_output(resp)
|
||||
|
||||
assert text == "Hello!"
|
||||
assert tool_calls is None
|
||||
assert reasoning is None
|
||||
|
||||
def test_text_plus_tool_calls(self):
|
||||
"""The key test: text and tool calls coexist without content:null."""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
|
||||
llm = LLM(model="gpt-4o")
|
||||
resp = self._make_mock_response([
|
||||
{"type": "message", "content": [{"type": "output_text", "text": "Let me calculate."}]},
|
||||
{"type": "function_call", "call_id": "call_123", "name": "add", "arguments": '{"a":2,"b":2}'},
|
||||
])
|
||||
text, tool_calls, reasoning = llm._extract_from_responses_output(resp)
|
||||
|
||||
assert text == "Let me calculate."
|
||||
assert len(tool_calls) == 1
|
||||
assert tool_calls[0]["function"]["name"] == "add"
|
||||
assert tool_calls[0]["id"] == "call_123"
|
||||
|
||||
def test_multiple_tool_calls(self):
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
|
||||
llm = LLM(model="gpt-4o")
|
||||
resp = self._make_mock_response([
|
||||
{"type": "function_call", "call_id": "c1", "name": "search", "arguments": '{"q":"a"}'},
|
||||
{"type": "function_call", "call_id": "c2", "name": "fetch", "arguments": '{"url":"b"}'},
|
||||
])
|
||||
text, tool_calls, _ = llm._extract_from_responses_output(resp)
|
||||
|
||||
assert text == ""
|
||||
assert len(tool_calls) == 2
|
||||
|
||||
|
||||
# ── Real API integration tests ─────────────────────────────────────────
|
||||
|
||||
@_skip_real
|
||||
class TestResponsesAPIRealAgent:
|
||||
"""End-to-end agent tests using real OpenAI API calls."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self):
|
||||
clear_modules()
|
||||
api_key = os.environ.get('OPENAI_API_KEY')
|
||||
if not api_key:
|
||||
pytest.skip("OPENAI_API_KEY not set")
|
||||
yield
|
||||
clear_modules()
|
||||
|
||||
def test_responses_api_path_with_slash_model(self):
|
||||
"""
|
||||
Test with 'openai/gpt-4o-mini' which routes through LLM class (Path P1)
|
||||
and uses the Responses API.
|
||||
"""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="ResponsesAPIAgent",
|
||||
instructions="You are a helpful assistant. Keep responses brief.",
|
||||
llm="openai/gpt-4o-mini",
|
||||
)
|
||||
response = agent.start(
|
||||
"Research the top 3 Python web frameworks (Django, FastAPI, Flask), then: "
|
||||
"1) Search the web for each framework's latest version and key features, "
|
||||
"2) Create a file called /tmp/framework_comparison.py that contains a Python dictionary with the comparison data, "
|
||||
"3) Execute the code to verify the dictionary is valid, "
|
||||
"4) Write a markdown report to /tmp/framework_report.md summarizing your findings in a table format, "
|
||||
"5) Read back the report file to verify it was written correctly, "
|
||||
"6) List the files in /tmp to confirm both files exist, "
|
||||
"7) Get system info to note what OS this report was generated on, "
|
||||
"8) Analyze the Python code you wrote for any issues, "
|
||||
"9) Format the Python code properly, "
|
||||
"10) Finally search for any recent news about Python 3.13 features"
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert len(response) > 100, f"Response too short ({len(response)} chars)"
|
||||
print(f"[PASS] openai/gpt-4o-mini - {len(response)} chars")
|
||||
|
||||
def test_chat_completions_path_with_bare_model(self):
|
||||
"""
|
||||
Test with 'gpt-4o-mini' which routes through OpenAIClient (Path P2)
|
||||
and uses the standard Chat Completions API.
|
||||
"""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="ChatCompletionsAgent",
|
||||
instructions="You are a helpful assistant. Keep responses brief.",
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
response = agent.start(
|
||||
"Research the top 3 Python web frameworks (Django, FastAPI, Flask), then: "
|
||||
"1) Search the web for each framework's latest version and key features, "
|
||||
"2) Create a file called /tmp/framework_comparison.py that contains a Python dictionary with the comparison data, "
|
||||
"3) Execute the code to verify the dictionary is valid, "
|
||||
"4) Write a markdown report to /tmp/framework_report.md summarizing your findings in a table format, "
|
||||
"5) Read back the report file to verify it was written correctly, "
|
||||
"6) List the files in /tmp to confirm both files exist, "
|
||||
"7) Get system info to note what OS this report was generated on, "
|
||||
"8) Analyze the Python code you wrote for any issues, "
|
||||
"9) Format the Python code properly, "
|
||||
"10) Finally search for any recent news about Python 3.13 features"
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert len(response) > 100, f"Response too short ({len(response)} chars)"
|
||||
print(f"[PASS] gpt-4o-mini - {len(response)} chars")
|
||||
|
||||
def test_responses_api_with_tool(self):
|
||||
"""
|
||||
Test that Responses API correctly handles tool calls — the key
|
||||
scenario where content:null was previously a problem.
|
||||
"""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
def add_numbers(a: int, b: int) -> int:
|
||||
"""Add two numbers together."""
|
||||
return a + b
|
||||
|
||||
agent = Agent(
|
||||
name="ToolAgent",
|
||||
instructions="You are a math assistant. Use the add_numbers tool.",
|
||||
llm="openai/gpt-4o-mini",
|
||||
tools=[add_numbers],
|
||||
)
|
||||
response = agent.chat("What is 15 + 27? Use the tool.")
|
||||
|
||||
assert response is not None
|
||||
assert "42" in response, f"Expected '42' in response, got: {response[:200]}"
|
||||
print(f"[PASS] Tool call via Responses API (Path P1) - response contains '42'")
|
||||
|
||||
def test_openai_client_path_with_tool(self):
|
||||
"""
|
||||
Test bare model (gpt-4o-mini) with tool via OpenAIClient (Path P2) —
|
||||
now using Responses API. This is the key test for the content:null fix
|
||||
on the OpenAIClient path.
|
||||
"""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
def add_numbers(a: int, b: int) -> int:
|
||||
"""Add two numbers together."""
|
||||
return a + b
|
||||
|
||||
agent = Agent(
|
||||
name="ToolAgentP2",
|
||||
instructions="You are a math assistant. Use the add_numbers tool.",
|
||||
model="gpt-4o-mini", # bare model → OpenAIClient path
|
||||
tools=[add_numbers],
|
||||
)
|
||||
response = agent.chat("What is 15 + 27? Use the tool.")
|
||||
|
||||
assert response is not None
|
||||
assert "42" in response, f"Expected '42' in response, got: {response[:200]}"
|
||||
print(f"[PASS] Tool call via OpenAIClient Responses API (Path P2) - response contains '42'")
|
||||
|
||||
|
||||
# ── OpenAIClient-specific unit tests ───────────────────────────────────
|
||||
|
||||
class TestOpenAIClientResponsesAPI:
|
||||
"""Verify OpenAIClient Responses API helper methods."""
|
||||
|
||||
def test_supports_responses_api_static(self):
|
||||
from praisonaiagents.llm.openai_client import OpenAIClient
|
||||
|
||||
# Positive cases
|
||||
assert OpenAIClient._supports_responses_api("gpt-4o-mini")
|
||||
assert OpenAIClient._supports_responses_api("gpt-4o")
|
||||
assert OpenAIClient._supports_responses_api("gpt-4-turbo")
|
||||
assert OpenAIClient._supports_responses_api("o3-mini")
|
||||
assert OpenAIClient._supports_responses_api("gpt-4.1")
|
||||
assert OpenAIClient._supports_responses_api("chatgpt-4o-latest")
|
||||
|
||||
# Negative cases
|
||||
assert not OpenAIClient._supports_responses_api("claude-3-opus")
|
||||
assert not OpenAIClient._supports_responses_api("ollama/llama3")
|
||||
assert not OpenAIClient._supports_responses_api("gemini/gemini-pro")
|
||||
assert not OpenAIClient._supports_responses_api("deepseek/deepseek-chat")
|
||||
assert not OpenAIClient._supports_responses_api("")
|
||||
|
||||
def test_build_responses_input(self):
|
||||
from praisonaiagents.llm.openai_client import OpenAIClient
|
||||
|
||||
client = OpenAIClient.__new__(OpenAIClient) # skip __init__
|
||||
messages = [
|
||||
{"role": "system", "content": "You are helpful."},
|
||||
{"role": "user", "content": "Hi"},
|
||||
]
|
||||
tools = [{"type": "function", "function": {"name": "add", "description": "Add", "parameters": {}}}]
|
||||
|
||||
params = client._build_responses_input(messages, "gpt-4o-mini", 0.5, tools)
|
||||
|
||||
assert params["model"] == "gpt-4o-mini"
|
||||
assert params["instructions"] == "You are helpful."
|
||||
assert len(params["input"]) == 1 # only user msg
|
||||
assert params["input"][0]["role"] == "user"
|
||||
assert params["temperature"] == 0.5
|
||||
# Tools should be in Responses API format (flattened)
|
||||
assert params["tools"] == [{"type": "function", "name": "add", "description": "Add", "parameters": {}}]
|
||||
|
||||
def test_responses_to_chat_completion(self):
|
||||
from praisonaiagents.llm.openai_client import OpenAIClient
|
||||
|
||||
client = OpenAIClient.__new__(OpenAIClient)
|
||||
|
||||
class MockItem:
|
||||
def __init__(self, **kwargs):
|
||||
for k, v in kwargs.items():
|
||||
setattr(self, k, v)
|
||||
|
||||
class MockResponse:
|
||||
id = "resp_test"
|
||||
model = "gpt-4o-mini"
|
||||
output = [
|
||||
MockItem(
|
||||
type="message",
|
||||
content=[MockItem(type="output_text", text="Hello!")]
|
||||
),
|
||||
MockItem(
|
||||
type="function_call",
|
||||
call_id="call_1",
|
||||
name="add",
|
||||
arguments='{"a":1,"b":2}'
|
||||
),
|
||||
]
|
||||
usage = MockItem(input_tokens=10, output_tokens=5, total_tokens=15)
|
||||
|
||||
result = client._responses_to_chat_completion(MockResponse())
|
||||
|
||||
assert result.id == "resp_test"
|
||||
assert result.choices[0].message.content == "Hello!"
|
||||
assert result.choices[0].message.tool_calls is not None
|
||||
assert len(result.choices[0].message.tool_calls) == 1
|
||||
assert result.choices[0].message.tool_calls[0].function["name"] == "add"
|
||||
assert result.choices[0].finish_reason == "tool_calls"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if os.environ.get('RUN_REAL_KEY_TESTS', '').lower() not in ('1', 'true', 'yes'):
|
||||
print("Set RUN_REAL_KEY_TESTS=1 to run real API tests")
|
||||
print("Running unit tests only...\n")
|
||||
pytest.main([__file__, "-v", "-s", "-k", "not Real"])
|
||||
else:
|
||||
pytest.main([__file__, "-v", "-s"])
|
||||
|
||||
@@ -88,7 +88,6 @@ class TestAgentKnowledgeRetrieval:
|
||||
instructions="Answer based ONLY on the provided knowledge. Quote exact codes.",
|
||||
knowledge=[temp_knowledge_dir],
|
||||
user_id="live_test_user",
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
response = agent.chat("What is the project code for Phoenix Initiative?")
|
||||
@@ -105,7 +104,6 @@ class TestAgentKnowledgeRetrieval:
|
||||
instructions="Answer based ONLY on the provided knowledge. Quote exact codes.",
|
||||
knowledge=[temp_knowledge_dir],
|
||||
user_id="live_test_user",
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
response = agent.chat("What is the budget code?")
|
||||
@@ -122,7 +120,6 @@ class TestAgentKnowledgeRetrieval:
|
||||
instructions="Answer based ONLY on the provided knowledge. Quote exact codes.",
|
||||
knowledge=[temp_knowledge_dir],
|
||||
user_id="live_test_user",
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
response = agent.chat("What is the security code?")
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
Real agentic test for store_learning/search_learning tools.
|
||||
Agent actually calls LLM and uses the tools end-to-end.
|
||||
"""
|
||||
import os, sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.tools import store_learning, search_learning
|
||||
|
||||
agent = Agent(
|
||||
name="LearningBot",
|
||||
instructions=(
|
||||
"You are a helpful assistant that learns user preferences. "
|
||||
"When the user tells you a preference, use store_learning with the appropriate category. "
|
||||
"When asked to recall, use search_learning."
|
||||
),
|
||||
memory=True,
|
||||
learn=True,
|
||||
tools=[store_learning, search_learning],
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
|
||||
# Turn 1: Store a learning
|
||||
result1 = agent.start("I always prefer bullet-point answers. Remember this as a persona preference.")
|
||||
print(f"Turn 1 result: {result1}")
|
||||
|
||||
# Turn 2: Recall the learning
|
||||
result2 = agent.start("What are my preferences?")
|
||||
print(f"Turn 2 result: {result2}")
|
||||
|
||||
# Simple check
|
||||
passed = "bullet" in str(result2).lower()
|
||||
if passed:
|
||||
print("\n✅ Real agentic learning test passed!")
|
||||
else:
|
||||
print("\n⚠️ Agent did not recall learning, but tools are wired correctly.")
|
||||
print(" (This may happen if LLM doesn't use search_learning; passive learn also works.)")
|
||||
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
Real agentic test for memory tools.
|
||||
|
||||
This test creates an Agent with memory=True and tools=[store_memory, search_memory],
|
||||
calls agent.start() with a real prompt, and verifies the agent actually calls the LLM.
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.tools import store_memory, search_memory
|
||||
|
||||
|
||||
def test_memory_tools_real_agent():
|
||||
"""Agent with memory tools can be created and started."""
|
||||
agent = Agent(
|
||||
name="MemoryTestAgent",
|
||||
instructions=(
|
||||
"You are a helpful assistant with memory. "
|
||||
"When the user tells you something to remember, use store_memory. "
|
||||
"When the user asks about something previously stored, use search_memory."
|
||||
),
|
||||
memory=True,
|
||||
tools=[store_memory, search_memory],
|
||||
)
|
||||
|
||||
# Turn 1: Ask the agent to store something
|
||||
result = agent.start("Remember that my favorite color is blue. Say OK when done.")
|
||||
print(f"Turn 1 result: {result}")
|
||||
assert result is not None
|
||||
assert len(str(result)) > 0
|
||||
|
||||
# Turn 2: Ask the agent to recall
|
||||
result2 = agent.start("What is my favorite color? Search your memory if needed.")
|
||||
print(f"Turn 2 result: {result2}")
|
||||
assert result2 is not None
|
||||
assert len(str(result2)) > 0
|
||||
|
||||
print("\n✅ Real agentic test passed!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_memory_tools_real_agent()
|
||||
@@ -0,0 +1,387 @@
|
||||
"""Unit tests for email_tools.py — mocks AgentMail SDK, no real API calls."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# Ensure the SDK is importable
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
|
||||
class TestEmailTools(unittest.TestCase):
|
||||
"""Tests for send_email, list_emails, read_email, list_inboxes."""
|
||||
|
||||
def setUp(self):
|
||||
"""Reset module-level client cache before each test."""
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
os.environ["AGENTMAIL_API_KEY"] = "am_test_key"
|
||||
os.environ["AGENTMAIL_INBOX_ID"] = "[email protected]"
|
||||
|
||||
def tearDown(self):
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
os.environ.pop("AGENTMAIL_API_KEY", None)
|
||||
os.environ.pop("AGENTMAIL_INBOX_ID", None)
|
||||
|
||||
def _mock_agentmail_module(self):
|
||||
"""Create a mock agentmail module with AgentMail class."""
|
||||
mock_client = MagicMock()
|
||||
mock_agentmail_cls = MagicMock(return_value=mock_client)
|
||||
|
||||
mock_module = types.ModuleType("agentmail")
|
||||
mock_module.AgentMail = mock_agentmail_cls
|
||||
return mock_module, mock_client
|
||||
|
||||
# ── send_email ──────────────────────────────────────────────
|
||||
|
||||
def test_send_email_success(self):
|
||||
mock_mod, mock_client = self._mock_agentmail_module()
|
||||
|
||||
# Mock the send response
|
||||
mock_result = MagicMock()
|
||||
mock_result.message_id = "msg_123"
|
||||
mock_result.thread_id = "thread_456"
|
||||
mock_client.inboxes.messages.send.return_value = mock_result
|
||||
|
||||
with patch.dict("sys.modules", {"agentmail": mock_mod}):
|
||||
from praisonaiagents.tools.email_tools import send_email
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None # Force re-creation
|
||||
|
||||
result = send_email(to="[email protected]", subject="Hello", body="Hi there")
|
||||
|
||||
self.assertIn("sent successfully", result)
|
||||
self.assertIn("msg_123", result)
|
||||
mock_client.inboxes.messages.send.assert_called_once_with(
|
||||
"[email protected]",
|
||||
to="[email protected]",
|
||||
subject="Hello",
|
||||
text="Hi there",
|
||||
)
|
||||
|
||||
def test_send_email_uses_text_not_body(self):
|
||||
"""Critical: SDK uses text= not body= parameter."""
|
||||
mock_mod, mock_client = self._mock_agentmail_module()
|
||||
mock_result = MagicMock()
|
||||
mock_result.message_id = "msg_1"
|
||||
mock_result.thread_id = "t1"
|
||||
mock_client.inboxes.messages.send.return_value = mock_result
|
||||
|
||||
with patch.dict("sys.modules", {"agentmail": mock_mod}):
|
||||
from praisonaiagents.tools.email_tools import send_email
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
|
||||
send_email(to="[email protected]", subject="s", body="b")
|
||||
|
||||
call_kwargs = mock_client.inboxes.messages.send.call_args
|
||||
# Must use text= keyword, never body=
|
||||
self.assertIn("text", call_kwargs.kwargs)
|
||||
self.assertNotIn("body", call_kwargs.kwargs)
|
||||
|
||||
def test_send_email_failure_returns_error(self):
|
||||
mock_mod, mock_client = self._mock_agentmail_module()
|
||||
mock_client.inboxes.messages.send.side_effect = Exception("Network error")
|
||||
|
||||
with patch.dict("sys.modules", {"agentmail": mock_mod}):
|
||||
from praisonaiagents.tools.email_tools import send_email
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
|
||||
result = send_email(to="[email protected]", subject="s", body="b")
|
||||
|
||||
self.assertIn("Failed to send", result)
|
||||
self.assertIn("Network error", result)
|
||||
|
||||
# ── list_emails ─────────────────────────────────────────────
|
||||
|
||||
def test_list_emails_success(self):
|
||||
mock_mod, mock_client = self._mock_agentmail_module()
|
||||
|
||||
mock_msg = MagicMock()
|
||||
mock_msg.from_ = "[email protected]"
|
||||
mock_msg.subject = "Test Subject"
|
||||
mock_msg.preview = "Preview text"
|
||||
mock_msg.message_id = "msg_100"
|
||||
mock_msg.timestamp = "2026-03-16"
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.messages = [mock_msg]
|
||||
mock_response.count = 1
|
||||
mock_client.inboxes.messages.list.return_value = mock_response
|
||||
|
||||
with patch.dict("sys.modules", {"agentmail": mock_mod}):
|
||||
from praisonaiagents.tools.email_tools import list_emails
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
|
||||
result = list_emails(limit=5)
|
||||
|
||||
self.assertIn("[email protected]", result)
|
||||
self.assertIn("Test Subject", result)
|
||||
self.assertIn("msg_100", result)
|
||||
# Must use .messages attribute, not iterate response directly
|
||||
mock_client.inboxes.messages.list.assert_called_once_with(
|
||||
"[email protected]", limit=5
|
||||
)
|
||||
|
||||
def test_list_emails_uses_from_underscore(self):
|
||||
"""Critical: SDK uses msg.from_ not msg.from_address."""
|
||||
mock_mod, mock_client = self._mock_agentmail_module()
|
||||
|
||||
mock_msg = MagicMock(spec=["from_", "subject", "preview", "message_id", "timestamp"])
|
||||
mock_msg.from_ = "[email protected]"
|
||||
mock_msg.subject = "Sub"
|
||||
mock_msg.preview = "Pre"
|
||||
mock_msg.message_id = "id1"
|
||||
mock_msg.timestamp = "now"
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.messages = [mock_msg]
|
||||
mock_response.count = 1
|
||||
mock_client.inboxes.messages.list.return_value = mock_response
|
||||
|
||||
with patch.dict("sys.modules", {"agentmail": mock_mod}):
|
||||
from praisonaiagents.tools.email_tools import list_emails
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
|
||||
result = list_emails()
|
||||
|
||||
self.assertIn("[email protected]", result)
|
||||
|
||||
def test_list_emails_empty(self):
|
||||
mock_mod, mock_client = self._mock_agentmail_module()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.messages = []
|
||||
mock_client.inboxes.messages.list.return_value = mock_response
|
||||
|
||||
with patch.dict("sys.modules", {"agentmail": mock_mod}):
|
||||
from praisonaiagents.tools.email_tools import list_emails
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
|
||||
result = list_emails()
|
||||
|
||||
self.assertIn("No emails found", result)
|
||||
|
||||
# ── read_email ──────────────────────────────────────────────
|
||||
|
||||
def test_read_email_success(self):
|
||||
mock_mod, mock_client = self._mock_agentmail_module()
|
||||
|
||||
mock_msg = MagicMock()
|
||||
mock_msg.from_ = "[email protected]"
|
||||
mock_msg.to = ["[email protected]"]
|
||||
mock_msg.subject = "Full Read Test"
|
||||
mock_msg.extracted_text = "Full body content here"
|
||||
mock_msg.timestamp = "2026-03-16T12:00:00"
|
||||
mock_msg.in_reply_to = ""
|
||||
mock_client.inboxes.messages.get.return_value = mock_msg
|
||||
|
||||
with patch.dict("sys.modules", {"agentmail": mock_mod}):
|
||||
from praisonaiagents.tools.email_tools import read_email
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
|
||||
result = read_email(message_id="msg_100")
|
||||
|
||||
self.assertIn("[email protected]", result)
|
||||
self.assertIn("Full Read Test", result)
|
||||
self.assertIn("Full body content here", result)
|
||||
|
||||
# ── list_inboxes ────────────────────────────────────────────
|
||||
|
||||
def test_list_inboxes_success(self):
|
||||
mock_mod, mock_client = self._mock_agentmail_module()
|
||||
|
||||
mock_inbox = MagicMock()
|
||||
mock_inbox.inbox_id = "[email protected]"
|
||||
mock_inbox.display_name = "Praison"
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.inboxes = [mock_inbox]
|
||||
mock_client.inboxes.list.return_value = mock_response
|
||||
|
||||
with patch.dict("sys.modules", {"agentmail": mock_mod}):
|
||||
from praisonaiagents.tools.email_tools import list_inboxes
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
|
||||
result = list_inboxes()
|
||||
|
||||
self.assertIn("[email protected]", result)
|
||||
self.assertIn("Praison", result)
|
||||
|
||||
# ── Missing env vars ────────────────────────────────────────
|
||||
|
||||
def test_missing_api_key_raises(self):
|
||||
os.environ.pop("AGENTMAIL_API_KEY", None)
|
||||
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
et._get_client()
|
||||
self.assertIn("AGENTMAIL_API_KEY", str(ctx.exception))
|
||||
|
||||
def test_missing_inbox_id_raises(self):
|
||||
os.environ.pop("AGENTMAIL_INBOX_ID", None)
|
||||
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
et._get_inbox_id()
|
||||
self.assertIn("AGENTMAIL_INBOX_ID", str(ctx.exception))
|
||||
|
||||
# ── Profile registration ────────────────────────────────────
|
||||
|
||||
def test_email_profile_exists(self):
|
||||
from praisonaiagents.tools.profiles import get_profile
|
||||
profile = get_profile("email")
|
||||
self.assertEqual(profile.name, "email")
|
||||
self.assertIn("send_email", profile.tools)
|
||||
self.assertIn("list_emails", profile.tools)
|
||||
self.assertIn("read_email", profile.tools)
|
||||
|
||||
# ── Lazy import via tools package ───────────────────────────
|
||||
|
||||
def test_lazy_import_from_tools_package(self):
|
||||
from praisonaiagents.tools import send_email
|
||||
from praisonaiagents.tools.email_tools import send_email as direct
|
||||
self.assertIs(send_email, direct)
|
||||
|
||||
# ── reply_email ──────────────────────────────────────────────
|
||||
|
||||
def test_reply_email_success(self):
|
||||
mock_mod, mock_client = self._mock_agentmail_module()
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.message_id = "reply_msg_1"
|
||||
mock_result.thread_id = "thread_1"
|
||||
mock_client.inboxes.messages.reply.return_value = mock_result
|
||||
|
||||
with patch.dict("sys.modules", {"agentmail": mock_mod}):
|
||||
from praisonaiagents.tools.email_tools import reply_email
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
|
||||
result = reply_email(message_id="msg_100", body="Thanks!")
|
||||
|
||||
self.assertIn("reply_msg_1", result)
|
||||
self.assertIn("Reply sent", result)
|
||||
mock_client.inboxes.messages.reply.assert_called_once_with(
|
||||
"[email protected]",
|
||||
"msg_100",
|
||||
text="Thanks!",
|
||||
)
|
||||
|
||||
def test_reply_email_uses_text_not_body(self):
|
||||
"""SDK reply uses text= not body= parameter."""
|
||||
mock_mod, mock_client = self._mock_agentmail_module()
|
||||
mock_result = MagicMock()
|
||||
mock_result.message_id = "r1"
|
||||
mock_result.thread_id = "t1"
|
||||
mock_client.inboxes.messages.reply.return_value = mock_result
|
||||
|
||||
with patch.dict("sys.modules", {"agentmail": mock_mod}):
|
||||
from praisonaiagents.tools.email_tools import reply_email
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
|
||||
reply_email(message_id="m1", body="b")
|
||||
|
||||
call_kwargs = mock_client.inboxes.messages.reply.call_args
|
||||
self.assertIn("text", call_kwargs.kwargs)
|
||||
self.assertNotIn("body", call_kwargs.kwargs)
|
||||
|
||||
# ── create_inbox ─────────────────────────────────────────────
|
||||
|
||||
def test_create_inbox_success(self):
|
||||
mock_mod, mock_client = self._mock_agentmail_module()
|
||||
|
||||
mock_inbox = MagicMock()
|
||||
mock_inbox.inbox_id = "[email protected]"
|
||||
mock_inbox.display_name = "My Agent"
|
||||
mock_client.inboxes.create.return_value = mock_inbox
|
||||
|
||||
with patch.dict("sys.modules", {"agentmail": mock_mod}):
|
||||
from praisonaiagents.tools.email_tools import create_inbox
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
|
||||
result = create_inbox(display_name="My Agent")
|
||||
|
||||
self.assertIn("[email protected]", result)
|
||||
self.assertIn("created", result.lower())
|
||||
|
||||
# ── Angle-bracket normalization ──────────────────────────────
|
||||
|
||||
def test_read_email_angle_bracket_normalization(self):
|
||||
"""LLMs strip angle brackets from message IDs. read_email should add them."""
|
||||
mock_mod, mock_client = self._mock_agentmail_module()
|
||||
|
||||
mock_msg = MagicMock()
|
||||
mock_msg.from_ = "[email protected]"
|
||||
mock_msg.to = ["[email protected]"]
|
||||
mock_msg.subject = "Test"
|
||||
mock_msg.extracted_text = "Body"
|
||||
mock_msg.timestamp = "now"
|
||||
mock_msg.in_reply_to = ""
|
||||
mock_client.inboxes.messages.get.return_value = mock_msg
|
||||
|
||||
with patch.dict("sys.modules", {"agentmail": mock_mod}):
|
||||
from praisonaiagents.tools.email_tools import read_email
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
|
||||
# Pass WITHOUT angle brackets — should be normalized
|
||||
read_email(message_id="[email protected]")
|
||||
|
||||
# The API call should have angle brackets
|
||||
call_args = mock_client.inboxes.messages.get.call_args
|
||||
actual_id = call_args[0][1] # second positional arg
|
||||
self.assertTrue(actual_id.startswith("<"), f"Expected <...> but got: {actual_id}")
|
||||
self.assertTrue(actual_id.endswith(">"), f"Expected <...> but got: {actual_id}")
|
||||
|
||||
# ── SMTP tools error handling ────────────────────────────────
|
||||
|
||||
def test_smtp_send_email_missing_creds(self):
|
||||
os.environ.pop("EMAIL_ADDRESS", None)
|
||||
os.environ.pop("EMAIL_PASSWORD", None)
|
||||
from praisonaiagents.tools.email_tools import smtp_send_email
|
||||
result = smtp_send_email(to="[email protected]", subject="s", body="b")
|
||||
self.assertIn("EMAIL_ADDRESS", result)
|
||||
self.assertIn("EMAIL_PASSWORD", result)
|
||||
|
||||
def test_smtp_read_inbox_missing_creds(self):
|
||||
os.environ.pop("EMAIL_ADDRESS", None)
|
||||
os.environ.pop("EMAIL_PASSWORD", None)
|
||||
from praisonaiagents.tools.email_tools import smtp_read_inbox
|
||||
result = smtp_read_inbox()
|
||||
self.assertIn("EMAIL_ADDRESS", result)
|
||||
self.assertIn("EMAIL_PASSWORD", result)
|
||||
|
||||
# ── SMTP profile registration ────────────────────────────────
|
||||
|
||||
def test_smtp_email_profile_exists(self):
|
||||
from praisonaiagents.tools.profiles import get_profile
|
||||
profile = get_profile("smtp_email")
|
||||
self.assertEqual(profile.name, "smtp_email")
|
||||
self.assertIn("smtp_send_email", profile.tools)
|
||||
self.assertIn("smtp_read_inbox", profile.tools)
|
||||
|
||||
# ── SMTP lazy import ─────────────────────────────────────────
|
||||
|
||||
def test_smtp_lazy_import_from_tools_package(self):
|
||||
from praisonaiagents.tools import smtp_send_email
|
||||
from praisonaiagents.tools.email_tools import smtp_send_email as direct
|
||||
self.assertIs(smtp_send_email, direct)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -434,7 +434,7 @@ callbacks:
|
||||
|
||||
|
||||
class TestYAMLWorkflowParserGuardrails:
|
||||
"""Tests for guardrails parsing."""
|
||||
"""Tests for guardrails parsing and wiring to Task objects."""
|
||||
|
||||
def test_parse_step_guardrail(self):
|
||||
"""Test parsing guardrail on a step."""
|
||||
@@ -459,6 +459,167 @@ steps:
|
||||
workflow = parser.parse_string(yaml_content)
|
||||
|
||||
assert workflow is not None
|
||||
|
||||
def test_guardrail_wired_to_task(self):
|
||||
"""Test that guardrail string from YAML is wired into the Task object."""
|
||||
from praisonaiagents.workflows import YAMLWorkflowParser
|
||||
|
||||
yaml_content = """
|
||||
name: Guardrail Wiring Test
|
||||
agents:
|
||||
writer:
|
||||
name: Writer
|
||||
role: Writer
|
||||
goal: Write
|
||||
instructions: "Write content"
|
||||
|
||||
steps:
|
||||
- agent: writer
|
||||
action: "Write professional content"
|
||||
guardrail: "Ensure the content is professional and error-free"
|
||||
"""
|
||||
parser = YAMLWorkflowParser()
|
||||
workflow = parser.parse_string(yaml_content)
|
||||
|
||||
# Normalize the step to a Task (this is what run() does internally)
|
||||
task = workflow._normalize_single_step(workflow.steps[0], 0)
|
||||
|
||||
# The guardrail string must be set on the Task
|
||||
assert task.guardrail == "Ensure the content is professional and error-free"
|
||||
# _setup_guardrail should have created an LLMGuardrail instance
|
||||
assert task._guardrail_fn is not None
|
||||
|
||||
def test_max_retries_wired_to_task(self):
|
||||
"""Test that max_retries from YAML is wired into the Task object."""
|
||||
from praisonaiagents.workflows import YAMLWorkflowParser
|
||||
|
||||
yaml_content = """
|
||||
name: Retries Wiring Test
|
||||
agents:
|
||||
writer:
|
||||
name: Writer
|
||||
role: Writer
|
||||
goal: Write
|
||||
instructions: "Write content"
|
||||
|
||||
steps:
|
||||
- agent: writer
|
||||
action: "Write content"
|
||||
max_retries: 7
|
||||
"""
|
||||
parser = YAMLWorkflowParser()
|
||||
workflow = parser.parse_string(yaml_content)
|
||||
task = workflow._normalize_single_step(workflow.steps[0], 0)
|
||||
|
||||
assert task.max_retries == 7
|
||||
|
||||
def test_expected_output_wired_to_task(self):
|
||||
"""Test that expected_output from YAML is wired into the Task object."""
|
||||
from praisonaiagents.workflows import YAMLWorkflowParser
|
||||
|
||||
yaml_content = """
|
||||
name: Expected Output Wiring Test
|
||||
agents:
|
||||
writer:
|
||||
name: Writer
|
||||
role: Writer
|
||||
goal: Write
|
||||
instructions: "Write content"
|
||||
|
||||
steps:
|
||||
- agent: writer
|
||||
action: "Write a blog post"
|
||||
expected_output: "A well-structured blog post with introduction, body, and conclusion"
|
||||
"""
|
||||
parser = YAMLWorkflowParser()
|
||||
workflow = parser.parse_string(yaml_content)
|
||||
task = workflow._normalize_single_step(workflow.steps[0], 0)
|
||||
|
||||
assert task.expected_output == "A well-structured blog post with introduction, body, and conclusion"
|
||||
|
||||
def test_skip_on_failure_wired_to_task(self):
|
||||
"""Test that skip_on_failure from YAML is wired into the Task object."""
|
||||
from praisonaiagents.workflows import YAMLWorkflowParser
|
||||
|
||||
yaml_content = """
|
||||
name: Skip Failure Wiring Test
|
||||
agents:
|
||||
writer:
|
||||
name: Writer
|
||||
role: Writer
|
||||
goal: Write
|
||||
instructions: "Write content"
|
||||
|
||||
steps:
|
||||
- agent: writer
|
||||
action: "Write content"
|
||||
skip_on_failure: true
|
||||
"""
|
||||
parser = YAMLWorkflowParser()
|
||||
workflow = parser.parse_string(yaml_content)
|
||||
task = workflow._normalize_single_step(workflow.steps[0], 0)
|
||||
|
||||
assert task.skip_on_failure == True
|
||||
|
||||
def test_output_file_wired_to_task(self):
|
||||
"""Test that output_file from YAML is wired into the Task object."""
|
||||
from praisonaiagents.workflows import YAMLWorkflowParser
|
||||
|
||||
yaml_content = """
|
||||
name: Output File Wiring Test
|
||||
agents:
|
||||
writer:
|
||||
name: Writer
|
||||
role: Writer
|
||||
goal: Write
|
||||
instructions: "Write content"
|
||||
|
||||
steps:
|
||||
- agent: writer
|
||||
action: "Write content"
|
||||
output_file: "output/result.txt"
|
||||
"""
|
||||
parser = YAMLWorkflowParser()
|
||||
workflow = parser.parse_string(yaml_content)
|
||||
task = workflow._normalize_single_step(workflow.steps[0], 0)
|
||||
|
||||
assert task.output_file == "output/result.txt"
|
||||
|
||||
def test_all_yaml_step_attrs_wired(self):
|
||||
"""Test that ALL YAML step-level attributes are wired through to Task."""
|
||||
from praisonaiagents.workflows import YAMLWorkflowParser
|
||||
|
||||
yaml_content = """
|
||||
name: Full Wiring Test
|
||||
agents:
|
||||
writer:
|
||||
name: Writer
|
||||
role: Writer
|
||||
goal: Write
|
||||
instructions: "Write content"
|
||||
|
||||
steps:
|
||||
- agent: writer
|
||||
action: "Write content"
|
||||
guardrail: "Check quality"
|
||||
max_retries: 5
|
||||
expected_output: "High quality content"
|
||||
output_file: "out.txt"
|
||||
skip_on_failure: true
|
||||
retry_delay: 2.5
|
||||
create_directory: true
|
||||
"""
|
||||
parser = YAMLWorkflowParser()
|
||||
workflow = parser.parse_string(yaml_content)
|
||||
task = workflow._normalize_single_step(workflow.steps[0], 0)
|
||||
|
||||
assert task.guardrail == "Check quality"
|
||||
assert task.max_retries == 5
|
||||
assert task.expected_output == "High quality content"
|
||||
assert task.output_file == "out.txt"
|
||||
assert task.skip_on_failure == True
|
||||
assert task.retry_delay == 2.5
|
||||
assert task.create_directory == True
|
||||
|
||||
|
||||
class TestYAMLWorkflowParserVariables:
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
"""
|
||||
TDD tests for autonomy mode redesign.
|
||||
|
||||
Tests the new `mode` field in AutonomyConfig and the caller-mode behavior
|
||||
where autonomy=True does a single chat() call instead of the internal loop.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 1. AutonomyConfig mode field tests
|
||||
# ============================================================================
|
||||
|
||||
class TestAutonomyConfigMode:
|
||||
"""Tests for the 'mode' field on AutonomyConfig."""
|
||||
|
||||
def test_mode_field_exists(self):
|
||||
"""AutonomyConfig should have a mode field."""
|
||||
from praisonaiagents.agent.autonomy import AutonomyConfig
|
||||
config = AutonomyConfig()
|
||||
assert hasattr(config, 'mode'), "AutonomyConfig must have a 'mode' field"
|
||||
|
||||
def test_default_mode_is_caller_for_suggest(self):
|
||||
"""Default level='suggest' should default to mode='caller'."""
|
||||
from praisonaiagents.agent.autonomy import AutonomyConfig
|
||||
config = AutonomyConfig(level="suggest")
|
||||
assert config.mode == "caller"
|
||||
|
||||
def test_default_mode_is_caller_for_auto_edit(self):
|
||||
"""level='auto_edit' should default to mode='caller'."""
|
||||
from praisonaiagents.agent.autonomy import AutonomyConfig
|
||||
config = AutonomyConfig(level="auto_edit")
|
||||
assert config.mode == "caller"
|
||||
|
||||
def test_default_mode_is_iterative_for_full_auto(self):
|
||||
"""level='full_auto' should default to mode='iterative' (backward compat)."""
|
||||
from praisonaiagents.agent.autonomy import AutonomyConfig
|
||||
config = AutonomyConfig(level="full_auto")
|
||||
assert config.mode == "iterative"
|
||||
|
||||
def test_explicit_mode_overrides_default(self):
|
||||
"""Explicit mode should override the level-based default."""
|
||||
from praisonaiagents.agent.autonomy import AutonomyConfig
|
||||
config = AutonomyConfig(level="full_auto", mode="caller")
|
||||
assert config.mode == "caller"
|
||||
|
||||
def test_explicit_iterative_with_suggest(self):
|
||||
"""Should be able to set mode='iterative' even with level='suggest'."""
|
||||
from praisonaiagents.agent.autonomy import AutonomyConfig
|
||||
config = AutonomyConfig(level="suggest", mode="iterative")
|
||||
assert config.mode == "iterative"
|
||||
|
||||
def test_invalid_mode_raises(self):
|
||||
"""Invalid mode value should raise ValueError."""
|
||||
from praisonaiagents.agent.autonomy import AutonomyConfig
|
||||
with pytest.raises(ValueError, match="Invalid autonomy mode"):
|
||||
AutonomyConfig(mode="invalid_mode")
|
||||
|
||||
def test_from_dict_with_mode(self):
|
||||
"""AutonomyConfig.from_dict should parse mode field."""
|
||||
from praisonaiagents.agent.autonomy import AutonomyConfig
|
||||
config = AutonomyConfig.from_dict({"mode": "iterative"})
|
||||
assert config.mode == "iterative"
|
||||
|
||||
def test_from_dict_without_mode_uses_smart_default(self):
|
||||
"""from_dict without mode should use smart default based on level."""
|
||||
from praisonaiagents.agent.autonomy import AutonomyConfig
|
||||
config = AutonomyConfig.from_dict({"level": "full_auto"})
|
||||
assert config.mode == "iterative"
|
||||
|
||||
def test_from_dict_suggest_defaults_to_caller(self):
|
||||
"""from_dict with suggest level defaults to caller mode."""
|
||||
from praisonaiagents.agent.autonomy import AutonomyConfig
|
||||
config = AutonomyConfig.from_dict({"level": "suggest"})
|
||||
assert config.mode == "caller"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 2. Agent _init_autonomy stores mode in autonomy_config dict
|
||||
# ============================================================================
|
||||
|
||||
class TestAgentAutonomyModeInit:
|
||||
"""Tests that Agent._init_autonomy correctly stores mode."""
|
||||
|
||||
def _make_agent(self, **autonomy_kwargs):
|
||||
"""Helper to create a minimal agent with autonomy config."""
|
||||
from praisonaiagents import Agent
|
||||
return Agent(
|
||||
name="test_mode",
|
||||
instructions="test",
|
||||
autonomy=autonomy_kwargs if autonomy_kwargs else True,
|
||||
)
|
||||
|
||||
def test_autonomy_true_stores_mode(self):
|
||||
"""Agent(autonomy=True) should store mode in autonomy_config."""
|
||||
agent = self._make_agent()
|
||||
assert "mode" in agent.autonomy_config, "autonomy_config must contain 'mode'"
|
||||
|
||||
def test_autonomy_true_default_mode_is_caller(self):
|
||||
"""Agent(autonomy=True) defaults to level='suggest', so mode='caller'."""
|
||||
agent = self._make_agent()
|
||||
assert agent.autonomy_config["mode"] == "caller"
|
||||
|
||||
def test_autonomy_dict_full_auto_mode_iterative(self):
|
||||
"""Agent(autonomy={'level':'full_auto'}) should default to mode='iterative'."""
|
||||
agent = self._make_agent(level="full_auto")
|
||||
assert agent.autonomy_config["mode"] == "iterative"
|
||||
|
||||
def test_autonomy_dict_explicit_mode(self):
|
||||
"""Agent(autonomy={'mode':'iterative'}) should store explicit mode."""
|
||||
agent = self._make_agent(mode="iterative")
|
||||
assert agent.autonomy_config["mode"] == "iterative"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 3. start() caller-mode behavior
|
||||
# ============================================================================
|
||||
|
||||
class TestStartCallerMode:
|
||||
"""Tests that start() in caller mode does a single chat() call."""
|
||||
|
||||
@patch("praisonaiagents.agent.agent.Agent.chat")
|
||||
def test_caller_mode_calls_chat_once(self, mock_chat):
|
||||
"""In caller mode, start() should call chat() exactly once."""
|
||||
mock_chat.return_value = "Hello! I'm here to help."
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(
|
||||
name="test_caller",
|
||||
instructions="You are helpful",
|
||||
autonomy=True, # level=suggest → mode=caller
|
||||
)
|
||||
result = agent.start("Say hello")
|
||||
|
||||
mock_chat.assert_called_once()
|
||||
|
||||
@patch("praisonaiagents.agent.agent.Agent.chat")
|
||||
def test_caller_mode_does_not_call_run_autonomous(self, mock_chat):
|
||||
"""In caller mode, start() must NOT call run_autonomous."""
|
||||
mock_chat.return_value = "Done"
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(
|
||||
name="test_no_loop",
|
||||
instructions="You are helpful",
|
||||
autonomy=True,
|
||||
)
|
||||
with patch.object(agent, 'run_autonomous') as mock_autonomous:
|
||||
agent.start("Do something")
|
||||
mock_autonomous.assert_not_called()
|
||||
|
||||
@patch("praisonaiagents.agent.agent.Agent.chat")
|
||||
def test_caller_mode_returns_autonomy_result(self, mock_chat):
|
||||
"""In caller mode, start() should return AutonomyResult."""
|
||||
mock_chat.return_value = "Task completed successfully"
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.agent.autonomy import AutonomyResult
|
||||
agent = Agent(
|
||||
name="test_result",
|
||||
instructions="You are helpful",
|
||||
autonomy=True,
|
||||
)
|
||||
result = agent.start("Do task")
|
||||
|
||||
assert isinstance(result, AutonomyResult)
|
||||
assert result.iterations == 1
|
||||
assert result.success is True
|
||||
assert result.completion_reason == "caller_mode"
|
||||
assert "Task completed" in str(result)
|
||||
|
||||
@patch("praisonaiagents.agent.agent.Agent.chat")
|
||||
def test_caller_mode_preserves_auto_approve(self, mock_chat):
|
||||
"""Caller mode should still have auto-approve wired."""
|
||||
mock_chat.return_value = "Done"
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(
|
||||
name="test_approve",
|
||||
instructions="test",
|
||||
autonomy={"level": "auto_edit"},
|
||||
)
|
||||
# auto_edit should wire approval
|
||||
assert agent.autonomy_enabled is True
|
||||
assert agent.autonomy_config.get("mode") == "caller"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 4. start() iterative-mode behavior (backward compat for full_auto)
|
||||
# ============================================================================
|
||||
|
||||
class TestStartIterativeMode:
|
||||
"""Tests that start() in iterative mode uses run_autonomous (backward compat)."""
|
||||
|
||||
@patch("praisonaiagents.agent.agent.Agent.run_autonomous")
|
||||
def test_iterative_mode_calls_run_autonomous(self, mock_run):
|
||||
"""In iterative mode, start() should call run_autonomous."""
|
||||
from praisonaiagents.agent.autonomy import AutonomyResult
|
||||
mock_run.return_value = AutonomyResult(
|
||||
success=True, output="Done", completion_reason="goal",
|
||||
iterations=1, stage="direct", actions=[], duration_seconds=1.0,
|
||||
)
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(
|
||||
name="test_iterative",
|
||||
instructions="test",
|
||||
autonomy={"level": "full_auto"}, # defaults to iterative
|
||||
)
|
||||
agent.start("Do something complex")
|
||||
mock_run.assert_called_once()
|
||||
|
||||
@patch("praisonaiagents.agent.agent.Agent.run_autonomous")
|
||||
def test_explicit_iterative_mode_with_suggest(self, mock_run):
|
||||
"""Explicit mode='iterative' should use run_autonomous."""
|
||||
from praisonaiagents.agent.autonomy import AutonomyResult
|
||||
mock_run.return_value = AutonomyResult(
|
||||
success=True, output="Done", completion_reason="goal",
|
||||
iterations=1, stage="direct", actions=[], duration_seconds=1.0,
|
||||
)
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(
|
||||
name="test_explicit_iterative",
|
||||
instructions="test",
|
||||
autonomy={"level": "suggest", "mode": "iterative"},
|
||||
)
|
||||
agent.start("Ralph loop task")
|
||||
mock_run.assert_called_once()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 5. astart() caller-mode behavior (async parity)
|
||||
# ============================================================================
|
||||
|
||||
class TestAstartCallerMode:
|
||||
"""Tests that astart() in caller mode does a single achat() call."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_caller_mode_achat_once(self):
|
||||
"""In caller mode, astart() should call achat() exactly once."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.agent.autonomy import AutonomyResult
|
||||
agent = Agent(
|
||||
name="test_async_caller",
|
||||
instructions="You are helpful",
|
||||
autonomy=True,
|
||||
)
|
||||
with patch.object(agent, 'achat', return_value="Hello async!") as mock_achat:
|
||||
result = await agent.astart("Say hello")
|
||||
mock_achat.assert_called_once()
|
||||
assert isinstance(result, AutonomyResult)
|
||||
assert result.iterations == 1
|
||||
@@ -2,15 +2,10 @@
|
||||
TDD tests for unified autonomy API.
|
||||
|
||||
Goal: Agent(autonomy=True) + agent.start("Task") should automatically
|
||||
use the autonomous loop. No need to call run_autonomous() separately.
|
||||
|
||||
Current (redundant):
|
||||
agent = Agent(autonomy=True)
|
||||
result = agent.run_autonomous("Task") # Why two steps?
|
||||
|
||||
Proposed (unified):
|
||||
agent = Agent(autonomy=True)
|
||||
result = agent.start("Task") # Automatically uses autonomy loop!
|
||||
use the appropriate mode based on config:
|
||||
- level=suggest (default) → caller mode (single chat call)
|
||||
- level=full_auto → iterative mode (run_autonomous loop)
|
||||
- explicit mode overrides level-based default
|
||||
"""
|
||||
|
||||
import pytest
|
||||
@@ -21,16 +16,11 @@ class TestUnifiedAutonomyAPI:
|
||||
"""Test that start() automatically uses autonomy when enabled."""
|
||||
|
||||
def test_start_uses_autonomy_when_enabled(self):
|
||||
"""start() should route to run_autonomous when autonomy=True."""
|
||||
"""start() should use caller mode (chat) when autonomy=True (default level=suggest)."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
with patch.object(Agent, 'run_autonomous') as mock_run_auto:
|
||||
mock_run_auto.return_value = MagicMock(
|
||||
success=True,
|
||||
output="Task completed",
|
||||
completion_reason="goal",
|
||||
iterations=1,
|
||||
)
|
||||
with patch.object(Agent, 'chat') as mock_chat:
|
||||
mock_chat.return_value = "Task completed"
|
||||
|
||||
agent = Agent(
|
||||
name="test",
|
||||
@@ -41,10 +31,11 @@ class TestUnifiedAutonomyAPI:
|
||||
|
||||
result = agent.start("Do something")
|
||||
|
||||
# Should have called run_autonomous, not chat
|
||||
mock_run_auto.assert_called_once()
|
||||
call_kwargs = mock_run_auto.call_args[1]
|
||||
assert call_kwargs.get("prompt") == "Do something"
|
||||
# With default level=suggest, should use caller mode (chat)
|
||||
mock_chat.assert_called_once()
|
||||
from praisonaiagents.agent.autonomy import AutonomyResult
|
||||
assert isinstance(result, AutonomyResult)
|
||||
assert result.completion_reason == "caller_mode"
|
||||
|
||||
def test_start_uses_chat_when_autonomy_disabled(self):
|
||||
"""start() should use regular chat when autonomy is not enabled."""
|
||||
@@ -67,18 +58,12 @@ class TestUnifiedAutonomyAPI:
|
||||
mock_chat.assert_called()
|
||||
|
||||
def test_start_returns_autonomy_result_when_autonomy_enabled(self):
|
||||
"""start() should return AutonomyResult when autonomy is enabled."""
|
||||
"""start() should return AutonomyResult when autonomy is enabled (caller mode)."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.agent.autonomy import AutonomyResult
|
||||
|
||||
with patch.object(Agent, 'run_autonomous') as mock_run_auto:
|
||||
mock_result = AutonomyResult(
|
||||
success=True,
|
||||
output="Done!",
|
||||
completion_reason="goal",
|
||||
iterations=2,
|
||||
)
|
||||
mock_run_auto.return_value = mock_result
|
||||
with patch.object(Agent, 'chat') as mock_chat:
|
||||
mock_chat.return_value = "Done!"
|
||||
|
||||
agent = Agent(
|
||||
name="test",
|
||||
@@ -94,7 +79,7 @@ class TestUnifiedAutonomyAPI:
|
||||
assert result.output == "Done!"
|
||||
|
||||
def test_start_passes_autonomy_config_to_run_autonomous(self):
|
||||
"""start() should pass autonomy config values to run_autonomous."""
|
||||
"""start() should pass autonomy config values to run_autonomous in iterative mode."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
with patch.object(Agent, 'run_autonomous') as mock_run_auto:
|
||||
@@ -107,6 +92,7 @@ class TestUnifiedAutonomyAPI:
|
||||
"max_iterations": 15,
|
||||
"completion_promise": "DONE",
|
||||
"clear_context": True,
|
||||
"mode": "iterative", # Explicit iterative mode
|
||||
},
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
@@ -119,7 +105,7 @@ class TestUnifiedAutonomyAPI:
|
||||
assert call_kwargs.get("clear_context") is True
|
||||
|
||||
def test_start_timeout_passed_to_run_autonomous(self):
|
||||
"""start(timeout=X) should pass timeout to run_autonomous."""
|
||||
"""start(timeout=X) should pass timeout to run_autonomous in iterative mode."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
with patch.object(Agent, 'run_autonomous') as mock_run_auto:
|
||||
@@ -128,7 +114,7 @@ class TestUnifiedAutonomyAPI:
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
autonomy=True,
|
||||
autonomy={"mode": "iterative"}, # Must be iterative for run_autonomous
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
|
||||
@@ -218,16 +204,12 @@ class TestAsyncUnifiedAPI:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_astart_uses_autonomy_when_enabled(self):
|
||||
"""astart() should route to run_autonomous_async when autonomy=True."""
|
||||
"""astart() should use caller mode (achat) when autonomy=True."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.agent.autonomy import AutonomyResult
|
||||
|
||||
with patch.object(Agent, 'run_autonomous_async') as mock_run_auto:
|
||||
mock_run_auto.return_value = MagicMock(
|
||||
success=True,
|
||||
output="Task completed",
|
||||
completion_reason="goal",
|
||||
iterations=1,
|
||||
)
|
||||
with patch.object(Agent, 'achat') as mock_achat:
|
||||
mock_achat.return_value = "Task completed"
|
||||
|
||||
agent = Agent(
|
||||
name="test",
|
||||
@@ -238,7 +220,8 @@ class TestAsyncUnifiedAPI:
|
||||
|
||||
result = await agent.astart("Do something")
|
||||
|
||||
mock_run_auto.assert_called_once()
|
||||
mock_achat.assert_called_once()
|
||||
assert isinstance(result, AutonomyResult)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_astart_uses_achat_when_autonomy_disabled(self):
|
||||
@@ -264,7 +247,7 @@ class TestCLISimplification:
|
||||
"""Test that CLI can use simplified API."""
|
||||
|
||||
def test_cli_can_use_start_for_loop(self):
|
||||
"""CLI loop command should be able to use agent.start() directly."""
|
||||
"""CLI loop command should be able to use agent.start() in iterative mode."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
with patch.object(Agent, 'run_autonomous') as mock_run_auto:
|
||||
@@ -277,13 +260,14 @@ class TestCLISimplification:
|
||||
started_at="2024-01-01T00:00:00Z",
|
||||
)
|
||||
|
||||
# This is what CLI should do - simple!
|
||||
# CLI loop must explicitly set mode="iterative"
|
||||
agent = Agent(
|
||||
name="loop_agent",
|
||||
instructions="Complete the task",
|
||||
autonomy={
|
||||
"max_iterations": 10,
|
||||
"completion_promise": "DONE",
|
||||
"mode": "iterative", # Explicit for loop
|
||||
},
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
|
||||
@@ -397,5 +397,57 @@ class TestBackgroundRunner:
|
||||
assert all(t.status == TaskStatus.CANCELLED for t in runner.tasks)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# BackgroundRunner Sync-Wrapper Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestBackgroundRunnerSync:
|
||||
"""Tests for submit_sync / submit_agent_sync methods."""
|
||||
|
||||
def test_submit_sync_function(self):
|
||||
"""submit_sync runs a plain function and returns a trackable task."""
|
||||
runner = BackgroundRunner()
|
||||
|
||||
def double(x):
|
||||
return x * 2
|
||||
|
||||
task = runner.submit_sync(func=double, args=(7,), name="sync-double")
|
||||
assert task is not None
|
||||
assert task.name == "sync-double"
|
||||
|
||||
# Wait for the result (polling).
|
||||
import time
|
||||
for _ in range(50):
|
||||
if task.is_completed:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
assert task.is_successful
|
||||
assert task.result == 14
|
||||
|
||||
def test_submit_agent_sync(self):
|
||||
"""submit_agent_sync dispatches a mock agent start()."""
|
||||
runner = BackgroundRunner()
|
||||
|
||||
class FakeAgent:
|
||||
name = "faker"
|
||||
def start(self, prompt):
|
||||
return f"echo: {prompt}"
|
||||
|
||||
agent = FakeAgent()
|
||||
task = runner.submit_agent_sync(agent, "hello", name="sync-agent")
|
||||
assert task is not None
|
||||
|
||||
import time
|
||||
for _ in range(50):
|
||||
if task.is_completed:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
assert task.is_successful
|
||||
assert task.result == "echo: hello"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"""TDD Tests for top-level learn= parameter on Agent and related classes.
|
||||
|
||||
Tests that learn= is a first-class citizen, peer to memory=, not nested inside it.
|
||||
"""
|
||||
|
||||
|
||||
class TestAgentLearnParam:
|
||||
"""Test Agent(learn=...) top-level parameter."""
|
||||
|
||||
def test_agent_accepts_learn_false(self):
|
||||
"""Agent should accept learn=False (default, no learning)."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
learn=False,
|
||||
)
|
||||
|
||||
assert agent._learn_config is None or agent._learn_config is False
|
||||
|
||||
def test_agent_accepts_learn_true(self):
|
||||
"""Agent should accept learn=True (enable with defaults)."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
learn=True,
|
||||
)
|
||||
|
||||
# Should have learn config enabled
|
||||
assert agent._learn_config is not None
|
||||
assert agent._learn_config is not False
|
||||
|
||||
def test_agent_accepts_learn_config(self):
|
||||
"""Agent should accept learn=LearnConfig(...)."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.config.feature_configs import LearnConfig
|
||||
|
||||
config = LearnConfig(persona=True, insights=True, patterns=False)
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
learn=config,
|
||||
)
|
||||
|
||||
# Should store the config
|
||||
assert agent._learn_config is not None
|
||||
if hasattr(agent._learn_config, 'persona'):
|
||||
assert agent._learn_config.persona is True
|
||||
assert agent._learn_config.insights is True
|
||||
|
||||
def test_learn_param_independent_of_memory(self):
|
||||
"""learn= should work without memory= being set."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# learn=True without memory=True should still work
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
learn=True,
|
||||
memory=None, # No memory explicitly
|
||||
)
|
||||
|
||||
# Learn should be enabled even without explicit memory
|
||||
assert agent._learn_config is not None
|
||||
|
||||
def test_learn_and_memory_both_work(self):
|
||||
"""learn= and memory= should work together."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
learn=True,
|
||||
memory=True,
|
||||
)
|
||||
|
||||
# Both should be enabled
|
||||
assert agent._learn_config is not None
|
||||
assert agent._memory_instance is not None
|
||||
|
||||
|
||||
class TestLearnConfigExport:
|
||||
"""Test that LearnConfig is exported from top-level package."""
|
||||
|
||||
def test_learn_config_exported(self):
|
||||
"""LearnConfig should be importable from praisonaiagents."""
|
||||
from praisonaiagents import LearnConfig
|
||||
|
||||
assert LearnConfig is not None
|
||||
|
||||
# Should be able to instantiate
|
||||
config = LearnConfig()
|
||||
assert config.persona is True # Default
|
||||
assert config.insights is True # Default
|
||||
|
||||
def test_learn_scope_exported(self):
|
||||
"""LearnScope should be importable from praisonaiagents."""
|
||||
from praisonaiagents import LearnScope
|
||||
|
||||
assert LearnScope is not None
|
||||
assert LearnScope.PRIVATE.value == "private"
|
||||
assert LearnScope.SHARED.value == "shared"
|
||||
|
||||
|
||||
class TestAgentTeamLearnParam:
|
||||
"""Test AgentTeam(learn=...) parameter."""
|
||||
|
||||
def test_agent_team_accepts_learn_param(self):
|
||||
"""AgentTeam should accept learn= parameter."""
|
||||
from praisonaiagents import Agent, AgentTeam
|
||||
|
||||
agent1 = Agent(name="agent1", instructions="Agent 1")
|
||||
agent2 = Agent(name="agent2", instructions="Agent 2")
|
||||
|
||||
team = AgentTeam(
|
||||
agents=[agent1, agent2],
|
||||
learn=True,
|
||||
)
|
||||
|
||||
# Should store learn config
|
||||
assert hasattr(team, '_learn') or hasattr(team, 'learn')
|
||||
|
||||
|
||||
class TestWorkflowLearnParam:
|
||||
"""Test Workflow/AgentFlow(learn=...) parameter."""
|
||||
|
||||
def test_workflow_accepts_learn_param(self):
|
||||
"""Workflow should accept learn= parameter."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.workflows import Workflow
|
||||
|
||||
agent = Agent(name="test", instructions="Test")
|
||||
|
||||
workflow = Workflow(
|
||||
steps=[agent], # Workflow uses steps=, not agents=
|
||||
learn=True,
|
||||
)
|
||||
|
||||
# Should store learn config (dataclass field)
|
||||
assert workflow.learn is True
|
||||
|
||||
|
||||
class TestLearnParamDocstring:
|
||||
"""Test that learn= param is documented."""
|
||||
|
||||
def test_agent_docstring_mentions_learn(self):
|
||||
"""Agent.__init__ docstring should mention learn parameter."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
docstring = Agent.__init__.__doc__ or ""
|
||||
|
||||
# Should mention learn parameter
|
||||
assert "learn" in docstring.lower()
|
||||
|
||||
|
||||
class TestBackwardCompatibility:
|
||||
"""Test backward compatibility with memory=MemoryConfig(learn=...)."""
|
||||
|
||||
def test_memory_config_learn_still_works(self):
|
||||
"""memory=MemoryConfig(learn=True) should still work."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.config.feature_configs import MemoryConfig
|
||||
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
memory=MemoryConfig(learn=True),
|
||||
)
|
||||
|
||||
# Should have memory with learn enabled
|
||||
assert agent._memory_instance is not None
|
||||
# Learn context should be accessible
|
||||
if hasattr(agent._memory_instance, 'get_learn_context'):
|
||||
context = agent._memory_instance.get_learn_context()
|
||||
assert isinstance(context, str)
|
||||
|
||||
def test_top_level_learn_takes_precedence(self):
|
||||
"""Top-level learn= should take precedence over memory.learn."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.config.feature_configs import MemoryConfig
|
||||
|
||||
# Top-level learn=False should override memory.learn=True
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
memory=MemoryConfig(learn=True),
|
||||
learn=False, # This should take precedence
|
||||
)
|
||||
|
||||
# Learn should be disabled due to top-level param
|
||||
assert agent._learn_config is None or agent._learn_config is False
|
||||
@@ -16,6 +16,17 @@ requires_knowledge = pytest.mark.skipif(
|
||||
)
|
||||
|
||||
|
||||
def _make_knowledge_or_skip():
|
||||
"""Create a Knowledge() instance, skipping if OpenAI API key is unavailable."""
|
||||
from praisonaiagents import Knowledge
|
||||
try:
|
||||
return Knowledge()
|
||||
except Exception as e:
|
||||
if "api_key" in str(e).lower() or "openai" in str(e).lower():
|
||||
pytest.skip(f"Skipping: OpenAI API key required for embeddings: {e}")
|
||||
raise
|
||||
|
||||
|
||||
@requires_knowledge
|
||||
class TestDirectoryIngestion:
|
||||
"""Test that directories are properly processed and file contents stored."""
|
||||
@@ -42,10 +53,14 @@ class TestDirectoryIngestion:
|
||||
|
||||
def test_directory_ingestion_stores_text_not_path(self, temp_knowledge_dir):
|
||||
"""Test that ingesting a directory stores file contents, not the directory path."""
|
||||
from praisonaiagents import Knowledge
|
||||
knowledge = _make_knowledge_or_skip()
|
||||
|
||||
knowledge = Knowledge()
|
||||
result = knowledge.add(temp_knowledge_dir, user_id='test_user')
|
||||
try:
|
||||
result = knowledge.add(temp_knowledge_dir, user_id='test_user')
|
||||
except Exception as e:
|
||||
if "api_key" in str(e).lower() or "openai" in str(e).lower():
|
||||
pytest.skip(f"Skipping: OpenAI API key required: {e}")
|
||||
raise
|
||||
|
||||
# Should have results from files
|
||||
assert 'results' in result
|
||||
@@ -65,10 +80,14 @@ class TestDirectoryIngestion:
|
||||
|
||||
def test_directory_ingestion_processes_multiple_files(self, temp_knowledge_dir):
|
||||
"""Test that all files in a directory are processed."""
|
||||
from praisonaiagents import Knowledge
|
||||
knowledge = _make_knowledge_or_skip()
|
||||
|
||||
knowledge = Knowledge()
|
||||
result = knowledge.add(temp_knowledge_dir, user_id='test_user')
|
||||
try:
|
||||
knowledge.add(temp_knowledge_dir, user_id='test_user')
|
||||
except Exception as e:
|
||||
if "api_key" in str(e).lower() or "openai" in str(e).lower():
|
||||
pytest.skip(f"Skipping: OpenAI API key required: {e}")
|
||||
raise
|
||||
|
||||
# Search for content from both files
|
||||
search1 = knowledge.search('ZEBRA-71', user_id='test_user')
|
||||
@@ -80,10 +99,14 @@ class TestDirectoryIngestion:
|
||||
|
||||
def test_directory_ingestion_sets_metadata(self, temp_knowledge_dir):
|
||||
"""Test that file metadata is properly set."""
|
||||
from praisonaiagents import Knowledge
|
||||
knowledge = _make_knowledge_or_skip()
|
||||
|
||||
knowledge = Knowledge()
|
||||
knowledge.add(temp_knowledge_dir, user_id='test_user')
|
||||
try:
|
||||
knowledge.add(temp_knowledge_dir, user_id='test_user')
|
||||
except Exception as e:
|
||||
if "api_key" in str(e).lower() or "openai" in str(e).lower():
|
||||
pytest.skip(f"Skipping: OpenAI API key required: {e}")
|
||||
raise
|
||||
|
||||
search_result = knowledge.search('ZEBRA-71', user_id='test_user')
|
||||
|
||||
@@ -114,15 +137,20 @@ class TestContextBuilderUsesText:
|
||||
"""Test that Agent._get_knowledge_context returns text, not path."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name='TestAgent',
|
||||
instructions='Answer based on knowledge.',
|
||||
knowledge=[temp_knowledge_dir],
|
||||
output='silent',
|
||||
)
|
||||
|
||||
# Ensure knowledge is processed
|
||||
agent._ensure_knowledge_processed()
|
||||
try:
|
||||
agent = Agent(
|
||||
name='TestAgent',
|
||||
instructions='Answer based on knowledge.',
|
||||
knowledge=[temp_knowledge_dir],
|
||||
output='silent',
|
||||
)
|
||||
|
||||
# Ensure knowledge is processed
|
||||
agent._ensure_knowledge_processed()
|
||||
except Exception as e:
|
||||
if "api_key" in str(e).lower() or "openai" in str(e).lower():
|
||||
pytest.skip(f"Skipping: OpenAI API key required: {e}")
|
||||
raise
|
||||
|
||||
# Get context
|
||||
context, _ = agent._get_knowledge_context('What is the password?', use_rag=True)
|
||||
|
||||
@@ -33,8 +33,6 @@ class TestLearnConfig:
|
||||
assert config.feedback is False
|
||||
assert config.improvements is False
|
||||
assert config.scope == LearnScope.PRIVATE
|
||||
assert config.auto_consolidate is True
|
||||
assert config.retention_days is None
|
||||
|
||||
def test_custom_values(self):
|
||||
"""Test LearnConfig with custom values."""
|
||||
@@ -43,12 +41,10 @@ class TestLearnConfig:
|
||||
insights=True,
|
||||
patterns=True,
|
||||
scope=LearnScope.SHARED,
|
||||
retention_days=30,
|
||||
)
|
||||
assert config.persona is False
|
||||
assert config.patterns is True
|
||||
assert config.scope == LearnScope.SHARED
|
||||
assert config.retention_days == 30
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test LearnConfig to_dict method."""
|
||||
@@ -271,6 +267,198 @@ class TestLearnManager:
|
||||
assert "Python" in context
|
||||
|
||||
|
||||
class TestBackendWiring:
|
||||
"""P3-A: Test LearnBackend wiring — SQLite, Redis, FILE."""
|
||||
|
||||
def setup_method(self):
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
|
||||
def teardown_method(self):
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_sqlite_backend_round_trip(self):
|
||||
"""SQLite backend stores and retrieves learnings."""
|
||||
from praisonaiagents.config.feature_configs import LearnBackend
|
||||
config = LearnConfig(
|
||||
persona=True, insights=True, patterns=False,
|
||||
backend=LearnBackend.SQLITE,
|
||||
)
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
|
||||
# Store
|
||||
entry = manager.capture_persona("Prefers dark mode")
|
||||
assert entry is not None
|
||||
assert "dark mode" in entry.content
|
||||
|
||||
# Retrieve
|
||||
context = manager.get_persona_context()
|
||||
assert any("dark mode" in c for c in context)
|
||||
|
||||
def test_file_backend_unchanged(self):
|
||||
"""FILE backend works as before (default)."""
|
||||
config = LearnConfig(persona=True)
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
entry = manager.capture_persona("Prefers light mode")
|
||||
assert entry is not None
|
||||
|
||||
# Verify JSON file exists
|
||||
store_files = list(Path(self.temp_dir).rglob("*.json"))
|
||||
assert len(store_files) > 0
|
||||
|
||||
def test_redis_fallback_when_unavailable(self):
|
||||
"""Redis backend falls back to FILE when Redis not running."""
|
||||
import warnings
|
||||
from praisonaiagents.config.feature_configs import LearnBackend
|
||||
config = LearnConfig(
|
||||
persona=True,
|
||||
backend=LearnBackend.REDIS,
|
||||
db_url="redis://localhost:59999", # unlikely port
|
||||
)
|
||||
with warnings.catch_warnings(record=True):
|
||||
warnings.simplefilter("always")
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
|
||||
# Should still work with FILE fallback
|
||||
entry = manager.capture_persona("Redis fallback test")
|
||||
assert entry is not None
|
||||
|
||||
def test_mongodb_warns_and_falls_back(self):
|
||||
"""MongoDB warns and falls back to FILE."""
|
||||
from praisonaiagents.config.feature_configs import LearnBackend
|
||||
config = LearnConfig(
|
||||
persona=True,
|
||||
backend=LearnBackend.MONGODB,
|
||||
)
|
||||
# Should not crash, falls back to FILE
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
entry = manager.capture_persona("Mongo fallback test")
|
||||
assert entry is not None
|
||||
|
||||
|
||||
class TestWasUpdated:
|
||||
"""P3-B: Test was_updated tracking on BaseStore."""
|
||||
|
||||
def setup_method(self):
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
|
||||
def teardown_method(self):
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_was_updated_false_initially(self):
|
||||
"""New store starts with was_updated=False."""
|
||||
store = PersonaStore(store_path=os.path.join(self.temp_dir, "persona.json"))
|
||||
assert store.was_updated is False
|
||||
|
||||
def test_was_updated_true_after_add(self):
|
||||
"""was_updated becomes True after add()."""
|
||||
store = PersonaStore(store_path=os.path.join(self.temp_dir, "persona.json"))
|
||||
store.add("test preference")
|
||||
assert store.was_updated is True
|
||||
|
||||
def test_was_updated_true_after_update(self):
|
||||
"""was_updated becomes True after update()."""
|
||||
store = PersonaStore(store_path=os.path.join(self.temp_dir, "persona.json"))
|
||||
entry = store.add("preference")
|
||||
store.reset_updated()
|
||||
store.update(entry.id, "updated preference")
|
||||
assert store.was_updated is True
|
||||
|
||||
def test_was_updated_true_after_delete(self):
|
||||
"""was_updated becomes True after delete()."""
|
||||
store = PersonaStore(store_path=os.path.join(self.temp_dir, "persona.json"))
|
||||
entry = store.add("to delete")
|
||||
store.reset_updated()
|
||||
store.delete(entry.id)
|
||||
assert store.was_updated is True
|
||||
|
||||
def test_was_updated_true_after_clear(self):
|
||||
"""was_updated becomes True after clear()."""
|
||||
store = PersonaStore(store_path=os.path.join(self.temp_dir, "persona.json"))
|
||||
store.add("something")
|
||||
store.reset_updated()
|
||||
store.clear()
|
||||
assert store.was_updated is True
|
||||
|
||||
def test_reset_updated(self):
|
||||
"""reset_updated() sets flag back to False."""
|
||||
store = PersonaStore(store_path=os.path.join(self.temp_dir, "persona.json"))
|
||||
store.add("test")
|
||||
assert store.was_updated is True
|
||||
store.reset_updated()
|
||||
assert store.was_updated is False
|
||||
|
||||
|
||||
class TestProposeMode:
|
||||
"""P3-C: Test PROPOSE mode — extract but don't auto-store."""
|
||||
|
||||
def setup_method(self):
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
|
||||
def teardown_method(self):
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_pending_learnings_initially_empty(self):
|
||||
"""Pending learnings list starts empty."""
|
||||
config = LearnConfig(persona=True, mode="propose")
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
assert manager.pending_learnings == []
|
||||
|
||||
def test_add_pending_learning(self):
|
||||
"""Can add a pending learning manually."""
|
||||
config = LearnConfig(persona=True, mode="propose")
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
manager.add_pending("User prefers Python", category="persona")
|
||||
assert len(manager.pending_learnings) == 1
|
||||
assert "Python" in manager.pending_learnings[0].content
|
||||
|
||||
def test_approve_learning_moves_to_store(self):
|
||||
"""Approving a pending learning moves it to the main store."""
|
||||
config = LearnConfig(persona=True, mode="propose")
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
manager.add_pending("User likes concise answers", category="persona")
|
||||
pending_id = manager.pending_learnings[0].id
|
||||
|
||||
result = manager.approve_learning(pending_id)
|
||||
assert result is True
|
||||
assert len(manager.pending_learnings) == 0
|
||||
# Should now be in persona store
|
||||
personas = manager.get_persona_context()
|
||||
assert any("concise" in p for p in personas)
|
||||
|
||||
def test_reject_learning_discards(self):
|
||||
"""Rejecting a pending learning discards it."""
|
||||
config = LearnConfig(persona=True, mode="propose")
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
manager.add_pending("Bad learning", category="persona")
|
||||
pending_id = manager.pending_learnings[0].id
|
||||
|
||||
result = manager.reject_learning(pending_id)
|
||||
assert result is True
|
||||
assert len(manager.pending_learnings) == 0
|
||||
# Should NOT be in any store
|
||||
personas = manager.get_persona_context()
|
||||
assert not any("Bad learning" in p for p in personas)
|
||||
|
||||
def test_approve_all_learnings(self):
|
||||
"""Bulk approve all pending learnings."""
|
||||
config = LearnConfig(persona=True, insights=True, mode="propose")
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
manager.add_pending("Preference A", category="persona")
|
||||
manager.add_pending("Insight B", category="insights")
|
||||
assert len(manager.pending_learnings) == 2
|
||||
|
||||
count = manager.approve_all_learnings()
|
||||
assert count == 2
|
||||
assert len(manager.pending_learnings) == 0
|
||||
|
||||
def test_reject_nonexistent_learning(self):
|
||||
"""Rejecting a nonexistent ID returns False."""
|
||||
config = LearnConfig(persona=True, mode="propose")
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
result = manager.reject_learning("nonexistent_id")
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestNoCortexParam:
|
||||
"""Test that cortex parameter does not exist."""
|
||||
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
"""
|
||||
TDD Tests for Learn Module Gap Fixes.
|
||||
|
||||
Tests for:
|
||||
- Gap 1: process_conversation() auto-learning
|
||||
- Gap 2: LearnProtocol for extensibility
|
||||
- Gap 3: Learning modes (AGENTIC, PROPOSE)
|
||||
- Gap 4: Async in learn module
|
||||
- Gap 5: DB backend support in LearnConfig
|
||||
"""
|
||||
|
||||
# Tests for Learn Module Gap Fixes
|
||||
|
||||
|
||||
class TestLearnProtocols:
|
||||
"""Test LearnProtocol and related protocols."""
|
||||
|
||||
def test_learn_protocol_exported(self):
|
||||
"""LearnProtocol should be exported from top-level."""
|
||||
from praisonaiagents import LearnProtocol
|
||||
assert LearnProtocol is not None
|
||||
|
||||
def test_async_learn_protocol_exported(self):
|
||||
"""AsyncLearnProtocol should be exported from top-level."""
|
||||
from praisonaiagents import AsyncLearnProtocol
|
||||
assert AsyncLearnProtocol is not None
|
||||
|
||||
def test_learn_manager_protocol_exported(self):
|
||||
"""LearnManagerProtocol should be exported from top-level."""
|
||||
from praisonaiagents import LearnManagerProtocol
|
||||
assert LearnManagerProtocol is not None
|
||||
|
||||
def test_learn_manager_exported(self):
|
||||
"""LearnManager should be exported from top-level."""
|
||||
from praisonaiagents import LearnManager
|
||||
assert LearnManager is not None
|
||||
|
||||
def test_custom_store_satisfies_protocol(self):
|
||||
"""Custom store should satisfy LearnProtocol."""
|
||||
from praisonaiagents import LearnProtocol
|
||||
|
||||
class CustomLearnStore:
|
||||
def add(self, content, metadata=None):
|
||||
return {"id": "test", "content": content}
|
||||
|
||||
def search(self, query, limit=10):
|
||||
return []
|
||||
|
||||
def list_all(self, limit=100):
|
||||
return []
|
||||
|
||||
def get(self, entry_id):
|
||||
return None
|
||||
|
||||
def update(self, entry_id, content, metadata=None):
|
||||
return None
|
||||
|
||||
def delete(self, entry_id):
|
||||
return True
|
||||
|
||||
def clear(self):
|
||||
return 0
|
||||
|
||||
store = CustomLearnStore()
|
||||
assert isinstance(store, LearnProtocol)
|
||||
|
||||
|
||||
class TestLearnModes:
|
||||
"""Test LearnMode enum and configuration."""
|
||||
|
||||
def test_learn_mode_exported(self):
|
||||
"""LearnMode should be exported from top-level."""
|
||||
from praisonaiagents import LearnMode
|
||||
assert LearnMode is not None
|
||||
|
||||
def test_learn_mode_values(self):
|
||||
"""LearnMode should have DISABLED, AGENTIC, PROPOSE values."""
|
||||
from praisonaiagents import LearnMode
|
||||
|
||||
assert LearnMode.DISABLED.value == "disabled"
|
||||
assert LearnMode.AGENTIC.value == "agentic"
|
||||
assert LearnMode.PROPOSE.value == "propose"
|
||||
|
||||
def test_learn_config_has_mode(self):
|
||||
"""LearnConfig should have mode parameter."""
|
||||
from praisonaiagents import LearnConfig, LearnMode
|
||||
|
||||
config = LearnConfig(mode=LearnMode.AGENTIC)
|
||||
assert config.mode == LearnMode.AGENTIC
|
||||
|
||||
def test_learn_config_mode_default_disabled(self):
|
||||
"""LearnConfig mode should default to DISABLED."""
|
||||
from praisonaiagents import LearnConfig, LearnMode
|
||||
|
||||
config = LearnConfig()
|
||||
assert config.mode == LearnMode.DISABLED
|
||||
|
||||
def test_learn_config_mode_string(self):
|
||||
"""LearnConfig should accept mode as string."""
|
||||
from praisonaiagents import LearnConfig
|
||||
|
||||
config = LearnConfig(mode="agentic")
|
||||
assert config.mode == "agentic"
|
||||
|
||||
|
||||
class TestLearnBackend:
|
||||
"""Test LearnBackend enum and configuration."""
|
||||
|
||||
def test_learn_backend_exported(self):
|
||||
"""LearnBackend should be exported from top-level."""
|
||||
from praisonaiagents import LearnBackend
|
||||
assert LearnBackend is not None
|
||||
|
||||
def test_learn_backend_values(self):
|
||||
"""LearnBackend should have FILE, SQLITE, REDIS, MONGODB values."""
|
||||
from praisonaiagents import LearnBackend
|
||||
|
||||
assert LearnBackend.FILE.value == "file"
|
||||
assert LearnBackend.SQLITE.value == "sqlite"
|
||||
assert LearnBackend.REDIS.value == "redis"
|
||||
assert LearnBackend.MONGODB.value == "mongodb"
|
||||
|
||||
def test_learn_config_has_backend(self):
|
||||
"""LearnConfig should have backend parameter."""
|
||||
from praisonaiagents import LearnConfig, LearnBackend
|
||||
|
||||
config = LearnConfig(backend=LearnBackend.SQLITE)
|
||||
assert config.backend == LearnBackend.SQLITE
|
||||
|
||||
def test_learn_config_has_db_url(self):
|
||||
"""LearnConfig should have db_url parameter."""
|
||||
from praisonaiagents import LearnConfig
|
||||
|
||||
config = LearnConfig(
|
||||
backend="sqlite",
|
||||
db_url="sqlite:///learn.db"
|
||||
)
|
||||
assert config.db_url == "sqlite:///learn.db"
|
||||
|
||||
def test_learn_config_has_llm(self):
|
||||
"""LearnConfig should have llm parameter for extraction."""
|
||||
from praisonaiagents import LearnConfig
|
||||
|
||||
config = LearnConfig(llm="gpt-4o")
|
||||
assert config.llm == "gpt-4o"
|
||||
|
||||
|
||||
class TestProcessConversation:
|
||||
"""Test process_conversation() auto-learning method."""
|
||||
|
||||
def test_learn_manager_has_process_conversation(self):
|
||||
"""LearnManager should have process_conversation method."""
|
||||
from praisonaiagents.memory.learn import LearnManager
|
||||
|
||||
manager = LearnManager()
|
||||
assert hasattr(manager, 'process_conversation')
|
||||
assert callable(manager.process_conversation)
|
||||
|
||||
def test_learn_manager_has_aprocess_conversation(self):
|
||||
"""LearnManager should have async aprocess_conversation method."""
|
||||
from praisonaiagents.memory.learn import LearnManager
|
||||
|
||||
manager = LearnManager()
|
||||
assert hasattr(manager, 'aprocess_conversation')
|
||||
assert callable(manager.aprocess_conversation)
|
||||
|
||||
def test_process_conversation_empty_messages(self):
|
||||
"""process_conversation should handle empty messages."""
|
||||
from praisonaiagents.memory.learn import LearnManager
|
||||
|
||||
manager = LearnManager()
|
||||
result = manager.process_conversation([])
|
||||
|
||||
assert result == {"persona": [], "insights": [], "patterns": [], "stored": {}}
|
||||
|
||||
def test_process_conversation_signature(self):
|
||||
"""process_conversation should have correct signature."""
|
||||
from praisonaiagents.memory.learn import LearnManager
|
||||
import inspect
|
||||
|
||||
manager = LearnManager()
|
||||
sig = inspect.signature(manager.process_conversation)
|
||||
params = list(sig.parameters.keys())
|
||||
|
||||
assert "messages" in params
|
||||
assert "llm" in params
|
||||
|
||||
|
||||
class TestLearnConfigToDict:
|
||||
"""Test LearnConfig.to_dict() includes new fields."""
|
||||
|
||||
def test_to_dict_includes_mode(self):
|
||||
"""to_dict should include mode."""
|
||||
from praisonaiagents import LearnConfig, LearnMode
|
||||
|
||||
config = LearnConfig(mode=LearnMode.AGENTIC)
|
||||
d = config.to_dict()
|
||||
|
||||
assert "mode" in d
|
||||
assert d["mode"] == "agentic"
|
||||
|
||||
def test_to_dict_includes_backend(self):
|
||||
"""to_dict should include backend."""
|
||||
from praisonaiagents import LearnConfig, LearnBackend
|
||||
|
||||
config = LearnConfig(backend=LearnBackend.SQLITE)
|
||||
d = config.to_dict()
|
||||
|
||||
assert "backend" in d
|
||||
assert d["backend"] == "sqlite"
|
||||
|
||||
def test_to_dict_includes_db_url(self):
|
||||
"""to_dict should include db_url."""
|
||||
from praisonaiagents import LearnConfig
|
||||
|
||||
config = LearnConfig(db_url="sqlite:///test.db")
|
||||
d = config.to_dict()
|
||||
|
||||
assert "db_url" in d
|
||||
assert d["db_url"] == "sqlite:///test.db"
|
||||
|
||||
def test_to_dict_includes_llm(self):
|
||||
"""to_dict should include llm."""
|
||||
from praisonaiagents import LearnConfig
|
||||
|
||||
config = LearnConfig(llm="gpt-4o")
|
||||
d = config.to_dict()
|
||||
|
||||
assert "llm" in d
|
||||
assert d["llm"] == "gpt-4o"
|
||||
|
||||
|
||||
class TestAgentWithLearnMode:
|
||||
"""Test Agent with learn mode configuration."""
|
||||
|
||||
def test_agent_accepts_learn_config_with_mode(self):
|
||||
"""Agent should accept LearnConfig with mode."""
|
||||
from praisonaiagents import Agent, LearnConfig, LearnMode
|
||||
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
learn=LearnConfig(
|
||||
mode=LearnMode.AGENTIC,
|
||||
persona=True,
|
||||
insights=True,
|
||||
),
|
||||
)
|
||||
|
||||
assert agent._learn_config is not None
|
||||
assert agent._learn_config.mode == LearnMode.AGENTIC
|
||||
|
||||
def test_agent_accepts_learn_config_with_backend(self):
|
||||
"""Agent should accept LearnConfig with backend."""
|
||||
from praisonaiagents import Agent, LearnConfig, LearnBackend
|
||||
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
learn=LearnConfig(
|
||||
backend=LearnBackend.SQLITE,
|
||||
db_url="sqlite:///test.db",
|
||||
),
|
||||
)
|
||||
|
||||
assert agent._learn_config is not None
|
||||
assert agent._learn_config.backend == LearnBackend.SQLITE
|
||||
|
||||
def test_agent_has_process_auto_learning_method(self):
|
||||
"""Agent should have _process_auto_learning method."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(name="test", instructions="Test agent")
|
||||
assert hasattr(agent, '_process_auto_learning')
|
||||
assert callable(agent._process_auto_learning)
|
||||
|
||||
|
||||
class TestCustomStores:
|
||||
"""Test custom_stores support in LearnManager."""
|
||||
|
||||
def test_learn_manager_accepts_custom_stores(self):
|
||||
"""LearnManager should accept custom_stores parameter."""
|
||||
from praisonaiagents import LearnManager, LearnConfig
|
||||
|
||||
class MyCustomStore:
|
||||
def __init__(self):
|
||||
self.data = []
|
||||
def add(self, content, metadata=None):
|
||||
self.data.append(content)
|
||||
return {"id": "1", "content": content}
|
||||
def search(self, query, limit=10):
|
||||
return []
|
||||
def list_all(self, limit=100):
|
||||
return self.data
|
||||
def get(self, entry_id):
|
||||
return None
|
||||
def update(self, entry_id, content, metadata=None):
|
||||
return None
|
||||
def delete(self, entry_id):
|
||||
return True
|
||||
def clear(self):
|
||||
return 0
|
||||
|
||||
custom_store = MyCustomStore()
|
||||
manager = LearnManager(
|
||||
config=LearnConfig(persona=True),
|
||||
custom_stores={"domain": custom_store}
|
||||
)
|
||||
|
||||
assert "domain" in manager._stores
|
||||
assert manager._stores["domain"] is custom_store
|
||||
|
||||
def test_custom_stores_can_override_builtin(self):
|
||||
"""Custom stores can override built-in stores."""
|
||||
from praisonaiagents import LearnManager, LearnConfig
|
||||
|
||||
class MyPersonaStore:
|
||||
def add(self, content, metadata=None):
|
||||
return {"id": "custom", "content": content}
|
||||
def search(self, query, limit=10):
|
||||
return []
|
||||
def list_all(self, limit=100):
|
||||
return []
|
||||
def get(self, entry_id):
|
||||
return None
|
||||
def update(self, entry_id, content, metadata=None):
|
||||
return None
|
||||
def delete(self, entry_id):
|
||||
return True
|
||||
def clear(self):
|
||||
return 0
|
||||
|
||||
custom_persona = MyPersonaStore()
|
||||
manager = LearnManager(
|
||||
config=LearnConfig(persona=True),
|
||||
custom_stores={"persona": custom_persona}
|
||||
)
|
||||
|
||||
# Custom stores should override built-in stores
|
||||
assert "persona" in manager._stores
|
||||
assert manager._stores["persona"] is custom_persona
|
||||
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
TDD tests for TOOL_CALL_START and TOOL_CALL_RESULT stream events.
|
||||
|
||||
These events provide complete parsed arguments (dict) for tool calls,
|
||||
enabling consumers like AIUI to display keyword-rich descriptions.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
class TestStreamEventTypeEnumValues:
|
||||
"""Test that new StreamEventType values exist."""
|
||||
|
||||
def test_tool_call_start_event_type_exists(self):
|
||||
"""Verify TOOL_CALL_START enum value exists."""
|
||||
from praisonaiagents.streaming.events import StreamEventType
|
||||
assert hasattr(StreamEventType, 'TOOL_CALL_START')
|
||||
assert StreamEventType.TOOL_CALL_START.value == "tool_call_start"
|
||||
|
||||
def test_tool_call_result_event_type_exists(self):
|
||||
"""Verify TOOL_CALL_RESULT enum value exists."""
|
||||
from praisonaiagents.streaming.events import StreamEventType
|
||||
assert hasattr(StreamEventType, 'TOOL_CALL_RESULT')
|
||||
assert StreamEventType.TOOL_CALL_RESULT.value == "tool_call_result"
|
||||
|
||||
def test_existing_events_unchanged(self):
|
||||
"""Verify existing event types are unchanged for backward compat."""
|
||||
from praisonaiagents.streaming.events import StreamEventType
|
||||
assert StreamEventType.DELTA_TOOL_CALL.value == "delta_tool_call"
|
||||
assert StreamEventType.TOOL_CALL_END.value == "tool_call_end"
|
||||
assert StreamEventType.REQUEST_START.value == "request_start"
|
||||
assert StreamEventType.STREAM_END.value == "stream_end"
|
||||
|
||||
|
||||
class TestStreamEventCreation:
|
||||
"""Test creating StreamEvent with new types."""
|
||||
|
||||
def test_create_tool_call_start_event(self):
|
||||
"""Verify TOOL_CALL_START event can be created with parsed args dict."""
|
||||
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
|
||||
|
||||
event = StreamEvent(
|
||||
type=StreamEventType.TOOL_CALL_START,
|
||||
tool_call={
|
||||
"name": "search_web",
|
||||
"arguments": {"query": "Django tutorials"}, # Parsed dict, NOT JSON string
|
||||
"id": "call_abc123",
|
||||
},
|
||||
agent_id="test_agent",
|
||||
)
|
||||
|
||||
assert event.type == StreamEventType.TOOL_CALL_START
|
||||
assert event.tool_call["name"] == "search_web"
|
||||
assert isinstance(event.tool_call["arguments"], dict)
|
||||
assert event.tool_call["arguments"]["query"] == "Django tutorials"
|
||||
assert event.agent_id == "test_agent"
|
||||
|
||||
def test_create_tool_call_result_event(self):
|
||||
"""Verify TOOL_CALL_RESULT event can be created with result."""
|
||||
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
|
||||
|
||||
event = StreamEvent(
|
||||
type=StreamEventType.TOOL_CALL_RESULT,
|
||||
tool_call={
|
||||
"name": "search_web",
|
||||
"arguments": {"query": "Django tutorials"},
|
||||
"result": "Found 10 results for Django tutorials",
|
||||
"id": "call_abc123",
|
||||
},
|
||||
agent_id="test_agent",
|
||||
metadata={"duration_ms": 150.5},
|
||||
)
|
||||
|
||||
assert event.type == StreamEventType.TOOL_CALL_RESULT
|
||||
assert event.tool_call["result"] == "Found 10 results for Django tutorials"
|
||||
assert event.metadata["duration_ms"] == 150.5
|
||||
|
||||
|
||||
class TestStreamEventEmitterWithNewEvents:
|
||||
"""Test StreamEventEmitter emits new event types correctly."""
|
||||
|
||||
def test_emitter_emits_tool_call_start(self):
|
||||
"""Verify emitter can emit TOOL_CALL_START events."""
|
||||
from praisonaiagents.streaming.events import (
|
||||
StreamEventEmitter, StreamEvent, StreamEventType
|
||||
)
|
||||
|
||||
emitter = StreamEventEmitter()
|
||||
received_events = []
|
||||
|
||||
def callback(event):
|
||||
received_events.append(event)
|
||||
|
||||
emitter.add_callback(callback)
|
||||
|
||||
event = StreamEvent(
|
||||
type=StreamEventType.TOOL_CALL_START,
|
||||
tool_call={
|
||||
"name": "read_file",
|
||||
"arguments": {"filepath": "/tmp/test.txt"},
|
||||
"id": "call_xyz789",
|
||||
},
|
||||
)
|
||||
emitter.emit(event)
|
||||
|
||||
assert len(received_events) == 1
|
||||
assert received_events[0].type == StreamEventType.TOOL_CALL_START
|
||||
assert received_events[0].tool_call["arguments"]["filepath"] == "/tmp/test.txt"
|
||||
|
||||
def test_emitter_emits_tool_call_result(self):
|
||||
"""Verify emitter can emit TOOL_CALL_RESULT events."""
|
||||
from praisonaiagents.streaming.events import (
|
||||
StreamEventEmitter, StreamEvent, StreamEventType
|
||||
)
|
||||
|
||||
emitter = StreamEventEmitter()
|
||||
received_events = []
|
||||
|
||||
def callback(event):
|
||||
received_events.append(event)
|
||||
|
||||
emitter.add_callback(callback)
|
||||
|
||||
event = StreamEvent(
|
||||
type=StreamEventType.TOOL_CALL_RESULT,
|
||||
tool_call={
|
||||
"name": "read_file",
|
||||
"arguments": {"filepath": "/tmp/test.txt"},
|
||||
"result": "File contents here...",
|
||||
"id": "call_xyz789",
|
||||
},
|
||||
)
|
||||
emitter.emit(event)
|
||||
|
||||
assert len(received_events) == 1
|
||||
assert received_events[0].type == StreamEventType.TOOL_CALL_RESULT
|
||||
assert received_events[0].tool_call["result"] == "File contents here..."
|
||||
|
||||
def test_no_callbacks_no_overhead(self):
|
||||
"""Verify no overhead when no callbacks registered."""
|
||||
from praisonaiagents.streaming.events import (
|
||||
StreamEventEmitter, StreamEvent, StreamEventType
|
||||
)
|
||||
|
||||
emitter = StreamEventEmitter()
|
||||
assert not emitter.has_callbacks
|
||||
|
||||
# Should not raise, just no-op
|
||||
event = StreamEvent(
|
||||
type=StreamEventType.TOOL_CALL_START,
|
||||
tool_call={"name": "test", "arguments": {}},
|
||||
)
|
||||
emitter.emit(event) # No error, no callbacks called
|
||||
|
||||
|
||||
class TestToolCallEventSemantics:
|
||||
"""Test the semantic differences between tool call event types."""
|
||||
|
||||
def test_delta_vs_start_event_args_type(self):
|
||||
"""
|
||||
DELTA_TOOL_CALL has partial JSON string args.
|
||||
TOOL_CALL_START has complete parsed dict args.
|
||||
"""
|
||||
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
|
||||
|
||||
# DELTA_TOOL_CALL - partial JSON string (as received from LLM streaming)
|
||||
delta_event = StreamEvent(
|
||||
type=StreamEventType.DELTA_TOOL_CALL,
|
||||
tool_call={
|
||||
"name": "search_web",
|
||||
"arguments": '{"qu', # Partial JSON string
|
||||
"id": "call_123",
|
||||
},
|
||||
)
|
||||
|
||||
# TOOL_CALL_START - complete parsed dict (at execution time)
|
||||
start_event = StreamEvent(
|
||||
type=StreamEventType.TOOL_CALL_START,
|
||||
tool_call={
|
||||
"name": "search_web",
|
||||
"arguments": {"query": "Django"}, # Complete parsed dict
|
||||
"id": "call_123",
|
||||
},
|
||||
)
|
||||
|
||||
# Delta has string args
|
||||
assert isinstance(delta_event.tool_call["arguments"], str)
|
||||
|
||||
# Start has dict args
|
||||
assert isinstance(start_event.tool_call["arguments"], dict)
|
||||
assert "query" in start_event.tool_call["arguments"]
|
||||
|
||||
|
||||
class TestToolCallIdPropagation:
|
||||
"""Test that tool_call_id is properly threaded through execute_tool."""
|
||||
|
||||
def test_execute_tool_accepts_tool_call_id(self):
|
||||
"""Verify execute_tool() accepts tool_call_id parameter."""
|
||||
from praisonaiagents import Agent
|
||||
import inspect
|
||||
|
||||
sig = inspect.signature(Agent.execute_tool)
|
||||
params = list(sig.parameters.keys())
|
||||
assert 'tool_call_id' in params, "execute_tool should accept tool_call_id parameter"
|
||||
|
||||
def test_execute_tool_with_context_accepts_tool_call_id(self):
|
||||
"""Verify _execute_tool_with_context() accepts tool_call_id parameter."""
|
||||
from praisonaiagents import Agent
|
||||
import inspect
|
||||
|
||||
sig = inspect.signature(Agent._execute_tool_with_context)
|
||||
params = list(sig.parameters.keys())
|
||||
assert 'tool_call_id' in params, "_execute_tool_with_context should accept tool_call_id parameter"
|
||||
|
||||
def test_tool_call_id_in_stream_event(self):
|
||||
"""Verify tool_call_id can be included in StreamEvent."""
|
||||
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
|
||||
|
||||
event = StreamEvent(
|
||||
type=StreamEventType.TOOL_CALL_START,
|
||||
tool_call={
|
||||
"name": "test_tool",
|
||||
"arguments": {"arg": "value"},
|
||||
"id": "call_abc123xyz", # The tool_call_id
|
||||
},
|
||||
)
|
||||
|
||||
assert event.tool_call["id"] == "call_abc123xyz"
|
||||
@@ -23,14 +23,18 @@ from unittest.mock import patch, MagicMock, PropertyMock
|
||||
class TestDoomLoopFiring:
|
||||
"""G1/G2: Doom loop detection must actually fire in run_autonomous()."""
|
||||
|
||||
def test_doom_loop_fires_when_agent_repeats_same_response(self):
|
||||
@patch("praisonaiagents.agent.agent.Agent.get_recommended_stage", return_value="heuristic")
|
||||
def test_doom_loop_fires_when_agent_repeats_same_response(self, mock_stage):
|
||||
"""run_autonomous() must call _record_action() so DoomLoopTracker gets data."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="test_doom",
|
||||
instructions="You are a test agent",
|
||||
autonomy={"max_iterations": 10, "doom_loop_threshold": 3},
|
||||
# Need enough iterations for graduated recovery to exhaust all 4 stages:
|
||||
# threshold=3 means 3 identical actions per doom-loop cycle, 4 recovery
|
||||
# stages, plus check iterations = ~16 iterations minimum.
|
||||
autonomy={"max_iterations": 25, "doom_loop_threshold": 3},
|
||||
)
|
||||
|
||||
# Mock chat to return the same response every time
|
||||
@@ -43,16 +47,16 @@ class TestDoomLoopFiring:
|
||||
|
||||
agent.chat = mock_chat
|
||||
|
||||
# Use a prompt that triggers autonomous stage (multi-step)
|
||||
# so the loop runs multiple iterations instead of exiting after 1 (direct)
|
||||
result = agent.run_autonomous("First refactor the auth module, then test it step by step")
|
||||
|
||||
# Doom loop should have been detected before max_iterations
|
||||
assert result.completion_reason == "doom_loop", (
|
||||
f"Expected doom_loop but got {result.completion_reason} after {result.iterations} iterations"
|
||||
# Graduated recovery produces needs_help (stage 3) or doom_loop (stage 4).
|
||||
# Both are valid doom-loop-family abort outcomes.
|
||||
assert result.completion_reason in ("doom_loop", "needs_help"), (
|
||||
f"Expected doom_loop or needs_help but got {result.completion_reason} "
|
||||
f"after {result.iterations} iterations"
|
||||
)
|
||||
assert result.success is False
|
||||
assert result.iterations < 10 # Should stop well before max
|
||||
assert result.iterations < 25 # Should stop well before max
|
||||
|
||||
def test_doom_loop_does_not_fire_with_varied_responses(self):
|
||||
"""Varied responses should NOT trigger doom loop."""
|
||||
|
||||
@@ -153,18 +153,25 @@ 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
|
||||
assert result.completion_reason == "doom_loop"
|
||||
# Graduated recovery produces needs_help (stage 3) or doom_loop (stage 4).
|
||||
# Both are valid doom-loop-family abort outcomes.
|
||||
assert result.completion_reason in ("doom_loop", "needs_help"), (
|
||||
f"Expected doom_loop or needs_help but got {result.completion_reason}"
|
||||
)
|
||||
|
||||
@patch("praisonaiagents.agent.agent.Agent.chat")
|
||||
@patch("praisonaiagents.agent.agent.Agent.get_recommended_stage", return_value="heuristic")
|
||||
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={
|
||||
@@ -174,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
|
||||
@@ -541,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
|
||||
@@ -232,7 +232,10 @@ class TestMultiChannelGatewayConfig:
|
||||
"""Test MultiChannelGatewayConfig with defaults."""
|
||||
from praisonaiagents.gateway import MultiChannelGatewayConfig
|
||||
config = MultiChannelGatewayConfig()
|
||||
assert isinstance(config.gateway, GatewayConfig)
|
||||
# Use qualname check to be robust against module reimport identity issues
|
||||
assert type(config.gateway).__qualname__ == "GatewayConfig", (
|
||||
f"Expected GatewayConfig, got {type(config.gateway).__qualname__}"
|
||||
)
|
||||
assert config.agents == {}
|
||||
assert config.channels == {}
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,336 @@
|
||||
"""TDD Tests for Memory vs History Separation Fixes.
|
||||
|
||||
Tests G-1 (auto_save_session routes to SessionStore) and
|
||||
G-2 (Session._save_agent_chat_histories routes to SessionStore).
|
||||
|
||||
These tests verify the clean separation between:
|
||||
- Memory = semantic facts/entities (injected into system prompt)
|
||||
- History = conversation turns (injected into message array)
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
class TestG1AutoSaveSessionRouting:
|
||||
"""G-1: _auto_save_session() should route to SessionStore first."""
|
||||
|
||||
def test_auto_save_uses_session_store_when_available(self):
|
||||
"""When SessionStore is available, use it instead of Memory.save_session()."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Create agent with auto_save
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
auto_save="test_session",
|
||||
)
|
||||
|
||||
# Mock session store
|
||||
mock_session_store = MagicMock()
|
||||
agent._session_store = mock_session_store
|
||||
|
||||
# Add some chat history
|
||||
agent.chat_history = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there!"},
|
||||
]
|
||||
|
||||
# Call _auto_save_session
|
||||
agent._auto_save_session()
|
||||
|
||||
# Verify SessionStore.add_message was called
|
||||
assert mock_session_store.add_message.call_count == 2
|
||||
|
||||
def test_auto_save_falls_back_to_memory_when_no_session_store(self):
|
||||
"""When SessionStore is not available, fall back to Memory.save_session()."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Create agent with auto_save and memory
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
auto_save="test_session",
|
||||
)
|
||||
|
||||
# No session store
|
||||
agent._session_store = None
|
||||
|
||||
# Mock memory instance
|
||||
mock_memory = MagicMock()
|
||||
mock_memory.save_session = MagicMock()
|
||||
agent._memory_instance = mock_memory
|
||||
|
||||
# Add some chat history
|
||||
agent.chat_history = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
|
||||
# Call _auto_save_session
|
||||
agent._auto_save_session()
|
||||
|
||||
# Verify Memory.save_session was called as fallback
|
||||
mock_memory.save_session.assert_called_once()
|
||||
|
||||
def test_auto_save_does_nothing_when_disabled(self):
|
||||
"""When auto_save is not set, do nothing."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
)
|
||||
|
||||
# No auto_save set
|
||||
agent.auto_save = None
|
||||
|
||||
# Mock session store
|
||||
mock_session_store = MagicMock()
|
||||
agent._session_store = mock_session_store
|
||||
|
||||
# Call _auto_save_session
|
||||
agent._auto_save_session()
|
||||
|
||||
# Verify nothing was called
|
||||
mock_session_store.add_message.assert_not_called()
|
||||
|
||||
|
||||
class TestG2SessionSaveAgentChatHistories:
|
||||
"""G-2: Session._save_agent_chat_histories() should route to SessionStore first."""
|
||||
|
||||
def test_save_agent_histories_routes_to_session_store(self):
|
||||
"""Verify _save_agent_chat_histories has SessionStore routing logic."""
|
||||
from praisonaiagents.session import Session
|
||||
import inspect
|
||||
|
||||
# Verify the method exists and has the G-2 fix comment
|
||||
source = inspect.getsource(Session._save_agent_chat_histories)
|
||||
|
||||
# Check that the G-2 fix is present
|
||||
assert "G-2 FIX" in source, "G-2 fix should be documented in the method"
|
||||
assert "SessionStore" in source, "Method should reference SessionStore"
|
||||
assert "get_default_session_store" in source, "Method should try to get SessionStore"
|
||||
|
||||
def test_save_agent_histories_behavioral(self):
|
||||
"""Issue 3 FIX: Behavioral test - verify actual routing to SessionStore."""
|
||||
from praisonaiagents.session import Session
|
||||
|
||||
# Create session
|
||||
session = Session(session_id="test_behavioral", user_id="test_user")
|
||||
|
||||
# Create mock agent with chat history
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.chat_history = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi!"},
|
||||
]
|
||||
|
||||
# Add agent to session
|
||||
session._agents["test_agent"] = {
|
||||
"agent": mock_agent,
|
||||
"chat_history": []
|
||||
}
|
||||
|
||||
# Call the method - it should not raise an exception
|
||||
# This tests the actual behavior, not just source code
|
||||
session._save_agent_chat_histories()
|
||||
|
||||
|
||||
class TestIssue1DuplicatePrevention:
|
||||
"""Issue 1: Verify duplicate history insertion is prevented."""
|
||||
|
||||
def test_auto_save_tracks_last_saved_index(self):
|
||||
"""Verify _auto_save_session tracks last saved index to prevent duplicates."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
auto_save="test_session",
|
||||
)
|
||||
|
||||
# Mock session store
|
||||
mock_session_store = MagicMock()
|
||||
agent._session_store = mock_session_store
|
||||
|
||||
# Add initial chat history
|
||||
agent.chat_history = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there!"},
|
||||
]
|
||||
|
||||
# First save - should save 2 messages
|
||||
agent._auto_save_session()
|
||||
assert mock_session_store.add_message.call_count == 2
|
||||
|
||||
# Second save with same history - should save 0 new messages
|
||||
mock_session_store.reset_mock()
|
||||
agent._auto_save_session()
|
||||
assert mock_session_store.add_message.call_count == 0, "Should not re-save existing messages"
|
||||
|
||||
# Add new message
|
||||
agent.chat_history.append({"role": "user", "content": "How are you?"})
|
||||
|
||||
# Third save - should save only 1 new message
|
||||
mock_session_store.reset_mock()
|
||||
agent._auto_save_session()
|
||||
assert mock_session_store.add_message.call_count == 1, "Should only save new message"
|
||||
|
||||
|
||||
class TestEdgeCaseClearHistoryResetIndex:
|
||||
"""Edge case: clear_history() must reset _auto_save_last_index."""
|
||||
|
||||
def test_clear_history_resets_auto_save_index(self):
|
||||
"""Verify clear_history() resets _auto_save_last_index to prevent silent message loss."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
auto_save="test_session",
|
||||
)
|
||||
|
||||
mock_store = MagicMock()
|
||||
agent._session_store = mock_store
|
||||
|
||||
# Save initial messages
|
||||
agent.chat_history = [{"role": "user", "content": "msg1"}]
|
||||
agent._auto_save_session()
|
||||
assert mock_store.add_message.call_count == 1
|
||||
assert agent._auto_save_last_index == 1
|
||||
|
||||
# Clear history - index should reset
|
||||
agent.clear_history()
|
||||
assert agent._auto_save_last_index == 0, "Index should reset to 0 after clear_history()"
|
||||
|
||||
# Add new message after clear
|
||||
agent.chat_history = [{"role": "user", "content": "msg2"}]
|
||||
mock_store.reset_mock()
|
||||
agent._auto_save_session()
|
||||
|
||||
# Should save the new message (not skip due to stale index)
|
||||
assert mock_store.add_message.call_count == 1, "New message should be saved after clear"
|
||||
|
||||
def test_prune_history_adjusts_auto_save_index(self):
|
||||
"""Verify prune_history() adjusts _auto_save_last_index."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
auto_save="test_session",
|
||||
)
|
||||
|
||||
mock_store = MagicMock()
|
||||
agent._session_store = mock_store
|
||||
|
||||
# Save 5 messages
|
||||
agent.chat_history = [
|
||||
{"role": "user", "content": f"msg{i}"} for i in range(5)
|
||||
]
|
||||
agent._auto_save_session()
|
||||
assert agent._auto_save_last_index == 5
|
||||
|
||||
# Prune to keep last 2
|
||||
agent.prune_history(keep_last=2)
|
||||
assert len(agent.chat_history) == 2
|
||||
assert agent._auto_save_last_index == 2, "Index should match pruned history length"
|
||||
|
||||
def test_delete_history_adjusts_auto_save_index(self):
|
||||
"""Verify delete_history() adjusts _auto_save_last_index."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
auto_save="test_session",
|
||||
)
|
||||
|
||||
mock_store = MagicMock()
|
||||
agent._session_store = mock_store
|
||||
|
||||
# Save 3 messages
|
||||
agent.chat_history = [
|
||||
{"role": "user", "content": f"msg{i}"} for i in range(3)
|
||||
]
|
||||
agent._auto_save_session()
|
||||
assert agent._auto_save_last_index == 3
|
||||
|
||||
# Delete one message
|
||||
agent.delete_history(0)
|
||||
assert len(agent.chat_history) == 2
|
||||
assert agent._auto_save_last_index == 2, "Index should be adjusted after deletion"
|
||||
|
||||
|
||||
class TestMemoryHistorySeparation:
|
||||
"""Test that Memory and History are properly separated."""
|
||||
|
||||
def test_memory_context_does_not_include_conversation_turns(self):
|
||||
"""Memory.get_context() should return facts/entities, not conversation turns."""
|
||||
from praisonaiagents.memory import FileMemory
|
||||
|
||||
memory = FileMemory(user_id="test_user")
|
||||
|
||||
# Store some facts using correct API
|
||||
memory.add_long_term("User prefers Python programming")
|
||||
memory.add_entity("Alice", "person", {"role": "developer"})
|
||||
|
||||
# Get context
|
||||
context = memory.get_context()
|
||||
|
||||
# Should contain facts and entities
|
||||
assert "Important Facts" in context or "Known Entities" in context or context == ""
|
||||
|
||||
# Should NOT contain conversation turn markers
|
||||
assert "role" not in context.lower() or "user:" not in context.lower()
|
||||
|
||||
def test_session_store_stores_conversation_turns(self):
|
||||
"""SessionStore should store conversation turns with role/content."""
|
||||
from praisonaiagents.session import get_default_session_store
|
||||
|
||||
store = get_default_session_store()
|
||||
|
||||
# Add messages
|
||||
store.add_message("test_session", role="user", content="Hello")
|
||||
store.add_message("test_session", role="assistant", content="Hi!")
|
||||
|
||||
# Get history
|
||||
history = store.get_chat_history("test_session")
|
||||
|
||||
# Should have conversation turns
|
||||
assert len(history) >= 2
|
||||
assert any(msg.get("role") == "user" for msg in history)
|
||||
assert any(msg.get("role") == "assistant" for msg in history)
|
||||
|
||||
|
||||
class TestG3PerTurnPersist:
|
||||
"""G-3: Verify per-turn persist exists via _persist_message()."""
|
||||
|
||||
def test_persist_message_exists(self):
|
||||
"""Agent should have _persist_message method for per-turn persistence."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(name="test", instructions="Test")
|
||||
|
||||
# Verify method exists
|
||||
assert hasattr(agent, '_persist_message')
|
||||
assert callable(agent._persist_message)
|
||||
|
||||
def test_persist_message_uses_session_store(self):
|
||||
"""_persist_message should use SessionStore when available."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Create agent without session_id (it's set via memory config)
|
||||
agent = Agent(name="test", instructions="Test")
|
||||
|
||||
# Mock session store
|
||||
mock_store = MagicMock()
|
||||
agent._session_store = mock_store
|
||||
agent._session_id = "test_session"
|
||||
agent._db = None # No DB adapter
|
||||
|
||||
# Call _persist_message
|
||||
agent._persist_message("user", "Hello")
|
||||
|
||||
# Verify SessionStore was used
|
||||
mock_store.add_user_message.assert_called_once_with("test_session", "Hello")
|
||||
@@ -0,0 +1,348 @@
|
||||
"""
|
||||
Unit tests for scheduler execution history functionality.
|
||||
|
||||
Tests:
|
||||
- RunRecord model serialization
|
||||
- FileScheduleStore.log_run() and get_history()
|
||||
- ConfigYamlScheduleStore.log_run() and get_history()
|
||||
- ScheduleRunner.mark_run() with history params
|
||||
- History limit enforcement
|
||||
- History persistence
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
|
||||
class TestRunRecordModel:
|
||||
"""Tests for RunRecord dataclass."""
|
||||
|
||||
def test_run_record_creation(self):
|
||||
"""Test basic RunRecord creation."""
|
||||
from praisonaiagents.scheduler.models import RunRecord
|
||||
|
||||
record = RunRecord(
|
||||
job_id="test123",
|
||||
job_name="Test Job",
|
||||
status="succeeded",
|
||||
result="Hello world",
|
||||
duration=1.5,
|
||||
delivered=True,
|
||||
)
|
||||
assert record.job_id == "test123"
|
||||
assert record.job_name == "Test Job"
|
||||
assert record.status == "succeeded"
|
||||
assert record.result == "Hello world"
|
||||
assert record.duration == 1.5
|
||||
assert record.delivered is True
|
||||
assert record.error is None
|
||||
assert record.timestamp > 0
|
||||
|
||||
def test_run_record_to_dict(self):
|
||||
"""Test RunRecord serialization to dict."""
|
||||
from praisonaiagents.scheduler.models import RunRecord
|
||||
|
||||
record = RunRecord(
|
||||
job_id="abc",
|
||||
job_name="My Job",
|
||||
status="failed",
|
||||
error="Something went wrong",
|
||||
duration=0.5,
|
||||
timestamp=1234567890.0,
|
||||
)
|
||||
d = record.to_dict()
|
||||
assert d["job_id"] == "abc"
|
||||
assert d["job_name"] == "My Job"
|
||||
assert d["status"] == "failed"
|
||||
assert d["error"] == "Something went wrong"
|
||||
assert d["duration"] == 0.5
|
||||
assert d["timestamp"] == 1234567890.0
|
||||
assert d["delivered"] is False
|
||||
assert d["result"] is None
|
||||
|
||||
def test_run_record_from_dict(self):
|
||||
"""Test RunRecord deserialization from dict."""
|
||||
from praisonaiagents.scheduler.models import RunRecord
|
||||
|
||||
d = {
|
||||
"job_id": "xyz",
|
||||
"job_name": "Restored Job",
|
||||
"status": "skipped",
|
||||
"result": "Skipped result",
|
||||
"error": None,
|
||||
"duration": 2.0,
|
||||
"delivered": False,
|
||||
"timestamp": 9999999999.0,
|
||||
}
|
||||
record = RunRecord.from_dict(d)
|
||||
assert record.job_id == "xyz"
|
||||
assert record.job_name == "Restored Job"
|
||||
assert record.status == "skipped"
|
||||
assert record.result == "Skipped result"
|
||||
assert record.duration == 2.0
|
||||
assert record.timestamp == 9999999999.0
|
||||
|
||||
|
||||
class TestFileScheduleStoreHistory:
|
||||
"""Tests for FileScheduleStore history functionality."""
|
||||
|
||||
def test_log_run_creates_history(self):
|
||||
"""Test that log_run adds a history record."""
|
||||
from praisonaiagents.scheduler.store import FileScheduleStore
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
store = FileScheduleStore(store_dir=tmpdir)
|
||||
assert len(store.get_history()) == 0
|
||||
|
||||
store.log_run(
|
||||
job_id="job1",
|
||||
status="succeeded",
|
||||
result="Test result",
|
||||
duration=1.0,
|
||||
delivered=True,
|
||||
job_name="Test Job",
|
||||
)
|
||||
|
||||
history = store.get_history()
|
||||
assert len(history) == 1
|
||||
assert history[0].job_id == "job1"
|
||||
assert history[0].status == "succeeded"
|
||||
assert history[0].result == "Test result"
|
||||
assert history[0].delivered is True
|
||||
|
||||
def test_get_history_newest_first(self):
|
||||
"""Test that history is returned newest first."""
|
||||
from praisonaiagents.scheduler.store import FileScheduleStore
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
store = FileScheduleStore(store_dir=tmpdir)
|
||||
|
||||
store.log_run(job_id="job1", status="succeeded", job_name="First")
|
||||
time.sleep(0.01)
|
||||
store.log_run(job_id="job2", status="succeeded", job_name="Second")
|
||||
time.sleep(0.01)
|
||||
store.log_run(job_id="job3", status="succeeded", job_name="Third")
|
||||
|
||||
history = store.get_history()
|
||||
assert len(history) == 3
|
||||
assert history[0].job_name == "Third"
|
||||
assert history[1].job_name == "Second"
|
||||
assert history[2].job_name == "First"
|
||||
|
||||
def test_get_history_filter_by_job_id(self):
|
||||
"""Test filtering history by job_id."""
|
||||
from praisonaiagents.scheduler.store import FileScheduleStore
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
store = FileScheduleStore(store_dir=tmpdir)
|
||||
|
||||
store.log_run(job_id="job1", status="succeeded", job_name="Job 1 Run 1")
|
||||
store.log_run(job_id="job2", status="succeeded", job_name="Job 2 Run 1")
|
||||
store.log_run(job_id="job1", status="failed", job_name="Job 1 Run 2")
|
||||
|
||||
all_history = store.get_history()
|
||||
assert len(all_history) == 3
|
||||
|
||||
job1_history = store.get_history(job_id="job1")
|
||||
assert len(job1_history) == 2
|
||||
assert all(r.job_id == "job1" for r in job1_history)
|
||||
|
||||
def test_get_history_limit(self):
|
||||
"""Test history limit parameter."""
|
||||
from praisonaiagents.scheduler.store import FileScheduleStore
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
store = FileScheduleStore(store_dir=tmpdir)
|
||||
|
||||
for i in range(10):
|
||||
store.log_run(job_id=f"job{i}", status="succeeded", job_name=f"Job {i}")
|
||||
|
||||
limited = store.get_history(limit=5)
|
||||
assert len(limited) == 5
|
||||
|
||||
def test_history_max_limit_enforcement(self):
|
||||
"""Test that history is pruned to max_history."""
|
||||
from praisonaiagents.scheduler.store import FileScheduleStore
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
store = FileScheduleStore(store_dir=tmpdir, max_history=5)
|
||||
|
||||
for i in range(10):
|
||||
store.log_run(job_id=f"job{i}", status="succeeded", job_name=f"Job {i}")
|
||||
|
||||
history = store.get_history()
|
||||
assert len(history) == 5
|
||||
# Should have the 5 most recent (jobs 5-9)
|
||||
assert history[0].job_name == "Job 9"
|
||||
assert history[4].job_name == "Job 5"
|
||||
|
||||
def test_history_persistence(self):
|
||||
"""Test that history is persisted to disk."""
|
||||
from praisonaiagents.scheduler.store import FileScheduleStore
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Create store and add history
|
||||
store1 = FileScheduleStore(store_dir=tmpdir)
|
||||
store1.log_run(job_id="job1", status="succeeded", result="Result 1", job_name="Job 1")
|
||||
store1.log_run(job_id="job2", status="failed", error="Error 2", job_name="Job 2")
|
||||
|
||||
# Verify file exists
|
||||
history_path = os.path.join(tmpdir, "history.json")
|
||||
assert os.path.exists(history_path)
|
||||
|
||||
# Create new store and verify history is loaded
|
||||
store2 = FileScheduleStore(store_dir=tmpdir)
|
||||
history = store2.get_history()
|
||||
assert len(history) == 2
|
||||
assert history[0].job_name == "Job 2"
|
||||
assert history[1].job_name == "Job 1"
|
||||
|
||||
def test_result_truncation(self):
|
||||
"""Test that long results are truncated."""
|
||||
from praisonaiagents.scheduler.store import FileScheduleStore
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
store = FileScheduleStore(store_dir=tmpdir)
|
||||
|
||||
long_result = "x" * 5000
|
||||
store.log_run(job_id="job1", status="succeeded", result=long_result, job_name="Job 1")
|
||||
|
||||
history = store.get_history()
|
||||
assert len(history[0].result) == 2000
|
||||
|
||||
|
||||
class TestConfigYamlScheduleStoreHistory:
|
||||
"""Tests for ConfigYamlScheduleStore history functionality."""
|
||||
|
||||
def test_log_run_creates_history(self):
|
||||
"""Test that log_run adds a history record."""
|
||||
from praisonaiagents.scheduler.config_store import ConfigYamlScheduleStore
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
config_path = os.path.join(tmpdir, "config.yaml")
|
||||
store = ConfigYamlScheduleStore(config_path=config_path)
|
||||
assert len(store.get_history()) == 0
|
||||
|
||||
store.log_run(
|
||||
job_id="job1",
|
||||
status="succeeded",
|
||||
result="Test result",
|
||||
duration=1.0,
|
||||
delivered=True,
|
||||
job_name="Test Job",
|
||||
)
|
||||
|
||||
history = store.get_history()
|
||||
assert len(history) == 1
|
||||
assert history[0].job_id == "job1"
|
||||
assert history[0].status == "succeeded"
|
||||
|
||||
def test_history_persistence_yaml(self):
|
||||
"""Test that history is persisted to YAML file."""
|
||||
from praisonaiagents.scheduler.config_store import ConfigYamlScheduleStore
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
config_path = os.path.join(tmpdir, "config.yaml")
|
||||
|
||||
# Create store and add history
|
||||
store1 = ConfigYamlScheduleStore(config_path=config_path)
|
||||
store1.log_run(job_id="job1", status="succeeded", job_name="Job 1")
|
||||
|
||||
# Verify history file exists
|
||||
history_path = os.path.join(tmpdir, "run_history.yaml")
|
||||
assert os.path.exists(history_path)
|
||||
|
||||
# Create new store and verify history is loaded
|
||||
store2 = ConfigYamlScheduleStore(config_path=config_path)
|
||||
history = store2.get_history()
|
||||
assert len(history) == 1
|
||||
assert history[0].job_id == "job1"
|
||||
|
||||
|
||||
class TestScheduleRunnerHistory:
|
||||
"""Tests for ScheduleRunner.mark_run() with history params."""
|
||||
|
||||
def test_mark_run_logs_history(self):
|
||||
"""Test that mark_run calls log_run on the store."""
|
||||
from praisonaiagents.scheduler.runner import ScheduleRunner
|
||||
from praisonaiagents.scheduler.store import FileScheduleStore
|
||||
from praisonaiagents.scheduler.models import ScheduleJob, Schedule
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
store = FileScheduleStore(store_dir=tmpdir)
|
||||
runner = ScheduleRunner(store)
|
||||
|
||||
job = ScheduleJob(
|
||||
name="Test Job",
|
||||
schedule=Schedule(kind="every", every_seconds=60),
|
||||
message="Test message",
|
||||
)
|
||||
store.add(job)
|
||||
|
||||
# Mark run with history params
|
||||
runner.mark_run(
|
||||
job,
|
||||
status="succeeded",
|
||||
result="Agent response",
|
||||
duration=2.5,
|
||||
delivered=True,
|
||||
)
|
||||
|
||||
# Verify history was logged
|
||||
history = store.get_history()
|
||||
assert len(history) == 1
|
||||
assert history[0].job_id == job.id
|
||||
assert history[0].job_name == "Test Job"
|
||||
assert history[0].status == "succeeded"
|
||||
assert history[0].result == "Agent response"
|
||||
assert history[0].duration == 2.5
|
||||
assert history[0].delivered is True
|
||||
|
||||
def test_mark_run_updates_last_run_at(self):
|
||||
"""Test that mark_run still updates last_run_at."""
|
||||
from praisonaiagents.scheduler.runner import ScheduleRunner
|
||||
from praisonaiagents.scheduler.store import FileScheduleStore
|
||||
from praisonaiagents.scheduler.models import ScheduleJob, Schedule
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
store = FileScheduleStore(store_dir=tmpdir)
|
||||
runner = ScheduleRunner(store)
|
||||
|
||||
job = ScheduleJob(
|
||||
name="Test Job",
|
||||
schedule=Schedule(kind="every", every_seconds=60),
|
||||
message="Test message",
|
||||
)
|
||||
store.add(job)
|
||||
assert job.last_run_at is None
|
||||
|
||||
runner.mark_run(job, status="succeeded")
|
||||
|
||||
# Verify last_run_at was updated
|
||||
updated_job = store.get(job.id)
|
||||
assert updated_job.last_run_at is not None
|
||||
|
||||
|
||||
class TestSchedulerExports:
|
||||
"""Tests for scheduler module exports."""
|
||||
|
||||
def test_run_record_exported(self):
|
||||
"""Test that RunRecord is exported from scheduler module."""
|
||||
from praisonaiagents.scheduler import RunRecord
|
||||
|
||||
record = RunRecord(job_id="test", status="succeeded")
|
||||
assert record.job_id == "test"
|
||||
|
||||
def test_all_exports_available(self):
|
||||
"""Test that all expected exports are available."""
|
||||
from praisonaiagents import scheduler
|
||||
|
||||
assert hasattr(scheduler, "RunRecord")
|
||||
assert hasattr(scheduler, "ScheduleJob")
|
||||
assert hasattr(scheduler, "Schedule")
|
||||
assert hasattr(scheduler, "DeliveryTarget")
|
||||
assert hasattr(scheduler, "FileScheduleStore")
|
||||
assert hasattr(scheduler, "ConfigYamlScheduleStore")
|
||||
assert hasattr(scheduler, "ScheduleRunner")
|
||||
assert hasattr(scheduler, "ScheduleStoreProtocol")
|
||||
@@ -410,3 +410,93 @@ class TestToolMappings:
|
||||
assert callable(schedule_list)
|
||||
schedule_remove = getattr(tools, 'schedule_remove')
|
||||
assert callable(schedule_remove)
|
||||
|
||||
|
||||
# ─── 9. ScheduleLoop Tests ───────────────────────────────────────────────────
|
||||
|
||||
class TestScheduleLoop:
|
||||
"""Test ScheduleLoop tick loop."""
|
||||
|
||||
def test_schedule_loop_fires_due_jobs(self, tmp_path):
|
||||
"""ScheduleLoop calls on_trigger for each due job."""
|
||||
from praisonaiagents.scheduler.loop import ScheduleLoop
|
||||
from praisonaiagents.scheduler.store import FileScheduleStore
|
||||
from praisonaiagents.scheduler.models import ScheduleJob, Schedule
|
||||
import threading
|
||||
|
||||
store = FileScheduleStore(store_dir=str(tmp_path))
|
||||
job = ScheduleJob(
|
||||
name="fire-me",
|
||||
schedule=Schedule(kind="every", every_seconds=1),
|
||||
message="hello",
|
||||
)
|
||||
job.last_run_at = time.time() - 10 # due
|
||||
store.add(job)
|
||||
|
||||
triggered = []
|
||||
event = threading.Event()
|
||||
|
||||
def on_trigger(j):
|
||||
triggered.append(j.name)
|
||||
event.set()
|
||||
|
||||
loop = ScheduleLoop(on_trigger=on_trigger, store=store, tick_seconds=0.1)
|
||||
loop.start()
|
||||
assert loop.is_running
|
||||
|
||||
event.wait(timeout=3)
|
||||
loop.stop()
|
||||
|
||||
assert "fire-me" in triggered
|
||||
|
||||
def test_schedule_loop_updates_last_run(self, tmp_path):
|
||||
"""After triggering, last_run_at is updated so job doesn't re-fire."""
|
||||
from praisonaiagents.scheduler.loop import ScheduleLoop
|
||||
from praisonaiagents.scheduler.store import FileScheduleStore
|
||||
from praisonaiagents.scheduler.models import ScheduleJob, Schedule
|
||||
import threading
|
||||
|
||||
store = FileScheduleStore(store_dir=str(tmp_path))
|
||||
old_time = time.time() - 100
|
||||
job = ScheduleJob(
|
||||
name="update-me",
|
||||
schedule=Schedule(kind="every", every_seconds=1),
|
||||
message="test",
|
||||
)
|
||||
job.last_run_at = old_time
|
||||
store.add(job)
|
||||
|
||||
event = threading.Event()
|
||||
def on_trigger(j):
|
||||
event.set()
|
||||
|
||||
loop = ScheduleLoop(on_trigger=on_trigger, store=store, tick_seconds=0.1)
|
||||
loop.start()
|
||||
event.wait(timeout=3)
|
||||
loop.stop()
|
||||
|
||||
updated = store.get(job.id)
|
||||
assert updated.last_run_at > old_time
|
||||
|
||||
def test_schedule_loop_start_stop(self, tmp_path):
|
||||
"""ScheduleLoop starts and stops cleanly."""
|
||||
from praisonaiagents.scheduler.loop import ScheduleLoop
|
||||
from praisonaiagents.scheduler.store import FileScheduleStore
|
||||
|
||||
store = FileScheduleStore(store_dir=str(tmp_path))
|
||||
loop = ScheduleLoop(on_trigger=lambda j: None, store=store, tick_seconds=0.1)
|
||||
|
||||
assert not loop.is_running
|
||||
loop.start()
|
||||
assert loop.is_running
|
||||
# start() when already running is a no-op
|
||||
loop.start()
|
||||
assert loop.is_running
|
||||
loop.stop()
|
||||
assert not loop.is_running
|
||||
|
||||
def test_schedule_loop_lazy_import(self):
|
||||
"""ScheduleLoop can be imported from scheduler package."""
|
||||
from praisonaiagents.scheduler import ScheduleLoop
|
||||
assert ScheduleLoop is not None
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"""
|
||||
TDD tests for store_learning and search_learning tools.
|
||||
|
||||
Tests follow the same pattern as test_memory_tools.py.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
# ─── Import Tests ────────────────────────────────────────────────────────────
|
||||
|
||||
class TestLearningToolImports:
|
||||
"""Verify clean imports and no naming collisions."""
|
||||
|
||||
def test_import_from_tools_module(self):
|
||||
from praisonaiagents.tools import store_learning, search_learning
|
||||
assert callable(store_learning)
|
||||
assert callable(search_learning)
|
||||
|
||||
def test_import_directly(self):
|
||||
from praisonaiagents.tools.learning import store_learning, search_learning
|
||||
assert callable(store_learning)
|
||||
assert callable(search_learning)
|
||||
|
||||
def test_no_collision_with_learn_package(self):
|
||||
"""tools.learning and memory.learn are separate Python paths."""
|
||||
from praisonaiagents.tools.learning import store_learning
|
||||
from praisonaiagents.memory.learn.manager import LearnManager
|
||||
assert store_learning is not LearnManager
|
||||
|
||||
|
||||
# ─── store_learning Tests ────────────────────────────────────────────────────
|
||||
|
||||
class TestStoreLearning:
|
||||
"""Test store_learning tool function."""
|
||||
|
||||
def _make_state(self, learn_manager=None):
|
||||
from praisonaiagents.tools.injected import AgentState
|
||||
return AgentState(
|
||||
agent_id="test",
|
||||
run_id="run-1",
|
||||
session_id="sess-1",
|
||||
learn_manager=learn_manager,
|
||||
)
|
||||
|
||||
def test_store_persona(self):
|
||||
from praisonaiagents.tools.learning import store_learning
|
||||
mgr = MagicMock()
|
||||
mgr.capture_persona.return_value = MagicMock(id="e1", content="likes blue")
|
||||
state = self._make_state(learn_manager=mgr)
|
||||
|
||||
result = store_learning("User likes blue", category="persona", state=state)
|
||||
mgr.capture_persona.assert_called_once_with("User likes blue")
|
||||
assert "persona" in result.lower()
|
||||
|
||||
def test_store_insight(self):
|
||||
from praisonaiagents.tools.learning import store_learning
|
||||
mgr = MagicMock()
|
||||
mgr.capture_insight.return_value = MagicMock(id="e2", content="uses FastAPI")
|
||||
state = self._make_state(learn_manager=mgr)
|
||||
|
||||
result = store_learning("Project uses FastAPI", category="insights", state=state)
|
||||
mgr.capture_insight.assert_called_once_with("Project uses FastAPI")
|
||||
assert "insight" in result.lower()
|
||||
|
||||
def test_store_pattern(self):
|
||||
from praisonaiagents.tools.learning import store_learning
|
||||
mgr = MagicMock()
|
||||
mgr.capture_pattern.return_value = MagicMock(id="e3", content="deploy=staging")
|
||||
state = self._make_state(learn_manager=mgr)
|
||||
|
||||
result = store_learning("deploy means staging", category="patterns", state=state)
|
||||
mgr.capture_pattern.assert_called_once_with("deploy means staging")
|
||||
assert "pattern" in result.lower()
|
||||
|
||||
def test_store_decision(self):
|
||||
from praisonaiagents.tools.learning import store_learning
|
||||
mgr = MagicMock()
|
||||
mgr.capture_decision.return_value = MagicMock(id="e4", content="chose postgres")
|
||||
state = self._make_state(learn_manager=mgr)
|
||||
|
||||
result = store_learning("Use PostgreSQL", category="decisions", state=state)
|
||||
mgr.capture_decision.assert_called_once_with("Use PostgreSQL")
|
||||
assert "decision" in result.lower()
|
||||
|
||||
def test_store_default_category_is_persona(self):
|
||||
"""Default category should be persona (most common use case)."""
|
||||
from praisonaiagents.tools.learning import store_learning
|
||||
mgr = MagicMock()
|
||||
mgr.capture_persona.return_value = MagicMock(id="e5")
|
||||
state = self._make_state(learn_manager=mgr)
|
||||
|
||||
store_learning("Prefers Python", state=state)
|
||||
mgr.capture_persona.assert_called_once()
|
||||
|
||||
def test_store_no_learn_manager(self):
|
||||
"""Graceful response when learn is not configured."""
|
||||
from praisonaiagents.tools.learning import store_learning
|
||||
state = self._make_state(learn_manager=None)
|
||||
|
||||
result = store_learning("test", state=state)
|
||||
assert "not configured" in result.lower() or "enable" in result.lower()
|
||||
|
||||
def test_store_no_state(self):
|
||||
"""Graceful response when no state is available."""
|
||||
from praisonaiagents.tools.learning import store_learning
|
||||
with patch("praisonaiagents.tools.learning.get_current_state", return_value=None):
|
||||
result = store_learning("test")
|
||||
assert "not configured" in result.lower() or "enable" in result.lower()
|
||||
|
||||
|
||||
# ─── search_learning Tests ───────────────────────────────────────────────────
|
||||
|
||||
class TestSearchLearning:
|
||||
"""Test search_learning tool function."""
|
||||
|
||||
def _make_state(self, learn_manager=None):
|
||||
from praisonaiagents.tools.injected import AgentState
|
||||
return AgentState(
|
||||
agent_id="test",
|
||||
run_id="run-1",
|
||||
session_id="sess-1",
|
||||
learn_manager=learn_manager,
|
||||
)
|
||||
|
||||
def test_search_with_results(self):
|
||||
from praisonaiagents.tools.learning import search_learning
|
||||
mgr = MagicMock()
|
||||
mgr.search.return_value = {
|
||||
"persona": [{"id": "1", "content": "likes blue"}],
|
||||
"insights": [{"id": "2", "content": "uses FastAPI"}],
|
||||
}
|
||||
state = self._make_state(learn_manager=mgr)
|
||||
|
||||
result = search_learning("preferences", state=state)
|
||||
mgr.search.assert_called_once_with("preferences", limit=5)
|
||||
assert "likes blue" in result
|
||||
assert "uses FastAPI" in result
|
||||
|
||||
def test_search_specific_category(self):
|
||||
from praisonaiagents.tools.learning import search_learning
|
||||
mgr = MagicMock()
|
||||
# When category is specified, search should filter to that store
|
||||
mgr.search.return_value = {
|
||||
"persona": [{"id": "1", "content": "prefers bullet points"}],
|
||||
}
|
||||
state = self._make_state(learn_manager=mgr)
|
||||
|
||||
result = search_learning("style", category="persona", state=state)
|
||||
assert "prefers bullet points" in result
|
||||
|
||||
def test_search_no_results(self):
|
||||
from praisonaiagents.tools.learning import search_learning
|
||||
mgr = MagicMock()
|
||||
mgr.search.return_value = {}
|
||||
state = self._make_state(learn_manager=mgr)
|
||||
|
||||
result = search_learning("nonexistent", state=state)
|
||||
assert "no" in result.lower() and ("found" in result.lower() or "learning" in result.lower())
|
||||
|
||||
def test_search_no_learn_manager(self):
|
||||
from praisonaiagents.tools.learning import search_learning
|
||||
state = self._make_state(learn_manager=None)
|
||||
|
||||
result = search_learning("test", state=state)
|
||||
assert "not configured" in result.lower() or "enable" in result.lower()
|
||||
|
||||
|
||||
# ─── Schema Tests ────────────────────────────────────────────────────────────
|
||||
|
||||
class TestLearningToolSchema:
|
||||
"""Verify Injected[AgentState] is hidden from LLM schema."""
|
||||
|
||||
def test_store_learning_injected_hidden(self):
|
||||
import inspect
|
||||
from praisonaiagents.tools.learning import store_learning
|
||||
sig = inspect.signature(store_learning)
|
||||
params = list(sig.parameters.keys())
|
||||
# 'state' should exist for injection but be hidden from LLM
|
||||
assert "state" in params
|
||||
# LLM-visible params should be content + category
|
||||
assert "content" in params
|
||||
assert "category" in params
|
||||
|
||||
def test_search_learning_injected_hidden(self):
|
||||
import inspect
|
||||
from praisonaiagents.tools.learning import search_learning
|
||||
sig = inspect.signature(search_learning)
|
||||
params = list(sig.parameters.keys())
|
||||
assert "state" in params
|
||||
assert "query" in params
|
||||
assert "limit" in params
|
||||
"""
|
||||
TDD tests for store_learning and search_learning tools.
|
||||
"""
|
||||
@@ -0,0 +1,211 @@
|
||||
"""
|
||||
Tests for memory tools (store_memory, search_memory).
|
||||
|
||||
These are standard tool functions that agents use via tools=[store_memory, search_memory].
|
||||
They use Injected[AgentState] to access the memory instance at runtime.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
class TestMemoryToolImports:
|
||||
"""Test that memory tools can be imported."""
|
||||
|
||||
def test_import_from_tools_module(self):
|
||||
"""store_memory and search_memory importable from tools package."""
|
||||
from praisonaiagents.tools import store_memory, search_memory
|
||||
assert callable(store_memory)
|
||||
assert callable(search_memory)
|
||||
|
||||
def test_import_directly(self):
|
||||
"""Direct import from tools.memory module."""
|
||||
from praisonaiagents.tools.memory import store_memory, search_memory
|
||||
assert callable(store_memory)
|
||||
assert callable(search_memory)
|
||||
|
||||
def test_no_collision_with_memory_package(self):
|
||||
"""tools.memory does not collide with memory/ package."""
|
||||
import praisonaiagents.memory as mem_pkg
|
||||
import praisonaiagents.tools.memory as tools_mem
|
||||
assert mem_pkg is not tools_mem
|
||||
|
||||
|
||||
class TestStoreMemory:
|
||||
"""Test store_memory tool function."""
|
||||
|
||||
def test_store_memory_with_memory_instance(self):
|
||||
"""store_memory stores content via the injected memory."""
|
||||
from praisonaiagents.tools.memory import store_memory
|
||||
from praisonaiagents.tools.injected import AgentState, with_injection_context
|
||||
|
||||
mock_memory = MagicMock()
|
||||
mock_memory.store_short_term = MagicMock()
|
||||
mock_memory.store_long_term = MagicMock()
|
||||
|
||||
state = AgentState(
|
||||
agent_id="test-agent",
|
||||
run_id="test-run",
|
||||
session_id="test-session",
|
||||
memory=mock_memory,
|
||||
)
|
||||
|
||||
with with_injection_context(state):
|
||||
result = store_memory(content="User likes Python", memory_type="long_term")
|
||||
|
||||
assert "stored" in result.lower() or "success" in result.lower() or "saved" in result.lower()
|
||||
|
||||
def test_store_memory_short_term(self):
|
||||
"""store_memory correctly stores to short_term."""
|
||||
from praisonaiagents.tools.memory import store_memory
|
||||
from praisonaiagents.tools.injected import AgentState, with_injection_context
|
||||
|
||||
mock_memory = MagicMock()
|
||||
mock_memory.store_short_term = MagicMock()
|
||||
|
||||
state = AgentState(
|
||||
agent_id="a", run_id="r", session_id="s", memory=mock_memory,
|
||||
)
|
||||
|
||||
with with_injection_context(state):
|
||||
store_memory(content="temporary note", memory_type="short_term")
|
||||
|
||||
mock_memory.store_short_term.assert_called_once()
|
||||
|
||||
def test_store_memory_long_term(self):
|
||||
"""store_memory correctly stores to long_term."""
|
||||
from praisonaiagents.tools.memory import store_memory
|
||||
from praisonaiagents.tools.injected import AgentState, with_injection_context
|
||||
|
||||
mock_memory = MagicMock()
|
||||
mock_memory.store_long_term = MagicMock()
|
||||
|
||||
state = AgentState(
|
||||
agent_id="a", run_id="r", session_id="s", memory=mock_memory,
|
||||
)
|
||||
|
||||
with with_injection_context(state):
|
||||
store_memory(content="important fact", memory_type="long_term")
|
||||
|
||||
mock_memory.store_long_term.assert_called_once()
|
||||
|
||||
def test_store_memory_no_memory_configured(self):
|
||||
"""store_memory returns helpful message when no memory."""
|
||||
from praisonaiagents.tools.memory import store_memory
|
||||
from praisonaiagents.tools.injected import AgentState, with_injection_context
|
||||
|
||||
state = AgentState(
|
||||
agent_id="a", run_id="r", session_id="s", memory=None,
|
||||
)
|
||||
|
||||
with with_injection_context(state):
|
||||
result = store_memory(content="test")
|
||||
|
||||
assert "not configured" in result.lower() or "not available" in result.lower()
|
||||
|
||||
def test_store_memory_no_state(self):
|
||||
"""store_memory returns helpful message with no injection context."""
|
||||
from praisonaiagents.tools.memory import store_memory
|
||||
from praisonaiagents.tools.injected import set_current_state
|
||||
|
||||
set_current_state(None)
|
||||
result = store_memory(content="test")
|
||||
assert "not configured" in result.lower() or "not available" in result.lower()
|
||||
|
||||
|
||||
class TestSearchMemory:
|
||||
"""Test search_memory tool function."""
|
||||
|
||||
def test_search_memory_with_results(self):
|
||||
"""search_memory returns formatted results."""
|
||||
from praisonaiagents.tools.memory import search_memory
|
||||
from praisonaiagents.tools.injected import AgentState, with_injection_context
|
||||
|
||||
mock_memory = MagicMock()
|
||||
mock_memory.search_long_term = MagicMock(return_value=[
|
||||
{"text": "User likes Python", "score": 0.9},
|
||||
{"text": "User is a developer", "score": 0.7},
|
||||
])
|
||||
|
||||
state = AgentState(
|
||||
agent_id="a", run_id="r", session_id="s", memory=mock_memory,
|
||||
)
|
||||
|
||||
with with_injection_context(state):
|
||||
result = search_memory(query="programming preferences")
|
||||
|
||||
assert "Python" in result
|
||||
|
||||
def test_search_memory_no_results(self):
|
||||
"""search_memory returns clear message when nothing found."""
|
||||
from praisonaiagents.tools.memory import search_memory
|
||||
from praisonaiagents.tools.injected import AgentState, with_injection_context
|
||||
|
||||
mock_memory = MagicMock()
|
||||
mock_memory.search_long_term = MagicMock(return_value=[])
|
||||
mock_memory.search_short_term = MagicMock(return_value=[])
|
||||
|
||||
state = AgentState(
|
||||
agent_id="a", run_id="r", session_id="s", memory=mock_memory,
|
||||
)
|
||||
|
||||
with with_injection_context(state):
|
||||
result = search_memory(query="nonexistent topic")
|
||||
|
||||
assert "no" in result.lower() or "nothing" in result.lower() or "found" in result.lower()
|
||||
|
||||
def test_search_memory_no_memory_configured(self):
|
||||
"""search_memory returns helpful message when no memory."""
|
||||
from praisonaiagents.tools.memory import search_memory
|
||||
from praisonaiagents.tools.injected import AgentState, with_injection_context
|
||||
|
||||
state = AgentState(
|
||||
agent_id="a", run_id="r", session_id="s", memory=None,
|
||||
)
|
||||
|
||||
with with_injection_context(state):
|
||||
result = search_memory(query="test")
|
||||
|
||||
assert "not configured" in result.lower() or "not available" in result.lower()
|
||||
|
||||
|
||||
class TestMemoryToolSchema:
|
||||
"""Test that Injected params are hidden from LLM schema."""
|
||||
|
||||
def test_store_memory_injected_hidden(self):
|
||||
"""state param should not appear in tool schema."""
|
||||
from praisonaiagents.tools.memory import store_memory
|
||||
from praisonaiagents.tools.injected import get_injected_params
|
||||
|
||||
injected = get_injected_params(store_memory)
|
||||
assert "state" in injected, "state param must be Injected"
|
||||
|
||||
def test_search_memory_injected_hidden(self):
|
||||
"""state param should not appear in tool schema."""
|
||||
from praisonaiagents.tools.memory import search_memory
|
||||
from praisonaiagents.tools.injected import get_injected_params
|
||||
|
||||
injected = get_injected_params(search_memory)
|
||||
assert "state" in injected, "state param must be Injected"
|
||||
|
||||
|
||||
class TestMemoryToolsWithFileMemory:
|
||||
"""Integration test with real FileMemory (no mocks)."""
|
||||
|
||||
def test_store_and_search_roundtrip(self, tmp_path):
|
||||
"""Store a memory, then search for it."""
|
||||
from praisonaiagents.tools.memory import store_memory, search_memory
|
||||
from praisonaiagents.tools.injected import AgentState, with_injection_context
|
||||
from praisonaiagents.memory.file_memory import FileMemory
|
||||
|
||||
fm = FileMemory(user_id="test_mem_tools", base_path=str(tmp_path))
|
||||
state = AgentState(
|
||||
agent_id="a", run_id="r", session_id="s", memory=fm,
|
||||
)
|
||||
|
||||
with with_injection_context(state):
|
||||
store_result = store_memory(content="The capital of France is Paris", memory_type="long_term")
|
||||
assert "stored" in store_result.lower() or "saved" in store_result.lower()
|
||||
|
||||
search_result = search_memory(query="capital France")
|
||||
assert "Paris" in search_result
|
||||
Generated
+931
-79
File diff suppressed because it is too large.
Load diff
Loaded 100 of 170 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user