Compare commits

...
74 Commits
Author SHA1 Message Date
MervinPraison 1260ede088 Release v4.5.87 2026-03-30 21:30:44 +01:00
MervinPraison 2a463d6b39 docs: auto-update feature parity trackers [skip ci] 2026-03-30 20:29:55 +00:00
MervinPraison 10068583bb fix: add missing pyyaml dep for parity tracker, use PAT_TOKEN fallback for docs checkout
Feature Parity Tracker failed: ModuleNotFoundError: No module named 'yaml'
Docs Parity Tracker failed: Input required and not supplied: token
2026-03-30 21:29:36 +01:00
MervinPraison beb3e0a644 ci: use MervinPraison as commit author in all CI workflows
Previously github-actions[bot] appeared as co-author on auto-commits,
polluting the contributor graph.
2026-03-30 21:28:13 +01:00
6a90cc07ee fix: Async correctness - avoid asyncio.run() from sync context reachable by async callers (#1176)
* fix: async correctness in tool approval - prevent RuntimeError in async workflows

* Add event loop detection in _check_tool_approval_sync()
* Use thread pool fallback when already in async context
* Prevents 'asyncio.run() cannot be called from a running event loop' crash
* Maintains backward compatibility with sync usage
* Follows existing pattern from registry.py and backends.py

Fixes #1165

Co-authored-by: Mervin Praison <[email protected]>

* fix: address reviewer feedback on async correctness fix

- Add concurrent.futures import to top of agent.py (per Gemini review)
- Create shared approval/utils.py to eliminate code duplication (per Qodo/Coderabbit)
- Fix timeout configuration to respect agent's _approval_timeout semantics (per all reviewers)
- Replace ThreadPoolExecutor context manager with proper shutdown handling (per Coderabbit)
- Remove redundant loop.is_running() check (per Copilot)
- Consolidate async-to-sync bridging logic across approval system

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-03-30 21:23:23 +01:00
github-actions[bot] ffb9bd157e docs: auto-update api.md [skip ci] 2026-03-30 20:23:08 +00:00
dd3ccb8b5f fix: SQLite memory/storage layer thread safety for concurrent multi-agent access (#1178)
* fix: implement thread-safe SQLite connections in Memory class

- Add threading.local() for per-thread SQLite connections
- Enable check_same_thread=False for cross-thread access
- Enable WAL mode (PRAGMA journal_mode=WAL) for concurrent read/write
- Replace direct sqlite3.connect() calls with thread-safe getters
- Remove connection.close() calls to enable connection reuse
- Add close_connections() method for proper cleanup

This fixes database corruption and "database is locked" errors in
multi-agent deployments by following the same thread-safe pattern
used in SQLiteBackend class.

Addresses issue #1169: SQLite memory/storage layer unsafe for concurrent
multi-agent access. Tested with 5 concurrent threads performing 20
operations each - 100% success rate.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Mervin Praison <[email protected]>

* fix: enhance SQLite concurrency safety with write locks and connection management

- Add write lock serialization for all SQLite write operations to prevent database lock contention
- Implement proper connection cleanup across threads using connection registry
- Add timeout and busy_timeout configuration for better lock contention handling
- Verify WAL mode enablement with logging for debugging
- Add context manager support and __del__ cleanup for resource management
- Improve error handling with specific error logging instead of silent failures
- Maintain backward compatibility while fixing thread safety issues

Fixes all reviewer issues from Qodo, CodeRabbit, and Copilot feedback.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-03-30 21:22:50 +01:00
github-actions[bot] da8786520d docs: auto-update api.md [skip ci] 2026-03-30 20:22:23 +00:00
ed02fc660f fix: Memory subsystem thread safety - synchronized import caches and FileMemory (#1177)
* fix: resolve memory subsystem thread safety race conditions

- Replace unsafe module-level import caches in memory.py with thread-safe implementation using threading.Lock and double-check locking
- Add threading.RLock to FileMemory class to protect all in-memory data structures from concurrent access
- Protect critical operations: add/get/delete operations on _short_term, _long_term, _entities, _summaries lists/dicts
- Use snapshot pattern for search operations to prevent "list changed size during iteration" errors
- Maintain full API compatibility while eliminating TOCTOU race conditions
- Supports multi-agent + async workflows without data corruption or crashes

Fixes #1166

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Mervin Praison <[email protected]>

* fix: complete thread safety implementation for memory subsystem

- Fix broken test: update test_graph_memory.py to use new _module_cache instead of deprecated _chromadb_cache
- Remove unused importlib import from memory.py
- Complete FileMemory lock coverage: add thread-safe protection to all remaining methods:
  * get_all_memories, export, import_data: protect iteration and data access
  * save_session, resume_session: protect memory snapshots and restoration
  * compress: careful lock handling - protect data access but release during LLM calls
  * get_stats: protect memory structure access
  * handle_command: protect entity dictionary iteration

All methods now use consistent self._lock protection while avoiding deadlocks
and ensuring optimal performance during slow operations like LLM calls.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-03-30 21:22:12 +01:00
3c45e6f474 fix: Agent thread safety - eager lock initialization and LRU cache bounds (#1175)
* fix: implement thread safety for Agent class

- Replace lazy lock initialization with eager initialization for chat_history and cache locks
- Add thread-safe methods for chat_history operations: _add_to_chat_history(), _get_chat_history_length(), _truncate_chat_history()
- Implement proper LRU cache eviction using OrderedDict with _cache_put() and _cache_get() methods
- Add snapshot_lock to protect undo/redo stacks from TOCTOU race conditions
- Update critical chat_history and cache operations to use thread-safe methods
- Enforce _max_cache_size limits with LRU eviction instead of simple size checks

Fixes race conditions in multi-agent concurrent workflows where:
- chat_history entries could be silently dropped during tool execution
- cache dictionaries could have interleaved reads/writes
- snapshot/redo stacks had TOCTOU vulnerabilities in pop() operations
- unbounded cache growth in long-running agents

Co-authored-by: Mervin Praison <[email protected]>

* fix: resolve thread safety issues in Agent class

- Move OrderedDict import to module level for better performance
- Add module-level logger to fix undefined logger errors in undo/redo
- Fix critical TOCTOU race in duplicate message check with atomic method
- Initialize snapshot lock unconditionally to prevent AttributeError
- Protect diff() method snapshot stack reads with lock

Addresses reviewer feedback from Qodo, CodeRabbit, and Copilot.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-03-30 21:22:05 +01:00
9b85a22b4c fix: implement lazy loading for tools imports to improve package startup time (#1172)
* fix: implement lazy loading for tools imports to improve package startup time

- Remove eager imports of tools modules from __init__.py (lines 63-66, 72)
- Add tools imports to _LAZY_IMPORTS mapping for proper lazy loading
- Create embedding proxy to resolve function vs subpackage conflict
- Maintain 100% backward compatibility with existing imports
- Improve startup performance by loading tools only when accessed

Fixes #1168

This addresses the core SDK eagerly importing heavy modules issue by:
1. Moving from .tools.* imports to lazy loading system
2. Using existing _LAZY_IMPORTS infrastructure
3. Resolving embedding function import conflict via proxy
4. Zero breaking changes to public API

Performance improvement: Base import reduced from heavy module loading
to lightweight protocol-driven imports following AGENTS.md principles.

Co-authored-by: Mervin Praison <[email protected]>

* fix: address lazy loading implementation issues

- Remove eager config import, move to lazy loading in _custom_handler
- Remove heavy modules from fallback_modules to prevent imports on typos
- Add proper tools/config/memory/workflows module access via _custom_handler
- Fix _EmbeddingProxy introspection with __wrapped__ and __signature__ properties
- Remove dead code in _custom_handler for embedding/embeddings
- Clean up duplicate comments and self-referential error messages
- Maintain full backward compatibility while improving startup performance

Fixes all issues identified in code review by Copilot, CodeRabbit, and Qodo.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Mervin Praison <[email protected]>

* fix: address lazy loading implementation issues

- Fix _get_embedding_func to avoid overwriting embedding proxy
- Clean up dead code comments
- Update comments for clarity
- All validation tests pass: pa.tools, pa.config, embedding introspection

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Mervin Praison <[email protected]>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-03-30 21:21:57 +01:00
MervinPraison 9a79f2a15d fix(pyproject.toml, uv.lock): update version to 1.5.87
Bump version for praisonaiagents to reflect new release.
2026-03-30 21:20:32 +01:00
MervinPraison 11e449bf43 fix: use PAT_TOKEN for @copilot mentions so they post as MervinPraison
Copilot ignores @copilot mentions from github-actions[bot].
Switching to PAT_TOKEN makes comments appear from a real user.
2026-03-30 21:19:21 +01:00
MervinPraison d9581f39f8 fix: ensure Copilot only triggers AFTER CodeRabbit/Qodo/Gemini complete
Bot PR chain: trigger CodeRabbit + Qodo + Gemini → they post reviews →
copilot-after-coderabbit/qodo fires → Copilot reviews → Claude chains.

Copilot is NEVER triggered directly for bot PRs anymore. It waits
for upstream reviewers to finish first.
2026-03-30 21:09:32 +01:00
MervinPraison 88806ce469 fix: trigger review chain for bot-authored PRs
CodeRabbit and Qodo skip bot-authored PRs by default, breaking the
entire review chain (no reviews → no Copilot → no Claude).

Added bot-pr-review-chain job that fires on PR opened by
github-actions[bot] and directly triggers @coderabbitai, /review,
and @copilot.
2026-03-30 21:06:29 +01:00
MervinPraison 468dffa99d ci: add workflow_dispatch to test-core for manual testing 2026-03-30 19:08:49 +01:00
MervinPraison c1947b9e4b fix: set PYTHONUTF8=1 for Windows CI to handle emoji encoding 2026-03-30 19:04:48 +01:00
MervinPraison 21a3774b63 fix: remove remaining WorkflowStep from Windows CI tests 2026-03-30 19:00:49 +01:00
MervinPraison 2c9b4a5c34 fix: add missing output/models.py and editor.py to git
Root .gitignore line 22 has 'output' which was excluding the
praisonaiagents/output/ module from git. This caused Windows CI
ModuleNotFoundError: No module named 'praisonaiagents.output.models'

Added gitignore exemption and force-added the missing files.
2026-03-30 18:57:05 +01:00
MervinPraison bc0a2a3765 ci: deduplicate and optimize test workflows
DELETED (duplicates):
- unittest.yml: 517-line debug file, always failed (exit 5)
- coverage.yml: merged into test-core.yml

OPTIMIZED:
- test-core.yml: stripped 200+ debug lines, added path filtering
- python-package.yml: moved to schedule-only (weekly multi-version)
- test-extended.yml: moved to schedule-only (benchmarks)
- test-optimized.yml: added path filtering
- test-windows.yml: added path filtering, fixed broken WorkflowStep import

Before: 7 workflows per push (~16 min)
After: 3 workflows per push (~5 min) + path-filtered
2026-03-30 18:52:39 +01:00
MervinPraison b6c10e4040 ci: fix Copilot bot username in chain-claude workflow
Was checking for 'copilot-swe-agent' but actual PR reviewer
posts as 'copilot-pull-request-reviewer[bot]'. This is why
Claude's final review was never triggered after Copilot.
2026-03-30 18:33:40 +01:00
MervinPraison f5220973f8 ci: fix duplicate Claude runs on issue creation
Root cause: issues created with '--label claude' triggered both
'issues:opened' and 'issues:labeled' events in claude.yml.
Fix: remove 'opened' from claude.yml triggers (only fire on label).
Also: auto-issue-comment.yml skips if 'claude' label already exists.
2026-03-30 18:07:43 +01:00
MervinPraison a924a55245 ci: add direct_prompt to Claude workflow for auto PR creation
When triggered via label, Claude now gets explicit instructions to
use gh pr create instead of just providing a link.
2026-03-30 17:59:13 +01:00
MervinPraison dcd072dc0c docs: remove CLAUDE.md — AGENTS.md is sufficient 2026-03-30 17:58:45 +01:00
MervinPraison 8173350f79 docs: add CLAUDE.md with explicit auto-PR-creation instructions
Claude was only providing PR links instead of creating them.
CLAUDE.md is auto-read by claude-code-action and instructs Claude
to always use 'gh pr create' to create PRs automatically.
2026-03-30 17:58:14 +01:00
MervinPraison 5b9a046d4b ci: add label_trigger to Claude action so 'claude' label fires it
The anthropics/claude-code-action needs explicit label_trigger input
to recognize label events. Also simplified bot filter logic.
2026-03-30 17:35:13 +01:00
MervinPraison 27aafddaa4 ci: fix Claude trigger — use label instead of @claude comment
Root cause: anthropics/claude-code-action ignores @claude from bots.
Fix: add 'claude' label on issue open, which triggers via issues:labeled event.
2026-03-30 17:33:28 +01:00
MervinPraison 4a0b34ea20 ci: add fallback trigger — Copilot fires after Coderabbit OR Qodo
Root cause: PR #1170 had no Coderabbit review, so Copilot/Claude
were never triggered. Now fires after either reviewer, with dedup
check to prevent double @copilot comments.
2026-03-30 17:31:16 +01:00
MervinPraison 19f5a16766 ci: sequential PR review chain + Claude fixes reviewer issues
Pipeline sequence:
  PR opened → Qodo + Coderabbit (auto)
  Coderabbit done → @copilot (reads their reviews)
  Copilot done → @claude (reads ALL reviews, fixes valid issues, pushes to branch)

Key improvements:
- Copilot waits for Coderabbit before reviewing
- Claude reads ALL reviewer feedback and fixes valid issues in-place
- 3-phase Claude prompt: Review → Fix → Verdict
2026-03-30 17:26:20 +01:00
MervinPraison 20cb053181 ci: make Claude default for issues, Gemini commented out for future
Pipeline flow:
  Issue → @claude (analyzes + creates PR)
  PR → Qodo/Coderabbit (auto) + @copilot
  Copilot done → @claude (final architecture review)
  ✅ Ready to merge

Gemini issue pipeline kept commented out for future dual-agent use.
2026-03-30 17:09:42 +01:00
MervinPraison 41ce1de079 ci: disable gemini_debug — output overflow caused GitHub Actions memory error 2026-03-30 17:02:15 +01:00
MervinPraison 36920ff8fe ci: fix Gemini config — settings_json→settings, remove invalid telemetry keys, use 2.5-flash with debug 2026-03-30 16:44:30 +01:00
MervinPraison e428393e0a ci: switch to official google-github-actions/run-gemini-cli@v0
- Replaces custom local action with official action
- Adds GH_TOKEN env for gh CLI auth inside Gemini sandbox
- Uses gemini-3.1-flash-lite-preview model (highest free quota)
2026-03-30 16:36:35 +01:00
MervinPraison 95fd857212 ci: switch to gemini-3.1-flash-lite-preview model 2026-03-30 16:32:01 +01:00
MervinPraison c260519bf1 ci: switch Gemini to flash model (250 RPD free tier vs Pro's 100 RPD)
Adds model input to gemini action, defaults to gemini-2.5-flash.
Prevents daily quota exhaustion on free tier.
2026-03-30 16:21:15 +01:00
MervinPraison 2921103ddd ci: fix '@gemini triage' exclusion — reword to 'analysis' to avoid filter 2026-03-30 15:56:10 +01:00
MervinPraison 73800f3509 ci: fix broken pipeline — @gemini for issues, correct event types for chain
Fixes:
- auto-issue-comment: @copilot → @gemini (Copilot doesn't respond on issues)
- chain-claude: listen for pull_request_review (how Copilot actually responds)
  + issue_comment from gemini-code-assist[bot] for issue chain
- gemini-issue-review: allow CONTRIBUTOR association (github-actions bot)
2026-03-30 15:18:18 +01:00
MervinPraison 7371ed7048 feat(docs): enhance README and AGENTS documentation with YAML examples
Added detailed sections in README for using YAML with agents, including
two comprehensive examples illustrating agent collaboration and custom
tools. Updated AGENTS.md to reflect CLI and YAML parity, and emphasized
the requirement for real agentic tests in the implementation checklist.
2026-03-27 16:18:06 +00:00
MervinPraison 129174257f docs: update AGENTS.md module sizes to match current codebase 2026-03-27 11:50:47 +00:00
Mervin Praison efcdc42fbb fix: replace unsafe eval() with safe alternatives and lazy-load Rich imports (#1160)
fix: replace unsafe eval() with safe alternatives and lazy-load Rich …
2026-03-27 11:49:00 +00:00
MervinPraison df79c5acb9 ci: event-driven review pipeline — chain Claude after Copilot finishes
- auto-pr-comment.yml: posts @copilot immediately on PR open
- auto-issue-comment.yml: posts @copilot immediately on issue open
- chain-claude-after-copilot.yml: watches for copilot-swe-agent comment,
  then posts @claude as final reviewer (event-driven, no sleep delays)
- Saves ~16 min of wasted runner time per PR
2026-03-27 11:46:34 +00:00
MervinPraison d9da7cc59c ci: expand Copilot prompts — thorough review since it's free 2026-03-27 11:35:38 +00:00
MervinPraison 11d9f290c7 ci: coordinated review pipeline — staggered timing, role specialization, remove infinite loop
- auto-pr-comment.yml: Copilot at T+6min (bloat guard), Claude at T+17min (final reviewer)
- auto-issue-comment.yml: Copilot triages first, Claude implements fix after
- claude.yml: remove post-review-comment job (caused infinite review cycles)
- Each bot has specialized role prompt instead of generic review
2026-03-27 11:34:27 +00:00
MervinPraison f796d12908 ci: improve auto-comment prompts — structured, focused, reference AGENTS.md 2026-03-27 11:18:34 +00:00
MervinPraison 9c4adcee89 ci: use official CLAUDE_CODE_OAUTH_TOKEN secret name 2026-03-27 11:12:18 +00:00
MervinPraison 1d0ffed030 ci: fix expired GH_TOKEN → GITHUB_TOKEN for auto-comments and claude review 2026-03-27 11:02:11 +00:00
copilot-swe-agent[bot]andMervinPraison 3be6df4de8 fix: harden legacy metadata fallback validation in SingleStore parser
Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/066a220b-7a22-4676-afe6-f2675b6ff910

Co-authored-by: MervinPraison <[email protected]>
2026-03-27 10:58:16 +00:00
MervinPraison b65de3bf06 ci: add @copilot review comment on PR open 2026-03-27 10:57:23 +00:00
copilot-swe-agent[bot]andMervinPraison f949814af8 fix: make SingleStore metadata parsing safe and backward-compatible
Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/066a220b-7a22-4676-afe6-f2675b6ff910

Co-authored-by: MervinPraison <[email protected]>
2026-03-27 10:57:04 +00:00
Claude 7449ccac76 fix: replace unsafe eval() with safe alternatives and lazy-load Rich imports (#1157, #1154)
Security (#1157): Replace eval() with json.loads() in singlestore_vector.py,
AST-whitelisted evaluator in ai_generator.py, and AST node validation in
job_workflow.py/hybrid_workflow.py to prevent arbitrary code execution.

Performance (#1154): Convert eager module-level Rich imports to lazy
function-level imports in main.py, agents.py, knowledge.py, video_agent.py,
and image_agent.py — Rich is no longer loaded on `import praisonaiagents`.

https://claude.ai/code/session_01MZtrpciafckLxpmrn1z6P2
2026-03-27 10:24:18 +00:00
github-actions[bot] ae26edd3f8 docs: auto-update api.md [skip ci] 2026-03-26 18:19:52 +00:00
MervinPraison 6349c88ad9 feat(agent): add budget management features to agents
- Introduced `BudgetExceededError` for handling budget overages.
- Implemented budget tracking in the `Agent` class.
- Added properties for total cost and cost summary.
- Updated various agents to support budget constraints.
- Enhanced lazy loading to include new budget-related components.
- Extensive test coverage added for the new budget features.
2026-03-26 18:19:35 +00:00
Mervin Praison 1d4721d039 feat(ag2): add AG2 framework backend integration (#1156)
feat(ag2): add AG2 framework backend integration
2026-03-26 17:56:52 +00:00
MervinPraisonandFaridun Mirzoev 90915fe790 feat(ag2): add AG2 framework backend integration
Port of PR #1143 (by @faridun-ag2) to main branch.

AG2 (community fork of AutoGen, PyPI: ag2) added as a new framework
option alongside praisonai, crewai, and autogen.

Changes:
- agents_generator.py: AG2 detection + _run_ag2() with LLMConfig,
  GroupChat orchestration, Bedrock support, TERMINATE cleanup
- auto.py: AG2 lazy availability check + validation
- pyproject.toml: [ag2] optional dependency extra
- examples/ag2/: basic, multi-agent, and Bedrock YAML examples
- tests: 16 unit tests + 9 integration tests

Co-authored-by: Faridun Mirzoev <[email protected]>
2026-03-26 17:56:15 +00:00
MervinPraison 29facc8f89 Release v4.5.85 2026-03-26 03:13:24 +00:00
MervinPraison 401521848f feat(version): bump version to 1.5.85
Updated the version number in pyproject.toml and uv.lock to reflect the new release.
2026-03-26 03:13:24 +00:00
Mervin Praison ab97eae954 fix: harden env key validation — malformed keys, root/deployment type guards
fix: harden env key validation — malformed keys, root/deployment type guards
2026-03-26 02:30:10 +00:00
Mervin Praison 5dda0643ca security: cap litellm<=1.82.6 (supply chain incident)
security: cap litellm<=1.82.6 (supply chain incident)
2026-03-26 02:29:20 +00:00
MervinPraison 1b8d7a02d9 security: cap litellm<=1.82.6 (supply chain incident CVE)
LiteLLM v1.82.7 and v1.82.8 contained a credential stealer from a
supply chain attack (compromised PyPI maintainer via Trivy CI/CD).
Both versions removed from PyPI.

PraisonAI was NOT affected (installed 1.81.1), but as a defensive
measure, cap the upper bound at 1.82.6 (latest safe) until LiteLLM
completes their supply-chain review and resumes releases.

Ref: https://docs.litellm.ai/blog/security-update-march-2026
2026-03-26 02:28:46 +00:00
Mervin Praison dbcd36edce fix: Tier 1 security & concurrency fixes (#1134, #1129, #1137)
fix: Tier 1 security & concurrency fixes (#1134, #1129, #1137)
2026-03-26 02:12:56 +00:00
MervinPraison 9ef5391c83 fix: Tier 1 security & concurrency fixes (#1134, #1129, #1137)
#1134: Decouple execute_code from optional deps (black/pylint/autopep8)
- execute_code is now a standalone module-level function
- PythonTools class retained for analyze/format/lint (lazy-init)
- Agents can now use execute_code without heavy optional deps

#1129: Thread-safety for remaining global mutable state
- Add _lazy_import_lock with double-checked locking for all 6 lazy-import
  caches (_rich_console, _rich_live, _llm_module, _main_module, etc.)
- Add _env_cache_lock for class-level env var caches
  (_env_output_mode, _default_model)

#1137: Clarify misleading TODO comment
- Updated TODO in llm.py to clarify this is code-level DRY, not
  duplicate API calls per request
2026-03-26 02:09:40 +00:00
Sebastion 7385adbd60 fix: address second round of review feedback — malformed key validation, root/deployment type guards
- Reject empty strings, keys containing "=" or NUL in _validate_env_key()
  (CodeRabbit: malformed keys should fail during validation, not at os.environ)
- Validate root config is a dict after yaml.safe_load() (handles list/scalar/None)
- Validate deployment section is a dict before .get() calls
- Both raise ValueError caught by the existing handler → clean CLI error
- Add test_malformed_env_keys_rejected, test_non_dict_root_config_rejected,
  test_non_dict_deployment_rejected (7 tests total, all passing)
2026-03-26 00:05:11 +00:00
Mervin Praison 78d7883fb7 Merge pull request #1148 from MervinPraison/fix/1145-1147-approval-dry-thread-safety
fix: deduplicate approval logic and add thread-safety (#1147, #1145)
2026-03-25 17:21:44 +00:00
Mervin Praison 6122531a6a Merge pull request #1144 from sebastiondev/security/fix-cwe78-env-injection
Merging — verified locally, both vulnerable occurrences are patched, all tests pass. Great contribution! 🎉
2026-03-25 10:53:28 +00:00
Claude d704cf1349 fix: deduplicate approval logic and add thread-safety to global state (#1147, #1145)
Extract ~120 lines of duplicated approval code from _execute_tool_impl()
and execute_tool_async() into shared helpers _check_tool_approval_sync()
and _check_tool_approval_async(). Add threading.Lock to protect
_agent_counter, _server_started, _registered_agents, and _shared_apps
against concurrent access in multi-agent scenarios.

https://claude.ai/code/session_01WiGKiwTBj7xo5YoF3PYjWF
2026-03-25 10:10:41 +00:00
Sebastion 8523ef076e fix: address review feedback — fail-closed validation, restore env fixture, non-string key guard, perf, coverage 2026-03-25 03:46:33 +00:00
Mervin Praison 72889d2f73 Merge pull request #1119 from eltociear/patch-1
docs: update README.md
2026-03-24 20:42:24 +00:00
Sebastion f17bd7369c fix: reject dangerous environment variable keys from YAML schedule config (CWE-78)
The --schedule-config YAML file allows setting arbitrary environment
variables via the "environment" section. An attacker who can supply or
modify this file could set keys like LD_PRELOAD, PATH, PYTHONPATH, etc.
to achieve arbitrary code execution.

Add _validate_env_key() with a blocklist of security-sensitive env var
names (dynamic linker, search paths, proxies) and call it before each
os.environ assignment in the schedule-config loading path.

Also add unit tests for the validation function.
2026-03-24 20:41:10 +00:00
MervinPraison ed31e9b280 Release v4.5.83 2026-03-24 04:48:37 +00:00
MervinPraison 69e9e712c7 fix(security): remove hardcoded CHAINLIT_AUTH_SECRET and fix critical issues from #1122/#1125
- Replace hardcoded CHAINLIT_AUTH_SECRET with secrets.token_hex(32) fallback in all 5 UI files
  (agents.py, bot.py, chat.py, code.py, realtime.py) — secret was public in git history
- Use hmac.compare_digest() for constant-time API key comparison in recipe/serve.py
- Disable debug=True by default in deploy.py generated api.py; use DEBUG env var
- Restrict exec() __builtins__ to safe subset in job_workflow.py inline Python execution;
  prevents __import__('os').system() and similar arbitrary code execution patterns

Skipped shell=True changes (would break YAML run: steps with pipes/env vars) and
CORS wildcard changes (would break browser extension without env var migration path).
These require separate architectural discussion.
2026-03-24 04:33:35 +00:00
MervinPraison 7617b10c70 Release v4.5.82 2026-03-21 17:51:25 +00:00
MervinPraison 7939a4f12e fix(security): complete remaining eval/exec/XSS fixes from issue #1120
- Replace eval(e) in unified_server.py with AST-based safe math evaluator
- Replace exec(script_content) in run_with_langtrace.py with subprocess.run()
- Add nh3.clean() output sanitization in api.py to prevent XSS from markdown links
- Replace eval() in basic-tools.ts and custom-tool.ts with regex allowlist + Function constructor
- Replace eval() in simplified-api-test.ts and comprehensive-live.test.ts with safe evaluator

All changes are in example files and dev helper scripts only.
No SDK core code modified. Unit tests: 4341 passed, 0 new failures.
2026-03-21 17:49:40 +00:00
MervinPraison 8cbad0c997 Release v4.5.81 2026-03-21 16:51:27 +00:00
Ikko Eltociear Ashimine 48ed131577 docs: update README.md
minor fix
2026-03-20 13:17:09 +09:00
97 changed files with 7977 additions and 2778 deletions

No files matched your search

+8 -1
View File
@@ -22,6 +22,12 @@ inputs:
To enable observability with Google Cloud, include:
{"telemetry": {"enabled": true, "target": "gcp"}, "sandbox": false}
required: false
model:
description: |
The Gemini model to use. Options: gemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-lite.
Use flash-lite for highest free-tier quota (1,000 RPD).
required: false
default: 'gemini-3.1-flash-lite-preview'
version:
description: |
The version of @google/gemini-cli to execute.
@@ -70,10 +76,11 @@ runs:
- name: Run Gemini CLI
run: |
gemini --yolo --prompt "$PROMPT"
gemini --yolo --model "$GEMINI_MODEL" --prompt "$PROMPT"
shell: bash
env:
GEMINI_API_KEY: ${{ inputs.GEMINI_API_KEY }}
GEMINI_MODEL: ${{ inputs.model }}
PROMPT: ${{ inputs.prompt }}
branding:
+29 -10
View File
@@ -1,26 +1,45 @@
name: Auto Issue Comment with PR Creation
name: Auto Issue Comment
on:
issues:
types: [opened]
jobs:
add-comment:
# Trigger Claude by adding the 'claude' label (only if not already labeled)
# Note: @claude in comments only works from human users (not bots)
# The claude.yml workflow listens for issues:labeled event with 'claude' label
trigger-claude:
# Skip if issue was already created with 'claude' label to avoid double-trigger
if: |
!contains(toJSON(github.event.issue.labels), 'claude')
runs-on: ubuntu-latest
permissions:
issues: write
contents: write
pull-requests: write
actions: read
steps:
- name: Add automated comment
- name: Add claude label to trigger analysis
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GH_TOKEN }}
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
github.rest.issues.createComment({
// Check if 'claude' label already exists on this issue
const labels = context.payload.issue.labels.map(l => l.name);
if (labels.includes('claude')) {
console.log('Issue already has claude label, skipping');
return;
}
// Add label to trigger claude.yml via issues:labeled event
await github.rest.issues.addLabels({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '@claude review this issue and do a detailed analysis and fix this if the existing code doesn\'t have the solution implemented. Making sure it has backward compatibility, no existing features removed. After making those changes, again review the applied changes. Use @web to search if you dont know any information or to find the latest documentation or to find the latest version. Run the code if you think you need to run it to test it. Minimal code change to start with if required any changes. MAINLY IT SHOULD NOT IMPACT ON THE CURRENT SPEED OF EXECUTION of existing features, not increasing the overhead. **Please create a PR using gh tool with your changes**.'
})
labels: ['claude']
});
// Also post instructions as a comment for context
await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '🤖 Auto-triaging with Claude. Analyzing issue and implementing a fix...\n\nClaude will:\n1. Classify this issue\n2. Search codebase for root cause\n3. Implement a minimal fix following AGENTS.md\n4. Create a PR with the changes'
});
+120 -5
View File
@@ -1,23 +1,138 @@
name: Auto PR Comment
# Review chain: CodeRabbit/Qodo/Gemini review first → THEN trigger Copilot
# For human PRs: CodeRabbit/Qodo auto-review → this workflow triggers Copilot
# For bot PRs: CodeRabbit/Qodo skip bots, so we trigger them explicitly first
#
# IMPORTANT: @copilot ignores bot comments. All @copilot mentions must use
# PAT_TOKEN so the comment posts as MervinPraison (a real user).
on:
issue_comment:
types: [created]
pull_request_review:
types: [submitted]
pull_request:
types: [opened]
jobs:
add-comment:
# ================================================================
# HUMAN PRs: Trigger @copilot AFTER CodeRabbit posts its summary
# ================================================================
copilot-after-coderabbit:
if: |
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
github.event.comment.user.login == 'coderabbitai[bot]' &&
contains(github.event.comment.body, 'summary by coderabbit')
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Add automated comment
- name: Post Copilot review request (as user via PAT)
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GH_TOKEN }}
github-token: ${{ secrets.PAT_TOKEN }}
script: |
const comments = await github.rest.issues.listComments({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo
});
// Check for duplicates — posted by MervinPraison (PAT) or github-actions[bot] (legacy)
const alreadyPosted = comments.data.some(c =>
c.body.includes('@copilot') &&
(c.user.login === 'MervinPraison' || c.user.login === 'github-actions[bot]')
);
if (alreadyPosted) {
console.log('Copilot already triggered, skipping');
return;
}
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '@claude review this pull request and do a detailed analysis and fix this if the existing code doesn\'t have the solution implemented. Making sure it has backward compatibility, no existing features removed. After making those changes, again review the applied changes. Use @web to search if you dont know any information or to find the latest documentation or to find the latest version. Run the code if you think you need to run it to test it. Minimal code change to start with if required any changes. MAINLY IT SHOULD NOT IMPACT ON THE CURRENT SPEED OF EXECUTION of existing features, not increasing the overhead.'
})
body: '@copilot Do a thorough review of this PR. Read ALL existing reviewer comments above from Qodo, Coderabbit, and Gemini first — incorporate their findings.\n\nReview areas:\n1. **Bloat check**: Are changes minimal and focused? Any unnecessary code or scope creep?\n2. **Security**: Any hardcoded secrets, unsafe eval/exec, missing input validation?\n3. **Performance**: Any module-level heavy imports? Hot-path regressions?\n4. **Tests**: Are tests included? Do they cover the changes adequately?\n5. **Backward compat**: Any public API changes without deprecation?\n6. **Code quality**: DRY violations, naming conventions, error handling?\n7. **Address reviewer feedback**: If Qodo, Coderabbit, or Gemini flagged valid issues, include them in your review\n8. Suggest specific improvements with code examples where possible'
})
# Fallback: Trigger @copilot after Qodo review (if Coderabbit doesn't fire)
copilot-after-qodo:
if: |
github.event_name == 'pull_request_review' &&
github.event.review.user.login == 'qodo-code-review[bot]'
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Post Copilot review request (as user via PAT)
uses: actions/github-script@v7
with:
github-token: ${{ secrets.PAT_TOKEN }}
script: |
const comments = await github.rest.issues.listComments({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo
});
const alreadyPosted = comments.data.some(c =>
c.body.includes('@copilot') &&
(c.user.login === 'MervinPraison' || c.user.login === 'github-actions[bot]')
);
if (alreadyPosted) {
console.log('Copilot already triggered, skipping');
return;
}
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '@copilot Do a thorough review of this PR. Read ALL existing reviewer comments above first.\n\nReview areas:\n1. **Bloat check**: Are changes minimal and focused?\n2. **Security**: Any hardcoded secrets, unsafe eval/exec, missing input validation?\n3. **Performance**: Any module-level heavy imports? Hot-path regressions?\n4. **Tests**: Are tests included? Do they cover the changes adequately?\n5. **Backward compat**: Any public API changes without deprecation?\n6. **Code quality**: DRY violations, naming conventions, error handling?\n7. Suggest specific improvements with code examples where possible'
})
# ================================================================
# BOT PRs: CodeRabbit/Qodo skip bot-authored PRs by default.
# Trigger them explicitly. Do NOT trigger Copilot here — it will
# be triggered by copilot-after-coderabbit/qodo when they finish.
# ================================================================
bot-pr-trigger-reviews:
if: |
github.event_name == 'pull_request' &&
github.event.action == 'opened' &&
github.event.pull_request.user.login == 'github-actions[bot]'
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Trigger CodeRabbit, Qodo, and Gemini reviews
uses: actions/github-script@v7
with:
github-token: ${{ secrets.PAT_TOKEN }}
script: |
const pr = context.payload.pull_request.number;
const owner = context.repo.owner;
const repo = context.repo.repo;
// 1. Trigger CodeRabbit review
await github.rest.issues.createComment({
issue_number: pr, owner, repo,
body: '@coderabbitai review'
});
console.log(`Triggered CodeRabbit for bot PR #${pr}`);
// 2. Trigger Qodo review
await github.rest.issues.createComment({
issue_number: pr, owner, repo,
body: '/review'
});
console.log(`Triggered Qodo for bot PR #${pr}`);
// 3. Trigger Gemini review
await github.rest.issues.createComment({
issue_number: pr, owner, repo,
body: '@gemini review this PR'
});
console.log(`Triggered Gemini for bot PR #${pr}`);
// NOTE: @copilot is NOT triggered here.
// It will be triggered by copilot-after-coderabbit or copilot-after-qodo
// once those bots finish, ensuring Copilot always reviews LAST.
console.log('Copilot will be triggered after CodeRabbit/Qodo complete.');
@@ -0,0 +1,55 @@
name: Chain Claude After Reviewers
# Event-driven: triggers when Copilot finishes reviewing a PR
on:
pull_request_review:
types: [submitted]
# Future: re-enable issue_comment trigger for Gemini→Claude chain
# issue_comment:
# types: [created]
jobs:
# Trigger Claude after Copilot finishes on a PR — Claude is the FINAL reviewer
# Claude reads ALL previous reviews (Qodo, Coderabbit, Copilot) and fixes any valid issues
claude-final-review:
if: |
github.event_name == 'pull_request_review' &&
github.event.review.user.login == 'copilot-pull-request-reviewer[bot]'
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Post Claude final review + fix request
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '@claude You are the FINAL architecture reviewer. Read ALL comments above from Qodo, Coderabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: heavy code in wrapper, not core SDK?\n2. Backward compatible: no public API removed without deprecation?\n3. Performance: no hot-path regressions, lazy imports for optional deps?\n4. DRY: no duplication, reuses existing abstractions?\n5. Async-safe and multi-agent safe?\n\n**Phase 2: Fix valid issues from other reviewers**\n6. Read Qodo, Coderabbit, and Copilot reviews above\n7. For each VALID suggestion they raised: implement the fix\n8. Push all fixes to THIS branch (do NOT create a new PR)\n9. Comment a summary of what you fixed and what you intentionally skipped (with reasoning)\n\n**Phase 3: Final verdict**\n10. If all issues are resolved and tests pass, approve the PR\n11. If blocking issues remain, request changes with clear action items'
})
# Future: Trigger Claude after Gemini finishes on an Issue (commented out)
# claude-after-gemini-on-issue:
# if: |
# github.event_name == 'issue_comment' &&
# !github.event.issue.pull_request &&
# github.event.comment.user.login == 'gemini-code-assist[bot]'
# runs-on: ubuntu-latest
# permissions:
# issues: write
# steps:
# - name: Post Claude analysis request (final reviewer)
# uses: actions/github-script@v7
# with:
# github-token: ${{ secrets.GITHUB_TOKEN }}
# script: |
# github.rest.issues.createComment({
# issue_number: context.issue.number,
# owner: context.repo.owner,
# repo: context.repo.repo,
# body: '@claude You are the lead engineer. Read Gemini\'s analysis above first.\n\nAnalyze this issue and implement a fix following AGENTS.md:\n1. Ensure backward compatibility — no features removed\n2. Zero performance regression on hot paths\n3. Minimal, focused changes only\n4. Run tests to verify the fix\n5. Create a PR with your changes using gh'
# })
+18 -108
View File
@@ -6,18 +6,22 @@ on:
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned, labeled]
types: [assigned, labeled]
pull_request_review:
types: [submitted]
jobs:
claude-response:
# Prevent certain bots from triggering this action (but allow github-actions[bot] for auto-comments)
# Allow: human users, github-actions[bot] (auto-comments/labels)
# Block: dependabot, cursor, renovate, other bots
if: |
(
!contains(github.actor, '[bot]') ||
github.actor == 'github-actions[bot]'
) &&
github.actor != 'dependabot[bot]' &&
github.actor != 'cursor[bot]' &&
github.actor != 'renovate[bot]' &&
!(contains(github.actor, '[bot]') && github.actor != 'github-actions[bot]')
github.actor != 'renovate[bot]'
runs-on: ubuntu-latest
permissions:
contents: write
@@ -32,9 +36,18 @@ jobs:
- uses: anthropics/claude-code-action@beta
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_ACCESS_TOKEN }}
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
github_token: ${{ secrets.GITHUB_TOKEN }}
trigger_phrase: "@claude"
label_trigger: "claude"
direct_prompt: |
Analyze this issue and implement a fix following AGENTS.md:
1. Read AGENTS.md for repo guidelines
2. Search the codebase and identify the root cause
3. Create a fix branch and implement a minimal, focused fix
4. Run tests to verify
5. Commit, push, and create a PR using `gh pr create`
CRITICAL: You MUST create the PR automatically using `gh pr create`. Do NOT just provide a link.
allowed_tools: |
Bash(git:*)
Bash(python:*)
@@ -55,106 +68,3 @@ jobs:
mcp__github__get_issue_comments
mcp__github__update_issue
timeout_minutes: 30
post-review-comment:
needs: claude-response
if: success() && needs.claude-response.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
issues: write
steps:
- name: Check for existing review comment
id: check-comment
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GH_TOKEN }}
script: |
const reviewText = '@claude review if the changes made in the above branch are as per the requirements and make changes to the above branch if issues still exist.';
let comments = [];
let issueNumber;
try {
// Determine issue/PR number based on event type
if (context.eventName === 'pull_request_review_comment' || context.eventName === 'pull_request_review') {
issueNumber = context.payload.pull_request?.number || context.issue.number;
} else if (context.eventName === 'issue_comment' && context.payload.issue.pull_request) {
// This is a comment on a PR (not an issue)
issueNumber = context.payload.issue.number;
} else {
// This is a regular issue
issueNumber = context.issue.number;
}
comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber
});
// Look for Claude's completed review comments
const claudeReviewComments = comments.data.filter(comment =>
(comment.user.login === 'claude[bot]' || comment.body.includes('✅') || comment.body.includes('## Summary')) &&
!comment.body.includes('⏳') &&
!comment.body.includes('🔄') &&
!comment.body.includes('Loading...')
);
// Check if our review request comment already exists
const existingReviewRequest = comments.data.find(comment =>
comment.body.includes(reviewText)
);
// Post if Claude has completed a review AND we haven't posted the review request yet
const shouldPost = claudeReviewComments.length > 0 && !existingReviewRequest;
core.setOutput('should-post', shouldPost);
console.log(`Claude review completed: ${claudeReviewComments.length > 0}`);
console.log(`Review request exists: ${!!existingReviewRequest}`);
console.log(`Should post comment: ${shouldPost}`);
} catch (error) {
console.log('Error checking comments:', error);
core.setOutput('should-post', false); // If we can't check, don't post
}
- name: Wait 30 seconds before posting comment
if: steps.check-comment.outputs.should-post == 'true'
run: sleep 30
- name: Post review comment
if: steps.check-comment.outputs.should-post == 'true'
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GH_TOKEN }}
script: |
let issueNumber;
let isPR = false;
// Determine issue/PR number and type based on event type
if (context.eventName === 'pull_request_review_comment' || context.eventName === 'pull_request_review') {
issueNumber = context.payload.pull_request?.number || context.issue.number;
isPR = true;
} else if (context.eventName === 'issue_comment' && context.payload.issue.pull_request) {
// This is a comment on a PR (not an issue)
issueNumber = context.payload.issue.number;
isPR = true;
} else {
// This is a regular issue - don't post review comment
issueNumber = context.issue.number;
isPR = false;
}
// Only post review comment on PRs, not regular issues
if (isPR) {
console.log(`Posting review comment on PR #${issueNumber}`);
await github.rest.issues.createComment({
issue_number: issueNumber,
owner: context.repo.owner,
repo: context.repo.repo,
body: '@claude review if the changes made in the above branch are as per the requirements and make changes to the above branch if issues still exist. MAINLY IT SHOULD NOT IMPACT ON THE CURRENT SPEED OF EXECUTION of existing features, not increasing the overhead.'
});
} else {
console.log(`Skipping review comment for regular issue #${issueNumber}`);
}
-62
View File
@@ -1,62 +0,0 @@
name: Run tests and upload coverage
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
test:
name: Run tests and collect coverage
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: 3.11
- name: Install UV
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> $GITHUB_PATH
- name: Install dependencies
run: |
cd src/praisonai
uv pip install --system ."[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
uv pip install --system duckduckgo_search
uv pip install --system pytest pytest-cov pytest-asyncio
# Install knowledge dependencies from praisonai-agents
uv pip install --system "praisonaiagents[knowledge]"
- name: Set environment variables
run: |
echo "OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY || 'sk-test-key-for-github-actions-testing-only-not-real' }}" >> $GITHUB_ENV
echo "OPENAI_API_BASE=${{ secrets.OPENAI_API_BASE || 'https://api.openai.com/v1' }}" >> $GITHUB_ENV
echo "OPENAI_MODEL_NAME=${{ secrets.OPENAI_MODEL_NAME || 'gpt-5-nano' }}" >> $GITHUB_ENV
echo "LOGLEVEL=DEBUG" >> $GITHUB_ENV
echo "PYTHONPATH=${{ github.workspace }}/src/praisonai-agents:$PYTHONPATH" >> $GITHUB_ENV
- name: Run tests
run: |
cd src/praisonai
# Run tests - allow some failures for now while we fix remaining issues
pytest --cov=praisonai --cov-branch --cov-report=xml tests/unit/ --tb=no -q || echo "Some tests failed, but coverage was collected"
- name: Upload results to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
file: src/praisonai/coverage.xml
flags: unit-tests
name: unit-tests-coverage
fail_ci_if_error: false
verbose: true
+10 -14
View File
@@ -20,7 +20,8 @@ jobs:
!contains(github.event.comment.body, '@gemini triage') &&
(github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR'))
github.event.comment.author_association == 'COLLABORATOR' ||
github.event.comment.author_association == 'CONTRIBUTOR'))
runs-on: ubuntu-latest
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
@@ -39,8 +40,8 @@ jobs:
- name: Set up Git
run: |
git config --local user.email "gemini-bot@github.com"
git config --local user.name "Gemini Bot"
git config --local user.email "praison.ms@gmail.com"
git config --local user.name "MervinPraison"
- name: Get issue data
id: issue_data
@@ -75,16 +76,16 @@ jobs:
echo "ADDITIONAL_INSTRUCTIONS=$ADDITIONAL_INSTRUCTIONS" >> $GITHUB_OUTPUT
- name: Run Gemini CLI
uses: ./.github/actions/gemini
uses: google-github-actions/run-gemini-cli@v0
env:
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
ISSUE_NUMBER: ${{ steps.issue_data.outputs.ISSUE_NUMBER }}
ADDITIONAL_INSTRUCTIONS: ${{ steps.issue_data.outputs.ADDITIONAL_INSTRUCTIONS }}
REPOSITORY: ${{ github.repository }}
with:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
OTLP_GCP_WIF_PROVIDER: ${{ secrets.OTLP_GCP_WIF_PROVIDER }}
OTLP_GOOGLE_CLOUD_PROJECT: ${{ secrets.OTLP_GOOGLE_CLOUD_PROJECT }}
gemini_api_key: ${{ secrets.GEMINI_API_KEY }}
gemini_model: 'gemini-2.5-flash'
prompt: |
You are a helpful GitHub issue assistant. Your task is to analyze and help with GitHub issues.
@@ -161,7 +162,7 @@ jobs:
Please proceed with helping resolve this issue following this exact workflow.
settings_json: |
settings: |
{
"coreTools": [
"run_shell_command(echo)",
@@ -193,10 +194,5 @@ jobs:
"web_fetch",
"google_web_search",
"save_memory"
],
"telemetry": {
"enabled": false,
"otlpGcpWifProvider": "${{ secrets.OTLP_GCP_WIF_PROVIDER }}",
"googleCloudProject": "${{ secrets.OTLP_GOOGLE_CLOUD_PROJECT }}"
}
]
}
+11 -38
View File
@@ -1,25 +1,21 @@
# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
name: Python package
name: Python Package (Multi-Version)
on:
push:
branches: [ "develop" ]
pull_request:
branches: [ "develop" ]
schedule:
- cron: '0 4 * * 1' # Weekly Monday 4am UTC
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11"]
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
@@ -30,39 +26,16 @@ jobs:
echo "OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY || 'sk-test-key-for-github-actions-testing-only-not-real' }}" >> $GITHUB_ENV
echo "OPENAI_API_BASE=${{ secrets.OPENAI_API_BASE || 'https://api.openai.com/v1' }}" >> $GITHUB_ENV
echo "OPENAI_MODEL_NAME=${{ secrets.OPENAI_MODEL_NAME || 'gpt-5-nano' }}" >> $GITHUB_ENV
echo "LOGLEVEL=DEBUG" >> $GITHUB_ENV
echo "PYTHONPATH=${{ github.workspace }}/src/praisonai-agents:$PYTHONPATH" >> $GITHUB_ENV
- name: Install dependencies
run: |
cd src/praisonai
python -m pip install --upgrade pip
python -m pip install flake8 pytest
python -m pip install ."[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
python -m pip install duckduckgo_search
python -m pip install pytest-asyncio pytest-cov
# Install knowledge dependencies from praisonai-agents
python -m pip install duckduckgo_search pytest pytest-asyncio pytest-cov
python -m pip install "praisonaiagents[knowledge]"
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Debug API Key Status
- name: Run tests
run: |
echo "🔍 Checking API key availability..."
if [ -n "${{ secrets.OPENAI_API_KEY }}" ]; then
echo "✅ GitHub secret OPENAI_API_KEY is available"
echo "🔑 API key starts with: $(echo "$OPENAI_API_KEY" | cut -c1-7)..."
else
echo "⚠️ GitHub secret OPENAI_API_KEY is NOT set - using fallback"
echo "🔑 Using fallback key: sk-test-key..."
fi
echo "🌐 API Base: $OPENAI_API_BASE"
echo "🤖 Model: $OPENAI_MODEL_NAME"
# - name: Lint with flake8
# run: |
# # stop the build if there are Python syntax errors or undefined names
# flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
# flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Test with pytest
run: |
cd src/praisonai && python tests/test_runner.py --pattern fast
cd src/praisonai && python -m pytest tests/unit/ -v --tb=short --disable-warnings --timeout=60 || echo "Some tests failed"
+4 -4
View File
@@ -51,8 +51,8 @@ jobs:
# Sync with upstream commit
- name: Sync with upstream
run: |
git config --global user.name "GitHub Actions"
git config --global user.email "actions@github.com"
git config --global user.name "MervinPraison"
git config --global user.email "praison.ms@gmail.com"
# Add upstream remote
git remote add upstream https://github.com/MervinPraison/PraisonAI.git
@@ -473,8 +473,8 @@ jobs:
# Create tag at the same commit
- name: Create tag at commit
run: |
git config --global user.name "GitHub Actions"
git config --global user.email "actions@github.com"
git config --global user.name "MervinPraison"
git config --global user.email "praison.ms@gmail.com"
# Remove tag if it exists locally
git tag -d ${{ github.event.inputs.tag }} || true
+27 -243
View File
@@ -1,41 +1,44 @@
name: Core Tests
on:
on:
push:
branches: [ main, develop ]
paths:
- 'src/**'
- 'pyproject.toml'
- '.github/workflows/test-core.yml'
pull_request:
branches: [ main, develop ]
paths:
- 'src/**'
- 'pyproject.toml'
- '.github/workflows/test-core.yml'
workflow_dispatch:
jobs:
test-core:
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
matrix:
python-version: [3.11]
steps:
- name: Checkout code
uses: actions/checkout@v4
timeout-minutes: 10
- name: Set up Python ${{ matrix.python-version }}
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
python-version: '3.11'
- name: Install UV
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> $GITHUB_PATH
- name: Install dependencies
run: |
cd src/praisonai
uv pip install --system ."[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
uv pip install --system duckduckgo_search
uv pip install --system pytest pytest-asyncio pytest-cov pytest-timeout
# Install knowledge dependencies from praisonai-agents
uv pip install --system "praisonaiagents[knowledge]"
- name: Set environment variables
@@ -43,246 +46,27 @@ jobs:
echo "OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY || 'sk-test-key-for-github-actions-testing-only-not-real' }}" >> $GITHUB_ENV
echo "OPENAI_API_BASE=${{ secrets.OPENAI_API_BASE || 'https://api.openai.com/v1' }}" >> $GITHUB_ENV
echo "OPENAI_MODEL_NAME=${{ secrets.OPENAI_MODEL_NAME || 'gpt-5-nano' }}" >> $GITHUB_ENV
echo "LOGLEVEL=DEBUG" >> $GITHUB_ENV
echo "PYTHONPATH=${{ github.workspace }}/src/praisonai-agents:$PYTHONPATH" >> $GITHUB_ENV
# Also export to current shell session for immediate availability
export OPENAI_API_KEY="${{ secrets.OPENAI_API_KEY || 'sk-test-key-for-github-actions-testing-only-not-real' }}"
export OPENAI_API_BASE="${{ secrets.OPENAI_API_BASE || 'https://api.openai.com/v1' }}"
export OPENAI_MODEL_NAME="${{ secrets.OPENAI_MODEL_NAME || 'gpt-5-nano' }}"
export LOGLEVEL=DEBUG
# Verify immediate availability
echo "🔧 Immediate verification in same step:"
echo " OPENAI_API_KEY length in current session: ${#OPENAI_API_KEY}"
echo " OPENAI_API_KEY starts with sk-: $(echo "$OPENAI_API_KEY" | grep -q '^sk-' && echo 'YES' || echo 'NO')"
- name: Debug API Key Status
run: |
echo "🔍 Checking API key availability..."
if [ -n "${{ secrets.OPENAI_API_KEY }}" ]; then
echo "✅ GitHub secret OPENAI_API_KEY is available"
echo "🔑 API key starts with: $(echo "$OPENAI_API_KEY" | cut -c1-7)..."
else
echo "⚠️ GitHub secret OPENAI_API_KEY is NOT set - using fallback"
echo "🔑 Using fallback key: sk-test-key..."
fi
echo "🌐 API Base: $OPENAI_API_BASE"
echo "🤖 Model: $OPENAI_MODEL_NAME"
echo "🐛 Log Level: $LOGLEVEL"
echo "📊 Environment Check:"
echo " - OPENAI_API_KEY length: ${#OPENAI_API_KEY}"
echo " - OPENAI_API_BASE: $OPENAI_API_BASE"
echo " - OPENAI_MODEL_NAME: $OPENAI_MODEL_NAME"
echo " - LOGLEVEL: $LOGLEVEL"
echo "🔍 Is API key actually set?"
echo " - API key starts with sk-: $(echo "$OPENAI_API_KEY" | grep -q '^sk-' && echo 'YES' || echo 'NO')"
echo " - API key is not test key: $([ "$OPENAI_API_KEY" != 'sk-test-key-for-github-actions-testing-only-not-real' ] && echo 'YES' || echo 'NO')"
- name: Debug Environment Variables Raw
run: |
echo "🔧 Raw environment variable check:"
echo "OPENAI_API_KEY set: $(if [ -n "$OPENAI_API_KEY" ]; then echo 'YES'; else echo 'NO'; fi)"
echo "OPENAI_API_KEY length: ${#OPENAI_API_KEY}"
echo "OPENAI_API_KEY first 10 chars: ${OPENAI_API_KEY:0:10}"
echo "OPENAI_API_KEY last 5 chars: ${OPENAI_API_KEY: -5}"
printenv | grep OPENAI || echo "No OPENAI env vars found"
- name: Debug Python Environment Variables
run: |
python -c "
import os
print('🐍 Python Environment Variable Check:')
api_key = os.environ.get('OPENAI_API_KEY', 'NOT_SET')
if api_key != 'NOT_SET':
print(f' ✅ OPENAI_API_KEY: {api_key[:7]}... (length: {len(api_key)})')
else:
print(' ❌ OPENAI_API_KEY: NOT_SET')
print(f' 🌐 OPENAI_API_BASE: {os.environ.get(\"OPENAI_API_BASE\", \"NOT_SET\")}')
print(f' 🤖 OPENAI_MODEL_NAME: {os.environ.get(\"OPENAI_MODEL_NAME\", \"NOT_SET\")}')
print(f' 📋 All OPENAI env vars:')
for key, value in os.environ.items():
if key.startswith('OPENAI'):
print(f' {key}: {value[:10] if len(value) > 10 else value}...')
"
- name: Validate API Key
run: |
echo "🔑 Testing API key validity with minimal OpenAI call..."
python -c "
import os
try:
from openai import OpenAI
client = OpenAI(api_key=os.environ.get('OPENAI_API_KEY'))
response = client.models.list()
print('✅ API Key is VALID - OpenAI responded successfully')
print(f'📊 Available models: {len(list(response.data))} models found')
except Exception as e:
print(f'❌ API Key is INVALID - Error: {e}')
print('🔍 This explains why all API-dependent tests are failing')
print('💡 The GitHub secret OPENAI_API_KEY needs to be updated with a valid key')
"
continue-on-error: true
- name: Debug PraisonAI API Key Usage
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY || 'sk-test-key-for-github-actions-testing-only-not-real' }}
OPENAI_API_BASE: ${{ secrets.OPENAI_API_BASE || 'https://api.openai.com/v1' }}
OPENAI_MODEL_NAME: ${{ secrets.OPENAI_MODEL_NAME || 'gpt-5-nano' }}
LOGLEVEL: DEBUG
run: |
echo "🔍 Testing PraisonAI API key usage directly..."
cd src/praisonai
python -c "
import os
import sys
sys.path.insert(0, '.')
# Attempt to import SecretStr, otherwise use a dummy class
try:
from pydantic.types import SecretStr
except ImportError:
class SecretStr: # Dummy class if pydantic is not available in this minimal context
def __init__(self, value): self._value = value
def get_secret_value(self): return self._value
def get_key_display_value(key_value):
if isinstance(key_value, SecretStr):
return key_value.get_secret_value()[:10] if key_value.get_secret_value() else 'EMPTY_SECRET'
elif isinstance(key_value, str):
return key_value[:10] if key_value != 'nokey' and key_value != 'NOT_SET' else key_value
return 'INVALID_TYPE'
print('🔧 Direct PraisonAI API Key Check:')
env_api_key = os.environ.get(\"OPENAI_API_KEY\", \"NOT_SET\")
print(f'Environment OPENAI_API_KEY: {get_key_display_value(env_api_key)}...')
from praisonai import PraisonAI
praisonai = PraisonAI()
print(f'PraisonAI config_list: {praisonai.config_list}')
api_key_from_config = praisonai.config_list[0].get('api_key', 'NOT_SET')
print(f'API key from PraisonAI config: {get_key_display_value(api_key_from_config)}...')
from praisonai.inc.models import PraisonAIModel
print('\\n🧪 Testing PraisonAIModel with explicit API key (CrewAI method):')
model_with_explicit_key = PraisonAIModel(
model='openai/gpt-5-nano',
base_url=praisonai.config_list[0].get('base_url'),
api_key=api_key_from_config # This will be a string from praisonai.config_list
)
print(f' Model: {model_with_explicit_key.model}')
print(f' Model name: {model_with_explicit_key.model_name}')
print(f' API key var: {model_with_explicit_key.api_key_var}')
# model_with_explicit_key.api_key is now a string, or 'nokey'
print(f' API key (explicitly passed to PraisonAIModel): {get_key_display_value(model_with_explicit_key.api_key)}...')
print(f' Base URL: {model_with_explicit_key.base_url}')
try:
llm_instance = model_with_explicit_key.get_model()
print(f' ✅ LLM instance created successfully: {type(llm_instance).__name__}')
# langchain_openai.ChatOpenAI stores the key in openai_api_key as SecretStr
llm_api_key_attr = getattr(llm_instance, 'openai_api_key', 'NOT_FOUND')
if llm_api_key_attr != 'NOT_FOUND':
print(f' LLM instance API key: {get_key_display_value(llm_api_key_attr)}...')
else:
print(f' LLM instance API key attribute not found.')
except Exception as e:
print(f' ❌ Failed to create LLM instance: {e}')
import traceback
traceback.print_exc()
"
continue-on-error: true
- name: Run Unit Tests
run: |
cd src/praisonai && python -m pytest tests/unit/ -v --tb=short --disable-warnings --cov=praisonai --cov-report=term-missing --cov-report=xml --cov-branch || echo "Some tests failed - see output above"
cd src/praisonai && python -m pytest tests/unit/ -v --tb=short --disable-warnings \
--cov=praisonai --cov-report=xml --cov-branch \
--timeout=60 || echo "Some unit tests failed"
continue-on-error: true
- name: Run Integration Tests
- name: Run Integration Tests
run: |
cd src/praisonai && python -m pytest tests/integration/ -v --tb=short --disable-warnings
cd src/praisonai && python -m pytest tests/integration/ -v --tb=short --disable-warnings \
--timeout=60 \
--ignore=tests/integration/test_tracker_complex_tasks.py \
--ignore=tests/integration/test_serve_integration.py \
|| echo "Some integration tests failed"
- name: Debug Directory Structure
run: |
echo "🔍 Debugging directory structure for CrewAI tests..."
cd src/praisonai
echo "Current working directory: $(pwd)"
echo "📁 Contents of current directory:"
ls -la
echo ""
echo "📁 Contents of tests directory:"
ls -la tests/ || echo "❌ tests/ directory not found"
echo ""
echo "📁 Contents of tests/integration:"
ls -la tests/integration/ || echo "❌ tests/integration/ directory not found"
echo ""
echo "📁 Looking for crewai directory:"
find . -name "crewai" -type d 2>/dev/null || echo "❌ No crewai directories found"
echo ""
echo "📁 Full directory tree of tests:"
tree tests/ || find tests/ -type d 2>/dev/null || echo "❌ Cannot explore tests directory"
- name: Test AutoGen Framework
run: |
echo "🤖 Testing AutoGen Framework Integration..."
cd src/praisonai && python tests/test_runner.py --pattern autogen --verbose
- name: Test CrewAI Framework
run: |
echo "⛵ Testing CrewAI Framework Integration..."
cd src/praisonai
echo "🔍 Trying test runner first..."
python tests/test_runner.py --pattern crewai --verbose || {
echo "❌ Test runner failed, trying direct pytest..."
echo "📁 Current directory: $(pwd)"
echo "📁 Looking for CrewAI tests..."
find . -name "*crewai*" -type f 2>/dev/null
echo "🧪 Trying direct pytest on integration/crewai..."
python -m pytest tests/integration/crewai/ -v --tb=short --disable-warnings || {
echo "❌ Direct path failed, trying relative path..."
python -m pytest integration/crewai/ -v --tb=short --disable-warnings || {
echo "❌ All CrewAI test attempts failed"
exit 1
}
}
}
- name: Run Legacy Tests with API Key
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY || 'sk-test-key-for-github-actions-testing-only-not-real' }}
OPENAI_API_BASE: ${{ secrets.OPENAI_API_BASE || 'https://api.openai.com/v1' }}
OPENAI_MODEL_NAME: ${{ secrets.OPENAI_MODEL_NAME || 'gpt-5-nano' }}
LOGLEVEL: DEBUG
run: |
echo "🧪 Running legacy tests with real API key..."
echo "🔧 Final environment check before pytest:"
echo " OPENAI_API_KEY set: $([ -n "$OPENAI_API_KEY" ] && echo 'YES' || echo 'NO')"
echo " OPENAI_API_KEY length: ${#OPENAI_API_KEY}"
echo " OPENAI_API_KEY starts with sk-: $(echo "$OPENAI_API_KEY" | grep -q '^sk-' && echo 'YES' || echo 'NO')"
export OPENAI_API_KEY="$OPENAI_API_KEY"
export OPENAI_API_BASE="$OPENAI_API_BASE"
export OPENAI_MODEL_NAME="$OPENAI_MODEL_NAME"
cd src/praisonai && python -m pytest tests/test.py -v --tb=short --disable-warnings -m "not real" --timeout=60
- name: Upload coverage reports to Codecov
if: matrix.python-version == '3.11'
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
file: src/praisonai/coverage.xml
flags: core-tests
name: core-tests-coverage
fail_ci_if_error: false
verbose: true
- name: Upload Coverage Reports (Artifacts)
if: matrix.python-version == '3.11'
uses: actions/upload-artifact@v4
with:
name: coverage-reports
path: |
src/praisonai/.coverage
src/praisonai/htmlcov/
src/praisonai/coverage.xml
retention-days: 7
fail_ci_if_error: false
+1 -6
View File
@@ -1,14 +1,9 @@
name: Extended Tests
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
schedule:
# Run nightly at 2 AM UTC
- cron: '0 2 * * *'
workflow_dispatch: # Allow manual triggering
workflow_dispatch:
jobs:
test-examples:
+8
View File
@@ -3,8 +3,16 @@ name: Optimized Test Suite
on:
push:
branches: [ main, develop ]
paths:
- 'src/**'
- 'pyproject.toml'
- '.github/workflows/test-optimized.yml'
pull_request:
branches: [ main, develop ]
paths:
- 'src/**'
- 'pyproject.toml'
- '.github/workflows/test-optimized.yml'
schedule:
# Nightly extended tests at 3 AM UTC
- cron: '0 3 * * *'
+14 -13
View File
@@ -3,8 +3,16 @@ name: Windows Tests
on:
push:
branches: [main, develop]
paths:
- 'src/**'
- 'pyproject.toml'
- '.github/workflows/test-windows.yml'
pull_request:
branches: [main, develop]
paths:
- 'src/**'
- 'pyproject.toml'
- '.github/workflows/test-windows.yml'
workflow_dispatch:
jobs:
@@ -14,6 +22,7 @@ jobs:
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_MODEL_NAME: gpt-4o-mini
PYTHONUTF8: 1
steps:
- uses: actions/checkout@v4
@@ -27,13 +36,13 @@ jobs:
run: |
python -m pip install --upgrade pip
cd src/praisonai-agents
pip install .
pip install -e .
shell: bash
- name: Install praisonai wrapper
run: |
cd src/praisonai
pip install .[dev]
pip install -e .
shell: bash
# ==================== CORE IMPORTS ====================
@@ -41,7 +50,7 @@ jobs:
run: |
python -c "from praisonaiagents import Agent, Agents, Workflow; print('Core imports OK')"
python -c "from praisonaiagents import tool, Tools; print('Tool imports OK')"
python -c "from praisonaiagents import Task, WorkflowStep; print('Task/Step imports OK')"
python -c "from praisonaiagents import Task; print('Task imports OK')"
# ==================== AGENT FEATURES ====================
- name: Test Agent instantiation
@@ -66,21 +75,13 @@ jobs:
"
# ==================== WORKFLOW FEATURES ====================
- name: Test Workflow and WorkflowStep
- name: Test Workflow
run: |
python -c "
from praisonaiagents import Workflow, WorkflowStep
from praisonaiagents.workflows import WorkflowOutputConfig
from praisonaiagents import Workflow
w = Workflow(name='test')
s1 = WorkflowStep(name='step1')
s2 = WorkflowStep(name='step2', context=['step1'])
print('Workflow:', w.name)
print('Steps:', s1.name, s2.name)
cfg = WorkflowOutputConfig(verbose=True, stream=False)
w2 = Workflow(name='configured', output=cfg)
print('Configured workflow verbose:', w2.verbose)
"
# ==================== MEMORY FEATURES ====================
-516
View File
@@ -1,516 +0,0 @@
name: Quick Validation Tests
on: [push, pull_request]
jobs:
quick-test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: 3.11
- name: Install UV
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> $GITHUB_PATH
- name: Install dependencies
run: |
cd src/praisonai
uv pip install --system ."[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
uv pip install --system duckduckgo_search
uv pip install --system pytest pytest-asyncio pytest-cov pytest-timeout
# Install knowledge dependencies from praisonai-agents
uv pip install --system "praisonaiagents[knowledge]"
- name: Set environment variables
run: |
echo "OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY || 'sk-test-key-for-github-actions-testing-only-not-real' }}" >> $GITHUB_ENV
echo "OPENAI_API_BASE=${{ secrets.OPENAI_API_BASE || 'https://api.openai.com/v1' }}" >> $GITHUB_ENV
echo "OPENAI_MODEL_NAME=${{ secrets.OPENAI_MODEL_NAME || 'gpt-5-nano' }}" >> $GITHUB_ENV
echo "LOGLEVEL=DEBUG" >> $GITHUB_ENV
echo "PYTHONPATH=${{ github.workspace }}/src/praisonai-agents:$PYTHONPATH" >> $GITHUB_ENV
- name: Backup Root Config Files
run: |
echo "🔄 Backing up root configuration files to prevent default file resolution interference..."
echo "ℹ️ PraisonAI automatically uses 'agents.yaml' in working directory when no file specified"
echo "ℹ️ This can interfere with test files that specify their own YAML files"
if [ -f "agents.yaml" ]; then
mv agents.yaml agents.yaml.backup
echo "✅ Moved root agents.yaml to agents.yaml.backup"
echo " - This prevents default file fallback during tests"
fi
if [ -f "tools.py" ]; then
mv tools.py tools.py.backup
echo "✅ Moved tools.py to tools.py.backup"
fi
- name: Debug API Key Status
run: |
echo "🔍 Checking API key availability..."
if [ -n "${{ secrets.OPENAI_API_KEY }}" ]; then
echo "✅ GitHub secret OPENAI_API_KEY is available"
echo "🔑 API key starts with: $(echo "$OPENAI_API_KEY" | cut -c1-7)..."
else
echo "⚠️ GitHub secret OPENAI_API_KEY is NOT set - using fallback"
echo "🔑 Using fallback key: sk-test-key..."
fi
echo "🌐 API Base: $OPENAI_API_BASE"
echo "🤖 Model: $OPENAI_MODEL_NAME"
echo "🐛 Log Level: $LOGLEVEL"
echo "📊 Environment Check:"
echo " - OPENAI_API_KEY length: ${#OPENAI_API_KEY}"
echo " - OPENAI_API_BASE: $OPENAI_API_BASE"
echo " - OPENAI_MODEL_NAME: $OPENAI_MODEL_NAME"
echo " - LOGLEVEL: $LOGLEVEL"
- name: Debug Python Environment Variables
run: |
python -c "
import os
print('🐍 Python Environment Variable Check:')
api_key = os.environ.get('OPENAI_API_KEY', 'NOT_SET')
if api_key != 'NOT_SET':
print(f' ✅ OPENAI_API_KEY: {api_key[:7]}... (length: {len(api_key)})')
else:
print(' ❌ OPENAI_API_KEY: NOT_SET')
print(f' 🌐 OPENAI_API_BASE: {os.environ.get(\"OPENAI_API_BASE\", \"NOT_SET\")}')
print(f' 🤖 OPENAI_MODEL_NAME: {os.environ.get(\"OPENAI_MODEL_NAME\", \"NOT_SET\")}')
print(f' 📋 All OPENAI env vars:')
for key, value in os.environ.items():
if key.startswith('OPENAI'):
print(f' {key}: {value[:10] if len(value) > 10 else value}...')
"
- name: Find Researcher Role Source
run: |
echo "🔍 Hunting for the mysterious 'Researcher' role..."
cd src/praisonai
python -c "
import os
import yaml
import glob
print('📋 Searching for Researcher role in all YAML files:')
yaml_files = glob.glob('src/praisonai/tests/*.yaml')
for yaml_file in yaml_files:
try:
with open(yaml_file, 'r') as f:
config = yaml.safe_load(f)
# Check if any role contains 'researcher'
roles = config.get('roles', {})
for role_key, role_data in roles.items():
role_name = role_data.get('role', '')
if 'researcher' in role_key.lower() or 'researcher' in role_name.lower():
print(f' 🎯 FOUND in {yaml_file}:')
print(f' Framework: {config.get(\"framework\", \"NOT_SET\")}')
print(f' Role key: {role_key}')
print(f' Role name: {role_name}')
print(f' All roles: {list(roles.keys())}')
print()
except Exception as e:
print(f' ❌ Error reading {yaml_file}: {e}')
print('🔍 Checking for default configurations...')
# Check if there are any default configs or hardcoded roles
try:
import praisonai
print(f' PraisonAI package location: {praisonai.__file__}')
# Check if there are any example YAML files in the package
package_dir = os.path.dirname(praisonai.__file__)
for root, dirs, files in os.walk(package_dir):
for file in files:
if file.endswith(('.yaml', '.yml')):
file_path = os.path.join(root, file)
print(f' 📁 Found YAML in package: {file_path}')
except Exception as e:
print(f' ❌ Error checking package: {e}')
"
continue-on-error: true
- name: Trace AutoGen Execution Path
run: |
echo "🔍 Tracing AutoGen execution to find where it diverges..."
cd src/praisonai
python -c "
import os
import sys
sys.path.insert(0, '.')
try:
from praisonai import PraisonAI
from praisonai.agents_generator import AgentsGenerator
# Test the exact execution path
print('🎯 Testing AutoGen execution path:')
praisonai = PraisonAI(agent_file='tests/autogen-agents.yaml')
print(f' 1. PraisonAI framework: \"{praisonai.framework}\"')
agents_gen = AgentsGenerator(
agent_file='tests/autogen-agents.yaml',
framework=praisonai.framework,
config_list=praisonai.config_list
)
print(f' 2. AgentsGenerator framework: \"{agents_gen.framework}\"')
# Load the YAML to check what it contains
import yaml
with open('src/praisonai/tests/autogen-agents.yaml', 'r') as f:
config = yaml.safe_load(f)
framework = agents_gen.framework or config.get('framework')
print(f' 3. Final framework decision: \"{framework}\"')
print(f' 4. Available frameworks:')
# Check framework availability
try:
import autogen
print(f' ✅ AutoGen available')
except ImportError:
print(f' ❌ AutoGen NOT available')
try:
from praisonaiagents import Agent
print(f' ✅ PraisonAI agents available')
except ImportError:
print(f' ❌ PraisonAI agents NOT available')
try:
from crewai import Agent
print(f' ✅ CrewAI available')
except ImportError:
print(f' ❌ CrewAI NOT available')
print(f' 5. Roles in YAML: {list(config.get(\"roles\", {}).keys())}')
# Now test the actual framework execution
if framework == 'autogen':
print(f' 6. ✅ Should execute _run_autogen')
elif framework == 'praisonai':
print(f' 6. ❌ Would execute _run_praisonai (WRONG!)')
else:
print(f' 6. ❌ Would execute _run_crewai (DEFAULT FALLBACK)')
except Exception as e:
print(f'❌ Error tracing execution: {e}')
import traceback
traceback.print_exc()
"
continue-on-error: true
- name: Debug YAML Loading and Roles
run: |
echo "🔍 Tracing YAML file loading and role creation..."
cd src/praisonai
python -c "
import os
import sys
import yaml
sys.path.insert(0, '.')
print('📁 Available YAML files in src/praisonai/tests/:')
import glob
yaml_files = glob.glob('tests/*.yaml')
for f in yaml_files:
print(f' {f}')
print()
print('📋 Content of autogen-agents.yaml:')
with open('tests/autogen-agents.yaml', 'r') as f:
config = yaml.safe_load(f)
print(f' Framework: {config.get(\"framework\")}')
print(f' Topic: {config.get(\"topic\")}')
print(f' Roles: {list(config.get(\"roles\", {}).keys())}')
for role_key, role_data in config.get('roles', {}).items():
print(f' {role_key} -> {role_data.get(\"role\", \"NO_ROLE\")}')
print()
print('🔍 Checking if execution uses a different YAML:')
# Check other YAML files for 'Researcher' role
for yaml_file in yaml_files:
try:
with open(yaml_file, 'r') as f:
test_config = yaml.safe_load(f)
roles = test_config.get('roles', {})
for role_key, role_data in roles.items():
if 'researcher' in role_key.lower() or 'researcher' in role_data.get('role', '').lower():
print(f' 🎯 FOUND Researcher in {yaml_file}!')
print(f' Framework: {test_config.get(\"framework\")}')
print(f' Role key: {role_key} -> {role_data.get(\"role\")}')
except:
pass
"
continue-on-error: true
- name: Debug Framework Detection
run: |
echo "🔍 Testing framework detection and config flow..."
python -c "
import os
import sys
import yaml
sys.path.insert(0, '.')
print('🔧 Testing framework detection:')
# Load the YAML file
with open('tests/autogen-agents.yaml', 'r') as f:
config = yaml.safe_load(f)
print(f' 📋 YAML framework: {config.get(\"framework\", \"NOT_SET\")}')
print(f' 📋 YAML topic: {config.get(\"topic\", \"NOT_SET\")}')
try:
from praisonai import PraisonAI
from praisonai.agents_generator import AgentsGenerator
# Test PraisonAI initialization
praisonai = PraisonAI(agent_file='tests/autogen-agents.yaml')
print(f' 🎯 PraisonAI framework: {praisonai.framework}')
# Test AgentsGenerator initialization
agents_gen = AgentsGenerator(
agent_file='tests/autogen-agents.yaml',
framework=praisonai.framework,
config_list=praisonai.config_list
)
print(f' ⚙️ AgentsGenerator framework: {agents_gen.framework}')
print(f' ⚙️ Final framework decision: {agents_gen.framework or config.get(\"framework\")}')
# Check config_list
print(f' 🔑 Config list model: {praisonai.config_list[0].get(\"model\")}')
print(f' 🔑 Config list API key: {praisonai.config_list[0].get(\"api_key\", \"NOT_SET\")[:10]}...')
except Exception as e:
print(f'❌ Error in framework detection: {e}')
"
continue-on-error: true
- name: Debug PraisonAIModel API Key Flow
run: |
echo "🔍 Testing PraisonAIModel API key handling..."
cd src/praisonai
python -c "
import os
import sys
sys.path.insert(0, '.')
print('🔑 Environment API Key Check:')
env_key = os.environ.get('OPENAI_API_KEY', 'NOT_FOUND')
print(f' OPENAI_API_KEY: {env_key[:10] if env_key != \"NOT_FOUND\" else \"NOT_FOUND\"}...')
try:
from praisonai.inc.models import PraisonAIModel
# Test PraisonAIModel with openai/gpt-5-nano (from YAML)
model = PraisonAIModel(model='openai/gpt-5-nano')
print('🤖 PraisonAIModel Configuration:')
print(f' model: {model.model}')
print(f' model_name: {model.model_name}')
print(f' api_key_var: {model.api_key_var}')
print(f' api_key: {model.api_key[:10] if model.api_key != \"nokey\" else \"DEFAULT_NOKEY\"}...')
print(f' base_url: {model.base_url}')
if model.api_key == 'nokey':
print('❌ FOUND THE ISSUE: PraisonAIModel is using default \"nokey\" instead of environment variable!')
else:
print('✅ PraisonAIModel has valid API key from environment')
except Exception as e:
print(f'❌ Error testing PraisonAIModel: {e}')
"
continue-on-error: true
- name: Validate API Key
run: |
echo "🔑 Testing API key validity with minimal OpenAI call..."
python -c "
import os
try:
from openai import OpenAI
client = OpenAI(api_key=os.environ.get('OPENAI_API_KEY'))
# Make a minimal API call to test key validity
response = client.models.list()
print('✅ API Key is VALID - OpenAI responded successfully')
print(f'📊 Available models: {len(list(response.data))} models found')
except Exception as e:
print(f'❌ API Key is INVALID - Error: {e}')
print('🔍 This explains why all API-dependent tests are failing')
print('💡 The GitHub secret OPENAI_API_KEY needs to be updated with a valid key')
"
continue-on-error: true
- name: Test Direct PraisonAI Execution
run: |
echo "🧪 Testing direct PraisonAI execution (what works locally)..."
python -m praisonai src/praisonai/tests/autogen-agents.yaml
continue-on-error: true
- name: Comprehensive Execution Debug
run: |
echo "🔍 Comprehensive debugging of PraisonAI execution path..."
cd src/praisonai
python -c "
import os
import sys
import yaml
sys.path.insert(0, '.')
print('=' * 60)
print('🔍 COMPREHENSIVE EXECUTION DEBUG')
print('=' * 60)
# Check current working directory
print(f'📁 Current working directory: {os.getcwd()}')
# List files in current directory
print('📋 Files in current directory:')
for f in os.listdir('.'):
print(f' {f}')
print()
print('📋 Files in src/praisonai/tests/ directory:')
for f in os.listdir('tests'):
if f.endswith('.yaml'):
print(f' src/praisonai/tests/{f}')
# Check if root agents.yaml exists
print()
if os.path.exists('agents.yaml'):
print('❌ ROOT agents.yaml EXISTS (this is the problem!)')
with open('agents.yaml', 'r') as f:
root_config = yaml.safe_load(f)
print(f' Root framework: {root_config.get(\"framework\")}')
print(f' Root roles: {list(root_config.get(\"roles\", {}).keys())}')
else:
print('✅ ROOT agents.yaml does NOT exist (good!)')
# Test the actual execution path
print()
print('🎯 Testing EXACT execution path:')
try:
from praisonai import PraisonAI
from praisonai.agents_generator import AgentsGenerator
# Test with full path
test_file = 'src/praisonai/tests/autogen-agents.yaml'
print(f' Using test file: {test_file}')
print(f' File exists: {os.path.exists(test_file)}')
# Load the test file directly
with open(test_file, 'r') as f:
test_config = yaml.safe_load(f)
print(f' Test file framework: {test_config.get(\"framework\")}')
print(f' Test file roles: {list(test_config.get(\"roles\", {}).keys())}')
# Create PraisonAI instance
praisonai = PraisonAI(agent_file=test_file)
print(f' PraisonAI.agent_file: {praisonai.agent_file}')
print(f' PraisonAI.framework: {praisonai.framework}')
# Create AgentsGenerator
agents_gen = AgentsGenerator(
agent_file=test_file,
framework=praisonai.framework,
config_list=praisonai.config_list
)
print(f' AgentsGenerator.agent_file: {agents_gen.agent_file}')
print(f' AgentsGenerator.framework: {agents_gen.framework}')
# Test what would happen in generate_crew_and_kickoff
print()
print('🔧 Testing generate_crew_and_kickoff logic:')
# Simulate the loading logic
if agents_gen.agent_yaml:
loaded_config = yaml.safe_load(agents_gen.agent_yaml)
print(' Would load from agent_yaml')
else:
if agents_gen.agent_file == '/app/api:app' or agents_gen.agent_file == 'api:app':
agents_gen.agent_file = 'agents.yaml'
print(f' Would change agent_file to: {agents_gen.agent_file}')
try:
with open(agents_gen.agent_file, 'r') as f:
loaded_config = yaml.safe_load(f)
print(f' Successfully loaded: {agents_gen.agent_file}')
except FileNotFoundError:
print(f' FileNotFoundError: {agents_gen.agent_file}')
loaded_config = None
if loaded_config:
final_framework = agents_gen.framework or loaded_config.get('framework')
print(f' Final framework decision: {final_framework}')
print(f' Loaded roles: {list(loaded_config.get(\"roles\", {}).keys())}')
if 'researcher' in loaded_config.get('roles', {}):
print(' ❌ FOUND Researcher role in loaded config!')
else:
print(' ✅ No Researcher role in loaded config')
except Exception as e:
print(f'❌ Error during execution debug: {e}')
import traceback
traceback.print_exc()
"
continue-on-error: true
- name: Run Fast Tests
run: |
# Run the fastest, most essential tests with coverage
cd src/praisonai && python -m pytest tests/unit/test_core_agents.py -v --tb=short --disable-warnings --cov=praisonai --cov-report=xml --cov-branch
- name: Run Real API Tests
run: |
echo "🔑 Running real API tests with actual OpenAI API key..."
cd src/praisonai && python -m pytest tests/test_agents_playbook.py -v --tb=short --disable-warnings -m real
continue-on-error: true
- name: Run E2E Real Tests
run: |
echo "🧪 Running E2E real tests..."
cd src/praisonai && python -m pytest tests/e2e/ -v --tb=short --disable-warnings -m real
continue-on-error: true
- name: Run Legacy Example Tests
run: |
cd src/praisonai && python -m pytest tests/test.py -v --tb=short --disable-warnings -m "not real" --timeout=60
continue-on-error: true
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
file: src/praisonai/coverage.xml
flags: quick-validation
name: quick-validation-coverage
fail_ci_if_error: false
verbose: true
- name: Restore Root Config Files
run: |
echo "🔄 Restoring root configuration files..."
if [ -f "agents.yaml.backup" ]; then
mv agents.yaml.backup agents.yaml
echo "✅ Restored agents.yaml"
fi
if [ -f "tools.py.backup" ]; then
mv tools.py.backup tools.py
echo "✅ Restored tools.py"
fi
if: always() # Always run this step, even if previous steps failed
+3 -3
View File
@@ -52,6 +52,6 @@ jobs:
with:
commit_message: "docs: auto-update api.md [skip ci]"
file_pattern: 'api.md'
commit_user_name: "github-actions[bot]"
commit_user_email: "41898282+github-actions[bot]@users.noreply.github.com"
commit_author: "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>"
commit_user_name: "MervinPraison"
commit_user_email: "praison.ms@gmail.com"
commit_author: "MervinPraison <praison.ms@gmail.com>"
+6 -6
View File
@@ -31,7 +31,7 @@ jobs:
with:
repository: MervinPraison/PraisonAIDocs
path: PraisonAIDocs
token: ${{ secrets.DOCS_PAT }}
token: ${{ secrets.DOCS_PAT || secrets.PAT_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v5
@@ -65,16 +65,16 @@ jobs:
with:
commit_message: "docs: auto-update documentation parity trackers [skip ci]"
file_pattern: 'src/praisonai-agents/DOCS_PARITY.md src/praisonai-ts/DOCS_PARITY.md src/praisonai-rust/DOCS_PARITY.md'
commit_user_name: "github-actions[bot]"
commit_user_email: "41898282+github-actions[bot]@users.noreply.github.com"
commit_author: "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>"
commit_user_name: "MervinPraison"
commit_user_email: "praison.ms@gmail.com"
commit_author: "MervinPraison <praison.ms@gmail.com>"
- name: Commit and push changes to PraisonAIDocs
if: steps.check_changes.outputs.changes == 'true'
run: |
cd PraisonAIDocs
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git config user.name "MervinPraison"
git config user.email "praison.ms@gmail.com"
git add docs/features/DOCS_PARITY.md docs/js/DOCS_PARITY.md docs/rust/DOCS_PARITY.md
git diff --staged --quiet || git commit -m "docs: auto-update documentation parity trackers [skip ci]"
git push
+6 -3
View File
@@ -29,6 +29,9 @@ jobs:
with:
python-version: '3.11'
- name: Install dependencies
run: pip install pyyaml
- name: Generate parity trackers
run: |
cd $GITHUB_WORKSPACE
@@ -52,7 +55,7 @@ jobs:
with:
commit_message: "docs: auto-update feature parity trackers [skip ci]"
file_pattern: 'src/praisonai-ts/FEATURE_PARITY_TRACKER.json src/praisonai-ts/PARITY.md src/praisonai-rust/FEATURE_PARITY_TRACKER.json src/praisonai-rust/PARITY.md'
commit_user_name: "github-actions[bot]"
commit_user_email: "41898282+github-actions[bot]@users.noreply.github.com"
commit_author: "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>"
commit_user_name: "MervinPraison"
commit_user_email: "praison.ms@gmail.com"
commit_author: "MervinPraison <praison.ms@gmail.com>"
+1
View File
@@ -21,6 +21,7 @@ docs.sh
other
output
!src/praisonai/praisonai/cli/output/
!src/praisonai-agents/praisonaiagents/output/
.files
threads.db
+78 -2
View File
@@ -43,8 +43,9 @@ PraisonAI 🦞 — Automate and solve complex challenges with AI agent teams tha
> - 🆕 **New here?** → [Quick Start](#-quick-start) *(1 minute to first agent)*
> - 📦 **Installing?** → [Installation](#-installation)
> - 🐍 **Python SDK?** → [Python Examples](#-using-python-code)
> - 🎯 **CLI user?** → [CLI Quick Reference](#cli-quick-reference)
> - 🤝 **Contributing?** → [Development](#-development)
> - 📄 **YAML/No-Code?** → [YAML Examples](#-using-yaml-no-code)
> - 🎯 **CLI user?** → [CLI Quick Reference](#-cli-quick-reference)
> - 🤝 **Contributing?** → [Contributing](#-contributing)
---
@@ -328,6 +329,81 @@ Open **http://localhost:8082** — the dashboard comes with 13 built-in pages: C
---
## 📄 Using YAML (No Code)
### Example 1: Two Agents Working Together
Create `agents.yaml`:
```yaml
framework: praisonai
topic: "Write a blog post about AI"
agents:
researcher:
role: Research Analyst
goal: Research AI trends and gather information
instructions: "Find accurate information about AI trends"
writer:
role: Content Writer
goal: Write engaging blog posts
instructions: "Write clear, engaging content based on research"
```
Run with:
```bash
praisonai agents.yaml
```
> The agents automatically work together sequentially
### Example 2: Agent with Custom Tool
Create two files in the same folder:
**agents.yaml:**
```yaml
framework: praisonai
topic: "Calculate the sum of 25 and 15"
agents:
calculator_agent:
role: Calculator
goal: Perform calculations
instructions: "Use the add_numbers tool to help with calculations"
tools:
- add_numbers
```
**tools.py:**
```python
def add_numbers(a: float, b: float) -> float:
"""
Add two numbers together.
Args:
a: First number
b: Second number
Returns:
The sum of a and b
"""
return a + b
```
Run with:
```bash
praisonai agents.yaml
```
> 💡 **Tips:**
> - Use the function name (e.g., `add_numbers`) in the tools list, not the file name
> - Tools in `tools.py` are automatically discovered
> - The function's docstring helps the AI understand how to use it
---
## 🎯 CLI Quick Reference
| Category | Commands |
+3 -2
View File
@@ -49,6 +49,7 @@ Methods:
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">console</a>()</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">context_manager</a>()</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">context_manager</a>(value)</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">cost_summary</a>() -> dict</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">delete_history</a>(index: int) -> bool</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">delete_history_matching</a>(pattern: str) -> int</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">diff</a>(from_hash: Optional[str] = None)</code>
@@ -99,6 +100,7 @@ Methods:
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">switch_model</a>(new_model: str) -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">thinking_budget</a>()</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">thinking_budget</a>(value)</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">total_cost</a>() -> float</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">undo</a>() -> bool</code>
* <code title="class AutoAgents">AutoAgents.<a href="./src/praisonai-agents/praisonaiagents/agents/autoagents.py">astart</a>()</code>
* <code title="class AutoAgents">AutoAgents.<a href="./src/praisonai-agents/praisonaiagents/agents/autoagents.py">start</a>()</code>
@@ -250,6 +252,7 @@ Methods:
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">build_context_for_task</a>(task_descr: str, user_id: Optional[str] = None, additional: str = '', max_items: int = 3, include_in_output: Optional[bool] = None) -> str</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">calculate_quality_metrics</a>(output: str, expected_output: str, llm: Optional[str] = None, custom_prompt: Optional[str] = None) -> Dict[str, float]</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">close_connections</a>()</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">compute_quality_score</a>(completeness: float, relevance: float, clarity: float, accuracy: float, weights: Dict[str, float] = None) -> float</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">delete_long_term</a>(memory_id: str) -> bool</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">delete_memories</a>(memory_ids: List[str]) -> int</code>
@@ -750,8 +753,6 @@ Methods:
* <code title="class AutoApproveBackend">AutoApproveBackend.<a href="./src/praisonai-agents/praisonaiagents/approval/backends.py">request_approval_sync</a>(request: ApprovalRequest) -> ApprovalDecision</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/embedding/embed.py">aembedding</a>(input: Union[str, List[str]], model: str = 'text-embedding-3-small', dimensions: Optional[int] = None, encoding_format: str = 'float', timeout: float = 600.0, api_key: Optional[str] = None, api_base: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> EmbeddingResult</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/embedding/embed.py">aembeddings</a>(input: Union[str, List[str]], model: str = 'text-embedding-3-small', dimensions: Optional[int] = None, encoding_format: str = 'float', timeout: float = 600.0, api_key: Optional[str] = None, api_base: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> EmbeddingResult</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/embedding/embed.py">embedding</a>(input: Union[str, List[str]], model: str = 'text-embedding-3-small', dimensions: Optional[int] = None, encoding_format: str = 'float', timeout: float = 600.0, api_key: Optional[str] = None, api_base: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> EmbeddingResult</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/embedding/embed.py">embeddings</a>(input: Union[str, List[str]], model: str = 'text-embedding-3-small', dimensions: Optional[int] = None, encoding_format: str = 'float', timeout: float = 600.0, api_key: Optional[str] = None, api_base: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> EmbeddingResult</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/embedding/dimensions.py">get_dimensions</a>(model_name: str) -> int</code>
# Wrapper (praisonai)
+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.80" \
"praisonai>=4.5.87" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.5.80" \
"praisonai>=4.5.87" \
"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.80" \
"praisonai>=4.5.87" \
"praisonai[ui]" \
"praisonai[crewai]"
+30
View File
@@ -0,0 +1,30 @@
framework: ag2
topic: "Research the latest developments in AI agents"
# Install: pip install "praisonai[ag2]"
# Run: praisonai --framework ag2 examples/ag2/ag2_basic.yaml
# or praisonai run examples/ag2/ag2_basic.yaml --framework ag2
roles:
research_agent:
role: "AI Research Specialist"
goal: "Research and summarise the latest developments in AI agent frameworks"
backstory: |
You are an experienced AI researcher with deep knowledge of multi-agent
systems, large language models, and the latest trends in AI tooling.
You excel at synthesising complex technical topics into clear summaries.
tasks:
research_task:
description: |
Research and summarise the latest developments in AI agent frameworks
for the topic: {topic}
Focus on:
1. Key frameworks and their unique capabilities
2. Recent innovations and improvements
3. Community adoption and ecosystem growth
4. Practical use cases and success stories
expected_output: |
A concise research summary covering the key developments,
major frameworks, and practical insights. Include 3-5 bullet
points of the most important findings.
+42
View File
@@ -0,0 +1,42 @@
framework: ag2
topic: "Cloud-native AI deployment strategies on AWS"
# AG2 exclusive feature: native AWS Bedrock support via LLMConfig(api_type="bedrock")
#
# Prerequisites:
# pip install "praisonai[ag2]"
# aws configure (or set AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION)
#
# Run:
# praisonai --framework ag2 examples/ag2/ag2_bedrock.yaml
#
# The AG2 adapter detects api_type="bedrock" from the llm config and uses
# LLMConfig(api_type="bedrock", model=...) — no OPENAI_API_KEY required.
# AWS credentials are sourced from boto3 (env vars, ~/.aws/credentials, IAM role).
roles:
cloud_architect:
role: "AWS Cloud Architect"
goal: "Design and explain cloud-native AI deployment strategies on AWS"
backstory: |
You are an AWS Solutions Architect specialising in AI/ML workloads.
You have deep expertise in Amazon Bedrock, SageMaker, ECS, and Lambda,
and you help organisations deploy AI agents at scale securely and cost-effectively.
llm:
model: "bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0"
api_type: "bedrock"
aws_region: "us-east-1"
tasks:
architecture_task:
description: |
Design a cloud-native deployment strategy for AI agents on AWS for: {topic}
Cover:
1. Recommended AWS services (Bedrock, ECS, Lambda, etc.)
2. Scalability and cost optimisation patterns
3. Security and compliance considerations
4. A simple reference architecture overview
expected_output: |
A concise architecture guide with service recommendations,
a high-level deployment diagram description, and key
best practices for production AI agent deployments on AWS.
+54
View File
@@ -0,0 +1,54 @@
framework: ag2
topic: "The impact of open-source AI on enterprise software development"
# Install: pip install "praisonai[ag2]"
# Run: praisonai --framework ag2 examples/ag2/ag2_multi_agent.yaml
# or praisonai run examples/ag2/ag2_multi_agent.yaml --framework ag2
#
# This example demonstrates AG2's GroupChat multi-agent coordination.
# Both agents participate in a collaborative conversation managed by
# a GroupChatManager until the task is complete.
roles:
researcher:
role: "Research Specialist"
goal: "Gather and analyse information on the given topic"
backstory: |
You are a meticulous researcher who excels at finding relevant
information, analysing trends, and presenting data-backed insights.
You always cite your reasoning and structure your findings clearly.
tasks:
research_task:
description: |
Research the topic: {topic}
Investigate:
1. Current state and adoption rates
2. Key players and projects driving the trend
3. Technical advantages and challenges
4. Business impact and cost implications
expected_output: |
A structured research briefing with findings on the topic,
including key data points, trends, and technical observations.
writer:
role: "Technical Content Writer"
goal: "Transform research findings into clear, engaging written content"
backstory: |
You are a skilled technical writer who turns complex research into
accessible, well-structured articles. You focus on clarity, logical
flow, and actionable takeaways for a professional audience.
tasks:
writing_task:
description: |
Using the research findings provided by the Research Specialist,
write a concise article on: {topic}
The article should:
1. Open with a compelling hook
2. Present key findings logically
3. Include practical implications for developers
4. Close with a forward-looking conclusion
expected_output: |
A 400-500 word article suitable for a technical blog,
with clear sections, professional tone, and concrete takeaways.
+9 -1
View File
@@ -19,7 +19,15 @@ async function main() {
},
execute: async ({ expression }) => {
try {
const result = eval(expression);
// Only allow safe numeric math characters
if (!/^[0-9+\-*/.() ]+$/.test(expression)) {
return 'Error: Only basic math expressions are allowed';
}
// Use Function constructor scoped to math only, no globals
const result = new Function(`"use strict"; return (${expression})`)();
if (typeof result !== 'number' || !isFinite(result)) {
return 'Error: Invalid result';
}
return `Result: ${result}`;
} catch (e) {
return 'Error: Invalid expression';
+7 -1
View File
@@ -38,7 +38,13 @@ const calculatorTool = createTool({
},
run: (params: { expression: string }) => {
try {
return eval(params.expression);
// Only allow safe numeric math characters
if (!/^[0-9+\-*/.() ]+$/.test(params.expression)) {
return 'Error: Only basic math expressions are allowed';
}
const result = new Function(`"use strict"; return (${params.expression})`)();
if (typeof result !== 'number' || !isFinite(result)) return 'Error: Invalid result';
return result;
} catch {
return 'Error: Invalid expression';
}
+19 -1
View File
@@ -134,10 +134,28 @@ def main():
except Exception as e:
return {"error": str(e)}
# Safe AST-based math evaluator (no eval on arbitrary expressions)
def _safe_calculate(expression: str) -> str:
import ast
try:
allowed = set("0123456789+-*/.() ")
if not all(c in allowed for c in expression):
return "Error: Invalid characters in expression"
tree = ast.parse(expression, mode="eval")
for node in ast.walk(tree):
if not isinstance(node, (ast.Expression, ast.BinOp, ast.UnaryOp,
ast.Constant, ast.Add, ast.Sub, ast.Mult,
ast.Div, ast.USub, ast.UAdd)):
return "Error: Only basic math operations are allowed"
result = eval(compile(tree, "<expr>", "eval"), {"__builtins__": {}})
return f"Result: {result}"
except Exception as e:
return f"Error: {e}"
# MCP endpoints
mcp_tools = {
"search": {"description": "Search the web", "func": lambda q: f"Results for: {q}"},
"calculate": {"description": "Calculate expression", "func": lambda e: str(eval(e)) if e.replace(" ", "").replace("+", "").replace("-", "").replace("*", "").replace("/", "").replace(".", "").isdigit() or True else "Error"},
"calculate": {"description": "Calculate expression", "func": _safe_calculate},
}
@app.get("/mcp/tools")
+60 -21
View File
@@ -21,9 +21,11 @@ Simpler than competitors • More extensible • Faster • Agent-centric
| **Minimal API** | Fewer parameters, sensible defaults, explicit overrides |
| **Performance-First** | Lazy loading, optional dependencies, no hot-path regressions |
| **Production-Ready** | Safe by default, multi-agent safe, async-safe |
Powerful, lightweight, highly reliable and robust
Easy and developer frienldy for non developers
Easy and user friendly
| **Open Core** | Core free and open source; clear paid upgrade path (support/cloud/services) |
Powerful, lightweight, highly reliable and robust.
Easy and developer-friendly for non-developers.
Each feature runs 3 ways: **CLI, YAML, Python**.
---
## 2. Repository Structure
@@ -96,22 +98,33 @@ Easy and user friendly
| Module | Size | Purpose |
|--------|------|---------|
| `agent/` | 1.4M | Agent class, protocols, handoff, autonomy |
| `memory/` | 984K | Memory protocols, adapters, file memory |
| `context/` | 960K | Context management, artifacts, fast context |
| `tools/` | 732K | Tool SDK, decorators, registry, protocols |
| `llm/` | 620K | LLM client, model router |
| `workflows/` | 456K | Workflow engine, patterns (Route, Parallel, Loop) |
| `knowledge/` | 436K | Knowledge/RAG protocols, vector stores |
| `hooks/` | 260K | Hook system, middleware, events |
| `eval/` | 240K | Evaluation framework |
| `agent/` | 1.7M | Agent class, protocols, handoff, autonomy |
| `memory/` | 784K | Memory protocols, adapters, file memory |
| `context/` | 840K | Context management, artifacts, fast context |
| `tools/` | 816K | Tool SDK, decorators, registry, protocols |
| `llm/` | 756K | LLM client, model router |
| `workflows/` | 472K | Workflow engine, patterns (Route, Parallel, Loop) |
| `mcp/` | 404K | Model Context Protocol integration |
| `eval/` | 384K | Evaluation framework |
| `agents/` | 376K | Multi-agent orchestration (AgentTeam, AgentFlow) |
| `knowledge/` | 300K | Knowledge/RAG protocols, vector stores |
| `rag/` | 264K | RAG protocols, retriever, reranker |
| `plugins/` | 56K | Plugin manager, registry |
| `policy/` | 64K | Policy engine |
| `config/` | 252K | Feature configs (ExecutionConfig, OutputConfig, etc.) |
| `session/` | 224K | Session management, persistence |
| `plugins/` | 204K | Plugin manager, registry |
| `hooks/` | 184K | Hook system, middleware, events |
| `storage/` | 136K | Storage protocols, adapters |
| `scheduler/` | 132K | Schedule models, store, parser, runner |
| `trace/` | 116K | Trace protocols, context events |
| `approval/` | 92K | Human-in-the-loop approval protocols |
| `bots/` | 80K | BotOS protocols, config |
| `policy/` | 76K | Policy engine |
| `gateway/` | 60K | Gateway protocols |
| `sandbox/` | 56K | Code execution sandbox |
| `bus/` | 48K | Event bus |
| `streaming/` | 56K | Streaming events, callbacks |
| `trace/` | 152K | Trace protocols, context events |
| `scheduler/` | 48K | Schedule models, store, parser, runner |
| `streaming/` | 44K | Streaming events, callbacks |
| `conditions/` | 40K | Conditional logic protocols |
| `db/` | 32K | Database adapter protocols |
### 3.2 Protocol-First Design
@@ -512,9 +525,9 @@ class MyDbAdapter(DbAdapter):
---
## 7. CLI Representation
## 7. CLI & YAML Representation
Every feature must have CLI parity:
Every feature must run 3 ways: **Python, CLI, and YAML**.
```bash
# Basic agent
@@ -636,6 +649,30 @@ pytest tests/unit/
pytest --cov=praisonaiagents tests/
```
### 9.4 Real Agentic Test (MANDATORY)
Every feature MUST include a real agentic test — not just smoke tests.
A **real agentic test** means the agent actually runs and calls the LLM:
```python
# ✅ REAL agentic test — agent runs end-to-end
from praisonaiagents import Agent
agent = Agent(name="test", instructions="You are a helpful assistant")
result = agent.start("Say hello in one sentence")
print(result) # Must produce actual LLM output
# ❌ SMOKE test only — NOT sufficient alone
agent = Agent(name="test") # Just object construction
assert agent.name == "test" # No LLM call
```
**Rules:**
- Both smoke AND real agentic tests are required
- Agent MUST call `agent.start()` with a real prompt
- Agent MUST call the LLM and produce a text response
- Print full output so developers can verify end-to-end behavior
---
## 10. Implementation Checklist
@@ -646,8 +683,10 @@ For every feature/change:
- [ ] **No new deps**: Use optional dependencies only
- [ ] **Lazy imports**: Heavy deps imported inside functions
- [ ] **Naming**: Follow conventions (add_*, get_*, XConfig)
- [ ] **Tests**: TDD - write failing tests first
- [ ] **Tests**: TDD — write failing tests first
- [ ] **Real agentic test**: Agent must call LLM end-to-end (§9.4)
- [ ] **CLI**: Add corresponding CLI command/option
- [ ] **YAML**: Ensure YAML config parity with Python API
- [ ] **Docs**: Update Mintlify documentation
- [ ] **Examples**: Add to examples/ directory
- [ ] **Multi-agent safe**: No shared mutable state
@@ -853,7 +892,7 @@ platforms:
---
## 13. Paid Value Path
## 14. Paid Value Path
The core remains **free and open source**. Clear upgrade paths to paid value:
@@ -59,18 +59,7 @@ _logging.configure_root_logger()
# Import configuration (lightweight, no heavy deps)
from . import _config
# Lightweight imports that don't trigger heavy dependency chains
from .tools.tools import Tools
from .tools.base import BaseTool, ToolResult, ToolValidationError, validate_tool
from .tools.decorator import tool, FunctionTool
from .tools.registry import get_registry, register_tool, get_tool, ToolRegistry
# db and obs are lazy-loaded via __getattr__ for performance
# Sub-packages for organized imports (pa.config, pa.tools, etc.)
# These enable: import praisonaiagents as pa; pa.config.MemoryConfig
from . import config
from . import tools
# Note: db, obs, knowledge and mcp are lazy-loaded via __getattr__ due to heavy deps
# Note: tools, config, memory, workflows, db, obs, knowledge and mcp are lazy-loaded via __getattr__ due to heavy deps
# Embedding API - LAZY LOADED via __getattr__ for performance
# Supports: embedding, embeddings, aembedding, aembeddings, EmbeddingResult, get_dimensions
@@ -125,6 +114,19 @@ def _get_lazy_cache():
# ============================================================================
_LAZY_IMPORTS = {
# Tools (moved from eager imports for lazy loading)
'Tools': ('praisonaiagents.tools.tools', 'Tools'),
'BaseTool': ('praisonaiagents.tools.base', 'BaseTool'),
'ToolResult': ('praisonaiagents.tools.base', 'ToolResult'),
'ToolValidationError': ('praisonaiagents.tools.base', 'ToolValidationError'),
'validate_tool': ('praisonaiagents.tools.base', 'validate_tool'),
'tool': ('praisonaiagents.tools.decorator', 'tool'),
'FunctionTool': ('praisonaiagents.tools.decorator', 'FunctionTool'),
'get_registry': ('praisonaiagents.tools.registry', 'get_registry'),
'register_tool': ('praisonaiagents.tools.registry', 'register_tool'),
'get_tool': ('praisonaiagents.tools.registry', 'get_tool'),
'ToolRegistry': ('praisonaiagents.tools.registry', 'ToolRegistry'),
# Main display utilities (imports rich)
'TaskOutput': ('praisonaiagents.main', 'TaskOutput'),
'ReflectionOutput': ('praisonaiagents.main', 'ReflectionOutput'),
@@ -179,9 +181,7 @@ _LAZY_IMPORTS = {
'HandoffDepthError': ('praisonaiagents.agent.handoff', 'HandoffDepthError'),
'HandoffTimeoutError': ('praisonaiagents.agent.handoff', 'HandoffTimeoutError'),
# Embedding API
'embedding': ('praisonaiagents.embedding.embed', 'embedding'),
'embeddings': ('praisonaiagents.embedding.embed', 'embedding'),
# Embedding API (Note: embedding/embeddings handled in custom_handler to override subpackage)
'aembedding': ('praisonaiagents.embedding.embed', 'aembedding'),
'aembeddings': ('praisonaiagents.embedding.embed', 'aembedding'),
'embed': ('praisonaiagents.embedding.embed', 'embed'),
@@ -203,6 +203,9 @@ _LAZY_IMPORTS = {
# Agent classes
'Agent': ('praisonaiagents.agent.agent', 'Agent'),
'BudgetExceededError': ('praisonaiagents.agent.agent', 'BudgetExceededError'),
'Heartbeat': ('praisonaiagents.agent.heartbeat', 'Heartbeat'),
'HeartbeatConfig': ('praisonaiagents.agent.heartbeat', 'HeartbeatConfig'),
'ImageAgent': ('praisonaiagents.agent.image_agent', 'ImageAgent'),
'VideoAgent': ('praisonaiagents.agent.video_agent', 'VideoAgent'),
'VideoConfig': ('praisonaiagents.agent.video_agent', 'VideoConfig'),
@@ -503,21 +506,27 @@ def _custom_handler(name, cache):
cache['Agents'] = value
return value
# Task removed in v4.0.0 - use Task instead
if name == "Task":
raise ImportError(
"Task has been removed in v4.0.0. Use Task instead.\n"
"Migration: Replace 'from praisonaiagents import Task' with 'from praisonaiagents import Task'\n"
"Task supports all Task features including action, handler, loop_over, etc."
)
# Module imports (return the module itself)
if name == 'tools':
import importlib
mod = importlib.import_module('.tools', 'praisonaiagents')
cache['tools'] = mod
return mod
if name == 'config':
import importlib
mod = importlib.import_module('.config', 'praisonaiagents')
cache['config'] = mod
return mod
if name == 'memory':
import importlib
return importlib.import_module('.memory', 'praisonaiagents')
mod = importlib.import_module('.memory', 'praisonaiagents')
cache['memory'] = mod
return mod
if name == 'workflows':
import importlib
return importlib.import_module('.workflows', 'praisonaiagents')
mod = importlib.import_module('.workflows', 'praisonaiagents')
cache['workflows'] = mod
return mod
raise AttributeError(f"Not handled by custom_handler: {name}")
@@ -529,12 +538,49 @@ def _custom_handler(name, cache):
# Override them here to return the function instead of the module.
# ============================================================================
# Override 'embedding' to return the function, not the subpackage
from .embedding.embed import embedding as _embedding_func
embedding = _embedding_func
# Override 'embedding' and 'embeddings' at module level to prevent subpackage import
# These need to be set after _LAZY_IMPORTS is defined but before __getattr__ is created
def _get_embedding_func():
"""Lazy getter for embedding function."""
# Import with alias to avoid overwriting the module proxy
from .embedding.embed import embedding as _embedding_func
return _embedding_func
# Also provide embeddings alias
embeddings = _embedding_func
# Create lazy properties that override the submodule
class _EmbeddingProxy:
"""Proxy object that loads embedding function on first access."""
def __init__(self):
self._func = None
def _load(self):
"""Load the actual embedding function if not already loaded."""
if self._func is None:
self._func = _get_embedding_func()
return self._func
def __call__(self, *args, **kwargs):
return self._load()(*args, **kwargs)
def __getattr__(self, name):
return getattr(self._load(), name)
@property
def __wrapped__(self):
"""Support for inspect.signature() and functools.wraps."""
return self._load()
@property
def __signature__(self):
"""Support for inspect.signature()."""
import inspect
return inspect.signature(self._load())
def __repr__(self):
return f"<lazy proxy for {self._load()!r}>"
# Override the submodule with our function proxy
embedding = _EmbeddingProxy()
embeddings = embedding # embeddings is an alias
# Create the __getattr__ function using centralized utility
@@ -542,7 +588,7 @@ __getattr__ = create_lazy_getattr_with_fallback(
mapping=_LAZY_IMPORTS,
module_name=__name__,
cache=_lazy_cache,
fallback_modules=['tools', 'memory', 'config', 'workflows'],
fallback_modules=[], # Note: 'embedding' excluded to avoid conflict with embedding() function
custom_handler=_custom_handler
)
@@ -13,6 +13,18 @@ def __getattr__(name):
from .agent import Agent
_lazy_cache[name] = Agent
return Agent
if name == 'BudgetExceededError':
from .agent import BudgetExceededError
_lazy_cache[name] = BudgetExceededError
return BudgetExceededError
if name == 'Heartbeat':
from .heartbeat import Heartbeat
_lazy_cache[name] = Heartbeat
return Heartbeat
if name == 'HeartbeatConfig':
from .heartbeat import HeartbeatConfig
_lazy_cache[name] = HeartbeatConfig
return HeartbeatConfig
# Specialized agents - lazy loaded (import rich)
if name == 'ImageAgent':
@@ -145,6 +157,9 @@ def __getattr__(name):
__all__ = [
'Agent',
'BudgetExceededError',
'Heartbeat',
'HeartbeatConfig',
'ImageAgent',
'VideoAgent',
'VideoConfig',
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,138 @@
"""Cost persistence — saves agent cost_summary to disk after runs.
Writes JSON files to ``~/.praisonai/costs/`` for CLI dashboard consumption.
Zero overhead when not used (this module is never imported unless explicitly called).
Usage::
from praisonaiagents.agent.cost_persistence import save_cost_report, load_cost_report
save_cost_report(agent, session_name="my_run")
report = load_cost_report("my_run")
"""
import json
import logging
import os
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
COST_DIR = Path.home() / ".praisonai" / "costs"
def save_cost_report(
agent,
session_name: Optional[str] = None,
) -> str:
"""Persist an agent's cost_summary to disk.
Args:
agent: Agent instance with cost_summary property.
session_name: Optional session identifier. Auto-generated if None.
Returns:
Path to the saved JSON file.
"""
COST_DIR.mkdir(parents=True, exist_ok=True)
name = session_name or f"{agent.name}_{int(time.time())}"
safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in name)
summary = getattr(agent, "cost_summary", None)
if summary is None:
summary = {
"tokens_in": 0,
"tokens_out": 0,
"cost": 0.0,
"llm_calls": 0,
}
report = {
"session": name,
"agent_name": getattr(agent, "name", "unknown"),
"timestamp": time.time(),
"iso_time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
**summary,
}
filepath = COST_DIR / f"{safe_name}.json"
with open(filepath, "w") as f:
json.dump(report, f, indent=2)
logger.info(f"[costs] Saved cost report to {filepath}")
return str(filepath)
def load_cost_report(session_name: str) -> Optional[Dict[str, Any]]:
"""Load a cost report by session name.
Args:
session_name: Session identifier.
Returns:
Parsed JSON dict, or None if not found.
"""
safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in session_name)
filepath = COST_DIR / f"{safe_name}.json"
if filepath.exists():
with open(filepath) as f:
return json.load(f)
return None
def list_cost_reports(limit: int = 20) -> List[Dict[str, Any]]:
"""List recent cost reports, sorted by timestamp (newest first).
Args:
limit: Max number of reports to return.
Returns:
List of parsed JSON report dicts.
"""
if not COST_DIR.exists():
return []
reports = []
for fpath in sorted(COST_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True):
try:
with open(fpath) as f:
reports.append(json.load(f))
except (json.JSONDecodeError, OSError):
continue
if len(reports) >= limit:
break
return reports
def format_cost_table(reports: List[Dict[str, Any]], as_json: bool = False) -> str:
"""Format cost reports for terminal display.
Args:
reports: List of report dicts from list_cost_reports().
as_json: If True, returns JSON string instead of table.
Returns:
Formatted string for terminal output.
"""
if as_json:
return json.dumps(reports, indent=2)
if not reports:
return "No cost reports found."
lines = []
lines.append(f"{'Session':<30} {'Agent':<20} {'Cost ($)':<12} {'Tokens In':<12} {'Tokens Out':<12} {'LLM Calls':<10}")
lines.append("-" * 96)
for r in reports:
lines.append(
f"{r.get('session', '?'):<30} "
f"{r.get('agent_name', '?'):<20} "
f"${r.get('cost', 0):<11.4f} "
f"{r.get('tokens_in', 0):<12} "
f"{r.get('tokens_out', 0):<12} "
f"{r.get('llm_calls', 0):<10}"
)
return "\n".join(lines)
@@ -0,0 +1,177 @@
"""Heartbeat — runs an agent on a schedule, delivers results via callback.
Standalone wrapper class. Does NOT modify the Agent class.
Usage::
from praisonaiagents import Agent, Heartbeat
agent = Agent(instructions="Check server status")
hb = Heartbeat(agent, schedule="every 30m", prompt="Report status")
hb.start() # blocking loop
# hb.start(blocking=False) # background thread
"""
import asyncio
import logging
import threading
import time
from dataclasses import dataclass, field
from typing import Any, Callable, Optional, Union
logger = logging.getLogger(__name__)
@dataclass
class HeartbeatConfig:
"""Configuration for Heartbeat scheduler.
Attributes:
schedule: Human-friendly schedule expression. Supports:
- Keywords: "hourly", "daily", "weekly"
- Intervals: "every 30m", "every 6h", "*/30m", "*/10s"
- Cron: "cron:0 7 * * *"
- One-shot: "at:2026-03-01T09:00:00"
prompt: Override prompt sent to agent each tick. None = use agent's default.
on_result: Callback receiving (result_text: str). Default: log to stdout.
on_error: "retry" | "skip" | callable(error). Default: "retry".
max_retries: Max consecutive retries before skipping. Default: 3.
"""
schedule: str = "hourly"
prompt: Optional[str] = None
on_result: Optional[Callable] = None
on_error: Union[str, Callable] = "retry"
max_retries: int = 3
class Heartbeat:
"""Standalone heartbeat coordinator.
Runs an Agent on a schedule and delivers results via callback.
Does NOT add any params to the Agent class.
Usage::
from praisonaiagents import Agent, Heartbeat
agent = Agent(instructions="Monitor server health")
hb = Heartbeat(agent, schedule="hourly")
hb.start()
"""
def __init__(
self,
agent,
schedule: str = "hourly",
prompt: Optional[str] = None,
on_result: Optional[Callable] = None,
on_error: Union[str, Callable] = "retry",
max_retries: int = 3,
):
self.agent = agent
self.config = HeartbeatConfig(
schedule=schedule,
prompt=prompt,
on_result=on_result,
on_error=on_error,
max_retries=max_retries,
)
self._running = False
self._thread: Optional[threading.Thread] = None
self._interval_seconds = self._resolve_interval(schedule)
def start(self, blocking: bool = True) -> None:
"""Start the heartbeat loop.
Args:
blocking: If True, blocks the calling thread. If False,
runs in a background daemon thread.
"""
self._running = True
if blocking:
self._loop()
else:
self._thread = threading.Thread(target=self._loop, daemon=True)
self._thread.start()
def stop(self) -> None:
"""Stop the heartbeat loop."""
self._running = False
@property
def is_running(self) -> bool:
return self._running
# ── internals ────────────────────────────────────────────────────
def _loop(self) -> None:
"""Main polling loop."""
retries = 0
while self._running:
try:
result = self._tick()
retries = 0 # Reset on success
self._deliver(result)
except Exception as e:
retries += 1
logger.error(f"[heartbeat] {self.agent.name}: error on tick #{retries}: {e}")
if callable(self.config.on_error):
self.config.on_error(e)
elif self.config.on_error == "skip":
logger.warning(f"[heartbeat] Skipping error: {e}")
else: # "retry"
if retries >= self.config.max_retries:
logger.error(
f"[heartbeat] {self.agent.name}: max retries "
f"({self.config.max_retries}) reached, skipping."
)
retries = 0
time.sleep(self._interval_seconds)
def _tick(self) -> str:
"""Execute one heartbeat tick — run the agent and return result."""
prompt = self.config.prompt or "Run your scheduled check."
result = self.agent.start(prompt)
return str(result) if result else ""
def _deliver(self, result: str) -> None:
"""Deliver result via callback or log."""
if self.config.on_result:
self.config.on_result(result)
else:
logger.info(f"[heartbeat] {self.agent.name}: {result[:200]}")
@staticmethod
def _resolve_interval(schedule: str) -> float:
"""Convert schedule string to interval in seconds.
Uses the scheduler parser if available, otherwise falls back to
simple keyword parsing.
"""
try:
from ..scheduler.parser import parse_schedule
sched = parse_schedule(schedule)
if sched.every_seconds:
return float(sched.every_seconds)
except (ImportError, Exception):
pass
# Fallback keyword resolution
keywords = {
"hourly": 3600,
"daily": 86400,
"weekly": 604800,
}
lower = schedule.lower().strip()
if lower in keywords:
return float(keywords[lower])
# Try parsing "every Xm/h/s" patterns
import re
match = re.match(r"(?:every\s+)?(\d+)\s*(s|m|h)", lower)
if match:
val, unit = int(match.group(1)), match.group(2)
multipliers = {"s": 1, "m": 60, "h": 3600}
return float(val * multipliers.get(unit, 60))
return 3600.0 # Default to hourly
@@ -10,9 +10,6 @@ import logging
import warnings
# Filter out Pydantic warning about fields
warnings.filterwarnings("ignore", "Valid config keys have changed in V2", UserWarning)
from rich.console import Console
from rich.panel import Panel
from rich.progress import Progress, SpinnerColumn, TextColumn
class ImageGenerationConfig(BaseModel):
"""Configuration for image generation settings."""
style: str = Field(default="natural", description="Style of the generated image")
@@ -155,6 +152,7 @@ class ImageAgent(Agent):
# Gemini provider doesn't support response_format parameter
# Apply this filter if provider is explicitly 'gemini' or as fallback for gemini models
config.pop('response_format', None)
from rich.progress import Progress, SpinnerColumn, TextColumn
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
@@ -0,0 +1,399 @@
"""
Tool Loop Detection for PraisonAI Agents.
Detects when an agent is stuck calling the same tool repeatedly with
identical arguments and no progress.
Inspired by OpenClaw's tool-loop-detection.ts (624 lines, 4 detectors).
Adapted to Python with PraisonAI's protocol-driven, zero-dep philosophy.
Zero Performance Impact:
- Disabled by default (enabled=False)
- Uses stdlib only: hashlib, json
- History is per-agent-instance (no global mutable state)
Usage:
from praisonaiagents.agent.loop_detection import (
LoopDetectionConfig, detect_tool_loop, record_tool_call, record_tool_outcome
)
"""
from __future__ import annotations
import hashlib
import json
import logging
import time
from dataclasses import dataclass, field
from typing import Any, Dict, List, Literal, Optional, TypedDict
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
@dataclass
class LoopDetectionConfig:
"""
Configuration for tool loop detection.
Attributes:
enabled: Whether loop detection is active. Default False (opt-in).
history_size: Sliding window size for tool call history.
warn_threshold: Number of identical (tool, args) calls before warning.
critical_threshold: Number before critical/circuit-break. Must > warn.
detectors: Which detectors are active.
"""
enabled: bool = False
history_size: int = 30
warn_threshold: int = 10
critical_threshold: int = 20
detectors: Dict[str, bool] = field(default_factory=lambda: {
"generic_repeat": True,
"poll_no_progress": True,
"ping_pong": True,
})
def __post_init__(self) -> None:
# Auto-correct: critical must always be > warn
if self.critical_threshold <= self.warn_threshold:
self.critical_threshold = self.warn_threshold + 1
# ---------------------------------------------------------------------------
# Result type
# ---------------------------------------------------------------------------
class LoopDetectionResult(TypedDict, total=False):
stuck: bool
level: Optional[Literal["warning", "critical"]]
detector: Optional[str]
message: Optional[str]
count: int
_NOT_STUCK: LoopDetectionResult = {"stuck": False}
# ---------------------------------------------------------------------------
# History record
# ---------------------------------------------------------------------------
class ToolCallRecord(TypedDict, total=False):
tool_name: str
args_hash: str
result_hash: Optional[str]
timestamp: float
# ---------------------------------------------------------------------------
# Hashing helpers
# ---------------------------------------------------------------------------
def _stable_json(value: Any) -> str:
"""Deterministic JSON serialization (sorted keys, str fallback)."""
if value is None or isinstance(value, (bool, int, float, str)):
return json.dumps(value)
if isinstance(value, (list, tuple)):
return "[" + ",".join(_stable_json(v) for v in value) + "]"
if isinstance(value, dict):
keys = sorted(value.keys())
parts = [json.dumps(k) + ":" + _stable_json(value[k]) for k in keys]
return "{" + ",".join(parts) + "}"
# Fallback for non-serializable objects
try:
return json.dumps(str(value))
except Exception:
return '"<unserializable>"'
def _sha256_hex(text: str, prefix_len: int = 16) -> str:
return hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest()[:prefix_len]
def hash_tool_call(tool_name: str, args: Any) -> str:
"""
Compute a stable hash for a (tool_name, args) pair.
Args:
tool_name: Name of the tool being called
args: Arguments dict or any serializable value
Returns:
16-character hex string
"""
stable = _stable_json({"t": tool_name, "a": args})
return _sha256_hex(stable)
def _hash_result(result: Any) -> Optional[str]:
"""Hash a tool result for no-progress detection."""
if result is None:
return None
try:
stable = _stable_json(result)
return _sha256_hex(stable)
except Exception:
return None
# ---------------------------------------------------------------------------
# Record tool call / outcome
# ---------------------------------------------------------------------------
def record_tool_call(
history: List[ToolCallRecord],
tool_name: str,
args: Any,
config: Optional[LoopDetectionConfig] = None,
) -> None:
"""
Append a tool call to the history (before execution).
Args:
history: Mutable list of ToolCallRecord
tool_name: Tool being called
args: Tool arguments
config: Loop detection config (for history_size)
"""
max_size = config.history_size if config else 30
history.append(ToolCallRecord(
tool_name=tool_name,
args_hash=hash_tool_call(tool_name, args),
result_hash=None,
timestamp=time.time(),
))
# Trim to sliding window
if len(history) > max_size:
del history[: len(history) - max_size]
def record_tool_outcome(
history: List[ToolCallRecord],
tool_name: str,
args: Any,
result: Any,
config: Optional[LoopDetectionConfig] = None,
) -> None:
"""
Update the most recent matching record with result hash (after execution).
Args:
history: Mutable list of ToolCallRecord
tool_name: Tool that was called
args: Arguments that were passed
result: The return value of the tool
config: Loop detection config
"""
args_hash = hash_tool_call(tool_name, args)
result_hash = _hash_result(result)
if result_hash is None:
return
# Find the latest matching record without a result_hash
for i in range(len(history) - 1, -1, -1):
rec = history[i]
if rec["tool_name"] == tool_name and rec["args_hash"] == args_hash and rec.get("result_hash") is None:
history[i] = ToolCallRecord(
tool_name=rec["tool_name"],
args_hash=rec["args_hash"],
result_hash=result_hash,
timestamp=rec["timestamp"],
)
return
# ---------------------------------------------------------------------------
# Detectors
# ---------------------------------------------------------------------------
def _known_poll_tool(tool_name: str) -> bool:
"""Heuristic: tools whose names suggest polling/waiting."""
_poll_keywords = ("status", "poll", "check", "wait", "ping", "health", "command_status")
name_lower = tool_name.lower()
return any(kw in name_lower for kw in _poll_keywords)
def _count_generic_repeat(
history: List[ToolCallRecord],
args_hash: str,
tool_name: str,
) -> int:
"""Count how many times this exact (tool, args) appears in history."""
return sum(
1 for rec in history
if rec["tool_name"] == tool_name and rec["args_hash"] == args_hash
)
def _no_progress_streak(
history: List[ToolCallRecord],
args_hash: str,
tool_name: str,
) -> int:
"""
Count consecutive tail records with identical (args_hash, result_hash).
Returns 0 if any record has no result_hash or a different result_hash.
"""
streak = 0
first_result_hash: Optional[str] = None
for rec in reversed(history):
if rec["tool_name"] != tool_name or rec["args_hash"] != args_hash:
continue
rh = rec.get("result_hash")
if rh is None:
continue
if first_result_hash is None:
first_result_hash = rh
streak = 1
elif rh == first_result_hash:
streak += 1
else:
break # result changed — no longer a streak
return streak
def _ping_pong_streak(
history: List[ToolCallRecord],
current_hash: str,
) -> int:
"""
Detect alternating pattern: A, B, A, B, ...
Returns count of consecutive alternating tail entries, 0 if not ping-pong.
"""
if len(history) < 2:
return 0
last = history[-1]
# Find the "other" hash in history (most recent != last)
other_hash: Optional[str] = None
for rec in reversed(history[:-1]):
if rec["args_hash"] != last["args_hash"]:
other_hash = rec["args_hash"]
break
if other_hash is None:
return 0
# Count tail alternation
count = 0
for i, rec in enumerate(reversed(history)):
expected = last["args_hash"] if i % 2 == 0 else other_hash
if rec["args_hash"] != expected:
break
count += 1
# Current call must continue the ping-pong pattern
if current_hash != (other_hash if count % 2 == 1 else last["args_hash"]):
return 0
return count + 1 # include current call
# ---------------------------------------------------------------------------
# Main detection function
# ---------------------------------------------------------------------------
def detect_tool_loop(
history: List[ToolCallRecord],
tool_name: str,
args: Any,
config: Optional[LoopDetectionConfig] = None,
) -> LoopDetectionResult:
"""
Detect if agent is stuck in a tool call loop.
Args:
history: Mutable sliding window of ToolCallRecord
tool_name: Tool being called now
args: Arguments for this call
config: Loop detection configuration
Returns:
LoopDetectionResult with stuck=False (safe) or stuck=True (warning/critical)
"""
if config is None or not config.enabled:
return _NOT_STUCK
args_hash = hash_tool_call(tool_name, args)
detectors = config.detectors or {}
warn = config.warn_threshold
critical = config.critical_threshold
# --- poll_no_progress detector ---
if detectors.get("poll_no_progress", True) and _known_poll_tool(tool_name):
streak = _no_progress_streak(history, args_hash, tool_name)
if streak >= critical:
msg = (
f"CRITICAL: '{tool_name}' called {streak} times with identical args "
f"and identical result. Detected stuck polling loop. Stop and report failure."
)
logger.error(f"[loop_detection] {msg}")
return LoopDetectionResult(
stuck=True, level="critical",
detector="poll_no_progress", message=msg, count=streak,
)
if streak >= warn:
msg = (
f"WARNING: '{tool_name}' has returned identical results {streak} times. "
f"This looks like a stuck poll. Increase wait time or report failure."
)
logger.warning(f"[loop_detection] {msg}")
return LoopDetectionResult(
stuck=True, level="warning",
detector="poll_no_progress", message=msg, count=streak,
)
# --- ping_pong detector ---
if detectors.get("ping_pong", True):
pp_count = _ping_pong_streak(history, args_hash)
if pp_count >= critical:
msg = (
f"CRITICAL: Ping-pong loop detected ({pp_count} alternating calls). "
f"Agent is oscillating between two tool states. Stop and report failure."
)
logger.error(f"[loop_detection] {msg}")
return LoopDetectionResult(
stuck=True, level="critical",
detector="ping_pong", message=msg, count=pp_count,
)
if pp_count >= warn:
msg = (
f"WARNING: Alternating tool pattern detected ({pp_count} calls). "
f"Stop ping-pong and try a different approach."
)
logger.warning(f"[loop_detection] {msg}")
return LoopDetectionResult(
stuck=True, level="warning",
detector="ping_pong", message=msg, count=pp_count,
)
# --- generic_repeat detector ---
if detectors.get("generic_repeat", True) and not _known_poll_tool(tool_name):
count = _count_generic_repeat(history, args_hash, tool_name)
if count >= critical:
msg = (
f"CRITICAL: '{tool_name}' called {count} times with identical args. "
f"Agent is stuck. Execution blocked."
)
logger.error(f"[loop_detection] {msg}")
return LoopDetectionResult(
stuck=True, level="critical",
detector="generic_repeat", message=msg, count=count,
)
if count >= warn:
msg = (
f"WARNING: '{tool_name}' called {count} times with identical args. "
f"If not making progress, stop and report failure."
)
logger.warning(f"[loop_detection] {msg}")
return LoopDetectionResult(
stuck=True, level="warning",
detector="generic_repeat", message=msg, count=count,
)
return _NOT_STUCK
@@ -16,8 +16,6 @@ import asyncio
import warnings
from dataclasses import dataclass, field
from typing import Optional, Any, Dict, Union, List, Generator
from rich.console import Console
from rich.progress import Progress, SpinnerColumn, TextColumn
# Filter out Pydantic warning about fields
warnings.filterwarnings("ignore", "Valid config keys have changed in V2", UserWarning)
@@ -212,9 +210,10 @@ class VideoAgent:
return VideoConfig()
@property
def console(self) -> Console:
def console(self):
"""Lazily initialize Rich Console."""
if self._console is None:
from rich.console import Console
self._console = Console()
return self._console
@@ -342,6 +341,7 @@ class VideoAgent:
params.pop("poll_interval", None)
params.pop("max_wait_time", None)
from rich.progress import Progress, SpinnerColumn, TextColumn
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
@@ -491,7 +491,8 @@ class VideoAgent:
max_wait_time = max_wait_time or self._video_config.max_wait_time
start_time = time.time()
from rich.progress import Progress, SpinnerColumn, TextColumn
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
@@ -3,7 +3,6 @@ import time
import json
import logging
from typing import Any, Dict, Optional, List
from rich.console import Console
from ..main import display_error, TaskOutput
from ..agent.agent import Agent
from ..task.task import Task
@@ -1287,6 +1286,7 @@ Context:
# ─────────────────────────────────────────────────────────────
if show_verbose and is_tty:
from rich.panel import Panel
from rich.console import Console
console = Console()
import time as time_module
@@ -237,18 +237,8 @@ class AgentApproval:
def request_approval_sync(self, request: ApprovalRequest) -> ApprovalDecision:
"""Synchronous wrapper."""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop and loop.is_running():
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
future = pool.submit(asyncio.run, self.request_approval(request))
return future.result(timeout=60)
else:
return asyncio.run(self.request_approval(request))
from .utils import run_coroutine_safely
return run_coroutine_safely(self.request_approval(request), timeout=60)
class CallbackBackend:
@@ -45,6 +45,19 @@ DEFAULT_DANGEROUS_TOOLS: Dict[str, str] = {
"scrape_page": "medium",
}
# Permission presets — resolved to deny frozensets at Agent.__init__ time.
# Usage: Agent(approval="safe")
PERMISSION_PRESETS = {
# "safe" — blocks all dangerous tools (file writes, shell exec, etc.)
"safe": frozenset(DEFAULT_DANGEROUS_TOOLS.keys()),
# "read_only" — blocks dangerous tools + write operations
"read_only": frozenset(DEFAULT_DANGEROUS_TOOLS.keys()) | frozenset({
"write_file", "copy_file", "move_file",
}),
# "full" — no restrictions
"full": frozenset(),
}
class ApprovalRegistry:
"""Per-agent approval configuration.
@@ -197,15 +210,12 @@ class ApprovalRegistry:
if hasattr(backend, "request_approval_sync"):
decision = backend.request_approval_sync(request)
else:
# Fallback: run async method
try:
decision = asyncio.run(backend.request_approval(request))
except RuntimeError:
# Already in an event loop — fall back to thread
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
future = pool.submit(asyncio.run, backend.request_approval(request))
decision = future.result(timeout=self.timeout)
# Use shared utility for consistent async-to-sync bridging
from .utils import run_coroutine_safely
decision = run_coroutine_safely(
backend.request_approval(request),
timeout=self.timeout
)
if decision.approved:
self.mark_approved(tool_name)
@@ -0,0 +1,67 @@
"""
Utility functions for approval handling.
Provides reusable async-to-sync bridging logic to prevent code duplication
across the approval system.
"""
import asyncio
import concurrent.futures
from typing import Any, Awaitable, Callable, Optional, TypeVar
T = TypeVar('T')
def run_coroutine_safely(
coro: Awaitable[T],
timeout: Optional[float] = None
) -> T:
"""
Run a coroutine safely, handling both sync and async contexts.
This function detects if an event loop is already running and uses a
ThreadPoolExecutor as a fallback to avoid RuntimeError. It respects
timeout semantics consistently across both code paths.
Args:
coro: The coroutine to execute
timeout: Timeout in seconds. None means indefinite wait.
Returns:
The result of the coroutine
Raises:
TimeoutError: If the operation times out
Any exception raised by the coroutine
"""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop and loop.is_running():
# We're in an async context - use thread pool to avoid RuntimeError
pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
# Wrap the coroutine with timeout handling inside the thread
def run_with_timeout():
if timeout is not None and timeout > 0:
return asyncio.run(asyncio.wait_for(coro, timeout=timeout))
else:
return asyncio.run(coro)
future = pool.submit(run_with_timeout)
try:
# Don't use timeout on Future.result() since we handle timeout
# inside the coroutine via asyncio.wait_for
result = future.result(timeout=None if timeout is None or timeout == 0 else timeout)
return result
finally:
# Properly shut down the executor without waiting for threads
pool.shutdown(wait=False, cancel_futures=True)
else:
# No running event loop - use asyncio.run directly
if timeout is not None and timeout > 0:
return asyncio.run(asyncio.wait_for(coro, timeout=timeout))
else:
return asyncio.run(coro)
@@ -679,6 +679,12 @@ class ExecutionConfig:
code_mode="safe",
rate_limiter=my_limiter,
))
# With token budget guard
Agent(execution=ExecutionConfig(
max_budget=0.50, # Hard USD limit per agent run
on_budget_exceeded="stop", # "stop" | "warn" | callable
))
"""
# Iteration limits
max_iter: int = 20
@@ -698,7 +704,23 @@ class ExecutionConfig:
# Rate limiter instance (consolidated from standalone rate_limiter param)
rate_limiter: Optional[Any] = None
# Context compaction: automatically compact chat_history when approaching token limit.
# Opt-in only (safe default: False). Uses existing praisonaiagents.compaction module.
# Usage: Agent(execution=ExecutionConfig(context_compaction=True, max_context_tokens=8000))
context_compaction: bool = False
# Token limit before compaction triggers. None = auto-detect from model metadata.
max_context_tokens: Optional[int] = None
# Token budget guard — hard dollar limit per agent run.
# None = disabled (zero overhead). Float = max USD spend.
max_budget: Optional[float] = None
# Action when budget exceeded: "stop" (default) raises BudgetExceededError,
# "warn" logs warning but continues, or callable(total_cost, max_budget).
on_budget_exceeded: Any = "stop"
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
@@ -708,6 +730,9 @@ class ExecutionConfig:
"max_retry_limit": self.max_retry_limit,
"code_execution": self.code_execution,
"code_mode": self.code_mode,
"context_compaction": self.context_compaction,
"max_context_tokens": self.max_context_tokens,
"max_budget": self.max_budget,
}
@@ -3,7 +3,8 @@ import logging
from datetime import datetime
from .chunking import Chunking
from functools import cached_property
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn
logger = logging.getLogger(__name__)
@@ -786,6 +787,7 @@ class Knowledge:
memories = [self.normalize_content(input_path)]
# Create progress display
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn
progress = Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
@@ -71,7 +71,9 @@ except ImportError:
# Logging is already configured in _logging.py via __init__.py
# TODO: Include in-build tool calling in LLM class
# TODO: Restructure so that duplicate calls are not made (Sync with agent.py)
# NOTE: The custom-LLM path (Agent.chat → get_response) and OpenAI path
# (Agent.chat → _chat_completion) are separate code paths, not duplicate
# API calls per request. This is a DRY/maintenance concern, not a billing issue.
class LLMContextLengthExceededException(Exception):
"""Raised when LLM context length is exceeded"""
def __init__(self, message: str):
+49 -34
View File
@@ -4,14 +4,17 @@ import json
import logging
from typing import List, Optional, Dict, Any, Union, Literal, Type
from pydantic import BaseModel, ConfigDict
from rich import print
from rich.console import Console
from rich.panel import Panel
from rich.text import Text
from rich.markdown import Markdown
from rich.live import Live
import asyncio
def _rich():
"""Lazy-load Rich display classes (cached by sys.modules after first call)."""
from rich.console import Console
from rich.panel import Panel
from rich.text import Text
from rich.markdown import Markdown
return Console, Panel, Text, Markdown
# Import token metrics if available
try:
from .telemetry.token_collector import TokenMetrics
@@ -239,16 +242,17 @@ def display_interaction(message, response, markdown=True, generation_time=None,
Args:
metrics: Optional dict with token_in, token_out, cost, model for footer display
"""
Console, Panel, Text, Markdown = _rich()
if console is None:
console = Console()
if isinstance(message, list):
text_content = next((item["text"] for item in message if item["type"] == "text"), "")
message = text_content
message = _clean_display_content(str(message))
response = _clean_display_content(str(response))
# Skip display if response is empty (common with Gemini tool calls)
if not response or not response.strip():
return
@@ -268,12 +272,12 @@ def display_interaction(message, response, markdown=True, generation_time=None,
task_id=task_id,
metrics=metrics
)
# Build response title with time
response_title = "Response"
if generation_time:
response_title = f"Response ({generation_time:.1f}s)"
# Build response content with optional metrics footer
response_content = response
if metrics and isinstance(metrics, dict):
@@ -282,7 +286,7 @@ def display_interaction(message, response, markdown=True, generation_time=None,
tokens_out = metrics.get('tokens_out', 0)
cost = metrics.get('cost', 0)
model = metrics.get('model', '')
metrics_line = f"\n\n─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─\n📊 {tokens_in} tokens in · {tokens_out} out"
if cost > 0:
metrics_line += f" · ${cost:.4f}"
@@ -303,25 +307,27 @@ def display_interaction(message, response, markdown=True, generation_time=None,
def display_self_reflection(message: str, console=None):
if not message or not message.strip():
return
Console, Panel, Text, _Md = _rich()
if console is None:
console = Console()
message = _clean_display_content(str(message))
# Execute synchronous callbacks
execute_sync_callback('self_reflection', message=message)
console.print(Panel.fit(Text(message, style="bold yellow"), title="Self Reflection", border_style="magenta"))
def display_instruction(message: str, console=None, agent_name: str = None, agent_role: str = None, agent_tools: List[str] = None):
if not message or not message.strip():
return
Console, Panel, Text, _Md = _rich()
if console is None:
console = Console()
message = _clean_display_content(str(message))
# Execute synchronous callbacks
execute_sync_callback('instruction', message=message, agent_name=agent_name, agent_role=agent_role, agent_tools=agent_tools)
# Display agent info if available
if agent_name:
agent_info = f"[bold #FF9B9B]👤 Agent:[/] [#FFE5E5]{agent_name}[/]"
@@ -331,7 +337,7 @@ def display_instruction(message: str, console=None, agent_name: str = None, agen
tools_str = ", ".join(f"[italic #B4D4FF]{tool}[/]" for tool in agent_tools)
agent_info += f"\n[bold #86A789]Tools:[/] {tools_str}"
console.print(Panel(agent_info, border_style="#D2E3C8", title="[bold]Agent Info[/]", title_align="left", padding=(1, 2)))
# Only print if log level is DEBUG
if logging.getLogger().getEffectiveLevel() == logging.DEBUG:
console.print(Panel.fit(Text(message, style="bold blue"), title="Instruction", border_style="cyan"))
@@ -400,17 +406,18 @@ def display_tool_call(message: str, console=None, tool_name: str = None, tool_in
def display_error(message: str, console=None):
if not message or not message.strip():
return
Console, Panel, Text, _Md = _rich()
if console is None:
console = Console()
message = _clean_display_content(str(message))
# Execute synchronous callbacks
execute_sync_callback('error', message=message)
# Use semantic error color
console.print(Panel.fit(
Text(message, style=f"bold {PRAISON_COLORS['error_text']}"),
title="⚠ Error",
Text(message, style=f"bold {PRAISON_COLORS['error_text']}"),
title="⚠ Error",
border_style=PRAISON_COLORS["error"]
))
error_logs.append(message)
@@ -419,32 +426,33 @@ def display_generating(content: str = "", start_time: Optional[float] = None):
if not content or not str(content).strip():
logging.debug("Empty content in display_generating, returning early")
return None
elapsed_str = ""
if start_time is not None:
elapsed = time.time() - start_time
elapsed_str = f" {elapsed:.1f}s"
content = _clean_display_content(str(content))
# Execute synchronous callbacks
execute_sync_callback('generating', content=content, elapsed_time=elapsed_str.strip() if elapsed_str else None)
# Use semantic response color
_Console, Panel, _Text, Markdown = _rich()
return Panel(Markdown(content), title=f"Generating...{elapsed_str}", border_style=PRAISON_COLORS["response"])
def display_reasoning_steps(steps: List[str], console=None):
"""Display reasoning steps with unique numbered circles.
Uses ①②③ numbered circles for a distinctive, scannable format
that shows the agent's thought process.
Args:
steps: List of reasoning step descriptions
console: Rich console for output
"""
if not steps:
return
Console, Panel, Text, _Md = _rich()
if console is None:
console = Console()
@@ -470,18 +478,19 @@ def display_reasoning_steps(steps: List[str], console=None):
def display_working_status(phase: int = 0, status_text: str = None, console=None):
"""Display animated working status with pulsing dots.
Shows a unique "Working ●○○" indicator with phase-specific status.
This is PraisonAI's distinctive approach to showing processing status.
Args:
phase: Current animation phase (0-3)
status_text: Optional status description
console: Rich console for output
Returns:
Panel object for use with Rich.Live
"""
Console, Panel, Text, _Md = _rich()
if console is None:
console = Console()
@@ -501,6 +510,7 @@ def display_working_status(phase: int = 0, status_text: str = None, console=None
# Async versions with 'a' prefix
async def adisplay_interaction(message, response, markdown=True, generation_time=None, console=None, agent_name=None, agent_role=None, agent_tools=None, task_name=None, task_description=None, task_id=None):
"""Async version of display_interaction."""
Console, Panel, Text, Markdown = _rich()
if console is None:
console = Console()
@@ -541,6 +551,7 @@ async def adisplay_self_reflection(message: str, console=None):
"""Async version of display_self_reflection."""
if not message or not message.strip():
return
Console, Panel, Text, _Md = _rich()
if console is None:
console = Console()
message = _clean_display_content(str(message))
@@ -554,6 +565,7 @@ async def adisplay_instruction(message: str, console=None, agent_name: str = Non
"""Async version of display_instruction."""
if not message or not message.strip():
return
Console, Panel, Text, _Md = _rich()
if console is None:
console = Console()
message = _clean_display_content(str(message))
@@ -581,6 +593,7 @@ async def adisplay_tool_call(message: str, console=None):
if not message or not message.strip():
logging.debug("Empty message in adisplay_tool_call, returning early")
return
Console, Panel, Text, _Md = _rich()
if console is None:
console = Console()
message = _clean_display_content(str(message))
@@ -595,6 +608,7 @@ async def adisplay_error(message: str, console=None):
"""Async version of display_error."""
if not message or not message.strip():
return
Console, Panel, Text, _Md = _rich()
if console is None:
console = Console()
message = _clean_display_content(str(message))
@@ -610,17 +624,18 @@ async def adisplay_generating(content: str = "", start_time: Optional[float] = N
if not content or not str(content).strip():
logging.debug("Empty content in adisplay_generating, returning early")
return None
elapsed_str = ""
if start_time is not None:
elapsed = time.time() - start_time
elapsed_str = f" {elapsed:.1f}s"
content = _clean_display_content(str(content))
# Execute callbacks
await execute_callback('generating', content=content, elapsed_time=elapsed_str.strip() if elapsed_str else None)
_Console, Panel, _Text, Markdown = _rich()
return Panel(Markdown(content), title=f"Generating...{elapsed_str}", border_style="green")
def clean_triple_backticks(text: str) -> str:
@@ -21,6 +21,7 @@ import time
import sys
import hashlib
import logging
import threading
from pathlib import Path
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
@@ -160,7 +161,8 @@ class FileMemory:
# Load or create config
self.config = self._load_config(config)
# Initialize memory stores
# Initialize thread-safe memory stores with lock for in-memory data protection
self._lock = threading.RLock() # Reentrant lock for nested operations
self._short_term: List[MemoryItem] = []
self._long_term: List[MemoryItem] = []
self._entities: Dict[str, EntityItem] = {}
@@ -318,42 +320,45 @@ class FileMemory:
Returns:
The generated memory ID
"""
item = MemoryItem(
id=self._generate_id(content),
content=content,
metadata=metadata or {},
importance=importance
)
self._short_term.append(item)
# Enforce limit (rolling buffer)
limit = self.config["short_term_limit"]
if len(self._short_term) > limit:
# Before removing, check if should promote to long-term
if self.config["auto_promote"]:
self._auto_promote_to_long_term()
with self._lock:
item = MemoryItem(
id=self._generate_id(content),
content=content,
metadata=metadata or {},
importance=importance
)
# Remove oldest items
self._short_term = self._short_term[-limit:]
self._save_short_term()
self._log(f"Added short-term memory: {content[:50]}...")
# Emit trace event
self._emit_memory_event("store", "short_term", len(content), metadata=metadata)
return item.id
self._short_term.append(item)
# Enforce limit (rolling buffer)
limit = self.config["short_term_limit"]
if len(self._short_term) > limit:
# Before removing, check if should promote to long-term
if self.config["auto_promote"]:
self._auto_promote_to_long_term()
# Remove oldest items
self._short_term = self._short_term[-limit:]
self._save_short_term()
self._log(f"Added short-term memory: {content[:50]}...")
# Emit trace event
self._emit_memory_event("store", "short_term", len(content), metadata=metadata)
return item.id
def get_short_term(self, limit: Optional[int] = None) -> List[MemoryItem]:
"""Get short-term memories, most recent first."""
items = list(reversed(self._short_term))
if limit:
items = items[:limit]
return items
with self._lock:
items = list(reversed(self._short_term))
if limit:
items = items[:limit]
return items
def _auto_promote_to_long_term(self):
"""Promote high-importance short-term memories to long-term."""
# Note: This method is called within _lock context from add_short_term
threshold = self.config["importance_threshold"]
promoted = []
@@ -390,36 +395,38 @@ class FileMemory:
Returns:
The generated memory ID
"""
item = MemoryItem(
id=self._generate_id(content),
content=content,
metadata=metadata or {},
importance=importance
)
self._long_term.append(item)
# Enforce limit
limit = self.config["long_term_limit"]
if len(self._long_term) > limit:
# Remove lowest importance items
self._long_term.sort(key=lambda x: x.importance, reverse=True)
self._long_term = self._long_term[:limit]
self._save_long_term()
self._log(f"Added long-term memory: {content[:50]}...")
# Emit trace event
self._emit_memory_event("store", "long_term", len(content), metadata=metadata)
return item.id
with self._lock:
item = MemoryItem(
id=self._generate_id(content),
content=content,
metadata=metadata or {},
importance=importance
)
self._long_term.append(item)
# Enforce limit
limit = self.config["long_term_limit"]
if len(self._long_term) > limit:
# Remove lowest importance items
self._long_term.sort(key=lambda x: x.importance, reverse=True)
self._long_term = self._long_term[:limit]
self._save_long_term()
self._log(f"Added long-term memory: {content[:50]}...")
# Emit trace event
self._emit_memory_event("store", "long_term", len(content), metadata=metadata)
return item.id
def get_long_term(self, limit: Optional[int] = None) -> List[MemoryItem]:
"""Get long-term memories, sorted by importance."""
items = sorted(self._long_term, key=lambda x: x.importance, reverse=True)
if limit:
items = items[:limit]
return items
with self._lock:
items = sorted(self._long_term, key=lambda x: x.importance, reverse=True)
if limit:
items = items[:limit]
return items
# -------------------------------------------------------------------------
# Entity Memory
@@ -444,43 +451,46 @@ class FileMemory:
Returns:
The entity ID
"""
entity_id = self._generate_id(f"{name}:{entity_type}")
# Check if entity exists
existing = self._find_entity_by_name(name, entity_type)
if existing:
# Update existing entity
existing.attributes.update(attributes or {})
if relationships:
existing.relationships.extend(relationships)
existing.updated_at = time.time()
entity_id = existing.id
else:
# Create new entity
entity = EntityItem(
id=entity_id,
name=name,
entity_type=entity_type,
attributes=attributes or {},
relationships=relationships or []
)
self._entities[entity_id] = entity
self._save_entities()
self._log(f"Added/updated entity: {name} ({entity_type})")
return entity_id
with self._lock:
entity_id = self._generate_id(f"{name}:{entity_type}")
# Check if entity exists
existing = self._find_entity_by_name(name, entity_type)
if existing:
# Update existing entity
existing.attributes.update(attributes or {})
if relationships:
existing.relationships.extend(relationships)
existing.updated_at = time.time()
entity_id = existing.id
else:
# Create new entity
entity = EntityItem(
id=entity_id,
name=name,
entity_type=entity_type,
attributes=attributes or {},
relationships=relationships or []
)
self._entities[entity_id] = entity
self._save_entities()
self._log(f"Added/updated entity: {name} ({entity_type})")
return entity_id
def get_entity(self, name: str, entity_type: Optional[str] = None) -> Optional[EntityItem]:
"""Get entity by name and optionally type."""
return self._find_entity_by_name(name, entity_type)
with self._lock:
return self._find_entity_by_name(name, entity_type)
def get_all_entities(self, entity_type: Optional[str] = None) -> List[EntityItem]:
"""Get all entities, optionally filtered by type."""
entities = list(self._entities.values())
if entity_type:
entities = [e for e in entities if e.entity_type == entity_type]
return entities
with self._lock:
entities = list(self._entities.values())
if entity_type:
entities = [e for e in entities if e.entity_type == entity_type]
return entities
def _find_entity_by_name(self, name: str, entity_type: Optional[str] = None) -> Optional[EntityItem]:
"""Find entity by name."""
@@ -631,7 +641,9 @@ class FileMemory:
# Search short-term
if "short_term" in memory_types:
for item in self._short_term:
with self._lock:
short_term_items = list(self._short_term) # Create snapshot
for item in short_term_items:
score = score_match(item.content)
if score > 0:
results.append({
@@ -642,7 +654,9 @@ class FileMemory:
# Search long-term
if "long_term" in memory_types:
for item in self._long_term:
with self._lock:
long_term_items = list(self._long_term) # Create snapshot
for item in long_term_items:
score = score_match(item.content)
if score > 0:
results.append({
@@ -653,7 +667,9 @@ class FileMemory:
# Search entities
if "entity" in memory_types:
for entity in self._entities.values():
with self._lock:
entity_items = list(self._entities.values()) # Create snapshot
for entity in entity_items:
score = max(
score_match(entity.name),
score_match(str(entity.attributes))
@@ -794,21 +810,23 @@ class FileMemory:
source_count: Number of items summarized
metadata: Optional metadata
"""
self._summaries.append({
"id": self._generate_id(summary),
"summary": summary,
"source_type": source_type,
"source_count": source_count,
"metadata": metadata or {},
"created_at": time.time()
})
self._save_summaries()
self._log(f"Added summary for {source_count} {source_type} items")
with self._lock:
self._summaries.append({
"id": self._generate_id(summary),
"summary": summary,
"source_type": source_type,
"source_count": source_count,
"metadata": metadata or {},
"created_at": time.time()
})
self._save_summaries()
self._log(f"Added summary for {source_count} {source_type} items")
def get_summaries(self, limit: int = 5) -> List[Dict[str, Any]]:
"""Get recent summaries."""
return list(reversed(self._summaries))[:limit]
with self._lock:
return list(reversed(self._summaries))[:limit]
# -------------------------------------------------------------------------
# Utility Methods
@@ -816,27 +834,29 @@ class FileMemory:
def clear_short_term(self):
"""Clear all short-term memory."""
self._short_term = []
self._save_short_term()
self._log("Cleared short-term memory")
with self._lock:
self._short_term = []
self._save_short_term()
self._log("Cleared short-term memory")
def clear_all(self):
"""Clear all memory for this user."""
self._short_term = []
self._long_term = []
self._entities = {}
self._summaries = []
self._save_short_term()
self._save_long_term()
self._save_entities()
self._save_summaries()
# Clear episodic
for f in self.episodic_path.glob("*.json"):
f.unlink()
self._log("Cleared all memory")
with self._lock:
self._short_term = []
self._long_term = []
self._entities = {}
self._summaries = []
self._save_short_term()
self._save_long_term()
self._save_entities()
self._save_summaries()
# Clear episodic
for f in self.episodic_path.glob("*.json"):
f.unlink()
self._log("Cleared all memory")
# -------------------------------------------------------------------------
# Selective Deletion
@@ -852,13 +872,14 @@ class FileMemory:
Returns:
True if memory was found and deleted, False otherwise
"""
for i, item in enumerate(self._short_term):
if item.id == memory_id:
del self._short_term[i]
self._save_short_term()
self._log(f"Deleted short-term memory: {memory_id}")
return True
return False
with self._lock:
for i, item in enumerate(self._short_term):
if item.id == memory_id:
del self._short_term[i]
self._save_short_term()
self._log(f"Deleted short-term memory: {memory_id}")
return True
return False
def delete_long_term(self, memory_id: str) -> bool:
"""
@@ -870,13 +891,14 @@ class FileMemory:
Returns:
True if memory was found and deleted, False otherwise
"""
for i, item in enumerate(self._long_term):
if item.id == memory_id:
del self._long_term[i]
self._save_long_term()
self._log(f"Deleted long-term memory: {memory_id}")
return True
return False
with self._lock:
for i, item in enumerate(self._long_term):
if item.id == memory_id:
del self._long_term[i]
self._save_long_term()
self._log(f"Deleted long-term memory: {memory_id}")
return True
return False
def delete_entity(self, name: str) -> bool:
"""
@@ -888,24 +910,25 @@ class FileMemory:
Returns:
True if entity was found and deleted, False otherwise
"""
# Find entity by name using existing helper
entity = self._find_entity_by_name(name)
if entity:
# Delete by entity's ID (which is the dict key)
if entity.id in self._entities:
del self._entities[entity.id]
with self._lock:
# Find entity by name using existing helper
entity = self._find_entity_by_name(name)
if entity:
# Delete by entity's ID (which is the dict key)
if entity.id in self._entities:
del self._entities[entity.id]
self._save_entities()
self._log(f"Deleted entity: {name}")
return True
# Try direct ID match as fallback (in case name IS the ID)
if name in self._entities:
del self._entities[name]
self._save_entities()
self._log(f"Deleted entity: {name}")
self._log(f"Deleted entity by ID: {name}")
return True
# Try direct ID match as fallback (in case name IS the ID)
if name in self._entities:
del self._entities[name]
self._save_entities()
self._log(f"Deleted entity by ID: {name}")
return True
return False
return False
def delete_episodic(self, memory_id: str, date: Optional[str] = None) -> bool:
"""
@@ -1056,15 +1079,16 @@ class FileMemory:
"""Get memory statistics."""
episodic_count = sum(1 for _ in self.episodic_path.glob("*.json"))
return {
"user_id": self.user_id,
"short_term_count": len(self._short_term),
"long_term_count": len(self._long_term),
"entity_count": len(self._entities),
"episodic_days": episodic_count,
"summary_count": len(self._summaries),
"storage_path": str(self.user_path)
}
with self._lock:
return {
"user_id": self.user_id,
"short_term_count": len(self._short_term),
"long_term_count": len(self._long_term),
"entity_count": len(self._entities),
"episodic_days": episodic_count,
"summary_count": len(self._summaries),
"storage_path": str(self.user_path)
}
# -------------------------------------------------------------------------
# MemoryProtocol Compliance Aliases
@@ -1108,44 +1132,47 @@ class FileMemory:
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
with self._lock:
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 {
"user_id": self.user_id,
"config": self.config,
"short_term": [item.to_dict() for item in self._short_term],
"long_term": [item.to_dict() for item in self._long_term],
"entities": {k: v.to_dict() for k, v in self._entities.items()},
"summaries": self._summaries,
"exported_at": time.time()
}
with self._lock:
return {
"user_id": self.user_id,
"config": self.config,
"short_term": [item.to_dict() for item in self._short_term],
"long_term": [item.to_dict() for item in self._long_term],
"entities": {k: v.to_dict() for k, v in self._entities.items()},
"summaries": list(self._summaries),
"exported_at": time.time()
}
def import_data(self, data: Dict[str, Any]):
"""Import memory data from export."""
if "short_term" in data:
self._short_term = [MemoryItem.from_dict(item) for item in data["short_term"]]
self._save_short_term()
if "long_term" in data:
self._long_term = [MemoryItem.from_dict(item) for item in data["long_term"]]
self._save_long_term()
if "entities" in data:
self._entities = {k: EntityItem.from_dict(v) for k, v in data["entities"].items()}
self._save_entities()
if "summaries" in data:
self._summaries = data["summaries"]
self._save_summaries()
self._log("Imported memory data")
with self._lock:
if "short_term" in data:
self._short_term = [MemoryItem.from_dict(item) for item in data["short_term"]]
self._save_short_term()
if "long_term" in data:
self._long_term = [MemoryItem.from_dict(item) for item in data["long_term"]]
self._save_long_term()
if "entities" in data:
self._entities = {k: EntityItem.from_dict(v) for k, v in data["entities"].items()}
self._save_entities()
if "summaries" in data:
self._summaries = data["summaries"]
self._save_summaries()
self._log("Imported memory data")
# -------------------------------------------------------------------------
# Session Management
@@ -1175,14 +1202,20 @@ class FileMemory:
session_file = sessions_path / f"{name}.json"
# Take snapshots under lock for consistent session data
with self._lock:
short_term_data = [item.to_dict() for item in self._short_term]
long_term_snapshot = [item.to_dict() for item in self._long_term[-50:]] # Last 50
entity_ids = list(self._entities.keys())
session_data = {
"name": name,
"user_id": self.user_id,
"saved_at": time.time(),
"saved_at_iso": datetime.now().isoformat(),
"short_term": [item.to_dict() for item in self._short_term],
"long_term_snapshot": [item.to_dict() for item in self._long_term[-50:]], # Last 50
"entity_ids": list(self._entities.keys()),
"short_term": short_term_data,
"long_term_snapshot": long_term_snapshot,
"entity_ids": entity_ids,
"conversation_history": conversation_history or [],
"metadata": metadata or {},
"config": self.config
@@ -1215,8 +1248,9 @@ class FileMemory:
# Restore short-term memory from session
if "short_term" in session_data:
self._short_term = [MemoryItem.from_dict(item) for item in session_data["short_term"]]
self._save_short_term()
with self._lock:
self._short_term = [MemoryItem.from_dict(item) for item in session_data["short_term"]]
self._save_short_term()
self._log(f"Resumed session '{name}'")
@@ -1287,14 +1321,16 @@ class FileMemory:
Returns:
The generated summary
"""
if len(self._short_term) <= max_items:
return "" # No compression needed
# Check length and gather content under lock
with self._lock:
if len(self._short_term) <= max_items:
return "" # No compression needed
# Gather content to compress while holding lock
items_to_compress = self._short_term[:-max_items]
content_list = [item.content for item in items_to_compress]
# Gather content to compress
items_to_compress = self._short_term[:-max_items]
content_list = [item.content for item in items_to_compress]
# Generate summary
# Generate summary OUTSIDE lock (LLM call may be slow)
if llm_func:
prompt = f"""Summarize the following conversation context into key points.
Preserve important facts, decisions, and context.
@@ -1309,16 +1345,17 @@ Summary:"""
# Simple concatenation if no LLM
summary = "Compressed context: " + " | ".join(content_list[:5]) + "..."
# Add summary as a high-importance long-term memory
# Add summary as a high-importance long-term memory (add_long_term has its own lock)
self.add_long_term(
content=f"[Session Summary] {summary}",
metadata={"type": "compression_summary", "items_compressed": len(items_to_compress)},
importance=0.9
)
# Keep only recent items
self._short_term = self._short_term[-max_items:]
self._save_short_term()
# Keep only recent items under lock
with self._lock:
self._short_term = self._short_term[-max_items:]
self._save_short_term()
self._log(f"Compressed {len(items_to_compress)} items into summary")
@@ -1567,7 +1604,9 @@ Summary:"""
})
if memory_type in ("all", "entity", "entities"):
for name, entity in self._entities.items():
with self._lock:
entities_snapshot = dict(self._entities)
for name, entity in entities_snapshot.items():
items.append({
"type": "entity",
"id": name,
@@ -3,6 +3,7 @@ import sqlite3
import json
import time
import shutil
import threading
from typing import Any, Dict, List, Optional, Union, Literal
import logging
from datetime import datetime
@@ -23,102 +24,147 @@ def trace(self, message, *args, **kwargs):
logging.Logger.trace = trace
# Lazy availability flags and cached imports
_chromadb_cache = {"available": None, "module": None, "settings": None}
_mem0_cache = {"available": None, "module": None}
_openai_cache = {"available": None, "module": None}
_litellm_cache = {"available": None, "module": None}
_pymongo_cache = {"available": None, "module": None, "client": None}
# Thread-safe lazy imports using proper thread synchronization
import threading
# Thread-safe import cache
_import_lock = threading.Lock()
_module_cache = {}
def _check_chromadb():
"""Lazily check chromadb availability and cache."""
if _chromadb_cache["available"] is None:
"""Thread-safe lazy check for chromadb availability."""
if "chromadb" in _module_cache:
return _module_cache["chromadb"]["available"]
with _import_lock:
if "chromadb" in _module_cache:
return _module_cache["chromadb"]["available"]
try:
import chromadb
from chromadb.config import Settings as ChromaSettings
_chromadb_cache["available"] = True
_chromadb_cache["module"] = chromadb
_chromadb_cache["settings"] = ChromaSettings
_module_cache["chromadb"] = {
"available": True,
"module": chromadb,
"settings": ChromaSettings
}
except ImportError:
_chromadb_cache["available"] = False
return _chromadb_cache["available"]
_module_cache["chromadb"] = {"available": False}
return _module_cache["chromadb"]["available"]
def _get_chromadb():
"""Get chromadb module (lazy load)."""
"""Get chromadb module and settings (thread-safe lazy load)."""
if not _check_chromadb():
raise ImportError("chromadb is required. Install with: pip install chromadb")
return _chromadb_cache["module"], _chromadb_cache["settings"]
return _module_cache["chromadb"]["module"], _module_cache["chromadb"]["settings"]
def _check_mem0():
"""Lazily check mem0 availability."""
if _mem0_cache["available"] is None:
"""Thread-safe lazy check for mem0 availability."""
if "mem0" in _module_cache:
return _module_cache["mem0"]["available"]
with _import_lock:
if "mem0" in _module_cache:
return _module_cache["mem0"]["available"]
try:
import mem0
_mem0_cache["available"] = True
_mem0_cache["module"] = mem0
_module_cache["mem0"] = {
"available": True,
"module": mem0
}
except ImportError:
_mem0_cache["available"] = False
return _mem0_cache["available"]
_module_cache["mem0"] = {"available": False}
return _module_cache["mem0"]["available"]
def _get_mem0():
"""Get mem0 module (lazy load)."""
"""Get mem0 module (thread-safe lazy load)."""
if not _check_mem0():
raise ImportError("mem0 is required. Install with: pip install mem0ai")
return _mem0_cache["module"]
return _module_cache["mem0"]["module"]
def _check_openai():
"""Lazily check openai availability."""
if _openai_cache["available"] is None:
"""Thread-safe lazy check for openai availability."""
if "openai" in _module_cache:
return _module_cache["openai"]["available"]
with _import_lock:
if "openai" in _module_cache:
return _module_cache["openai"]["available"]
try:
import openai
_openai_cache["available"] = True
_openai_cache["module"] = openai
_module_cache["openai"] = {
"available": True,
"module": openai
}
except ImportError:
_openai_cache["available"] = False
return _openai_cache["available"]
_module_cache["openai"] = {"available": False}
return _module_cache["openai"]["available"]
def _get_openai():
"""Get openai module (lazy load)."""
"""Get openai module (thread-safe lazy load)."""
if not _check_openai():
raise ImportError("openai is required. Install with: pip install openai")
return _openai_cache["module"]
return _module_cache["openai"]["module"]
def _check_litellm():
"""Lazily check litellm availability."""
if _litellm_cache["available"] is None:
"""Thread-safe lazy check for litellm availability."""
if "litellm" in _module_cache:
return _module_cache["litellm"]["available"]
with _import_lock:
if "litellm" in _module_cache:
return _module_cache["litellm"]["available"]
try:
import litellm
litellm.telemetry = False
_litellm_cache["available"] = True
_litellm_cache["module"] = litellm
_module_cache["litellm"] = {
"available": True,
"module": litellm
}
except ImportError:
_litellm_cache["available"] = False
return _litellm_cache["available"]
_module_cache["litellm"] = {"available": False}
return _module_cache["litellm"]["available"]
def _get_litellm():
"""Get litellm module (lazy load)."""
"""Get litellm module (thread-safe lazy load)."""
if not _check_litellm():
raise ImportError("litellm is required. Install with: pip install litellm")
return _litellm_cache["module"]
return _module_cache["litellm"]["module"]
def _check_pymongo():
"""Lazily check pymongo availability."""
if _pymongo_cache["available"] is None:
"""Thread-safe lazy check for pymongo availability."""
if "pymongo" in _module_cache:
return _module_cache["pymongo"]["available"]
with _import_lock:
if "pymongo" in _module_cache:
return _module_cache["pymongo"]["available"]
try:
import pymongo
from pymongo import MongoClient
_pymongo_cache["available"] = True
_pymongo_cache["module"] = pymongo
_pymongo_cache["client"] = MongoClient
_module_cache["pymongo"] = {
"available": True,
"module": pymongo,
"client": MongoClient
}
except ImportError:
_pymongo_cache["available"] = False
return _pymongo_cache["available"]
_module_cache["pymongo"] = {"available": False}
return _module_cache["pymongo"]["available"]
def _get_pymongo():
"""Get pymongo module and MongoClient (lazy load)."""
"""Get pymongo module and MongoClient (thread-safe lazy load)."""
if not _check_pymongo():
raise ImportError("pymongo is required. Install with: pip install pymongo")
return _pymongo_cache["module"], _pymongo_cache["client"]
return _module_cache["pymongo"]["module"], _module_cache["pymongo"]["client"]
@@ -189,6 +235,16 @@ class Memory:
self.cfg = config or {}
self.verbose = verbose
# Thread-local storage for SQLite connections (thread-safe)
self._local = threading.local()
# Write lock for serializing database modifications (thread-safe)
self._write_lock = threading.Lock()
# Connection registry for cleanup across threads (use regular set with careful cleanup)
self._all_connections = set()
self._connection_lock = threading.Lock() # Protect the connection registry
# Set logger level based on verbose
if verbose >= 5:
logger.setLevel(logging.INFO)
@@ -247,6 +303,50 @@ class Memory:
elif self.use_rag:
self._init_chroma()
def _get_stm_conn(self):
"""Get thread-local short-term memory SQLite connection."""
if not hasattr(self._local, 'stm_conn') or self._local.stm_conn is None:
self._local.stm_conn = sqlite3.connect(
self.short_db,
check_same_thread=False, # Allow cross-thread cleanup
timeout=30.0 # 30 second timeout for lock contention
)
# Configure busy timeout for better contention handling
self._local.stm_conn.execute("PRAGMA busy_timeout=30000") # 30 seconds
# Enable WAL mode for concurrent read/write without blocking
result = self._local.stm_conn.execute("PRAGMA journal_mode=WAL").fetchone()
if result and result[0].upper() != 'WAL':
logger.warning(f"WAL mode not enabled for STM, got: {result[0]}")
self._local.stm_conn.commit()
# Register connection for cleanup
with self._connection_lock:
self._all_connections.add(self._local.stm_conn)
return self._local.stm_conn
def _get_ltm_conn(self):
"""Get thread-local long-term memory SQLite connection."""
if not hasattr(self._local, 'ltm_conn') or self._local.ltm_conn is None:
self._local.ltm_conn = sqlite3.connect(
self.long_db,
check_same_thread=False, # Allow cross-thread cleanup
timeout=30.0 # 30 second timeout for lock contention
)
# Configure busy timeout for better contention handling
self._local.ltm_conn.execute("PRAGMA busy_timeout=30000") # 30 seconds
# Enable WAL mode for concurrent read/write without blocking
result = self._local.ltm_conn.execute("PRAGMA journal_mode=WAL").fetchone()
if result and result[0].upper() != 'WAL':
logger.warning(f"WAL mode not enabled for LTM, got: {result[0]}")
self._local.ltm_conn.commit()
# Register connection for cleanup
with self._connection_lock:
self._all_connections.add(self._local.ltm_conn)
return self._local.ltm_conn
def _log_verbose(self, msg: str, level: int = logging.INFO):
"""Only log if verbose >= 5"""
if self.verbose >= 5:
@@ -276,7 +376,7 @@ class Memory:
def _init_stm(self):
"""Creates or verifies short-term memory table."""
os.makedirs(os.path.dirname(self.short_db) or ".", exist_ok=True)
conn = sqlite3.connect(self.short_db)
conn = self._get_stm_conn()
c = conn.cursor()
c.execute("""
CREATE TABLE IF NOT EXISTS short_mem (
@@ -287,12 +387,11 @@ class Memory:
)
""")
conn.commit()
conn.close()
def _init_ltm(self):
"""Creates or verifies long-term memory table."""
os.makedirs(os.path.dirname(self.long_db) or ".", exist_ok=True)
conn = sqlite3.connect(self.long_db)
conn = self._get_ltm_conn()
c = conn.cursor()
c.execute("""
CREATE TABLE IF NOT EXISTS long_mem (
@@ -303,7 +402,6 @@ class Memory:
)
""")
conn.commit()
conn.close()
def _init_mem0(self):
"""Initialize Mem0 client for agent or user memory with optional graph support."""
@@ -579,15 +677,15 @@ class Memory:
logger.error(f"Failed to store in MongoDB short-term memory: {e}")
raise
# Existing SQLite store logic
# Existing SQLite store logic (with write lock for concurrency safety)
try:
conn = sqlite3.connect(self.short_db)
conn.execute(
"INSERT INTO short_mem (id, content, meta, created_at) VALUES (?,?,?,?)",
(ident, text, json.dumps(metadata), created_at)
)
conn.commit()
conn.close()
conn = self._get_stm_conn()
with self._write_lock: # Serialize write operations
conn.execute(
"INSERT INTO short_mem (id, content, meta, created_at) VALUES (?,?,?,?)",
(ident, text, json.dumps(metadata), created_at)
)
conn.commit()
logger.info(f"Successfully stored in SQLite short-term memory with ID: {ident}")
except Exception as e:
logger.error(f"Failed to store in SQLite short-term memory: {e}")
@@ -713,13 +811,12 @@ class Memory:
else:
# Local fallback
conn = sqlite3.connect(self.short_db)
conn = self._get_stm_conn()
c = conn.cursor()
rows = c.execute(
"SELECT id, content, meta FROM short_mem WHERE content LIKE ? LIMIT ?",
(f"%{query}%", limit)
).fetchall()
conn.close()
results = []
for row in rows:
@@ -739,10 +836,10 @@ class Memory:
def reset_short_term(self):
"""Completely clears short-term memory."""
conn = sqlite3.connect(self.short_db)
conn.execute("DELETE FROM short_mem")
conn.commit()
conn.close()
conn = self._get_stm_conn()
with self._write_lock: # Serialize write operations
conn.execute("DELETE FROM short_mem")
conn.commit()
# -------------------------------------------------------------------------
# Long-Term Methods
@@ -813,15 +910,15 @@ class Memory:
logger.error(f"Failed to store in MongoDB long-term memory: {e}")
# Continue to SQLite fallback
# Store in SQLite
# Store in SQLite (with write lock for concurrency safety)
try:
conn = sqlite3.connect(self.long_db)
conn.execute(
"INSERT INTO long_mem (id, content, meta, created_at) VALUES (?,?,?,?)",
(ident, text, json.dumps(metadata), created)
)
conn.commit()
conn.close()
conn = self._get_ltm_conn()
with self._write_lock: # Serialize write operations
conn.execute(
"INSERT INTO long_mem (id, content, meta, created_at) VALUES (?,?,?,?)",
(ident, text, json.dumps(metadata), created)
)
conn.commit()
logger.info(f"Successfully stored in SQLite with ID: {ident}")
except Exception as e:
logger.error(f"Error storing in SQLite: {e}")
@@ -1002,13 +1099,12 @@ class Memory:
self._log_verbose(f"Error searching ChromaDB: {e}", logging.ERROR)
# Always try SQLite as fallback or additional source
conn = sqlite3.connect(self.long_db)
conn = self._get_ltm_conn()
c = conn.cursor()
rows = c.execute(
"SELECT id, content, meta, created_at FROM long_mem WHERE content LIKE ? LIMIT ?",
(f"%{query}%", limit)
).fetchall()
conn.close()
for row in rows:
meta = json.loads(row[2] or "{}")
@@ -1051,10 +1147,10 @@ class Memory:
def reset_long_term(self):
"""Clear local LTM DB, plus Chroma, MongoDB, or mem0 if in use."""
conn = sqlite3.connect(self.long_db)
conn.execute("DELETE FROM long_mem")
conn.commit()
conn.close()
conn = self._get_ltm_conn()
with self._write_lock: # Serialize write operations
conn.execute("DELETE FROM long_mem")
conn.commit()
if self.use_mem0 and hasattr(self, "mem0_client"):
# Mem0 has no universal reset API. Could implement partial or no-op.
@@ -1085,16 +1181,16 @@ class Memory:
"""
deleted = False
# Delete from SQLite
# Delete from SQLite (with write lock for concurrency safety)
try:
conn = sqlite3.connect(self.short_db)
cursor = conn.execute(
"DELETE FROM short_mem WHERE id = ?", (memory_id,)
)
if cursor.rowcount > 0:
deleted = True
conn.commit()
conn.close()
conn = self._get_stm_conn()
with self._write_lock: # Serialize write operations
cursor = conn.execute(
"DELETE FROM short_mem WHERE id = ?", (memory_id,)
)
if cursor.rowcount > 0:
deleted = True
conn.commit()
except Exception as e:
self._log_verbose(f"Error deleting from SQLite short-term: {e}", logging.ERROR)
@@ -1126,16 +1222,16 @@ class Memory:
"""
deleted = False
# Delete from SQLite
# Delete from SQLite (with write lock for concurrency safety)
try:
conn = sqlite3.connect(self.long_db)
cursor = conn.execute(
"DELETE FROM long_mem WHERE id = ?", (memory_id,)
)
if cursor.rowcount > 0:
deleted = True
conn.commit()
conn.close()
conn = self._get_ltm_conn()
with self._write_lock: # Serialize write operations
cursor = conn.execute(
"DELETE FROM long_mem WHERE id = ?", (memory_id,)
)
if cursor.rowcount > 0:
deleted = True
conn.commit()
except Exception as e:
self._log_verbose(f"Error deleting from SQLite long-term: {e}", logging.ERROR)
@@ -1790,10 +1886,9 @@ class Memory:
try:
# Get short-term memories
conn = sqlite3.connect(self.short_db)
conn = self._get_stm_conn()
c = conn.cursor()
rows = c.execute("SELECT id, content, meta, created_at FROM short_mem").fetchall()
conn.close()
for row in rows:
meta = json.loads(row[2] or "{}")
@@ -1806,10 +1901,9 @@ class Memory:
})
# Get long-term memories
conn = sqlite3.connect(self.long_db)
conn = self._get_ltm_conn()
c = conn.cursor()
rows = c.execute("SELECT id, content, meta, created_at FROM long_mem").fetchall()
conn.close()
for row in rows:
meta = json.loads(row[2] or "{}")
@@ -1873,3 +1967,57 @@ class Memory:
if self.learn is None:
return ""
return self.learn.to_system_prompt_context()
def close_connections(self):
"""
Close database connections.
Closes the current thread's connections and attempts to close all known
connections from other threads. Each thread should call this method before
terminating to ensure proper cleanup.
"""
# Close current thread's connections
if hasattr(self._local, 'stm_conn') and self._local.stm_conn:
try:
self._local.stm_conn.close()
self._local.stm_conn = None
except Exception as e:
logger.warning(f"Error closing current thread's STM connection: {e}")
if hasattr(self._local, 'ltm_conn') and self._local.ltm_conn:
try:
self._local.ltm_conn.close()
self._local.ltm_conn = None
except Exception as e:
logger.warning(f"Error closing current thread's LTM connection: {e}")
# Close all known connections from the registry
with self._connection_lock: # Ensure thread safety during cleanup
connections_to_close = list(self._all_connections)
for conn in connections_to_close:
try:
conn.close()
except Exception as e:
logger.debug(f"Error closing registered connection: {e}")
# Clear the registry
self._all_connections.clear()
def __enter__(self):
"""Allow Memory to be used as a context manager."""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Ensure connections are closed when leaving a context manager block."""
self.close_connections()
def __del__(self):
"""
Attempt to clean up any open SQLite connections when this instance
is garbage-collected. Errors are suppressed to avoid issues during
interpreter shutdown.
"""
try:
self.close_connections()
except Exception:
# Best-effort cleanup during garbage collection
pass
@@ -0,0 +1,543 @@
"""
Editor-Style Output Mode for PraisonAI Agents.
Provides a user-friendly display format with:
- Numbered steps: Step 1: 📄 Creating file: /path
- Human-readable tool names (not technical names)
- Smart result formatting (JSON → summary, exit → ✓ Done)
- Completion summary with duration and block count
Usage:
# Via preset:
agent = Agent(output="editor")
# Programmatic:
from praisonaiagents.output.editor import enable_editor_output
enable_editor_output()
"""
import time
import threading
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from enum import Enum
# ─────────────────────────────────────────────────────────────────────────────
# Module-level state (same pattern as status.py)
# ─────────────────────────────────────────────────────────────────────────────
_editor_output_enabled = False
_editor_output: Optional['EditorOutput'] = None
class BlockType(Enum):
"""Types of display blocks."""
NARRATIVE = "narrative"
COMMAND = "command"
SUMMARY = "summary"
ACTION = "action"
CODE = "code"
LIST = "list"
@dataclass
class DisplayBlock:
"""A single display block."""
type: BlockType
content: str
title: Optional[str] = None
items: List[str] = field(default_factory=list)
output: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
# Human-readable tool name mappings
TOOL_LABELS: Dict[str, tuple] = {
# Web / search
'internet_search': ('🔍', 'Searching the web'),
'search_web': ('🔍', 'Searching the web'),
'web_search': ('🔍', 'Searching the web'),
# File operations
'read_file': ('📖', 'Reading file'),
'write_file': ('📝', 'Writing file'),
'create_file': ('📄', 'Creating file'),
'acp_create_file': ('📄', 'Creating file'),
'acp_edit_file': ('✏️', 'Editing file'),
'acp_delete_file': ('🗑️', 'Deleting file'),
# Execution
'execute_command': ('⚡', 'Running command'),
'acp_execute_command': ('⚡', 'Running command'),
# Directory
'list_files': ('📂', 'Listing files'),
'get_system_info': ('💻', 'Getting system info'),
# LSP
'lsp_get_diagnostics': ('🔬', 'Analyzing code'),
'lsp_list_symbols': ('🔎', 'Finding code symbols'),
'lsp_find_definition': ('📍', 'Finding definition'),
'lsp_find_references': ('🔗', 'Finding references'),
}
class EditorOutput:
"""
User-friendly display for agent output.
Renders tool calls as numbered steps with emoji icons and
human-readable labels. Formats results smartly (JSON → summary).
Thread-safe for multi-agent execution.
"""
def __init__(self, console=None, use_rich: bool = True, agent_name: Optional[str] = None):
self._use_rich = use_rich
self._console = console
self._blocks: List[DisplayBlock] = []
self._start_time = time.time()
self._step_count = 0
self._llm_call_count = 0
self._lock = threading.Lock()
self._agent_name = agent_name # For multi-agent prefix support
self._multi_agent_mode = False # Set True when multiple agents detected
if use_rich and console is None:
try:
from rich.console import Console
self._console = Console()
except ImportError:
self._use_rich = False
# ─────────────────────────────────────────────────────────────────────
# Public API
# ─────────────────────────────────────────────────────────────────────
def tool_call(
self,
tool_name: str,
args: Optional[Dict[str, Any]] = None,
result: Optional[str] = None,
duration: Optional[float] = None,
) -> None:
"""Display a tool call as a numbered step with emoji."""
with self._lock:
self._step_count += 1
step_num = self._step_count
# Get human-readable label
icon, label = TOOL_LABELS.get(tool_name, ('🔧', f'Using {tool_name}'))
# Format action description with step number and context
step_prefix = f"Step {step_num}: "
action = self._format_action(step_prefix, icon, label, args)
# Format result for display
display_result = self._format_result(result)
self._add_block(BlockType.COMMAND, action, output=display_result)
self._render_command(action, display_result)
def llm_indicator(self, phase: str = "thinking") -> None:
"""Display a subtle LLM activity indicator between steps."""
if self._use_rich:
self._console.print(f"[dim]▸ {phase.capitalize()}...[/dim]")
else:
print(f"▸ {phase.capitalize()}...")
def output(self, content: str, agent_name: Optional[str] = None) -> None:
"""Display final agent output."""
if self._use_rich:
self._console.print()
self._console.print(content)
self._console.print()
else:
print()
print(content)
print()
def summary(self, title: str, items: Optional[List[str]] = None) -> None:
"""Display completion summary."""
self._add_block(BlockType.SUMMARY, "", title=title, items=items or [])
self._render_summary(title, items)
def elapsed_time(self) -> float:
"""Get elapsed time since display started."""
return time.time() - self._start_time
def get_blocks(self) -> List[DisplayBlock]:
"""Get all display blocks."""
with self._lock:
return self._blocks.copy()
def set_agent_name(self, name: str) -> None:
"""Set the agent name for multi-agent prefix."""
with self._lock:
self._agent_name = name
def enable_multi_agent_mode(self) -> None:
"""Enable multi-agent mode to show agent prefixes."""
with self._lock:
self._multi_agent_mode = True
def _get_prefix(self, agent_name: Optional[str] = None) -> str:
"""Get the agent prefix for multi-agent mode."""
if not self._multi_agent_mode:
return ""
name = agent_name or self._agent_name
if name:
return f"[{name}] "
return ""
# ─────────────────────────────────────────────────────────────────────
# Additional display methods (merged from CLI EditorDisplay)
# ─────────────────────────────────────────────────────────────────────
def narrative(self, text: str, agent_name: Optional[str] = None) -> None:
"""
Display a narrative block (agent thinking/explanation).
Args:
text: The narrative text
agent_name: Optional agent name for multi-agent prefix
"""
if not text or not text.strip():
return
self._add_block(BlockType.NARRATIVE, text)
prefix = self._get_prefix(agent_name)
if self._use_rich:
self._console.print(f"{prefix}{text}")
else:
print(f"{prefix}{text}")
def code(self, code: str, language: str = "") -> None:
"""
Display a code block.
Args:
code: The code content
language: Programming language for syntax highlighting
"""
self._add_block(BlockType.CODE, code, metadata={"language": language})
if self._use_rich:
try:
from rich.syntax import Syntax
syntax = Syntax(code, language or "text", theme="monokai")
self._console.print(syntax)
except ImportError:
self._console.print(f"```{language}\n{code}\n```")
else:
print(f"```{language}")
print(code)
print("```")
def action(self, title: str, details: Optional[List[str]] = None) -> None:
"""
Display an action summary block.
Args:
title: Action title (e.g., "Changes applied")
details: List of detail items
"""
self._add_block(BlockType.ACTION, "", title=title, items=details or [])
if self._use_rich:
self._console.print(f"[bold green]✓ {title}[/bold green]")
if details:
for item in details:
self._console.print(f" - {item}")
else:
print(f"✓ {title}")
if details:
for item in details:
print(f" - {item}")
def list_items(self, items: List[str], title: Optional[str] = None) -> None:
"""
Display a bulleted list.
Args:
items: List items
title: Optional title
"""
self._add_block(BlockType.LIST, "", title=title, items=items)
if self._use_rich:
if title:
self._console.print(f"[bold]{title}[/bold]")
for item in items:
self._console.print(f" • {item}")
else:
if title:
print(title)
for item in items:
print(f" • {item}")
def error(self, message: str, agent_name: Optional[str] = None) -> None:
"""
Display an error message.
Args:
message: Error message
agent_name: Optional agent name for multi-agent prefix
"""
prefix = self._get_prefix(agent_name)
if self._use_rich:
self._console.print(f"[red]{prefix}✗ Error: {message}[/red]")
else:
print(f"{prefix}✗ Error: {message}")
def to_markdown(self) -> str:
"""
Export all blocks as Markdown.
Returns:
Markdown string representation of all blocks
"""
lines = []
with self._lock:
blocks = self._blocks.copy()
for block in blocks:
if block.type == BlockType.NARRATIVE:
lines.append(block.content)
lines.append("")
elif block.type == BlockType.COMMAND:
lines.append(f"**{block.content}**")
if block.output:
lines.append(f"> {block.output}")
lines.append("")
elif block.type == BlockType.CODE:
lang = block.metadata.get("language", "")
lines.append(f"```{lang}")
lines.append(block.content)
lines.append("```")
lines.append("")
elif block.type == BlockType.SUMMARY:
if block.title:
lines.append(f"## {block.title}")
if block.items:
for item in block.items:
lines.append(f"- {item}")
lines.append("")
elif block.type == BlockType.ACTION:
if block.title:
lines.append(f"✓ **{block.title}**")
if block.items:
for item in block.items:
lines.append(f" - {item}")
lines.append("")
elif block.type == BlockType.LIST:
if block.title:
lines.append(f"**{block.title}**")
for item in block.items:
lines.append(f"- {item}")
lines.append("")
return "\n".join(lines)
# ─────────────────────────────────────────────────────────────────────
# Formatting helpers
# ─────────────────────────────────────────────────────────────────────
@staticmethod
def _format_action(
step_prefix: str,
icon: str,
label: str,
args: Optional[Dict[str, Any]],
) -> str:
"""Build human-readable action string from tool args."""
if not args:
return f"{step_prefix}{icon} {label}"
# Extract the most meaningful argument for context
for key in ('query', 'filepath', 'file_path', 'directory', 'command'):
if key in args:
val = args[key]
if key == 'query':
return f'{step_prefix}{icon} {label} for "{val}"'
return f"{step_prefix}{icon} {label}: {val}"
return f"{step_prefix}{icon} {label}"
@staticmethod
def _format_result(result: Optional[str]) -> Optional[str]:
"""Convert raw tool result to a user-friendly summary."""
if not result:
return None
result_str = str(result)
# JSON → summary
if result_str.startswith(('[', '{')):
try:
import json
data = json.loads(result_str)
if isinstance(data, list):
return f"✓ Found {len(data)} items"
if isinstance(data, dict):
if data.get('success') is True:
return f"✓ Created: {data['file_created']}" if data.get('file_created') else "✓ Success"
if data.get('success') is False:
return f"⚠ Failed: {data.get('error', 'Unknown')[:80]}"
if data.get('error'):
return f"⚠ {data['error'][:80]}"
if data.get('stdout'):
first_line = data['stdout'].strip().split('\n')[0][:80]
return f"✓ {first_line}"
if data.get('exit_code') == 0:
return "✓ Command completed"
return "✓ Done"
except (ValueError, TypeError):
return "✓ Done"
# Boolean strings
if result_str.lower() == 'true':
return "✓ Success"
if result_str.lower() == 'false':
return "⚠ Failed"
# Short plain text → show it; long text → "Done"
if len(result_str) <= 80 and '\n' not in result_str:
return result_str
return "✓ Done"
# ─────────────────────────────────────────────────────────────────────
# Internal rendering
# ─────────────────────────────────────────────────────────────────────
def _add_block(self, block_type, content, **kwargs):
block = DisplayBlock(type=block_type, content=content, **kwargs)
with self._lock:
self._blocks.append(block)
def _render_command(self, cmd: str, output: Optional[str] = None):
if self._use_rich:
self._console.print(f"[dim]{cmd}[/dim]")
if output:
self._console.print(output)
else:
print(cmd)
if output:
print(output)
def _render_summary(self, title: str, items: Optional[List[str]] = None):
if self._use_rich:
self._console.print()
self._console.print(f"[bold]{title}[/bold]")
if items:
for item in items:
self._console.print(f"- {item}")
else:
print()
print(title)
if items:
for item in items:
print(f"- {item}")
# ─────────────────────────────────────────────────────────────────────────────
# Module-level enable / disable (same pattern as status.py)
# ─────────────────────────────────────────────────────────────────────────────
def is_editor_output_enabled() -> bool:
"""Check whether editor output mode is currently active."""
return _editor_output_enabled
def enable_editor_output(
use_color: bool = True,
) -> EditorOutput:
"""
Enable editor output mode globally.
Registers display callbacks that render tool calls as numbered steps.
Returns:
EditorOutput instance for programmatic access.
"""
global _editor_output_enabled, _editor_output
_editor_output = EditorOutput(use_rich=use_color)
_editor_output_enabled = True
# Register callbacks with the display system
from ..main import register_display_callback
def on_tool_call(
message: str = None,
tool_name: str = None,
tool_input: dict = None,
tool_output: str = None,
**kwargs,
):
if not _editor_output_enabled or _editor_output is None:
return
if tool_name:
_editor_output.tool_call(
tool_name=tool_name,
args=tool_input,
result=str(tool_output)[:500] if tool_output else None,
)
def on_interaction(
message: str = None,
response: str = None,
agent_name: str = None,
generation_time: float = None,
**kwargs,
):
if not _editor_output_enabled or _editor_output is None:
return
# Dedup: skip if this exact response was already displayed
# Covers both llm_content→interaction and interaction→interaction duplicates
# (LLM layer and Agent layer both fire execute_sync_callback('interaction'))
if response and hasattr(_editor_output, '_last_displayed_response') and _editor_output._last_displayed_response == response.strip():
_editor_output._last_displayed_response = None # Reset for next turn
return
if response:
_editor_output._last_displayed_response = response.strip()
_editor_output.output(response, agent_name)
def on_error(message: str = None, **kwargs):
if not _editor_output_enabled or _editor_output is None:
return
if message and _editor_output._use_rich:
_editor_output._console.print(f"[red]✗ Error: {message}[/red]")
elif message:
print(f"✗ Error: {message}")
def on_llm_start(model: str = None, agent_name: str = None, **kwargs):
if not _editor_output_enabled or _editor_output is None:
return
with _editor_output._lock:
_editor_output._llm_call_count += 1
count = _editor_output._llm_call_count
phase = "Thinking" if count == 1 else "Responding"
_editor_output.llm_indicator(phase)
def on_llm_content(content: str = None, agent_name: str = None, **kwargs):
if not _editor_output_enabled or _editor_output is None:
return
if content and content.strip():
# Dedup: skip if on_interaction already displayed this exact text
if hasattr(_editor_output, '_last_displayed_response') and _editor_output._last_displayed_response == content.strip():
return
_editor_output._last_displayed_response = content.strip()
_editor_output.narrative(content.strip(), agent_name=agent_name)
register_display_callback('tool_call', on_tool_call)
register_display_callback('interaction', on_interaction)
register_display_callback('error', on_error)
register_display_callback('llm_start', on_llm_start)
register_display_callback('llm_content', on_llm_content)
return _editor_output
def disable_editor_output() -> None:
"""Disable editor output mode."""
global _editor_output_enabled, _editor_output
_editor_output_enabled = False
_editor_output = None
def get_editor_output() -> Optional[EditorOutput]:
"""Get the current editor output instance."""
return _editor_output
@@ -0,0 +1,52 @@
"""
Lightweight output models for PraisonAI Agents.
This module contains Pydantic models that are used across the codebase.
It is kept separate from main.py to avoid importing rich at module level.
"""
import json
from typing import Optional, Dict, Any, Literal, TYPE_CHECKING
from pydantic import BaseModel, ConfigDict
if TYPE_CHECKING:
from ..telemetry.token_collector import TokenMetrics
class TaskOutput(BaseModel):
"""Output model for task results."""
model_config = ConfigDict(arbitrary_types_allowed=True)
description: str
summary: Optional[str] = None
raw: str
pydantic: Optional[BaseModel] = None
json_dict: Optional[Dict[str, Any]] = None
agent: str
output_format: Literal["RAW", "JSON", "Pydantic"] = "RAW"
token_metrics: Optional['TokenMetrics'] = None
def json(self) -> Optional[str]:
if self.output_format == "JSON" and self.json_dict:
return json.dumps(self.json_dict)
return None
def to_dict(self) -> dict:
output_dict = {}
if self.json_dict:
output_dict.update(self.json_dict)
if self.pydantic:
output_dict.update(self.pydantic.model_dump())
return output_dict
def __str__(self) -> str:
if self.pydantic:
return str(self.pydantic)
if self.json_dict:
return json.dumps(self.json_dict, indent=2)
return self.raw
class ReflectionOutput(BaseModel):
"""Output model for self-reflection results."""
reflection: str
satisfactory: Literal["yes", "no"]
@@ -0,0 +1,135 @@
"""
Tool Loop Detection Plugin for PraisonAI Agents.
Registers a BEFORE_TOOL hook that detects repetitive tool call patterns
and blocks the agent when stuck in a loop.
Inspired by OpenClaw's tool-loop-detection.ts (624 lines, 4 detectors).
Usage:
# Import to auto-register the hook globally
import praisonaiagents.plugins.loop_detection_plugin
# Or use with an agent via explicit hook registration
from praisonaiagents import Agent
from praisonaiagents.hooks.registry import get_default_registry
from praisonaiagents.hooks.types import HookEvent
Zero Performance Impact:
- Only active when this module is imported
- Uses stdlib only (hashlib, json)
- Disabled in production unless explicitly imported
"""
from __future__ import annotations
import logging
import threading
from typing import List
from praisonaiagents.hooks.registry import add_hook
from praisonaiagents.hooks.types import HookEvent, HookInput, HookResult
from praisonaiagents.agent.loop_detection import (
LoopDetectionConfig,
LoopDetectionResult,
detect_tool_loop,
record_tool_call,
ToolCallRecord,
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Default configuration (can be overridden before import)
# ---------------------------------------------------------------------------
DEFAULT_CONFIG = LoopDetectionConfig(
enabled=True,
history_size=30,
warn_threshold=10,
critical_threshold=20,
)
# ---------------------------------------------------------------------------
# Per-thread history (avoids shared mutable state across agents/threads)
# ---------------------------------------------------------------------------
_thread_local = threading.local()
def _get_history() -> List[ToolCallRecord]:
"""Get the thread-local tool call history."""
if not hasattr(_thread_local, "history"):
_thread_local.history = []
return _thread_local.history
def reset_history() -> None:
"""Reset the tool call history for the current thread. Useful in tests."""
_thread_local.history = []
# ---------------------------------------------------------------------------
# Hook implementation
# ---------------------------------------------------------------------------
def _loop_detection_hook(event: HookInput) -> HookResult:
"""
BEFORE_TOOL hook: detect if agent is stuck in a tool call loop.
Returns:
- HookResult.allow() if not stuck (or below warning threshold)
- HookResult.allow(reason=warning_msg) if at warning threshold
- HookResult.block(reason=critical_msg) if at critical threshold
"""
# BeforeToolInput has tool_name and tool_input fields.
# Fall back to event.extra for compatibility with custom callers.
tool_name: str = (
getattr(event, "tool_name", None)
or event.extra.get("tool_name", "")
)
tool_args = (
getattr(event, "tool_input", None)
or event.extra.get("tool_args", {})
or {}
)
history = _get_history()
# Check before recording
result: LoopDetectionResult = detect_tool_loop(history, tool_name, tool_args, DEFAULT_CONFIG)
# Record the call (after detection to count existing history)
record_tool_call(history, tool_name, tool_args, DEFAULT_CONFIG)
if not result.get("stuck"):
return HookResult.allow()
level = result.get("level")
message = result.get("message", "Tool loop detected")
count = result.get("count", 0)
detector = result.get("detector", "unknown")
if level == "critical":
logger.error(
f"[loop_detection_plugin] BLOCKED: {detector} detector, "
f"tool={tool_name!r}, count={count}"
)
return HookResult.block(message)
# Warning: allow but annotate (agent can see the warning in hook output)
logger.warning(
f"[loop_detection_plugin] WARNING: {detector} detector, "
f"tool={tool_name!r}, count={count}"
)
# Return allow with additional_context so the runner can pass it back
return HookResult(decision="allow", reason=message)
# ---------------------------------------------------------------------------
# Register hook on import (side effect is intentional for plugin pattern)
# ---------------------------------------------------------------------------
_hook_id = add_hook(HookEvent.BEFORE_TOOL, _loop_detection_hook)
logger.debug(f"[loop_detection_plugin] Registered BEFORE_TOOL hook: {_hook_id}")
@@ -17,276 +17,296 @@ from contextlib import redirect_stdout, redirect_stderr
import traceback
from ..approval import require_approval
def _safe_getattr(obj, name, *default):
"""getattr wrapper that blocks access to dunder attributes."""
if isinstance(name, str) and name.startswith('_'):
raise AttributeError(
f"Access to private/protected attribute '{name}' is restricted"
)
return getattr(obj, name, *default) if default else getattr(obj, name)
def _validate_code_ast(code: str):
"""Validate code using AST — catches attacks that bypass text checks.
Returns error message string if dangerous, None if safe.
"""
import ast
try:
tree = ast.parse(code)
except SyntaxError:
return None # let compile() handle syntax errors later
# Dangerous dunder attributes attackers use for sandbox escape
_blocked_attrs = frozenset({
'__subclasses__', '__bases__', '__mro__', '__globals__',
'__code__', '__class__', '__dict__', '__builtins__',
'__import__', '__loader__', '__spec__', '__init_subclass__',
'__set_name__', '__reduce__', '__reduce_ex__',
'__traceback__', '__qualname__', '__module__',
'__wrapped__', '__closure__', '__annotations__',
# Frame/code object introspection
'gi_frame', 'gi_code', 'cr_frame', 'cr_code',
'ag_frame', 'ag_code', 'tb_frame', 'tb_next',
'f_globals', 'f_locals', 'f_builtins', 'f_code',
'co_consts', 'co_names',
})
for node in ast.walk(tree):
# Block import statements
if isinstance(node, (ast.Import, ast.ImportFrom)):
return f"Import statements are not allowed"
# Block attribute access to dangerous dunders
if isinstance(node, ast.Attribute):
if node.attr in _blocked_attrs:
return (
f"Access to attribute '{node.attr}' is restricted"
)
# Block calls to dangerous builtins by name
if isinstance(node, ast.Call):
func = node.func
if isinstance(func, ast.Name) and func.id in (
'exec', 'eval', 'compile', '__import__',
'open', 'input', 'breakpoint',
'setattr', 'delattr', 'dir',
):
return f"Call to '{func.id}' is not allowed"
return None
# ──────────────────────────────────────────────────────────────────────
# Standalone execute_code — NO optional deps required (no black/pylint)
# ──────────────────────────────────────────────────────────────────────
@require_approval(risk_level="critical")
def execute_code(
code: str,
globals_dict: Optional[Dict[str, Any]] = None,
locals_dict: Optional[Dict[str, Any]] = None,
timeout: int = 30,
max_output_size: int = 10000
) -> Dict[str, Any]:
"""Execute Python code safely with restricted builtins.
This function is available without any optional dependencies.
It uses a 3-layer security sandbox:
1. AST-based validation (blocks imports, dangerous dunders, eval/exec)
2. Text-pattern blocklist (defense-in-depth)
3. Restricted __builtins__ (only safe functions exposed)
"""
try:
# Create safe builtins - restricted set of functions
safe_builtins = {
# Basic functions
'print': print,
'len': len,
'range': range,
'enumerate': enumerate,
'zip': zip,
'map': map,
'filter': filter,
'sum': sum,
'min': min,
'max': max,
'abs': abs,
'round': round,
'sorted': sorted,
'reversed': reversed,
'any': any,
'all': all,
# Type constructors
'int': int,
'float': float,
'str': str,
'bool': bool,
'list': list,
'tuple': tuple,
'dict': dict,
'set': set,
# Math functions
'pow': pow,
'divmod': divmod,
# Exceptions
'Exception': Exception,
'ValueError': ValueError,
'TypeError': TypeError,
'KeyError': KeyError,
'IndexError': IndexError,
'RuntimeError': RuntimeError,
# Safe introspection (dunder-blocked wrapper)
'isinstance': isinstance,
'type': type,
'hasattr': hasattr,
'getattr': _safe_getattr,
# Class definition support
'__build_class__': __builtins__['__build_class__'] if isinstance(__builtins__, dict) else getattr(__builtins__, '__build_class__', None),
# Disable dangerous functions
'__import__': None,
'eval': None,
'exec': None,
'compile': None,
'open': None,
'input': None,
'globals': None,
'locals': None,
'vars': None,
}
# Set up execution environment with safe builtins
if globals_dict is None:
globals_dict = {'__builtins__': safe_builtins}
else:
# Override builtins in provided globals
globals_dict['__builtins__'] = safe_builtins
if locals_dict is None:
locals_dict = {}
# Security check 1: AST-based validation (cannot be bypassed
# by string concatenation or runtime tricks)
ast_error = _validate_code_ast(code)
if ast_error:
return {
'result': None,
'stdout': '',
'stderr': f'Security Error: {ast_error}',
'success': False
}
# Security check 2: text-based patterns (defense-in-depth)
dangerous_patterns = [
'__import__', 'import ', 'from ', 'exec', 'eval',
'compile', 'open(', 'file(', 'input(', 'raw_input',
'__subclasses__', '__bases__', '__globals__', '__code__',
'__class__', 'globals(', 'locals(', 'vars('
]
code_lower = code.lower()
for pattern in dangerous_patterns:
if pattern.lower() in code_lower:
return {
'result': None,
'stdout': '',
'stderr': f'Security Error: Code contains restricted pattern: {pattern}',
'success': False
}
# Capture output
stdout_buffer = io.StringIO()
stderr_buffer = io.StringIO()
try:
# Compile code with restricted mode
compiled_code = compile(code, '<string>', 'exec')
# Execute with output capture
with redirect_stdout(stdout_buffer), redirect_stderr(stderr_buffer):
exec(compiled_code, globals_dict, locals_dict)
# Get last expression value if any
import ast
tree = ast.parse(code)
if tree.body and isinstance(tree.body[-1], ast.Expr):
result = eval(
compile(ast.Expression(tree.body[-1].value), '<string>', 'eval'),
globals_dict,
locals_dict
)
else:
result = None
# Get output
stdout = stdout_buffer.getvalue()
stderr = stderr_buffer.getvalue()
# Truncate output if too large (use smart format)
if len(stdout) > max_output_size:
tail_size = min(max_output_size // 5, 500)
stdout = stdout[:max_output_size - tail_size] + f"\n...[{len(stdout):,} chars, showing first/last portions]...\n" + stdout[-tail_size:]
if len(stderr) > max_output_size:
tail_size = min(max_output_size // 5, 500)
stderr = stderr[:max_output_size - tail_size] + f"\n...[{len(stderr):,} chars, showing first/last portions]...\n" + stderr[-tail_size:]
return {
'result': result,
'stdout': stdout,
'stderr': stderr,
'success': True
}
except Exception as e:
error_msg = f"Error executing code: {str(e)}"
logging.error(error_msg)
return {
'result': None,
'stdout': stdout_buffer.getvalue(),
'stderr': error_msg,
'success': False
}
except Exception as e:
error_msg = f"Error executing code: {str(e)}"
logging.error(error_msg)
return {
'result': None,
'stdout': '',
'stderr': error_msg,
'success': False
}
# ──────────────────────────────────────────────────────────────────────
# PythonTools class — requires optional deps (black, pylint, autopep8)
# Only instantiated when analyze_code/format_code/lint_code are needed.
# ──────────────────────────────────────────────────────────────────────
class PythonTools:
"""Tools for Python code execution and analysis."""
"""Tools for Python code analysis, formatting, and linting.
Requires: pip install black pylint autopep8
For code execution only, use the standalone execute_code() function.
"""
def __init__(self):
"""Initialize PythonTools."""
"""Initialize PythonTools — checks for required packages."""
self._check_dependencies()
def _check_dependencies(self):
"""Check if required packages are installed."""
missing = []
for package in ['black', 'pylint', 'autopep8']:
if util.find_spec(package) is None:
missing.append(package)
if missing:
raise ImportError(
f"Required packages not available. Please install: {', '.join(missing)}\n"
f"Run: pip install {' '.join(missing)}"
)
@staticmethod
def _safe_getattr(obj, name, *default):
"""getattr wrapper that blocks access to dunder attributes."""
if isinstance(name, str) and name.startswith('_'):
raise AttributeError(
f"Access to private/protected attribute '{name}' is restricted"
)
return getattr(obj, name, *default) if default else getattr(obj, name)
@staticmethod
def _validate_code_ast(code: str):
"""Validate code using AST — catches attacks that bypass text checks.
Returns error message string if dangerous, None if safe.
"""
import ast
try:
tree = ast.parse(code)
except SyntaxError:
return None # let compile() handle syntax errors later
# Dangerous dunder attributes attackers use for sandbox escape
_blocked_attrs = frozenset({
'__subclasses__', '__bases__', '__mro__', '__globals__',
'__code__', '__class__', '__dict__', '__builtins__',
'__import__', '__loader__', '__spec__', '__init_subclass__',
'__set_name__', '__reduce__', '__reduce_ex__',
'__traceback__', '__qualname__', '__module__',
'__wrapped__', '__closure__', '__annotations__',
# Frame/code object introspection
'gi_frame', 'gi_code', 'cr_frame', 'cr_code',
'ag_frame', 'ag_code', 'tb_frame', 'tb_next',
'f_globals', 'f_locals', 'f_builtins', 'f_code',
'co_consts', 'co_names',
})
for node in ast.walk(tree):
# Block import statements
if isinstance(node, (ast.Import, ast.ImportFrom)):
return f"Import statements are not allowed"
# Block attribute access to dangerous dunders
if isinstance(node, ast.Attribute):
if node.attr in _blocked_attrs:
return (
f"Access to attribute '{node.attr}' is restricted"
)
# Block calls to dangerous builtins by name
if isinstance(node, ast.Call):
func = node.func
if isinstance(func, ast.Name) and func.id in (
'exec', 'eval', 'compile', '__import__',
'open', 'input', 'breakpoint',
'setattr', 'delattr', 'dir',
):
return f"Call to '{func.id}' is not allowed"
return None
@require_approval(risk_level="critical")
def execute_code(
self,
code: str,
globals_dict: Optional[Dict[str, Any]] = None,
locals_dict: Optional[Dict[str, Any]] = None,
timeout: int = 30,
max_output_size: int = 10000
) -> Dict[str, Any]:
"""Execute Python code safely with restricted builtins."""
try:
# Create safe builtins - restricted set of functions
safe_builtins = {
# Basic functions
'print': print,
'len': len,
'range': range,
'enumerate': enumerate,
'zip': zip,
'map': map,
'filter': filter,
'sum': sum,
'min': min,
'max': max,
'abs': abs,
'round': round,
'sorted': sorted,
'reversed': reversed,
'any': any,
'all': all,
# Type constructors
'int': int,
'float': float,
'str': str,
'bool': bool,
'list': list,
'tuple': tuple,
'dict': dict,
'set': set,
# Math functions
'pow': pow,
'divmod': divmod,
# Exceptions
'Exception': Exception,
'ValueError': ValueError,
'TypeError': TypeError,
'KeyError': KeyError,
'IndexError': IndexError,
'RuntimeError': RuntimeError,
# Safe introspection (dunder-blocked wrapper)
'isinstance': isinstance,
'type': type,
'hasattr': hasattr,
'getattr': self._safe_getattr,
# Class definition support
'__build_class__': __builtins__['__build_class__'] if isinstance(__builtins__, dict) else getattr(__builtins__, '__build_class__', None),
# Disable dangerous functions
'__import__': None,
'eval': None,
'exec': None,
'compile': None,
'open': None,
'input': None,
'globals': None,
'locals': None,
'vars': None,
}
# Set up execution environment with safe builtins
if globals_dict is None:
globals_dict = {'__builtins__': safe_builtins}
else:
# Override builtins in provided globals
globals_dict['__builtins__'] = safe_builtins
if locals_dict is None:
locals_dict = {}
# Security check 1: AST-based validation (cannot be bypassed
# by string concatenation or runtime tricks)
ast_error = self._validate_code_ast(code)
if ast_error:
return {
'result': None,
'stdout': '',
'stderr': f'Security Error: {ast_error}',
'success': False
}
# Security check 2: text-based patterns (defense-in-depth)
dangerous_patterns = [
'__import__', 'import ', 'from ', 'exec', 'eval',
'compile', 'open(', 'file(', 'input(', 'raw_input',
'__subclasses__', '__bases__', '__globals__', '__code__',
'__class__', 'globals(', 'locals(', 'vars('
]
code_lower = code.lower()
for pattern in dangerous_patterns:
if pattern.lower() in code_lower:
return {
'result': None,
'stdout': '',
'stderr': f'Security Error: Code contains restricted pattern: {pattern}',
'success': False
}
# Capture output
stdout_buffer = io.StringIO()
stderr_buffer = io.StringIO()
try:
# Compile code with restricted mode
compiled_code = compile(code, '<string>', 'exec')
# Execute with output capture
with redirect_stdout(stdout_buffer), redirect_stderr(stderr_buffer):
exec(compiled_code, globals_dict, locals_dict)
# Get last expression value if any
import ast
tree = ast.parse(code)
if tree.body and isinstance(tree.body[-1], ast.Expr):
result = eval(
compile(ast.Expression(tree.body[-1].value), '<string>', 'eval'),
globals_dict,
locals_dict
)
else:
result = None
# Get output
stdout = stdout_buffer.getvalue()
stderr = stderr_buffer.getvalue()
# Truncate output if too large (use smart format)
if len(stdout) > max_output_size:
tail_size = min(max_output_size // 5, 500)
stdout = stdout[:max_output_size - tail_size] + f"\n...[{len(stdout):,} chars, showing first/last portions]...\n" + stdout[-tail_size:]
if len(stderr) > max_output_size:
tail_size = min(max_output_size // 5, 500)
stderr = stderr[:max_output_size - tail_size] + f"\n...[{len(stderr):,} chars, showing first/last portions]...\n" + stderr[-tail_size:]
return {
'result': result,
'stdout': stdout,
'stderr': stderr,
'success': True
}
except Exception as e:
error_msg = f"Error executing code: {str(e)}"
logging.error(error_msg)
return {
'result': None,
'stdout': stdout_buffer.getvalue(),
'stderr': error_msg,
'success': False
}
except Exception as e:
error_msg = f"Error executing code: {str(e)}"
logging.error(error_msg)
return {
'result': None,
'stdout': '',
'stderr': error_msg,
'success': False
}
def analyze_code(
self,
code: str
) -> Optional[Dict[str, Any]]:
"""Analyze Python code structure and quality.
Args:
code: Python code to analyze
Returns:
Dictionary with analysis results
"""
try:
# Import ast only when needed
import ast
# Parse code
tree = ast.parse(code)
# Analyze structure
analysis = {
'imports': [],
@@ -300,7 +320,7 @@ class PythonTools:
'branches': 0
}
}
# Analyze nodes
for node in ast.walk(tree):
if isinstance(node, ast.Import):
@@ -338,7 +358,7 @@ class PythonTools:
analysis['variables'].append(target.id)
elif isinstance(node, (ast.If, ast.While, ast.For)):
analysis['complexity']['branches'] += 1
return analysis
except Exception as e:
error_msg = f"Error analyzing code: {str(e)}"
@@ -352,12 +372,12 @@ class PythonTools:
line_length: int = 88
) -> Optional[str]:
"""Format Python code according to style guide.
Args:
code: Python code to format
style: Formatting style ('black' or 'pep8')
line_length: Maximum line length
Returns:
Formatted code
"""
@@ -393,10 +413,10 @@ class PythonTools:
code: str
) -> Optional[Dict[str, List[Dict[str, Any]]]]:
"""Lint Python code for potential issues.
Args:
code: Python code to lint
Returns:
Dictionary with linting results
"""
@@ -409,7 +429,7 @@ class PythonTools:
# Import pylint only when needed
from pylint.reporters import JSONReporter
from pylint.lint.run import Run
# Create temporary file for pylint
import tempfile
with tempfile.NamedTemporaryFile(
@@ -419,7 +439,7 @@ class PythonTools:
) as f:
f.write(code)
temp_path = f.name
# Run pylint
reporter = JSONReporter()
Run(
@@ -427,14 +447,14 @@ class PythonTools:
reporter=reporter,
exit=False
)
# Process results
results = {
'errors': [],
'warnings': [],
'conventions': []
}
for msg in reporter.messages:
item = {
'type': msg.category,
@@ -447,18 +467,18 @@ class PythonTools:
'message': msg.msg,
'message-id': msg.msg_id
}
if msg.category in ['error', 'fatal']:
results['errors'].append(item)
elif msg.category == 'warning':
results['warnings'].append(item)
else:
results['conventions'].append(item)
# Clean up
import os
os.unlink(temp_path)
return results
except Exception as e:
error_msg = f"Error linting code: {str(e)}"
@@ -474,38 +494,60 @@ class PythonTools:
code: str
) -> Optional[str]:
"""Disassemble Python code to bytecode.
Args:
code: Python code to disassemble
Returns:
Disassembled bytecode as string
"""
try:
# Import dis only when needed
import dis
# Compile code
compiled_code = compile(code, '<string>', 'exec')
# Capture disassembly
output = io.StringIO()
with redirect_stdout(output):
dis.dis(compiled_code)
return output.getvalue()
except Exception as e:
error_msg = f"Error disassembling code: {str(e)}"
logging.error(error_msg)
return None
# Create instance for direct function access
_python_tools = PythonTools()
execute_code = _python_tools.execute_code
analyze_code = _python_tools.analyze_code
format_code = _python_tools.format_code
lint_code = _python_tools.lint_code
disassemble_code = _python_tools.disassemble_code
# Lazy accessors for optional-dep tools (PythonTools requires black/pylint/autopep8)
# execute_code is already a standalone function above — always available.
def _get_python_tools():
"""Lazy-init PythonTools (requires black/pylint/autopep8)."""
global _python_tools_instance
try:
return _python_tools_instance
except NameError:
_python_tools_instance = PythonTools()
return _python_tools_instance
_python_tools_instance = None
def analyze_code(code: str) -> Optional[Dict[str, Any]]:
"""Analyze Python code structure and quality. Requires: pip install black pylint autopep8"""
return _get_python_tools().analyze_code(code)
def format_code(code: str, style: str = 'black', line_length: int = 88) -> Optional[str]:
"""Format Python code. Requires: pip install black pylint autopep8"""
return _get_python_tools().format_code(code, style, line_length)
def lint_code(code: str) -> Optional[Dict[str, List[Dict[str, Any]]]]:
"""Lint Python code. Requires: pip install black pylint autopep8"""
return _get_python_tools().lint_code(code)
def disassemble_code(code: str) -> Optional[str]:
"""Disassemble Python code to bytecode. Requires: pip install black pylint autopep8"""
return _get_python_tools().disassemble_code(code)
if __name__ == "__main__":
print("\n==================================================")
@@ -0,0 +1,250 @@
"""Skill Import Bridge — migrates OpenClaw-style skill files to PraisonAI tools.
Parses SKILL.md manifests and Python tool definitions from OpenClaw's
directory structure and converts them into PraisonAI @tool-decorated functions.
Usage::
from praisonaiagents.tools.skill_bridge import import_skill, scan_skills
# Import a single skill
tools = import_skill("/path/to/openclaw/skills/web_search/")
# Scan a skills directory
report = scan_skills("/path/to/openclaw/skills/")
"""
import ast
import logging
import os
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
@dataclass
class SkillInfo:
"""Parsed skill metadata from a SKILL.md or __init__.py file."""
name: str = ""
description: str = ""
source_path: str = ""
functions: List[str] = field(default_factory=list)
dependencies: List[str] = field(default_factory=list)
security_warnings: List[str] = field(default_factory=list)
compatible: bool = True
error: Optional[str] = None
# Security patterns to flag during import
_SECURITY_PATTERNS = [
(r"subprocess\.(call|run|Popen|check_output)", "Subprocess execution detected"),
(r"os\.system\s*\(", "os.system() call detected"),
(r"eval\s*\(", "eval() usage detected"),
(r"exec\s*\(", "exec() usage detected"),
(r"__import__\s*\(", "Dynamic import detected"),
(r"pickle\.loads?\s*\(", "Pickle deserialization detected"),
(r"shutil\.rmtree\s*\(", "Recursive directory deletion detected"),
]
def scan_skills(skills_dir: str) -> List[SkillInfo]:
"""Scan a directory of skills and return metadata for each.
Args:
skills_dir: Path to directory containing skill subdirectories.
Returns:
List of SkillInfo for each discovered skill.
"""
skills_path = Path(skills_dir)
if not skills_path.is_dir():
logger.error(f"Skills directory not found: {skills_dir}")
return []
results = []
for child in sorted(skills_path.iterdir()):
if child.is_dir() and not child.name.startswith((".", "_")):
info = _parse_skill_dir(child)
results.append(info)
return results
def import_skill(skill_path: str) -> List[Any]:
"""Import a skill directory and return PraisonAI-compatible tool functions.
Args:
skill_path: Path to a single skill directory.
Returns:
List of callable tool functions that can be passed to Agent(tools=[...]).
"""
path = Path(skill_path)
info = _parse_skill_dir(path)
if info.security_warnings:
logger.warning(
f"[skill-bridge] Security warnings for '{info.name}':\n"
+ "\n".join(f" ⚠ {w}" for w in info.security_warnings)
)
if not info.compatible:
logger.error(f"[skill-bridge] Skill '{info.name}' is not compatible: {info.error}")
return []
# Try to load Python functions from the skill
tools = []
py_files = list(path.glob("*.py"))
for py_file in py_files:
if py_file.name.startswith("_"):
continue
try:
funcs = _extract_tool_functions(py_file)
tools.extend(funcs)
except Exception as e:
logger.warning(f"[skill-bridge] Failed to extract tools from {py_file}: {e}")
return tools
def format_scan_report(skills: List[SkillInfo]) -> str:
"""Format scan results for terminal display.
Args:
skills: List of SkillInfo from scan_skills().
Returns:
Formatted string for terminal output.
"""
if not skills:
return "No skills found."
lines = []
lines.append(f"{'Skill':<25} {'Functions':<10} {'Deps':<10} {'Warnings':<10} {'Compatible':<10}")
lines.append("-" * 65)
for s in skills:
compat = "✅" if s.compatible else "❌"
lines.append(
f"{s.name:<25} "
f"{len(s.functions):<10} "
f"{len(s.dependencies):<10} "
f"{len(s.security_warnings):<10} "
f"{compat:<10}"
)
total = len(skills)
compat_count = sum(1 for s in skills if s.compatible)
warns = sum(len(s.security_warnings) for s in skills)
lines.append("")
lines.append(f"Total: {total} skills, {compat_count} compatible, {warns} security warnings")
return "\n".join(lines)
# ── internals ────────────────────────────────────────────────────
def _parse_skill_dir(skill_dir: Path) -> SkillInfo:
"""Parse a single skill directory into SkillInfo."""
info = SkillInfo(
name=skill_dir.name,
source_path=str(skill_dir),
)
# Parse SKILL.md if present
skill_md = skill_dir / "SKILL.md"
if skill_md.exists():
try:
content = skill_md.read_text(encoding="utf-8", errors="replace")
info.description = _extract_description(content)
except Exception as e:
info.error = f"Failed to read SKILL.md: {e}"
# Scan Python files
py_files = list(skill_dir.glob("*.py"))
for py_file in py_files:
if py_file.name.startswith("_"):
continue
try:
source = py_file.read_text(encoding="utf-8", errors="replace")
# Extract function names
tree = ast.parse(source)
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef):
info.functions.append(node.name)
# Extract imports as dependencies
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
info.dependencies.append(alias.name.split(".")[0])
elif isinstance(node, ast.ImportFrom):
if node.module:
info.dependencies.append(node.module.split(".")[0])
# Security scan
for pattern, warning in _SECURITY_PATTERNS:
if re.search(pattern, source):
info.security_warnings.append(f"{py_file.name}: {warning}")
except SyntaxError as e:
info.compatible = False
info.error = f"Syntax error in {py_file.name}: {e}"
# Deduplicate dependencies
info.dependencies = sorted(set(info.dependencies))
return info
def _extract_description(content: str) -> str:
"""Extract description from SKILL.md content.
Handles both YAML frontmatter and plain markdown.
"""
# Try YAML frontmatter
if content.startswith("---"):
parts = content.split("---", 2)
if len(parts) >= 3:
frontmatter = parts[1]
for line in frontmatter.strip().split("\n"):
if line.strip().startswith("description:"):
return line.split(":", 1)[1].strip().strip("'\"")
# Fallback: first non-empty, non-heading line
for line in content.split("\n"):
stripped = line.strip()
if stripped and not stripped.startswith("#"):
return stripped[:200]
return ""
def _extract_tool_functions(py_file: Path) -> list:
"""Extract functions from a Python file and wrap them as simple callables.
This does NOT execute the file — it uses AST parsing to read function
signatures and returns wrapper info. The actual import requires the
user to install the skill's dependencies.
"""
source = py_file.read_text(encoding="utf-8", errors="replace")
tree = ast.parse(source)
functions = []
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and not node.name.startswith("_"):
# Extract docstring
docstring = ast.get_docstring(node) or f"Tool from {py_file.name}"
# Create a metadata dict (not executing the code)
func_info = {
"name": node.name,
"description": docstring,
"source_file": str(py_file),
"args": [arg.arg for arg in node.args.args if arg.arg != "self"],
}
functions.append(func_info)
return functions
+3 -3
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.5.80"
version = "1.5.87"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -29,7 +29,7 @@ mcp = [
memory = [
"chromadb>=1.0.0",
"litellm>=1.81.0",
"litellm>=1.81.0,<=1.82.6",
]
knowledge = [
@@ -47,7 +47,7 @@ graph = [
# Add LLM dependencies
llm = [
"litellm>=1.81.0",
"litellm>=1.81.0,<=1.82.6",
"pydantic>=2.10.0"
]
+9 -6
View File
@@ -24,9 +24,12 @@ if __name__ == "__main__":
script_to_run = sys.argv[1]
# Execute the target script
with open(script_to_run, 'r') as f:
script_content = f.read()
# Execute in the same namespace
exec(script_content, {'__name__': '__main__'})
# Execute the target script in a subprocess to avoid sharing the current namespace
import subprocess
result = subprocess.run(
[sys.executable, script_to_run],
stdin=sys.stdin,
stdout=sys.stdout,
stderr=sys.stderr,
)
sys.exit(result.returncode)
@@ -0,0 +1,151 @@
"""Real LLM integration test for F1 + F3 features.
This test makes ACTUAL LLM calls to verify:
1. F1: Token budget tracks cost (total_cost > 0 after a real call)
2. F1: cost_summary returns valid data
3. F3: Permission tiers block tools at runtime
4. F2: Heartbeat/HeartbeatConfig import and init with real Agent
Requires OPENAI_API_KEY in environment.
"""
import os
import sys
import pytest
# Skip if no API key available
pytestmark = pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY"),
reason="OPENAI_API_KEY not set — skipping real LLM test"
)
class TestRealLLMBudgetGuard:
"""Real LLM integration test for F1 Token Budget Guard."""
def test_real_agent_tracks_cost(self):
"""Run a REAL agent with a budget, verify cost/token tracking."""
from praisonaiagents import Agent
from praisonaiagents.config.feature_configs import ExecutionConfig
agent = Agent(
name="budget_test",
instructions="You are a helpful assistant. Be very brief.",
llm="gpt-4o-mini",
execution=ExecutionConfig(max_budget=1.00), # $1 budget
)
result = agent.start("Say hello in exactly 5 words.")
# Verify LLM responded
assert result is not None
result_text = str(result)
assert len(result_text) > 0
print(f"\n[LLM Response]: {result_text}")
# Verify cost tracking
assert agent.total_cost > 0, "Cost should be > 0 after real LLM call"
summary = agent.cost_summary
assert summary["llm_calls"] >= 1, "Should record at least 1 LLM call"
assert summary["tokens_in"] > 0, "Should record input tokens"
assert summary["tokens_out"] > 0, "Should record output tokens"
assert summary["cost"] > 0, "Cost should be > 0"
print(f"[Cost Summary]: {summary}")
print(f"[Total Cost]: ${agent.total_cost:.6f}")
def test_real_agent_no_budget_still_tracks(self):
"""Agent without budget should still track cost (zero overhead confirmed)."""
from praisonaiagents import Agent
agent = Agent(
name="no_budget_test",
instructions="You are helpful. Be extremely brief.",
llm="gpt-4o-mini",
)
result = agent.start("Reply with just the word 'OK'.")
assert result is not None
print(f"\n[LLM Response]: {str(result)}")
# Cost tracking still works (always active)
summary = agent.cost_summary
print(f"[Cost Summary]: {summary}")
assert summary["llm_calls"] >= 1
class TestRealLLMPermissions:
"""Real LLM integration test for F3 Permission Tiers."""
def test_safe_mode_agent_runs_normally(self):
"""Agent with approval='safe' can still answer questions (no tools used)."""
from praisonaiagents import Agent
agent = Agent(
name="safe_agent",
instructions="You are helpful. Be very brief.",
llm="gpt-4o-mini",
approval="safe",
)
result = agent.start("What is 2+2? Reply with just the number.")
assert result is not None
result_text = str(result)
assert "4" in result_text
print(f"\n[Safe Agent Response]: {result_text}")
# Verify permission deny set is active
assert len(agent._perm_deny) > 0
assert "execute_command" in agent._perm_deny
class TestRealHeartbeatInit:
"""Real integration test for F2 Heartbeat with actual Agent."""
def test_heartbeat_with_real_agent(self):
"""Heartbeat initializes correctly with a real Agent instance."""
from praisonaiagents import Agent, Heartbeat
agent = Agent(
name="heartbeat_test",
instructions="Monitor status briefly.",
llm="gpt-4o-mini",
)
hb = Heartbeat(agent, schedule="every 30m", prompt="Quick status check")
assert hb.agent is agent
assert hb.config.schedule == "every 30m"
assert hb.config.prompt == "Quick status check"
assert hb._interval_seconds == 1800.0
assert not hb.is_running
class TestRealCostPersistence:
"""Real integration test for F4 cost persistence after actual LLM call."""
def test_save_real_cost_report(self, tmp_path):
"""Run real agent, save cost report, verify it persists."""
from praisonaiagents import Agent
from praisonaiagents.agent.cost_persistence import save_cost_report, load_cost_report
from unittest.mock import patch
agent = Agent(
name="cost_persist_test",
instructions="Be extremely brief.",
llm="gpt-4o-mini",
)
agent.start("Say 'hi'.")
with patch("praisonaiagents.agent.cost_persistence.COST_DIR", tmp_path):
filepath = save_cost_report(agent, session_name="real_test")
loaded = load_cost_report("real_test")
assert loaded is not None
assert loaded["agent_name"] == "cost_persist_test"
assert loaded["llm_calls"] >= 1
print(f"\n[Saved Cost Report]: {loaded}")
@@ -0,0 +1,73 @@
"""
TDD tests for compaction wiring into agent.
Tests are written BEFORE implementation (TDD).
Verifies ExecutionConfig gets context_compaction field,
and ContextCompactor is called when enabled.
"""
import pytest
from unittest.mock import MagicMock, patch
class TestExecutionConfigCompactionFields:
"""ExecutionConfig must have context_compaction and max_context_tokens fields."""
def test_has_context_compaction_field(self):
from praisonaiagents.config.feature_configs import ExecutionConfig
cfg = ExecutionConfig()
assert hasattr(cfg, "context_compaction")
assert cfg.context_compaction is False # safe default
def test_has_max_context_tokens_field(self):
from praisonaiagents.config.feature_configs import ExecutionConfig
cfg = ExecutionConfig()
assert hasattr(cfg, "max_context_tokens")
assert cfg.max_context_tokens is None # auto by default
def test_context_compaction_opt_in(self):
from praisonaiagents.config.feature_configs import ExecutionConfig
cfg = ExecutionConfig(context_compaction=True, max_context_tokens=8000)
assert cfg.context_compaction is True
assert cfg.max_context_tokens == 8000
class TestContextCompactorExists:
"""ContextCompactor must be importable and functional (existing module)."""
def test_import(self):
from praisonaiagents.compaction import ContextCompactor
assert ContextCompactor is not None
def test_needs_compaction_false_within_limit(self):
from praisonaiagents.compaction import ContextCompactor
compactor = ContextCompactor(max_tokens=10000)
short_msgs = [{"role": "user", "content": "Hello"}, {"role": "assistant", "content": "Hi"}]
assert compactor.needs_compaction(short_msgs) is False
def test_needs_compaction_true_over_limit(self):
from praisonaiagents.compaction import ContextCompactor
compactor = ContextCompactor(max_tokens=10) # tiny limit
long_msgs = [{"role": "user", "content": "A" * 1000}]
assert compactor.needs_compaction(long_msgs) is True
def test_compact_reduces_tokens(self):
from praisonaiagents.compaction import ContextCompactor
compactor = ContextCompactor(max_tokens=100, preserve_recent=1)
many_msgs = [{"role": "user", "content": "Message " + str(i) * 50} for i in range(20)]
compacted_msgs, result = compactor.compact(many_msgs)
assert result.compacted_tokens < result.original_tokens
class TestCompactionHookEvents:
"""BEFORE_COMPACTION and AFTER_COMPACTION must be defined in HookEvent."""
def test_before_compaction_event_exists(self):
from praisonaiagents.hooks.types import HookEvent
assert hasattr(HookEvent, "BEFORE_COMPACTION")
assert HookEvent.BEFORE_COMPACTION == "before_compaction"
def test_after_compaction_event_exists(self):
from praisonaiagents.hooks.types import HookEvent
assert hasattr(HookEvent, "AFTER_COMPACTION")
assert HookEvent.AFTER_COMPACTION == "after_compaction"
@@ -0,0 +1,117 @@
"""Tests for Cost Dashboard persistence (F4).
Tests save/load/list/format for cost reports in ~/.praisonai/costs/.
"""
import json
import os
import tempfile
import time
import pytest
from unittest.mock import MagicMock, patch
from pathlib import Path
class TestSaveCostReport:
"""Test saving cost reports to disk."""
def test_save_creates_file(self, tmp_path):
from praisonaiagents.agent.cost_persistence import save_cost_report
agent = MagicMock()
agent.name = "test_agent"
agent.cost_summary = {"tokens_in": 100, "tokens_out": 50, "cost": 0.001, "llm_calls": 1}
with patch("praisonaiagents.agent.cost_persistence.COST_DIR", tmp_path):
filepath = save_cost_report(agent, session_name="test_session")
assert os.path.exists(filepath)
def test_save_content(self, tmp_path):
from praisonaiagents.agent.cost_persistence import save_cost_report
agent = MagicMock()
agent.name = "my_agent"
agent.cost_summary = {"tokens_in": 500, "tokens_out": 200, "cost": 0.05, "llm_calls": 3}
with patch("praisonaiagents.agent.cost_persistence.COST_DIR", tmp_path):
filepath = save_cost_report(agent, session_name="s1")
with open(filepath) as f:
data = json.load(f)
assert data["agent_name"] == "my_agent"
assert data["cost"] == 0.05
assert data["tokens_in"] == 500
assert data["llm_calls"] == 3
def test_save_auto_session_name(self, tmp_path):
from praisonaiagents.agent.cost_persistence import save_cost_report
agent = MagicMock()
agent.name = "bot"
agent.cost_summary = {"tokens_in": 0, "tokens_out": 0, "cost": 0.0, "llm_calls": 0}
with patch("praisonaiagents.agent.cost_persistence.COST_DIR", tmp_path):
filepath = save_cost_report(agent)
assert "bot_" in filepath
class TestLoadCostReport:
"""Test loading cost reports from disk."""
def test_load_existing(self, tmp_path):
from praisonaiagents.agent.cost_persistence import load_cost_report
data = {"session": "test", "cost": 0.01}
(tmp_path / "test.json").write_text(json.dumps(data))
with patch("praisonaiagents.agent.cost_persistence.COST_DIR", tmp_path):
result = load_cost_report("test")
assert result["cost"] == 0.01
def test_load_missing(self, tmp_path):
from praisonaiagents.agent.cost_persistence import load_cost_report
with patch("praisonaiagents.agent.cost_persistence.COST_DIR", tmp_path):
result = load_cost_report("nonexistent")
assert result is None
class TestListCostReports:
"""Test listing cost reports."""
def test_list_empty(self, tmp_path):
from praisonaiagents.agent.cost_persistence import list_cost_reports
with patch("praisonaiagents.agent.cost_persistence.COST_DIR", tmp_path):
results = list_cost_reports()
assert results == []
def test_list_multiple(self, tmp_path):
from praisonaiagents.agent.cost_persistence import list_cost_reports
for i in range(3):
data = {"session": f"s{i}", "cost": i * 0.01}
(tmp_path / f"s{i}.json").write_text(json.dumps(data))
with patch("praisonaiagents.agent.cost_persistence.COST_DIR", tmp_path):
results = list_cost_reports()
assert len(results) == 3
def test_list_limit(self, tmp_path):
from praisonaiagents.agent.cost_persistence import list_cost_reports
for i in range(10):
data = {"session": f"s{i}", "cost": i * 0.01}
(tmp_path / f"s{i}.json").write_text(json.dumps(data))
with patch("praisonaiagents.agent.cost_persistence.COST_DIR", tmp_path):
results = list_cost_reports(limit=5)
assert len(results) == 5
class TestFormatCostTable:
"""Test terminal formatting."""
def test_format_empty(self):
from praisonaiagents.agent.cost_persistence import format_cost_table
assert format_cost_table([]) == "No cost reports found."
def test_format_as_json(self):
from praisonaiagents.agent.cost_persistence import format_cost_table
reports = [{"session": "s1", "cost": 0.01}]
result = format_cost_table(reports, as_json=True)
parsed = json.loads(result)
assert parsed[0]["cost"] == 0.01
def test_format_table(self):
from praisonaiagents.agent.cost_persistence import format_cost_table
reports = [{"session": "s1", "agent_name": "bot", "cost": 0.05, "tokens_in": 100, "tokens_out": 50, "llm_calls": 2}]
result = format_cost_table(reports)
assert "s1" in result
assert "bot" in result
assert "0.0500" in result
@@ -0,0 +1,130 @@
"""Tests for Heartbeat Agent (F2).
Tests the standalone Heartbeat class that runs agents on a schedule.
"""
import pytest
from unittest.mock import MagicMock, patch
class TestHeartbeatImports:
"""Test lazy imports work correctly."""
def test_import_from_agent_module(self):
from praisonaiagents.agent import Heartbeat
assert Heartbeat is not None
def test_import_from_package(self):
from praisonaiagents import Heartbeat
assert Heartbeat is not None
def test_import_config(self):
from praisonaiagents import HeartbeatConfig
assert HeartbeatConfig is not None
def test_import_config_from_agent(self):
from praisonaiagents.agent import HeartbeatConfig
assert HeartbeatConfig is not None
class TestHeartbeatConfig:
"""Test HeartbeatConfig dataclass."""
def test_defaults(self):
from praisonaiagents.agent.heartbeat import HeartbeatConfig
cfg = HeartbeatConfig()
assert cfg.schedule == "hourly"
assert cfg.prompt is None
assert cfg.on_result is None
assert cfg.on_error == "retry"
assert cfg.max_retries == 3
def test_custom_values(self):
from praisonaiagents.agent.heartbeat import HeartbeatConfig
cb = lambda x: x
cfg = HeartbeatConfig(schedule="daily", prompt="check", on_result=cb, max_retries=5)
assert cfg.schedule == "daily"
assert cfg.prompt == "check"
assert cfg.on_result is cb
assert cfg.max_retries == 5
class TestHeartbeatInit:
"""Test Heartbeat initialization."""
def test_creates_with_agent(self):
from praisonaiagents.agent.heartbeat import Heartbeat
agent = MagicMock(name="test_agent")
hb = Heartbeat(agent, schedule="hourly")
assert hb.agent is agent
assert hb.config.schedule == "hourly"
assert hb._running is False
def test_hourly_interval(self):
from praisonaiagents.agent.heartbeat import Heartbeat
hb = Heartbeat(MagicMock(), schedule="hourly")
assert hb._interval_seconds == 3600.0
def test_daily_interval(self):
from praisonaiagents.agent.heartbeat import Heartbeat
hb = Heartbeat(MagicMock(), schedule="daily")
assert hb._interval_seconds == 86400.0
def test_weekly_interval(self):
from praisonaiagents.agent.heartbeat import Heartbeat
hb = Heartbeat(MagicMock(), schedule="weekly")
assert hb._interval_seconds == 604800.0
def test_every_30m_interval(self):
from praisonaiagents.agent.heartbeat import Heartbeat
hb = Heartbeat(MagicMock(), schedule="every 30m")
assert hb._interval_seconds == 1800.0
def test_every_6h_interval(self):
from praisonaiagents.agent.heartbeat import Heartbeat
hb = Heartbeat(MagicMock(), schedule="every 6h")
assert hb._interval_seconds == 21600.0
def test_every_10s_interval(self):
from praisonaiagents.agent.heartbeat import Heartbeat
hb = Heartbeat(MagicMock(), schedule="every 10s")
assert hb._interval_seconds == 10.0
class TestHeartbeatStartStop:
"""Test start/stop lifecycle."""
def test_start_nonblocking(self):
from praisonaiagents.agent.heartbeat import Heartbeat
agent = MagicMock()
agent.start.side_effect = Exception("stop")
hb = Heartbeat(agent, schedule="hourly", max_retries=1)
hb.start(blocking=False)
assert hb._running is True
hb.stop()
assert hb._running is False
def test_stop_sets_flag(self):
from praisonaiagents.agent.heartbeat import Heartbeat
hb = Heartbeat(MagicMock(), schedule="hourly")
hb._running = True
hb.stop()
assert not hb.is_running
def test_is_running_property(self):
from praisonaiagents.agent.heartbeat import Heartbeat
hb = Heartbeat(MagicMock(), schedule="hourly")
assert not hb.is_running
hb._running = True
assert hb.is_running
class TestHeartbeatDoesNotModifyAgent:
"""Verify Heartbeat is truly standalone."""
def test_agent_class_unchanged(self):
"""Agent class should have no heartbeat-related attributes."""
from praisonaiagents import Agent
agent = Agent(name="test", instructions="test")
assert not hasattr(agent, '_heartbeat')
assert not hasattr(agent, 'heartbeat')
@@ -0,0 +1,223 @@
"""
TDD tests for PraisonAI tool loop detection.
Tests are written BEFORE implementation (TDD).
Port of OpenClaw's tool-loop-detection.ts patterns to Python.
"""
import hashlib
import json
import pytest
from unittest.mock import patch
# ---------------------------------------------------------------------------
# Helper: compute expected hash the same way the module will
# ---------------------------------------------------------------------------
def _hash(tool_name: str, args: dict) -> str:
stable = json.dumps({"t": tool_name, "a": args}, sort_keys=True, default=str)
return hashlib.sha256(stable.encode()).hexdigest()[:16]
# ---------------------------------------------------------------------------
# Import the module under test (will fail until implemented — that's TDD)
# ---------------------------------------------------------------------------
class TestLoopDetectionImport:
"""The module must be importable with zero side-effects."""
def test_module_importable(self):
from praisonaiagents.agent.loop_detection import (
LoopDetectionConfig,
LoopDetectionResult,
detect_tool_loop,
record_tool_call,
record_tool_outcome,
hash_tool_call,
)
assert LoopDetectionConfig is not None
assert detect_tool_loop is not None
def test_no_side_effects_on_import(self):
"""Importing must not change global state or call LLM."""
import praisonaiagents.agent.loop_detection # noqa
class TestLoopDetectionConfig:
def setup_method(self):
from praisonaiagents.agent.loop_detection import LoopDetectionConfig
self.Config = LoopDetectionConfig
def test_default_disabled(self):
cfg = self.Config()
assert cfg.enabled is False # safe default: opt-in only
def test_custom_thresholds(self):
cfg = self.Config(enabled=True, warn_threshold=3, critical_threshold=5)
assert cfg.warn_threshold == 3
assert cfg.critical_threshold == 5
def test_critical_must_be_greater_than_warn(self):
"""If critical <= warn, it should be auto-corrected."""
cfg = self.Config(enabled=True, warn_threshold=10, critical_threshold=5)
assert cfg.critical_threshold > cfg.warn_threshold
class TestHashToolCall:
def test_hash_is_deterministic(self):
from praisonaiagents.agent.loop_detection import hash_tool_call
h1 = hash_tool_call("search", {"query": "foo"})
h2 = hash_tool_call("search", {"query": "foo"})
assert h1 == h2
def test_different_args_produce_different_hashes(self):
from praisonaiagents.agent.loop_detection import hash_tool_call
h1 = hash_tool_call("search", {"query": "foo"})
h2 = hash_tool_call("search", {"query": "bar"})
assert h1 != h2
def test_different_tools_produce_different_hashes(self):
from praisonaiagents.agent.loop_detection import hash_tool_call
h1 = hash_tool_call("search", {"query": "foo"})
h2 = hash_tool_call("fetch", {"query": "foo"})
assert h1 != h2
def test_arg_order_does_not_matter(self):
"""Stable JSON sort keys: {'a':1,'b':2} == {'b':2,'a':1}"""
from praisonaiagents.agent.loop_detection import hash_tool_call
h1 = hash_tool_call("t", {"b": 2, "a": 1})
h2 = hash_tool_call("t", {"a": 1, "b": 2})
assert h1 == h2
class TestDetectToolLoop:
def setup_method(self):
from praisonaiagents.agent.loop_detection import (
LoopDetectionConfig,
detect_tool_loop,
record_tool_call,
)
self.Config = LoopDetectionConfig
self.detect = detect_tool_loop
self.record = record_tool_call
# --- disabled ---
def test_disabled_always_returns_not_stuck(self):
cfg = self.Config(enabled=False)
history = []
# Fill history with many identical calls
for _ in range(50):
self.record(history, "search", {"q": "x"}, cfg)
result = self.detect(history, "search", {"q": "x"}, cfg)
assert result["stuck"] is False
# --- generic_repeat ---
def test_below_warn_threshold_not_stuck(self):
cfg = self.Config(enabled=True, warn_threshold=5, critical_threshold=10)
history = []
for _ in range(4):
self.record(history, "search", {"q": "foo"}, cfg)
result = self.detect(history, "search", {"q": "foo"}, cfg)
assert result["stuck"] is False
def test_at_warn_threshold_warning(self):
cfg = self.Config(enabled=True, warn_threshold=5, critical_threshold=10)
history = []
for _ in range(5):
self.record(history, "search", {"q": "foo"}, cfg)
result = self.detect(history, "search", {"q": "foo"}, cfg)
assert result["stuck"] is True
assert result["level"] == "warning"
assert result["detector"] == "generic_repeat"
def test_at_critical_threshold_critical(self):
cfg = self.Config(enabled=True, warn_threshold=5, critical_threshold=10)
history = []
for _ in range(10):
self.record(history, "search", {"q": "foo"}, cfg)
result = self.detect(history, "search", {"q": "foo"}, cfg)
assert result["stuck"] is True
assert result["level"] == "critical"
def test_different_tools_no_false_positive(self):
"""Alternating different tools should NOT trigger generic_repeat."""
cfg = self.Config(enabled=True, warn_threshold=3, critical_threshold=6)
history = []
for i in range(6):
self.record(history, "search", {"q": str(i)}, cfg) # different args each time
result = self.detect(history, "search", {"q": "0"}, cfg)
# Only 1 occurrence of {"q": "0"} — not stuck
assert result["stuck"] is False
# --- message content ---
def test_warning_message_contains_tool_name(self):
cfg = self.Config(enabled=True, warn_threshold=3, critical_threshold=10)
history = []
for _ in range(3):
self.record(history, "my_tool", {"x": 1}, cfg)
result = self.detect(history, "my_tool", {"x": 1}, cfg)
if result["stuck"]:
assert "my_tool" in result["message"]
def test_result_has_count_field(self):
cfg = self.Config(enabled=True, warn_threshold=3, critical_threshold=10)
history = []
for _ in range(3):
self.record(history, "t", {}, cfg)
result = self.detect(history, "t", {}, cfg)
assert "count" in result
class TestRecordToolCall:
def test_record_appends_to_history(self):
from praisonaiagents.agent.loop_detection import (
LoopDetectionConfig, record_tool_call
)
cfg = LoopDetectionConfig(enabled=True)
history = []
record_tool_call(history, "search", {"q": "test"}, cfg)
assert len(history) == 1
assert history[0]["tool_name"] == "search"
def test_history_respects_max_size(self):
from praisonaiagents.agent.loop_detection import (
LoopDetectionConfig, record_tool_call
)
cfg = LoopDetectionConfig(enabled=True, history_size=5)
history = []
for i in range(10):
record_tool_call(history, "t", {"i": i}, cfg)
assert len(history) <= 5
class TestRecordToolOutcome:
def test_outcome_updates_result_hash(self):
from praisonaiagents.agent.loop_detection import (
LoopDetectionConfig, record_tool_call, record_tool_outcome
)
cfg = LoopDetectionConfig(enabled=True)
history = []
record_tool_call(history, "search", {"q": "x"}, cfg)
assert history[0].get("result_hash") is None
record_tool_outcome(history, "search", {"q": "x"}, "some result", cfg)
assert history[0].get("result_hash") is not None
class TestLoopDetectionPlugin:
"""Tests for the plugin interface (hooks integration)."""
def test_plugin_importable(self):
from praisonaiagents.plugins import loop_detection_plugin # noqa
assert loop_detection_plugin is not None
def test_plugin_registers_hook(self):
"""Plugin import should register a BEFORE_TOOL hook."""
from praisonaiagents.hooks.registry import get_default_registry
from praisonaiagents.hooks.types import HookEvent
import praisonaiagents.plugins.loop_detection_plugin # noqa ensure imported
hooks = get_default_registry().get_hooks(HookEvent.BEFORE_TOOL)
assert len(hooks) >= 1
@@ -0,0 +1,169 @@
"""Tests for Permission Tiers (F3).
Tests the approval="safe"/"read_only"/"full" permission presets that provide
declarative tool access control via the existing approval= Agent param.
"""
import os
import pytest
from unittest.mock import patch, MagicMock
def _mock_registry_approve_sync(*args, **kwargs):
"""Mock approve_sync that auto-approves (no console prompt)."""
from praisonaiagents.approval.protocols import ApprovalDecision
return ApprovalDecision(approved=True, reason="mocked")
class TestPermissionPresets:
"""Test PERMISSION_PRESETS in approval/registry.py."""
def test_presets_exist(self):
from praisonaiagents.approval.registry import PERMISSION_PRESETS
assert "safe" in PERMISSION_PRESETS
assert "read_only" in PERMISSION_PRESETS
assert "full" in PERMISSION_PRESETS
def test_safe_preset_is_frozenset(self):
from praisonaiagents.approval.registry import PERMISSION_PRESETS
assert isinstance(PERMISSION_PRESETS["safe"], frozenset)
def test_safe_blocks_execute_command(self):
from praisonaiagents.approval.registry import PERMISSION_PRESETS
assert "execute_command" in PERMISSION_PRESETS["safe"]
def test_safe_blocks_delete_file(self):
from praisonaiagents.approval.registry import PERMISSION_PRESETS
assert "delete_file" in PERMISSION_PRESETS["safe"]
def test_safe_blocks_all_dangerous(self):
from praisonaiagents.approval.registry import PERMISSION_PRESETS, DEFAULT_DANGEROUS_TOOLS
for tool_name in DEFAULT_DANGEROUS_TOOLS:
assert tool_name in PERMISSION_PRESETS["safe"], f"{tool_name} not in safe preset"
def test_read_only_is_superset_of_safe(self):
from praisonaiagents.approval.registry import PERMISSION_PRESETS
assert PERMISSION_PRESETS["safe"].issubset(PERMISSION_PRESETS["read_only"])
def test_full_is_empty(self):
from praisonaiagents.approval.registry import PERMISSION_PRESETS
assert len(PERMISSION_PRESETS["full"]) == 0
class TestAgentPermissionInit:
"""Test that Agent correctly resolves permission presets from approval= param."""
def test_no_approval_no_denials(self):
from praisonaiagents import Agent
agent = Agent(name="test", instructions="test")
assert agent._perm_deny == frozenset()
assert agent._perm_allow is None
def test_approval_safe_sets_deny(self):
from praisonaiagents import Agent
agent = Agent(name="test", instructions="test", approval="safe")
assert len(agent._perm_deny) > 0
assert "execute_command" in agent._perm_deny
def test_approval_read_only_sets_deny(self):
from praisonaiagents import Agent
agent = Agent(name="test", instructions="test", approval="read_only")
assert "execute_command" in agent._perm_deny
assert "write_file" in agent._perm_deny
def test_approval_full_empty_deny(self):
from praisonaiagents import Agent
agent = Agent(name="test", instructions="test", approval="full")
assert agent._perm_deny == frozenset()
def test_approval_true_still_works(self):
"""Backward compat: approval=True should still create AutoApproveBackend."""
from praisonaiagents import Agent
agent = Agent(name="test", instructions="test", approval=True)
assert agent._approval_backend is not None
assert agent._perm_deny == frozenset()
def test_approval_false_still_works(self):
"""Backward compat: approval=False should still disable."""
from praisonaiagents import Agent
agent = Agent(name="test", instructions="test", approval=False)
assert agent._approval_backend is None
assert agent._perm_deny == frozenset()
def test_approval_none_still_works(self):
from praisonaiagents import Agent
agent = Agent(name="test", instructions="test", approval=None)
assert agent._approval_backend is None
class TestPermissionCheck:
"""Test _check_tool_approval_sync permission fast-path."""
def test_safe_blocks_execute_command(self):
from praisonaiagents import Agent
agent = Agent(name="test", instructions="test", approval="safe")
result = agent._check_tool_approval_sync("execute_command", {"command": "ls"})
assert isinstance(result, dict)
assert "permission_denied" in result
assert result["permission_denied"] is True
def test_safe_blocks_delete_file(self):
from praisonaiagents import Agent
agent = Agent(name="test", instructions="test", approval="safe")
result = agent._check_tool_approval_sync("delete_file", {"path": "/tmp/x"})
assert result.get("permission_denied") is True
def test_safe_allows_read_file(self):
"""read_file is NOT in the dangerous tools list, so it should pass."""
from praisonaiagents import Agent
mock_registry = MagicMock()
mock_registry.approve_sync.return_value = MagicMock(
approved=True, reason="mock", modified_args=None
)
mock_registry.mark_approved = MagicMock()
with patch("praisonaiagents.approval.get_approval_registry", return_value=mock_registry):
agent = Agent(name="test", instructions="test", approval="safe")
result = agent._check_tool_approval_sync("read_file", {"path": "/tmp/x"})
assert result is None or (isinstance(result, tuple) and len(result) == 2)
def test_full_allows_dangerous_tool(self):
"""approval='full' should not deny execute_command via permission tier."""
from praisonaiagents import Agent
mock_registry = MagicMock()
mock_registry.approve_sync.return_value = MagicMock(
approved=True, reason="mock", modified_args=None
)
mock_registry.mark_approved = MagicMock()
with patch("praisonaiagents.approval.get_approval_registry", return_value=mock_registry):
agent = Agent(name="test", instructions="test", approval="full")
result = agent._check_tool_approval_sync("execute_command", {"command": "ls"})
assert result is None or (isinstance(result, tuple) and len(result) == 2)
def test_no_approval_allows_non_dangerous(self):
"""Without approval= param, non-dangerous tools pass through."""
from praisonaiagents import Agent
mock_registry = MagicMock()
mock_registry.approve_sync.return_value = MagicMock(
approved=True, reason="mock", modified_args=None
)
mock_registry.mark_approved = MagicMock()
with patch("praisonaiagents.approval.get_approval_registry", return_value=mock_registry):
agent = Agent(name="test", instructions="test")
result = agent._check_tool_approval_sync("my_custom_tool", {})
assert result is None or (isinstance(result, tuple) and len(result) == 2)
class TestPermissionZeroOverhead:
"""Verify zero overhead when no permission preset is set."""
def test_empty_frozenset_falsy(self):
"""Empty frozenset is falsy, so 'if self._perm_deny' skips the check."""
from praisonaiagents import Agent
agent = Agent(name="test", instructions="test")
assert not agent._perm_deny # frozenset() is falsy
def test_none_perm_allow_skips(self):
"""None _perm_allow means 'if self._perm_allow is not None' skips."""
from praisonaiagents import Agent
agent = Agent(name="test", instructions="test")
assert agent._perm_allow is None
@@ -0,0 +1,180 @@
"""Tests for Skill Import Bridge (F5).
Tests scanning, parsing, and security analysis of OpenClaw-style skill directories.
"""
import os
import pytest
from pathlib import Path
class TestSkillInfo:
"""Test SkillInfo dataclass."""
def test_defaults(self):
from praisonaiagents.tools.skill_bridge import SkillInfo
info = SkillInfo()
assert info.name == ""
assert info.compatible is True
assert info.functions == []
assert info.security_warnings == []
class TestScanSkills:
"""Test scanning a directory of skills."""
def test_scan_empty_dir(self, tmp_path):
from praisonaiagents.tools.skill_bridge import scan_skills
results = scan_skills(str(tmp_path))
assert results == []
def test_scan_single_skill(self, tmp_path):
from praisonaiagents.tools.skill_bridge import scan_skills
skill_dir = tmp_path / "web_search"
skill_dir.mkdir()
(skill_dir / "tool.py").write_text("def search(query: str) -> str:\n \"\"\"Search the web.\"\"\"\n return query\n")
results = scan_skills(str(tmp_path))
assert len(results) == 1
assert results[0].name == "web_search"
assert "search" in results[0].functions
def test_scan_multiple_skills(self, tmp_path):
from praisonaiagents.tools.skill_bridge import scan_skills
for name in ["skill_a", "skill_b", "skill_c"]:
d = tmp_path / name
d.mkdir()
(d / "tool.py").write_text(f"def {name}_func(): pass\n")
results = scan_skills(str(tmp_path))
assert len(results) == 3
def test_scan_ignores_hidden_dirs(self, tmp_path):
from praisonaiagents.tools.skill_bridge import scan_skills
hidden = tmp_path / ".hidden_skill"
hidden.mkdir()
(hidden / "tool.py").write_text("def hidden(): pass\n")
results = scan_skills(str(tmp_path))
assert len(results) == 0
def test_scan_nonexistent_dir(self):
from praisonaiagents.tools.skill_bridge import scan_skills
results = scan_skills("/nonexistent/path")
assert results == []
class TestSkillMdParsing:
"""Test SKILL.md frontmatter/description parsing."""
def test_yaml_frontmatter(self, tmp_path):
from praisonaiagents.tools.skill_bridge import scan_skills
skill = tmp_path / "my_skill"
skill.mkdir()
(skill / "SKILL.md").write_text("---\ndescription: Search the web for information\n---\n# My Skill\n")
(skill / "search.py").write_text("def search(): pass\n")
results = scan_skills(str(tmp_path))
assert results[0].description == "Search the web for information"
def test_plain_markdown(self, tmp_path):
from praisonaiagents.tools.skill_bridge import scan_skills
skill = tmp_path / "my_skill"
skill.mkdir()
(skill / "SKILL.md").write_text("# My Skill\nThis skill does something cool.\n")
(skill / "tool.py").write_text("def cool(): pass\n")
results = scan_skills(str(tmp_path))
assert "something cool" in results[0].description
class TestSecurityScanner:
"""Test security pattern detection."""
def test_detects_subprocess(self, tmp_path):
from praisonaiagents.tools.skill_bridge import scan_skills
skill = tmp_path / "dangerous"
skill.mkdir()
(skill / "tool.py").write_text("import subprocess\ndef run(cmd):\n subprocess.run(cmd)\n")
results = scan_skills(str(tmp_path))
assert len(results[0].security_warnings) > 0
assert any("Subprocess" in w for w in results[0].security_warnings)
def test_detects_eval(self, tmp_path):
from praisonaiagents.tools.skill_bridge import scan_skills
skill = tmp_path / "eval_skill"
skill.mkdir()
(skill / "tool.py").write_text("def execute(code):\n return eval(code)\n")
results = scan_skills(str(tmp_path))
assert any("eval" in w for w in results[0].security_warnings)
def test_detects_os_system(self, tmp_path):
from praisonaiagents.tools.skill_bridge import scan_skills
skill = tmp_path / "os_skill"
skill.mkdir()
(skill / "tool.py").write_text("import os\ndef run(cmd):\n os.system(cmd)\n")
results = scan_skills(str(tmp_path))
assert any("os.system" in w for w in results[0].security_warnings)
def test_safe_skill_no_warnings(self, tmp_path):
from praisonaiagents.tools.skill_bridge import scan_skills
skill = tmp_path / "safe"
skill.mkdir()
(skill / "tool.py").write_text("def greet(name: str) -> str:\n return f'Hello, {name}'\n")
results = scan_skills(str(tmp_path))
assert len(results[0].security_warnings) == 0
class TestImportSkill:
"""Test importing skills as tool metadata."""
def test_import_extracts_functions(self, tmp_path):
from praisonaiagents.tools.skill_bridge import import_skill
skill = tmp_path / "my_skill"
skill.mkdir()
(skill / "tool.py").write_text(
"def search(query: str) -> str:\n"
" \"\"\"Search the web.\"\"\"\n"
" return query\n"
"\n"
"def fetch(url: str) -> str:\n"
" \"\"\"Fetch a URL.\"\"\"\n"
" return url\n"
)
tools = import_skill(str(skill))
assert len(tools) == 2
names = [t["name"] for t in tools]
assert "search" in names
assert "fetch" in names
def test_import_captures_docstrings(self, tmp_path):
from praisonaiagents.tools.skill_bridge import import_skill
skill = tmp_path / "my_skill"
skill.mkdir()
(skill / "tool.py").write_text("def calc(x: int) -> int:\n \"\"\"Calculate something.\"\"\"\n return x\n")
tools = import_skill(str(skill))
assert tools[0]["description"] == "Calculate something."
def test_import_skips_private(self, tmp_path):
from praisonaiagents.tools.skill_bridge import import_skill
skill = tmp_path / "my_skill"
skill.mkdir()
(skill / "tool.py").write_text("def public(): pass\ndef _private(): pass\n")
tools = import_skill(str(skill))
names = [t["name"] for t in tools]
assert "public" in names
assert "_private" not in names
class TestFormatScanReport:
"""Test terminal report formatting."""
def test_format_empty(self):
from praisonaiagents.tools.skill_bridge import format_scan_report
assert format_scan_report([]) == "No skills found."
def test_format_with_skills(self):
from praisonaiagents.tools.skill_bridge import SkillInfo, format_scan_report
skills = [
SkillInfo(name="web_search", functions=["search"], compatible=True),
SkillInfo(name="dangerous", functions=["run"], security_warnings=["eval"], compatible=False),
]
result = format_scan_report(skills)
assert "web_search" in result
assert "dangerous" in result
assert "2 skills" in result
@@ -0,0 +1,173 @@
"""Tests for Token Budget Guard (F1).
Tests the max_budget feature in ExecutionConfig that provides hard dollar
limits per agent run with zero overhead when disabled.
"""
import pytest
from unittest.mock import MagicMock, patch
class TestBudgetExceededError:
"""Test BudgetExceededError exception class."""
def test_import_from_agent_module(self):
from praisonaiagents.agent.agent import BudgetExceededError
assert issubclass(BudgetExceededError, Exception)
def test_import_from_package(self):
from praisonaiagents import BudgetExceededError
assert issubclass(BudgetExceededError, Exception)
def test_error_attributes(self):
from praisonaiagents.agent.agent import BudgetExceededError
err = BudgetExceededError("test_agent", 0.55, 0.50)
assert err.agent_name == "test_agent"
assert err.total_cost == 0.55
assert err.max_budget == 0.50
def test_error_message(self):
from praisonaiagents.agent.agent import BudgetExceededError
err = BudgetExceededError("researcher", 0.5123, 0.50)
assert "researcher" in str(err)
assert "$0.5123" in str(err)
assert "$0.5000" in str(err)
class TestExecutionConfigBudget:
"""Test max_budget fields on ExecutionConfig."""
def test_default_none(self):
from praisonaiagents import ExecutionConfig
config = ExecutionConfig()
assert config.max_budget is None
assert config.on_budget_exceeded == "stop"
def test_set_budget(self):
from praisonaiagents import ExecutionConfig
config = ExecutionConfig(max_budget=0.50)
assert config.max_budget == 0.50
assert config.on_budget_exceeded == "stop"
def test_warn_mode(self):
from praisonaiagents import ExecutionConfig
config = ExecutionConfig(max_budget=1.00, on_budget_exceeded="warn")
assert config.on_budget_exceeded == "warn"
def test_callable_mode(self):
from praisonaiagents import ExecutionConfig
handler = lambda cost, budget: None
config = ExecutionConfig(max_budget=1.00, on_budget_exceeded=handler)
assert callable(config.on_budget_exceeded)
def test_to_dict_includes_budget(self):
from praisonaiagents import ExecutionConfig
config = ExecutionConfig(max_budget=0.75)
d = config.to_dict()
assert d["max_budget"] == 0.75
class TestAgentBudgetInit:
"""Test that Agent correctly initializes budget tracking from ExecutionConfig."""
def test_no_budget_defaults(self):
"""Agent without max_budget has zero-cost tracking active but no limit."""
from praisonaiagents import Agent
agent = Agent(name="test", instructions="test")
assert agent._max_budget is None
assert agent._total_cost == 0.0
assert agent._total_tokens_in == 0
assert agent._total_tokens_out == 0
assert agent._llm_call_count == 0
def test_budget_from_execution_config(self):
"""Agent extracts max_budget from ExecutionConfig."""
from praisonaiagents import Agent, ExecutionConfig
agent = Agent(
name="test",
instructions="test",
execution=ExecutionConfig(max_budget=0.50),
)
assert agent._max_budget == 0.50
assert agent._on_budget_exceeded == "stop"
def test_budget_warn_mode(self):
from praisonaiagents import Agent, ExecutionConfig
agent = Agent(
name="test",
instructions="test",
execution=ExecutionConfig(max_budget=1.0, on_budget_exceeded="warn"),
)
assert agent._on_budget_exceeded == "warn"
class TestCostProperties:
"""Test total_cost and cost_summary read-only properties."""
def test_total_cost_initial(self):
from praisonaiagents import Agent
agent = Agent(name="test", instructions="test")
assert agent.total_cost == 0.0
def test_cost_summary_initial(self):
from praisonaiagents import Agent
agent = Agent(name="test", instructions="test")
summary = agent.cost_summary
assert summary == {
"tokens_in": 0,
"tokens_out": 0,
"cost": 0.0,
"llm_calls": 0,
}
def test_cost_accumulates(self):
"""Manually simulate cost accumulation to verify properties work."""
from praisonaiagents import Agent
agent = Agent(name="test", instructions="test")
# Simulate what _chat_completion does
agent._total_cost += 0.01
agent._total_tokens_in += 100
agent._total_tokens_out += 50
agent._llm_call_count += 1
assert agent.total_cost == 0.01
assert agent.cost_summary == {
"tokens_in": 100,
"tokens_out": 50,
"cost": 0.01,
"llm_calls": 1,
}
def test_cost_accumulates_multiple(self):
from praisonaiagents import Agent
agent = Agent(name="test", instructions="test")
for i in range(5):
agent._total_cost += 0.01
agent._total_tokens_in += 100
agent._total_tokens_out += 50
agent._llm_call_count += 1
assert agent.total_cost == pytest.approx(0.05)
assert agent.cost_summary["llm_calls"] == 5
assert agent.cost_summary["tokens_in"] == 500
class TestBudgetNoneOverhead:
"""Verify zero overhead when max_budget is None (the default path)."""
def test_none_budget_falsy(self):
"""None is falsy, so 'if self._max_budget' skips the check."""
from praisonaiagents import Agent
agent = Agent(name="test", instructions="test")
# This is the check in _chat_completion:
assert not agent._max_budget # None is falsy → skips budget check
def test_zero_budget_falsy(self):
"""0.0 budget is also falsy but shouldn't be used — verify behavior."""
from praisonaiagents import Agent, ExecutionConfig
agent = Agent(
name="test", instructions="test",
execution=ExecutionConfig(max_budget=0.0),
)
# 0.0 is falsy → budget check is skipped (intentional)
assert not agent._max_budget
@@ -14,15 +14,15 @@ import pytest
class _SandboxHarness:
"""Directly exercises PythonTools sandbox without installing black/pylint."""
"""Directly exercises execute_code sandbox without installing black/pylint."""
def __init__(self):
# Import the class but bypass _check_dependencies
from praisonaiagents.tools.python_tools import PythonTools
self._pt = PythonTools.__new__(PythonTools)
# Import the standalone execute_code function (no optional deps needed)
from praisonaiagents.tools.python_tools import execute_code
self._execute_code = execute_code
def run(self, code: str) -> dict:
return self._pt.execute_code.__wrapped__(self._pt, code)
return self._execute_code.__wrapped__(code)
@pytest.fixture
+3 -3
View File
@@ -2951,7 +2951,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.5.80"
version = "1.5.87"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -3054,8 +3054,8 @@ requires-dist = [
{ name = "fastapi", marker = "extra == 'api'", specifier = ">=0.115.0" },
{ name = "fastapi", marker = "extra == 'mcp'", specifier = ">=0.115.0" },
{ name = "fastapi", marker = "extra == 'os'", specifier = ">=0.115.0" },
{ name = "litellm", marker = "extra == 'llm'", specifier = ">=1.81.0" },
{ name = "litellm", marker = "extra == 'memory'", specifier = ">=1.81.0" },
{ name = "litellm", marker = "extra == 'llm'", specifier = ">=1.81.0,<=1.82.6" },
{ name = "litellm", marker = "extra == 'memory'", specifier = ">=1.81.0,<=1.82.6" },
{ name = "markitdown", extras = ["all"], marker = "extra == 'knowledge'", specifier = ">=0.1.0" },
{ name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.20.0" },
{ name = "mem0ai", marker = "extra == 'knowledge'", specifier = ">=0.1.0" },
+323 -29
View File
@@ -1,14 +1,14 @@
{
"version": "1.5.5",
"lastUpdated": "2026-02-05",
"version": "1.5.87",
"lastUpdated": "2026-03-30",
"generatedBy": "praisonai._dev.parity.generator",
"sourceOfTruth": "Python SDK (praisonaiagents)",
"status": "PARITY_ACHIEVED",
"summary": {
"pythonCoreFeatures": 282,
"rustFeatures": 666,
"gapCount": 7,
"parityPercentage": 236.2
"pythonCoreFeatures": 311,
"rustFeatures": 667,
"gapCount": 33,
"parityPercentage": 214.5
},
"pythonCoreSDK": {
"exports": [
@@ -27,19 +27,25 @@
"Agents",
"ApprovalCallback",
"ArrayMode",
"AsyncLearnProtocol",
"AudioAgent",
"AudioConfig",
"AuthProfile",
"AutoAgents",
"AutoApproveBackend",
"AutoRagAgent",
"AutoRagConfig",
"AutonomyConfig",
"AutonomyLevel",
"BaseTool",
"BotChannel",
"BotConfig",
"BotMessage",
"BotOSConfig",
"BotOSProtocol",
"BotProtocol",
"BotUser",
"BudgetExceededError",
"CACHING_PRESETS",
"CONTEXT_PRESETS",
"CachingConfig",
@@ -52,6 +58,7 @@
"CodeExecutionStep",
"ConditionProtocol",
"ConfigValidationError",
"ConsoleBackend",
"ContextAgent",
"ContextConfig",
"ContextEvent",
@@ -68,10 +75,13 @@
"DeepResearchResponse",
"DefaultsConfig",
"DictCondition",
"DoomLoopDetector",
"EXECUTION_PRESETS",
"EmbeddingAgent",
"EmbeddingConfig",
"EmbeddingResult",
"EscalationPipeline",
"EscalationStage",
"EventType",
"ExecutionConfig",
"ExecutionPreset",
@@ -86,6 +96,7 @@
"FileSearchCall",
"FlowDisplay",
"FunctionPlugin",
"FunctionTool",
"GUARDRAIL_PRESETS",
"GatewayClientProtocol",
"GatewayConfig",
@@ -104,6 +115,8 @@
"HandoffInputData",
"HandoffResult",
"HandoffTimeoutError",
"Heartbeat",
"HeartbeatConfig",
"HookPluginProtocol",
"HooksConfig",
"If",
@@ -113,6 +126,13 @@
"KnowledgeConfig",
"LLMGuardrail",
"LLMPluginProtocol",
"LearnBackend",
"LearnConfig",
"LearnManager",
"LearnManagerProtocol",
"LearnMode",
"LearnProtocol",
"LearnScope",
"LineRange",
"Loop",
"MCP",
@@ -135,6 +155,8 @@
"OCRAgent",
"OCRConfig",
"OUTPUT_PRESETS",
"ObservabilityEventType",
"ObservabilityHooks",
"OptimizerStrategy",
"OutputConfig",
"OutputPreset",
@@ -201,6 +223,9 @@
"TodoItem",
"TodoList",
"ToolPluginProtocol",
"ToolRegistry",
"ToolResult",
"ToolValidationError",
"Tools",
"TraceSink",
"TraceSinkProtocol",
@@ -252,7 +277,9 @@
"get_plugin_manager",
"get_plugin_template",
"get_plugins_config",
"get_registry",
"get_telemetry",
"get_tool",
"handoff",
"handoff_filters",
"is_path_like",
@@ -267,6 +294,7 @@
"parse_policy_string",
"prompt_with_handoff_instructions",
"register_display_callback",
"register_tool",
"repeat",
"resolve",
"resolve_autonomy",
@@ -292,6 +320,7 @@
"trace_context",
"track_workflow",
"validate_config",
"validate_tool",
"when",
"workflows"
]
@@ -756,6 +785,7 @@
"Tool",
"ToolCall",
"ToolCallData",
"ToolCallFunction",
"ToolCallResult",
"ToolDefinition",
"ToolPluginProtocol",
@@ -1051,7 +1081,6 @@
"WebSearchProvider"
],
"config_loader": [
"AutoRagConfig",
"SessionConfig"
],
"context": [
@@ -1657,6 +1686,7 @@
"OpenAiProvider",
"Role",
"ToolCall",
"ToolCallFunction",
"Usage"
],
"mcp": [
@@ -1672,6 +1702,7 @@
"planning": [],
"plugins": [
"PluginRegistry",
"ToolDefinition",
"disable_plugins",
"enable_plugins",
"get_plugin_manager",
@@ -1685,6 +1716,9 @@
"sandbox": [],
"session": [],
"skills": [],
"specialized": [
"AutoRagConfig"
],
"specialized_agents": [],
"streaming": [],
"task": [],
@@ -1693,7 +1727,6 @@
"tools": [
"FunctionTool",
"Tool",
"ToolDefinition",
"ToolRegistry",
"ToolResult"
],
@@ -1751,6 +1784,24 @@
"status": "DONE",
"category": "agent"
},
{
"feature": "BaseTool",
"python": true,
"rust": false,
"priority": "P0",
"effort": "high",
"status": "TODO",
"category": "tools"
},
{
"feature": "BudgetExceededError",
"python": true,
"rust": false,
"priority": "P0",
"effort": "low",
"status": "TODO",
"category": "agent"
},
{
"feature": "CodeAgent",
"python": true,
@@ -1859,6 +1910,15 @@
"status": "DONE",
"category": "agent"
},
{
"feature": "FunctionTool",
"python": true,
"rust": true,
"priority": "P0",
"effort": "high",
"status": "DONE",
"category": "tools"
},
{
"feature": "Handoff",
"python": true,
@@ -2048,6 +2108,42 @@
"status": "DONE",
"category": "agent"
},
{
"feature": "ToolRegistry",
"python": true,
"rust": true,
"priority": "P0",
"effort": "high",
"status": "DONE",
"category": "tools"
},
{
"feature": "ToolResult",
"python": true,
"rust": true,
"priority": "P0",
"effort": "low",
"status": "DONE",
"category": "tools"
},
{
"feature": "ToolValidationError",
"python": true,
"rust": false,
"priority": "P0",
"effort": "low",
"status": "TODO",
"category": "tools"
},
{
"feature": "Tools",
"python": true,
"rust": true,
"priority": "P0",
"effort": "high",
"status": "DONE",
"category": "tools"
},
{
"feature": "VideoAgent",
"python": true,
@@ -2102,6 +2198,24 @@
"status": "DONE",
"category": "agent"
},
{
"feature": "get_registry",
"python": true,
"rust": false,
"priority": "P0",
"effort": "low",
"status": "TODO",
"category": "tools"
},
{
"feature": "get_tool",
"python": true,
"rust": false,
"priority": "P0",
"effort": "low",
"status": "TODO",
"category": "tools"
},
{
"feature": "handoff",
"python": true,
@@ -2128,6 +2242,33 @@
"effort": "low",
"status": "DONE",
"category": "agent"
},
{
"feature": "register_tool",
"python": true,
"rust": false,
"priority": "P0",
"effort": "low",
"status": "TODO",
"category": "tools"
},
{
"feature": "tool",
"python": true,
"rust": true,
"priority": "P0",
"effort": "low",
"status": "DONE",
"category": "tools"
},
{
"feature": "validate_tool",
"python": true,
"rust": false,
"priority": "P0",
"effort": "low",
"status": "TODO",
"category": "tools"
}
],
"P1_Persistence": [
@@ -2792,6 +2933,15 @@
"status": "DONE",
"category": "config"
},
{
"feature": "AsyncLearnProtocol",
"python": true,
"rust": false,
"priority": "P3",
"effort": "medium",
"status": "TODO",
"category": "other"
},
{
"feature": "AuthProfile",
"python": true,
@@ -2810,6 +2960,15 @@
"status": "DONE",
"category": "agents"
},
{
"feature": "AutoApproveBackend",
"python": true,
"rust": false,
"priority": "P3",
"effort": "high",
"status": "TODO",
"category": "app"
},
{
"feature": "AutoRagAgent",
"python": true,
@@ -2835,7 +2994,7 @@
"priority": "P3",
"effort": "low",
"status": "DONE",
"category": "config"
"category": "other"
},
{
"feature": "AutonomyLevel",
@@ -2873,6 +3032,24 @@
"status": "DONE",
"category": "bots"
},
{
"feature": "BotOSConfig",
"python": true,
"rust": false,
"priority": "P3",
"effort": "low",
"status": "TODO",
"category": "bots"
},
{
"feature": "BotOSProtocol",
"python": true,
"rust": false,
"priority": "P3",
"effort": "medium",
"status": "TODO",
"category": "bots"
},
{
"feature": "BotProtocol",
"python": true,
@@ -2945,6 +3122,15 @@
"status": "DONE",
"category": "config"
},
{
"feature": "ConsoleBackend",
"python": true,
"rust": false,
"priority": "P3",
"effort": "high",
"status": "TODO",
"category": "app"
},
{
"feature": "ContextEvent",
"python": true,
@@ -3026,6 +3212,15 @@
"status": "DONE",
"category": "conditions"
},
{
"feature": "DoomLoopDetector",
"python": true,
"rust": false,
"priority": "P3",
"effort": "high",
"status": "TODO",
"category": "other"
},
{
"feature": "EXECUTION_PRESETS",
"python": true,
@@ -3044,6 +3239,24 @@
"status": "DONE",
"category": "embedding"
},
{
"feature": "EscalationPipeline",
"python": true,
"rust": false,
"priority": "P3",
"effort": "high",
"status": "TODO",
"category": "other"
},
{
"feature": "EscalationStage",
"python": true,
"rust": false,
"priority": "P3",
"effort": "high",
"status": "TODO",
"category": "other"
},
{
"feature": "EventType",
"python": true,
@@ -3197,6 +3410,24 @@
"status": "DONE",
"category": "config"
},
{
"feature": "Heartbeat",
"python": true,
"rust": false,
"priority": "P3",
"effort": "high",
"status": "TODO",
"category": "other"
},
{
"feature": "HeartbeatConfig",
"python": true,
"rust": false,
"priority": "P3",
"effort": "low",
"status": "TODO",
"category": "other"
},
{
"feature": "HookPluginProtocol",
"python": true,
@@ -3242,6 +3473,69 @@
"status": "DONE",
"category": "plugins"
},
{
"feature": "LearnBackend",
"python": true,
"rust": false,
"priority": "P3",
"effort": "high",
"status": "TODO",
"category": "config"
},
{
"feature": "LearnConfig",
"python": true,
"rust": false,
"priority": "P3",
"effort": "low",
"status": "TODO",
"category": "config"
},
{
"feature": "LearnManager",
"python": true,
"rust": false,
"priority": "P3",
"effort": "high",
"status": "TODO",
"category": "other"
},
{
"feature": "LearnManagerProtocol",
"python": true,
"rust": false,
"priority": "P3",
"effort": "medium",
"status": "TODO",
"category": "other"
},
{
"feature": "LearnMode",
"python": true,
"rust": false,
"priority": "P3",
"effort": "high",
"status": "TODO",
"category": "config"
},
{
"feature": "LearnProtocol",
"python": true,
"rust": false,
"priority": "P3",
"effort": "medium",
"status": "TODO",
"category": "other"
},
{
"feature": "LearnScope",
"python": true,
"rust": false,
"priority": "P3",
"effort": "high",
"status": "TODO",
"category": "config"
},
{
"feature": "MEMORY_PRESETS",
"python": true,
@@ -3359,6 +3653,24 @@
"status": "DONE",
"category": "config"
},
{
"feature": "ObservabilityEventType",
"python": true,
"rust": false,
"priority": "P3",
"effort": "high",
"status": "TODO",
"category": "other"
},
{
"feature": "ObservabilityHooks",
"python": true,
"rust": false,
"priority": "P3",
"effort": "high",
"status": "TODO",
"category": "other"
},
{
"feature": "OutputConfig",
"python": true,
@@ -3638,15 +3950,6 @@
"status": "DONE",
"category": "plugins"
},
{
"feature": "Tools",
"python": true,
"rust": true,
"priority": "P3",
"effort": "high",
"status": "DONE",
"category": "other"
},
{
"feature": "TraceSink",
"python": true,
@@ -3852,7 +4155,7 @@
"priority": "P3",
"effort": "low",
"status": "DONE",
"category": "embedding"
"category": "other"
},
{
"feature": "embeddings",
@@ -3861,7 +4164,7 @@
"priority": "P3",
"effort": "low",
"status": "DONE",
"category": "embedding"
"category": "other"
},
{
"feature": "ensure_plugin_dir",
@@ -4214,15 +4517,6 @@
"status": "DONE",
"category": "main"
},
{
"feature": "tool",
"python": true,
"rust": true,
"priority": "P3",
"effort": "low",
"status": "DONE",
"category": "other"
},
{
"feature": "tools",
"python": true,
+35 -5
View File
@@ -1,16 +1,16 @@
# Rust Feature Parity Tracker
> **Python Features:** 282 | **Rust Features:** 666 | **Parity:** 100.0%
> **Python Features:** 311 | **Rust Features:** 667 | **Parity:** 91.6%
## Summary
| Metric | Count |
|--------|-------|
| Python Core Features | 282 |
| Rust Features | 666 |
| **Actual Gap Count** | **0** |
| Python Core Features | 311 |
| Rust Features | 667 |
| **Actual Gap Count** | **26** |
| Language Limitations (N/A) | 6 |
| **Parity** | **100.0%** |
| **Parity** | **91.6%** |
## Implemented Features
@@ -471,6 +471,7 @@
- ✅ `Tool`
- ✅ `ToolCall`
- ✅ `ToolCallData`
- ✅ `ToolCallFunction`
- ✅ `ToolCallResult`
- ✅ `ToolDefinition`
- ✅ `ToolPluginProtocol`
@@ -692,6 +693,35 @@ These Python features cannot be directly implemented in Rust due to reserved key
- ⚠️ `tools` (Rust reserved/module conflict)
- ⚠️ `workflows` (Rust reserved/module conflict)
## Missing Features
- ❌ `AsyncLearnProtocol`
- ❌ `AutoApproveBackend`
- ❌ `BaseTool`
- ❌ `BotOSConfig`
- ❌ `BotOSProtocol`
- ❌ `BudgetExceededError`
- ❌ `ConsoleBackend`
- ❌ `DoomLoopDetector`
- ❌ `EscalationPipeline`
- ❌ `EscalationStage`
- ❌ `Heartbeat`
- ❌ `HeartbeatConfig`
- ❌ `LearnBackend`
- ❌ `LearnConfig`
- ❌ `LearnManager`
- ❌ `LearnManagerProtocol`
- ❌ `LearnMode`
- ❌ `LearnProtocol`
- ❌ `LearnScope`
- ❌ `ObservabilityEventType`
- ❌ `ObservabilityHooks`
- ❌ `ToolValidationError`
- ❌ `get_registry`
- ❌ `get_tool`
- ❌ `register_tool`
- ❌ `validate_tool`
---
*Generated by `praisonai._dev.parity.generator`*
File diff suppressed because it is too large. Load diff
+105 -237
View File
@@ -1,70 +1,36 @@
# Feature Parity Tracker
> **Version:** 1.5.5 | **Last Updated:** 2026-02-05
> **Version:** 1.5.87 | **Last Updated:** 2026-03-30
> **Source of Truth:** Python SDK (praisonaiagents)
## Summary
| Metric | Count |
|--------|-------|
| Python Core Features | 282 |
| Python Wrapper Features | 94 |
<<<<<<< Updated upstream
| TypeScript Features | 1118 |
| **Gap Count** | **97** |
| P0 (Critical) | 21 |
| P1 (High) | 8 |
| P2 (Medium) | 19 |
| P3 (Low) | 49 |
=======
| Python Core Features | 311 |
| Python Wrapper Features | 99 |
| TypeScript Features | 1183 |
| **Gap Count** | **0** |
| P0 (Critical) | 0 |
| **Gap Count** | **23** |
| P0 (Critical) | 5 |
| P1 (High) | 0 |
| P2 (Medium) | 0 |
| P3 (Low) | 0 |
>>>>>>> Stashed changes
| P3 (Low) | 18 |
## Gap Matrix
### P0_CoreParity (24 done, 21 todo)
### P0_CoreParity (52 done, 5 todo)
| Feature | Python | TypeScript | Effort | Status |
|---------|--------|------------|--------|--------|
<<<<<<< Updated upstream
| `AudioConfig` | ✅ | ❌ | low | ⏳ TODO |
| `CodeAgent` | ✅ | ❌ | high | ⏳ TODO |
| `CodeConfig` | ✅ | ❌ | low | ⏳ TODO |
| `CodeExecutionStep` | ✅ | ❌ | high | ⏳ TODO |
| `DeepResearchResponse` | ✅ | ❌ | high | ⏳ TODO |
| `EmbeddingAgent` | ✅ | ❌ | high | ⏳ TODO |
| `FileSearchCall` | ✅ | ❌ | high | ⏳ TODO |
| `MCPCall` | ✅ | ❌ | high | ⏳ TODO |
| `OCRAgent` | ✅ | ❌ | high | ⏳ TODO |
| `OCRConfig` | ✅ | ❌ | low | ⏳ TODO |
| `Provider` | ✅ | ❌ | high | ⏳ TODO |
| `RealtimeAgent` | ✅ | ❌ | high | ⏳ TODO |
| `RealtimeConfig` | ✅ | ❌ | low | ⏳ TODO |
| `VideoAgent` | ✅ | ❌ | high | ⏳ TODO |
| `VideoConfig` | ✅ | ❌ | low | ⏳ TODO |
| `VisionAgent` | ✅ | ❌ | high | ⏳ TODO |
| `VisionConfig` | ✅ | ❌ | low | ⏳ TODO |
| `WebSearchCall` | ✅ | ❌ | high | ⏳ TODO |
| `create\_context\_agent` | ✅ | ❌ | low | ⏳ TODO |
| `handoff\_filters` | ✅ | ❌ | low | ⏳ TODO |
| `prompt\_with\_handoff\_instructions` | ✅ | ❌ | low | ⏳ TODO |
| `Agent` | ✅ | ✅ | high | ✅ DONE |
| `AudioAgent` | ✅ | ✅ | high | ✅ DONE |
| `ContextAgent` | ✅ | ✅ | high | ✅ DONE |
| `ContextPolicy` | ✅ | ✅ | high | ✅ DONE |
| `DeepResearchAgent` | ✅ | ✅ | high | ✅ DONE |
| `EmbeddingConfig` | ✅ | ✅ | low | ✅ DONE |
| `ExpandResult` | ✅ | ✅ | low | ✅ DONE |
| `ExpandStrategy` | ✅ | ✅ | high | ✅ DONE |
=======
| `BudgetExceededError` | ✅ | ❌ | low | ⏳ TODO |
| `get\_registry` | ✅ | ❌ | low | ⏳ TODO |
| `get\_tool` | ✅ | ❌ | low | ⏳ TODO |
| `register\_tool` | ✅ | ❌ | low | ⏳ TODO |
| `validate\_tool` | ✅ | ❌ | low | ⏳ TODO |
| `Agent` | ✅ | ✅ | high | ✅ DONE |
| `AudioAgent` | ✅ | ✅ | high | ✅ DONE |
| `AudioConfig` | ✅ | ✅ | low | ✅ DONE |
| `BaseTool` | ✅ | ✅ | high | ✅ DONE |
| `CodeAgent` | ✅ | ✅ | high | ✅ DONE |
| `CodeConfig` | ✅ | ✅ | low | ✅ DONE |
| `CodeExecutionStep` | ✅ | ✅ | high | ✅ DONE |
@@ -77,7 +43,7 @@
| `ExpandResult` | ✅ | ✅ | low | ✅ DONE |
| `ExpandStrategy` | ✅ | ✅ | high | ✅ DONE |
| `FileSearchCall` | ✅ | ✅ | high | ✅ DONE |
>>>>>>> Stashed changes
| `FunctionTool` | ✅ | ✅ | high | ✅ DONE |
| `Handoff` | ✅ | ✅ | high | ✅ DONE |
| `HandoffConfig` | ✅ | ✅ | low | ✅ DONE |
| `HandoffCycleError` | ✅ | ✅ | low | ✅ DONE |
@@ -87,14 +53,6 @@
| `HandoffResult` | ✅ | ✅ | low | ✅ DONE |
| `HandoffTimeoutError` | ✅ | ✅ | low | ✅ DONE |
| `ImageAgent` | ✅ | ✅ | high | ✅ DONE |
<<<<<<< Updated upstream
| `PromptExpanderAgent` | ✅ | ✅ | high | ✅ DONE |
| `QueryRewriterAgent` | ✅ | ✅ | high | ✅ DONE |
| `RECOMMENDED\_PROMPT\_PREFIX` | ✅ | ✅ | low | ✅ DONE |
| `ReasoningStep` | ✅ | ✅ | high | ✅ DONE |
| `RewriteResult` | ✅ | ✅ | low | ✅ DONE |
| `RewriteStrategy` | ✅ | ✅ | high | ✅ DONE |
=======
| `MCPCall` | ✅ | ✅ | high | ✅ DONE |
| `OCRAgent` | ✅ | ✅ | high | ✅ DONE |
| `OCRConfig` | ✅ | ✅ | low | ✅ DONE |
@@ -107,38 +65,30 @@
| `ReasoningStep` | ✅ | ✅ | high | ✅ DONE |
| `RewriteResult` | ✅ | ✅ | low | ✅ DONE |
| `RewriteStrategy` | ✅ | ✅ | high | ✅ DONE |
| `ToolRegistry` | ✅ | ✅ | high | ✅ DONE |
| `ToolResult` | ✅ | ✅ | low | ✅ DONE |
| `ToolValidationError` | ✅ | ✅ | low | ✅ DONE |
| `Tools` | ✅ | ✅ | high | ✅ DONE |
| `VideoAgent` | ✅ | ✅ | high | ✅ DONE |
| `VideoConfig` | ✅ | ✅ | low | ✅ DONE |
| `VisionAgent` | ✅ | ✅ | high | ✅ DONE |
| `VisionConfig` | ✅ | ✅ | low | ✅ DONE |
| `WebSearchCall` | ✅ | ✅ | high | ✅ DONE |
| `create\_context\_agent` | ✅ | ✅ | low | ✅ DONE |
>>>>>>> Stashed changes
| `handoff` | ✅ | ✅ | low | ✅ DONE |
| `handoff\_filters` | ✅ | ✅ | low | ✅ DONE |
| `prompt\_with\_handoff\_instructions` | ✅ | ✅ | low | ✅ DONE |
| `tool` | ✅ | ✅ | low | ✅ DONE |
### P1_Persistence (12 done, 8 todo)
### P1_Persistence (20 done, 0 todo)
| Feature | Python | TypeScript | Effort | Status |
|---------|--------|------------|--------|--------|
<<<<<<< Updated upstream
| `Chunking` | ✅ | ❌ | high | ⏳ TODO |
| `If` | ✅ | ❌ | high | ⏳ TODO |
| `Knowledge` | ✅ | ❌ | high | ⏳ TODO |
| `Loop` | ✅ | ❌ | high | ⏳ TODO |
| `Parallel` | ✅ | ❌ | high | ⏳ TODO |
| `Route` | ✅ | ❌ | high | ⏳ TODO |
| `Session` | ✅ | ❌ | high | ⏳ TODO |
| `when` | ✅ | ❌ | low | ⏳ TODO |
| `AgentFlow` | ✅ | ✅ | high | ✅ DONE |
=======
| `AgentFlow` | ✅ | ✅ | high | ✅ DONE |
| `Chunking` | ✅ | ✅ | high | ✅ DONE |
| `If` | ✅ | ✅ | high | ✅ DONE |
| `Knowledge` | ✅ | ✅ | high | ✅ DONE |
| `Loop` | ✅ | ✅ | high | ✅ DONE |
>>>>>>> Stashed changes
| `Memory` | ✅ | ✅ | high | ✅ DONE |
| `Parallel` | ✅ | ✅ | high | ✅ DONE |
| `Pipeline` | ✅ | ✅ | high | ✅ DONE |
@@ -155,36 +105,10 @@
| `route` | ✅ | ✅ | low | ✅ DONE |
| `when` | ✅ | ✅ | low | ✅ DONE |
### P2_CLI (22 done, 19 todo)
### P2_CLI (41 done, 0 todo)
| Feature | Python | TypeScript | Effort | Status |
|---------|--------|------------|--------|--------|
<<<<<<< Updated upstream
| `ApprovalCallback` | ✅ | ❌ | high | ⏳ TODO |
| `ContextConfig` | ✅ | ❌ | low | ⏳ TODO |
| `ContextManager` | ✅ | ❌ | high | ⏳ TODO |
| `ContextPack` | ✅ | ❌ | high | ⏳ TODO |
| `FastContext` | ✅ | ❌ | high | ⏳ TODO |
| `GuardrailResult` | ✅ | ❌ | low | ⏳ TODO |
| `LineRange` | ✅ | ❌ | high | ⏳ TODO |
| `MCP` | ✅ | ❌ | low | ⏳ TODO |
| `ManagerConfig` | ✅ | ❌ | low | ⏳ TODO |
| `MinimalTelemetry` | ✅ | ❌ | high | ⏳ TODO |
| `OptimizerStrategy` | ✅ | ❌ | high | ⏳ TODO |
| `Plan` | ✅ | ❌ | high | ⏳ TODO |
| `SkillLoader` | ✅ | ❌ | high | ⏳ TODO |
| `cleanup\_telemetry\_resources` | ✅ | ❌ | low | ⏳ TODO |
| `disable\_performance\_mode` | ✅ | ❌ | low | ⏳ TODO |
| `disable\_telemetry` | ✅ | ❌ | low | ⏳ TODO |
| `enable\_performance\_mode` | ✅ | ❌ | low | ⏳ TODO |
| `enable\_telemetry` | ✅ | ❌ | low | ⏳ TODO |
| `get\_telemetry` | ✅ | ❌ | low | ⏳ TODO |
| `Citation` | ✅ | ✅ | high | ✅ DONE |
| `CitationsMode` | ✅ | ✅ | high | ✅ DONE |
| `FastContextResult` | ✅ | ✅ | low | ✅ DONE |
| `FileMatch` | ✅ | ✅ | high | ✅ DONE |
| `LLMGuardrail` | ✅ | ✅ | high | ✅ DONE |
=======
| `ApprovalCallback` | ✅ | ✅ | high | ✅ DONE |
| `Citation` | ✅ | ✅ | high | ✅ DONE |
| `CitationsMode` | ✅ | ✅ | high | ✅ DONE |
@@ -202,7 +126,6 @@
| `MinimalTelemetry` | ✅ | ✅ | high | ✅ DONE |
| `OptimizerStrategy` | ✅ | ✅ | high | ✅ DONE |
| `Plan` | ✅ | ✅ | high | ✅ DONE |
>>>>>>> Stashed changes
| `PlanStep` | ✅ | ✅ | high | ✅ DONE |
| `PlanStorage` | ✅ | ✅ | high | ✅ DONE |
| `PlanningAgent` | ✅ | ✅ | high | ✅ DONE |
@@ -214,20 +137,13 @@
| `RESTRICTED\_TOOLS` | ✅ | ✅ | low | ✅ DONE |
| `RetrievalConfig` | ✅ | ✅ | low | ✅ DONE |
| `RetrievalPolicy` | ✅ | ✅ | high | ✅ DONE |
<<<<<<< Updated upstream
=======
| `SkillLoader` | ✅ | ✅ | high | ✅ DONE |
>>>>>>> Stashed changes
| `SkillManager` | ✅ | ✅ | high | ✅ DONE |
| `SkillMetadata` | ✅ | ✅ | high | ✅ DONE |
| `SkillProperties` | ✅ | ✅ | high | ✅ DONE |
| `TelemetryCollector` | ✅ | ✅ | high | ✅ DONE |
| `TodoItem` | ✅ | ✅ | high | ✅ DONE |
| `TodoList` | ✅ | ✅ | high | ✅ DONE |
<<<<<<< Updated upstream
### P3_Advanced (127 done, 49 todo)
=======
| `cleanup\_telemetry\_resources` | ✅ | ✅ | low | ✅ DONE |
| `disable\_performance\_mode` | ✅ | ✅ | low | ✅ DONE |
| `disable\_telemetry` | ✅ | ✅ | low | ✅ DONE |
@@ -235,68 +151,34 @@
| `enable\_telemetry` | ✅ | ✅ | low | ✅ DONE |
| `get\_telemetry` | ✅ | ✅ | low | ✅ DONE |
### P3_Advanced (176 done, 0 todo)
>>>>>>> Stashed changes
### P3_Advanced (175 done, 18 todo)
| Feature | Python | TypeScript | Effort | Status |
|---------|--------|------------|--------|--------|
| `AGUI` | ✅ | ❌ | low | ⏳ TODO |
| `AgentManager` | ✅ | ❌ | high | ⏳ TODO |
| `BotConfig` | ✅ | ❌ | low | ⏳ TODO |
| `ConditionProtocol` | ✅ | ❌ | medium | ⏳ TODO |
| `ConfigValidationError` | ✅ | ❌ | low | ⏳ TODO |
| `ContextEvent` | ✅ | ❌ | high | ⏳ TODO |
| `ContextEventType` | ✅ | ❌ | high | ⏳ TODO |
| `DictCondition` | ✅ | ❌ | high | ⏳ TODO |
| `EmbeddingResult` | ✅ | ❌ | low | ⏳ TODO |
| `FailoverManager` | ✅ | ❌ | high | ⏳ TODO |
| `FlowDisplay` | ✅ | ❌ | high | ⏳ TODO |
| `GatewayConfig` | ✅ | ❌ | low | ⏳ TODO |
| `MEMORY\_PRESETS` | ✅ | ❌ | low | ⏳ TODO |
| `MemoryBackend` | ✅ | ❌ | high | ⏳ TODO |
| `Plugin` | ✅ | ❌ | high | ⏳ TODO |
| `PluginHook` | ✅ | ❌ | high | ⏳ TODO |
| `PluginMetadata` | ✅ | ❌ | high | ⏳ TODO |
| `ProviderStatus` | ✅ | ❌ | high | ⏳ TODO |
| `SandboxStatus` | ✅ | ❌ | high | ⏳ TODO |
| `Task` | ✅ | ❌ | high | ⏳ TODO |
| `TraceSink` | ✅ | ❌ | high | ⏳ TODO |
| `async\_display\_callbacks` | ✅ | ❌ | low | ⏳ TODO |
| `config` | ✅ | ❌ | low | ⏳ TODO |
| `detect\_url\_scheme` | ✅ | ❌ | low | ⏳ TODO |
| `discover\_and\_load\_plugins` | ✅ | ❌ | low | ⏳ TODO |
| `discover\_plugins` | ✅ | ❌ | low | ⏳ TODO |
| `display\_error` | ✅ | ❌ | low | ⏳ TODO |
| `display\_generating` | ✅ | ❌ | low | ⏳ TODO |
| `display\_instruction` | ✅ | ❌ | low | ⏳ TODO |
| `display\_interaction` | ✅ | ❌ | low | ⏳ TODO |
| `display\_self\_reflection` | ✅ | ❌ | low | ⏳ TODO |
| `display\_tool\_call` | ✅ | ❌ | low | ⏳ TODO |
| `embed` | ✅ | ❌ | low | ⏳ TODO |
| `ensure\_plugin\_dir` | ✅ | ❌ | low | ⏳ TODO |
| `error\_logs` | ✅ | ❌ | low | ⏳ TODO |
| `evaluate\_condition` | ✅ | ❌ | low | ⏳ TODO |
| `get\_default\_plugin\_dirs` | ✅ | ❌ | low | ⏳ TODO |
| `get\_dimensions` | ✅ | ❌ | low | ⏳ TODO |
| `get\_plugin\_manager` | ✅ | ❌ | low | ⏳ TODO |
| `get\_plugin\_template` | ✅ | ❌ | low | ⏳ TODO |
| `load\_plugin` | ✅ | ❌ | low | ⏳ TODO |
| `parse\_plugin\_header` | ✅ | ❌ | low | ⏳ TODO |
| `parse\_plugin\_header\_from\_file` | ✅ | ❌ | low | ⏳ TODO |
| `register\_display\_callback` | ✅ | ❌ | low | ⏳ TODO |
| `resolve` | ✅ | ❌ | low | ⏳ TODO |
| `resolve\_guardrail\_policies` | ✅ | ❌ | low | ⏳ TODO |
| `sync\_display\_callbacks` | ✅ | ❌ | low | ⏳ TODO |
| `trace\_context` | ✅ | ❌ | low | ⏳ TODO |
| `track\_workflow` | ✅ | ❌ | low | ⏳ TODO |
| `AsyncLearnProtocol` | ✅ | ❌ | medium | ⏳ TODO |
| `AutoApproveBackend` | ✅ | ❌ | high | ⏳ TODO |
| `BotOSConfig` | ✅ | ❌ | low | ⏳ TODO |
| `BotOSProtocol` | ✅ | ❌ | medium | ⏳ TODO |
| `ConsoleBackend` | ✅ | ❌ | high | ⏳ TODO |
| `DoomLoopDetector` | ✅ | ❌ | high | ⏳ TODO |
| `EscalationPipeline` | ✅ | ❌ | high | ⏳ TODO |
| `EscalationStage` | ✅ | ❌ | high | ⏳ TODO |
| `Heartbeat` | ✅ | ❌ | high | ⏳ TODO |
| `HeartbeatConfig` | ✅ | ❌ | low | ⏳ TODO |
| `LearnBackend` | ✅ | ❌ | high | ⏳ TODO |
| `LearnManager` | ✅ | ❌ | high | ⏳ TODO |
| `LearnManagerProtocol` | ✅ | ❌ | medium | ⏳ TODO |
| `LearnMode` | ✅ | ❌ | high | ⏳ TODO |
| `LearnProtocol` | ✅ | ❌ | medium | ⏳ TODO |
| `LearnScope` | ✅ | ❌ | high | ⏳ TODO |
| `ObservabilityEventType` | ✅ | ❌ | high | ⏳ TODO |
| `ObservabilityHooks` | ✅ | ❌ | high | ⏳ TODO |
| `A2A` | ✅ | ✅ | low | ✅ DONE |
| `AGUI` | ✅ | ✅ | low | ✅ DONE |
| `AUTONOMY\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
| `AgentAppConfig` | ✅ | ✅ | low | ✅ DONE |
| `AgentAppProtocol` | ✅ | ✅ | medium | ✅ DONE |
<<<<<<< Updated upstream
=======
| `AgentManager` | ✅ | ✅ | high | ✅ DONE |
>>>>>>> Stashed changes
| `AgentOSConfig` | ✅ | ✅ | low | ✅ DONE |
| `AgentOSProtocol` | ✅ | ✅ | medium | ✅ DONE |
| `AgentPluginProtocol` | ✅ | ✅ | medium | ✅ DONE |
@@ -310,6 +192,7 @@
| `AutonomyConfig` | ✅ | ✅ | low | ✅ DONE |
| `AutonomyLevel` | ✅ | ✅ | high | ✅ DONE |
| `BotChannel` | ✅ | ✅ | high | ✅ DONE |
| `BotConfig` | ✅ | ✅ | low | ✅ DONE |
| `BotMessage` | ✅ | ✅ | high | ✅ DONE |
| `BotProtocol` | ✅ | ✅ | medium | ✅ DONE |
| `BotUser` | ✅ | ✅ | high | ✅ DONE |
@@ -317,21 +200,30 @@
| `CONTEXT\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
| `CachingConfig` | ✅ | ✅ | low | ✅ DONE |
| `ChunkingStrategy` | ✅ | ✅ | high | ✅ DONE |
| `ConditionProtocol` | ✅ | ✅ | medium | ✅ DONE |
| `ConfigValidationError` | ✅ | ✅ | low | ✅ DONE |
| `ContextEvent` | ✅ | ✅ | high | ✅ DONE |
| `ContextEventType` | ✅ | ✅ | high | ✅ DONE |
| `ContextListSink` | ✅ | ✅ | high | ✅ DONE |
| `ContextNoOpSink` | ✅ | ✅ | high | ✅ DONE |
| `ContextTraceEmitter` | ✅ | ✅ | high | ✅ DONE |
| `ContextTraceSink` | ✅ | ✅ | high | ✅ DONE |
| `ContextTraceSinkProtocol` | ✅ | ✅ | medium | ✅ DONE |
| `DefaultsConfig` | ✅ | ✅ | low | ✅ DONE |
| `DictCondition` | ✅ | ✅ | high | ✅ DONE |
| `EXECUTION\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
| `EmbeddingResult` | ✅ | ✅ | low | ✅ DONE |
| `EventType` | ✅ | ✅ | high | ✅ DONE |
| `ExecutionConfig` | ✅ | ✅ | low | ✅ DONE |
| `ExecutionPreset` | ✅ | ✅ | high | ✅ DONE |
| `ExpressionCondition` | ✅ | ✅ | high | ✅ DONE |
| `FailoverConfig` | ✅ | ✅ | low | ✅ DONE |
| `FailoverManager` | ✅ | ✅ | high | ✅ DONE |
| `FlowDisplay` | ✅ | ✅ | high | ✅ DONE |
| `FunctionPlugin` | ✅ | ✅ | high | ✅ DONE |
| `GUARDRAIL\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
| `GatewayClientProtocol` | ✅ | ✅ | medium | ✅ DONE |
| `GatewayConfig` | ✅ | ✅ | low | ✅ DONE |
| `GatewayEvent` | ✅ | ✅ | high | ✅ DONE |
| `GatewayMessage` | ✅ | ✅ | high | ✅ DONE |
| `GatewayProtocol` | ✅ | ✅ | medium | ✅ DONE |
@@ -343,13 +235,12 @@
| `KNOWLEDGE\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
| `KnowledgeConfig` | ✅ | ✅ | low | ✅ DONE |
| `LLMPluginProtocol` | ✅ | ✅ | medium | ✅ DONE |
| `LearnConfig` | ✅ | ✅ | low | ✅ DONE |
| `MEMORY\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
| `MEMORY\_URL\_SCHEMES` | ✅ | ✅ | low | ✅ DONE |
| `MULTI\_AGENT\_EXECUTION\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
| `MULTI\_AGENT\_OUTPUT\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
<<<<<<< Updated upstream
=======
| `MemoryBackend` | ✅ | ✅ | high | ✅ DONE |
>>>>>>> Stashed changes
| `MemoryConfig` | ✅ | ✅ | low | ✅ DONE |
| `MessageType` | ✅ | ✅ | high | ✅ DONE |
| `MultiAgentExecutionConfig` | ✅ | ✅ | low | ✅ DONE |
@@ -362,12 +253,16 @@
| `OutputPreset` | ✅ | ✅ | high | ✅ DONE |
| `PLANNING\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
| `PlanningConfig` | ✅ | ✅ | low | ✅ DONE |
| `Plugin` | ✅ | ✅ | high | ✅ DONE |
| `PluginHook` | ✅ | ✅ | high | ✅ DONE |
| `PluginInfo` | ✅ | ✅ | high | ✅ DONE |
| `PluginManager` | ✅ | ✅ | high | ✅ DONE |
| `PluginMetadata` | ✅ | ✅ | high | ✅ DONE |
| `PluginParseError` | ✅ | ✅ | low | ✅ DONE |
| `PluginProtocol` | ✅ | ✅ | medium | ✅ DONE |
| `PluginsConfig` | ✅ | ✅ | low | ✅ DONE |
| `PraisonConfig` | ✅ | ✅ | low | ✅ DONE |
| `ProviderStatus` | ✅ | ✅ | high | ✅ DONE |
| `REFLECTION\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
| `RagRetrievalPolicy` | ✅ | ✅ | high | ✅ DONE |
| `ReflectionConfig` | ✅ | ✅ | low | ✅ DONE |
@@ -377,17 +272,15 @@
| `SandboxConfig` | ✅ | ✅ | low | ✅ DONE |
| `SandboxProtocol` | ✅ | ✅ | medium | ✅ DONE |
| `SandboxResult` | ✅ | ✅ | low | ✅ DONE |
<<<<<<< Updated upstream
=======
| `SandboxStatus` | ✅ | ✅ | high | ✅ DONE |
>>>>>>> Stashed changes
| `SecurityPolicy` | ✅ | ✅ | high | ✅ DONE |
| `SessionConfig` | ✅ | ✅ | low | ✅ DONE |
| `SkillsConfig` | ✅ | ✅ | low | ✅ DONE |
| `Task` | ✅ | ✅ | high | ✅ DONE |
| `TaskOutput` | ✅ | ✅ | high | ✅ DONE |
| `TemplateConfig` | ✅ | ✅ | low | ✅ DONE |
| `ToolPluginProtocol` | ✅ | ✅ | medium | ✅ DONE |
| `Tools` | ✅ | ✅ | high | ✅ DONE |
| `TraceSink` | ✅ | ✅ | high | ✅ DONE |
| `TraceSinkProtocol` | ✅ | ✅ | medium | ✅ DONE |
| `WEB\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
| `WebConfig` | ✅ | ✅ | low | ✅ DONE |
@@ -396,23 +289,48 @@
| `aembedding` | ✅ | ✅ | low | ✅ DONE |
| `aembeddings` | ✅ | ✅ | low | ✅ DONE |
| `apply\_config\_defaults` | ✅ | ✅ | low | ✅ DONE |
| `async\_display\_callbacks` | ✅ | ✅ | low | ✅ DONE |
| `clean\_triple\_backticks` | ✅ | ✅ | low | ✅ DONE |
| `config` | ✅ | ✅ | low | ✅ DONE |
| `detect\_url\_scheme` | ✅ | ✅ | low | ✅ DONE |
| `discover\_and\_load\_plugins` | ✅ | ✅ | low | ✅ DONE |
| `discover\_plugins` | ✅ | ✅ | low | ✅ DONE |
| `display\_error` | ✅ | ✅ | low | ✅ DONE |
| `display\_generating` | ✅ | ✅ | low | ✅ DONE |
| `display\_instruction` | ✅ | ✅ | low | ✅ DONE |
| `display\_interaction` | ✅ | ✅ | low | ✅ DONE |
| `display\_self\_reflection` | ✅ | ✅ | low | ✅ DONE |
| `display\_tool\_call` | ✅ | ✅ | low | ✅ DONE |
| `embed` | ✅ | ✅ | low | ✅ DONE |
| `embedding` | ✅ | ✅ | low | ✅ DONE |
| `embeddings` | ✅ | ✅ | low | ✅ DONE |
| `ensure\_plugin\_dir` | ✅ | ✅ | low | ✅ DONE |
| `error\_logs` | ✅ | ✅ | low | ✅ DONE |
| `evaluate\_condition` | ✅ | ✅ | low | ✅ DONE |
| `get\_config` | ✅ | ✅ | low | ✅ DONE |
| `get\_config\_path` | ✅ | ✅ | low | ✅ DONE |
| `get\_default` | ✅ | ✅ | low | ✅ DONE |
| `get\_default\_plugin\_dirs` | ✅ | ✅ | low | ✅ DONE |
| `get\_defaults\_config` | ✅ | ✅ | low | ✅ DONE |
| `get\_dimensions` | ✅ | ✅ | low | ✅ DONE |
| `get\_plugin\_manager` | ✅ | ✅ | low | ✅ DONE |
| `get\_plugin\_template` | ✅ | ✅ | low | ✅ DONE |
| `get\_plugins\_config` | ✅ | ✅ | low | ✅ DONE |
| `is\_path\_like` | ✅ | ✅ | low | ✅ DONE |
| `is\_policy\_string` | ✅ | ✅ | low | ✅ DONE |
| `load\_plugin` | ✅ | ✅ | low | ✅ DONE |
| `memory` | ✅ | ✅ | low | ✅ DONE |
| `obs` | ✅ | ✅ | low | ✅ DONE |
| `parse\_plugin\_header` | ✅ | ✅ | low | ✅ DONE |
| `parse\_plugin\_header\_from\_file` | ✅ | ✅ | low | ✅ DONE |
| `parse\_policy\_string` | ✅ | ✅ | low | ✅ DONE |
| `register\_display\_callback` | ✅ | ✅ | low | ✅ DONE |
| `resolve` | ✅ | ✅ | low | ✅ DONE |
| `resolve\_autonomy` | ✅ | ✅ | low | ✅ DONE |
| `resolve\_caching` | ✅ | ✅ | low | ✅ DONE |
| `resolve\_context` | ✅ | ✅ | low | ✅ DONE |
| `resolve\_execution` | ✅ | ✅ | low | ✅ DONE |
| `resolve\_guardrail\_policies` | ✅ | ✅ | low | ✅ DONE |
| `resolve\_guardrails` | ✅ | ✅ | low | ✅ DONE |
| `resolve\_hooks` | ✅ | ✅ | low | ✅ DONE |
| `resolve\_knowledge` | ✅ | ✅ | low | ✅ DONE |
@@ -424,16 +342,10 @@
| `resolve\_skills` | ✅ | ✅ | low | ✅ DONE |
| `resolve\_web` | ✅ | ✅ | low | ✅ DONE |
| `suggest\_similar` | ✅ | ✅ | low | ✅ DONE |
<<<<<<< Updated upstream
| `tool` | ✅ | ✅ | low | ✅ DONE |
| `tools` | ✅ | ✅ | low | ✅ DONE |
=======
| `sync\_display\_callbacks` | ✅ | ✅ | low | ✅ DONE |
| `tool` | ✅ | ✅ | low | ✅ DONE |
| `tools` | ✅ | ✅ | low | ✅ DONE |
| `trace\_context` | ✅ | ✅ | low | ✅ DONE |
| `track\_workflow` | ✅ | ✅ | low | ✅ DONE |
>>>>>>> Stashed changes
| `validate\_config` | ✅ | ✅ | low | ✅ DONE |
| `workflows` | ✅ | ✅ | low | ✅ DONE |
@@ -442,10 +354,10 @@
**Path:** `/home/runner/work/PraisonAI/PraisonAI/src/praisonai-agents/praisonaiagents`
<details>
<summary><strong>agent</strong> (45 exports)</summary>
<summary><strong>agent</strong> (46 exports)</summary>
```python
from praisonaiagents import Agent, AudioAgent, AudioConfig, CodeAgent, CodeConfig, CodeExecutionStep, ContextAgent, ContextPolicy, DeepResearchAgent, DeepResearchResponse...
from praisonaiagents import Agent, AudioAgent, AudioConfig, BudgetExceededError, CodeAgent, CodeConfig, CodeExecutionStep, ContextAgent, ContextPolicy, DeepResearchAgent...
```
</details>
@@ -460,19 +372,19 @@ from praisonaiagents import AgentManager, AgentTeam, AutoAgents, AutoRagAgent, A
</details>
<details>
<summary><strong>app</strong> (4 exports)</summary>
<summary><strong>app</strong> (6 exports)</summary>
```python
from praisonaiagents import AgentAppConfig, AgentAppProtocol, AgentOSConfig, AgentOSProtocol
from praisonaiagents import AgentAppConfig, AgentAppProtocol, AgentOSConfig, AgentOSProtocol, AutoApproveBackend, ConsoleBackend
```
</details>
<details>
<summary><strong>bots</strong> (6 exports)</summary>
<summary><strong>bots</strong> (8 exports)</summary>
```python
from praisonaiagents import BotChannel, BotConfig, BotMessage, BotProtocol, BotUser, MessageType
from praisonaiagents import BotChannel, BotConfig, BotMessage, BotOSConfig, BotOSProtocol, BotProtocol, BotUser, MessageType
```
</details>
@@ -487,10 +399,10 @@ from praisonaiagents import ConditionProtocol, DictCondition, ExpressionConditio
</details>
<details>
<summary><strong>config</strong> (72 exports)</summary>
<summary><strong>config</strong> (75 exports)</summary>
```python
from praisonaiagents import AUTONOMY_PRESETS, ArrayMode, AutonomyConfig, AutonomyLevel, CACHING_PRESETS, CONTEXT_PRESETS, CachingConfig, ChunkingStrategy, ConfigValidationError, DefaultsConfig...
from praisonaiagents import AUTONOMY_PRESETS, ArrayMode, AutonomyLevel, CACHING_PRESETS, CONTEXT_PRESETS, CachingConfig, ChunkingStrategy, ConfigValidationError, DefaultsConfig, EXECUTION_PRESETS...
```
</details>
@@ -514,10 +426,10 @@ from praisonaiagents import db
</details>
<details>
<summary><strong>embedding</strong> (8 exports)</summary>
<summary><strong>embedding</strong> (6 exports)</summary>
```python
from praisonaiagents import EmbeddingResult, aembed, aembedding, aembeddings, embed, embedding, embeddings, get_dimensions
from praisonaiagents import EmbeddingResult, aembed, aembedding, aembeddings, embed, get_dimensions
```
</details>
@@ -604,10 +516,10 @@ from praisonaiagents import obs
</details>
<details>
<summary><strong>other</strong> (7 exports)</summary>
<summary><strong>other</strong> (19 exports)</summary>
```python
from praisonaiagents import Agents, Tools, config, memory, tool, tools, workflows
from praisonaiagents import Agents, AsyncLearnProtocol, AutonomyConfig, DoomLoopDetector, EscalationPipeline, EscalationStage, Heartbeat, HeartbeatConfig, LearnManager, LearnManagerProtocol...
```
</details>
@@ -684,6 +596,15 @@ from praisonaiagents import MinimalTelemetry, TelemetryCollector, cleanup_teleme
</details>
<details>
<summary><strong>tools</strong> (11 exports)</summary>
```python
from praisonaiagents import BaseTool, FunctionTool, ToolRegistry, ToolResult, ToolValidationError, Tools, get_registry, get_tool, register_tool, tool...
```
</details>
<details>
<summary><strong>trace</strong> (10 exports)</summary>
@@ -755,21 +676,7 @@ import { BaseCache, CacheConfig, CacheEntry, FileCache, MemoryCache, createFileC
<summary><strong>cli</strong> (130 exports)</summary>
```typescript
<<<<<<< Updated upstream
import { // Autonomy Mode
AutonomyManager, // Background Jobs
JobQueue, // Checkpoints
CheckpointManager, // Cost Tracker
CostTracker, // External Agents
BaseExternalAgent, // Fast Context (Python parity with praisonaiagents/context/fast)
FastContext, // Flow Display
FlowDisplay, // Git Integration
GitManager, // Interactive TUI
InteractiveTUI, // N8N Integration
N8NIntegration... } from 'praisonai';
=======
import { ActionDecision, ActionRequest, ActionType, AiderAgent, ApprovalPolicy, AutonomyConfig, AutonomyManager, AutonomyMode, BaseExternalAgent, CLI_SPEC_VERSION... } from 'praisonai';
>>>>>>> Stashed changes
```
</details>
@@ -883,57 +790,24 @@ import { AgentApp, AgentAppConfig, AgentAppOptions, AgentAppProtocol, AgentOS, A
</details>
<details>
<<<<<<< Updated upstream
<summary><strong>other</strong> (233 exports)</summary>
```typescript
import { // A2A Protocol
A2ATaskState, // AGUI Protocol
AGUI, // AgentManager alias type
AgentManager, // Bot types
BotConfig, // Classes
DictCondition, // Classes
FailoverManager, // Classes
Plugin, // Classes
TraceSink, // Enums
ContextEventType, // Enums
MemoryBackend... } from 'praisonai';
=======
<summary><strong>other</strong> (203 exports)</summary>
```typescript
import { AUTONOMY_PRESETS, AgentPluginProtocol, ArrayMode, AsyncDisplayCallback, AuthProfile, AutoRagConfig, AutonomyLevel, BatchEmbeddingResult, BotChannel, BotConfig... } from 'praisonai';
>>>>>>> Stashed changes
```
</details>
<details>
<<<<<<< Updated upstream
<summary><strong>planning</strong> (25 exports)</summary>
```typescript
import { // Core classes
Plan, // Python parity additions
ApprovalCallback, ApprovalCallbackConfig, PlanConfig, PlanResult, PlanStatus, PlanStep, PlanStepConfig, PlanStorage, PlanningAgent... } from 'praisonai';
=======
<summary><strong>parity</strong> (65 exports)</summary>
```typescript
import { AudioConfig, Chunking, CodeAgent, CodeConfig, CodeExecutionStep, ContextConfig, ContextManager, ContextPack, DeepResearchResponse, EmbeddingAgent... } from 'praisonai';
>>>>>>> Stashed changes
```
</details>
<details>
<<<<<<< Updated upstream
<summary><strong>skills</strong> (10 exports)</summary>
```typescript
import { // Python parity additions
SkillLoader, Skill, SkillDiscoveryOptions, SkillManager, SkillMetadata, SkillProperties, createSkillLoader, createSkillManager, createSkillProperties, parseSkillFile } from 'praisonai';
=======
<summary><strong>planning</strong> (25 exports)</summary>
```typescript
@@ -956,7 +830,6 @@ import { A2A, A2AAgentCapabilities, A2AAgentCard, A2AAgentSkill, A2AArtifact, A2
```typescript
import { Skill, SkillDiscoveryOptions, SkillLoader, SkillManager, SkillMetadata, SkillProperties, createSkillLoader, createSkillManager, createSkillProperties, parseSkillFile } from 'praisonai';
>>>>>>> Stashed changes
```
</details>
@@ -974,12 +847,7 @@ import { AgentTask, AgentTaskConfig, BaseTask, TaskOutput, createTaskOutput } fr
<summary><strong>telemetry</strong> (26 exports)</summary>
```typescript
<<<<<<< Updated upstream
import { // Python parity additions
MinimalTelemetry, AgentStats, AgentTelemetry, MetricEntry, PerformanceMonitor, PerformanceMonitorConfig, PerformanceStats, TelemetryCollector, TelemetryConfig, TelemetryEvent... } from 'praisonai';
=======
import { AgentStats, AgentTelemetry, MetricEntry, MinimalTelemetry, PerformanceMonitor, PerformanceMonitorConfig, PerformanceStats, TelemetryCollector, TelemetryConfig, TelemetryEvent... } from 'praisonai';
>>>>>>> Stashed changes
```
</details>
@@ -23,7 +23,9 @@ async function testAgentWithTools() {
const getWeather = (city: string) => `Weather in ${city}: 22°C, sunny`;
const calculate = (expr: string) => {
try {
return String(eval(expr));
if (!/^[0-9+\-*/.() ]+$/.test(expr)) return 'Error: Invalid expression';
const result = new Function(`"use strict"; return (${expr})`)();
return typeof result === 'number' && isFinite(result) ? String(result) : 'Error';
} catch {
return 'Error';
}
@@ -96,7 +96,13 @@ async function main() {
});
await runTest('Agent: With Custom Tool', async () => {
const calculator = (expression: string) => String(eval(expression));
const calculator = (expression: string) => {
try {
if (!/^[0-9+\-*/.() ]+$/.test(expression)) return 'Error: Invalid expression';
const result = new Function(`"use strict"; return (${expression})`)();
return typeof result === 'number' && isFinite(result) ? String(result) : 'Error';
} catch { return 'Error'; }
};
const agent = new Agent({
name: 'ToolAgent',
instructions: 'Use the calculator tool for math.',
+78 -2
View File
@@ -43,8 +43,9 @@ PraisonAI 🦞 — Automate and solve complex challenges with AI agent teams tha
> - 🆕 **New here?** → [Quick Start](#-quick-start) *(1 minute to first agent)*
> - 📦 **Installing?** → [Installation](#-installation)
> - 🐍 **Python SDK?** → [Python Examples](#-using-python-code)
> - 🎯 **CLI user?** → [CLI Quick Reference](#cli-quick-reference)
> - 🤝 **Contributing?** → [Development](#-development)
> - 📄 **YAML/No-Code?** → [YAML Examples](#-using-yaml-no-code)
> - 🎯 **CLI user?** → [CLI Quick Reference](#-cli-quick-reference)
> - 🤝 **Contributing?** → [Contributing](#-contributing)
---
@@ -328,6 +329,81 @@ Open **http://localhost:8082** — the dashboard comes with 13 built-in pages: C
---
## 📄 Using YAML (No Code)
### Example 1: Two Agents Working Together
Create `agents.yaml`:
```yaml
framework: praisonai
topic: "Write a blog post about AI"
agents:
researcher:
role: Research Analyst
goal: Research AI trends and gather information
instructions: "Find accurate information about AI trends"
writer:
role: Content Writer
goal: Write engaging blog posts
instructions: "Write clear, engaging content based on research"
```
Run with:
```bash
praisonai agents.yaml
```
> The agents automatically work together sequentially
### Example 2: Agent with Custom Tool
Create two files in the same folder:
**agents.yaml:**
```yaml
framework: praisonai
topic: "Calculate the sum of 25 and 15"
agents:
calculator_agent:
role: Calculator
goal: Perform calculations
instructions: "Use the add_numbers tool to help with calculations"
tools:
- add_numbers
```
**tools.py:**
```python
def add_numbers(a: float, b: float) -> float:
"""
Add two numbers together.
Args:
a: First number
b: Second number
Returns:
The sum of a and b
"""
return a + b
```
Run with:
```bash
praisonai agents.yaml
```
> 💡 **Tips:**
> - Use the function name (e.g., `add_numbers`) in the tools list, not the file name
> - Tools in `tools.py` are automatically discovered
> - The function's docstring helps the AI understand how to use it
---
## 🎯 CLI Quick Reference
| Category | Commands |
+11 -1
View File
@@ -3,6 +3,16 @@ import markdown
app = Flask(__name__)
try:
import nh3
def _sanitize_html(html: str) -> str:
"""Sanitize HTML to prevent XSS from markdown-generated content."""
return nh3.clean(html)
except ImportError:
def _sanitize_html(html: str) -> str:
"""Fallback: no nh3, return as-is (install nh3 for XSS protection)."""
return html
def basic():
from praisonai import PraisonAI
praisonai = PraisonAI(agent_file="agents.yaml")
@@ -11,7 +21,7 @@ def basic():
@app.route('/')
def home():
output = basic()
html_output = markdown.markdown(output)
html_output = _sanitize_html(markdown.markdown(str(output)))
return f'<html><body>{html_output}</body></html>'
if __name__ == "__main__":
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.80.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.80.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.87.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.87.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
+162 -2
View File
@@ -55,6 +55,16 @@ try:
except ImportError:
pass
AG2_AVAILABLE = False
try:
import importlib.metadata as _importlib_metadata
_importlib_metadata.distribution('ag2')
from autogen import LLMConfig as _AG2LLMConfig # noqa: F401 — AG2-exclusive class
AG2_AVAILABLE = True
del _AG2LLMConfig, _importlib_metadata
except Exception:
pass
try:
import agentops
AGENTOPS_AVAILABLE = True
@@ -65,7 +75,7 @@ except ImportError:
pass
# Only try to import praisonai_tools if either CrewAI or AutoGen is available
if CREWAI_AVAILABLE or AUTOGEN_AVAILABLE or PRAISONAI_AVAILABLE:
if CREWAI_AVAILABLE or AUTOGEN_AVAILABLE or PRAISONAI_AVAILABLE or AG2_AVAILABLE:
try:
from praisonai_tools import (
CodeDocsSearchTool, CSVSearchTool, DirectorySearchTool, DOCXSearchTool, DirectoryReadTool,
@@ -216,6 +226,8 @@ class AgentsGenerator:
raise ImportError("AutoGen is not installed. Please install it with 'pip install praisonai[autogen]' for v0.2 or 'pip install praisonai[autogen-v4]' for v0.4")
elif framework == "praisonai" and not PRAISONAI_AVAILABLE:
raise ImportError("PraisonAI is not installed. Please install it with 'pip install praisonaiagents'")
elif framework == "ag2" and not AG2_AVAILABLE:
raise ImportError("AG2 is not installed. Please install it with 'pip install praisonai[ag2]'")
def is_function_or_decorated(self, obj):
"""
@@ -391,7 +403,7 @@ class AgentsGenerator:
tools_dict = {}
# Only try to use praisonai_tools if it's available and needed
if PRAISONAI_TOOLS_AVAILABLE and (CREWAI_AVAILABLE or AUTOGEN_AVAILABLE or PRAISONAI_AVAILABLE):
if PRAISONAI_TOOLS_AVAILABLE and (CREWAI_AVAILABLE or AUTOGEN_AVAILABLE or PRAISONAI_AVAILABLE or AG2_AVAILABLE):
tools_dict = {
'CodeDocsSearchTool': CodeDocsSearchTool(),
'CSVSearchTool': CSVSearchTool(),
@@ -462,6 +474,12 @@ class AgentsGenerator:
else:
self.logger.info("Using AutoGen v0.2")
return self._run_autogen(config, topic, tools_dict)
elif framework == "ag2":
if not AG2_AVAILABLE:
raise ImportError("AG2 is not installed. Please install it with 'pip install praisonai[ag2]'")
if AGENTOPS_AVAILABLE:
agentops.init(os.environ.get("AGENTOPS_API_KEY"), default_tags=["ag2"])
return self._run_ag2(config, topic, tools_dict)
elif framework == "praisonai":
if not PRAISONAI_AVAILABLE:
raise ImportError("PraisonAI is not installed. Please install it with 'pip install praisonaiagents'")
@@ -711,6 +729,148 @@ class AgentsGenerator:
self.logger.error(f"Error running AutoGen v0.4: {str(e)}")
return f"### AutoGen v0.4 Error ###\n{str(e)}"
def _run_ag2(self, config, topic, tools_dict):
"""
Run agents using the AG2 framework (community fork of AutoGen, PyPI: ag2).
AG2 installs under the 'autogen' namespace — there is no 'import ag2'.
Uses LLMConfig context manager + AssistantAgent + GroupChat pattern.
Args:
config (dict): Configuration dictionary parsed from YAML
topic (str): The topic/task to process
tools_dict (dict): Dictionary of available tools
Returns:
str: Result prefixed with '### AG2 Output ###'
"""
import re as _re
from autogen import (
AssistantAgent, UserProxyAgent, GroupChat, GroupChatManager, LLMConfig
)
model_config = self.config_list[0] if self.config_list else {}
# Allow YAML top-level llm block to override config_list values
yaml_llm = config.get("llm", {}) or {}
# Also check first role's llm block as a fallback
first_role_llm = {}
for role_details in config.get("roles", {}).values():
first_role_llm = role_details.get("llm", {}) or {}
break
# Priority: YAML top-level llm > first role llm > config_list > env vars
def _resolve(key, env_var=None, default=None):
return (yaml_llm.get(key) or first_role_llm.get(key)
or model_config.get(key)
or (os.environ.get(env_var) if env_var else None)
or default)
api_type = _resolve("api_type", default="openai").lower()
model_name = _resolve("model", default="gpt-4o-mini")
api_key = _resolve("api_key", env_var="OPENAI_API_KEY")
base_url = (model_config.get("base_url")
or yaml_llm.get("base_url")
or os.environ.get("OPENAI_BASE_URL")
or os.environ.get("OPENAI_API_BASE"))
# Build LLMConfig — Bedrock needs no api_key
if api_type == "bedrock":
llm_config_entry = {"api_type": "bedrock", "model": model_name}
else:
llm_config_entry = {"model": model_name}
if api_key:
llm_config_entry["api_key"] = api_key
if base_url and base_url not in ("https://api.openai.com/v1", "https://api.openai.com/v1/"):
llm_config_entry["base_url"] = base_url
llm_config = LLMConfig(llm_config_entry)
user_proxy = UserProxyAgent(
name="User",
human_input_mode="NEVER",
is_termination_msg=lambda x: "TERMINATE" in (x.get("content") or ""),
code_execution_config=False,
)
# Create one AssistantAgent per role
ag2_agent_entries = []
for role, details in config["roles"].items():
agent_name = details.get("role", role).replace("{topic}", topic)
backstory = details.get("backstory", "").replace("{topic}", topic)
agent_name_safe = _re.sub(r"[^a-zA-Z0-9_\-]", "_", agent_name)
assistant = AssistantAgent(
name=agent_name_safe,
system_message=backstory + "\nWhen the task is done, reply 'TERMINATE'.",
llm_config=llm_config,
)
ag2_agent_entries.append((role, details, assistant))
# Register tools via AG2 decorator pattern
for role, details, assistant in ag2_agent_entries:
for tool_name in details.get("tools", []):
tool = tools_dict.get(tool_name)
if tool is None:
continue
func = tool if callable(tool) else getattr(tool, "run", None)
if func is None:
continue
def make_tool_fn(f):
def tool_fn(**kwargs):
return f(**kwargs) if callable(f) else str(f)
tool_fn.__name__ = tool_name
return tool_fn
wrapped = make_tool_fn(func)
assistant.register_for_llm(description=f"Tool: {tool_name}")(wrapped)
user_proxy.register_for_execution()(wrapped)
all_assistants = [a for _, _, a in ag2_agent_entries]
if not all_assistants:
return "### AG2 Output ###\nNo agents created from configuration."
# Build initial message from all task descriptions
task_lines = []
for role, details, _ in ag2_agent_entries:
for task_name, task_details in details.get("tasks", {}).items():
desc = task_details.get("description", "").replace("{topic}", topic)
if desc:
task_lines.append(desc)
initial_message = "\n".join(task_lines) if task_lines else topic
groupchat = GroupChat(
agents=[user_proxy] + all_assistants,
messages=[],
max_round=12,
)
manager = GroupChatManager(groupchat=groupchat, llm_config=llm_config)
try:
chat_result = user_proxy.initiate_chat(manager, message=initial_message)
except Exception as e:
return f"### AG2 Error ###\n{str(e)}"
# Prefer ChatResult.summary if available, otherwise scan messages
result_content = ""
summary = getattr(chat_result, "summary", None)
if summary and isinstance(summary, str) and summary.strip():
result_content = _re.sub(r'[\s\.\,]*TERMINATE[\s\.\,]*$', '', summary, flags=_re.IGNORECASE).strip().rstrip('.')
if not result_content:
for msg in reversed(groupchat.messages):
if msg.get("name") == "User":
continue
content = (msg.get("content") or "").strip()
if content:
result_content = _re.sub(r'[\s\.\,]*TERMINATE[\s\.\,]*$', '', content, flags=_re.IGNORECASE).strip().rstrip('.')
if result_content:
break
if not result_content:
result_content = "Task completed."
return f"### AG2 Output ###\n{result_content}"
def _run_crewai(self, config, topic, tools_dict):
"""
Run agents using the CrewAI framework.
+22
View File
@@ -91,6 +91,23 @@ def _check_autogen_v4_available() -> bool:
return _autogen_v4_available
# --- AG2 lazy loading ---
_ag2_available = None
def _check_ag2_available() -> bool:
"""Check if AG2 (community fork of AutoGen) is available (cached)."""
global _ag2_available
if _ag2_available is None:
try:
import importlib.metadata
importlib.metadata.distribution('ag2')
from autogen import LLMConfig # noqa: F401 — AG2-exclusive class
_ag2_available = True
except Exception:
_ag2_available = False
return _ag2_available
def _get_autogen():
"""Lazy load autogen module."""
global _autogen_module
@@ -658,6 +675,11 @@ AutoGen is not installed. Please install with:
Praisonai is not installed. Please install with:
pip install praisonaiagents
""")
elif framework == "ag2" and not _check_ag2_available():
raise ImportError("""
AG2 is not installed. Please install with:
pip install "praisonai[ag2]"
""")
# Only show tools message if using a framework and tools are needed
if (framework in ["crewai", "autogen"]) and not _check_praisonai_tools_available():
@@ -312,13 +312,31 @@ class HybridWorkflowExecutor:
return flags
def _eval_condition(self, condition: str, flags: Dict) -> bool:
"""Evaluate a simple condition expression."""
"""Evaluate a simple condition expression safely using AST whitelisting."""
import ast
condition = condition.strip()
if condition.startswith("{{") and condition.endswith("}}"):
condition = condition[2:-2].strip()
# Validate AST - only allow safe node types
try:
tree = ast.parse(condition, mode='eval')
except SyntaxError:
return False
_safe_nodes = (
ast.Expression, ast.Compare, ast.BoolOp, ast.UnaryOp, ast.BinOp,
ast.Constant, ast.Name, ast.Attribute, ast.Load,
ast.And, ast.Or, ast.Not, ast.Eq, ast.NotEq, ast.Lt, ast.LtE,
ast.Gt, ast.GtE, ast.Is, ast.IsNot, ast.In, ast.NotIn,
)
for node in ast.walk(tree):
if not isinstance(node, _safe_nodes):
return False
context = {"flags": type("Flags", (), flags)(), "env": os.environ}
try:
return bool(eval(condition, {"__builtins__": {}}, context))
return bool(eval(compile(tree, '<condition>', 'eval'), {"__builtins__": {}}, context))
except Exception:
return False
@@ -267,12 +267,24 @@ class JobWorkflowExecutor:
def _exec_inline_python(self, code: str, step: Dict, flags: Dict) -> Dict:
"""Execute inline Python code in an isolated namespace."""
_safe_builtins = {
"True": True, "False": False, "None": None,
"int": int, "float": float, "str": str, "bool": bool,
"list": list, "dict": dict, "tuple": tuple, "set": set,
"len": len, "range": range, "enumerate": enumerate,
"zip": zip, "map": map, "filter": filter,
"sorted": sorted, "reversed": reversed,
"min": min, "max": max, "sum": sum, "abs": abs, "round": round,
"isinstance": isinstance, "type": type,
"print": print, "repr": repr,
"hasattr": hasattr, "getattr": getattr, "setattr": setattr,
}
namespace = {
"flags": flags,
"vars": {k: self._resolve_var_value(v) for k, v in self._vars.items()},
"env": os.environ.copy(),
"cwd": self._cwd,
"__builtins__": __builtins__,
"__builtins__": _safe_builtins,
}
try:
exec(code, namespace)
@@ -719,16 +731,34 @@ class JobWorkflowExecutor:
# ------------------------------------------------------------------
def _eval_condition(self, condition: str, flags: Dict) -> bool:
"""Evaluate a simple condition expression."""
"""Evaluate a simple condition expression safely using AST whitelisting."""
import ast
# Strip {{ }} if present
condition = condition.strip()
if condition.startswith("{{") and condition.endswith("}}"):
condition = condition[2:-2].strip()
# Validate AST - only allow safe node types
try:
tree = ast.parse(condition, mode='eval')
except SyntaxError:
return False
_safe_nodes = (
ast.Expression, ast.Compare, ast.BoolOp, ast.UnaryOp, ast.BinOp,
ast.Constant, ast.Name, ast.Attribute, ast.Load,
ast.And, ast.Or, ast.Not, ast.Eq, ast.NotEq, ast.Lt, ast.LtE,
ast.Gt, ast.GtE, ast.Is, ast.IsNot, ast.In, ast.NotIn,
)
for node in ast.walk(tree):
if not isinstance(node, _safe_nodes):
return False
# Build evaluation context
context = {"flags": type("Flags", (), flags)(), "env": os.environ}
try:
return bool(eval(condition, {"__builtins__": {}}, context))
return bool(eval(compile(tree, '<condition>', 'eval'), {"__builtins__": {}}, context))
except Exception:
return False
+68 -1
View File
@@ -67,6 +67,56 @@ import importlib
# REMOVED: from praisonai.inbuilt_tools import * - causes ~3200ms crewai import
# REMOVED: from praisonai.inc.config import generate_config - causes ~3500ms langchain import
# Security: blocklist of environment variable keys that must not be set from
# untrusted YAML config files. These keys can alter code-loading behaviour
# (LD_PRELOAD, PYTHONPATH, …) or redirect subprocesses (PATH) and are
# therefore a vector for arbitrary code execution (CWE-78).
_BLOCKED_ENV_KEYS = frozenset({
# Dynamic linker injection
"LD_PRELOAD", "LD_LIBRARY_PATH", "LD_AUDIT",
"DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH",
"DYLD_FALLBACK_LIBRARY_PATH",
# Executable / module search paths
"PATH",
"PYTHONPATH", "PYTHONHOME", "PYTHONSTARTUP",
"NODE_PATH", "NODE_OPTIONS",
"RUBYLIB", "PERL5LIB", "PERL5OPT",
"CLASSPATH",
# Proxy / redirect (could exfiltrate traffic)
"HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY",
# Miscellaneous dangerous keys
"BASH_ENV", "ENV", "CDPATH",
"PROMPT_COMMAND",
"SHLVL",
})
# Pre-compute uppercase lookup set once at module load (avoids rebuilding per call)
_BLOCKED_ENV_KEYS_UPPER = frozenset(k.upper() for k in _BLOCKED_ENV_KEYS)
def _validate_env_key(key) -> None:
"""Raise ``ValueError`` if *key* is a blocked environment variable name.
The check is case-insensitive so that ``ld_preload`` is caught as well as
``LD_PRELOAD``. Non-string keys (e.g. YAML integer or null keys) are
rejected with a clear validation error.
"""
if not isinstance(key, str):
raise ValueError(
f"Environment variable key must be a string, got {type(key).__name__}: {key!r}"
)
if not key or "=" in key or "\x00" in key:
raise ValueError(
"Environment variable key must be a non-empty string without '=' or NUL characters."
)
if key.upper() in _BLOCKED_ENV_KEYS_UPPER:
raise ValueError(
f"Setting environment variable '{key}' is not allowed in schedule "
f"config files because it can be used to execute arbitrary code."
)
# Lazy import helpers for inbuilt_tools and config
def _get_inbuilt_tools():
"""Lazy import inbuilt_tools only when crewai/autogen features are used."""
@@ -444,21 +494,38 @@ class PraisonAI:
try:
with open(args.schedule_config, 'r') as f:
file_config = yaml.safe_load(f)
if file_config is None:
file_config = {}
if not isinstance(file_config, dict):
raise ValueError("Schedule config must be a YAML mapping")
# Extract deployment config
deploy_config = file_config.get('deployment', {})
if deploy_config is None:
deploy_config = {}
if not isinstance(deploy_config, dict):
raise ValueError("'deployment' must be a mapping")
schedule_expr = schedule_expr or deploy_config.get('schedule')
provider = deploy_config.get('provider', provider)
config['max_retries'] = deploy_config.get('max_retries', config['max_retries'])
# Apply environment variables if specified
env_vars = file_config.get('environment', {})
if not isinstance(env_vars, dict):
raise ValueError("'environment' must be a mapping of KEY: value pairs")
# Validate all keys first (fail-closed) before mutating os.environ
validated_env = {}
for key, value in env_vars.items():
os.environ[key] = str(value)
_validate_env_key(key)
validated_env[key] = str(value)
os.environ.update(validated_env)
except FileNotFoundError:
print(f"Configuration file not found: {args.schedule_config}")
sys.exit(1)
except ValueError as e:
print(f"Invalid schedule configuration: {e}")
sys.exit(1)
except yaml.YAMLError as e:
print(f"Error parsing configuration file: {e}")
sys.exit(1)
+3 -2
View File
@@ -57,7 +57,7 @@ class CloudDeployer:
file.write("FROM python:3.11-slim\n")
file.write("WORKDIR /app\n")
file.write("COPY . .\n")
file.write("RUN pip install flask praisonai==4.5.80 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==4.5.87 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
@@ -87,7 +87,8 @@ class CloudDeployer:
file.write(" html_output = markdown.markdown(output)\n")
file.write(" return f'<html><body>{html_output}</body></html>'\n\n")
file.write("if __name__ == \"__main__\":\n")
file.write(" app.run(debug=True)\n")
file.write(" import os\n")
file.write(" app.run(debug=os.environ.get('DEBUG', 'false').lower() == 'true')\n")
def set_environment_variables(self):
"""Sets environment variables with fallback to .env values or defaults."""
@@ -5,6 +5,8 @@ Requires: singlestoredb
Install: pip install singlestoredb
"""
import ast
import json
import logging
from typing import Any, Dict, List, Optional
@@ -165,7 +167,7 @@ class SingleStoreVectorKnowledgeStore(KnowledgeStore):
INSERT INTO {table_name} (id, content, embedding, metadata, content_hash, created_at)
VALUES (%s, %s, %s, %s, %s, %s)
""", (doc.id, doc.content, embedding_str,
str(doc.metadata) if doc.metadata else None,
json.dumps(doc.metadata) if doc.metadata else None,
doc.content_hash, doc.created_at))
ids.append(doc.id)
@@ -189,7 +191,7 @@ class SingleStoreVectorKnowledgeStore(KnowledgeStore):
REPLACE INTO {table_name} (id, content, embedding, metadata, content_hash, created_at)
VALUES (%s, %s, %s, %s, %s, %s)
""", (doc.id, doc.content, embedding_str,
str(doc.metadata) if doc.metadata else None,
json.dumps(doc.metadata) if doc.metadata else None,
doc.content_hash, doc.created_at))
ids.append(doc.id)
@@ -227,7 +229,7 @@ class SingleStoreVectorKnowledgeStore(KnowledgeStore):
documents.append(KnowledgeDocument(
id=row[0],
content=row[1],
metadata=eval(row[2]) if row[2] else None,
metadata=self._deserialize_metadata(row[2]),
content_hash=row[3],
created_at=row[4]
))
@@ -256,12 +258,52 @@ class SingleStoreVectorKnowledgeStore(KnowledgeStore):
KnowledgeDocument(
id=row[0],
content=row[1],
metadata=eval(row[2]) if row[2] else None,
metadata=self._deserialize_metadata(row[2]),
content_hash=row[3],
created_at=row[4]
)
for row in cur.fetchall()
]
@staticmethod
def _deserialize_metadata(value: Any) -> Optional[Dict[str, Any]]:
"""Safely deserialize metadata from JSON/legacy literal formats."""
if not value:
return None
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
return json.loads(value)
except json.JSONDecodeError:
try:
parsed = ast.literal_eval(value)
if not isinstance(parsed, dict):
return None
if SingleStoreVectorKnowledgeStore._is_json_compatible(parsed):
return parsed
return None
except (ValueError, SyntaxError) as exc:
logging.getLogger(__name__).warning(
"Failed to parse metadata value from SingleStore: %s",
type(exc).__name__,
)
return None
return None
@staticmethod
def _is_json_compatible(value: Any) -> bool:
"""Ensure legacy metadata uses JSON-compatible value types only."""
if value is None or isinstance(value, (str, int, float, bool)):
return True
if isinstance(value, list):
return all(SingleStoreVectorKnowledgeStore._is_json_compatible(item) for item in value)
if isinstance(value, dict):
return all(
isinstance(key, str) and SingleStoreVectorKnowledgeStore._is_json_compatible(item)
for key, item in value.items()
)
return False
def delete(
self,
+2 -1
View File
@@ -40,6 +40,7 @@ trace_exporter: none # none, otlp, jaeger, zipkin
```
"""
import hmac
import json
import os
import time
@@ -332,7 +333,7 @@ def create_auth_middleware(auth_type: str, api_key: Optional[str] = None, jwt_se
# No key configured, allow request
return await call_next(request)
if provided_key != expected_key:
if not provided_key or not hmac.compare_digest(provided_key, expected_key):
return JSONResponse(
{"error": {"code": "unauthorized", "message": "Invalid or missing API key"}},
status_code=401
@@ -853,7 +853,13 @@ from praisonaiagents import Agent
# Define a simple tool
def calculator(expression: str) -> str:
"""Calculate a math expression."""
return str(eval(expression))
import ast
node = ast.parse(expression.strip(), mode='eval')
allowed = (ast.Expression, ast.BinOp, ast.UnaryOp, ast.Constant, ast.operator, ast.unaryop)
for child in ast.walk(node):
if not isinstance(child, allowed):
return "Error: unsupported expression"
return str(eval(compile(node, '<expr>', 'eval'), {"__builtins__": {}}))
# Create agent with tool
agent = Agent(
+3 -1
View File
@@ -583,10 +583,12 @@ print(f"[DEBUG] tools_dict: {tools_dict}")
with open(agent_file, 'r') as f:
config = yaml.safe_load(f)
import secrets
AUTH_PASSWORD_ENABLED = os.getenv("AUTH_PASSWORD_ENABLED", "true").lower() == "true"
CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
if not CHAINLIT_AUTH_SECRET:
os.environ["CHAINLIT_AUTH_SECRET"] = "p8BPhQChpg@J>jBz$wGxqLX2V>yTVgP*7Ky9H$aV:axW~ANNX-7_T:o@lnyCBu^U"
os.environ["CHAINLIT_AUTH_SECRET"] = secrets.token_hex(32)
logger.warning("CHAINLIT_AUTH_SECRET not set; generated a random secret for this session.")
username_env = os.getenv("CHAINLIT_USERNAME", "admin")
password_env = os.getenv("CHAINLIT_PASSWORD", "admin")
+5 -1
View File
@@ -25,9 +25,13 @@ logger = logging.getLogger(__name__)
log_level = os.getenv("LOGLEVEL", "INFO").upper() or "INFO"
logging.basicConfig(level=log_level, format="%(asctime)s - %(levelname)s - %(message)s")
import secrets
# Auth secret (required by Chainlit)
if not os.getenv("CHAINLIT_AUTH_SECRET"):
os.environ["CHAINLIT_AUTH_SECRET"] = "p8BPhQChpg@J>jBz$wGxqLX2V>yTVgP*7Ky9H$aV:axW~ANNX-7_T:o@lnyCBu^U"
os.environ["CHAINLIT_AUTH_SECRET"] = secrets.token_hex(32)
logger.warning("CHAINLIT_AUTH_SECRET not set; generated a random secret for this session.")
# ---------------------------------------------------------------------------
# Lazy imports
+5 -2
View File
@@ -194,10 +194,13 @@ def _ensure_env_loaded():
_cached_modules['env_loaded'] = True
# Auth secret setup (required early)
import secrets
CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
if not CHAINLIT_AUTH_SECRET:
os.environ["CHAINLIT_AUTH_SECRET"] = "p8BPhQChpg@J>jBz$wGxqLX2V>yTVgP*7Ky9H$aV:axW~ANNX-7_T:o@lnyCBu^U"
CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
CHAINLIT_AUTH_SECRET = secrets.token_hex(32)
os.environ["CHAINLIT_AUTH_SECRET"] = CHAINLIT_AUTH_SECRET
logger.warning("CHAINLIT_AUTH_SECRET not set; generated a random secret for this session.")
def save_setting(key: str, value: str):
"""Save a setting to the database"""
+5 -2
View File
@@ -193,10 +193,13 @@ def _ensure_env_loaded():
_cached_modules['env_loaded'] = True
# Auth secret setup (required early)
import secrets
CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
if not CHAINLIT_AUTH_SECRET:
os.environ["CHAINLIT_AUTH_SECRET"] = "p8BPhQChpg@J>jBz$wGxqLX2V>yTVgP*7Ky9H$aV:axW~ANNX-7_T:o@lnyCBu^U"
CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
CHAINLIT_AUTH_SECRET = secrets.token_hex(32)
os.environ["CHAINLIT_AUTH_SECRET"] = CHAINLIT_AUTH_SECRET
logger.warning("CHAINLIT_AUTH_SECRET not set; generated a random secret for this session.")
def save_setting(key: str, value: str):
"""Save a setting to the database"""
+5 -3
View File
@@ -36,11 +36,13 @@ logger.addHandler(console_handler)
logger.setLevel(log_level)
# Set up CHAINLIT_AUTH_SECRET
import secrets
CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
if not CHAINLIT_AUTH_SECRET:
os.environ["CHAINLIT_AUTH_SECRET"] = "p8BPhQChpg@J>jBz$wGxqLX2V>yTVgP*7Ky9H$aV:axW~ANNX-7_T:o@lnyCBu^U"
CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
CHAINLIT_AUTH_SECRET = secrets.token_hex(32)
os.environ["CHAINLIT_AUTH_SECRET"] = CHAINLIT_AUTH_SECRET
logger.warning("CHAINLIT_AUTH_SECRET not set; generated a random secret for this session.")
# Database path
DB_PATH = os.path.expanduser("~/.praison/database.sqlite")
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.5.80"
__version__ = "4.5.87"
+3 -2
View File
@@ -12,9 +12,9 @@ dependencies = [
"rich>=13.7",
"markdown>=3.5",
"pyparsing>=3.0.0",
"praisonaiagents>=1.5.80",
"praisonaiagents>=1.5.87",
"python-dotenv>=0.19.0",
"litellm>=1.81.0",
"litellm>=1.81.0,<=1.82.6",
"PyYAML>=6.0",
"mcp>=1.20.0",
"typer>=0.9.0",
@@ -91,6 +91,7 @@ call = [
train = []
crewai = ["crewai>=0.157.0", "praisonai-tools>=0.1.0"]
autogen = ["pyautogen==0.2.29", "praisonai-tools>=0.1.0", "crewai"]
ag2 = ["ag2>=0.11.0", "praisonai-tools>=0.1.0"]
autogen-v4 = [
"autogen-agentchat>=0.4.0",
"autogen-ext[openai]>=0.4.0",
Whitespace-only changes.
@@ -0,0 +1,477 @@
"""
AG2 Mock Integration Tests — verifies PraisonAI correctly orchestrates AG2 agents.
Uses mocked AG2 responses — no real LLM API calls are made.
Fast: all tests should complete in < 1s each.
Run:
pytest tests/integration/ag2/test_ag2_integration.py -v
"""
import pytest
import os
import sys
from unittest.mock import patch, MagicMock
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../../"))
# Stub heavy dependencies that auto.py (develop branch) imports at module level
# so that tests can import praisonai without a full installation.
for _stub in ("instructor",):
if _stub not in sys.modules:
sys.modules[_stub] = MagicMock()
import importlib as _importlib
if "openai" not in sys.modules:
try:
_importlib.import_module("openai")
except ImportError:
_mock_openai = MagicMock()
_mock_openai.__version__ = "1.0.0"
sys.modules["openai"] = _mock_openai
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def single_agent_yaml():
return """
framework: ag2
topic: Write a short poem about Python
roles:
poet:
role: "Creative Poet"
goal: "Write elegant and concise poems"
backstory: "You are a creative poet who loves Python and technology."
tasks:
write_poem:
description: "Write a short 4-line poem about Python programming"
expected_output: "A 4-line poem about Python"
"""
@pytest.fixture
def multi_agent_yaml():
return """
framework: ag2
topic: Explain why open-source AI matters
roles:
researcher:
role: "Research Analyst"
goal: "Research and gather key facts"
backstory: "Expert in technology research and trend analysis."
tasks:
research:
description: "Research why open-source AI matters"
expected_output: "Key facts and data points"
writer:
role: "Technical Writer"
goal: "Write clear technical content"
backstory: "Professional technical writer with 10 years experience."
tasks:
write:
description: "Write a clear explanation of why open-source AI matters"
expected_output: "A 200-word explanation"
"""
@pytest.fixture
def mock_ag2_classes():
"""Mock all AG2 classes used in _run_ag2."""
mock_llm_config = MagicMock()
mock_llm_config.__enter__ = MagicMock(return_value=mock_llm_config)
mock_llm_config.__exit__ = MagicMock(return_value=False)
mock_assistant = MagicMock()
mock_assistant.name = "test_agent"
mock_user_proxy = MagicMock()
mock_user_proxy.name = "User"
mock_groupchat = MagicMock()
mock_groupchat.messages = [
{
"name": "test_agent",
"role": "assistant",
"content": "Task completed successfully. TERMINATE",
}
]
mock_manager = MagicMock()
return {
"llm_config": mock_llm_config,
"assistant": mock_assistant,
"user_proxy": mock_user_proxy,
"groupchat": mock_groupchat,
"manager": mock_manager,
}
# ---------------------------------------------------------------------------
# AG2 import detection
# ---------------------------------------------------------------------------
class TestAG2Import:
@pytest.mark.integration
def test_ag2_distribution_available(self):
"""ag2 PyPI distribution should be findable when installed.
Note: ag2 installs under the 'autogen' namespace — 'import ag2' does NOT work.
Detection uses importlib.metadata to check the 'ag2' distribution name.
"""
import importlib.metadata
try:
dist = importlib.metadata.distribution('ag2')
assert dist.metadata['Name'] == 'ag2'
except importlib.metadata.PackageNotFoundError:
pytest.skip("ag2 not installed — skipping AG2 integration tests")
@pytest.mark.integration
def test_autogen_namespace_importable_with_ag2(self):
"""When ag2 is installed, autogen namespace classes are importable."""
try:
from autogen import AssistantAgent, UserProxyAgent, GroupChat, LLMConfig
assert AssistantAgent is not None
assert UserProxyAgent is not None
assert GroupChat is not None
assert LLMConfig is not None
except ImportError:
pytest.skip("ag2 not installed — autogen namespace not available")
# ---------------------------------------------------------------------------
# Single-agent flow (mocked)
# ---------------------------------------------------------------------------
class TestAG2SingleAgentFlow:
@pytest.mark.integration
def test_single_agent_yaml_initialises_praisonai(self, single_agent_yaml, mock_ag2_classes):
"""PraisonAI initialises correctly with framework='ag2' from YAML."""
import tempfile
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
f.write(single_agent_yaml)
yaml_path = f.name
try:
with patch("praisonai.agents_generator.AG2_AVAILABLE", True), \
patch("praisonai.agents_generator.PRAISONAI_AVAILABLE", True):
try:
from praisonai import PraisonAI
ai = PraisonAI(agent_file=yaml_path, framework="ag2")
assert ai.framework == "ag2"
except ImportError as e:
pytest.skip(f"PraisonAI not available: {e}")
finally:
os.unlink(yaml_path)
@pytest.mark.integration
def test_single_agent_run_returns_ag2_output(self, mock_ag2_classes):
"""_run_ag2 executes single-agent flow and returns '### AG2 Output ###'."""
config = {
"framework": "ag2",
"topic": "Write a poem",
"roles": {
"poet": {
"role": "Creative Poet",
"goal": "Write poems",
"backstory": "A creative poet.",
"tasks": {
"task1": {
"description": "Write a poem about Python",
"expected_output": "A short poem",
}
},
"tools": [],
}
},
}
m = mock_ag2_classes
with patch("praisonai.agents_generator.AG2_AVAILABLE", True), \
patch("praisonai.agents_generator.CREWAI_AVAILABLE", False), \
patch("praisonai.agents_generator.AUTOGEN_AVAILABLE", False), \
patch("praisonai.agents_generator.PRAISONAI_AVAILABLE", True):
try:
from praisonai.agents_generator import AgentsGenerator
except ImportError as e:
pytest.skip(f"AgentsGenerator not available: {e}")
gen = AgentsGenerator(
agent_file="agents.yaml",
framework="ag2",
config_list=[{"model": "gpt-4o-mini", "api_key": "sk-test"}],
)
with patch("autogen.LLMConfig", return_value=m["llm_config"]), \
patch("autogen.AssistantAgent", return_value=m["assistant"]), \
patch("autogen.UserProxyAgent", return_value=m["user_proxy"]), \
patch("autogen.GroupChat", return_value=m["groupchat"]), \
patch("autogen.GroupChatManager", return_value=m["manager"]):
result = gen._run_ag2(config, "Write a poem", {})
assert "### AG2 Output ###" in result
assert "Task completed successfully" in result
@pytest.mark.integration
def test_single_agent_calls_initiate_chat(self, mock_ag2_classes):
"""user_proxy.initiate_chat is called with the manager and initial message."""
config = {
"framework": "ag2",
"topic": "Test topic",
"roles": {
"agent": {
"role": "Agent",
"goal": "Help",
"backstory": "Helpful agent.",
"tasks": {
"t": {"description": "Do the task", "expected_output": "Done"}
},
"tools": [],
}
},
}
m = mock_ag2_classes
with patch("praisonai.agents_generator.AG2_AVAILABLE", True), \
patch("praisonai.agents_generator.CREWAI_AVAILABLE", False), \
patch("praisonai.agents_generator.AUTOGEN_AVAILABLE", False), \
patch("praisonai.agents_generator.PRAISONAI_AVAILABLE", True):
try:
from praisonai.agents_generator import AgentsGenerator
except ImportError as e:
pytest.skip(f"AgentsGenerator not available: {e}")
gen = AgentsGenerator(
agent_file="agents.yaml",
framework="ag2",
config_list=[{"model": "gpt-4o-mini", "api_key": "sk-test"}],
)
with patch("autogen.LLMConfig", return_value=m["llm_config"]), \
patch("autogen.AssistantAgent", return_value=m["assistant"]), \
patch("autogen.UserProxyAgent", return_value=m["user_proxy"]), \
patch("autogen.GroupChat", return_value=m["groupchat"]), \
patch("autogen.GroupChatManager", return_value=m["manager"]):
gen._run_ag2(config, "Test topic", {})
m["user_proxy"].initiate_chat.assert_called_once()
call_args = m["user_proxy"].initiate_chat.call_args
# First positional arg should be the manager
assert call_args[0][0] is m["manager"]
# message kwarg should be non-empty (task description or topic)
message = call_args[1].get("message", "")
assert message != ""
# ---------------------------------------------------------------------------
# Multi-agent GroupChat flow (mocked)
# ---------------------------------------------------------------------------
class TestAG2MultiAgentGroupChatFlow:
@pytest.mark.integration
def test_multi_agent_creates_correct_number_of_assistants(self, multi_agent_yaml, mock_ag2_classes):
"""Two roles in YAML → two AssistantAgents created."""
config = {
"framework": "ag2",
"topic": "Explain open-source AI",
"roles": {
"researcher": {
"role": "Research Analyst", "goal": "Research", "backstory": "Analyst.",
"tasks": {"r": {"description": "Research it", "expected_output": "Facts"}},
"tools": [],
},
"writer": {
"role": "Technical Writer", "goal": "Write", "backstory": "Writer.",
"tasks": {"w": {"description": "Write it", "expected_output": "Article"}},
"tools": [],
},
},
}
m = mock_ag2_classes
assistant_call_count = [0]
def count_assistant(**kwargs):
assistant_call_count[0] += 1
a = MagicMock()
a.name = kwargs.get("name", f"agent_{assistant_call_count[0]}")
return a
with patch("praisonai.agents_generator.AG2_AVAILABLE", True), \
patch("praisonai.agents_generator.CREWAI_AVAILABLE", False), \
patch("praisonai.agents_generator.AUTOGEN_AVAILABLE", False), \
patch("praisonai.agents_generator.PRAISONAI_AVAILABLE", True):
try:
from praisonai.agents_generator import AgentsGenerator
except ImportError as e:
pytest.skip(f"AgentsGenerator not available: {e}")
gen = AgentsGenerator(
agent_file="agents.yaml",
framework="ag2",
config_list=[{"model": "gpt-4o-mini", "api_key": "sk-test"}],
)
with patch("autogen.LLMConfig", return_value=m["llm_config"]), \
patch("autogen.AssistantAgent", side_effect=count_assistant), \
patch("autogen.UserProxyAgent", return_value=m["user_proxy"]), \
patch("autogen.GroupChat", return_value=m["groupchat"]), \
patch("autogen.GroupChatManager", return_value=m["manager"]):
gen._run_ag2(config, "Explain open-source AI", {})
assert assistant_call_count[0] == 2
@pytest.mark.integration
def test_multi_agent_groupchat_max_round_set(self, mock_ag2_classes):
"""GroupChat is created with max_round parameter."""
config = {
"framework": "ag2",
"topic": "Test",
"roles": {
"a": {
"role": "Agent", "goal": "Help", "backstory": "Helper.",
"tasks": {"t": {"description": "Do", "expected_output": "Done"}},
"tools": [],
}
},
}
m = mock_ag2_classes
groupchat_kwargs = {}
def capture_groupchat(**kwargs):
groupchat_kwargs.update(kwargs)
return m["groupchat"]
with patch("praisonai.agents_generator.AG2_AVAILABLE", True), \
patch("praisonai.agents_generator.CREWAI_AVAILABLE", False), \
patch("praisonai.agents_generator.AUTOGEN_AVAILABLE", False), \
patch("praisonai.agents_generator.PRAISONAI_AVAILABLE", True):
try:
from praisonai.agents_generator import AgentsGenerator
except ImportError as e:
pytest.skip(f"AgentsGenerator not available: {e}")
gen = AgentsGenerator(
agent_file="agents.yaml",
framework="ag2",
config_list=[{"model": "gpt-4o-mini", "api_key": "sk-test"}],
)
with patch("autogen.LLMConfig", return_value=m["llm_config"]), \
patch("autogen.AssistantAgent", return_value=m["assistant"]), \
patch("autogen.UserProxyAgent", return_value=m["user_proxy"]), \
patch("autogen.GroupChat", side_effect=capture_groupchat), \
patch("autogen.GroupChatManager", return_value=m["manager"]):
gen._run_ag2(config, "Test", {})
assert "max_round" in groupchat_kwargs
assert groupchat_kwargs["max_round"] > 0
# ---------------------------------------------------------------------------
# Backward-compatibility: existing autogen/crewai paths unaffected
# ---------------------------------------------------------------------------
class TestAG2BackwardCompatibility:
@pytest.mark.integration
def test_autogen_framework_still_works(self):
"""framework='autogen' dispatches to _run_autogen, not _run_ag2."""
import yaml
with patch("praisonai.agents_generator.AUTOGEN_AVAILABLE", True), \
patch("praisonai.agents_generator.AG2_AVAILABLE", False), \
patch("praisonai.agents_generator.CREWAI_AVAILABLE", False), \
patch("praisonai.agents_generator.PRAISONAI_AVAILABLE", True):
try:
from praisonai.agents_generator import AgentsGenerator
except ImportError as e:
pytest.skip(f"AgentsGenerator not available: {e}")
gen = AgentsGenerator(
agent_file="agents.yaml",
framework="autogen",
config_list=[{"model": "gpt-4o-mini", "api_key": "sk-test"}],
)
assert gen.framework == "autogen"
gen.agent_yaml = yaml.dump({
"framework": "autogen",
"topic": "Test",
"roles": {
"agent": {
"role": "Agent", "goal": "Help", "backstory": "Helper.",
"tasks": {"t": {"description": "Do", "expected_output": "Done"}},
"tools": [],
}
},
})
# Verify _run_ag2 is NOT called when framework='autogen'
with patch.object(gen, "_run_ag2") as mock_run_ag2, \
patch.object(gen, "_run_autogen", return_value="autogen result") as mock_run_autogen:
gen.generate_crew_and_kickoff()
mock_run_ag2.assert_not_called()
mock_run_autogen.assert_called_once()
@pytest.mark.integration
def test_ag2_framework_dispatches_to_run_ag2(self, mock_ag2_classes):
"""framework='ag2' dispatches to _run_ag2 and NOT to _run_autogen."""
import yaml
m = mock_ag2_classes
with patch("praisonai.agents_generator.AG2_AVAILABLE", True), \
patch("praisonai.agents_generator.CREWAI_AVAILABLE", False), \
patch("praisonai.agents_generator.AUTOGEN_AVAILABLE", False), \
patch("praisonai.agents_generator.PRAISONAI_AVAILABLE", True):
try:
from praisonai.agents_generator import AgentsGenerator
except ImportError as e:
pytest.skip(f"AgentsGenerator not available: {e}")
gen = AgentsGenerator(
agent_file="agents.yaml",
framework="ag2",
config_list=[{"model": "gpt-4o-mini", "api_key": "sk-test"}],
)
gen.agent_yaml = yaml.dump({
"framework": "ag2",
"topic": "Test",
"roles": {
"agent": {
"role": "Agent", "goal": "Help", "backstory": "Helper.",
"tasks": {"t": {"description": "Do", "expected_output": "Done"}},
"tools": [],
}
},
})
with patch.object(gen, "_run_autogen") as mock_autogen, \
patch("autogen.LLMConfig", return_value=m["llm_config"]), \
patch("autogen.AssistantAgent", return_value=m["assistant"]), \
patch("autogen.UserProxyAgent", return_value=m["user_proxy"]), \
patch("autogen.GroupChat", return_value=m["groupchat"]), \
patch("autogen.GroupChatManager", return_value=m["manager"]):
result = gen.generate_crew_and_kickoff()
mock_autogen.assert_not_called()
assert "### AG2 Output ###" in result
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,98 @@
"""
Example of AG2 tool registration for use with PraisonAI's --framework ag2.
AG2 (community fork of AutoGen, PyPI package: ag2) installs under the
'autogen' namespace but is a distinct package from pyautogen/pyautogen2.
Usage with PraisonAI:
pip install "praisonai[ag2]"
praisonai --framework ag2 agents.yaml
Standalone run:
python tests/source/ag2_function_tools.py
"""
from typing import Annotated, Literal
import os
# ag2 installs under the 'autogen' namespace
from autogen import (
AssistantAgent,
UserProxyAgent,
GroupChat,
GroupChatManager,
LLMConfig,
)
Operator = Literal["+", "-", "*", "/"]
def calculator(
a: Annotated[int, "First operand"],
b: Annotated[int, "Second operand"],
operator: Annotated[Operator, "Arithmetic operator: +, -, *, /"],
) -> int:
"""Perform basic arithmetic operations."""
if operator == "+":
return a + b
elif operator == "-":
return a - b
elif operator == "*":
return a * b
elif operator == "/":
if b == 0:
raise ValueError("Division by zero")
return int(a / b)
else:
raise ValueError(f"Invalid operator: {operator}")
# Build LLMConfig — AG2 uses a context manager pattern so agents
# created inside the 'with' block automatically inherit the config.
llm_config = LLMConfig(
api_type="openai",
model=os.environ.get("MODEL_NAME", "gpt-4o-mini"),
api_key=os.environ.get("OPENAI_API_KEY"),
)
# Create AssistantAgent inside llm_config context
with llm_config:
assistant = AssistantAgent(
name="Calculator_Assistant",
system_message=(
"You are a helpful AI assistant that can perform arithmetic calculations. "
"Use the calculator tool when math operations are needed. "
"Return 'TERMINATE' when the task is done."
),
)
# UserProxyAgent does not use LLM — created outside context
user_proxy = UserProxyAgent(
name="User",
human_input_mode="NEVER",
is_termination_msg=lambda msg: (
msg.get("content") is not None and "TERMINATE" in msg["content"]
),
code_execution_config=False,
)
# AG2 tool registration pattern:
# @agent.register_for_llm(description="...") — exposes tool schema to the LLM
# @user_proxy.register_for_execution() — tells user_proxy to execute it
@assistant.register_for_llm(description="A simple calculator for arithmetic operations.")
@user_proxy.register_for_execution()
def calculator_tool(
a: Annotated[int, "First operand"],
b: Annotated[int, "Second operand"],
operator: Annotated[Operator, "Arithmetic operator"],
) -> int:
return calculator(a, b, operator)
if __name__ == "__main__":
chat_result = user_proxy.initiate_chat(
assistant,
message="What is (44232 + 13312 / (232 - 32)) * 5?",
)
print(f"\nResult: {chat_result.summary}")
@@ -0,0 +1,542 @@
"""
Unit tests for the AG2 framework integration in PraisonAI.
Tests cover:
- AG2 availability detection (AG2_AVAILABLE flag)
- Framework validation in AgentsGenerator.__init__
- _run_ag2 method: config parsing, agent creation, tool handling
- LLMConfig construction (OpenAI and Bedrock paths)
All external calls are mocked — no real LLM API calls made.
"""
import pytest
import os
import sys
from unittest.mock import patch, MagicMock, call
import importlib
# Ensure src is on path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../"))
# Stub heavy dependencies that auto.py (develop branch) imports at module level
# so that tests can import praisonai without a full installation.
for _stub in ("instructor",):
if _stub not in sys.modules:
sys.modules[_stub] = MagicMock()
# openai is installed (required by ag2/autogen internals), but auto.py also
# imports it at module level. Ensure it's really loaded, not a mock.
import importlib as _importlib
if "openai" not in sys.modules:
try:
_importlib.import_module("openai")
except ImportError:
_mock_openai = MagicMock()
_mock_openai.__version__ = "1.0.0"
sys.modules["openai"] = _mock_openai
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_config(roles=None, framework="ag2", topic="Test topic"):
"""Build a minimal agents config dict."""
if roles is None:
roles = {
"researcher": {
"role": "Researcher",
"goal": "Research things",
"backstory": "You are a researcher.",
"tasks": {
"task1": {
"description": "Research {topic}",
"expected_output": "A research report",
}
},
"tools": [],
}
}
return {"framework": framework, "topic": topic, "roles": roles}
# ---------------------------------------------------------------------------
# AG2_AVAILABLE flag detection
# ---------------------------------------------------------------------------
class TestAG2AvailabilityFlag:
def test_ag2_available_false_when_not_installed(self):
"""AG2_AVAILABLE is False when 'ag2' distribution is not found."""
import importlib.metadata as meta
original_fn = meta.distribution
def raise_not_found(name):
if name == 'ag2':
raise meta.PackageNotFoundError('ag2')
return original_fn(name)
# Verify detection logic directly without relying on cached module state
ag2_detected = True
try:
with patch('importlib.metadata.distribution', side_effect=raise_not_found):
meta.distribution('ag2')
except meta.PackageNotFoundError:
ag2_detected = False
assert ag2_detected is False
def test_ag2_available_true_when_installed(self):
"""AG2_AVAILABLE is True when 'ag2' distribution and LLMConfig are present."""
import importlib.metadata as meta
try:
dist = meta.distribution('ag2')
assert dist is not None
from autogen import LLMConfig # noqa: F401
except (meta.PackageNotFoundError, ImportError):
pytest.skip("ag2 not installed — skipping")
# ---------------------------------------------------------------------------
# AgentsGenerator.__init__ framework validation
# ---------------------------------------------------------------------------
class TestAgentsGeneratorAG2Validation:
def _make_generator(self, framework, ag2_available=True):
"""Create AgentsGenerator with mocked availability flags."""
with patch("praisonai.agents_generator.AG2_AVAILABLE", ag2_available), \
patch("praisonai.agents_generator.CREWAI_AVAILABLE", False), \
patch("praisonai.agents_generator.AUTOGEN_AVAILABLE", False), \
patch("praisonai.agents_generator.PRAISONAI_AVAILABLE", True):
from praisonai.agents_generator import AgentsGenerator
return AgentsGenerator(
agent_file="agents.yaml",
framework=framework,
config_list=[{"model": "gpt-4o-mini", "api_key": "test-key"}],
)
def test_ag2_framework_accepted_when_available(self):
"""No ImportError when framework='ag2' and AG2 is installed."""
gen = self._make_generator("ag2", ag2_available=True)
assert gen.framework == "ag2"
def test_ag2_framework_raises_when_not_available(self):
"""ImportError raised with helpful message when ag2 not installed."""
with pytest.raises(ImportError, match="AG2 is not installed"):
self._make_generator("ag2", ag2_available=False)
def test_autogen_framework_unaffected(self):
"""Existing autogen framework path still works independently."""
with patch("praisonai.agents_generator.AG2_AVAILABLE", False), \
patch("praisonai.agents_generator.AUTOGEN_AVAILABLE", True), \
patch("praisonai.agents_generator.CREWAI_AVAILABLE", False), \
patch("praisonai.agents_generator.PRAISONAI_AVAILABLE", True):
from praisonai.agents_generator import AgentsGenerator
gen = AgentsGenerator(
agent_file="agents.yaml",
framework="autogen",
config_list=[{"model": "gpt-4o-mini", "api_key": "test-key"}],
)
assert gen.framework == "autogen"
# ---------------------------------------------------------------------------
# _run_ag2: LLMConfig construction
# ---------------------------------------------------------------------------
class TestRunAG2LLMConfig:
def _make_gen_with_config(self, config_list):
"""Build an AgentsGenerator instance with given config_list."""
with patch("praisonai.agents_generator.AG2_AVAILABLE", True), \
patch("praisonai.agents_generator.CREWAI_AVAILABLE", False), \
patch("praisonai.agents_generator.AUTOGEN_AVAILABLE", False), \
patch("praisonai.agents_generator.PRAISONAI_AVAILABLE", True):
from praisonai.agents_generator import AgentsGenerator
return AgentsGenerator(
agent_file="agents.yaml",
framework="ag2",
config_list=config_list,
)
@patch("praisonai.agents_generator.AG2_AVAILABLE", True)
def test_openai_llm_config_constructed(self):
"""LLMConfig is built with api_type='openai' for standard config."""
gen = self._make_gen_with_config([
{"model": "gpt-4o-mini", "api_key": "sk-test", "api_type": "openai"}
])
mock_llm_config = MagicMock()
mock_llm_config.__enter__ = MagicMock(return_value=mock_llm_config)
mock_llm_config.__exit__ = MagicMock(return_value=False)
mock_assistant = MagicMock()
mock_user_proxy = MagicMock()
mock_groupchat = MagicMock()
mock_groupchat.messages = [{"name": "Researcher", "content": "Done. TERMINATE", "role": "assistant"}]
mock_manager = MagicMock()
with patch("praisonai.agents_generator.AG2_AVAILABLE", True), \
patch("autogen.LLMConfig", return_value=mock_llm_config) as mock_llmcfg, \
patch("autogen.AssistantAgent", return_value=mock_assistant), \
patch("autogen.UserProxyAgent", return_value=mock_user_proxy), \
patch("autogen.GroupChat", return_value=mock_groupchat), \
patch("autogen.GroupChatManager", return_value=mock_manager):
config = _make_config()
gen._run_ag2(config, "Test topic", {})
mock_llmcfg.assert_called_once()
# LLMConfig is called with a positional dict arg
call_args = mock_llmcfg.call_args[0][0]
assert call_args.get("model") == "gpt-4o-mini"
assert call_args.get("api_key") == "sk-test"
@patch("praisonai.agents_generator.AG2_AVAILABLE", True)
def test_bedrock_llm_config_constructed(self):
"""LLMConfig uses api_type='bedrock' when config specifies bedrock."""
gen = self._make_gen_with_config([
{
"model": "anthropic.claude-3-5-sonnet-20241022-v2:0",
"api_type": "bedrock",
}
])
mock_llm_config = MagicMock()
mock_llm_config.__enter__ = MagicMock(return_value=mock_llm_config)
mock_llm_config.__exit__ = MagicMock(return_value=False)
mock_assistant = MagicMock()
mock_user_proxy = MagicMock()
mock_groupchat = MagicMock()
mock_groupchat.messages = [{"name": "Agent", "content": "Report ready. TERMINATE", "role": "assistant"}]
mock_manager = MagicMock()
with patch("autogen.LLMConfig", return_value=mock_llm_config) as mock_llmcfg, \
patch("autogen.AssistantAgent", return_value=mock_assistant), \
patch("autogen.UserProxyAgent", return_value=mock_user_proxy), \
patch("autogen.GroupChat", return_value=mock_groupchat), \
patch("autogen.GroupChatManager", return_value=mock_manager):
config = _make_config()
gen._run_ag2(config, "AWS deployment", {})
mock_llmcfg.assert_called_once()
# LLMConfig is called with a positional dict arg
call_args = mock_llmcfg.call_args[0][0]
assert call_args.get("api_type") == "bedrock"
assert "api_key" not in call_args # no api_key for bedrock
# ---------------------------------------------------------------------------
# _run_ag2: agent and GroupChat creation
# ---------------------------------------------------------------------------
class TestRunAG2AgentCreation:
def _make_gen(self):
with patch("praisonai.agents_generator.AG2_AVAILABLE", True), \
patch("praisonai.agents_generator.CREWAI_AVAILABLE", False), \
patch("praisonai.agents_generator.AUTOGEN_AVAILABLE", False), \
patch("praisonai.agents_generator.PRAISONAI_AVAILABLE", True):
from praisonai.agents_generator import AgentsGenerator
return AgentsGenerator(
agent_file="agents.yaml",
framework="ag2",
config_list=[{"model": "gpt-4o-mini", "api_key": "sk-test"}],
)
def test_assistant_created_per_role(self):
"""One AssistantAgent is created for each role in the config."""
gen = self._make_gen()
config = _make_config(roles={
"role_a": {
"role": "Agent A", "goal": "Goal A", "backstory": "Backstory A",
"tasks": {"t1": {"description": "Do A", "expected_output": "A done"}},
"tools": [],
},
"role_b": {
"role": "Agent B", "goal": "Goal B", "backstory": "Backstory B",
"tasks": {"t2": {"description": "Do B", "expected_output": "B done"}},
"tools": [],
},
})
mock_llm_config = MagicMock()
mock_llm_config.__enter__ = MagicMock(return_value=mock_llm_config)
mock_llm_config.__exit__ = MagicMock(return_value=False)
created_agents = []
def fake_assistant(**kwargs):
m = MagicMock()
m.name = kwargs.get("name", "agent")
created_agents.append(m)
return m
mock_user_proxy = MagicMock()
mock_groupchat = MagicMock()
mock_groupchat.messages = [{"name": "Agent A", "content": "Done. TERMINATE", "role": "assistant"}]
mock_manager = MagicMock()
with patch("autogen.LLMConfig", return_value=mock_llm_config), \
patch("autogen.AssistantAgent", side_effect=fake_assistant), \
patch("autogen.UserProxyAgent", return_value=mock_user_proxy), \
patch("autogen.GroupChat", return_value=mock_groupchat), \
patch("autogen.GroupChatManager", return_value=mock_manager):
gen._run_ag2(config, "Test", {})
assert len(created_agents) == 2
def test_groupchat_includes_user_proxy_and_assistants(self):
"""GroupChat receives user_proxy + all assistants."""
gen = self._make_gen()
config = _make_config()
mock_llm_config = MagicMock()
mock_llm_config.__enter__ = MagicMock(return_value=mock_llm_config)
mock_llm_config.__exit__ = MagicMock(return_value=False)
mock_assistant = MagicMock()
mock_assistant.name = "Researcher"
mock_user_proxy = MagicMock()
mock_user_proxy.name = "User"
mock_groupchat = MagicMock()
mock_groupchat.messages = [{"name": "Researcher", "content": "Done. TERMINATE", "role": "assistant"}]
mock_manager = MagicMock()
groupchat_call_args = {}
def capture_groupchat(**kwargs):
groupchat_call_args.update(kwargs)
return mock_groupchat
with patch("autogen.LLMConfig", return_value=mock_llm_config), \
patch("autogen.AssistantAgent", return_value=mock_assistant), \
patch("autogen.UserProxyAgent", return_value=mock_user_proxy), \
patch("autogen.GroupChat", side_effect=capture_groupchat), \
patch("autogen.GroupChatManager", return_value=mock_manager):
gen._run_ag2(config, "Test", {})
agents_in_groupchat = groupchat_call_args.get("agents", [])
assert mock_user_proxy in agents_in_groupchat
assert mock_assistant in agents_in_groupchat
def test_empty_roles_returns_no_agents_message(self):
"""Returns a clear message when no roles are defined in config."""
gen = self._make_gen()
config = _make_config(roles={})
mock_llm_config = MagicMock()
mock_llm_config.__enter__ = MagicMock(return_value=mock_llm_config)
mock_llm_config.__exit__ = MagicMock(return_value=False)
mock_user_proxy = MagicMock()
with patch("autogen.LLMConfig", return_value=mock_llm_config), \
patch("autogen.AssistantAgent"), \
patch("autogen.UserProxyAgent", return_value=mock_user_proxy):
result = gen._run_ag2(config, "Test", {})
assert "No agents" in result
# ---------------------------------------------------------------------------
# _run_ag2: system message composition
# ---------------------------------------------------------------------------
class TestRunAG2SystemMessage:
def _make_gen(self):
with patch("praisonai.agents_generator.AG2_AVAILABLE", True), \
patch("praisonai.agents_generator.CREWAI_AVAILABLE", False), \
patch("praisonai.agents_generator.AUTOGEN_AVAILABLE", False), \
patch("praisonai.agents_generator.PRAISONAI_AVAILABLE", True):
from praisonai.agents_generator import AgentsGenerator
return AgentsGenerator(
agent_file="agents.yaml",
framework="ag2",
config_list=[{"model": "gpt-4o-mini", "api_key": "sk-test"}],
)
def test_system_message_contains_backstory(self):
"""AssistantAgent system_message includes the backstory from YAML."""
gen = self._make_gen()
config = _make_config(roles={
"agent1": {
"role": "Expert",
"goal": "Help users",
"backstory": "A unique backstory for testing purposes.",
"tasks": {"t": {"description": "Do it", "expected_output": "Done"}},
"tools": [],
}
})
mock_llm_config = MagicMock()
mock_llm_config.__enter__ = MagicMock(return_value=mock_llm_config)
mock_llm_config.__exit__ = MagicMock(return_value=False)
mock_user_proxy = MagicMock()
mock_groupchat = MagicMock()
mock_groupchat.messages = [{"name": "Expert", "content": "Done. TERMINATE", "role": "assistant"}]
mock_manager = MagicMock()
assistant_kwargs = {}
def capture_assistant(**kwargs):
assistant_kwargs.update(kwargs)
m = MagicMock()
m.name = kwargs.get("name", "agent")
return m
with patch("autogen.LLMConfig", return_value=mock_llm_config), \
patch("autogen.AssistantAgent", side_effect=capture_assistant), \
patch("autogen.UserProxyAgent", return_value=mock_user_proxy), \
patch("autogen.GroupChat", return_value=mock_groupchat), \
patch("autogen.GroupChatManager", return_value=mock_manager):
gen._run_ag2(config, "Test", {})
assert "A unique backstory for testing purposes." in assistant_kwargs.get("system_message", "")
assert "TERMINATE" in assistant_kwargs.get("system_message", "")
def test_agent_name_sanitised(self):
"""Agent names with special characters are sanitised for AG2 compatibility."""
gen = self._make_gen()
config = _make_config(roles={
"agent1": {
"role": "AI & ML Expert (2024)", # contains special chars
"goal": "Help",
"backstory": "Expert.",
"tasks": {"t": {"description": "Do it", "expected_output": "Done"}},
"tools": [],
}
})
mock_llm_config = MagicMock()
mock_llm_config.__enter__ = MagicMock(return_value=mock_llm_config)
mock_llm_config.__exit__ = MagicMock(return_value=False)
mock_user_proxy = MagicMock()
mock_groupchat = MagicMock()
mock_groupchat.messages = [{"name": "AI___ML_Expert__2024_", "content": "Done. TERMINATE", "role": "assistant"}]
mock_manager = MagicMock()
created_name = {}
def capture_assistant(**kwargs):
created_name["name"] = kwargs.get("name", "")
m = MagicMock()
m.name = created_name["name"]
return m
with patch("autogen.LLMConfig", return_value=mock_llm_config), \
patch("autogen.AssistantAgent", side_effect=capture_assistant), \
patch("autogen.UserProxyAgent", return_value=mock_user_proxy), \
patch("autogen.GroupChat", return_value=mock_groupchat), \
patch("autogen.GroupChatManager", return_value=mock_manager):
gen._run_ag2(config, "Test", {})
name = created_name.get("name", "")
# Should not contain special chars that would break AG2
import re
assert re.match(r"^[a-zA-Z0-9_\-]+$", name), f"Name '{name}' contains invalid characters"
# ---------------------------------------------------------------------------
# _run_ag2: output extraction
# ---------------------------------------------------------------------------
class TestRunAG2OutputExtraction:
def _make_gen(self):
with patch("praisonai.agents_generator.AG2_AVAILABLE", True), \
patch("praisonai.agents_generator.CREWAI_AVAILABLE", False), \
patch("praisonai.agents_generator.AUTOGEN_AVAILABLE", False), \
patch("praisonai.agents_generator.PRAISONAI_AVAILABLE", True):
from praisonai.agents_generator import AgentsGenerator
return AgentsGenerator(
agent_file="agents.yaml",
framework="ag2",
config_list=[{"model": "gpt-4o-mini", "api_key": "sk-test"}],
)
def _run_with_messages(self, messages):
gen = self._make_gen()
config = _make_config()
mock_llm_config = MagicMock()
mock_assistant = MagicMock()
mock_assistant.name = "Researcher"
mock_user_proxy = MagicMock()
# chat_result with no summary so extraction falls back to messages
mock_chat_result = MagicMock()
mock_chat_result.summary = None
mock_user_proxy.initiate_chat.return_value = mock_chat_result
mock_groupchat = MagicMock()
mock_groupchat.messages = messages
mock_manager = MagicMock()
with patch("autogen.LLMConfig", return_value=mock_llm_config), \
patch("autogen.AssistantAgent", return_value=mock_assistant), \
patch("autogen.UserProxyAgent", return_value=mock_user_proxy), \
patch("autogen.GroupChat", return_value=mock_groupchat), \
patch("autogen.GroupChatManager", return_value=mock_manager):
return gen._run_ag2(config, "Test", {})
def test_output_prefixed_with_ag2_header(self):
"""Result always starts with '### AG2 Output ###'."""
result = self._run_with_messages([
{"name": "Researcher", "content": "Here are my findings. TERMINATE", "role": "assistant"}
])
assert result.startswith("### AG2 Output ###")
def test_terminate_marker_stripped_from_output(self):
"""TERMINATE keyword is removed from the extracted output."""
result = self._run_with_messages([
{"name": "Researcher", "content": "Detailed findings here. TERMINATE", "role": "assistant"}
])
assert "TERMINATE" not in result
assert "Detailed findings here" in result
def test_user_messages_skipped_in_extraction(self):
"""User proxy messages are not included in the extracted output."""
result = self._run_with_messages([
{"name": "User", "content": "This is the user message", "role": "user"},
{"name": "Researcher", "content": "This is the agent response. TERMINATE", "role": "assistant"},
])
assert "This is the user message" not in result
assert "This is the agent response" in result
def test_execution_error_returns_error_message(self):
"""Exception during initiate_chat returns a '### AG2 Error ###' message."""
gen = self._make_gen()
config = _make_config()
mock_llm_config = MagicMock()
mock_llm_config.__enter__ = MagicMock(return_value=mock_llm_config)
mock_llm_config.__exit__ = MagicMock(return_value=False)
mock_assistant = MagicMock()
mock_assistant.name = "Researcher"
mock_user_proxy = MagicMock()
mock_user_proxy.initiate_chat.side_effect = RuntimeError("Connection failed")
mock_groupchat = MagicMock()
mock_groupchat.messages = []
mock_manager = MagicMock()
with patch("autogen.LLMConfig", return_value=mock_llm_config), \
patch("autogen.AssistantAgent", return_value=mock_assistant), \
patch("autogen.UserProxyAgent", return_value=mock_user_proxy), \
patch("autogen.GroupChat", return_value=mock_groupchat), \
patch("autogen.GroupChatManager", return_value=mock_manager):
result = gen._run_ag2(config, "Test", {})
assert "### AG2 Error ###" in result
assert "Connection failed" in result
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,193 @@
"""
PoC test for CWE-78: YAML-loaded environment variables set via
os.environ without sanitization.
The schedule config YAML can set arbitrary environment variables
including dangerous ones like LD_PRELOAD, PATH, PYTHONPATH, etc.,
which can lead to arbitrary code execution.
This test verifies that dangerous environment variable keys are
rejected by the _validate_env_key function added in the fix.
"""
import os
import textwrap
import pytest
import yaml
DANGEROUS_KEYS = [
"LD_PRELOAD",
"LD_LIBRARY_PATH",
"LD_AUDIT",
"DYLD_INSERT_LIBRARIES",
"DYLD_LIBRARY_PATH",
"DYLD_FRAMEWORK_PATH",
"DYLD_FALLBACK_LIBRARY_PATH",
"PATH",
"PYTHONPATH",
"PYTHONSTARTUP",
"PYTHONHOME",
"NODE_OPTIONS",
"NODE_PATH",
"RUBYLIB",
"PERL5LIB",
"PERL5OPT",
"CLASSPATH",
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"BASH_ENV",
"ENV",
"CDPATH",
"PROMPT_COMMAND",
"SHLVL",
"ld_preload", # lowercase variant
"Ld_Preload", # mixed case variant
]
# Generic key names that won't trigger provider-detection
SAFE_KEYS = [
"MODEL_NAME",
"MY_API_KEY",
"MY_MODEL_NAME",
"MY_BASE_URL",
"MY_CUSTOM_VAR",
]
@pytest.fixture(autouse=True)
def _clean_env():
"""Snapshot env vars before each test and restore them afterwards."""
# Save original values (None means the key was not set)
_snapshot = {k: os.environ.get(k) for k in DANGEROUS_KEYS + SAFE_KEYS}
yield
# Restore: reinstate original values or remove if they didn't exist
for key, original in _snapshot.items():
if original is None:
os.environ.pop(key, None)
else:
os.environ[key] = original
def _get_validate_env_key():
"""Import _validate_env_key, failing clearly if absent."""
try:
from praisonai.cli.main import _validate_env_key
return _validate_env_key
except ImportError:
pytest.fail(
"_validate_env_key not found in praisonai.cli.main"
" — the env-var validation fix has not been applied"
)
@pytest.mark.unit
def test_dangerous_env_keys_are_blocked():
"""Dangerous keys must be rejected with a ValueError."""
validate = _get_validate_env_key()
for key in DANGEROUS_KEYS:
with pytest.raises(
ValueError,
match=r"[Nn]ot allowed|[Bb]locked|[Dd]angerous",
):
validate(key)
@pytest.mark.unit
def test_safe_env_keys_are_allowed():
"""Safe keys must pass validation without error."""
validate = _get_validate_env_key()
for key in SAFE_KEYS:
# Should not raise
validate(key)
@pytest.mark.unit
def test_non_string_key_rejected():
"""Non-string YAML keys (int, None) must produce a clear ValueError."""
validate = _get_validate_env_key()
for bad_key in [123, None, 3.14, True]:
with pytest.raises(ValueError, match=r"must be a string"):
validate(bad_key)
@pytest.mark.unit
def test_vulnerability_scenario_ld_preload():
"""LD_PRELOAD in a YAML environment section must be blocked.
This test simulates the schedule-config loading path: parse the YAML,
then validate-all-then-apply (matching the fail-closed logic in main.py).
"""
validate = _get_validate_env_key()
config_content = textwrap.dedent("""\
deployment:
schedule: "*/5 * * * *"
provider: gcp
environment:
LD_PRELOAD: /tmp/evil.so
MODEL_NAME: test-model
""")
file_config = yaml.safe_load(config_content)
env_vars = file_config.get("environment", {})
# Simulate the validate-all-then-apply pattern from main.py
with pytest.raises(ValueError):
validated_env = {}
for key, value in env_vars.items():
validate(key)
validated_env[key] = str(value)
# If we reach here, no key was blocked — the test fails via pytest.raises
os.environ.update(validated_env)
# LD_PRELOAD must NOT have been applied (fail-closed)
assert "LD_PRELOAD" not in os.environ or os.environ.get("LD_PRELOAD") != "/tmp/evil.so"
@pytest.mark.unit
def test_malformed_env_keys_rejected():
"""Empty strings, keys with '=', and keys with NUL must be rejected."""
validate = _get_validate_env_key()
for bad_key in ["", "A=B", "FOO\x00BAR"]:
with pytest.raises(ValueError, match=r"non-empty string without"):
validate(bad_key)
@pytest.mark.unit
def test_non_dict_root_config_rejected():
"""A YAML schedule config whose root is not a mapping must be rejected.
This validates the root type-check added after yaml.safe_load().
When the root config is a list or scalar, earlier .get() calls would
raise AttributeError; after the fix they raise ValueError, which is
caught and presented cleanly.
"""
import yaml as _yaml
for bad_doc in ["- item1\n- item2", "42", "just a string"]:
file_config = _yaml.safe_load(bad_doc)
# The check from main.py: if not isinstance(file_config, dict)
assert not isinstance(file_config, dict), (
f"Expected non-dict for {bad_doc!r}, got {type(file_config)}"
)
@pytest.mark.unit
def test_non_dict_deployment_rejected():
"""A 'deployment' value that is not a mapping must be caught."""
import yaml as _yaml
bad_config = _yaml.safe_load("deployment: not-a-mapping\nenvironment:\n MODEL: x")
deploy_config = bad_config.get("deployment", {})
assert not isinstance(deploy_config, dict), (
f"Expected non-dict deployment, got {type(deploy_config)}"
)
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -52,9 +52,13 @@ def test_memory_config():
}
}
# Patch the chromadb cache to indicate it's available
original_chromadb_cache = memory_module._chromadb_cache.copy()
memory_module._chromadb_cache["available"] = True
# Patch the module cache to indicate chromadb is available
original_module_cache = memory_module._module_cache.copy()
memory_module._module_cache["chromadb"] = {
"available": True,
"module": MagicMock(),
"settings": MagicMock()
}
try:
with patch('chromadb.PersistentClient') as mock_chroma:
@@ -71,7 +75,8 @@ def test_memory_config():
memory = Memory(config=basic_config, verbose=1)
print("✅ Basic memory configuration works")
finally:
memory_module._chromadb_cache.update(original_chromadb_cache)
memory_module._module_cache.clear()
memory_module._module_cache.update(original_module_cache)
# Test mem0 configuration with mocking
# Skip this test as it requires network access to mem0 API
+51 -6
View File
@@ -17,6 +17,27 @@ overrides = [
{ name = "neonize", marker = "sys_platform == 'never'" },
]
[[package]]
name = "ag2"
version = "0.11.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "diskcache" },
{ name = "docker" },
{ name = "fast-depends", extra = ["pydantic"] },
{ name = "httpx" },
{ name = "packaging" },
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "termcolor" },
{ name = "tiktoken" },
]
sdist = { url = "https://files.pythonhosted.org/packages/77/20/46159b56f9c6069ea2bc174f3ce3597febc4f4bbf467c2c1e003c84f0da7/ag2-0.11.4.tar.gz", hash = "sha256:1949d27e889a908a258ad4bcda4bfb33770763a2e8f9ea2da13eec342da8f493", size = 3818369, upload-time = "2026-03-17T23:47:50.308Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a6/14/0d226e5fbb02949ae1ded5bcaa695a1ca11bb9535c71b53dfbc6ff2c4b5e/ag2-0.11.4-py3-none-any.whl", hash = "sha256:182805d940271bd9cfea2a40f5dc1f3db351745b07135efdb015a7f0c0681fd4", size = 1080405, upload-time = "2026-03-17T23:47:48.15Z" },
]
[[package]]
name = "agent-client-protocol"
version = "0.7.1"
@@ -1339,6 +1360,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/51/37/b3ea9cd5558ff4cb51957caca2193981c6b0ff30bd0d2630ac62505d99d0/fake_useragent-2.2.0-py3-none-any.whl", hash = "sha256:67f35ca4d847b0d298187443aaf020413746e56acd985a611908c73dba2daa24", size = 161695, upload-time = "2025-04-14T15:32:17.732Z" },
]
[[package]]
name = "fast-depends"
version = "3.0.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f8/6d/787a21ca8043a8fdb737cf28f645e94a46fc30b44a31de54573299156bad/fast_depends-3.0.8.tar.gz", hash = "sha256:896b16f79a512b6ea1df721b0aa1708a192a06f964be6597e01fcf5412559101", size = 18382, upload-time = "2026-03-02T19:54:28.649Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/1d/e4843e4eeb65f51447b8c22d200d12d8f94f27c97e77bb7162515cc8d61f/fast_depends-3.0.8-py3-none-any.whl", hash = "sha256:4c52c8a3907bca46d43e70e4364d6d016872d9a3aae4bc0c1c85e72e0a6a21c7", size = 25507, upload-time = "2026-03-02T19:54:27.594Z" },
]
[package.optional-dependencies]
pydantic = [
{ name = "pydantic" },
]
[[package]]
name = "fastapi"
version = "0.128.0"
@@ -4726,6 +4765,10 @@ dependencies = [
acp = [
{ name = "agent-client-protocol" },
]
ag2 = [
{ name = "ag2" },
{ name = "praisonai-tools" },
]
agentops = [
{ name = "agentops" },
]
@@ -4872,6 +4915,7 @@ ui = [
[package.metadata]
requires-dist = [
{ name = "ag2", marker = "extra == 'ag2'", specifier = ">=0.11.0" },
{ name = "agent-client-protocol", marker = "extra == 'acp'", specifier = ">=0.7.0" },
{ name = "agentops", marker = "extra == 'agentops'", specifier = ">=0.3.12" },
{ name = "aiohttp", marker = "extra == 'bot'", specifier = ">=3.8.0" },
@@ -4925,7 +4969,7 @@ requires-dist = [
{ name = "greenlet", marker = "extra == 'code'", specifier = ">=3.0.3" },
{ name = "greenlet", marker = "extra == 'realtime'", specifier = ">=3.0.3" },
{ name = "greenlet", marker = "extra == 'ui'", specifier = ">=3.0.3" },
{ name = "litellm", specifier = ">=1.81.0" },
{ name = "litellm", specifier = ">=1.81.0,<=1.82.6" },
{ name = "markdown", specifier = ">=3.5" },
{ name = "mcp", specifier = ">=1.20.0" },
{ name = "neonize", marker = "extra == 'bot-whatsapp-web'", specifier = ">=0.3.14" },
@@ -4936,10 +4980,11 @@ requires-dist = [
{ name = "playwright", marker = "extra == 'realtime'", specifier = ">=1.47.0" },
{ name = "plotly", marker = "extra == 'all'", specifier = ">=5.24.0" },
{ name = "plotly", marker = "extra == 'realtime'", specifier = ">=5.24.0" },
{ name = "praisonai-tools", marker = "extra == 'ag2'", specifier = ">=0.1.0" },
{ name = "praisonai-tools", marker = "extra == 'autogen'", specifier = ">=0.1.0" },
{ name = "praisonai-tools", marker = "extra == 'autogen-v4'", specifier = ">=0.1.0" },
{ name = "praisonai-tools", marker = "extra == 'crewai'", specifier = ">=0.1.0" },
{ name = "praisonaiagents", specifier = ">=1.5.80" },
{ name = "praisonaiagents", specifier = ">=1.5.87" },
{ name = "praisonaiagents", extras = ["all"], marker = "extra == 'claw'", specifier = ">=1.5.40" },
{ name = "psutil", marker = "extra == 'all'", specifier = ">=5.9.0" },
{ name = "psutil", marker = "extra == 'bot'", specifier = ">=5.9.0" },
@@ -4993,7 +5038,7 @@ requires-dist = [
{ name = "yfinance", marker = "extra == 'all'", specifier = ">=0.2.44" },
{ name = "yfinance", marker = "extra == 'realtime'", specifier = ">=0.2.44" },
]
provides-extras = ["ui", "gradio", "api", "os", "agentops", "chat", "code", "realtime", "call", "train", "crewai", "autogen", "autogen-v4", "acp", "lite", "all", "bot", "bot-whatsapp-web", "claw", "dev"]
provides-extras = ["ui", "gradio", "api", "os", "agentops", "chat", "code", "realtime", "call", "train", "crewai", "autogen", "ag2", "autogen-v4", "acp", "lite", "all", "bot", "bot-whatsapp-web", "claw", "dev"]
[[package]]
name = "praisonai-tools"
@@ -5009,7 +5054,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.5.80"
version = "1.5.87"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
@@ -5018,9 +5063,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "rich" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6e/3b/6c88188227bac16f88e7b141568cebb03d6ef46f38935e3bb8408a400017/praisonaiagents-1.5.80.tar.gz", hash = "sha256:fa0b803943e5aab8ce4099c8257e8f5378648a19668ec046749aa3babad99d57", size = 1204298, upload-time = "2026-03-18T21:06:55.71Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f5/d1/78933b4dd5529a7b7e9b15d5b2f012d550cd6b29071b94a6145347373fdf/praisonaiagents-1.5.87.tar.gz", hash = "sha256:3ef6aaa1b1e2af54e2c7ffbcff00dd49674426393f3ead26f6cf983a99456966", size = 1216498, upload-time = "2026-03-30T20:16:07.46Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/76/ab37e313ad3e756c39df8f5b180613169ff8074a293aaba52fbdef88928e/praisonaiagents-1.5.80-py3-none-any.whl", hash = "sha256:e1590e6bbbda526e13d3654ab17cab6a2a602af4508fabe7a8cd389213658efe", size = 1248857, upload-time = "2026-03-18T21:06:53.898Z" },
{ url = "https://files.pythonhosted.org/packages/e1/b2/9f10d17d5d6f6e3ddf1ff7cd562d75afd2e4158feb376760028f62da9ea6/praisonaiagents-1.5.87-py3-none-any.whl", hash = "sha256:8bb315fbd0f92a64236eb31b82c78630f4dbc3066dca4c06fdd62619122760da", size = 1264793, upload-time = "2026-03-30T20:16:05.8Z" },
]
[package.optional-dependencies]