Compare commits

...
64 Commits
Author SHA1 Message Date
Mervin Praison 710624bd69 Release v4.5.49 2026-03-13 00:23:37 +00:00
Mervin Praison a35a6690c4 Release v4.5.48 2026-03-12 16:38:48 +00:00
Mervin Praison 1b9d569de8 Release v4.5.46 2026-03-12 13:59:37 +00:00
Mervin Praison 956e4eeb91 feat(tools): add learning tools for improved user preference management
Introduced `store_learning` and `search_learning` functions to
facilitate categorized storage and retrieval of user insights and
preferences. Enhanced agent interaction by allowing agents to
remember and recollect user behaviors across sessions. Updated
agent state to include a learn manager for integration with the
learning system.
2026-03-12 13:52:36 +00:00
Mervin Praison 654e1e2aec Release v4.5.45 2026-03-12 12:26:17 +00:00
Mervin Praison 2870aa9861 feat(a2a): add authentication support and standalone server feature
- Introduced optional bearer token for POST /a2a requests
- Added serve method for standalone A2A server using FastAPI
- Updated router to enforce authentication when token is set
- Enhanced message parsing to support additional part types
2026-03-12 12:23:31 +00:00
Mervin Praison 7bec22ce77 Release v4.5.44 2026-03-12 12:04:19 +00:00
Mervin Praison 520a46019a feat(docs): enhance user guide with Python examples and CLI reference
Added detailed usage examples for single, multi agents, MCP, custom tools, and persistence. Included a quick reference for CLI commands to improve user onboarding. Removed outdated sections for clarity.
2026-03-12 12:00:48 +00:00
Mervin Praison 7ee1c642d4 docs(README): remove outdated features from the description
Updated the README to eliminate references to specific deployment and connection features that are no longer relevant.
2026-03-12 11:01:41 +00:00
Mervin Praison 397f499dda docs(README): restructure and simplify features section
Updated the "Why PraisonAI?" section for clarity and conciseness,
retaining key features while enabling easier navigation with an
expandable details section to view all features.
2026-03-12 10:56:37 +00:00
Mervin Praison d4dac28434 docs(README): update structure and content for clarity and branding
Modified logo dimensions and enhanced the documentation layout by
refactoring sections related to quick start instructions and added
details on supported LLM providers. Overall goal was to improve
readability and user experience.
2026-03-12 10:53:29 +00:00
Mervin Praison dbca6e0047 feat(docs): update README and add 24 new provider icons
Updated README.md to reflect changes in logo paths, added new features,
and improved contribution guidelines. Added 24 new provider icons to
public directory for better integration visibility.
2026-03-12 10:47:24 +00:00
Mervin Praison ed3da59b2c docs(README): update use cases section and clarify performance benchmarks
Added a new "Use Cases" section outlining practical applications of AI agents
and streamlined the language in the performance benchmarks for clarity.
2026-03-12 10:37:20 +00:00
Mervin Praison 55ab4acf8d docs(README): update feature names and add use cases section
Revised feature names for clarity and consistency, changing "Dashboard UI" to "AgentClaw." Added a new section outlining various use cases for AI agents across multiple industries. Updated links and formatting for better readability.
2026-03-12 10:31:06 +00:00
Mervin Praison e0c1301965 docs(README.md): add example command for using praisonai claw feature
Added a line to the README.md to provide an example command for
installing the claw feature of the praisonai package.
2026-03-12 08:11:14 +00:00
Mervin Praison 79c4002e3c docs(README): clarify section title for PraisonAI Claw
Updated the section title to include "Dashboard UI" for better clarity.
2026-03-12 08:05:33 +00:00
Mervin Praison 2e5a8c5fe5 docs(README): Add section for PraisonAI Claw integration
Included installation instructions and dashboard overview for connecting AI agents to messaging platforms like Telegram, Discord, and WhatsApp.
2026-03-12 08:04:44 +00:00
Mervin Praison 99c811da21 feat(docs): add dashboard image to README
Added a central image of the PraisonAI dashboard to enhance visual appeal
and provide users with a clear reference to the interface.
2026-03-12 01:34:56 +00:00
Mervin Praison 0fb773adcd Release v4.5.43 2026-03-12 00:27:47 +00:00
Mervin Praison d95d3854db feat(dependencies): update praisonai and praisonaiagents to v4.5.43
Updated Dockerfiles and project files to use praisonai version 4.5.43
and praisonaiagents version 1.5.43 for improved functionality and
features. Added new 'claw' command in the CLI for enhanced user
experience.
2026-03-12 00:27:47 +00:00
github-actions[bot] 1383db2fba docs: auto-update api.md [skip ci] 2026-03-12 00:18:34 +00:00
Mervin Praison 75adcfed68 Release v4.5.42 2026-03-12 00:18:16 +00:00
Mervin Praison 3793ef0387 docs(README): update project description for clarity and impact
Updated the project description to highlight key features and capabilities, including automation, supported platforms, and other enhancements for better understanding.
2026-03-11 23:06:42 +00:00
Mervin Praison bf3b7f8e9d docs(README): update feature list with consistent layout and missing details
Adjusted the feature table in README.md for improved readability.
Added missing features and ensured consistent formatting throughout the list.
2026-03-11 22:55:11 +00:00
Mervin Praison a4da8c6ab6 feat(docs): update documentation links and improve clarity
Revised multiple links in the README to point to the correct documentation
pages, specifically changing references from features to concepts to enhance
user navigation and comprehension.
2026-03-11 22:47:54 +00:00
Mervin Praison f59ba1b83d Release v4.5.41 2026-03-11 22:32:27 +00:00
Mervin Praison 8c88766900 feat(docs): update README and versioning for Dashboard UI feature
Added a new entry for the Dashboard UI in the README and updated
the version of `praisonaiagents` to 1.5.41 in the relevant files.
This reflects recent changes and improvements in UI capabilities.
2026-03-11 22:31:09 +00:00
Mervin Praison a6eaa854fe Release v4.5.40 2026-03-11 22:21:06 +00:00
Mervin Praison e36911e712 feat(README, pyproject): update bot features and version number
Updated README.md to highlight new bot functionalities including Telegram,
Discord, Slack, and WhatsApp integration. Increased version to 1.5.40
in `pyproject.toml` for `praisonaiagents`. Expanded dependencies for
claw in the main `pyproject.toml`. Enhanced .gitignore to include
build files.
2026-03-11 21:22:25 +00:00
Mervin Praison c3a6a39ee1 docs(readme): update links to feature documentation in README.md
Replaced outdated links in the README file with new feature documentation
links to ensure users have access to the latest information about the
project's capabilities.
2026-03-11 20:58:02 +00:00
Mervin Praison 15146ff6a7 docs(README): add new features to the documentation table
Added several new features to the README.md including Sessions + Auto-Save, Telemetry, Policy Engine, Thinking Budgets, Background Tasks, Doom Loop Detection, Graph Memory, RAG + Quality-Based RAG, and Sandbox Execution.
2026-03-11 20:55:38 +00:00
Mervin Praison 0d0c2fa762 docs(README): update features section with detailed explanations
Enhanced the features section with specific feature descriptions and
usage examples to provide clearer insights into the framework's
capabilities and benefits for new users.
2026-03-11 20:53:30 +00:00
Mervin Praison c9010fca9f docs(readme): major rewrite of README for clarity and content updates
The README.md has been significantly updated with a focus on clear
guidance and reduced verbosity. Many sections were condensed or
removed, resulting in a net decrease in lines while enhancing
navigation and support links for users.
2026-03-11 20:45:19 +00:00
Mervin Praison 7a36352196 Release v4.5.39 2026-03-11 00:23:03 +00:00
Mervin Praison 6ae4ebedcb Merge branch 'main' of https://github.com/MervinPraison/PraisonAI 2026-03-11 00:20:17 +00:00
Mervin Praison 3377347087 feat(scheduler): add session ID to DeliveryTarget and scheduling commands
Added an optional session_id parameter to DeliveryTarget and scheduling
functions to preserve conversation context during job execution. This
enhancement allows agents to access prior chat history and improves
overall task management flexibility. Updated relevant documentation and
CLI commands accordingly.
2026-03-11 00:19:32 +00:00
github-actions[bot] f47aa248ea docs: auto-update api.md [skip ci] 2026-03-10 18:00:30 +00:00
Mervin Praison 0379e9f196 Release v4.5.38 2026-03-10 18:00:13 +00:00
Mervin Praison c841d46ae9 Release v4.5.37 2026-03-10 15:27:06 +00:00
Mervin Praison cd6fdd0107 feat(memory): implement MongoDB storage backend and memory protocol aliases
Added a MongoDB backend for storage, which allows document-oriented
data storage. Implemented protocol-compliant aliases for memory operations
to improve consistency. Updated related memory management logic to support
new functionality, enhancing overall performance and user experience.
2026-03-10 15:23:43 +00:00
Mervin Praison d840407b12 Release v4.5.36 2026-03-10 14:05:29 +00:00
Mervin Praison b99ca3f0ae Release v4.5.35 2026-03-10 13:09:43 +00:00
Mervin Praison 23c48f6cd5 Release v4.5.34 2026-03-10 11:16:45 +00:00
Mervin Praison d0fa48b110 Release v4.5.33 2026-03-10 06:07:07 +00:00
Mervin Praison 0d2c1bd961 Release v4.5.32 2026-03-10 05:42:59 +00:00
Mervin Praison ef6b1a8913 Release v4.5.31 2026-03-09 14:43:23 +00:00
github-actions[bot] dc9c250adb docs: auto-update api.md [skip ci] 2026-03-09 14:06:13 +00:00
Mervin Praison d1fb090a07 Release v4.5.30 2026-03-09 14:05:55 +00:00
Mervin Praison 8479fffa8c feat(paths): refactor directory management for agents and config loading
Updated various parts of the codebase to centralize directory paths
using the new `paths.py` utility. This improves maintainability and
consistency across the project when accessing directory paths for
plugins, snapshots, memory, and configuration.

Added integration and unit tests to ensure correct behavior of path
configurations.
2026-03-09 13:57:35 +00:00
Mervin Praison 8555b5ad69 Release v4.5.29 2026-03-09 06:18:18 +00:00
Mervin Praison 16f9325176 Release v4.5.28 2026-03-08 07:03:40 +00:00
Mervin Praison fc9f8c5e1d Release v4.5.27 2026-03-08 00:06:51 +00:00
Mervin Praison f5ee866e6c feat(gateway, session): add gateway integration and lazy loading
- Implement lazy loading for WebSocketGateway and GatewaySession in the
  gateway module, allowing easier access without needing wrapper imports.
- Enhance SessionData to include gateway session ID and agent ID.
- Introduce methods in DefaultSessionStore for querying sessions by agent
  and linking sessions to gateway sessions.
2026-03-08 00:02:39 +00:00
Mervin Praison af1c26c471 Release v4.5.26 2026-03-07 07:10:51 +00:00
Mervin Praison f20d5d95e4 feat(workflow): add agent-centric and hybrid workflow examples
Introduced new YAML workflow examples demonstrating agent-centric steps
and hybrid workflows. The agent-centric job workflow supports roles
like Technical Writer and includes quality checks, while the hybrid
workflow combines deterministic steps with multi-agent coordination.
Updated version to 1.5.26 for the praisonai-agents package.
2026-03-07 07:07:56 +00:00
Mervin Praison ac89f8a17c Release v4.5.25 2026-03-07 05:51:43 +00:00
Mervin Praison abc7f09046 feat(workflows): add file-based and YAML-defined actions examples
Added examples for file-based actions (line count and system info)
and YAML-defined actions in new workflow files. Updated relevant
files to support these actions, enhancing the capabilities for
custom workflows. Updated package version to 1.5.25.
2026-03-07 05:32:23 +00:00
github-actions[bot] a0287a9919 docs: auto-update api.md [skip ci] 2026-03-06 14:58:00 +00:00
Mervin Praison 5046e9cf88 Release v4.5.24 2026-03-06 14:57:43 +00:00
Mervin Praison 89624c1580 Release v4.5.23 2026-03-06 07:50:12 +00:00
Mervin Praison cc719e5379 Release v4.5.22 2026-03-05 15:18:22 +00:00
Mervin Praison e854a50fd2 Release v4.5.21 2026-03-03 15:46:18 +00:00
Mervin Praison 99fde9b9d9 Delete tmp directory 2026-03-03 13:20:31 +00:00
Mervin Praison 3c73ca95b6 u 2026-03-03 13:12:44 +00:00
164 changed files with 16099 additions and 7183 deletions

No files matched your search

+3 -1
View File
@@ -93,4 +93,6 @@ Cargo.lock
**/*.rs.bk
*.pdb
.windsurf
.agent
.agent
src/praisonai/build
+465 -3086
View File
File diff suppressed because it is too large. Load diff
+8 -1
View File
@@ -381,7 +381,7 @@ from praisonaiagents import GuardrailAction, GuardrailConfig, LLMGuardrail
Methods:
* <code title="class GuardrailConfig">GuardrailConfig.<a href="./src/praisonai-agents/praisonaiagents/config/feature_configs.py">to_dict</a>() -> Dict[str, Any]</code>
* <code title="class LLMGuardrail">LLMGuardrail.<a href="./src/praisonai-agents/praisonaiagents/guardrails/llm_guardrail.py">__call__</a>(task_output: TaskOutput) -> Tuple[bool, Union[str, TaskOutput]]</code>
* <code title="class LLMGuardrail">LLMGuardrail.<a href="./src/praisonai-agents/praisonaiagents/guardrails/llm_guardrail.py">__call__</a>(task_output) -> Tuple[bool, Union[str, 'TaskOutput']]</code>
# Planning
@@ -802,6 +802,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 +869,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 +909,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>
@@ -923,6 +927,8 @@ Methods:
* <code title="cli">praisonai profile snapshot <a href="./src/praisonai/praisonai/cli/commands/profile.py">--help</a></code>
* <code title="cli">praisonai profile startup <a href="./src/praisonai/praisonai/cli/commands/profile.py">--help</a></code>
* <code title="cli">praisonai profile suite <a href="./src/praisonai/praisonai/cli/commands/profile.py">--help</a></code>
* <code title="cli">praisonai publish publish-main <a href="./src/praisonai/praisonai/cli/commands/publish.py">--help</a></code>
* <code title="cli">praisonai publish pypi <a href="./src/praisonai/praisonai/cli/commands/publish.py">--help</a></code>
* <code title="cli">praisonai rag chat <a href="./src/praisonai/praisonai/cli/commands/rag.py">--help</a></code>
* <code title="cli">praisonai rag eval <a href="./src/praisonai/praisonai/cli/commands/rag.py">--help</a></code>
* <code title="cli">praisonai rag index <a href="./src/praisonai/praisonai/cli/commands/rag.py">--help</a></code>
@@ -961,6 +967,7 @@ Methods:
* <code title="cli">praisonai sandbox recreate <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
* <code title="cli">praisonai sandbox sandbox-callback <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
* <code title="cli">praisonai sandbox status <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
* <code title="cli">praisonai schedule add <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule delete <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule describe <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule list <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.5.20" \
"praisonai>=4.5.49" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+61
View File
@@ -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"]
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.5.20" \
"praisonai>=4.5.49" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.5.20" \
"praisonai>=4.5.49" \
"praisonai[ui]" \
"praisonai[crewai]"
+25
View File
@@ -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
View File
@@ -0,0 +1,23 @@
"""Calculator with Tests - Editor Output Example.
Builds a calculator module, writes unit tests, runs them, generates a
coverage-style report, and reads it back.
"""
from praisonaiagents import Agent
from praisonaiagents.tools import (
write_file, read_file, execute_command
)
agent = Agent(
instructions="You are a helpful coding assistant.",
output="editor",
tools=[write_file, read_file, execute_command],
approval=True,
)
agent.start(
"Build a mini calculator - create /tmp/calculator.py with add, subtract, "
"multiply, divide functions, write unit tests in /tmp/test_calculator.py "
"using unittest, run the tests to make sure they pass, then generate a "
"coverage-style report in /tmp/calc_report.md listing which functions were "
"tested and read it back"
)
+22
View File
@@ -0,0 +1,22 @@
"""Fibonacci Generator - Editor Output Example.
Creates a Fibonacci script, runs it, writes a summary, and reads back
both files to verify everything looks good.
"""
from praisonaiagents import Agent
from praisonaiagents.tools import (
write_file, read_file, execute_command, list_directory
)
agent = Agent(
instructions="You are a helpful coding assistant.",
output="editor",
tools=[write_file, read_file, execute_command, list_directory],
approval=True,
)
agent.start(
"Create a Python file at /tmp/fibonacci.py that generates the first 20 "
"Fibonacci numbers, run it to verify the output is correct, then write "
"a summary to /tmp/fib_summary.md explaining the algorithm you used and "
"the output, finally read back both files to confirm everything looks good"
)
+36
View File
@@ -0,0 +1,36 @@
"""Multi-Step Research - Editor Output Example.
Researches Python frameworks, creates comparison code, runs it, writes a
markdown report, reads it back, lists files, and gets system info.
"""
from praisonaiagents import Agent
from praisonaiagents.tools import (
write_file, read_file, execute_command, list_directory, get_system_info
)
try:
from praisonaiagents.tools import web_search
tools = [web_search, write_file, read_file, execute_command,
list_directory, get_system_info]
except ImportError:
tools = [write_file, read_file, execute_command,
list_directory, get_system_info]
agent = Agent(
instructions="You are a helpful research assistant.",
output="editor",
tools=tools,
approval=True,
)
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"
)
+21
View File
@@ -0,0 +1,21 @@
"""System Audit - Editor Output Example.
Runs system commands, gathers info, writes a report, and reads it back.
"""
from praisonaiagents import Agent
from praisonaiagents.tools import (
write_file, read_file, execute_command
)
agent = Agent(
instructions="You are a helpful system administration assistant.",
output="editor",
tools=[write_file, read_file, execute_command],
approval=True,
)
agent.start(
"Investigate my system - get the current date, check disk usage, find out "
"what Python version is installed, list running processes sorted by memory, "
"then write all findings to /tmp/system_audit.md as a neat report and read "
"it back to verify"
)
+28
View File
@@ -0,0 +1,28 @@
"""Weather Search - Editor Output Example.
Searches for weather, creates JSON data, writes a parser script, executes
it, and lists created files.
"""
from praisonaiagents import Agent
from praisonaiagents.tools import (
write_file, execute_command, list_directory
)
try:
from praisonaiagents.tools import web_search
tools = [web_search, write_file, execute_command, list_directory]
except ImportError:
tools = [write_file, execute_command, list_directory]
agent = Agent(
instructions="You are a helpful research assistant.",
output="editor",
tools=tools,
approval=True,
)
agent.start(
"Search the web for the current weather in Tokyo, create a JSON file at "
"/tmp/weather_data.json with the findings, then write a Python script at "
"/tmp/parse_weather.py that reads the JSON and prints a formatted weather "
"report, execute the script, and list all files you created in /tmp"
)
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# Calculator with tests: code + unit tests + test execution + coverage report
# Tests: multiple file creation, test execution, report generation, file reading
praisonai "Build a mini calculator - create /tmp/calculator.py with add, subtract, multiply, divide functions, write unit tests in /tmp/test_calculator.py using unittest, run the tests to make sure they pass, then generate a coverage-style report in /tmp/calc_report.md listing which functions were tested and read it back" --trust
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# Fibonacci generator: code creation, execution, documentation, verification
# Tests: file creation, code execution, markdown writing, file reading
praisonai "Create a Python file at /tmp/fibonacci.py that generates the first 20 Fibonacci numbers, run it to verify the output is correct, then write a summary to /tmp/fib_summary.md explaining the algorithm you used and the output, finally read back both files to confirm everything looks good" --trust
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# Multi-step research: web search, file creation, code execution, reporting
# Tests: web search, file write, code execution, markdown report, file read, directory listing, system info
praisonai "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" --trust
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# System audit: multiple command executions, report generation
# Tests: command execution (date, disk, python, processes), file creation, file reading
praisonai "Investigate my system - get the current date, check disk usage, find out what Python version is installed, list running processes sorted by memory, then write all findings to /tmp/system_audit.md as a neat report and read it back to verify" --trust
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# Todo app: multi-file app creation, execution, documentation, verification
# Tests: multiple file creation, code execution, markdown docs, directory listing
praisonai "Write a todo list app at /tmp/todo.py with functions to add, remove, and list tasks stored in /tmp/todos.json, run the script with some sample tasks added, then create documentation at /tmp/todo_docs.md explaining usage with examples, and verify all three files exist" --trust
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# Weather search: web search, JSON creation, Python script, execution
# Tests: web search, JSON file creation, Python scripting, code execution, directory listing
praisonai "Search the web for the current weather in Tokyo, create a JSON file at /tmp/weather_data.json with the findings, then write a Python script at /tmp/parse_weather.py that reads the JSON and prints a formatted weather report, execute the script, and list all files you created in /tmp" --trust
+41
View File
@@ -0,0 +1,41 @@
"""
Example file-based action: line-count
Counts lines in files matching a pattern. Demonstrates how actions
receive step config and flags from the workflow YAML.
Usage in workflow:
- name: Count lines
action: line-count
pattern: "*.py"
directory: src/
"""
import os
from pathlib import Path
def run(step, flags, cwd):
"""Count lines in files matching a glob pattern."""
pattern = step.get("pattern", "*.py")
directory = step.get("directory", ".")
target = Path(cwd) / directory
if not target.exists():
return {"ok": False, "error": f"Directory not found: {target}"}
total_lines = 0
file_count = 0
for filepath in target.rglob(pattern):
if filepath.is_file():
try:
total_lines += len(filepath.read_text().splitlines())
file_count += 1
except (UnicodeDecodeError, PermissionError):
continue
return {
"ok": True,
"output": f"{file_count} files, {total_lines} lines ({pattern} in {directory})",
}
+33
View File
@@ -0,0 +1,33 @@
"""
Example file-based action: system-info
Collects basic system information. Demonstrates the action file contract:
- Must define run(step, flags, cwd) → dict
- Return {"ok": True, "output": "..."} on success
- Return {"ok": False, "error": "..."} on failure
Place in: actions/system_info.py (next to workflow)
or: .praison/actions/system_info.py (project-level)
"""
import platform
import os
def run(step, flags, cwd):
"""Collect system information."""
info_parts = [
f"OS: {platform.system()} {platform.release()}",
f"Python: {platform.python_version()}",
f"Machine: {platform.machine()}",
f"CWD: {cwd}",
]
# Optional: include env vars if requested via step config
if step.get("show_env"):
env_vars = step.get("show_env", "").split(",")
for var in env_vars:
var = var.strip()
info_parts.append(f"{var}: {os.environ.get(var, '(not set)')}")
return {"ok": True, "output": " | ".join(info_parts)}
+33
View File
@@ -0,0 +1,33 @@
# Example: File-Based Actions (Option A)
#
# Actions loaded from .py files in the actions/ directory next to this workflow.
# Each action file exports: run(step, flags, cwd) → dict
#
# Directory structure:
# examples/workflows/
# ├── file-actions-demo.yaml ← this file
# └── actions/
# ├── system_info.py ← action: system-info
# └── line_count.py ← action: line-count
#
# Usage:
# praisonai workflow run file-actions-demo.yaml
# praisonai workflow run file-actions-demo.yaml --dry-run
type: job
name: file-actions-demo
description: Demonstrates file-based custom actions from actions/ directory
steps:
- name: System info
action: system-info
- name: Count Python files
action: line-count
pattern: "*.py"
directory: "."
- name: Count YAML files
action: line-count
pattern: "*.yaml"
directory: "."
+40
View File
@@ -0,0 +1,40 @@
# Example Step Workflow: Publish to PyPI
# Usage:
# praisonai workflow run publish-pypi.yaml --dry-run
# praisonai workflow run publish-pypi.yaml
# praisonai workflow run publish-pypi.yaml --major
# praisonai workflow run publish-pypi.yaml --minor
# praisonai workflow run publish-pypi.yaml --no-bump
type: job
name: publish-pypi
description: Publish package to PyPI with automatic version bump
vars:
token_var:
default: PYPI_TOKEN
description: Environment variable for PyPI token
flags:
major: { description: "Bump major version (X.0.0)" }
minor: { description: "Bump minor version (X.Y.0)" }
no-bump: { description: "Skip version bump" }
steps:
- name: Bump version
action: bump-version
file: pyproject.toml
strategy: patch
if: "{{ not flags.no_bump }}"
- name: Clean dist
run: rm -rf dist
- name: Lock dependencies
run: uv lock
- name: Build package
run: uv build
- name: Publish to PyPI
run: uv publish --token ${{ env.PYPI_TOKEN }}
+40
View File
@@ -0,0 +1,40 @@
# Example: YAML-Defined Actions (Option B)
#
# Actions defined inline in the workflow — self-contained, portable.
# No external files needed.
#
# Usage:
# praisonai workflow run yaml-actions-demo.yaml
# praisonai workflow run yaml-actions-demo.yaml --dry-run
type: job
name: yaml-actions-demo
description: Demonstrates YAML-defined custom actions
# Define reusable actions inline
actions:
check-python:
run: python3 --version
count-files:
script: |
import os
cwd = os.getcwd()
py_files = [f for f in os.listdir(cwd) if f.endswith('.py')]
yaml_files = [f for f in os.listdir(cwd) if f.endswith('.yaml') or f.endswith('.yml')]
result = f"Python: {len(py_files)}, YAML: {len(yaml_files)}"
timestamp:
script: |
from datetime import datetime
result = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
steps:
- name: Check Python version
action: check-python
- name: Count project files
action: count-files
- name: Get timestamp
action: timestamp
@@ -0,0 +1,70 @@
# Agent-Centric Job Workflow Example
#
# This workflow demonstrates the new agent-centric step types:
# - agent: AI agent execution
# - judge: AI quality gate with threshold
# - approve: AI or human approval gate
#
# Run with: praisonai workflow run agent-centric-job-workflow.yaml
# Dry run: praisonai workflow run agent-centric-job-workflow.yaml --dry-run
type: job
name: smart-release
description: Intelligent release workflow with AI-powered steps
flags:
major:
description: Bump major version
minor:
description: Bump minor version
skip-approval:
description: Skip approval gate (auto-approve)
steps:
# Step 1: Deterministic - Bump version
- name: Bump version
action: bump-version
strategy: patch
# Step 2: Agent - Generate changelog
- name: Generate changelog
agent:
role: Technical Writer
instructions: |
You are a technical writer who creates clear, concise changelogs.
Focus on user-facing changes, bug fixes, and new features.
prompt: |
Generate a changelog entry for the latest release.
Include sections for: Features, Bug Fixes, and Breaking Changes.
Keep it concise and professional.
model: gpt-4o-mini
output_file: CHANGELOG_ENTRY.md
# Step 3: Judge - Quality check
- name: Quality check
judge:
input_file: CHANGELOG_ENTRY.md
criteria: |
- Completeness: All sections present (Features, Bug Fixes, Breaking Changes)
- Clarity: Easy to understand for end users
- Formatting: Proper markdown structure
- Conciseness: Not overly verbose
threshold: 7.0
on_fail: warn
model: gpt-4o-mini
# Step 4: Approve - Human approval gate
- name: Approve release
approve:
description: Review and approve the release
risk_level: medium
auto_approve: "{{ flags.skip_approval }}"
# Step 5: Deterministic - Build
- name: Build package
run: echo "Building package..."
# Step 6: Deterministic - Publish (conditional)
- name: Publish to PyPI
run: echo "Publishing to PyPI..."
if: "{{ not flags.skip_approval }}"
+89
View File
@@ -0,0 +1,89 @@
# Hybrid Workflow Example
#
# This workflow demonstrates the `type: hybrid` feature that combines:
# - Deterministic steps (run, action) from job workflows
# - Agent-centric steps (agent, judge, approve) from job workflows
# - Multi-agent workflow steps (workflow, parallel)
#
# Run with: praisonai workflow run hybrid-workflow.yaml
# Dry run: praisonai workflow run hybrid-workflow.yaml --dry-run
type: hybrid
name: hybrid-release-pipeline
description: Hybrid workflow combining shell commands, AI agents, and multi-agent steps
# Define agents for multi-agent workflow steps
agents:
researcher:
name: Researcher
role: Research Analyst
goal: Research and gather information
instructions: Provide concise, factual research findings
model: gpt-4o-mini
writer:
name: Writer
role: Content Writer
goal: Write clear, engaging content
instructions: Write professional documentation
model: gpt-4o-mini
flags:
skip-tests:
description: Skip test execution
auto-approve:
description: Auto-approve deployment
steps:
# Step 1: Deterministic - Check environment
- name: Check environment
run: echo "Checking environment..." && python --version
# Step 2: Agent - Generate release notes
- name: Generate release notes
agent:
role: Technical Writer
instructions: You write clear, concise release notes
prompt: Generate release notes for version 1.2.0 with bug fixes and new features
model: gpt-4o-mini
output_file: RELEASE_NOTES.md
# Step 3: Parallel - Run multiple checks simultaneously
- name: Parallel checks
parallel:
- run: echo "Running lint check..."
- run: echo "Running type check..."
- run: echo "Running security scan..."
# Step 4: Multi-agent workflow step
- name: Research and document
workflow:
agent: researcher
action: Research best practices for release management
# Step 5: Judge - Quality gate
- name: Quality check
judge:
input_file: RELEASE_NOTES.md
criteria: |
- Completeness: All sections present
- Clarity: Easy to understand
- Professional tone
threshold: 7.0
on_fail: warn
# Step 6: Approve - Human approval gate
- name: Approve release
approve:
description: Review and approve the release
risk_level: medium
auto_approve: "{{ flags.auto_approve }}"
# Step 7: Deterministic - Build
- name: Build package
run: echo "Building package..."
if: "{{ not flags.skip_tests }}"
# Step 8: Final notification
- name: Notify completion
run: echo "✅ Hybrid workflow completed successfully!"
+15 -3
View File
@@ -123,6 +123,8 @@ Pattern: Each major module has a protocols.py file
- Protocols define WHAT (interface contract)
- Adapters implement HOW (concrete implementation)
- Naming: XProtocol for interfaces, XAdapter for implementations
(ALWAYS suffix with "Protocol" — never use bare concept names like TraceSink)
- File placement: All protocols live in protocols.py within their module
- Users can implement any protocol for custom behavior
```
@@ -286,9 +288,19 @@ save() / load() # Disk persistence (NOT store)
# Configuration
class XConfig: # Configuration dataclass (MemoryConfig, HooksConfig)
# Protocols
class XProtocol(Protocol): # Abstract interface
class XAdapter: # Implementation
# Protocols — ALWAYS suffix with "Protocol"
class XProtocol(Protocol): # Abstract interface (TraceSinkProtocol, MemoryProtocol)
class XAdapter: # Concrete implementation (ChromaAdapter, FileAdapter)
# ✅ CORRECT protocol naming:
class MemoryProtocol(Protocol): ... # Clear it's a protocol
class SessionStoreProtocol(Protocol): ... # Descriptive + Protocol suffix
class TraceSinkProtocol(Protocol): ... # In protocols.py + suffixed
# ❌ WRONG protocol naming:
class TraceSink(Protocol): ... # Missing Protocol suffix — ambiguous
class IMemory(Protocol): ... # TypeScript convention, not Python
class MemoryBase(Protocol): ... # "Base" implies ABC, not Protocol
# Execution
run() # Synchronous, blocking
@@ -344,6 +344,14 @@ _LAZY_IMPORTS = {
'ObservabilityEventType': ('praisonaiagents.escalation.observability', 'EventType'),
'DoomLoopDetector': ('praisonaiagents.escalation.doom_loop', 'DoomLoopDetector'),
'MemoryBackend': ('praisonaiagents.config.feature_configs', 'MemoryBackend'),
'LearnConfig': ('praisonaiagents.config.feature_configs', 'LearnConfig'),
'LearnScope': ('praisonaiagents.config.feature_configs', 'LearnScope'),
'LearnMode': ('praisonaiagents.config.feature_configs', 'LearnMode'),
'LearnBackend': ('praisonaiagents.config.feature_configs', 'LearnBackend'),
'LearnProtocol': ('praisonaiagents.memory.learn.protocols', 'LearnProtocol'),
'AsyncLearnProtocol': ('praisonaiagents.memory.learn.protocols', 'AsyncLearnProtocol'),
'LearnManagerProtocol': ('praisonaiagents.memory.learn.protocols', 'LearnManagerProtocol'),
'LearnManager': ('praisonaiagents.memory.learn', 'LearnManager'),
'ChunkingStrategy': ('praisonaiagents.config.feature_configs', 'ChunkingStrategy'),
'GuardrailAction': ('praisonaiagents.config.feature_configs', 'GuardrailAction'),
'WebSearchProvider': ('praisonaiagents.config.feature_configs', 'WebSearchProvider'),
@@ -439,6 +439,7 @@ class Agent:
skills: Optional[Union[List[str], Any]] = None, # Union[list, SkillsConfig]
approval: Optional[Union[bool, Any]] = None, # Union[bool, ApprovalProtocol backend]
tool_timeout: Optional[int] = None, # P8/G11: Timeout in seconds for each tool call
learn: Optional[Union[bool, Any]] = None, # Union[bool, LearnConfig] - Continuous learning (peer to memory)
):
"""Initialize an Agent instance.
@@ -509,6 +510,11 @@ class Agent:
skills: Agent skills. Accepts:
- List[str]: Skill directory paths
- SkillsConfig: Custom configuration
learn: Continuous learning configuration. Accepts:
- bool: True enables with defaults, False disables
- LearnConfig: Custom configuration
Learning is a first-class citizen, peer to memory. It captures patterns,
preferences, and insights from interactions to improve future responses.
Raises:
ValueError: If all of name, role, goal, backstory, and instructions are None.
@@ -714,6 +720,7 @@ class Agent:
json_output = getattr(_output_config, 'json_output', False)
status_trace = getattr(_output_config, 'status_trace', False) # New: clean inline status
simple_output = getattr(_output_config, 'simple_output', False) # status preset: no timestamps
editor_output = getattr(_output_config, 'editor_output', False) # Editor: Step N display
output_file = getattr(_output_config, 'output_file', None) # Auto-save to file
output_template = getattr(_output_config, 'template', None) # Response template
else:
@@ -723,10 +730,20 @@ class Agent:
json_output = False
status_trace = False
simple_output = False
editor_output = False
# Enable trace output mode if configured (takes priority)
# Enable editor output mode if configured (beginner-friendly, takes priority)
# Shows: Step 1: 📄 Creating file: path → ✓ Done
if editor_output:
try:
from ..output.editor import enable_editor_output, is_editor_output_enabled
if not is_editor_output_enabled():
enable_editor_output(use_color=True)
except ImportError:
pass # Editor module not available
# Enable trace output mode if configured
# This provides timestamped inline status with duration
if status_trace:
elif status_trace:
try:
from ..output.trace import enable_trace_output, is_trace_output_enabled
if not is_trace_output_enabled():
@@ -958,6 +975,49 @@ class Agent:
elif memory is False:
memory = None
# ─────────────────────────────────────────────────────────────────────
# Resolve LEARN param - FAST PATH (top-level, peer to memory)
# learn= is a first-class citizen, independent of memory=
# ─────────────────────────────────────────────────────────────────────
from ..config.feature_configs import LearnConfig
_learn_config = None
# Top-level learn= takes precedence over memory.learn
if learn is not None:
# Explicit top-level learn= param
if learn is False:
_learn_config = None
elif learn is True:
# learn=True shorthand enables AGENTIC mode (auto-learning)
from ..memory.learn.protocols import LearnMode
_learn_config = LearnConfig(mode=LearnMode.AGENTIC)
elif isinstance(learn, LearnConfig):
_learn_config = learn
elif isinstance(learn, dict):
_learn_config = LearnConfig(**learn)
else:
_learn_config = learn # Pass through
elif _memory_config is not None and isinstance(_memory_config, MemoryConfig):
# Fallback to memory.learn for backward compatibility
if _memory_config.learn:
if _memory_config.learn is True:
# learn=True shorthand enables AGENTIC mode
from ..memory.learn.protocols import LearnMode
_learn_config = LearnConfig(mode=LearnMode.AGENTIC)
elif isinstance(_memory_config.learn, LearnConfig):
_learn_config = _memory_config.learn
elif isinstance(_memory_config.learn, dict):
_learn_config = LearnConfig(**_memory_config.learn)
# If learn is enabled but memory is not, we need to enable memory for learn to work
if _learn_config is not None and memory is None:
# Enable minimal memory for learn to work
memory = {"learn": _learn_config.to_dict() if hasattr(_learn_config, 'to_dict') else _learn_config}
elif _learn_config is not None and isinstance(memory, dict):
# Merge learn config into memory dict
memory["learn"] = _learn_config.to_dict() if hasattr(_learn_config, 'to_dict') else _learn_config
# ─────────────────────────────────────────────────────────────────────
# Resolve HISTORY from MemoryConfig - FAST PATH
# History is enabled via memory="history" preset or MemoryConfig(history=True)
@@ -1564,6 +1624,10 @@ Your Goal: {self.goal}
self._history_enabled = _history_enabled
self._history_limit = _history_limit
self._history_session_id = _history_session_id
# Learning configuration (top-level, peer to memory)
# Stored for access via agent._learn_config
self._learn_config = _learn_config
# Agent-centric feature instances (lazy loaded for zero performance impact)
self._auto_memory = auto_memory
@@ -1956,6 +2020,8 @@ Summary:"""
self._file_snapshot = None
self._snapshot_stack = []
self._redo_stack = []
self._autonomy_turn_tool_count = 0
self._consecutive_no_tool_turns = 0
return
self.autonomy_enabled = True
@@ -1983,12 +2049,15 @@ Summary:"""
self._file_snapshot = None
self._snapshot_stack = []
self._redo_stack = []
self._autonomy_turn_tool_count = 0
self._consecutive_no_tool_turns = 0
return
# Preserve ALL AutonomyConfig fields in the dict (G14 fix: no lossy extraction)
self.autonomy_config = {
"enabled": config.enabled,
"level": config.level,
"mode": config.mode,
"max_iterations": config.max_iterations,
"doom_loop_threshold": config.doom_loop_threshold,
"auto_escalate": config.auto_escalate,
@@ -2424,6 +2493,8 @@ Summary:"""
# Execute one turn using the agent's chat method
# Always use the original prompt (prompt re-injection)
# Reset per-turn tool count for no-tool-call detection
self._autonomy_turn_tool_count = 0
try:
response = self.chat(prompt)
except Exception as e:
@@ -2441,6 +2512,10 @@ Summary:"""
response_str = str(response)
# Record response text for content streaming loop detection
if self._doom_loop_tracker is not None:
self._doom_loop_tracker.record_response(response_str)
# Record the action for doom loop tracking (G1 fix: was missing)
# Use iteration number + response hash to avoid false positives
# when same prompt is re-injected (G8 fix: doom loop false positive)
@@ -2558,6 +2633,30 @@ Summary:"""
# Clear context between iterations if enabled
if effective_clear_context:
self.clear_history()
# No-tool-call termination: if model makes no tool calls for 2+
# consecutive turns, treat as completion signal.
# Skip first iteration (model may be planning).
if self._autonomy_turn_tool_count == 0 and iterations > 1:
self._consecutive_no_tool_turns += 1
if self._consecutive_no_tool_turns >= 2:
execute_sync_callback('autonomy_complete',
completion_reason="no_tool_calls",
iterations=iterations,
duration_seconds=time_module.time() - start_time
)
return AutonomyResult(
success=True,
output=response_str,
completion_reason="no_tool_calls",
iterations=iterations,
stage=stage,
actions=actions_taken,
duration_seconds=time_module.time() - start_time,
started_at=started_at,
)
else:
self._consecutive_no_tool_turns = 0
# Max iterations reached
# P3/G2: Emit completion callback for max iterations
@@ -3411,10 +3510,17 @@ Summary:"""
if not self._memory_instance:
return
if memory_type == "short_term" and hasattr(self._memory_instance, 'add_short_term'):
self._memory_instance.add_short_term(content, **kwargs)
elif memory_type == "long_term" and hasattr(self._memory_instance, 'add_long_term'):
self._memory_instance.add_long_term(content, **kwargs)
# Use protocol names first (store_*), fallback to legacy names (add_*)
if memory_type == "short_term":
if hasattr(self._memory_instance, 'store_short_term'):
self._memory_instance.store_short_term(content, **kwargs)
elif hasattr(self._memory_instance, 'add_short_term'):
self._memory_instance.add_short_term(content, **kwargs)
elif memory_type == "long_term":
if hasattr(self._memory_instance, 'store_long_term'):
self._memory_instance.store_long_term(content, **kwargs)
elif hasattr(self._memory_instance, 'add_long_term'):
self._memory_instance.add_long_term(content, **kwargs)
elif memory_type == "entity" and hasattr(self._memory_instance, 'add_entity'):
self._memory_instance.add_entity(content, **kwargs)
elif memory_type == "episodic" and hasattr(self._memory_instance, 'add_episodic'):
@@ -4449,6 +4555,8 @@ Your Goal: {self.goal}"""
run_id=getattr(self, '_current_run_id', 'unknown'),
session_id=getattr(self, '_session_id', None) or 'default',
last_user_message=self.chat_history[-1].get('content') if self.chat_history else None,
memory=getattr(self, '_memory_instance', None),
learn_manager=getattr(getattr(self, '_memory_instance', None), 'learn', None),
metadata={'agent_name': self.name}
)
@@ -4561,6 +4669,9 @@ Your Goal: {self.goal}"""
if not is_error:
self._doom_loop_tracker.mark_progress(f"tool:{function_name}")
# Increment per-turn tool count for no-tool-call detection
self._autonomy_turn_tool_count = getattr(self, '_autonomy_turn_tool_count', 0) + 1
return result
except Exception as e:
# Emit tool call end with error
@@ -4592,6 +4703,36 @@ Your Goal: {self.goal}"""
prompt_str = prompt if isinstance(prompt, str) else str(prompt)
self._process_auto_memory(prompt_str, str(response))
# Auto-learning extraction (opt-in via LearnConfig(mode=LearnMode.AGENTIC))
self._process_auto_learning()
return response
async def _atrigger_after_agent_hook(self, prompt, response, start_time, tools_used=None):
"""Async version: Trigger AFTER_AGENT hook and return response."""
from ..hooks import HookEvent, AfterAgentInput
after_agent_input = AfterAgentInput(
session_id=getattr(self, '_session_id', 'default'),
cwd=os.getcwd(),
event_name=HookEvent.AFTER_AGENT,
timestamp=str(time.time()),
agent_name=self.name,
prompt=prompt if isinstance(prompt, str) else str(prompt),
response=response or "",
tools_used=tools_used or [],
total_tokens=0,
execution_time_ms=(time.time() - start_time) * 1000
)
await self._hook_runner.execute(HookEvent.AFTER_AGENT, after_agent_input)
# Auto-memory extraction (opt-in via MemoryConfig(auto_memory=True))
if response:
prompt_str = prompt if isinstance(prompt, str) else str(prompt)
self._process_auto_memory(prompt_str, str(response))
# Auto-learning extraction (opt-in via LearnConfig(mode=LearnMode.AGENTIC))
self._process_auto_learning()
return response
@@ -4712,6 +4853,9 @@ Your Goal: {self.goal}"""
error_msg = f"Tool execution denied: {decision.reason}"
logging.warning(error_msg)
return {"error": error_msg, "approval_denied": True}
# Bridge: mark tool as approved in global registry context so
# @require_approval decorator wrappers skip their own check.
get_approval_registry().mark_approved(function_name)
if decision.modified_args:
arguments = decision.modified_args
logging.info(f"Using modified arguments: {arguments}")
@@ -4849,7 +4993,14 @@ Your Goal: {self.goal}"""
return {"error": error_msg}
def clear_history(self):
"""Clear all chat history.
Also resets _auto_save_last_index to prevent silent message loss
when auto_save is enabled.
"""
self.chat_history = []
# Reset auto-save index to prevent stale index causing message loss
self._auto_save_last_index = 0
# -------------------------------------------------------------------------
# History Management Methods
@@ -4874,6 +5025,8 @@ Your Goal: {self.goal}"""
deleted_count = len(self.chat_history) - keep_last
self.chat_history = self.chat_history[-keep_last:]
# Reset auto-save index to match new history length
self._auto_save_last_index = len(self.chat_history)
return deleted_count
def delete_history(self, index: int) -> bool:
@@ -4891,6 +5044,12 @@ Your Goal: {self.goal}"""
with self._history_lock:
try:
del self.chat_history[index]
# Adjust auto-save index if deletion affects saved range
if hasattr(self, '_auto_save_last_index'):
self._auto_save_last_index = min(
self._auto_save_last_index,
len(self.chat_history)
)
return True
except IndexError:
return False
@@ -4913,7 +5072,14 @@ Your Goal: {self.goal}"""
msg for msg in self.chat_history
if pattern.lower() not in msg.get("content", "").lower()
]
return original_len - len(self.chat_history)
deleted_count = original_len - len(self.chat_history)
# Adjust auto-save index if deletion affects saved range
if deleted_count > 0 and hasattr(self, '_auto_save_last_index'):
self._auto_save_last_index = min(
self._auto_save_last_index,
len(self.chat_history)
)
return deleted_count
def get_history_size(self) -> int:
"""Get the current number of messages in chat history."""
@@ -5152,7 +5318,9 @@ Your Goal: {self.goal}"""
"display_fn": self._display_generating if self.verbose else None,
"reasoning_steps": reasoning_steps,
"verbose": self.verbose,
"max_iterations": 10
"max_iterations": 10,
"stream_callback": self.stream_emitter.emit,
"emit_events": True,
}
if response_format:
chat_kwargs["response_format"] = response_format
@@ -5971,7 +6139,7 @@ Your Goal: {self.goal}"""
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return validated_response
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
except Exception as e:
logging.error(f"Agent {self.name}: Guardrail validation failed for JSON output: {e}")
# Rollback chat history on guardrail failure
@@ -6003,7 +6171,7 @@ Your Goal: {self.goal}"""
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return validated_response
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
except Exception as e:
logging.error(f"Agent {self.name}: Guardrail validation failed: {e}")
# Rollback chat history on guardrail failure
@@ -6073,7 +6241,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
self._end_run(validated_response, "completed", {"duration_ms": (time.time() - start_time) * 1000})
return validated_response
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
except Exception as e:
logging.error(f"Agent {self.name}: Guardrail validation failed after reflection: {e}")
# Rollback chat history on guardrail failure
@@ -6092,7 +6260,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return validated_response
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
except Exception as e:
logging.error(f"Agent {self.name}: Guardrail validation failed after max reflections: {e}")
# Rollback chat history on guardrail failure
@@ -6280,7 +6448,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
validated_response = self._apply_guardrail_with_retry(response_text, prompt, temperature, tools, task_name, task_description, task_id)
# Execute callback after validation
self._execute_callback_and_display(normalized_content, validated_response, time.time() - start_time, task_name, task_description, task_id)
return validated_response
return await self._atrigger_after_agent_hook(prompt, validated_response, start_time)
except Exception as e:
logging.error(f"Agent {self.name}: Guardrail validation failed for custom LLM: {e}")
# Rollback chat history on guardrail failure
@@ -6358,7 +6526,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
logging.debug(f"Agent.achat completed in {total_time:.2f} seconds")
# Execute callback after tool completion
self._execute_callback_and_display(original_prompt, result, time.time() - start_time, task_name, task_description, task_id)
return result
return await self._atrigger_after_agent_hook(original_prompt, result, start_time)
elif output_json or output_pydantic:
response = await self._openai_client.async_client.chat.completions.create(
model=self.llm,
@@ -6372,7 +6540,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
logging.debug(f"Agent.achat completed in {total_time:.2f} seconds")
# Execute callback after JSON/Pydantic completion
self._execute_callback_and_display(original_prompt, response_text, time.time() - start_time, task_name, task_description, task_id)
return response_text
return await self._atrigger_after_agent_hook(original_prompt, response_text, start_time)
else:
response = await self._openai_client.async_client.chat.completions.create(
model=self.llm,
@@ -6412,7 +6580,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
if logging.getLogger().getEffectiveLevel() == logging.DEBUG:
total_time = time.time() - start_time
logging.debug(f"Agent.achat completed in {total_time:.2f} seconds")
return response_text
return await self._atrigger_after_agent_hook(original_prompt, response_text, start_time)
reflection_response = await self._openai_client.async_client.beta.chat.completions.parse(
model=self.reflect_llm if self.reflect_llm else self.llm,
@@ -6470,7 +6638,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return validated_response
return await self._atrigger_after_agent_hook(original_prompt, validated_response, start_time)
except Exception as e:
logging.error(f"Agent {self.name}: Guardrail validation failed for OpenAI client: {e}")
# Rollback chat history on guardrail failure
@@ -6614,23 +6782,49 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# This allows: Agent(autonomy=True) + await agent.astart("Task") to just work!
# ─────────────────────────────────────────────────────────────────────
if self.autonomy_enabled:
# Extract autonomy-specific kwargs
timeout = kwargs.pop('timeout', None)
kwargs.pop('stream', None) # Not used in autonomous mode
# Get config values from autonomy_config
auto_config = self.autonomy_config or {}
max_iterations = auto_config.get('max_iterations', 20)
completion_promise = auto_config.get('completion_promise')
clear_context = auto_config.get('clear_context', False)
mode = auto_config.get('mode', 'caller')
return await self.run_autonomous_async(
prompt=prompt,
max_iterations=max_iterations,
timeout_seconds=timeout,
completion_promise=completion_promise,
clear_context=clear_context,
)
if mode == 'iterative':
# Iterative mode: use run_autonomous_async (backward compat for full_auto)
timeout = kwargs.pop('timeout', None)
kwargs.pop('stream', None) # Not used in autonomous mode
max_iterations = auto_config.get('max_iterations', 20)
completion_promise = auto_config.get('completion_promise')
clear_context = auto_config.get('clear_context', False)
return await self.run_autonomous_async(
prompt=prompt,
max_iterations=max_iterations,
timeout_seconds=timeout,
completion_promise=completion_promise,
clear_context=clear_context,
)
else:
# Caller mode: single achat() call, wrapper-equivalent
import time as time_module
from datetime import datetime, timezone
start_time = time_module.time()
started_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
response = await self.achat(prompt, **kwargs)
response_str = str(response) if response else ""
# Auto-save session after chat
self._auto_save_session()
# Wrap in AutonomyResult for consistent API
from .autonomy import AutonomyResult
return AutonomyResult(
success=True,
output=response_str,
completion_reason="caller_mode",
iterations=1,
stage="direct",
actions=[],
duration_seconds=time_module.time() - start_time,
started_at=started_at,
)
# Determine streaming behavior (same logic as start())
stream_requested = kwargs.get('stream')
@@ -6883,22 +7077,54 @@ Write the complete compiled report:"""
# This allows: Agent(autonomy=True) + agent.start("Task") to just work!
# ─────────────────────────────────────────────────────────────────────
if self.autonomy_enabled:
# Extract autonomy-specific kwargs
timeout = kwargs.pop('timeout', None)
# Get config values from autonomy_config
auto_config = self.autonomy_config or {}
max_iterations = auto_config.get('max_iterations', 20)
completion_promise = auto_config.get('completion_promise')
clear_context = auto_config.get('clear_context', False)
mode = auto_config.get('mode', 'caller')
return self.run_autonomous(
prompt=prompt,
max_iterations=max_iterations,
timeout_seconds=timeout,
completion_promise=completion_promise,
clear_context=clear_context,
)
if mode == 'iterative':
# Iterative mode: use run_autonomous (backward compat for full_auto)
timeout = kwargs.pop('timeout', None)
max_iterations = auto_config.get('max_iterations', 20)
completion_promise = auto_config.get('completion_promise')
clear_context = auto_config.get('clear_context', False)
return self.run_autonomous(
prompt=prompt,
max_iterations=max_iterations,
timeout_seconds=timeout,
completion_promise=completion_promise,
clear_context=clear_context,
)
else:
# Caller mode: single chat() call, wrapper-equivalent
# All init-time features (approval, doom-loop per-tool,
# track_changes, sandbox, default_tools) are already wired.
import time as time_module
from datetime import datetime, timezone
start_time = time_module.time()
started_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
# Load history from past sessions
self._load_history_context()
# Use chat() — all tools, approval, doom-loop per-tool work
response = self.chat(prompt, **kwargs)
response_str = str(response) if response else ""
# Auto-save session after chat
self._auto_save_session()
# Wrap in AutonomyResult for consistent API
from .autonomy import AutonomyResult
return AutonomyResult(
success=True,
output=response_str,
completion_reason="caller_mode",
iterations=1,
stage="direct",
actions=[],
duration_seconds=time_module.time() - start_time,
started_at=started_at,
)
# Load history from past sessions
self._load_history_context()
@@ -7191,9 +7417,88 @@ Write the complete compiled report:"""
except Exception as e:
logging.debug(f"Auto-memory extraction failed: {e}")
def _process_auto_learning(self):
"""Process auto-learning extraction after agent response.
Called after each agent response when LearnMode.AGENTIC is set.
Uses LearnManager.process_conversation() to extract and store learnings
(persona, insights, patterns) from the conversation. No-op when learning
is disabled or mode is not AGENTIC.
"""
# Quick exit if no learn config or mode is not AGENTIC
if not self._learn_config:
return
# Check if mode is AGENTIC (auto-extract learnings)
from ..memory.learn.protocols import LearnMode
mode = getattr(self._learn_config, 'mode', None)
# Handle PROPOSE mode — extract but store as pending for user approval
if (isinstance(mode, LearnMode) and mode == LearnMode.PROPOSE) or \
(isinstance(mode, str) and mode == 'propose'):
learn_manager = None
if self._memory_instance and hasattr(self._memory_instance, 'learn'):
learn_manager = self._memory_instance.learn
if learn_manager:
try:
recent_messages = self.chat_history[-2:] if len(self.chat_history) >= 2 else self.chat_history
if recent_messages:
# Use same extraction as AGENTIC, but don't auto-store
result = learn_manager.process_conversation(
messages=recent_messages,
llm=getattr(self._learn_config, 'llm', None) or self.llm,
extract_only=True,
)
# Move extracted items to pending queue
if result:
for p in result.get("persona", []):
if p:
learn_manager.add_pending(p, category="persona")
for i in result.get("insights", []):
if i:
learn_manager.add_pending(i, category="insights")
for pt in result.get("patterns", []):
if pt:
learn_manager.add_pending(pt, category="patterns")
except Exception as e:
logging.debug(f"PROPOSE mode learning extraction failed: {e}")
return
if mode is None or (isinstance(mode, LearnMode) and mode != LearnMode.AGENTIC):
return
if isinstance(mode, str) and mode != 'agentic':
return
# Get LearnManager from memory instance
learn_manager = None
if self._memory_instance and hasattr(self._memory_instance, 'learn'):
learn_manager = self._memory_instance.learn
if not learn_manager:
return
try:
# Process recent conversation (last 2 messages: user + assistant)
recent_messages = self.chat_history[-2:] if len(self.chat_history) >= 2 else self.chat_history
if recent_messages:
learn_manager.process_conversation(
messages=recent_messages,
llm=getattr(self._learn_config, 'llm', None) or self.llm,
)
except Exception as e:
logging.debug(f"Auto-learning extraction failed: {e}")
def _auto_save_session(self):
"""Auto-save session if auto_save is enabled."""
if not self.auto_save or not self._memory_instance:
"""Auto-save session if auto_save is enabled.
G-1 FIX: Routes to SessionStore instead of Memory.save_session() to
maintain clean separation between conversation history (SessionStore)
and semantic memory (Memory).
Issue 1 FIX: Track last saved index to avoid duplicate insertion when
called multiple times in the same session.
"""
if not self.auto_save:
return
try:
@@ -7203,12 +7508,36 @@ Write the complete compiled report:"""
for msg in self.chat_history
]
self._memory_instance.save_session(
name=self.auto_save,
conversation_history=clean_history,
metadata={"agent_name": self.name, "user_id": self.user_id}
)
logging.debug(f"Auto-saved session: {self.auto_save}")
# Issue 1 FIX: Only save NEW messages since last save
# Track last saved index to avoid duplicates
last_saved = getattr(self, '_auto_save_last_index', 0)
new_messages = clean_history[last_saved:]
if not new_messages:
return # Nothing new to save
# G-1 FIX: Use SessionStore for conversation history persistence
# This maintains clean separation: Memory = facts, SessionStore = turns
if self._session_store is not None:
# Persist only NEW messages to SessionStore
for msg in new_messages:
self._session_store.add_message(
self.auto_save, # Use auto_save name as session_id
role=msg.get("role", "user"),
content=msg.get("content", ""),
)
# Update last saved index
self._auto_save_last_index = len(clean_history)
logging.debug(f"Auto-saved {len(new_messages)} new messages to SessionStore: {self.auto_save}")
elif self._memory_instance and hasattr(self._memory_instance, 'save_session'):
# Fallback to Memory.save_session() for backward compatibility
# Memory.save_session() replaces entire history, so no duplicate issue
self._memory_instance.save_session(
name=self.auto_save,
conversation_history=clean_history,
metadata={"agent_name": self.name, "user_id": self.user_id}
)
logging.debug(f"Auto-saved session to Memory: {self.auto_save}")
except Exception as e:
logging.debug(f"Error auto-saving session: {e}")
@@ -7405,11 +7734,31 @@ Write the complete compiled report:"""
if formatted_tools:
completion_args["tools"] = formatted_tools
# Import StreamEvent types for event emission
from ..streaming.events import StreamEvent, StreamEventType
import time as time_module
# Emit REQUEST_START event
request_start_perf = time_module.perf_counter()
self.stream_emitter.emit(StreamEvent(
type=StreamEventType.REQUEST_START,
timestamp=request_start_perf,
metadata={"model": self.llm, "message_count": len(messages)}
))
completion = self._openai_client.sync_client.chat.completions.create(**completion_args)
# Emit HEADERS_RECEIVED event
self.stream_emitter.emit(StreamEvent(
type=StreamEventType.HEADERS_RECEIVED,
timestamp=time_module.perf_counter()
))
# Stream the response chunks without display
response_text = ""
tool_calls_data = []
first_token_emitted = False
last_content_time = None
for chunk in completion:
delta = chunk.choices[0].delta
@@ -7418,6 +7767,24 @@ Write the complete compiled report:"""
if delta.content is not None:
chunk_content = delta.content
response_text += chunk_content
last_content_time = time_module.perf_counter()
# Emit FIRST_TOKEN on first content
if not first_token_emitted:
self.stream_emitter.emit(StreamEvent(
type=StreamEventType.FIRST_TOKEN,
timestamp=last_content_time,
content=chunk_content
))
first_token_emitted = True
else:
# Emit DELTA_TEXT for subsequent tokens
self.stream_emitter.emit(StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=last_content_time,
content=chunk_content
))
yield chunk_content
# Handle tool calls (accumulate but don't yield as chunks)
@@ -7435,6 +7802,18 @@ Write the complete compiled report:"""
if tool_call_delta.function.arguments:
tool_calls_data[tool_call_delta.index]['function']['arguments'] += tool_call_delta.function.arguments
# Emit LAST_TOKEN and STREAM_END events after streaming loop
if last_content_time:
self.stream_emitter.emit(StreamEvent(
type=StreamEventType.LAST_TOKEN,
timestamp=last_content_time
))
self.stream_emitter.emit(StreamEvent(
type=StreamEventType.STREAM_END,
timestamp=time_module.perf_counter(),
metadata={"response_length": len(response_text)}
))
# Handle any tool calls that were accumulated
if tool_calls_data:
# Add assistant message with tool calls to chat history
@@ -7667,6 +8046,10 @@ Write the complete compiled report:"""
error_msg = f"Tool execution denied: {decision.reason}"
logging.warning(error_msg)
return {"error": error_msg, "approval_denied": True}
# Bridge: mark tool as approved in global registry context so
# @require_approval decorator wrappers skip their own check.
from ..approval import get_approval_registry as _get_reg
_get_reg().mark_approved(function_name)
if decision.modified_args:
arguments = decision.modified_args
logging.info(f"Using modified arguments: {arguments}")
@@ -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,15 +149,21 @@ 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")
# Default snapshot directory
if self.snapshot_dir is None:
import os
self.snapshot_dir = os.path.join(
os.path.expanduser("~"), ".praisonai", "snapshots"
)
from ..paths import get_snapshots_dir
self.snapshot_dir = str(get_snapshots_dir())
@property
def effective_track_changes(self) -> bool:
@@ -158,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),
@@ -380,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()
@@ -162,7 +162,8 @@ class ContextAgent(Agent):
# Enhanced logging and output management
self.debug_mode = os.getenv('LOGLEVEL', '').lower() == 'debug'
self.output_dir = Path(".praisonai/prp") # Save in .praisonai/prp folder
from ..paths import get_project_prp_dir
self.output_dir = get_project_prp_dir() # Save in .praisonai/prp folder
self.setup_output_directories() # Create directories first
self.setup_logging() # Then setup logging
@@ -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
@@ -512,14 +515,16 @@ class AgentTeam:
if not mem_cfg:
mem_cfg = next((t.config.get('memory_config') for t in tasks if hasattr(t, 'config') and t.config), None)
if not mem_cfg:
from ..paths import get_project_data_dir
_pd = str(get_project_data_dir())
mem_cfg = {
"provider": "rag",
"use_embedding": True,
"storage": {
"type": "sqlite",
"path": "./.praisonai/memory.db"
"path": f"{_pd}/memory.db"
},
"rag_db_path": "./.praisonai/chroma_db"
"rag_db_path": f"{_pd}/chroma_db"
}
if _embedder:
if isinstance(_embedder, dict):
@@ -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
@@ -47,7 +47,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
@@ -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,
}
@@ -574,6 +613,10 @@ class OutputConfig:
# Shows: [timestamp] Calling LLM..., Executing tool..., Response: ...
status_trace: bool = False
# Editor output mode - beginner-friendly numbered steps
# Shows: Step 1: 📄 Creating file: path → ✓ Done
editor_output: bool = False
# Output file - save agent response to file automatically
# When set, saves the response to the specified file path
output_file: Optional[str] = None
@@ -596,6 +639,7 @@ class OutputConfig:
"simple_output": self.simple_output,
"show_parameters": self.show_parameters,
"status_trace": self.status_trace,
"editor_output": self.editor_output,
"output_file": self.output_file,
"template": self.template,
}
@@ -85,14 +85,32 @@ _config_cache: Optional[Dict[str, Any]] = None
_config_loaded: bool = False
def _default_project_plugins_dir():
"""Return project-local plugins directory via paths.py."""
from ..paths import get_project_data_dir
return get_project_data_dir() / "plugins"
def _default_global_plugins_dir():
"""Return user-global plugins directory via paths.py."""
from ..paths import get_plugins_dir
return get_plugins_dir()
def get_project_data_dir():
"""Re-export for local use in config search."""
from ..paths import get_project_data_dir as _gpd
return _gpd()
@dataclass
class PluginsConfig:
"""Configuration for the plugin system."""
enabled: Union[bool, List[str]] = False # True, False, or list of plugin names
auto_discover: bool = True
directories: List[str] = field(default_factory=lambda: [
"./.praisonai/plugins/",
"~/.praisonai/plugins/"
str(_default_project_plugins_dir()),
str(_default_global_plugins_dir())
])
def to_dict(self) -> Dict[str, Any]:
@@ -174,7 +192,7 @@ def _find_config_file() -> Optional[Path]:
# Project-local locations
cwd = Path.cwd()
local_paths = [
cwd / ".praisonai" / "config.toml",
get_project_data_dir() / "config.toml",
cwd / "praisonai.toml",
]
@@ -183,8 +201,8 @@ def _find_config_file() -> Optional[Path]:
return path
# User global location
home = Path.home()
global_path = home / ".praisonai" / "config.toml"
from ..paths import get_data_dir
global_path = get_data_dir() / "config.toml"
if global_path.exists():
return global_path
@@ -245,8 +263,8 @@ def _dict_to_plugins_config(data: Dict[str, Any]) -> PluginsConfig:
enabled=data.get("enabled", False),
auto_discover=data.get("auto_discover", True),
directories=data.get("directories", [
"./.praisonai/plugins/",
"~/.praisonai/plugins/"
str(_default_project_plugins_dir()),
str(_default_global_plugins_dir())
]),
)
@@ -133,6 +133,17 @@ OUTPUT_PRESETS: Dict[str, Dict[str, Any]] = {
"actions_trace": True,
"json_output": True, # Output as JSONL
},
# Editor preset - beginner-friendly numbered steps
# Shows: Step 1: 📄 Creating file: path → ✓ Done
# Best for non-programmers and interactive scripts
"editor": {
"verbose": False,
"markdown": False,
"stream": False,
"metrics": False,
"reasoning_steps": False,
"editor_output": True, # Enable editor display mode
},
# ==========================================================================
# ALIASES (for backward compatibility)
@@ -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")
@@ -6,6 +6,9 @@ implementations that coordinate multi-agent deployments.
This module contains only protocols and lightweight utilities.
Heavy implementations live in the praisonai wrapper package.
Gap S2: WebSocketGateway is re-exported here for convenience but requires
the praisonai wrapper package to be installed.
"""
from .protocols import (
@@ -18,15 +21,59 @@ from .protocols import (
)
from .config import GatewayConfig, SessionConfig, ChannelRouteConfig, MultiChannelGatewayConfig
# Lazy loading cache
_lazy_cache = {}
def __getattr__(name: str):
"""Lazy load heavy gateway implementations from praisonai wrapper.
Gap S2: Re-export WebSocketGateway from praisonai wrapper for convenience.
This allows downstream packages to import from praisonaiagents.gateway
without needing to know about the praisonai wrapper.
"""
if name in _lazy_cache:
return _lazy_cache[name]
if name == "WebSocketGateway":
try:
from praisonai.gateway import WebSocketGateway
_lazy_cache[name] = WebSocketGateway
return WebSocketGateway
except ImportError:
raise ImportError(
"WebSocketGateway requires the praisonai wrapper package. "
"Install with: pip install praisonai"
)
if name == "GatewaySession":
try:
from praisonai.gateway import GatewaySession
_lazy_cache[name] = GatewaySession
return GatewaySession
except ImportError:
raise ImportError(
"GatewaySession requires the praisonai wrapper package. "
"Install with: pip install praisonai"
)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
# Protocols (always available)
"GatewayProtocol",
"GatewaySessionProtocol",
"GatewayClientProtocol",
"GatewayEvent",
"GatewayMessage",
"EventType",
# Config (always available)
"GatewayConfig",
"SessionConfig",
"ChannelRouteConfig",
"MultiChannelGatewayConfig",
# Implementations (lazy loaded from praisonai wrapper)
"WebSocketGateway",
"GatewaySession",
]
@@ -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,9 +412,11 @@ 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]}"
persist_dir = ".praisonai"
# 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())
# Create persistent client config
base_config = {
@@ -862,7 +862,8 @@ class Knowledge:
result = IndexResult()
# Initialize file tracker for incremental indexing
state_dir = os.path.join(os.path.dirname(path) if os.path.isfile(path) else path, ".praisonai")
from ..paths import DEFAULT_DIR_NAME
state_dir = os.path.join(os.path.dirname(path) if os.path.isfile(path) else path, DEFAULT_DIR_NAME)
os.makedirs(state_dir, exist_ok=True)
state_file = os.path.join(state_dir, ".index_state.json")
@@ -1156,6 +1156,8 @@ class OpenAIClient:
reasoning_steps: bool = False,
verbose: bool = True,
max_iterations: int = 10,
stream_callback: Optional[Callable] = None,
emit_events: bool = False,
**kwargs
) -> Optional[ChatCompletion]:
"""
@@ -1179,6 +1181,8 @@ class OpenAIClient:
reasoning_steps: Whether to show reasoning
verbose: Whether to show verbose output
max_iterations: Maximum tool calling iterations
stream_callback: Optional callback for StreamEvent emission
emit_events: Whether to emit StreamEvents (requires stream_callback)
**kwargs: Additional API parameters
Returns:
@@ -1208,6 +1212,8 @@ class OpenAIClient:
console=console,
display_fn=display_fn,
reasoning_steps=reasoning_steps,
stream_callback=stream_callback,
emit_events=emit_events,
**kwargs
)
else:
@@ -1377,6 +1383,8 @@ class OpenAIClient:
reasoning_steps: bool = False,
verbose: bool = True,
max_iterations: int = 10,
stream_callback: Optional[Callable] = None,
emit_events: bool = False,
**kwargs
) -> Optional[ChatCompletion]:
"""
@@ -1394,6 +1402,8 @@ class OpenAIClient:
reasoning_steps: Whether to show reasoning
verbose: Whether to show verbose output
max_iterations: Maximum tool calling iterations
stream_callback: Optional callback for StreamEvent emission (can be sync or async)
emit_events: Whether to emit StreamEvents (requires stream_callback)
**kwargs: Additional API parameters
Returns:
@@ -1419,6 +1429,8 @@ class OpenAIClient:
console=console,
display_fn=display_fn,
reasoning_steps=reasoning_steps,
stream_callback=stream_callback,
emit_events=emit_events,
**kwargs
)
else:
@@ -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,
@@ -80,7 +80,8 @@ class DocsManager:
- Priority-based ordering
"""
DOCS_DIR_NAME = ".praisonai/docs"
from ..paths import DEFAULT_DIR_NAME as _DIR_NAME
DOCS_DIR_NAME = f"{_DIR_NAME}/docs"
SUPPORTED_EXTENSIONS = [".md", ".txt", ".rst"]
# Maximum characters per doc (context window management)
@@ -101,7 +102,11 @@ class DocsManager:
verbose: Verbosity level
"""
self.workspace_path = Path(workspace_path) if workspace_path else Path.cwd()
self.global_docs_path = Path(global_docs_path) if global_docs_path else Path.home() / ".praisonai" / "docs"
if global_docs_path:
self.global_docs_path = Path(global_docs_path)
else:
from ..paths import get_docs_dir
self.global_docs_path = get_docs_dir()
self.verbose = verbose
self._docs: Dict[str, Doc] = {}
@@ -140,7 +140,8 @@ class FileMemory:
if base_path:
self.base_path = Path(base_path)
else:
self.base_path = Path(".praisonai/memory")
from ..paths import get_project_data_dir
self.base_path = get_project_data_dir() / "memory"
self.user_path = self.base_path / user_id
self.episodic_path = self.user_path / "episodic"
@@ -1065,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 {
@@ -103,7 +103,8 @@ class HooksManager:
```
"""
CONFIG_FILE = ".praisonai/hooks.json"
from ..paths import DEFAULT_DIR_NAME as _DIR_NAME
CONFIG_FILE = f"{_DIR_NAME}/hooks.json"
def __init__(
self,
@@ -384,7 +385,8 @@ class HooksManager:
timeout: Global timeout in seconds
enabled: Whether hooks are enabled
"""
config_dir = self.workspace_path / ".praisonai"
from ..paths import DEFAULT_DIR_NAME
config_dir = self.workspace_path / DEFAULT_DIR_NAME
config_dir.mkdir(parents=True, exist_ok=True)
config = {
@@ -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
@@ -113,7 +113,8 @@ class MCPConfigManager:
- Easy server management
"""
MCP_DIR_NAME = ".praisonai/mcp"
from ..paths import DEFAULT_DIR_NAME as _DIR_NAME
MCP_DIR_NAME = f"{_DIR_NAME}/mcp"
SUPPORTED_EXTENSIONS = [".json", ".mcpjson"]
def __init__(
@@ -131,7 +132,11 @@ class MCPConfigManager:
verbose: Verbosity level
"""
self.workspace_path = Path(workspace_path) if workspace_path else Path.cwd()
self.global_mcp_path = Path(global_mcp_path) if global_mcp_path else Path.home() / ".praisonai" / "mcp"
if global_mcp_path:
self.global_mcp_path = Path(global_mcp_path)
else:
from ..paths import get_mcp_dir
self.global_mcp_path = get_mcp_dir()
self.verbose = verbose
self._configs: Dict[str, MCPConfig] = {}
@@ -226,15 +226,17 @@ class Memory:
self.embedding_dimensions = self._get_embedding_dimensions(self.embedding_model)
self._log_verbose(f"Using embedding dimensions: {self.embedding_dimensions}")
# Create .praisonai directory if it doesn't exist
os.makedirs(".praisonai", exist_ok=True)
# Create project data directory if it doesn't exist
from ..paths import get_project_data_dir
_project_data = str(get_project_data_dir())
os.makedirs(_project_data, exist_ok=True)
# Short-term DB
self.short_db = self.cfg.get("short_db", ".praisonai/short_term.db")
self.short_db = self.cfg.get("short_db", os.path.join(_project_data, "short_term.db"))
self._init_stm()
# Long-term DB
self.long_db = self.cfg.get("long_db", ".praisonai/long_term.db")
self.long_db = self.cfg.get("long_db", os.path.join(_project_data, "long_term.db"))
self._init_ltm()
# Conditionally init Mem0, MongoDB, or local RAG
@@ -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):
@@ -122,7 +122,8 @@ class RulesManager:
```
"""
RULES_DIR_NAME = ".praisonai/rules"
from ..paths import DEFAULT_DIR_NAME as _DIR_NAME
RULES_DIR_NAME = f"{_DIR_NAME}/rules"
SUPPORTED_EXTENSIONS = [".md", ".mdc", ".txt"]
# Root-level instruction files (like Claude, Codex, Gemini, Cursor, Windsurf)
@@ -165,7 +166,11 @@ class RulesManager:
verbose: Verbosity level
"""
self.workspace_path = Path(workspace_path) if workspace_path else Path.cwd()
self.global_rules_path = Path(global_rules_path) if global_rules_path else Path.home() / ".praisonai" / "rules"
if global_rules_path:
self.global_rules_path = Path(global_rules_path)
else:
from ..paths import get_rules_dir
self.global_rules_path = get_rules_dir()
self.verbose = verbose
self._rules: Dict[str, Rule] = {}
@@ -782,7 +782,8 @@ class WorkflowManager:
```
"""
WORKFLOWS_DIR = ".praisonai/workflows"
from ..paths import DEFAULT_DIR_NAME as _DIR_NAME
WORKFLOWS_DIR = f"{_DIR_NAME}/workflows"
SUPPORTED_EXTENSIONS = [".md", ".yaml", ".yml"]
def __init__(
@@ -1857,7 +1858,8 @@ class WorkflowManager:
def _get_checkpoints_dir(self) -> Path:
"""Get the checkpoints directory path."""
checkpoints_dir = self.workspace_path / ".praisonai" / "checkpoints"
from ..paths import DEFAULT_DIR_NAME
checkpoints_dir = self.workspace_path / DEFAULT_DIR_NAME / "checkpoints"
checkpoints_dir.mkdir(parents=True, exist_ok=True)
return checkpoints_dir
@@ -49,6 +49,15 @@ __all__ = [
"disable_trace_output",
"is_trace_output_enabled",
"get_trace_output",
# Editor output (for editor preset - beginner-friendly numbered steps)
"EditorOutput",
"enable_editor_output",
"disable_editor_output",
"is_editor_output_enabled",
"get_editor_output",
"TOOL_LABELS",
"BlockType",
"DisplayBlock",
]
@@ -112,4 +121,37 @@ def __getattr__(name: str):
from .trace import get_trace_output
return get_trace_output
# Editor output (for editor preset - beginner-friendly numbered steps)
if name == "EditorOutput":
from .editor import EditorOutput
return EditorOutput
if name == "enable_editor_output":
from .editor import enable_editor_output
return enable_editor_output
if name == "disable_editor_output":
from .editor import disable_editor_output
return disable_editor_output
if name == "is_editor_output_enabled":
from .editor import is_editor_output_enabled
return is_editor_output_enabled
if name == "get_editor_output":
from .editor import get_editor_output
return get_editor_output
if name == "TOOL_LABELS":
from .editor import TOOL_LABELS
return TOOL_LABELS
if name == "BlockType":
from .editor import BlockType
return BlockType
if name == "DisplayBlock":
from .editor import DisplayBlock
return DisplayBlock
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -309,6 +309,78 @@ def get_project_data_dir(project_path: Optional[Union[str, Path]] = None) -> Pat
return base / DEFAULT_DIR_NAME
def get_project_sessions_dir(project_path: Optional[Union[str, Path]] = None) -> Path:
"""
Get project-level sessions directory.
Args:
project_path: Project root (defaults to cwd)
Returns:
Path to .praisonai/sessions/ in project
"""
return get_project_data_dir(project_path) / "sessions"
def get_project_knowledge_dir(project_path: Optional[Union[str, Path]] = None) -> Path:
"""
Get project-level knowledge directory.
Args:
project_path: Project root (defaults to cwd)
Returns:
Path to .praisonai/knowledge/ in project
"""
return get_project_data_dir(project_path) / "knowledge"
def get_project_summaries_dir(project_path: Optional[Union[str, Path]] = None) -> Path:
"""
Get project-level summaries directory for RAG.
Args:
project_path: Project root (defaults to cwd)
Returns:
Path to .praisonai/summaries/ in project
"""
return get_project_data_dir(project_path) / "summaries"
def get_project_prp_dir(project_path: Optional[Union[str, Path]] = None) -> Path:
"""
Get project-level PRP output directory.
Args:
project_path: Project root (defaults to cwd)
Returns:
Path to .praisonai/prp/ in project
"""
return get_project_data_dir(project_path) / "prp"
def get_schedules_dir() -> Path:
"""
Get schedules directory.
Returns:
Path to ~/.praisonai/schedules/
"""
return get_data_dir() / "schedules"
def get_storage_path() -> Path:
"""
Get default SQLite storage database path.
Returns:
Path to ~/.praisonai/storage.db
"""
return get_data_dir() / "storage.db"
def ensure_dir(path: Union[str, Path]) -> Path:
"""
Ensure a directory exists, creating it if necessary.
@@ -352,6 +424,8 @@ def get_all_paths() -> Dict[str, Path]:
"rules": get_rules_dir(),
"permissions": get_permissions_dir(),
"storage": get_storage_dir(),
"storage_db": get_storage_path(),
"schedules": get_schedules_dir(),
"checkpoints": get_checkpoints_dir(),
"snapshots": get_snapshots_dir(),
"learn": get_learn_dir(),
@@ -15,8 +15,9 @@ from typing import Optional, List, Any, Dict
logger = logging.getLogger(__name__)
# Default rules file path (follows ~/.praisonai/ convention)
DEFAULT_RULES_DIR = os.path.join(os.path.expanduser("~"), ".praisonai", "rules")
# Default rules file path (uses centralized paths - DRY)
from ..paths import get_rules_dir as _get_rules_dir
DEFAULT_RULES_DIR = str(_get_rules_dir())
DEFAULT_RULES_FILE = os.path.join(DEFAULT_RULES_DIR, "default.rules")
@@ -92,7 +92,7 @@ class RetrievalConfig:
vector_store_provider: str = "chroma"
vector_store_config: Dict[str, Any] = field(default_factory=dict)
collection_name: Optional[str] = None
persist_path: str = ".praisonai"
persist_path: str = None # Resolved to str(get_project_data_dir()) in __post_init__
# Embedding configuration
embedder_provider: Optional[str] = None
@@ -112,6 +112,11 @@ class RetrievalConfig:
{context}
</retrieved_context>"""
system_separation: bool = True # Use system prompt separation for safety
def __post_init__(self):
if self.persist_path is None:
from ..paths import get_project_data_dir
self.persist_path = str(get_project_data_dir())
def get_token_budget(self, model_name: Optional[str] = None):
"""
@@ -233,7 +238,7 @@ class RetrievalConfig:
vector_store_provider=data.get("vector_store_provider", "chroma"),
vector_store_config=data.get("vector_store_config", {}),
collection_name=data.get("collection_name"),
persist_path=data.get("persist_path", ".praisonai"),
persist_path=data.get("persist_path"),
embedder_provider=data.get("embedder_provider"),
embedder_model=data.get("embedder_model"),
auto_keywords=auto_keywords,
@@ -99,7 +99,7 @@ class HierarchicalSummarizer:
def __init__(
self,
llm=None,
persist_path: str = ".praisonai/summaries",
persist_path: str = None,
verbose: bool = False,
):
"""
@@ -111,6 +111,9 @@ class HierarchicalSummarizer:
verbose: Enable verbose logging
"""
self._llm = llm
if persist_path is None:
from ..paths import get_project_summaries_dir
persist_path = str(get_project_summaries_dir())
self._persist_path = persist_path
self._verbose = verbose
self._hierarchy: Dict[str, SummaryNode] = {}
@@ -20,10 +20,11 @@ Default storage: ~/.praisonai/schedules/jobs.json
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .models import Schedule, ScheduleJob
from .models import Schedule, ScheduleJob, DeliveryTarget
from .store import FileScheduleStore
from .parser import parse_schedule
from .runner import ScheduleRunner
from .loop import ScheduleLoop
_module_cache = {}
@@ -33,10 +34,11 @@ def __getattr__(name: str):
if name in _module_cache:
return _module_cache[name]
if name in ("Schedule", "ScheduleJob"):
from .models import Schedule, ScheduleJob
if name in ("Schedule", "ScheduleJob", "DeliveryTarget"):
from .models import Schedule, ScheduleJob, DeliveryTarget
_module_cache["Schedule"] = Schedule
_module_cache["ScheduleJob"] = ScheduleJob
_module_cache["DeliveryTarget"] = DeliveryTarget
return _module_cache[name]
if name == "FileScheduleStore":
@@ -54,13 +56,20 @@ def __getattr__(name: str):
_module_cache[name] = ScheduleRunner
return ScheduleRunner
if name == "ScheduleLoop":
from .loop import ScheduleLoop
_module_cache[name] = ScheduleLoop
return ScheduleLoop
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
"Schedule",
"ScheduleJob",
"DeliveryTarget",
"FileScheduleStore",
"parse_schedule",
"ScheduleRunner",
"ScheduleLoop",
]
@@ -0,0 +1,126 @@
"""
Schedule Loop for PraisonAI Agents.
Polls ScheduleRunner for due jobs and fires a callback.
"""
import logging
import threading
import time
from typing import Callable, Optional
from .runner import ScheduleRunner
from .store import FileScheduleStore
logger = logging.getLogger(__name__)
class ScheduleLoop:
"""Tick loop that polls a ScheduleRunner and fires due jobs.
Runs on a **daemon thread** so it never blocks the caller and
is automatically cleaned up on process exit.
Example::
from praisonaiagents.scheduler import ScheduleLoop
def handle(job):
print(f"Firing: {job.name} — {job.message}")
loop = ScheduleLoop(on_trigger=handle, tick_seconds=30)
loop.start()
# … later …
loop.stop()
"""
def __init__(
self,
on_trigger: Callable,
store: Optional[FileScheduleStore] = None,
tick_seconds: float = 30.0,
):
"""
Args:
on_trigger: Called with each due ``ScheduleJob``.
store: Schedule store to poll. Defaults to the global
``FileScheduleStore()``.
tick_seconds: Seconds between polls (default 30).
"""
self._on_trigger = on_trigger
self._store = store or FileScheduleStore()
self._runner = ScheduleRunner(self._store)
self._tick = tick_seconds
self._stop_event = threading.Event()
self._thread: Optional[threading.Thread] = None
# ── Public API ──────────────────────────────────────────────────────
@property
def is_running(self) -> bool:
"""Whether the loop thread is alive."""
return self._thread is not None and self._thread.is_alive()
def start(self) -> None:
"""Start the tick loop on a daemon thread.
Calling ``start()`` when already running is a no-op.
"""
if self.is_running:
return
self._stop_event.clear()
self._thread = threading.Thread(
target=self._run, name="schedule-loop", daemon=True
)
self._thread.start()
logger.info("ScheduleLoop started (tick=%ss)", self._tick)
def stop(self, timeout: float = 5.0) -> None:
"""Signal the loop to stop and wait for the thread to exit.
Args:
timeout: Max seconds to wait for the thread to join.
"""
self._stop_event.set()
if self._thread is not None:
self._thread.join(timeout=timeout)
self._thread = None
logger.info("ScheduleLoop stopped")
# ── Internal ────────────────────────────────────────────────────────
def _run(self) -> None:
"""Loop body executed on the daemon thread."""
while not self._stop_event.is_set():
try:
due = self._runner.get_due_jobs()
for job in due:
try:
self._on_trigger(job)
# Update last_run_at so the job won't re-fire
# until the next interval elapses.
job.last_run_at = time.time()
self._store.update(job)
self._emit_hook(job)
except Exception:
logger.exception(
"Error triggering schedule job %s (%s)",
job.name, job.id,
)
except Exception:
logger.exception("ScheduleLoop tick error")
# Sleep in small increments so stop() is responsive.
self._stop_event.wait(timeout=self._tick)
def _emit_hook(self, job) -> None:
"""Best-effort emit of SCHEDULE_TRIGGER notification.
Logs the trigger event. Hook-aware callers (e.g. bots) can
wire a proper ``HookRunner`` externally via the ``on_trigger``
callback.
"""
logger.debug(
"SCHEDULE_TRIGGER: job=%s id=%s", job.name, job.id
)
@@ -51,6 +51,45 @@ class Schedule:
)
@dataclass
class DeliveryTarget:
"""Where to deliver the result when a scheduled job fires.
Attributes:
channel: Platform name (``"telegram"``, ``"discord"``,
``"slack"``, ``"whatsapp"``).
channel_id: Platform-specific chat / channel / group ID.
thread_id: Optional thread ID for threaded delivery.
session_id: Optional session ID to preserve conversation
context when the cron fires.
"""
channel: str = ""
channel_id: str = ""
thread_id: Optional[str] = None
session_id: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
d: Dict[str, Any] = {
"channel": self.channel,
"channel_id": self.channel_id,
}
if self.thread_id is not None:
d["thread_id"] = self.thread_id
if self.session_id is not None:
d["session_id"] = self.session_id
return d
@classmethod
def from_dict(cls, d: Dict[str, Any]) -> "DeliveryTarget":
return cls(
channel=d.get("channel", ""),
channel_id=d.get("channel_id", ""),
thread_id=d.get("thread_id"),
session_id=d.get("session_id"),
)
@dataclass
class ScheduleJob:
"""A persisted scheduled job.
@@ -67,6 +106,8 @@ class ScheduleJob:
delete_after_run: Auto-remove after first execution (one-shot).
created_at: Epoch timestamp.
last_run_at: Epoch timestamp of most recent execution.
delivery: Optional delivery target for routing results back
to a channel bot (e.g. Telegram chat).
"""
name: str = ""
@@ -79,11 +120,12 @@ class ScheduleJob:
delete_after_run: bool = False
created_at: float = field(default_factory=time.time)
last_run_at: Optional[float] = None
delivery: Optional[DeliveryTarget] = None
# ── serialisation ────────────────────────────────────────────────
def to_dict(self) -> Dict[str, Any]:
return {
d: Dict[str, Any] = {
"id": self.id,
"name": self.name,
"schedule": self.schedule.to_dict(),
@@ -95,10 +137,14 @@ class ScheduleJob:
"created_at": self.created_at,
"last_run_at": self.last_run_at,
}
if self.delivery is not None:
d["delivery"] = self.delivery.to_dict()
return d
@classmethod
def from_dict(cls, d: Dict[str, Any]) -> "ScheduleJob":
sched_data = d.get("schedule", {})
delivery_data = d.get("delivery")
return cls(
id=d.get("id", uuid.uuid4().hex[:12]),
name=d.get("name", ""),
@@ -110,4 +156,5 @@ class ScheduleJob:
delete_after_run=d.get("delete_after_run", False),
created_at=d.get("created_at", time.time()),
last_run_at=d.get("last_run_at"),
delivery=DeliveryTarget.from_dict(delivery_data) if isinstance(delivery_data, dict) else None,
)
@@ -15,7 +15,8 @@ from .models import ScheduleJob
logger = logging.getLogger(__name__)
_DEFAULT_DIR = os.path.join(os.path.expanduser("~"), ".praisonai", "schedules")
from ..paths import get_schedules_dir as _get_schedules_dir
_DEFAULT_DIR = str(_get_schedules_dir())
_JOBS_FILE = "jobs.json"
+44 -15
View File
@@ -83,7 +83,7 @@ class Session:
default_memory_config = {
"provider": "rag",
"use_embedding": False, # Disable embeddings to avoid OpenAI API key requirement
"rag_db_path": f".praisonai/sessions/{self.session_id}/chroma_db"
"rag_db_path": str(self._get_session_dir() / "chroma_db")
}
if memory_config:
default_memory_config.update(memory_config)
@@ -94,7 +94,7 @@ class Session:
self.knowledge_config = default_knowledge_config
# Create session directory
os.makedirs(f".praisonai/sessions/{self.session_id}", exist_ok=True)
self._get_session_dir().mkdir(parents=True, exist_ok=True)
# Initialize components lazily
self._memory = None
@@ -110,6 +110,12 @@ class Session:
self._agents_instance = None
self._agents = {} # Track agents and their chat histories
def _get_session_dir(self):
"""Return session-specific directory using paths.py."""
from pathlib import Path
from .paths import get_project_sessions_dir
return get_project_sessions_dir() / self.session_id
@property
def memory(self) -> Memory:
"""Lazy-loaded memory instance"""
@@ -323,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
@@ -340,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"""
@@ -86,7 +86,7 @@ class Session:
default_memory_config = {
"provider": "rag",
"use_embedding": False,
"rag_db_path": f".praisonai/sessions/{self.session_id}/chroma_db"
"rag_db_path": str(self._get_session_dir() / "chroma_db")
}
if memory_config:
default_memory_config.update(memory_config)
@@ -95,7 +95,7 @@ class Session:
default_knowledge_config = knowledge_config or {}
self.knowledge_config = default_knowledge_config
os.makedirs(f".praisonai/sessions/{self.session_id}", exist_ok=True)
self._get_session_dir().mkdir(parents=True, exist_ok=True)
self._memory = None
self._knowledge = None
@@ -109,6 +109,12 @@ class Session:
self._agents_instance = None
self._agents = {}
def _get_session_dir(self):
"""Get session-specific directory using centralized paths."""
from ..paths import get_project_sessions_dir
from pathlib import Path
return get_project_sessions_dir() / self.session_id
@property
def memory(self) -> "Memory":
"""Lazy-loaded memory instance"""
@@ -305,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
@@ -320,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"""
@@ -67,6 +67,9 @@ class SessionData:
agent_name: Optional[str] = None
user_id: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
# Gap S3: Gateway integration - link session to gateway session
gateway_session_id: Optional[str] = None
agent_id: Optional[str] = None # Gateway agent ID (different from agent_name)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
@@ -78,6 +81,8 @@ class SessionData:
"agent_name": self.agent_name,
"user_id": self.user_id,
"metadata": self.metadata,
"gateway_session_id": self.gateway_session_id,
"agent_id": self.agent_id,
}
@classmethod
@@ -95,6 +100,8 @@ class SessionData:
agent_name=data.get("agent_name"),
user_id=data.get("user_id"),
metadata=data.get("metadata", {}),
gateway_session_id=data.get("gateway_session_id"),
agent_id=data.get("agent_id"),
)
def get_chat_history(self, max_messages: Optional[int] = None) -> List[Dict[str, str]]:
@@ -489,6 +496,177 @@ class DefaultSessionStore:
sessions.sort(key=lambda x: x.get("updated_at", ""), reverse=True)
return sessions[:limit]
# ── Agent-Level Queries (Gap S4) ──────────────────────────────────
def list_sessions_by_agent(self, agent_name: str, limit: int = 50) -> List[str]:
"""List session IDs for a specific agent.
Gap S4: Enables querying sessions by agent instead of just session ID.
Args:
agent_name: The agent name to filter by
limit: Maximum number of session IDs to return
Returns:
List of session IDs belonging to the specified agent
"""
session_ids = []
try:
for filename in os.listdir(self.session_dir):
if filename.endswith(".json"):
filepath = os.path.join(self.session_dir, filename)
try:
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
if data.get("agent_name") == agent_name:
session_ids.append(data.get("session_id", filename[:-5]))
except (json.JSONDecodeError, IOError):
continue
except (IOError, OSError):
pass
return session_ids[:limit]
def get_sessions_by_agent(
self,
agent_name: str,
limit: int = 10,
) -> List[SessionData]:
"""Get full session data for a specific agent.
Gap S4: Enables retrieving all sessions for an agent.
Args:
agent_name: The agent name to filter by
limit: Maximum number of sessions to return
Returns:
List of SessionData objects for the specified agent
"""
session_ids = self.list_sessions_by_agent(agent_name, limit)
return [self._load_session(sid) for sid in session_ids]
def get_agent_chat_history(
self,
agent_name: str,
max_messages: Optional[int] = None,
max_sessions: int = 5,
) -> List[Dict[str, Any]]:
"""Get combined chat history across all sessions for an agent.
Gap S4: Enables retrieving conversation history by agent.
Args:
agent_name: The agent name to filter by
max_messages: Maximum messages per session
max_sessions: Maximum number of sessions to include
Returns:
List of messages with session context
"""
sessions = self.get_sessions_by_agent(agent_name, max_sessions)
messages = []
for session in sessions:
history = session.get_chat_history(max_messages)
for msg in history:
messages.append({
**msg,
"session_id": session.session_id,
"agent_name": agent_name,
})
return messages
# ── Gateway Integration (Gap S3) ──────────────────────────────────
def set_gateway_info(
self,
session_id: str,
gateway_session_id: Optional[str] = None,
agent_id: Optional[str] = None,
) -> bool:
"""Link a session to a gateway session.
Gap S3: Enables memory to be scoped to gateway agents/sessions.
Args:
session_id: The local session ID
gateway_session_id: The gateway session ID to link to
agent_id: The gateway agent ID
Returns:
True if saved successfully
"""
session = self._load_session(session_id)
with self._lock:
if gateway_session_id:
session.gateway_session_id = gateway_session_id
if agent_id:
session.agent_id = agent_id
self._cache[session_id] = session
return self._save_session(session)
def get_by_gateway_session(self, gateway_session_id: str) -> Optional[SessionData]:
"""Get session data linked to a gateway session.
Gap S3: Enables retrieving memory by gateway session ID.
Args:
gateway_session_id: The gateway session ID to look up
Returns:
SessionData if found, None otherwise
"""
try:
for filename in os.listdir(self.session_dir):
if filename.endswith(".json"):
filepath = os.path.join(self.session_dir, filename)
try:
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
if data.get("gateway_session_id") == gateway_session_id:
return SessionData.from_dict(data)
except (json.JSONDecodeError, IOError):
continue
except (IOError, OSError):
pass
return None
def list_sessions_by_gateway_agent(self, agent_id: str, limit: int = 50) -> List[str]:
"""List session IDs linked to a gateway agent.
Gap S3: Enables querying sessions by gateway agent ID.
Args:
agent_id: The gateway agent ID to filter by
limit: Maximum number of session IDs to return
Returns:
List of session IDs linked to the gateway agent
"""
session_ids = []
try:
for filename in os.listdir(self.session_dir):
if filename.endswith(".json"):
filepath = os.path.join(self.session_dir, filename)
try:
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
if data.get("agent_id") == agent_id:
session_ids.append(data.get("session_id", filename[:-5]))
except (json.JSONDecodeError, IOError):
continue
except (IOError, OSError):
pass
return session_ids[:limit]
def session_exists(self, session_id: str) -> bool:
"""Check if a session exists."""
filepath = self._get_session_path(session_id)
@@ -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,13 +13,14 @@ DRY: All backends implement StorageBackendProtocol for consistent interface.
import json
import os
import time
import threading
import tempfile
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional
from ..paths import get_storage_dir
from ..paths import get_storage_dir, get_storage_path
logger = logging.getLogger(__name__)
@@ -175,7 +176,7 @@ class SQLiteBackend:
def __init__(
self,
db_path: str = "~/.praisonai/storage.db",
db_path: str = None,
table_name: str = "praison_storage",
auto_create: bool = True,
):
@@ -187,7 +188,7 @@ class SQLiteBackend:
table_name: Name of the storage table
auto_create: Create table if it doesn't exist
"""
self.db_path = os.path.expanduser(db_path)
self.db_path = os.path.expanduser(db_path) if db_path else str(get_storage_path())
self.table_name = table_name
self._local = threading.local()
@@ -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',
]
@@ -163,6 +163,14 @@ 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),
}
_instances = {} # Cache for class instances
@@ -221,7 +229,9 @@ 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'
]:
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']:
@@ -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,23 @@ 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)",
),
}
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 +106,7 @@ def _build_autonomy_tools() -> List[str]:
AUTONOMY_PROFILE = ToolProfile(
name="autonomy",
tools=_build_autonomy_tools(),
description="Default tools for autonomous agents (file_ops + shell + web + code_intelligence)",
description="Default tools for autonomous agents (code_intelligence + file_ops + shell + web + memory + learning + schedule)",
)
@@ -41,6 +41,10 @@ def schedule_add(
name: str,
schedule: str,
message: str = "",
channel: str = "",
channel_id: str = "",
agent_id: str = "",
session_id: str = "",
) -> str:
"""Add a new scheduled job.
@@ -53,16 +57,39 @@ def schedule_add(
- "at:2026-03-01T09:00:00" (one-shot ISO timestamp)
- "in 20 minutes" (relative one-shot)
message: The prompt or reminder text to deliver when triggered.
channel: Delivery platform ("telegram", "discord", "slack",
"whatsapp"). If set with channel_id, the result will
be sent back to that chat when the job fires.
channel_id: Target chat/channel/group ID on the platform.
agent_id: Which agent should execute this job. If empty, the
executor uses its default agent.
session_id: Optional session ID to preserve conversation context.
If set, the agent will have access to prior chat history.
Returns:
Confirmation string with the job id.
"""
try:
from ..scheduler.parser import parse_schedule
from ..scheduler.models import ScheduleJob
from ..scheduler.models import ScheduleJob, DeliveryTarget
sched = parse_schedule(schedule)
job = ScheduleJob(name=name, schedule=sched, message=message)
delivery = None
if channel and channel_id:
delivery = DeliveryTarget(
channel=channel,
channel_id=channel_id,
session_id=session_id or None,
)
job = ScheduleJob(
name=name,
schedule=sched,
message=message,
agent_id=agent_id or None,
delivery=delivery,
)
store = _get_store()
@@ -72,7 +99,9 @@ def schedule_add(
return f"A schedule named '{name}' already exists (id: {existing.id}). Remove it first or choose a different name."
store.add(job)
return f"Schedule '{name}' added (id: {job.id}, {schedule})."
delivery_note = f" → deliver to {channel}:{channel_id}" if delivery else ""
agent_note = f" agent={agent_id}" if agent_id else ""
return f"Schedule '{name}' added (id: {job.id}, {schedule}{agent_note}{delivery_note})."
except ValueError as e:
return f"Error adding schedule: {e}"
except Exception as e:
@@ -105,10 +134,15 @@ def schedule_list() -> str:
sched_str = f"at: {sched.at}"
else:
sched_str = str(sched.kind)
lines.append(
f" - {j.name} (id: {j.id}) [{status}] — {sched_str}"
+ (f" — \"{j.message}\"" if j.message else "")
)
parts = [f" - {j.name} (id: {j.id}) [{status}] — {sched_str}"]
if j.agent_id:
parts.append(f" agent={j.agent_id}")
if j.delivery:
parts.append(f" → {j.delivery.channel}:{j.delivery.channel_id}")
if j.message:
parts.append(f' — "{j.message}"')
lines.append("".join(parts))
return "\n".join(lines)
except Exception as e:
logger.error("schedule_list failed: %s", e, exc_info=True)
@@ -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}'),
@@ -2955,7 +2988,8 @@ class WorkflowManager:
```
"""
WORKFLOWS_DIR = ".praisonai/workflows"
from ..paths import DEFAULT_DIR_NAME as _DIR_NAME
WORKFLOWS_DIR = f"{_DIR_NAME}/workflows"
SUPPORTED_EXTENSIONS = [".md", ".yaml", ".yml"]
def __init__(
@@ -4246,7 +4280,8 @@ class WorkflowManager:
def _get_checkpoints_dir(self) -> Path:
"""Get the checkpoints directory path."""
checkpoints_dir = self.workspace_path / ".praisonai" / "checkpoints"
from ..paths import DEFAULT_DIR_NAME
checkpoints_dir = self.workspace_path / DEFAULT_DIR_NAME / "checkpoints"
checkpoints_dir.mkdir(parents=True, exist_ok=True)
return checkpoints_dir
+8 -2
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.5.20"
version = "1.5.49"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -88,6 +88,11 @@ autonomy = [
"ast-grep-cli>=0.30.0"
]
# Web search (DuckDuckGo — zero-config, no API key required)
search = [
"ddgs>=9.0.0",
]
# Combined features
all = [
"praisonaiagents[memory]",
@@ -98,7 +103,8 @@ all = [
"praisonaiagents[api]",
"praisonaiagents[telemetry]",
"praisonaiagents[mongodb]",
"praisonaiagents[auth]"
"praisonaiagents[auth]",
"praisonaiagents[search]"
]
[tool.setuptools.packages.find]
+688 -2
View File
@@ -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,200 @@
"""
Integration tests for directory consistency with real API calls.
Tests that PRAISONAI_HOME overrides actually redirect all data,
and that a real agentic call works end-to-end with the wired paths.
"""
import os
import shutil
import tempfile
from pathlib import Path
from unittest.mock import patch
import pytest
class TestPraisonaiHomeOverride:
"""Tests that PRAISONAI_HOME env var redirects all global paths."""
def test_all_global_paths_honor_override(self):
"""Every global helper should resolve under PRAISONAI_HOME."""
from praisonaiagents.paths import (
_clear_cache,
get_data_dir,
get_sessions_dir,
get_skills_dir,
get_plugins_dir,
get_mcp_dir,
get_docs_dir,
get_rules_dir,
get_permissions_dir,
get_storage_dir,
get_checkpoints_dir,
get_snapshots_dir,
get_learn_dir,
get_cache_dir,
get_mcp_auth_path,
get_memory_dir,
get_workflows_dir,
get_summaries_dir,
get_prp_dir,
get_runs_dir,
get_schedules_dir,
get_storage_path,
)
custom_home = tempfile.mkdtemp(prefix="praisonai_test_")
try:
_clear_cache()
with patch.dict(os.environ, {"PRAISONAI_HOME": custom_home}):
base = Path(custom_home)
assert get_data_dir() == base
assert get_sessions_dir() == base / "sessions"
assert get_skills_dir() == base / "skills"
assert get_plugins_dir() == base / "plugins"
assert get_mcp_dir() == base / "mcp"
assert get_docs_dir() == base / "docs"
assert get_rules_dir() == base / "rules"
assert get_permissions_dir() == base / "permissions"
assert get_storage_dir() == base / "storage"
assert get_checkpoints_dir() == base / "checkpoints"
assert get_snapshots_dir() == base / "snapshots"
assert get_learn_dir() == base / "learn"
assert get_cache_dir() == base / "cache"
assert get_mcp_auth_path() == base / "mcp-auth.json"
assert get_memory_dir() == base / "memory"
assert get_workflows_dir() == base / "workflows"
assert get_summaries_dir() == base / "summaries"
assert get_prp_dir() == base / "prp"
assert get_runs_dir() == base / "runs"
assert get_schedules_dir() == base / "schedules"
assert get_storage_path() == base / "storage.db"
finally:
_clear_cache()
shutil.rmtree(custom_home, ignore_errors=True)
def test_autonomy_config_respects_override(self):
"""AutonomyConfig.snapshot_dir should redirect with PRAISONAI_HOME."""
from praisonaiagents.paths import _clear_cache
custom_home = tempfile.mkdtemp(prefix="praisonai_test_")
try:
_clear_cache()
with patch.dict(os.environ, {"PRAISONAI_HOME": custom_home}):
from praisonaiagents.agent.autonomy import AutonomyConfig
config = AutonomyConfig()
assert config.snapshot_dir == str(Path(custom_home) / "snapshots")
finally:
_clear_cache()
shutil.rmtree(custom_home, ignore_errors=True)
def test_retrieval_config_defaults_to_project_local(self):
"""RetrievalConfig.persist_path should default to project-local .praisonai."""
from praisonaiagents.rag.retrieval_config import RetrievalConfig
from praisonaiagents.paths import get_project_data_dir
rc = RetrievalConfig()
assert rc.persist_path == str(get_project_data_dir())
def test_summarizer_defaults_to_project_local(self):
"""HierarchicalSummarizer._persist_path should default to project-local .praisonai/summaries."""
from praisonaiagents.rag.summarizer import HierarchicalSummarizer
from praisonaiagents.paths import get_project_summaries_dir
hs = HierarchicalSummarizer()
assert hs._persist_path == str(get_project_summaries_dir())
class TestRealAgenticCall:
"""Real agentic test — creates Agent, calls LLM, verifies path consistency."""
@pytest.mark.skipif(
not os.getenv("OPENAI_API_KEY"),
reason="OPENAI_API_KEY not set — skipping real LLM call"
)
def test_agent_start_with_custom_home(self):
"""
MANDATORY REAL AGENTIC TEST.
Creates Agent, calls agent.start(), verifies LLM responds.
"""
from praisonaiagents import Agent
agent = Agent(
name="path-test",
instructions="You are a helpful assistant. Respond in one sentence.",
)
result = agent.start("Say hello in one sentence")
# Agent must produce a non-empty text response from the LLM
assert result is not None
result_str = str(result)
assert len(result_str) > 0, "Agent returned empty response"
print(f"\n✓ Real agentic response: {result_str[:200]}")
@pytest.mark.skipif(
not os.getenv("OPENAI_API_KEY"),
reason="OPENAI_API_KEY not set — skipping real LLM call"
)
def test_agent_with_praisonai_home_override(self):
"""
Run agent with PRAISONAI_HOME override and verify data paths work.
"""
from praisonaiagents.paths import _clear_cache
custom_home = tempfile.mkdtemp(prefix="praisonai_agent_test_")
try:
_clear_cache()
os.environ["PRAISONAI_HOME"] = custom_home
from praisonaiagents import Agent
agent = Agent(
name="home-test",
instructions="You are a helpful assistant.",
)
result = agent.start("Say 'path test passed' in one sentence")
assert result is not None
print(f"\n✓ Agent with custom PRAISONAI_HOME: {str(result)[:200]}")
finally:
os.environ.pop("PRAISONAI_HOME", None)
_clear_cache()
shutil.rmtree(custom_home, ignore_errors=True)
class TestNoHardcodedPaths:
"""Verify no remaining hardcoded paths in the fixed modules."""
def test_no_expanduser_in_policy_config(self):
"""policy/config.py should not expanduser for rules."""
import inspect
from praisonaiagents.policy import config
source = inspect.getsource(config)
assert 'expanduser("~"), ".praisonai", "rules"' not in source
def test_no_expanduser_in_autonomy(self):
"""autonomy.py should not expanduser for snapshots."""
import inspect
from praisonaiagents.agent import autonomy
source = inspect.getsource(autonomy.AutonomyConfig.__post_init__)
assert 'expanduser("~"), ".praisonai", "snapshots"' not in source
def test_no_expanduser_in_scheduler_store(self):
"""scheduler/store.py should not expanduser for schedules."""
import inspect
from praisonaiagents.scheduler import store
source = inspect.getsource(store)
assert 'expanduser("~"), ".praisonai", "schedules"' not in source
def test_no_hardcoded_string_in_backends(self):
"""storage/backends.py should not have hardcoded ~/.praisonai/storage.db default."""
import inspect
from praisonaiagents.storage.backends import SQLiteBackend
source = inspect.getsource(SQLiteBackend.__init__)
assert '"~/.praisonai/storage.db"' not in source
@@ -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()
@@ -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
@@ -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,130 @@ 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."""
# Response without completion keywords, but no tool calls made
mock_chat.return_value = "I have analyzed the situation and here is my analysis of the data."
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
@@ -0,0 +1,322 @@
"""
Unit tests for EditorOutput module.
Tests the user-friendly editor-style output mode with:
- Step numbering
- Human-readable tool labels
- Multi-agent prefix support
- Markdown export
- Thread safety
"""
import pytest
import threading
from unittest.mock import MagicMock, patch
class TestEditorOutputImports:
"""Test that EditorOutput can be imported correctly."""
def test_import_from_output_module(self):
"""Test lazy import from output module."""
from praisonaiagents.output import EditorOutput
assert EditorOutput is not None
def test_import_enable_disable(self):
"""Test enable/disable functions can be imported."""
from praisonaiagents.output import enable_editor_output, disable_editor_output
assert callable(enable_editor_output)
assert callable(disable_editor_output)
def test_import_tool_labels(self):
"""Test TOOL_LABELS can be imported."""
from praisonaiagents.output import TOOL_LABELS
assert isinstance(TOOL_LABELS, dict)
assert 'internet_search' in TOOL_LABELS
def test_import_block_types(self):
"""Test BlockType and DisplayBlock can be imported."""
from praisonaiagents.output import BlockType, DisplayBlock
assert BlockType.NARRATIVE.value == "narrative"
assert BlockType.COMMAND.value == "command"
class TestEditorOutputBasic:
"""Test basic EditorOutput functionality."""
def test_init_default(self):
"""Test default initialization."""
from praisonaiagents.output.editor import EditorOutput
editor = EditorOutput(use_rich=False)
assert editor._step_count == 0
assert editor._blocks == []
assert editor._multi_agent_mode is False
def test_init_with_agent_name(self):
"""Test initialization with agent name."""
from praisonaiagents.output.editor import EditorOutput
editor = EditorOutput(use_rich=False, agent_name="TestAgent")
assert editor._agent_name == "TestAgent"
def test_elapsed_time(self):
"""Test elapsed time tracking."""
from praisonaiagents.output.editor import EditorOutput
import time
editor = EditorOutput(use_rich=False)
time.sleep(0.1)
elapsed = editor.elapsed_time()
assert elapsed >= 0.1
class TestToolCall:
"""Test tool_call method."""
def test_tool_call_increments_step(self):
"""Test that tool_call increments step counter."""
from praisonaiagents.output.editor import EditorOutput
editor = EditorOutput(use_rich=False)
editor.tool_call("test_tool")
assert editor._step_count == 1
editor.tool_call("test_tool")
assert editor._step_count == 2
def test_tool_call_with_known_tool(self):
"""Test tool_call with a known tool label."""
from praisonaiagents.output.editor import EditorOutput, TOOL_LABELS
editor = EditorOutput(use_rich=False)
# internet_search should have a label
assert 'internet_search' in TOOL_LABELS
editor.tool_call("internet_search", args={"query": "test"})
blocks = editor.get_blocks()
assert len(blocks) == 1
assert "Searching the web" in blocks[0].content
def test_tool_call_with_unknown_tool(self):
"""Test tool_call with an unknown tool."""
from praisonaiagents.output.editor import EditorOutput
editor = EditorOutput(use_rich=False)
editor.tool_call("my_custom_tool")
blocks = editor.get_blocks()
assert len(blocks) == 1
assert "my_custom_tool" in blocks[0].content
def test_tool_call_result_formatting(self):
"""Test that JSON results are formatted nicely."""
from praisonaiagents.output.editor import EditorOutput
editor = EditorOutput(use_rich=False)
# Test list result
editor.tool_call("test", result='[{"a": 1}, {"b": 2}]')
blocks = editor.get_blocks()
assert "Found 2 items" in blocks[0].output
def test_tool_call_success_result(self):
"""Test success result formatting."""
from praisonaiagents.output.editor import EditorOutput
editor = EditorOutput(use_rich=False)
editor.tool_call("test", result='{"success": true}')
blocks = editor.get_blocks()
assert "Success" in blocks[0].output
class TestMultiAgentPrefix:
"""Test multi-agent prefix support."""
def test_no_prefix_by_default(self):
"""Test that no prefix is shown by default."""
from praisonaiagents.output.editor import EditorOutput
editor = EditorOutput(use_rich=False, agent_name="Agent1")
prefix = editor._get_prefix()
assert prefix == ""
def test_prefix_when_multi_agent_enabled(self):
"""Test prefix is shown when multi-agent mode is enabled."""
from praisonaiagents.output.editor import EditorOutput
editor = EditorOutput(use_rich=False, agent_name="Agent1")
editor.enable_multi_agent_mode()
prefix = editor._get_prefix()
assert prefix == "[Agent1] "
def test_set_agent_name(self):
"""Test setting agent name."""
from praisonaiagents.output.editor import EditorOutput
editor = EditorOutput(use_rich=False)
editor.set_agent_name("NewAgent")
editor.enable_multi_agent_mode()
prefix = editor._get_prefix()
assert prefix == "[NewAgent] "
def test_prefix_override(self):
"""Test prefix can be overridden per-call."""
from praisonaiagents.output.editor import EditorOutput
editor = EditorOutput(use_rich=False, agent_name="Agent1")
editor.enable_multi_agent_mode()
prefix = editor._get_prefix("Agent2")
assert prefix == "[Agent2] "
class TestAdditionalMethods:
"""Test additional display methods."""
def test_narrative(self):
"""Test narrative method."""
from praisonaiagents.output.editor import EditorOutput, BlockType
editor = EditorOutput(use_rich=False)
editor.narrative("This is a narrative block")
blocks = editor.get_blocks()
assert len(blocks) == 1
assert blocks[0].type == BlockType.NARRATIVE
assert blocks[0].content == "This is a narrative block"
def test_code(self):
"""Test code method."""
from praisonaiagents.output.editor import EditorOutput, BlockType
editor = EditorOutput(use_rich=False)
editor.code("print('hello')", language="python")
blocks = editor.get_blocks()
assert len(blocks) == 1
assert blocks[0].type == BlockType.CODE
assert blocks[0].metadata["language"] == "python"
def test_action(self):
"""Test action method."""
from praisonaiagents.output.editor import EditorOutput, BlockType
editor = EditorOutput(use_rich=False)
editor.action("File created", details=["path: /tmp/test.py"])
blocks = editor.get_blocks()
assert len(blocks) == 1
assert blocks[0].type == BlockType.ACTION
assert blocks[0].title == "File created"
def test_list_items(self):
"""Test list_items method."""
from praisonaiagents.output.editor import EditorOutput, BlockType
editor = EditorOutput(use_rich=False)
editor.list_items(["Item 1", "Item 2"], title="My List")
blocks = editor.get_blocks()
assert len(blocks) == 1
assert blocks[0].type == BlockType.LIST
assert blocks[0].items == ["Item 1", "Item 2"]
def test_error(self):
"""Test error method."""
from praisonaiagents.output.editor import EditorOutput
editor = EditorOutput(use_rich=False)
# Just verify it doesn't crash
editor.error("Something went wrong")
def test_summary(self):
"""Test summary method."""
from praisonaiagents.output.editor import EditorOutput, BlockType
editor = EditorOutput(use_rich=False)
editor.summary("Completed", items=["Duration: 5s", "Steps: 3"])
blocks = editor.get_blocks()
assert len(blocks) == 1
assert blocks[0].type == BlockType.SUMMARY
class TestToMarkdown:
"""Test to_markdown export."""
def test_to_markdown_empty(self):
"""Test to_markdown with no blocks."""
from praisonaiagents.output.editor import EditorOutput
editor = EditorOutput(use_rich=False)
md = editor.to_markdown()
assert md == ""
def test_to_markdown_with_blocks(self):
"""Test to_markdown with various blocks."""
from praisonaiagents.output.editor import EditorOutput
editor = EditorOutput(use_rich=False)
editor.narrative("Hello world")
editor.tool_call("test_tool", result="Done")
editor.summary("Complete", items=["Step 1"])
md = editor.to_markdown()
assert "Hello world" in md
assert "Complete" in md
assert "Step 1" in md
class TestThreadSafety:
"""Test thread safety of EditorOutput."""
def test_concurrent_tool_calls(self):
"""Test that concurrent tool_call is thread-safe."""
from praisonaiagents.output.editor import EditorOutput
editor = EditorOutput(use_rich=False)
def call_tool(n):
for _ in range(10):
editor.tool_call(f"tool_{n}")
threads = [threading.Thread(target=call_tool, args=(i,)) for i in range(5)]
for t in threads:
t.start()
for t in threads:
t.join()
# Should have 50 steps total (5 threads * 10 calls)
assert editor._step_count == 50
assert len(editor.get_blocks()) == 50
class TestEnableDisable:
"""Test enable/disable functions."""
def test_enable_returns_editor(self):
"""Test that enable_editor_output returns an EditorOutput instance."""
from praisonaiagents.output.editor import enable_editor_output, disable_editor_output, EditorOutput
editor = enable_editor_output(use_color=False)
assert isinstance(editor, EditorOutput)
disable_editor_output()
def test_is_enabled(self):
"""Test is_editor_output_enabled function."""
from praisonaiagents.output.editor import (
enable_editor_output, disable_editor_output, is_editor_output_enabled
)
disable_editor_output()
assert is_editor_output_enabled() is False
enable_editor_output(use_color=False)
assert is_editor_output_enabled() is True
disable_editor_output()
assert is_editor_output_enabled() is False
def test_get_editor_output(self):
"""Test get_editor_output function."""
from praisonaiagents.output.editor import (
enable_editor_output, disable_editor_output, get_editor_output
)
disable_editor_output()
assert get_editor_output() is None
editor = enable_editor_output(use_color=False)
assert get_editor_output() is editor
disable_editor_output()
@@ -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,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")
@@ -322,3 +322,146 @@ class TestImportPerformance:
# Should import in under 10ms (no heavy deps)
assert elapsed < 0.01, f"Import took {elapsed*1000:.2f}ms, expected < 10ms"
class TestProjectLocalHelpers:
"""Tests for project-local path helpers (get_project_*_dir)."""
def test_project_sessions_dir(self):
"""get_project_sessions_dir returns .praisonai/sessions/ in cwd."""
from praisonaiagents.paths import get_project_sessions_dir
result = get_project_sessions_dir()
assert result == Path.cwd() / ".praisonai" / "sessions"
def test_project_sessions_dir_custom_path(self, tmp_path):
"""get_project_sessions_dir accepts custom project path."""
from praisonaiagents.paths import get_project_sessions_dir
result = get_project_sessions_dir(tmp_path)
assert result == tmp_path / ".praisonai" / "sessions"
def test_project_knowledge_dir(self):
"""get_project_knowledge_dir returns .praisonai/knowledge/ in cwd."""
from praisonaiagents.paths import get_project_knowledge_dir
result = get_project_knowledge_dir()
assert result == Path.cwd() / ".praisonai" / "knowledge"
def test_project_knowledge_dir_custom_path(self, tmp_path):
"""get_project_knowledge_dir accepts custom project path."""
from praisonaiagents.paths import get_project_knowledge_dir
result = get_project_knowledge_dir(tmp_path)
assert result == tmp_path / ".praisonai" / "knowledge"
def test_project_summaries_dir(self):
"""get_project_summaries_dir returns .praisonai/summaries/ in cwd."""
from praisonaiagents.paths import get_project_summaries_dir
result = get_project_summaries_dir()
assert result == Path.cwd() / ".praisonai" / "summaries"
def test_project_prp_dir(self):
"""get_project_prp_dir returns .praisonai/prp/ in cwd."""
from praisonaiagents.paths import get_project_prp_dir
result = get_project_prp_dir()
assert result == Path.cwd() / ".praisonai" / "prp"
class TestGetSchedulesDir:
"""Tests for get_schedules_dir() function."""
def test_returns_schedules_subdir(self):
"""Should return schedules subdirectory of data dir."""
from praisonaiagents.paths import get_schedules_dir
result = get_schedules_dir()
assert result == Path.home() / ".praisonai" / "schedules"
def test_respects_env_override(self):
"""Should respect PRAISONAI_HOME override."""
from praisonaiagents.paths import get_schedules_dir
with patch.dict(os.environ, {"PRAISONAI_HOME": "/custom"}):
result = get_schedules_dir()
assert result == Path("/custom/schedules")
class TestGetStoragePath:
"""Tests for get_storage_path() function."""
def test_returns_storage_db(self):
"""Should return storage.db in data dir."""
from praisonaiagents.paths import get_storage_path
result = get_storage_path()
assert result == Path.home() / ".praisonai" / "storage.db"
def test_respects_env_override(self):
"""Should respect PRAISONAI_HOME override."""
from praisonaiagents.paths import get_storage_path
with patch.dict(os.environ, {"PRAISONAI_HOME": "/custom"}):
result = get_storage_path()
assert result == Path("/custom/storage.db")
class TestModuleWiring:
"""Tests verifying that modules use paths.py instead of hardcoded paths."""
def test_policy_config_uses_paths(self):
"""policy/config.py should resolve rules dir via paths.py."""
from praisonaiagents.policy.config import DEFAULT_RULES_DIR
from praisonaiagents.paths import get_rules_dir
assert DEFAULT_RULES_DIR == str(get_rules_dir())
def test_policy_config_no_hardcoded_expanduser(self):
"""policy/config.py should not have hardcoded expanduser for rules."""
import inspect
from praisonaiagents.policy import config
source = inspect.getsource(config)
# The module should not contain expanduser + .praisonai for rules
assert 'expanduser("~"), ".praisonai", "rules"' not in source
def test_autonomy_snapshot_dir_defaults_via_paths(self):
"""autonomy.py AutonomyConfig should default snapshot_dir via paths.py."""
from praisonaiagents.agent.autonomy import AutonomyConfig
from praisonaiagents.paths import get_snapshots_dir
config = AutonomyConfig()
assert config.snapshot_dir == str(get_snapshots_dir())
def test_autonomy_snapshot_dir_respects_env(self):
"""AutonomyConfig snapshot_dir should respect PRAISONAI_HOME."""
from praisonaiagents.paths import _clear_cache
_clear_cache()
with patch.dict(os.environ, {"PRAISONAI_HOME": "/custom"}):
from praisonaiagents.agent.autonomy import AutonomyConfig
config = AutonomyConfig()
assert config.snapshot_dir == str(Path("/custom/snapshots"))
_clear_cache()
def test_scheduler_store_uses_paths(self):
"""scheduler/store.py should resolve default dir via paths.py."""
from praisonaiagents.scheduler.store import _DEFAULT_DIR
from praisonaiagents.paths import get_schedules_dir
assert _DEFAULT_DIR == str(get_schedules_dir())
def test_sqlite_backend_default_uses_paths(self):
"""SQLiteBackend default db_path should use paths.py."""
from praisonaiagents.storage.backends import SQLiteBackend
from praisonaiagents.paths import get_storage_path
# We can't construct SQLiteBackend without it trying to create the DB,
# so we verify the import is present
import inspect
source = inspect.getsource(SQLiteBackend.__init__)
assert "get_storage_path" in source
@@ -0,0 +1,456 @@
"""
Unit tests verifying that all modules use paths.py instead of hardcoded paths.
Tests every module fixed in Phases 2, 3, and 3.5 of the path standardization effort.
Validates both source-level absence of hardcoded strings AND runtime behavior.
"""
import inspect
import os
from pathlib import Path
from unittest.mock import patch
import pytest
# ---------------------------------------------------------------------------
# Phase 2 Modules (SDK core)
# ---------------------------------------------------------------------------
class TestPolicyConfigWiring:
"""Verify policy/config.py routes through paths.py."""
def test_default_rules_dir_matches_paths(self):
from praisonaiagents.policy.config import DEFAULT_RULES_DIR
from praisonaiagents.paths import get_rules_dir
assert DEFAULT_RULES_DIR == str(get_rules_dir())
def test_no_hardcoded_expanduser(self):
from praisonaiagents.policy import config
source = inspect.getsource(config)
assert 'expanduser("~"), ".praisonai", "rules"' not in source
assert 'os.path.join(os.path.expanduser("~")' not in source
class TestAutonomyWiring:
"""Verify agent/autonomy.py routes through paths.py."""
def test_default_snapshot_dir_matches_paths(self):
from praisonaiagents.agent.autonomy import AutonomyConfig
from praisonaiagents.paths import get_snapshots_dir
config = AutonomyConfig()
assert config.snapshot_dir == str(get_snapshots_dir())
def test_snapshot_dir_respects_env(self):
from praisonaiagents.paths import _clear_cache
_clear_cache()
with patch.dict(os.environ, {"PRAISONAI_HOME": "/custom"}):
from praisonaiagents.agent.autonomy import AutonomyConfig
config = AutonomyConfig()
assert config.snapshot_dir == str(Path("/custom/snapshots"))
_clear_cache()
def test_no_hardcoded_expanduser(self):
from praisonaiagents.agent import autonomy
source = inspect.getsource(autonomy.AutonomyConfig.__post_init__)
assert 'expanduser' not in source
class TestSchedulerStoreWiring:
"""Verify scheduler/store.py routes through paths.py."""
def test_default_dir_matches_paths(self):
from praisonaiagents.scheduler.store import _DEFAULT_DIR
from praisonaiagents.paths import get_schedules_dir
assert _DEFAULT_DIR == str(get_schedules_dir())
def test_no_hardcoded_expanduser(self):
from praisonaiagents.scheduler import store
source = inspect.getsource(store)
assert 'expanduser("~"), ".praisonai"' not in source
class TestStorageBackendsWiring:
"""Verify storage/backends.py routes through paths.py."""
def test_sqlite_init_uses_get_storage_path(self):
from praisonaiagents.storage.backends import SQLiteBackend
source = inspect.getsource(SQLiteBackend.__init__)
assert "get_storage_path" in source
assert '"~/.praisonai/storage.db"' not in source
# ---------------------------------------------------------------------------
# Phase 3 Modules (project-local)
# ---------------------------------------------------------------------------
class TestSessionApiWiring:
"""Verify session/api.py routes through paths.py."""
def test_no_hardcoded_praisonai_sessions(self):
from praisonaiagents.session import api
source = inspect.getsource(api)
assert '".praisonai/sessions/' not in source
assert "f'.praisonai/sessions" not in source
def test_has_get_session_dir_method(self):
from praisonaiagents.session.api import Session
assert hasattr(Session, '_get_session_dir')
def test_get_session_dir_uses_paths(self):
from praisonaiagents.session.api import Session
source = inspect.getsource(Session._get_session_dir)
assert "get_project_sessions_dir" in source
class TestKnowledgeWiring:
"""Verify knowledge/knowledge.py routes through paths.py."""
def test_no_hardcoded_praisonai_literal(self):
from praisonaiagents.knowledge import knowledge
source = inspect.getsource(knowledge)
# Should not have hardcoded path construction with ".praisonai"
# But it's OK in docstrings/comments
lines = source.split('\n')
code_lines = [l for l in lines
if l.strip() and not l.strip().startswith('#')
and not l.strip().startswith('"""')
and not l.strip().startswith("'''")
and 'docstring' not in l.lower()]
code_text = '\n'.join(code_lines)
assert 'Path(".praisonai"' not in code_text
assert '".praisonai"' not in code_text or 'DEFAULT_DIR_NAME' in source
class TestContextAgentWiring:
"""Verify agent/context_agent.py routes through paths.py."""
def test_prp_dir_uses_paths(self):
from praisonaiagents.agent import context_agent
source = inspect.getsource(context_agent)
assert "get_project_prp_dir" in source
def test_no_hardcoded_prp_path(self):
from praisonaiagents.agent import context_agent
source = inspect.getsource(context_agent)
assert 'Path(".praisonai/prp")' not in source
class TestSummarizerWiring:
"""Verify rag/summarizer.py routes through paths.py."""
def test_default_persist_path_matches_paths(self):
from praisonaiagents.rag.summarizer import HierarchicalSummarizer
from praisonaiagents.paths import get_project_summaries_dir
hs = HierarchicalSummarizer()
assert hs._persist_path == str(get_project_summaries_dir())
def test_no_hardcoded_summaries_path(self):
from praisonaiagents.rag import summarizer
source = inspect.getsource(summarizer.HierarchicalSummarizer.__init__)
assert '".praisonai/summaries"' not in source
class TestRetrievalConfigWiring:
"""Verify rag/retrieval_config.py routes through paths.py."""
def test_default_persist_path_matches_paths(self):
from praisonaiagents.rag.retrieval_config import RetrievalConfig
from praisonaiagents.paths import get_project_data_dir
rc = RetrievalConfig()
assert rc.persist_path == str(get_project_data_dir())
def test_no_hardcoded_persist_path(self):
from praisonaiagents.rag import retrieval_config
source = inspect.getsource(retrieval_config)
assert 'persist_path: str = ".praisonai"' not in source
# ---------------------------------------------------------------------------
# Phase 3.5 Modules (batch fix)
# ---------------------------------------------------------------------------
class TestMemoryMemoryWiring:
"""Verify memory/memory.py routes through paths.py."""
def test_no_hardcoded_makedirs(self):
from praisonaiagents.memory import memory
source = inspect.getsource(memory.Memory.__init__)
assert 'makedirs(".praisonai"' not in source
def test_no_hardcoded_short_db(self):
from praisonaiagents.memory import memory
source = inspect.getsource(memory.Memory.__init__)
assert '".praisonai/short_term.db"' not in source
def test_no_hardcoded_long_db(self):
from praisonaiagents.memory import memory
source = inspect.getsource(memory.Memory.__init__)
assert '".praisonai/long_term.db"' not in source
def test_uses_get_project_data_dir(self):
from praisonaiagents.memory import memory
source = inspect.getsource(memory.Memory.__init__)
assert "get_project_data_dir" in source
class TestMemoryHooksWiring:
"""Verify memory/hooks.py routes through paths.py."""
def test_config_file_uses_default_dir_name(self):
from praisonaiagents.memory import hooks
source = inspect.getsource(hooks)
# Should use DEFAULT_DIR_NAME for CONFIG_FILE
assert "DEFAULT_DIR_NAME" in source or "_DIR_NAME" in source
def test_no_hardcoded_config_file(self):
from praisonaiagents.memory import hooks
# Check the HooksManager class body for hardcoded CONFIG_FILE
source = inspect.getsource(hooks.HooksManager)
assert 'CONFIG_FILE = ".praisonai/hooks.json"' not in source
def test_workspace_dir_uses_default_dir_name(self):
from praisonaiagents.memory import hooks
source = inspect.getsource(hooks.HooksManager.__init__)
assert 'workspace_path / ".praisonai"' not in source
class TestDocsManagerWiring:
"""Verify memory/docs_manager.py routes through paths.py."""
def test_no_hardcoded_docs_dir_name(self):
from praisonaiagents.memory import docs_manager
source = inspect.getsource(docs_manager.DocsManager)
assert 'DOCS_DIR_NAME = ".praisonai/docs"' not in source
def test_global_docs_path_uses_get_docs_dir(self):
from praisonaiagents.memory import docs_manager
source = inspect.getsource(docs_manager.DocsManager.__init__)
assert "get_docs_dir" in source
def test_no_hardcoded_home_praisonai_docs(self):
from praisonaiagents.memory import docs_manager
source = inspect.getsource(docs_manager.DocsManager.__init__)
assert 'Path.home() / ".praisonai" / "docs"' not in source
class TestRulesManagerWiring:
"""Verify memory/rules_manager.py routes through paths.py."""
def test_no_hardcoded_rules_dir_name(self):
from praisonaiagents.memory import rules_manager
source = inspect.getsource(rules_manager.RulesManager)
assert 'RULES_DIR_NAME = ".praisonai/rules"' not in source
def test_global_rules_path_uses_get_rules_dir(self):
from praisonaiagents.memory import rules_manager
source = inspect.getsource(rules_manager.RulesManager.__init__)
assert "get_rules_dir" in source
def test_no_hardcoded_home_praisonai_rules(self):
from praisonaiagents.memory import rules_manager
source = inspect.getsource(rules_manager.RulesManager.__init__)
assert 'Path.home() / ".praisonai" / "rules"' not in source
class TestMcpConfigWiring:
"""Verify memory/mcp_config.py routes through paths.py."""
def test_no_hardcoded_mcp_dir_name(self):
from praisonaiagents.memory import mcp_config
source = inspect.getsource(mcp_config.MCPConfigManager)
assert 'MCP_DIR_NAME = ".praisonai/mcp"' not in source
def test_global_mcp_path_uses_get_mcp_dir(self):
from praisonaiagents.memory import mcp_config
source = inspect.getsource(mcp_config.MCPConfigManager.__init__)
assert "get_mcp_dir" in source
def test_no_hardcoded_home_praisonai_mcp(self):
from praisonaiagents.memory import mcp_config
source = inspect.getsource(mcp_config.MCPConfigManager.__init__)
assert 'Path.home() / ".praisonai" / "mcp"' not in source
class TestFileMemoryWiring:
"""Verify memory/file_memory.py routes through paths.py."""
def test_no_hardcoded_base_path(self):
from praisonaiagents.memory import file_memory
source = inspect.getsource(file_memory.FileMemory.__init__)
assert 'Path(".praisonai/memory")' not in source
def test_uses_get_project_data_dir(self):
from praisonaiagents.memory import file_memory
source = inspect.getsource(file_memory.FileMemory.__init__)
assert "get_project_data_dir" in source
class TestMemoryWorkflowsWiring:
"""Verify memory/workflows.py routes through paths.py."""
def test_no_hardcoded_workflows_dir(self):
from praisonaiagents.memory import workflows
source = inspect.getsource(workflows.WorkflowManager)
assert 'WORKFLOWS_DIR = ".praisonai/workflows"' not in source
def test_uses_default_dir_name(self):
from praisonaiagents.memory import workflows
source = inspect.getsource(workflows.WorkflowManager)
assert "DEFAULT_DIR_NAME" in source or "_DIR_NAME" in source
def test_no_hardcoded_checkpoints_dir(self):
from praisonaiagents.memory import workflows
source = inspect.getsource(workflows.WorkflowManager._get_checkpoints_dir)
assert '".praisonai"' not in source
class TestWorkflowsWorkflowsWiring:
"""Verify workflows/workflows.py routes through paths.py."""
def test_no_hardcoded_workflows_dir(self):
from praisonaiagents.workflows import workflows
source = inspect.getsource(workflows.WorkflowManager)
assert 'WORKFLOWS_DIR = ".praisonai/workflows"' not in source
def test_uses_default_dir_name(self):
from praisonaiagents.workflows import workflows
source = inspect.getsource(workflows.WorkflowManager)
assert "DEFAULT_DIR_NAME" in source or "_DIR_NAME" in source
def test_no_hardcoded_checkpoints_dir(self):
from praisonaiagents.workflows import workflows
source = inspect.getsource(workflows.WorkflowManager._get_checkpoints_dir)
assert '".praisonai"' not in source
class TestConfigLoaderWiring:
"""Verify config/loader.py routes through paths.py."""
def test_no_hardcoded_plugin_dirs(self):
from praisonaiagents.config import loader
source = inspect.getsource(loader)
assert '"./.praisonai/plugins/"' not in source
assert '"~/.praisonai/plugins/"' not in source
def test_config_search_uses_paths(self):
from praisonaiagents.config import loader
source = inspect.getsource(loader._find_config_file)
assert "get_project_data_dir" in source or "get_data_dir" in source
def test_global_config_path_uses_get_data_dir(self):
from praisonaiagents.config import loader
source = inspect.getsource(loader._find_config_file)
assert "get_data_dir" in source
class TestAgentsAgentsWiring:
"""Verify agents/agents.py routes through paths.py."""
def test_no_hardcoded_memory_db(self):
from praisonaiagents.agents import agents
source = inspect.getsource(agents)
assert '"./.praisonai/memory.db"' not in source
def test_no_hardcoded_chroma_db(self):
from praisonaiagents.agents import agents
source = inspect.getsource(agents)
assert '"./.praisonai/chroma_db"' not in source
def test_uses_get_project_data_dir(self):
from praisonaiagents.agents import agents
source = inspect.getsource(agents)
assert "get_project_data_dir" in source
class TestLegacySessionWiring:
"""Verify session.py (legacy) routes through paths.py."""
def test_has_get_session_dir_method(self):
from praisonaiagents.session import Session
assert hasattr(Session, '_get_session_dir')
def test_get_session_dir_uses_paths(self):
from praisonaiagents.session import Session
source = inspect.getsource(Session._get_session_dir)
assert "get_project_sessions_dir" in source
def test_no_hardcoded_session_path(self):
from praisonaiagents import session
source = inspect.getsource(session.Session.__init__)
assert 'f".praisonai/sessions/' not in source
assert "makedirs(f\".praisonai" not in source
# ---------------------------------------------------------------------------
# Cross-cutting: comprehensive hardcoded path scan
# ---------------------------------------------------------------------------
class TestNoHardcodedPathsComprehensive:
"""
Scan all fixed modules' source code for any remaining hardcoded
.praisonai path construction patterns.
"""
FIXED_MODULES = [
"praisonaiagents.policy.config",
"praisonaiagents.agent.autonomy",
"praisonaiagents.scheduler.store",
"praisonaiagents.storage.backends",
"praisonaiagents.session.api",
"praisonaiagents.knowledge.knowledge",
"praisonaiagents.agent.context_agent",
"praisonaiagents.rag.summarizer",
"praisonaiagents.rag.retrieval_config",
"praisonaiagents.memory.memory",
"praisonaiagents.memory.hooks",
"praisonaiagents.memory.docs_manager",
"praisonaiagents.memory.rules_manager",
"praisonaiagents.memory.mcp_config",
"praisonaiagents.memory.file_memory",
"praisonaiagents.memory.workflows",
"praisonaiagents.workflows.workflows",
"praisonaiagents.config.loader",
"praisonaiagents.agents.agents",
"praisonaiagents.session",
]
# Patterns that indicate hardcoded path construction (not just docs/comments)
FORBIDDEN_PATTERNS = [
'Path(".praisonai"',
'Path(".praisonai/',
'os.makedirs(".praisonai"',
"os.makedirs('.praisonai'",
'"./.praisonai/',
"'./.praisonai/",
'= ".praisonai/',
"= '.praisonai/",
]
@pytest.mark.parametrize("module_name", FIXED_MODULES)
def test_no_forbidden_patterns_in_code(self, module_name):
"""Each fixed module should not contain forbidden path patterns in code."""
import importlib
mod = importlib.import_module(module_name)
source = inspect.getsource(mod)
# Extract only code lines (skip docstrings and comments)
in_docstring = False
code_lines = []
for line in source.split('\n'):
stripped = line.strip()
if stripped.startswith('"""') or stripped.startswith("'''"):
if stripped.count('"""') == 1 or stripped.count("'''") == 1:
in_docstring = not in_docstring
continue
if in_docstring:
continue
if stripped.startswith('#'):
continue
code_lines.append(line)
code_text = '\n'.join(code_lines)
for pattern in self.FORBIDDEN_PATTERNS:
assert pattern not in code_text, (
f"Module {module_name} still contains forbidden pattern: {pattern!r}"
)
Loaded 100 of 164 files, more files were not shown because too many files have changed in this diff. Show more