Compare 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
Mervin Praison f8c6f32d00 Release v4.5.80 2026-03-18 21:09:57 +00:00
Mervin Praison 69ff8e7f03 Release v4.5.79 2026-03-18 15:57:13 +00:00
Mervin Praison feaa81b68f Release v4.5.78 2026-03-18 15:28:36 +00:00
Mervin Praison 39406e904f feat(crawl4ai_tools): enhance availability checks for Crawl4AI and Playwright
Updated the availability check for Crawl4AI to include Playwright browser installation advice. Improved error handling when starting the web crawler, ensuring users are informed if necessary binaries are missing. Incremented version in pyproject.toml to 1.5.78 and added new dependencies for crawling functionality.
2026-03-18 15:26:43 +00:00
Mervin Praison 2bb37b1076 Release v4.5.77 2026-03-18 14:46:46 +00:00
Mervin Praison cc36ac1cf8 feat(scheduler): add execution history logging for scheduled jobs
Implemented logging for job execution history via a new RunRecord class.
This includes methods for logging job runs, retrieving execution history,
and persisting the history to a file. Enhancements to the config store
allow for configurable maximum history depth, improving traceability
of job executions.
2026-03-18 14:39:41 +00:00
Mervin Praison a041a76f0d Release v4.5.76 2026-03-18 11:08:18 +00:00
Mervin Praison f77cca9931 feat(scheduler): add ConfigYamlScheduleStore for schedule management
Introduced a new schedule store utilizing YAML files for
persistence. This allows scheduled jobs to be stored under
the "schedules" key in the existing config.yaml, enhancing
thread safety and maintaining existing config information.
Updated related modules to integrate the new store.
2026-03-18 11:07:33 +00:00
Mervin Praison ef176298b9 feat(scheduler): implement ScheduleStoreProtocol for better schedule management
Added a new ScheduleStoreProtocol to standardize schedule persistence backends.
Updated ScheduleLoop and ScheduleRunner to use the protocol, allowing various
implementations for schedule storage. Introduced _run_schedule_loop in BotOS
to ensure scheduled jobs execute alongside bot tasks. Updated project version to
1.5.75 for this update.
2026-03-18 10:52:42 +00:00
Mervin Praison 31f5cbb1cf Release v4.5.74 2026-03-17 14:57:13 +00:00
Mervin Praison 85ec6cba90 feat(web_crawl_tools): enhance URL handling in web_crawl function
Improved the URL normalization logic to handle comma-separated URLs
passed as a single string. Updated version numbers in pyproject.toml
and uv.lock files to reflect the latest changes.
2026-03-17 14:54:27 +00:00
Mervin Praison c77259eda7 Release v4.5.73 2026-03-17 12:11:44 +00:00
Mervin Praison e69e726ca6 refactor(tools): rename web_crawl.py to web_crawl_tools.py
Updated the import paths in TOOL_MAPPINGS to reflect the new file name.
Bumped version to 1.5.73 in pyproject.toml and uv.lock for the change.
2026-03-17 12:08:30 +00:00
Mervin Praison 3130ae4e19 Release v4.5.72 2026-03-17 11:17:13 +00:00
Mervin Praison c872be20ed feat(email_tools): enhance backend detection and streamline usage
Updated email_tools to auto-detect available email backends (AgentMail or SMTP) based on environment variables. This refactor simplifies usage by providing generic tools while maintaining backend-specific functionalities. Enhanced documentation to reflect these changes and improve usability.
2026-03-17 11:14:39 +00:00
Mervin Praison 4548783a40 Release v4.5.71 2026-03-17 10:41:33 +00:00
Mervin Praison 5b1c39d496 feat(bots): add mode and webhook port configurability
Enhanced BotConfig and related classes to support multiple operation modes (poll, ws, webhook, hybrid) and configurable webhook port. Updated documentation and validation for new parameters in the AgentMailBot for improved flexibility in message handling. Bumped version to 1.5.71.
2026-03-17 10:38:13 +00:00
Mervin Praison 1e3933f5f3 Release v4.5.70 2026-03-17 08:14:59 +00:00
Mervin Praison 203ebe4960 ```
feat(email_tools): add reply_email and create_inbox functions

Implemented reply_email for responding to emails and create_inbox for
creating new email inboxes in the AgentMail system. Updated integration
in the tool mappings and profiles to include new functionalities.
Enhanced tests to cover the new features.
```
2026-03-17 08:12:11 +00:00
Mervin Praison e144d840bb Release v4.5.69 2026-03-16 21:43:41 +00:00
Mervin Praison 47bff65413 feat(mcp): enhance command validation in MCPHandler
Added an allowlist for MCP server commands to improve security by
rejecting disallowed and dangerous commands in the MCPHandler class.
Updated tests to verify the new validation logic and ensure expected
executables are accepted. Bumped version to 1.5.69.
2026-03-16 21:39:47 +00:00
Mervin Praison 66bd9ee2a9 Release v4.5.68 2026-03-16 17:48:36 +00:00
github-actions[bot] 1dd5d7e706 docs: auto-update api.md [skip ci] 2026-03-16 17:45:42 +00:00
Mervin Praison fee74fab53 ```
feat(email): add AgentMail tools for sending and managing emails

- Introduced send_email, list_emails, and read_email functions.
- Added email_tools module for agent interaction.
- Updated tool mappings to include email functionalities.
- Included example YAML configuration for an AgentMail bot.
- Enhanced tests for email tools and added new test cases.
```
2026-03-16 17:45:17 +00:00
Mervin Praison 003f9371a1 Release v4.5.67 2026-03-16 16:31:48 +00:00
Mervin Praison 7234cd5682 ```
feat(bots): add email bot examples and functionalities

This commit introduces a new email bot feature, enabling agents to
interact through email via IMAP/SMTP. It includes example usage,
custom configurations, and a multi-platform approach, enhancing the
bot's usability in diverse environments.
```
2026-03-16 16:31:23 +00:00
Mervin Praison 4373fb3adb docs(README): update performance metrics and clarify agent orchestration
Refined performance section to emphasize speed and reduced overhead.
Updated terminology for clarity in the external agents description.
2026-03-16 12:11:56 +00:00
Mervin Praison e87dd8e37a ```
docs(readme): refine performance section and enhance descriptions

Updated the performance metrics and descriptions to clarify advantages of PraisonAI. Improved consistency in terminology for better readability.
```
2026-03-16 12:08:35 +00:00
Mervin Praison 872cd87638 Release v4.5.65 2026-03-16 12:00:17 +00:00
Mervin Praison 8ea0065f83 ```
feat(bots): add EmailBot for IMAP polling and SMTP sending

This commit introduces the EmailBot, enabling integration with email
services using IMAP for receiving messages and SMTP for sending.
Updates include new environment variables for configuration,
enhancements to the bot registry, and modifications to schema and
initialization logic for seamless integration.
```
2026-03-16 11:58:55 +00:00
Mervin Praison e922eb1e64 Release v4.5.64 2026-03-16 10:02:40 +00:00
Mervin Praison e364aa7519 Release v4.5.63 2026-03-15 15:36:23 +00:00
Mervin Praison e67a11db1f ```
fix(agent): handle potential None value for agent name

Updated agent hash generation to default to "agent" when name is None.
Bumped version to 1.5.63 to reflect this change.
```
2026-03-15 15:31:02 +00:00
Mervin Praison 9071af74ea Merge pull request #1114 from jnMetaCode/fix-agent-name-none-crash
fix: handle None agent name when generating history session ID
2026-03-15 07:12:47 +00:00
Mervin Praison 7821916c56 Merge pull request #1115 from jnMetaCode/fix-model-windows-duplicate-key
fix: correct duplicate gpt-4o-mini key in MODEL_WINDOWS to gpt-4o
2026-03-15 07:12:16 +00:00
Mervin Praison dd1b9a7f1f Release v4.5.62 2026-03-15 05:30:13 +00:00
Mervin Praison 3b006be069 ```
feat(tests): add deep performance profiling integration test

Introduced a new integration test to run cProfile-wrapped Agent prompts.
This test stores detailed function-level breakdown as JSONL for regression
tracking and comparison of performance over time. Updated version number
and modified documentation accordingly.
```
2026-03-15 05:29:29 +00:00
Mervin Praison 4d80c60ecb ```
feat(llm): add support for OpenAI Responses API

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

Added integration and unit tests to ensure correct behavior of path
configurations.
2026-03-09 13:57:35 +00:00
Mervin Praison 8555b5ad69 Release v4.5.29 2026-03-09 06:18:18 +00:00
JiangNan ad8ee02b5a fix: correct duplicate gpt-4o-mini key in MODEL_WINDOWS to gpt-4o
MODEL_WINDOWS had two entries for gpt-4o-mini and none for gpt-4o.
The first entry was clearly intended to be gpt-4o based on context.
This meant gpt-4o users got no context window match, potentially
causing suboptimal chunking or fallback behavior.

Signed-off-by: JiangNan <[email protected]>
2026-03-08 22:32:24 +08:00
JiangNan 02b9c64c0a fix: handle None agent name when generating history session ID
When an agent is created with instructions but no explicit name, and
memory history is enabled, name can be None. Calling .encode() on None
raises AttributeError. Fall back to "agent" when name is None.

Signed-off-by: JiangNan <[email protected]>
2026-03-08 22:32:21 +08:00
Mervin Praison 16f9325176 Release v4.5.28 2026-03-08 07:03:40 +00:00
Mervin Praison fc9f8c5e1d Release v4.5.27 2026-03-08 00:06:51 +00:00
Mervin Praison f5ee866e6c feat(gateway, session): add gateway integration and lazy loading
- Implement lazy loading for WebSocketGateway and GatewaySession in the
  gateway module, allowing easier access without needing wrapper imports.
- Enhance SessionData to include gateway session ID and agent ID.
- Introduce methods in DefaultSessionStore for querying sessions by agent
  and linking sessions to gateway sessions.
2026-03-08 00:02:39 +00:00
Mervin Praison af1c26c471 Release v4.5.26 2026-03-07 07:10:51 +00:00
Mervin Praison f20d5d95e4 feat(workflow): add agent-centric and hybrid workflow examples
Introduced new YAML workflow examples demonstrating agent-centric steps
and hybrid workflows. The agent-centric job workflow supports roles
like Technical Writer and includes quality checks, while the hybrid
workflow combines deterministic steps with multi-agent coordination.
Updated version to 1.5.26 for the praisonai-agents package.
2026-03-07 07:07:56 +00:00
Mervin Praison ac89f8a17c Release v4.5.25 2026-03-07 05:51:43 +00:00
Mervin Praison abc7f09046 feat(workflows): add file-based and YAML-defined actions examples
Added examples for file-based actions (line count and system info)
and YAML-defined actions in new workflow files. Updated relevant
files to support these actions, enhancing the capabilities for
custom workflows. Updated package version to 1.5.25.
2026-03-07 05:32:23 +00:00
github-actions[bot] a0287a9919 docs: auto-update api.md [skip ci] 2026-03-06 14:58:00 +00:00
Mervin Praison 5046e9cf88 Release v4.5.24 2026-03-06 14:57:43 +00:00
Mervin Praison 89624c1580 Release v4.5.23 2026-03-06 07:50:12 +00:00
Mervin Praison cc719e5379 Release v4.5.22 2026-03-05 15:18:22 +00:00
Mervin Praison e854a50fd2 Release v4.5.21 2026-03-03 15:46:18 +00:00
Mervin Praison 99fde9b9d9 Delete tmp directory 2026-03-03 13:20:31 +00:00
Mervin Praison 3c73ca95b6 u 2026-03-03 13:12:44 +00:00
Mervin Praison d7c274489e Release v4.5.20 2026-03-03 11:08:28 +00:00
Mervin Praison 23f6c00e21 Release v4.5.19 2026-03-03 09:37:24 +00:00
Mervin Praison a293999e8b Release v4.5.18 2026-03-03 07:22:28 +00:00
Mervin Praison 0b14ae49ef update 2026-03-01 07:12:47 +00:00
Mervin Praison b33c649de5 Release v4.5.17 2026-02-28 08:47:18 +00:00
Mervin Praison 02a1977651 Release v4.5.16 2026-02-27 04:43:28 +00:00
286 changed files with 36481 additions and 10014 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>"
+6 -1
View File
@@ -21,6 +21,7 @@ docs.sh
other
output
!src/praisonai/praisonai/cli/output/
!src/praisonai-agents/praisonaiagents/output/
.files
threads.db
@@ -91,4 +92,8 @@ issue_details.json
target/
Cargo.lock
**/*.rs.bk
*.pdb
*.pdb
.windsurf
.agent
src/praisonai/build
+545 -3095
View File
File diff suppressed because it is too large. Load diff
+14 -4
View File
@@ -49,13 +49,14 @@ 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>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">display_name</a>() -> str</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">ephemeral</a>()</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">execute</a>(task, context = None)</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">execute_tool</a>(function_name, arguments)</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">execute_tool</a>(function_name, arguments, tool_call_id = None)</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">execute_tool_async</a>(function_name: str, arguments: Dict[str, Any]) -> Any</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">from_template</a>(uri: str, config: Optional[Dict[str, Any]] = None, offline: bool = False, **kwargs) -> 'Agent'</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">generate_task</a>() -> 'Task'</code>
@@ -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>
@@ -381,7 +384,7 @@ from praisonaiagents import GuardrailAction, GuardrailConfig, LLMGuardrail
Methods:
* <code title="class GuardrailConfig">GuardrailConfig.<a href="./src/praisonai-agents/praisonaiagents/config/feature_configs.py">to_dict</a>() -> Dict[str, Any]</code>
* <code title="class LLMGuardrail">LLMGuardrail.<a href="./src/praisonai-agents/praisonaiagents/guardrails/llm_guardrail.py">__call__</a>(task_output: TaskOutput) -> Tuple[bool, Union[str, TaskOutput]]</code>
* <code title="class LLMGuardrail">LLMGuardrail.<a href="./src/praisonai-agents/praisonaiagents/guardrails/llm_guardrail.py">__call__</a>(task_output) -> Tuple[bool, Union[str, 'TaskOutput']]</code>
# Planning
@@ -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)
@@ -784,8 +785,10 @@ Methods:
* <code title="cli">praisonai benchmark profile <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
* <code title="cli">praisonai benchmark sdk <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
* <code title="cli">praisonai benchmark workflow <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
* <code title="cli">praisonai bot agentmail <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot bot-callback <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot discord <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot email <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot slack <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot start <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot telegram <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
@@ -802,6 +805,7 @@ Methods:
* <code title="cli">praisonai call call-main <a href="./src/praisonai/praisonai/cli/commands/call.py">--help</a></code>
* <code title="cli">praisonai chat <a href="./src/praisonai/praisonai/cli/main.py">--help</a></code>
* <code title="cli">praisonai chat chat-main <a href="./src/praisonai/praisonai/cli/commands/chat.py">--help</a></code>
* <code title="cli">praisonai claw claw <a href="./src/praisonai/praisonai/cli/commands/claw.py">--help</a></code>
* <code title="cli">praisonai code <a href="./src/praisonai/praisonai/cli/main.py">--help</a></code>
* <code title="cli">praisonai code code-main <a href="./src/praisonai/praisonai/cli/commands/code.py">--help</a></code>
* <code title="cli">praisonai commit commit-main <a href="./src/praisonai/praisonai/cli/commands/commit.py">--help</a></code>
@@ -868,7 +872,9 @@ Methods:
* <code title="cli">praisonai examples run <a href="./src/praisonai/praisonai/cli/commands/examples.py">--help</a></code>
* <code title="cli">praisonai examples run-all <a href="./src/praisonai/praisonai/cli/commands/examples.py">--help</a></code>
* <code title="cli">praisonai examples stats <a href="./src/praisonai/praisonai/cli/commands/examples.py">--help</a></code>
* <code title="cli">praisonai gateway channels <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai gateway gateway-callback <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai gateway send <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai gateway start <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai gateway status <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai hooks add <a href="./src/praisonai/praisonai/cli/commands/hooks.py">--help</a></code>
@@ -906,6 +912,7 @@ Methods:
* <code title="cli">praisonai package install <a href="./src/praisonai/praisonai/cli/commands/package.py">--help</a></code>
* <code title="cli">praisonai package list <a href="./src/praisonai/praisonai/cli/commands/package.py">--help</a></code>
* <code title="cli">praisonai package uninstall <a href="./src/praisonai/praisonai/cli/commands/package.py">--help</a></code>
* <code title="cli">praisonai paths show <a href="./src/praisonai/praisonai/cli/commands/paths.py">--help</a></code>
* <code title="cli">praisonai plugins create <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
* <code title="cli">praisonai plugins disable <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
* <code title="cli">praisonai plugins discover <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
@@ -923,6 +930,8 @@ Methods:
* <code title="cli">praisonai profile snapshot <a href="./src/praisonai/praisonai/cli/commands/profile.py">--help</a></code>
* <code title="cli">praisonai profile startup <a href="./src/praisonai/praisonai/cli/commands/profile.py">--help</a></code>
* <code title="cli">praisonai profile suite <a href="./src/praisonai/praisonai/cli/commands/profile.py">--help</a></code>
* <code title="cli">praisonai publish publish-main <a href="./src/praisonai/praisonai/cli/commands/publish.py">--help</a></code>
* <code title="cli">praisonai publish pypi <a href="./src/praisonai/praisonai/cli/commands/publish.py">--help</a></code>
* <code title="cli">praisonai rag chat <a href="./src/praisonai/praisonai/cli/commands/rag.py">--help</a></code>
* <code title="cli">praisonai rag eval <a href="./src/praisonai/praisonai/cli/commands/rag.py">--help</a></code>
* <code title="cli">praisonai rag index <a href="./src/praisonai/praisonai/cli/commands/rag.py">--help</a></code>
@@ -961,6 +970,7 @@ Methods:
* <code title="cli">praisonai sandbox recreate <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
* <code title="cli">praisonai sandbox sandbox-callback <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
* <code title="cli">praisonai sandbox status <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
* <code title="cli">praisonai schedule add <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule delete <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule describe <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule list <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.5.15" \
"praisonai>=4.5.87" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+61
View File
@@ -0,0 +1,61 @@
# PraisonAI 🦞 Claw — All-in-one: dashboard + bots + gateway + agents
#
# Build: docker build -f docker/Dockerfile.claw -t praisonai:claw ..
# Run: docker run -p 8082:8082 -e OPENAI_API_KEY=sk-xxx praisonai:claw
# Health: curl http://localhost:8082/health
#
# Override CMD for other modes:
# Bot: docker run -e ... praisonai:claw praisonai bot telegram
# Gateway: docker run -e ... praisonai:claw praisonai gateway start
# Serve: docker run -e ... praisonai:claw praisonai serve agents.yaml
# Shell: docker run --rm -it praisonai:claw bash
FROM python:3.12-slim AS base
# ── Environment ──────────────────────────────────────────────────────────────
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
AIUI_PORT=8082 \
AIUI_HOST=0.0.0.0 \
AIUI_DATA_DIR=/data
WORKDIR /app
# ── System dependencies ─────────────────────────────────────────────────────
# tini: PID 1 signal handling (graceful shutdown on Fly/Railway/k8s)
# curl: HEALTHCHECK probe
# build-essential: compile native Python wheels (chromadb, etc.)
RUN apt-get update && apt-get install -y --no-install-recommends \
tini \
curl \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# ── Install PraisonAI 🦞 ────────────────────────────────────────────────────
# Single command installs: AIUI dashboard, agents SDK (memory/knowledge/llm/mcp),
# bot channels (Telegram/Discord/Slack), gateway, search tools, and more.
RUN pip install --no-cache-dir "praisonai[claw]"
# ── Data volume ──────────────────────────────────────────────────────────────
# Persistence for sessions, config, memory, knowledge stores
RUN mkdir -p /data
VOLUME /data
# ── Entrypoint ───────────────────────────────────────────────────────────────
# Ensures /data exists and forwards all signals via tini
RUN printf '#!/bin/sh\n\
set -e\n\
mkdir -p /data 2>/dev/null || true\n\
exec "$@"\n' > /usr/local/bin/entrypoint.sh \
&& chmod +x /usr/local/bin/entrypoint.sh
EXPOSE 8082
# ── Health check ─────────────────────────────────────────────────────────────
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl -f http://localhost:8082/health || exit 1
# ── Run ──────────────────────────────────────────────────────────────────────
# Default: AIUI dashboard via praisonai claw. Override CMD for bot/gateway/serve.
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/entrypoint.sh"]
CMD ["praisonai", "claw", "--port", "8082"]
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.5.15" \
"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.15" \
"praisonai>=4.5.87" \
"praisonai[ui]" \
"praisonai[crewai]"
+25
View File
@@ -1,6 +1,29 @@
version: '3.8'
services:
# PraisonAI 🦞 Claw — Recommended all-in-one service
claw:
build:
context: ..
dockerfile: docker/Dockerfile.claw
volumes:
- claw_data:/data
ports:
- "8082:8082"
environment:
OPENAI_API_KEY: ${OPENAI_API_KEY}
restart: unless-stopped
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:8082/health" ]
interval: 30s
timeout: 5s
start_period: 20s
retries: 3
networks:
- praison_network
# ─── Legacy services (kept for backward compatibility) ─────────────────
# PraisonAI UI Service
ui:
build:
@@ -92,6 +115,8 @@ services:
# For bot services (Slack, Discord, Telegram), see docker/bots/
volumes:
claw_data:
driver: local
praison_data:
driver: local
+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.
+23
View File
@@ -0,0 +1,23 @@
"""Calculator with Tests - Editor Output Example.
Builds a calculator module, writes unit tests, runs them, generates a
coverage-style report, and reads it back.
"""
from praisonaiagents import Agent
from praisonaiagents.tools import (
write_file, read_file, execute_command
)
agent = Agent(
instructions="You are a helpful coding assistant.",
output="editor",
tools=[write_file, read_file, execute_command],
approval=True,
)
agent.start(
"Build a mini calculator - create /tmp/calculator.py with add, subtract, "
"multiply, divide functions, write unit tests in /tmp/test_calculator.py "
"using unittest, run the tests to make sure they pass, then generate a "
"coverage-style report in /tmp/calc_report.md listing which functions were "
"tested and read it back"
)
+22
View File
@@ -0,0 +1,22 @@
"""Fibonacci Generator - Editor Output Example.
Creates a Fibonacci script, runs it, writes a summary, and reads back
both files to verify everything looks good.
"""
from praisonaiagents import Agent
from praisonaiagents.tools import (
write_file, read_file, execute_command, list_directory
)
agent = Agent(
instructions="You are a helpful coding assistant.",
output="editor",
tools=[write_file, read_file, execute_command, list_directory],
approval=True,
)
agent.start(
"Create a Python file at /tmp/fibonacci.py that generates the first 20 "
"Fibonacci numbers, run it to verify the output is correct, then write "
"a summary to /tmp/fib_summary.md explaining the algorithm you used and "
"the output, finally read back both files to confirm everything looks good"
)
+36
View File
@@ -0,0 +1,36 @@
"""Multi-Step Research - Editor Output Example.
Researches Python frameworks, creates comparison code, runs it, writes a
markdown report, reads it back, lists files, and gets system info.
"""
from praisonaiagents import Agent
from praisonaiagents.tools import (
write_file, read_file, execute_command, list_directory, get_system_info
)
try:
from praisonaiagents.tools import web_search
tools = [web_search, write_file, read_file, execute_command,
list_directory, get_system_info]
except ImportError:
tools = [write_file, read_file, execute_command,
list_directory, get_system_info]
agent = Agent(
instructions="You are a helpful research assistant.",
output="editor",
tools=tools,
approval=True,
)
agent.start(
"Research the top 3 Python web frameworks (Django, FastAPI, Flask), then: "
"1) Search the web for each framework's latest version and key features, "
"2) Create a file called /tmp/framework_comparison.py that contains a "
"Python dictionary with the comparison data, "
"3) Execute the code to verify the dictionary is valid, "
"4) Write a markdown report to /tmp/framework_report.md summarizing your "
"findings in a table format, "
"5) Read back the report file to verify it was written correctly, "
"6) List the files in /tmp to confirm both files exist, "
"7) Get system info to note what OS this report was generated on"
)
+21
View File
@@ -0,0 +1,21 @@
"""System Audit - Editor Output Example.
Runs system commands, gathers info, writes a report, and reads it back.
"""
from praisonaiagents import Agent
from praisonaiagents.tools import (
write_file, read_file, execute_command
)
agent = Agent(
instructions="You are a helpful system administration assistant.",
output="editor",
tools=[write_file, read_file, execute_command],
approval=True,
)
agent.start(
"Investigate my system - get the current date, check disk usage, find out "
"what Python version is installed, list running processes sorted by memory, "
"then write all findings to /tmp/system_audit.md as a neat report and read "
"it back to verify"
)
+28
View File
@@ -0,0 +1,28 @@
"""Weather Search - Editor Output Example.
Searches for weather, creates JSON data, writes a parser script, executes
it, and lists created files.
"""
from praisonaiagents import Agent
from praisonaiagents.tools import (
write_file, execute_command, list_directory
)
try:
from praisonaiagents.tools import web_search
tools = [web_search, write_file, execute_command, list_directory]
except ImportError:
tools = [write_file, execute_command, list_directory]
agent = Agent(
instructions="You are a helpful research assistant.",
output="editor",
tools=tools,
approval=True,
)
agent.start(
"Search the web for the current weather in Tokyo, create a JSON file at "
"/tmp/weather_data.json with the findings, then write a Python script at "
"/tmp/parse_weather.py that reads the JSON and prints a formatted weather "
"report, execute the script, and list all files you created in /tmp"
)
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# Calculator with tests: code + unit tests + test execution + coverage report
# Tests: multiple file creation, test execution, report generation, file reading
praisonai "Build a mini calculator - create /tmp/calculator.py with add, subtract, multiply, divide functions, write unit tests in /tmp/test_calculator.py using unittest, run the tests to make sure they pass, then generate a coverage-style report in /tmp/calc_report.md listing which functions were tested and read it back" --trust
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# Fibonacci generator: code creation, execution, documentation, verification
# Tests: file creation, code execution, markdown writing, file reading
praisonai "Create a Python file at /tmp/fibonacci.py that generates the first 20 Fibonacci numbers, run it to verify the output is correct, then write a summary to /tmp/fib_summary.md explaining the algorithm you used and the output, finally read back both files to confirm everything looks good" --trust
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# Multi-step research: web search, file creation, code execution, reporting
# Tests: web search, file write, code execution, markdown report, file read, directory listing, system info
praisonai "Research the top 3 Python web frameworks (Django, FastAPI, Flask), then: 1) Search the web for each framework's latest version and key features, 2) Create a file called /tmp/framework_comparison.py that contains a Python dictionary with the comparison data, 3) Execute the code to verify the dictionary is valid, 4) Write a markdown report to /tmp/framework_report.md summarizing your findings in a table format, 5) Read back the report file to verify it was written correctly, 6) List the files in /tmp to confirm both files exist, 7) Get system info to note what OS this report was generated on, 8) Analyze the Python code you wrote for any issues, 9) Format the Python code properly, 10) Finally search for any recent news about Python 3.13 features" --trust
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# System audit: multiple command executions, report generation
# Tests: command execution (date, disk, python, processes), file creation, file reading
praisonai "Investigate my system - get the current date, check disk usage, find out what Python version is installed, list running processes sorted by memory, then write all findings to /tmp/system_audit.md as a neat report and read it back to verify" --trust
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# Todo app: multi-file app creation, execution, documentation, verification
# Tests: multiple file creation, code execution, markdown docs, directory listing
praisonai "Write a todo list app at /tmp/todo.py with functions to add, remove, and list tasks stored in /tmp/todos.json, run the script with some sample tasks added, then create documentation at /tmp/todo_docs.md explaining usage with examples, and verify all three files exist" --trust
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# Weather search: web search, JSON creation, Python script, execution
# Tests: web search, JSON file creation, Python scripting, code execution, directory listing
praisonai "Search the web for the current weather in Tokyo, create a JSON file at /tmp/weather_data.json with the findings, then write a Python script at /tmp/parse_weather.py that reads the JSON and prints a formatted weather report, execute the script, and list all files you created in /tmp" --trust
+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';
}
+216
View File
@@ -0,0 +1,216 @@
"""
Email Bot Example — Deploy an AI Agent to Your Mailbox
This example shows how to deploy PraisonAI agents as email bots
using IMAP/SMTP. The bot monitors an inbox, processes incoming emails,
and replies intelligently using an LLM-powered agent.
Supported providers:
- Gmail (with App Password)
- Outlook/Microsoft 365
- Any IMAP/SMTP server
Environment Variables:
export OPENAI_API_KEY=your_key
export EMAIL_ADDRESS=support@example.com
export EMAIL_APP_PASSWORD=your_app_password
export EMAIL_IMAP_SERVER=imap.gmail.com # optional, default: imap.gmail.com
export EMAIL_SMTP_SERVER=smtp.gmail.com # optional, default: smtp.gmail.com
Usage:
python bot_email_example.py
"""
import asyncio
import os
from praisonaiagents import Agent
# ── Example 1: Minimal — Bot() wrapper (recommended) ──────────────
def example_minimal():
"""Minimal email bot — 3 lines to start."""
from praisonai.bots import Bot
agent = Agent(
name="email_assistant",
instructions="You are a helpful email assistant. Reply concisely and professionally.",
llm="gpt-4o-mini",
)
# Token and server resolved from env vars automatically:
# EMAIL_APP_PASSWORD, EMAIL_ADDRESS, EMAIL_IMAP_SERVER, EMAIL_SMTP_SERVER
bot = Bot("email", agent=agent)
bot.run() # Blocks until Ctrl+C
# ── Example 2: Custom configuration ───────────────────────────────
def example_with_config():
"""Email bot with explicit configuration."""
from praisonai.bots import Bot
from praisonaiagents import BotConfig
config = BotConfig(
polling_interval=30.0, # Check inbox every 30s
allowed_users=[ # Only respond to these senders
"[email protected]",
"[email protected]",
],
max_message_length=10000, # Allow long email replies
session_ttl=86400, # Clear session after 24h inactivity
metadata={
"imap_folder": "INBOX", # Which folder to monitor
},
)
agent = Agent(
name="support_agent",
instructions="""You are a technical support agent for ExampleCo.
Guidelines:
- Be professional and empathetic
- Provide clear step-by-step solutions
- If you don't know the answer, escalate to [email protected]
- Keep replies under 500 words
""",
llm="gpt-4o-mini",
)
bot = Bot("email", agent=agent, config=config)
bot.run()
# ── Example 3: Direct EmailBot with custom handlers ──────────────
def example_direct():
"""Direct EmailBot usage with custom message handler and commands."""
from praisonai.bots import EmailBot
agent = Agent(
name="smart_inbox",
instructions="You help triage and respond to support emails.",
llm="gpt-4o-mini",
)
bot = EmailBot(
token=os.environ.get("EMAIL_APP_PASSWORD", ""),
agent=agent,
email_address=os.environ.get("EMAIL_ADDRESS", ""),
imap_server=os.environ.get("EMAIL_IMAP_SERVER", "imap.gmail.com"),
smtp_server=os.environ.get("EMAIL_SMTP_SERVER", "smtp.gmail.com"),
)
# Custom message handler — runs on every incoming email
@bot.on_message
async def log_incoming(message):
print(f"📧 New email from: {message.sender.username}")
print(f" Subject: {message.metadata.get('subject', '(no subject)')}")
# Custom command — triggered when subject starts with "/status"
@bot.on_command("status")
async def handle_status(message):
await bot.send_message(
channel_id=message.sender.user_id,
content={
"subject": "Re: Bot Status",
"body": f"🤖 Bot is running!\nProcessed emails: {bot._emails_processed}",
},
reply_to=message.message_id,
)
asyncio.run(bot.start())
# ── Example 4: Multi-platform with BotOS ─────────────────────────
def example_multi_platform():
"""Run email bot alongside Telegram and Discord bots."""
from praisonai.bots import BotOS, Bot
agent = Agent(
name="omni_assistant",
instructions="You are a helpful assistant available on email, Telegram, and Discord.",
llm="gpt-4o-mini",
)
botos = BotOS(agent=agent, platforms=["email", "telegram", "discord"])
botos.run() # All three bots run concurrently
# ── Example 5: Probe connectivity ─────────────────────────────────
async def example_probe():
"""Test email server connectivity without starting the bot."""
from praisonai.bots import Bot
bot = Bot("email")
result = await bot.probe()
print(f"Connection OK: {result.ok}")
print(f"Platform: {result.platform}")
if result.bot_username:
print(f"Email: {result.bot_username}")
if result.error:
print(f"Error: {result.error}")
print(f"Latency: {result.elapsed_ms:.0f}ms")
# ── Example 6: YAML config (for praisonai bot CLI) ───────────────
YAML_EXAMPLE = """
# bot.yaml — Save this file and run: praisonai bot --config bot.yaml
agent:
name: email_support
instructions: >
You are a professional support agent.
Reply concisely and helpfully.
llm: gpt-4o-mini
tools:
- search_web
platforms:
email:
token: ${EMAIL_APP_PASSWORD}
"""
# ── Main ──────────────────────────────────────────────────────────
if __name__ == "__main__":
import sys
examples = {
"minimal": ("Minimal email bot", example_minimal),
"config": ("With custom config", example_with_config),
"direct": ("Direct EmailBot with handlers", example_direct),
"multi": ("Multi-platform (email+telegram+discord)", example_multi_platform),
"probe": ("Test connectivity", lambda: asyncio.run(example_probe())),
}
if len(sys.argv) > 1 and sys.argv[1] in examples:
name = sys.argv[1]
desc, fn = examples[name]
print(f"Running: {desc}")
fn()
else:
print("PraisonAI Email Bot Examples")
print("=" * 40)
print()
print("Usage: python bot_email_example.py <example>")
print()
print("Available examples:")
for key, (desc, _) in examples.items():
print(f" {key:10s} {desc}")
print()
print("Required environment variables:")
print(" OPENAI_API_KEY Your OpenAI API key")
print(" EMAIL_ADDRESS Bot's email address")
print(" EMAIL_APP_PASSWORD App password (Gmail: myaccount.google.com/apppasswords)")
print()
print("Optional:")
print(" EMAIL_IMAP_SERVER IMAP server (default: imap.gmail.com)")
print(" EMAIL_SMTP_SERVER SMTP server (default: smtp.gmail.com)")
print()
print("YAML config example:")
print(YAML_EXAMPLE)
+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")
+41
View File
@@ -0,0 +1,41 @@
"""
Example file-based action: line-count
Counts lines in files matching a pattern. Demonstrates how actions
receive step config and flags from the workflow YAML.
Usage in workflow:
- name: Count lines
action: line-count
pattern: "*.py"
directory: src/
"""
import os
from pathlib import Path
def run(step, flags, cwd):
"""Count lines in files matching a glob pattern."""
pattern = step.get("pattern", "*.py")
directory = step.get("directory", ".")
target = Path(cwd) / directory
if not target.exists():
return {"ok": False, "error": f"Directory not found: {target}"}
total_lines = 0
file_count = 0
for filepath in target.rglob(pattern):
if filepath.is_file():
try:
total_lines += len(filepath.read_text().splitlines())
file_count += 1
except (UnicodeDecodeError, PermissionError):
continue
return {
"ok": True,
"output": f"{file_count} files, {total_lines} lines ({pattern} in {directory})",
}
+33
View File
@@ -0,0 +1,33 @@
"""
Example file-based action: system-info
Collects basic system information. Demonstrates the action file contract:
- Must define run(step, flags, cwd) → dict
- Return {"ok": True, "output": "..."} on success
- Return {"ok": False, "error": "..."} on failure
Place in: actions/system_info.py (next to workflow)
or: .praison/actions/system_info.py (project-level)
"""
import platform
import os
def run(step, flags, cwd):
"""Collect system information."""
info_parts = [
f"OS: {platform.system()} {platform.release()}",
f"Python: {platform.python_version()}",
f"Machine: {platform.machine()}",
f"CWD: {cwd}",
]
# Optional: include env vars if requested via step config
if step.get("show_env"):
env_vars = step.get("show_env", "").split(",")
for var in env_vars:
var = var.strip()
info_parts.append(f"{var}: {os.environ.get(var, '(not set)')}")
return {"ok": True, "output": " | ".join(info_parts)}
+33
View File
@@ -0,0 +1,33 @@
# Example: File-Based Actions (Option A)
#
# Actions loaded from .py files in the actions/ directory next to this workflow.
# Each action file exports: run(step, flags, cwd) → dict
#
# Directory structure:
# examples/workflows/
# ├── file-actions-demo.yaml ← this file
# └── actions/
# ├── system_info.py ← action: system-info
# └── line_count.py ← action: line-count
#
# Usage:
# praisonai workflow run file-actions-demo.yaml
# praisonai workflow run file-actions-demo.yaml --dry-run
type: job
name: file-actions-demo
description: Demonstrates file-based custom actions from actions/ directory
steps:
- name: System info
action: system-info
- name: Count Python files
action: line-count
pattern: "*.py"
directory: "."
- name: Count YAML files
action: line-count
pattern: "*.yaml"
directory: "."
+40
View File
@@ -0,0 +1,40 @@
# Example Step Workflow: Publish to PyPI
# Usage:
# praisonai workflow run publish-pypi.yaml --dry-run
# praisonai workflow run publish-pypi.yaml
# praisonai workflow run publish-pypi.yaml --major
# praisonai workflow run publish-pypi.yaml --minor
# praisonai workflow run publish-pypi.yaml --no-bump
type: job
name: publish-pypi
description: Publish package to PyPI with automatic version bump
vars:
token_var:
default: PYPI_TOKEN
description: Environment variable for PyPI token
flags:
major: { description: "Bump major version (X.0.0)" }
minor: { description: "Bump minor version (X.Y.0)" }
no-bump: { description: "Skip version bump" }
steps:
- name: Bump version
action: bump-version
file: pyproject.toml
strategy: patch
if: "{{ not flags.no_bump }}"
- name: Clean dist
run: rm -rf dist
- name: Lock dependencies
run: uv lock
- name: Build package
run: uv build
- name: Publish to PyPI
run: uv publish --token ${{ env.PYPI_TOKEN }}
+40
View File
@@ -0,0 +1,40 @@
# Example: YAML-Defined Actions (Option B)
#
# Actions defined inline in the workflow — self-contained, portable.
# No external files needed.
#
# Usage:
# praisonai workflow run yaml-actions-demo.yaml
# praisonai workflow run yaml-actions-demo.yaml --dry-run
type: job
name: yaml-actions-demo
description: Demonstrates YAML-defined custom actions
# Define reusable actions inline
actions:
check-python:
run: python3 --version
count-files:
script: |
import os
cwd = os.getcwd()
py_files = [f for f in os.listdir(cwd) if f.endswith('.py')]
yaml_files = [f for f in os.listdir(cwd) if f.endswith('.yaml') or f.endswith('.yml')]
result = f"Python: {len(py_files)}, YAML: {len(yaml_files)}"
timestamp:
script: |
from datetime import datetime
result = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
steps:
- name: Check Python version
action: check-python
- name: Count project files
action: count-files
- name: Get timestamp
action: timestamp
@@ -0,0 +1,70 @@
# Agent-Centric Job Workflow Example
#
# This workflow demonstrates the new agent-centric step types:
# - agent: AI agent execution
# - judge: AI quality gate with threshold
# - approve: AI or human approval gate
#
# Run with: praisonai workflow run agent-centric-job-workflow.yaml
# Dry run: praisonai workflow run agent-centric-job-workflow.yaml --dry-run
type: job
name: smart-release
description: Intelligent release workflow with AI-powered steps
flags:
major:
description: Bump major version
minor:
description: Bump minor version
skip-approval:
description: Skip approval gate (auto-approve)
steps:
# Step 1: Deterministic - Bump version
- name: Bump version
action: bump-version
strategy: patch
# Step 2: Agent - Generate changelog
- name: Generate changelog
agent:
role: Technical Writer
instructions: |
You are a technical writer who creates clear, concise changelogs.
Focus on user-facing changes, bug fixes, and new features.
prompt: |
Generate a changelog entry for the latest release.
Include sections for: Features, Bug Fixes, and Breaking Changes.
Keep it concise and professional.
model: gpt-4o-mini
output_file: CHANGELOG_ENTRY.md
# Step 3: Judge - Quality check
- name: Quality check
judge:
input_file: CHANGELOG_ENTRY.md
criteria: |
- Completeness: All sections present (Features, Bug Fixes, Breaking Changes)
- Clarity: Easy to understand for end users
- Formatting: Proper markdown structure
- Conciseness: Not overly verbose
threshold: 7.0
on_fail: warn
model: gpt-4o-mini
# Step 4: Approve - Human approval gate
- name: Approve release
approve:
description: Review and approve the release
risk_level: medium
auto_approve: "{{ flags.skip_approval }}"
# Step 5: Deterministic - Build
- name: Build package
run: echo "Building package..."
# Step 6: Deterministic - Publish (conditional)
- name: Publish to PyPI
run: echo "Publishing to PyPI..."
if: "{{ not flags.skip_approval }}"
+27
View File
@@ -0,0 +1,27 @@
# PraisonAI AgentMail Bot Configuration (Zero-Code)
#
# AgentMail = API-first email (no IMAP/SMTP). Programmatic inboxes.
#
# Usage:
# praisonai bot start --config agentmail-bot.yaml
#
# Requires:
# pip install praisonai agentmail
# export AGENTMAIL_API_KEY=am_...
#
# Optional env vars:
# AGENTMAIL_INBOX_ID=inbox_... (reuse existing inbox)
# AGENTMAIL_DOMAIN=mycompany.com (custom domain for new inboxes)
# Agent configuration
agent:
name: "Email Assistant"
instructions: "You are a helpful email assistant. Keep replies concise and professional."
model: "gpt-4o-mini"
memory: true
# Channel configuration (multi-channel format)
channels:
agentmail:
token: "${AGENTMAIL_API_KEY}"
# Optional: inbox_id, domain are resolved from env vars automatically
+89
View File
@@ -0,0 +1,89 @@
# Hybrid Workflow Example
#
# This workflow demonstrates the `type: hybrid` feature that combines:
# - Deterministic steps (run, action) from job workflows
# - Agent-centric steps (agent, judge, approve) from job workflows
# - Multi-agent workflow steps (workflow, parallel)
#
# Run with: praisonai workflow run hybrid-workflow.yaml
# Dry run: praisonai workflow run hybrid-workflow.yaml --dry-run
type: hybrid
name: hybrid-release-pipeline
description: Hybrid workflow combining shell commands, AI agents, and multi-agent steps
# Define agents for multi-agent workflow steps
agents:
researcher:
name: Researcher
role: Research Analyst
goal: Research and gather information
instructions: Provide concise, factual research findings
model: gpt-4o-mini
writer:
name: Writer
role: Content Writer
goal: Write clear, engaging content
instructions: Write professional documentation
model: gpt-4o-mini
flags:
skip-tests:
description: Skip test execution
auto-approve:
description: Auto-approve deployment
steps:
# Step 1: Deterministic - Check environment
- name: Check environment
run: echo "Checking environment..." && python --version
# Step 2: Agent - Generate release notes
- name: Generate release notes
agent:
role: Technical Writer
instructions: You write clear, concise release notes
prompt: Generate release notes for version 1.2.0 with bug fixes and new features
model: gpt-4o-mini
output_file: RELEASE_NOTES.md
# Step 3: Parallel - Run multiple checks simultaneously
- name: Parallel checks
parallel:
- run: echo "Running lint check..."
- run: echo "Running type check..."
- run: echo "Running security scan..."
# Step 4: Multi-agent workflow step
- name: Research and document
workflow:
agent: researcher
action: Research best practices for release management
# Step 5: Judge - Quality gate
- name: Quality check
judge:
input_file: RELEASE_NOTES.md
criteria: |
- Completeness: All sections present
- Clarity: Easy to understand
- Professional tone
threshold: 7.0
on_fail: warn
# Step 6: Approve - Human approval gate
- name: Approve release
approve:
description: Review and approve the release
risk_level: medium
auto_approve: "{{ flags.auto_approve }}"
# Step 7: Deterministic - Build
- name: Build package
run: echo "Building package..."
if: "{{ not flags.skip_tests }}"
# Step 8: Final notification
- name: Notify completion
run: echo "✅ Hybrid workflow completed successfully!"
+75 -24
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
@@ -123,6 +136,8 @@ Pattern: Each major module has a protocols.py file
- Protocols define WHAT (interface contract)
- Adapters implement HOW (concrete implementation)
- Naming: XProtocol for interfaces, XAdapter for implementations
(ALWAYS suffix with "Protocol" — never use bare concept names like TraceSink)
- File placement: All protocols live in protocols.py within their module
- Users can implement any protocol for custom behavior
```
@@ -286,9 +301,19 @@ save() / load() # Disk persistence (NOT store)
# Configuration
class XConfig: # Configuration dataclass (MemoryConfig, HooksConfig)
# Protocols
class XProtocol(Protocol): # Abstract interface
class XAdapter: # Implementation
# Protocols — ALWAYS suffix with "Protocol"
class XProtocol(Protocol): # Abstract interface (TraceSinkProtocol, MemoryProtocol)
class XAdapter: # Concrete implementation (ChromaAdapter, FileAdapter)
# ✅ CORRECT protocol naming:
class MemoryProtocol(Protocol): ... # Clear it's a protocol
class SessionStoreProtocol(Protocol): ... # Descriptive + Protocol suffix
class TraceSinkProtocol(Protocol): ... # In protocols.py + suffixed
# ❌ WRONG protocol naming:
class TraceSink(Protocol): ... # Missing Protocol suffix — ambiguous
class IMemory(Protocol): ... # TypeScript convention, not Python
class MemoryBase(Protocol): ... # "Base" implies ABC, not Protocol
# Execution
run() # Synchronous, blocking
@@ -500,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
@@ -624,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
@@ -634,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
@@ -841,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'),
@@ -344,6 +347,14 @@ _LAZY_IMPORTS = {
'ObservabilityEventType': ('praisonaiagents.escalation.observability', 'EventType'),
'DoomLoopDetector': ('praisonaiagents.escalation.doom_loop', 'DoomLoopDetector'),
'MemoryBackend': ('praisonaiagents.config.feature_configs', 'MemoryBackend'),
'LearnConfig': ('praisonaiagents.config.feature_configs', 'LearnConfig'),
'LearnScope': ('praisonaiagents.config.feature_configs', 'LearnScope'),
'LearnMode': ('praisonaiagents.config.feature_configs', 'LearnMode'),
'LearnBackend': ('praisonaiagents.config.feature_configs', 'LearnBackend'),
'LearnProtocol': ('praisonaiagents.memory.learn.protocols', 'LearnProtocol'),
'AsyncLearnProtocol': ('praisonaiagents.memory.learn.protocols', 'AsyncLearnProtocol'),
'LearnManagerProtocol': ('praisonaiagents.memory.learn.protocols', 'LearnManagerProtocol'),
'LearnManager': ('praisonaiagents.memory.learn', 'LearnManager'),
'ChunkingStrategy': ('praisonaiagents.config.feature_configs', 'ChunkingStrategy'),
'GuardrailAction': ('praisonaiagents.config.feature_configs', 'GuardrailAction'),
'WebSearchProvider': ('praisonaiagents.config.feature_configs', 'WebSearchProvider'),
@@ -495,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}")
@@ -521,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
@@ -534,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
@@ -73,6 +73,17 @@ _ESCALATION_TO_AUTONOMY_SIGNAL = {
_AUTONOMY_TO_ESCALATION_SIGNAL = {v: k for k, v in _ESCALATION_TO_AUTONOMY_SIGNAL.items()}
# Valid autonomy modes
VALID_AUTONOMY_MODES = {"caller", "iterative"}
# Smart default mode based on level
_LEVEL_TO_DEFAULT_MODE = {
"suggest": "caller",
"auto_edit": "caller",
"full_auto": "iterative",
}
@dataclass
class AutonomyConfig:
"""Configuration for Agent autonomy features.
@@ -83,6 +94,10 @@ class AutonomyConfig:
Attributes:
enabled: Whether autonomy is enabled
level: Autonomy level (suggest, auto_edit, full_auto)
mode: Execution mode — "caller" (single chat call, wrapper-equivalent)
or "iterative" (internal loop with completion detection).
Defaults based on level: suggest/auto_edit → "caller",
full_auto → "iterative".
max_iterations: Maximum iterations before stopping
doom_loop_threshold: Number of repeated actions to trigger doom loop
auto_escalate: Whether to automatically escalate complexity
@@ -99,18 +114,21 @@ class AutonomyConfig:
Usage::
# Simple — full_auto auto-enables tracking + sandbox
# Simple — default caller mode (wrapper-equivalent, no wasteful loop)
Agent(autonomy=True) # level=suggest, mode=caller
# Full auto — iterative loop with completion detection
Agent(autonomy="full_auto")
# Advanced — explicit config
# Ralph Loop — explicit iterative mode with completion promise
Agent(autonomy=AutonomyConfig(
level="full_auto",
track_changes=True,
sandbox=SandboxConfig.native(writable_paths=["./src"]),
mode="iterative",
completion_promise="COMPLETE",
))
"""
enabled: bool = True
level: str = "suggest"
mode: Optional[str] = None # None = smart default based on level
max_iterations: int = 20
doom_loop_threshold: int = 3
auto_escalate: bool = True
@@ -122,6 +140,8 @@ class AutonomyConfig:
track_changes: Optional[bool] = None # None = auto (True for full_auto)
sandbox: Optional[Any] = None # SandboxConfig (lazy import to avoid circular)
snapshot_dir: Optional[str] = None # Defaults to ~/.praisonai/snapshots
# Default tools for autonomy mode (auto-populated with code intelligence tools)
default_tools: Optional[List[Any]] = None # None = auto (uses get_autonomy_default_tools())
def __post_init__(self):
if self.level not in VALID_AUTONOMY_LEVELS:
@@ -129,15 +149,21 @@ class AutonomyConfig:
f"Invalid autonomy level: {self.level!r}. "
f"Must be one of {sorted(VALID_AUTONOMY_LEVELS)}"
)
# Smart default mode based on level
if self.mode is None:
self.mode = _LEVEL_TO_DEFAULT_MODE.get(self.level, "caller")
elif self.mode not in VALID_AUTONOMY_MODES:
raise ValueError(
f"Invalid autonomy mode: {self.mode!r}. "
f"Must be one of {sorted(VALID_AUTONOMY_MODES)}"
)
# Auto-enable track_changes for full_auto if not explicitly set
if self.track_changes is None:
self.track_changes = (self.level == "full_auto")
# Default snapshot directory
if self.snapshot_dir is None:
import os
self.snapshot_dir = os.path.join(
os.path.expanduser("~"), ".praisonai", "snapshots"
)
from ..paths import get_snapshots_dir
self.snapshot_dir = str(get_snapshots_dir())
@property
def effective_track_changes(self) -> bool:
@@ -156,6 +182,7 @@ class AutonomyConfig:
return cls(
enabled=data.get("enabled", True),
level=level,
mode=data.get("mode"), # None = smart default in __post_init__
max_iterations=data.get("max_iterations", 20),
doom_loop_threshold=data.get("doom_loop_threshold", 3),
auto_escalate=data.get("auto_escalate", True),
@@ -166,6 +193,7 @@ class AutonomyConfig:
track_changes=data.get("track_changes"),
sandbox=data.get("sandbox"),
snapshot_dir=data.get("snapshot_dir"),
default_tools=data.get("default_tools"),
)
@@ -377,6 +405,14 @@ class DoomLoopTracker:
"""
self._delegate.mark_progress(marker)
def record_response(self, text: str) -> None:
"""Record model response text for content streaming loop detection.
Args:
text: The model's response text
"""
self._delegate.record_response(text)
def reset(self) -> None:
"""Reset the tracker completely."""
self._delegate.start_session()
@@ -162,7 +162,8 @@ class ContextAgent(Agent):
# Enhanced logging and output management
self.debug_mode = os.getenv('LOGLEVEL', '').lower() == 'debug'
self.output_dir = Path(".praisonai/prp") # Save in .praisonai/prp folder
from ..paths import get_project_prp_dir
self.output_dir = get_project_prp_dir() # Save in .praisonai/prp folder
self.setup_output_directories() # Create directories first
self.setup_logging() # Then setup logging
@@ -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
@@ -220,6 +219,7 @@ class AgentTeam:
web: Optional[Any] = None, # Union[bool, WebConfig] - web search/fetch
reflection: Optional[Any] = None, # Union[bool, ReflectionConfig] - self-reflection
caching: Optional[Any] = None, # Union[bool, CachingConfig] - caching
learn: Optional[Any] = None, # Union[bool, LearnConfig] - continuous learning
):
"""
Initialize AgentManager with consolidated feature parameters.
@@ -243,8 +243,10 @@ class AgentTeam:
guardrails: Guardrails configuration (bool | Callable | GuardrailConfig)
web: Web search/fetch configuration (bool | WebConfig)
reflection: Self-reflection configuration (bool | ReflectionConfig)
learn: Continuous learning configuration (bool | LearnConfig)
"""
# Store new params for propagation to agents
self._learn = learn
self.llm = llm # Store default LLM for API consistency
self._autonomy = autonomy
self._knowledge = knowledge
@@ -512,14 +514,16 @@ class AgentTeam:
if not mem_cfg:
mem_cfg = next((t.config.get('memory_config') for t in tasks if hasattr(t, 'config') and t.config), None)
if not mem_cfg:
from ..paths import get_project_data_dir
_pd = str(get_project_data_dir())
mem_cfg = {
"provider": "rag",
"use_embedding": True,
"storage": {
"type": "sqlite",
"path": "./.praisonai/memory.db"
"path": f"{_pd}/memory.db"
},
"rag_db_path": "./.praisonai/chroma_db"
"rag_db_path": f"{_pd}/chroma_db"
}
if _embedder:
if isinstance(_embedder, dict):
@@ -1282,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)
@@ -319,3 +319,106 @@ class BackgroundRunner:
except asyncio.CancelledError:
pass
self._cleanup_task = None
# ── Sync-friendly wrappers ──────────────────────────────────────────
def submit_sync(
self,
func: Callable,
args: tuple = (),
kwargs: Optional[Dict[str, Any]] = None,
name: Optional[str] = None,
timeout: Optional[float] = None,
on_complete: Optional[Callable[[BackgroundTask], None]] = None,
) -> BackgroundTask:
"""Submit a task from synchronous code.
Creates a background event loop (once) and schedules the task
there. Returns the ``BackgroundTask`` immediately so callers
can poll ``task.status`` / ``task.result`` without blocking.
Args:
func: Function to execute (sync or async)
args: Positional arguments for *func*
kwargs: Keyword arguments for *func*
name: Optional human-readable task name
timeout: Optional timeout in seconds
on_complete: Callback when task completes
Returns:
BackgroundTask for tracking
"""
loop = _get_bg_loop()
future = asyncio.run_coroutine_threadsafe(
self.submit(
func, args=args, kwargs=kwargs,
name=name, timeout=timeout, on_complete=on_complete,
),
loop,
)
# Block briefly to get the BackgroundTask object (not the task result).
return future.result(timeout=5)
def submit_agent_sync(
self,
agent: Any,
prompt: str,
name: Optional[str] = None,
timeout: Optional[float] = None,
on_complete: Optional[Callable[[BackgroundTask], None]] = None,
) -> BackgroundTask:
"""Submit an agent task from synchronous code.
Convenience wrapper around :meth:`submit_sync` that resolves
the agent's callable automatically.
Args:
agent: Agent instance (must have ``start``, ``chat``, or ``run``)
prompt: Prompt to send to the agent
name: Optional task name
timeout: Optional timeout in seconds
on_complete: Callback when task completes
Returns:
BackgroundTask for tracking
"""
loop = _get_bg_loop()
future = asyncio.run_coroutine_threadsafe(
self.submit_agent(
agent, prompt,
name=name, timeout=timeout, on_complete=on_complete,
),
loop,
)
return future.result(timeout=5)
# ── Module-level background event loop (lazy, daemon) ───────────────────
import threading as _threading
_bg_loop: Optional[asyncio.AbstractEventLoop] = None
_bg_lock = _threading.Lock()
def _get_bg_loop() -> asyncio.AbstractEventLoop:
"""Return a running event loop on a background daemon thread.
Created once on first call; reused thereafter. The thread is a
daemon so it won't prevent process exit.
"""
global _bg_loop
if _bg_loop is not None and _bg_loop.is_running():
return _bg_loop
with _bg_lock:
# Double-check after acquiring lock.
if _bg_loop is not None and _bg_loop.is_running():
return _bg_loop
_bg_loop = asyncio.new_event_loop()
t = _threading.Thread(target=_bg_loop.run_forever, daemon=True)
t.start()
return _bg_loop
@@ -22,6 +22,8 @@ if TYPE_CHECKING:
ChatCommandProtocol,
ProbeResult,
HealthResult,
EmailProtocol,
EmailInbox,
)
from .protocols import (
BotProtocol,
@@ -37,6 +39,8 @@ from .protocols import (
BotOSProtocol,
ProbeResult,
HealthResult,
EmailProtocol,
EmailInbox,
)
from .config import BotConfig, BotOSConfig
@@ -56,4 +60,6 @@ __all__ = [
"BotOSConfig",
"ProbeResult",
"HealthResult",
"EmailProtocol",
"EmailInbox",
]
@@ -29,8 +29,10 @@ class BotConfig:
"""
token: str = ""
mode: str = "poll" # poll | ws | webhook | hybrid
webhook_url: Optional[str] = None
webhook_path: str = "/webhook"
webhook_port: int = 8080
polling_interval: float = 1.0
allowed_users: List[str] = field(default_factory=list)
allowed_channels: List[str] = field(default_factory=list)
@@ -47,7 +49,12 @@ class BotConfig:
group_policy: str = "mention_only" # respond_all, mention_only, command_only
# Default tools enabled for all bots
default_tools: List[str] = field(default_factory=lambda: ["execute_command", "search_web", "schedule_add", "schedule_list", "schedule_remove"])
default_tools: List[str] = field(default_factory=lambda: [
"execute_command", "search_web", "web_crawl",
"schedule_add", "schedule_list", "schedule_remove",
"store_memory", "search_memory",
"store_learning", "search_learning",
])
# Auto-approve tool calls (useful for trusted environments)
auto_approve_tools: bool = False # If True, skip confirmation for tool execution
@@ -72,8 +79,10 @@ class BotConfig:
"""Convert to dictionary (hides sensitive data)."""
return {
"token": "***" if self.token else None,
"mode": self.mode,
"webhook_url": self.webhook_url,
"webhook_path": self.webhook_path,
"webhook_port": self.webhook_port,
"polling_interval": self.polling_interval,
"allowed_users": self.allowed_users,
"allowed_channels": self.allowed_channels,
@@ -97,7 +106,17 @@ class BotConfig:
@property
def is_webhook_mode(self) -> bool:
"""Whether bot is configured for webhook mode."""
return bool(self.webhook_url)
return self.mode == "webhook" or bool(self.webhook_url)
@property
def is_ws_mode(self) -> bool:
"""Whether bot is configured for WebSocket mode."""
return self.mode == "ws"
@property
def is_hybrid_mode(self) -> bool:
"""Whether bot is configured for hybrid mode (WS + slow poll)."""
return self.mode == "hybrid"
def is_user_allowed(self, user_id: str) -> bool:
"""Check if a user is allowed to interact with the bot."""
@@ -613,6 +613,96 @@ class ChatCommandProtocol(Protocol):
...
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# EmailProtocol — email-specific bot capabilities
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@dataclass
class EmailInbox:
"""Information about an email inbox.
Attributes:
id: Unique inbox identifier
email_address: The inbox's email address
domain: Domain of the inbox
created_at: When the inbox was created (ISO format)
"""
id: str
email_address: str
domain: str = ""
created_at: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"id": self.id,
"email_address": self.email_address,
"domain": self.domain,
"created_at": self.created_at,
}
@runtime_checkable
class EmailProtocol(Protocol):
"""Protocol for email bots with inbox lifecycle management.
Extends BotProtocol with email-specific capabilities like
programmatic inbox creation, listing, and deletion.
This protocol is optional — only AgentMail-style bots that
support API-based inbox management need to implement it.
Traditional IMAP/SMTP bots (EmailBot) do not implement this.
Example:
# Check if a bot supports inbox management
if isinstance(bot, EmailProtocol):
inbox = await bot.create_inbox(domain="mycompany.com")
print(f"Created: {inbox['email_address']}")
"""
@property
def email_address(self) -> Optional[str]:
"""The bot's current email address."""
...
async def create_inbox(
self,
domain: Optional[str] = None,
**kwargs,
) -> Dict[str, Any]:
"""Create a new email inbox programmatically.
Args:
domain: Optional custom domain for the inbox
**kwargs: Additional provider-specific parameters
Returns:
Dict with at least 'id' and 'email_address' keys
"""
...
async def list_inboxes(self) -> List[Dict[str, Any]]:
"""List all inboxes accessible to this bot.
Returns:
List of inbox dicts with 'id' and 'email_address' keys
"""
...
async def delete_inbox(self, inbox_id: str) -> bool:
"""Delete an inbox.
Args:
inbox_id: ID of the inbox to delete
Returns:
True if deleted successfully
"""
...
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# BotOS Protocol — multi-platform bot orchestration
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -57,6 +57,34 @@ class LearnScope(str, Enum):
SHARED = "shared" # Shared with all agents
class LearnMode(str, Enum):
"""Learning mode for automatic learning extraction.
DISABLED: No automatic learning (manual capture only)
AGENTIC: Agent autonomously extracts and stores learnings after each conversation
PROPOSE: (Future) Agent proposes learnings for user approval before storing.
Note: PROPOSE mode is defined but not yet implemented. Using it will
behave the same as DISABLED until the approval workflow is added.
"""
DISABLED = "disabled"
AGENTIC = "agentic"
PROPOSE = "propose"
class LearnBackend(str, Enum):
"""Storage backend for learning data.
FILE: JSON files (default, zero dependencies)
SQLITE: SQLite database
REDIS: Redis (requires redis package)
MONGODB: MongoDB (requires pymongo)
"""
FILE = "file"
SQLITE = "sqlite"
REDIS = "redis"
MONGODB = "mongodb"
@dataclass
class LearnConfig:
"""
@@ -66,16 +94,21 @@ class LearnConfig:
to improve future responses. All learning data is stored within the memory system.
Usage:
# Simple enable
Agent(memory=MemoryConfig(learn=True))
# Simple enable (top-level param)
Agent(learn=True)
# With specific capabilities
Agent(memory=MemoryConfig(
learn=LearnConfig(
persona=True, # User preferences
insights=True, # Observations
patterns=True, # Reusable knowledge
)
Agent(learn=LearnConfig(
persona=True, # User preferences
insights=True, # Observations
patterns=True, # Reusable knowledge
mode="agentic", # Auto-extract learnings
))
# With database backend
Agent(learn=LearnConfig(
backend="sqlite",
db_url="sqlite:///learn.db",
))
"""
# Learning capabilities
@@ -87,15 +120,19 @@ class LearnConfig:
feedback: bool = False # Outcome signals
improvements: bool = False # Self-improvement proposals
# Learning mode
mode: Union[str, LearnMode] = LearnMode.DISABLED # How to extract learnings
# Scope configuration
scope: Union[str, LearnScope] = LearnScope.PRIVATE
# Storage configuration
store_path: Optional[str] = None # Custom storage path
# Storage backend configuration
backend: Union[str, LearnBackend] = LearnBackend.FILE # Storage backend
db_url: Optional[str] = None # Database connection URL (for non-file backends)
store_path: Optional[str] = None # Custom storage path (for file backend)
# Maintenance settings
auto_consolidate: bool = True # Auto-consolidate learnings
retention_days: Optional[int] = None # Days to retain (None = forever)
# LLM for learning extraction
llm: Optional[str] = None # LLM model for extracting learnings (defaults to agent's LLM)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
@@ -107,10 +144,12 @@ class LearnConfig:
"decisions": self.decisions,
"feedback": self.feedback,
"improvements": self.improvements,
"mode": self.mode.value if isinstance(self.mode, LearnMode) else self.mode,
"scope": self.scope.value if isinstance(self.scope, LearnScope) else self.scope,
"backend": self.backend.value if isinstance(self.backend, LearnBackend) else self.backend,
"db_url": self.db_url,
"store_path": self.store_path,
"auto_consolidate": self.auto_consolidate,
"retention_days": self.retention_days,
"llm": self.llm,
}
@@ -574,6 +613,10 @@ class OutputConfig:
# Shows: [timestamp] Calling LLM..., Executing tool..., Response: ...
status_trace: bool = False
# Editor output mode - beginner-friendly numbered steps
# Shows: Step 1: 📄 Creating file: path → ✓ Done
editor_output: bool = False
# Output file - save agent response to file automatically
# When set, saves the response to the specified file path
output_file: Optional[str] = None
@@ -596,6 +639,7 @@ class OutputConfig:
"simple_output": self.simple_output,
"show_parameters": self.show_parameters,
"status_trace": self.status_trace,
"editor_output": self.editor_output,
"output_file": self.output_file,
"template": self.template,
}
@@ -635,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
@@ -654,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 {
@@ -664,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,
}
@@ -85,14 +85,32 @@ _config_cache: Optional[Dict[str, Any]] = None
_config_loaded: bool = False
def _default_project_plugins_dir():
"""Return project-local plugins directory via paths.py."""
from ..paths import get_project_data_dir
return get_project_data_dir() / "plugins"
def _default_global_plugins_dir():
"""Return user-global plugins directory via paths.py."""
from ..paths import get_plugins_dir
return get_plugins_dir()
def get_project_data_dir():
"""Re-export for local use in config search."""
from ..paths import get_project_data_dir as _gpd
return _gpd()
@dataclass
class PluginsConfig:
"""Configuration for the plugin system."""
enabled: Union[bool, List[str]] = False # True, False, or list of plugin names
auto_discover: bool = True
directories: List[str] = field(default_factory=lambda: [
"./.praisonai/plugins/",
"~/.praisonai/plugins/"
str(_default_project_plugins_dir()),
str(_default_global_plugins_dir())
])
def to_dict(self) -> Dict[str, Any]:
@@ -174,7 +192,7 @@ def _find_config_file() -> Optional[Path]:
# Project-local locations
cwd = Path.cwd()
local_paths = [
cwd / ".praisonai" / "config.toml",
get_project_data_dir() / "config.toml",
cwd / "praisonai.toml",
]
@@ -183,8 +201,8 @@ def _find_config_file() -> Optional[Path]:
return path
# User global location
home = Path.home()
global_path = home / ".praisonai" / "config.toml"
from ..paths import get_data_dir
global_path = get_data_dir() / "config.toml"
if global_path.exists():
return global_path
@@ -245,8 +263,8 @@ def _dict_to_plugins_config(data: Dict[str, Any]) -> PluginsConfig:
enabled=data.get("enabled", False),
auto_discover=data.get("auto_discover", True),
directories=data.get("directories", [
"./.praisonai/plugins/",
"~/.praisonai/plugins/"
str(_default_project_plugins_dir()),
str(_default_global_plugins_dir())
]),
)
@@ -133,6 +133,17 @@ OUTPUT_PRESETS: Dict[str, Dict[str, Any]] = {
"actions_trace": True,
"json_output": True, # Output as JSONL
},
# Editor preset - beginner-friendly numbered steps
# Shows: Step 1: 📄 Creating file: path → ✓ Done
# Best for non-programmers and interactive scripts
"editor": {
"verbose": False,
"markdown": False,
"stream": False,
"metrics": False,
"reasoning_steps": False,
"editor_output": True, # Enable editor display mode
},
# ==========================================================================
# ALIASES (for backward compatibility)
@@ -10,13 +10,25 @@ from .models import BudgetAllocation
# Model token limits (context window sizes)
MODEL_LIMITS: Dict[str, int] = {
# OpenAI
# OpenAI — gpt-5 family (1M context window)
"gpt-5": 1047576,
"gpt-5-mini": 1047576,
"gpt-5-nano": 1047576,
# OpenAI — gpt-4.1 family (1M context window)
"gpt-4.1": 1047576,
"gpt-4.1-mini": 1047576,
"gpt-4.1-nano": 1047576,
# OpenAI — gpt-4o family
"gpt-4o": 128000,
"gpt-4o-mini": 128000,
"gpt-4-turbo": 128000,
"gpt-4": 8192,
"gpt-3.5-turbo": 16385,
"gpt-3.5-turbo-16k": 16385,
# OpenAI — o-series
"o3-mini": 200000,
"o3": 200000,
"o4-mini": 200000,
# Anthropic
"claude-3-5-sonnet": 200000,
"claude-3-5-haiku": 200000,
@@ -33,11 +45,15 @@ MODEL_LIMITS: Dict[str, int] = {
# Default output reserves by model family
OUTPUT_RESERVES: Dict[str, int] = {
"gpt-5": 32768,
"gpt-4.1": 32768,
"gpt-4o": 16384,
"gpt-4o-mini": 16384,
"gpt-4-turbo": 4096,
"gpt-4": 4096,
"gpt-3.5-turbo": 4096,
"o3": 100000,
"o4-mini": 100000,
"claude-3": 8192,
"gemini": 8192,
"default": 8000,
@@ -99,7 +99,6 @@ def test_db_hook_lifecycle():
instructions="Test agent",
db=mock_db,
session_id="test-session-001",
verbose=False
)
# Verify db is stored
@@ -125,7 +124,6 @@ def test_db_none_unchanged_behavior():
agent = Agent(
name="TestAgent",
instructions="Test agent",
verbose=False
)
# Verify db is None
@@ -157,7 +155,6 @@ def test_session_id_auto_generation():
instructions="Test agent",
db=mock_db,
# No session_id provided
verbose=False
)
# Trigger db initialization
@@ -22,6 +22,7 @@ class DoomLoopType(Enum):
NO_PROGRESS = "no_progress" # No meaningful progress
CIRCULAR_PLAN = "circular_plan" # Plan loops back to start
RESOURCE_EXHAUSTION = "resource_exhaustion" # Budget exceeded
REPEATED_OUTPUT = "repeated_output" # Same output text repeated (content chanting)
class RecoveryAction(Enum):
@@ -58,6 +59,10 @@ class DoomLoopConfig:
initial_backoff: float = 1.0 # Initial backoff in seconds
backoff_multiplier: float = 2.0 # Backoff multiplier
max_backoff: float = 30.0 # Maximum backoff
# Content streaming detection
max_repeated_chunks: int = 8 # Max identical output chunks before flagging
content_chunk_size: int = 50 # Sliding window chunk size (chars)
@dataclass
@@ -116,6 +121,7 @@ class DoomLoopDetector:
self._start_time: Optional[float] = None
self._progress_markers: List[str] = []
self._current_backoff: float = self.config.initial_backoff
self._content_chunk_counts: Dict[str, int] = {} # hash -> count
def start_session(self):
"""Start a new detection session."""
@@ -125,6 +131,7 @@ class DoomLoopDetector:
self._recovery_attempts = 0
self._progress_markers.clear()
self._current_backoff = self.config.initial_backoff
self._content_chunk_counts.clear()
def record_action(
self,
@@ -162,6 +169,25 @@ class DoomLoopDetector:
if self.is_doom_loop():
self._handle_loop_detection()
def record_response(self, text: str):
"""
Record model response text for content streaming loop detection.
Hashes sliding window chunks and tracks repetition counts.
Inspired by gemini-cli's sliding window approach.
Args:
text: The model's response text
"""
if not text or len(text) < self.config.content_chunk_size:
return
chunk_size = self.config.content_chunk_size
for i in range(0, len(text) - chunk_size + 1, chunk_size):
chunk = text[i:i + chunk_size]
chunk_hash = hashlib.md5(chunk.encode()).hexdigest()[:16]
self._content_chunk_counts[chunk_hash] = self._content_chunk_counts.get(chunk_hash, 0) + 1
def mark_progress(self, marker: str):
"""
Mark meaningful progress.
@@ -178,6 +204,10 @@ class DoomLoopDetector:
Returns:
True if doom loop detected
"""
# Content streaming loop can occur even without recorded actions
if self._check_content_loop():
return True
if len(self._actions) < 2:
return False
@@ -201,6 +231,10 @@ class DoomLoopDetector:
if self._check_resource_exhaustion():
return True
# Check for content streaming loops (repeated output text)
if self._check_content_loop():
return True
return False
def get_loop_type(self) -> Optional[DoomLoopType]:
@@ -215,6 +249,8 @@ class DoomLoopDetector:
return DoomLoopType.NO_PROGRESS
if self._check_resource_exhaustion():
return DoomLoopType.RESOURCE_EXHAUSTION
if self._check_content_loop():
return DoomLoopType.REPEATED_OUTPUT
return None
def get_loop_event(self) -> Optional[DoomLoopEvent]:
@@ -371,6 +407,19 @@ class DoomLoopDetector:
elapsed = time.time() - self._start_time
return elapsed > self.config.max_total_time
def _check_content_loop(self) -> bool:
"""Check for repeated output text (content streaming loop).
Uses sliding window chunk hashing. If any chunk hash appears
more than max_repeated_chunks times, it indicates the model
is generating repetitive output (chanting).
"""
if not self._content_chunk_counts:
return False
threshold = self.config.max_repeated_chunks
return any(count >= threshold for count in self._content_chunk_counts.values())
def _determine_recovery_action(self, loop_type: DoomLoopType) -> RecoveryAction:
"""Determine appropriate recovery action."""
# If max recovery attempts reached, abort
@@ -408,6 +457,9 @@ class DoomLoopDetector:
DoomLoopType.RESOURCE_EXHAUSTION: (
f"Exceeded time limit of {self.config.max_total_time}s"
),
DoomLoopType.REPEATED_OUTPUT: (
f"Model output repeating — {self.config.max_repeated_chunks}+ identical chunks detected"
),
}
return descriptions.get(loop_type, "Unknown loop type")
@@ -6,6 +6,9 @@ implementations that coordinate multi-agent deployments.
This module contains only protocols and lightweight utilities.
Heavy implementations live in the praisonai wrapper package.
Gap S2: WebSocketGateway is re-exported here for convenience but requires
the praisonai wrapper package to be installed.
"""
from .protocols import (
@@ -18,15 +21,59 @@ from .protocols import (
)
from .config import GatewayConfig, SessionConfig, ChannelRouteConfig, MultiChannelGatewayConfig
# Lazy loading cache
_lazy_cache = {}
def __getattr__(name: str):
"""Lazy load heavy gateway implementations from praisonai wrapper.
Gap S2: Re-export WebSocketGateway from praisonai wrapper for convenience.
This allows downstream packages to import from praisonaiagents.gateway
without needing to know about the praisonai wrapper.
"""
if name in _lazy_cache:
return _lazy_cache[name]
if name == "WebSocketGateway":
try:
from praisonai.gateway import WebSocketGateway
_lazy_cache[name] = WebSocketGateway
return WebSocketGateway
except ImportError:
raise ImportError(
"WebSocketGateway requires the praisonai wrapper package. "
"Install with: pip install praisonai"
)
if name == "GatewaySession":
try:
from praisonai.gateway import GatewaySession
_lazy_cache[name] = GatewaySession
return GatewaySession
except ImportError:
raise ImportError(
"GatewaySession requires the praisonai wrapper package. "
"Install with: pip install praisonai"
)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
# Protocols (always available)
"GatewayProtocol",
"GatewaySessionProtocol",
"GatewayClientProtocol",
"GatewayEvent",
"GatewayMessage",
"EventType",
# Config (always available)
"GatewayConfig",
"SessionConfig",
"ChannelRouteConfig",
"MultiChannelGatewayConfig",
# Implementations (lazy loaded from praisonai wrapper)
"WebSocketGateway",
"GatewaySession",
]
@@ -67,16 +67,22 @@ class LLMGuardrail:
self.logger.warning(f"Unknown LLM type: {type(llm)}, treating as-is")
return llm
def __call__(self, task_output: TaskOutput) -> Tuple[bool, Union[str, TaskOutput]]:
def __call__(self, task_output) -> Tuple[bool, Union[str, "TaskOutput"]]:
"""Validate the task output using the LLM.
Args:
task_output: The task output to validate
task_output: The task output to validate (TaskOutput or plain str)
Returns:
Tuple of (success, result) where result is the output or error message
"""
try:
# Accept plain str for convenience (e.g. chat-input screening)
if isinstance(task_output, str):
raw_text = task_output
else:
raw_text = task_output.raw
if not self.llm:
self.logger.warning("No LLM provided for guardrail validation")
return True, task_output
@@ -88,7 +94,7 @@ You are a quality assurance validator. Your task is to evaluate the following ou
Validation Criteria: {self.description}
Output to Validate:
{task_output.raw}
{raw_text}
Please evaluate if this output meets the criteria. Respond with:
1. "PASS" if the output meets all criteria
@@ -20,34 +20,59 @@ class Chunking:
@cached_property
def SUPPORTED_CHUNKERS(self) -> Dict[str, Any]:
"""Lazy load chunker classes."""
chunkers = {}
try:
from chonkie import (
TokenChunker,
SentenceChunker,
SemanticChunker,
SDPMChunker,
LateChunker,
RecursiveChunker,
CodeChunker,
NeuralChunker,
SlumberChunker
)
from chonkie import TokenChunker
chunkers['token'] = TokenChunker
except ImportError:
pass
try:
from chonkie import SentenceChunker
chunkers['sentence'] = SentenceChunker
except ImportError:
pass
try:
from chonkie import SemanticChunker
chunkers['semantic'] = SemanticChunker
except ImportError:
pass
try:
from chonkie import SDPMChunker
chunkers['sdpm'] = SDPMChunker
except ImportError:
pass
try:
from chonkie import LateChunker
chunkers['late'] = LateChunker
except ImportError:
pass
try:
from chonkie import RecursiveChunker
chunkers['recursive'] = RecursiveChunker
except ImportError:
pass
try:
from chonkie import CodeChunker
chunkers['code'] = CodeChunker
except ImportError:
pass
try:
from chonkie import NeuralChunker
chunkers['neural'] = NeuralChunker
except ImportError:
pass
try:
from chonkie import SlumberChunker
chunkers['slumber'] = SlumberChunker
except ImportError:
pass
if not chunkers:
raise ImportError(
"chonkie package not found. Please install it using: pip install 'praisonaiagents[knowledge]'"
)
return {
'token': TokenChunker,
'sentence': SentenceChunker,
'semantic': SemanticChunker,
'sdpm': SDPMChunker,
'late': LateChunker,
'recursive': RecursiveChunker,
'code': CodeChunker,
'neural': NeuralChunker,
'slumber': SlumberChunker
}
return chunkers
def __init__(
self,
@@ -116,7 +141,18 @@ class Chunking:
if self._chunker is None:
chunker_cls = self.SUPPORTED_CHUNKERS[self.chunker_type]
common_params = self._get_chunker_params()
self._chunker = chunker_cls(**common_params)
try:
self._chunker = chunker_cls(**common_params)
except TypeError:
# Chonkie API may have changed — retry with just chunk_size
fallback_params = {'chunk_size': self.chunk_size}
if 'embedding_model' in common_params:
fallback_params['embedding_model'] = common_params['embedding_model']
try:
self._chunker = chunker_cls(**fallback_params)
except TypeError:
# Last resort — no params
self._chunker = chunker_cls()
return self._chunker
@@ -1,11 +1,10 @@
import os
import logging
import uuid
import time
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__)
@@ -414,9 +413,11 @@ class Knowledge:
@cached_property
def config(self):
# Generate unique collection name for each instance (only if not provided in config)
default_collection = f"test_{int(time.time())}_{str(uuid.uuid4())[:8]}"
persist_dir = ".praisonai"
# Use deterministic collection name for persistence across restarts
# Only generate unique name if explicitly requested via config
default_collection = "praisonai_knowledge"
from ..paths import get_project_data_dir
persist_dir = str(get_project_data_dir())
# Create persistent client config
base_config = {
@@ -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}"),
@@ -862,7 +864,8 @@ class Knowledge:
result = IndexResult()
# Initialize file tracker for incremental indexing
state_dir = os.path.join(os.path.dirname(path) if os.path.isfile(path) else path, ".praisonai")
from ..paths import DEFAULT_DIR_NAME
state_dir = os.path.join(os.path.dirname(path) if os.path.isfile(path) else path, DEFAULT_DIR_NAME)
os.makedirs(state_dir, exist_ok=True)
state_file = os.path.join(state_dir, ".index_state.json")
+844 -16
View File
@@ -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):
@@ -107,7 +109,7 @@ class LLM:
MODEL_WINDOWS = {
# OpenAI
"gpt-4": 6144, # 8,192 actual
"gpt-4o-mini": 96000, # 128,000 actual
"gpt-4o": 96000, # 128,000 actual
"gpt-4o-mini": 96000, # 128,000 actual
"gpt-4-turbo": 96000, # 128,000 actual
"o1-preview": 96000, # 128,000 actual
@@ -592,6 +594,18 @@ Respond with ONLY a valid JSON tool call in this format:
f"waiting {retry_delay:.1f}s before retry..."
)
# P5: Emit retry callback for CLI visibility
try:
from ..main import execute_sync_callback
execute_sync_callback('retry',
attempt=attempt + 1,
max_attempts=self._max_retries + 1,
error=error_str[:200],
retry_in_seconds=retry_delay
)
except ImportError:
pass
if self._rate_limiter is not None:
self._rate_limiter.wait_for_retry(retry_delay)
else:
@@ -1673,6 +1687,167 @@ Now provide your final answer using this result. Summarize the information natur
# Initial callback is in agent._chat_completion, so this triggers for all loop iterations
_get_display_functions()['execute_sync_callback']('llm_start', model=self.model, agent_name=agent_name)
# ── Responses API path ──────────────────────────────────
# OpenAI models use the Responses API which returns text
# and tool calls as separate output items — no content:null.
if self._supports_responses_api():
responses_params = self._build_responses_params(
messages=messages,
tools=formatted_tools,
temperature=temperature,
output_json=output_json,
output_pydantic=output_pydantic,
**{k: v for k, v in kwargs.items() if k != 'reasoning_steps'},
)
if stream:
response_text, tool_calls = self._stream_responses_api(
responses_params,
verbose=verbose,
stream_callback=stream_callback,
emit_events=emit_events,
current_time=current_time,
)
else:
resp = self._call_responses_api(**responses_params)
response_text, tool_calls, _reasoning = self._extract_from_responses_output(resp)
# Track token usage
if self.metrics and hasattr(resp, 'usage') and resp.usage:
usage = resp.usage
tokens_in = getattr(usage, 'input_tokens', 0) or 0
tokens_out = getattr(usage, 'output_tokens', 0) or 0
# Emit llm_end with metrics
llm_latency_ms = (time.time() - current_time) * 1000
_get_display_functions()['execute_sync_callback'](
'llm_end', model=self.model,
tokens_in=tokens_in, tokens_out=tokens_out,
cost=None, latency_ms=llm_latency_ms,
)
# Emit reasoning if present
if _emit and _reasoning:
stream_callback(StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=time.perf_counter(),
content=_reasoning, is_reasoning=True,
))
# Emit text content as stream event
if _emit and response_text:
stream_callback(StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=time.perf_counter(),
content=response_text, is_reasoning=False,
))
# Build a Chat-Completions-compatible final_response
# so all downstream code (display, callbacks) works unchanged
final_response = {
"choices": [{
"message": {
"content": response_text,
"tool_calls": tool_calls,
}
}]
}
# Display / callback handling (same as Chat Completions path)
if verbose and not interaction_displayed:
_get_display_functions()['display_interaction'](
original_prompt, response_text,
markdown=markdown,
generation_time=time.time() - current_time,
console=self.console,
agent_name=agent_name, agent_role=agent_role,
agent_tools=agent_tools, task_name=task_name,
task_description=task_description, task_id=task_id,
)
interaction_displayed = True
callback_executed = True
elif not callback_executed:
_get_display_functions()['execute_sync_callback'](
'interaction',
message=original_prompt, response=response_text,
markdown=markdown,
generation_time=time.time() - current_time,
agent_name=agent_name, agent_role=agent_role,
agent_tools=agent_tools, task_name=task_name,
task_description=task_description, task_id=task_id,
)
callback_executed = True
# Emit llm_content for intermediate narrative display
if response_text and response_text.strip():
_get_display_functions()['execute_sync_callback'](
'llm_content',
content=response_text.strip(),
agent_name=agent_name,
)
# ── Handle tool calls ───────────────────────────────
if tool_calls and execute_tool_fn:
serializable_tool_calls = self._serialize_tool_calls(tool_calls)
messages.append({
"role": "assistant",
"content": response_text,
"tool_calls": serializable_tool_calls,
})
tool_results = []
for tool_call in tool_calls:
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call)
logging.debug(f"[RESPONSES_API] Executing tool {function_name} with args: {arguments}")
tool_result = execute_tool_fn(function_name, arguments, tool_call_id=tool_call_id)
tool_results.append(tool_result)
accumulated_tool_results.append(tool_result)
if verbose:
display_message = f"Agent {agent_name} called function '{function_name}' with arguments: {arguments}\n"
display_message += f"Function returned: {tool_result}" if tool_result else "Function returned no output"
_get_display_functions()['display_tool_call'](display_message, console=self.console)
result_str = json.dumps(tool_result) if tool_result else "empty"
_get_display_functions()['execute_sync_callback'](
'tool_call',
message=f"Calling function: {function_name}",
tool_name=function_name,
tool_input=arguments,
tool_output=result_str[:200] if result_str else None,
)
# Append tool result as standard tool message
if tool_result is None:
content = "Function returned an empty output"
elif isinstance(tool_result, dict) and 'error' in tool_result:
content = f"Error: {tool_result.get('error', 'Unknown error')}. Please inform the user."
elif isinstance(tool_result, list) and tool_result and isinstance(tool_result[0], dict) and 'error' in tool_result[0]:
content = f"Error: {tool_result[0].get('error', 'Unknown error')}. Please inform the user."
else:
content = json.dumps(tool_result)
messages.append({
"role": "tool",
"tool_call_id": tool_call_id,
"content": content,
})
# Safety: break after 5 iterations
if iteration_count >= 5:
final_response_text = response_text.strip() if response_text else "Task completed."
break
iteration_count += 1
response_text = ""
continue
else:
# No tool calls — we have a final answer
if iteration_count > 0:
final_response_text = response_text.strip() if response_text else ""
else:
final_response_text = response_text.strip() if response_text else ""
break
# ── Chat Completions path (unchanged) ───────────────────
# If reasoning_steps is True, do a single non-streaming call
if reasoning_steps:
resp = litellm.completion(
@@ -2057,18 +2232,59 @@ Now provide your final answer using this result. Summarize the information natur
}]
}
else:
# For non-streaming + non-verbose: no display_generating (per user requirements)
final_response = litellm.completion(
**self._build_completion_params(
messages=messages,
tools=formatted_tools,
temperature=temperature,
stream=False,
output_json=output_json,
output_pydantic=output_pydantic,
**kwargs
# For non-streaming + non-verbose path
# When editor output is enabled, use silent streaming
# to capture intermediate text (OpenAI returns content:null
# on tool-call turns in non-streaming mode, but streaming
# delivers text tokens before tool call tokens).
_use_silent_stream = False
try:
from ..output.editor import is_editor_output_enabled
_use_silent_stream = is_editor_output_enabled()
except Exception:
pass
if _use_silent_stream:
# Silent streaming: no display, just accumulate
response_text = ""
tool_calls = []
for chunk in litellm.completion(
**self._build_completion_params(
messages=messages,
tools=formatted_tools,
temperature=temperature,
stream=True,
output_json=output_json,
output_pydantic=output_pydantic,
**kwargs
)
):
if chunk and chunk.choices and chunk.choices[0].delta:
delta = chunk.choices[0].delta
response_text, tool_calls = self._process_stream_delta(
delta, response_text, tool_calls, formatted_tools
)
final_response = {
"choices": [{
"message": {
"content": response_text,
"tool_calls": tool_calls if tool_calls else None
}
}]
}
else:
# Standard non-streaming path
final_response = litellm.completion(
**self._build_completion_params(
messages=messages,
tools=formatted_tools,
temperature=temperature,
stream=False,
output_json=output_json,
output_pydantic=output_pydantic,
**kwargs
)
)
)
# Handle None content from Gemini
response_content = final_response["choices"][0]["message"].get("content")
response_text = response_content if response_content is not None else ""
@@ -2259,6 +2475,16 @@ Now provide your final answer using this result. Summarize the information natur
# Reset repair count on successful tool call
self._current_repair_count = 0
# Emit intermediate LLM narrative for display
# This fires for ALL responses with text, not just tool-call turns.
# Competitors (Gemini CLI, Codex, OpenCode) display this inline.
if response_text and response_text.strip():
_get_display_functions()['execute_sync_callback'](
'llm_content',
content=response_text.strip(),
agent_name=agent_name,
)
# Handle tool calls - Sequential tool calling logic
if tool_calls and execute_tool_fn:
# Convert tool_calls to a serializable format for all providers
@@ -2301,7 +2527,7 @@ Now provide your final answer using this result. Summarize the information natur
arguments = self._validate_and_filter_ollama_arguments(function_name, arguments, tools)
logging.debug(f"[TOOL_EXEC_DEBUG] About to execute tool {function_name} with args: {arguments}")
tool_result = execute_tool_fn(function_name, arguments)
tool_result = execute_tool_fn(function_name, arguments, tool_call_id=tool_call_id)
logging.debug(f"[TOOL_EXEC_DEBUG] Tool execution result: {tool_result}")
tool_results.append(tool_result) # Store the result
accumulated_tool_results.append(tool_result) # Accumulate across iterations
@@ -2947,6 +3173,15 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
logging.error(f"Non-recoverable stream iterator error: {iterator_error}")
raise iterator_error
# Emit intermediate LLM narrative for display
# (parity with non-streaming path above)
if response_text and response_text.strip():
_get_display_functions()['execute_sync_callback'](
'llm_content',
content=response_text.strip(),
agent_name=agent_name,
)
# After streaming completes, handle tool calls if present
if tool_calls and execute_tool_fn:
# Add assistant message with tool calls to conversation
@@ -2969,8 +3204,8 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call, is_ollama)
try:
# Execute the tool
tool_result = execute_tool_fn(function_name, arguments)
# Execute the tool (pass tool_call_id for event correlation)
tool_result = execute_tool_fn(function_name, arguments, tool_call_id=tool_call_id)
# Add tool result to messages
tool_message = self._create_tool_message(function_name, tool_result, tool_call_id, is_ollama)
@@ -3185,6 +3420,104 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
reasoning_content = None
tool_calls = []
# ── Responses API path (async) ──────────────────────────
if self._supports_responses_api():
responses_params = self._build_responses_params(
messages=messages,
tools=formatted_tools,
temperature=temperature,
output_json=output_json,
output_pydantic=output_pydantic,
**{k: v for k, v in kwargs.items() if k != 'reasoning_steps'},
)
current_time = time.time()
if stream:
response_text, tool_calls = await self._stream_responses_api_async(
responses_params,
stream_callback=kwargs.get('stream_callback'),
emit_events=kwargs.get('emit_events', False),
)
else:
resp = await self._call_responses_api_async(**responses_params)
response_text, tool_calls, _reasoning = self._extract_from_responses_output(resp)
# Build Chat-Completions-compatible final_response
final_response = {
"choices": [{
"message": {
"content": response_text,
"tool_calls": tool_calls,
}
}]
}
# Display / callback handling
if verbose and not interaction_displayed:
_get_display_functions()['display_interaction'](
original_prompt, response_text,
markdown=markdown,
generation_time=time.time() - current_time,
console=self.console,
agent_name=agent_name, agent_role=agent_role,
agent_tools=agent_tools, task_name=task_name,
task_description=task_description, task_id=task_id,
)
interaction_displayed = True
callback_executed = True
# Emit llm_content for narrative display
if response_text and response_text.strip():
_get_display_functions()['execute_sync_callback'](
'llm_content',
content=response_text.strip(),
agent_name=agent_name,
)
# ── Handle tool calls ────────────────────────────
if tool_calls and execute_tool_fn:
serializable_tool_calls = self._serialize_tool_calls(tool_calls)
messages.append({
"role": "assistant",
"content": response_text,
"tool_calls": serializable_tool_calls,
})
for tool_call in tool_calls:
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call)
logging.debug(f"[RESPONSES_API_ASYNC] Executing tool {function_name}")
tool_result = execute_tool_fn(function_name, arguments, tool_call_id=tool_call_id)
accumulated_tool_results.append(tool_result)
if verbose:
display_message = f"Agent {agent_name} called function '{function_name}' with arguments: {arguments}\n"
display_message += f"Function returned: {tool_result}" if tool_result else "Function returned no output"
_get_display_functions()['display_tool_call'](display_message, console=self.console)
# Append tool result
if tool_result is None:
content = "Function returned an empty output"
elif isinstance(tool_result, dict) and 'error' in tool_result:
content = f"Error: {tool_result.get('error', 'Unknown error')}. Please inform the user."
else:
content = json.dumps(tool_result)
messages.append({
"role": "tool",
"tool_call_id": tool_call_id,
"content": content,
})
if iteration_count >= 5:
final_response_text = response_text.strip() if response_text else "Task completed."
break
iteration_count += 1
continue
else:
final_response_text = response_text.strip() if response_text else ""
break
# ── Chat Completions path (unchanged) ───────────────
if reasoning_steps and iteration_count == 0:
# Non-streaming call to capture reasoning
resp = await litellm.acompletion(
@@ -4057,6 +4390,501 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
return params
# ── Responses API support ───────────────────────────────────────────
# These methods add a Responses API call path alongside the existing
# Chat Completions path. Model detection gates which path is used.
def _supports_responses_api(self) -> bool:
"""
Check if the current model supports the OpenAI Responses API.
Returns True for OpenAI models that support the Responses API
(gpt-4o, gpt-4-turbo, o1, o3, chatgpt-4o, etc.).
Returns False for Anthropic, Gemini, Ollama, local, and all other models.
"""
if not self.model:
return False
model_lower = self.model.lower()
# Exclude non-OpenAI providers that happen to use similar model names
if self._is_ollama_provider():
return False
if self._is_gemini_model():
return False
if self._is_anthropic_model():
return False
# Explicit prefix check for OpenAI & Azure OpenAI
# Note: Azure models often start with the deployment name, but LiteLLM
# normalises them to "azure/<model>" – the prefixes below handle both.
_openai_prefixes = (
"gpt-4o", "gpt-4-turbo", "gpt-4.1", "gpt-4.5",
"o1", "o3", "o4",
"chatgpt-4o",
"azure/gpt-4o", "azure/gpt-4-turbo", "azure/gpt-4.1",
"azure/o1", "azure/o3", "azure/o4",
)
# Match on prefix (gpt-4o, gpt-4o-mini, gpt-4o-2024-08-06, etc.)
for prefix in _openai_prefixes:
if model_lower.startswith(prefix):
return True
# Any model routed through openai/ prefix
if model_lower.startswith("openai/"):
remainder = model_lower[len("openai/"):]
for prefix in _openai_prefixes:
if remainder.startswith(prefix):
return True
return False
def _build_responses_params(
self,
messages: List[Dict[str, Any]],
tools: Optional[List[Dict]] = None,
temperature: Optional[float] = None,
**kwargs,
) -> Dict[str, Any]:
"""
Build parameters for ``litellm.responses()`` from the Chat Completions-
style message list that PraisonAI already constructs.
Mapping:
- system message → ``instructions``
- remaining messages → ``input`` (list of role/content items)
- tools → ``tools`` (Responses API uses same function schema)
- temperature, max_tokens, etc. → direct pass-through
"""
params: Dict[str, Any] = {"model": self.model}
# ── Extract system instructions ─────────────────────────────────
instructions = None
input_items: List[Dict[str, Any]] = []
for msg in messages:
role = msg.get("role", "")
if role in ("system", "developer"):
# Accumulate system / developer messages as instructions
content = msg.get("content", "")
if instructions is None:
instructions = content
else:
instructions += "\n" + content
else:
# user / assistant / tool messages become input items
# Special handling for Chat Completions→Responses API format:
if role == "assistant" and msg.get("tool_calls"):
# Assistant message with tool_calls → emit text (if any)
# then emit function_call items for each tool call
content = msg.get("content")
if content and content.strip():
input_items.append({"role": "assistant", "content": content})
for tc in msg["tool_calls"]:
fn = tc.get("function", tc) if isinstance(tc, dict) else tc
fn_name = fn.get("name", "") if isinstance(fn, dict) else getattr(fn, "name", "")
fn_args = fn.get("arguments", "{}") if isinstance(fn, dict) else getattr(fn, "arguments", "{}")
tc_id = tc.get("id", "") if isinstance(tc, dict) else getattr(tc, "id", "")
input_items.append({
"type": "function_call",
"call_id": tc_id,
"name": fn_name,
"arguments": fn_args if isinstance(fn_args, str) else json.dumps(fn_args),
})
elif role == "tool":
# Tool result → function_call_output
input_items.append({
"type": "function_call_output",
"call_id": msg.get("tool_call_id", ""),
"output": msg.get("content", ""),
})
else:
input_items.append(msg)
if instructions:
params["instructions"] = instructions
params["input"] = input_items
# ── Tools ───────────────────────────────────────────────────────
if tools:
# Responses API tool format differs from Chat Completions:
# Chat Completions: {"type": "function", "function": {"name": ..., "parameters": ...}}
# Responses API: {"type": "function", "name": ..., "parameters": ...}
# Transform Chat Completions format → Responses API format
responses_tools = []
for tool in tools:
if isinstance(tool, dict) and "function" in tool:
fn = tool["function"]
responses_tool = {
"type": "function",
"name": fn.get("name", ""),
}
if "description" in fn:
responses_tool["description"] = fn["description"]
if "parameters" in fn:
responses_tool["parameters"] = fn["parameters"]
if fn.get("strict") is not None:
responses_tool["strict"] = fn["strict"]
responses_tools.append(responses_tool)
else:
# Already in Responses API format or unknown — pass through
responses_tools.append(tool)
params["tools"] = responses_tools
if "tool_choice" not in kwargs:
params["tool_choice"] = "auto"
# ── Scalar params ───────────────────────────────────────────────
if temperature is not None:
params["temperature"] = temperature
if self.max_tokens:
params["max_output_tokens"] = self.max_tokens
if self.top_p:
params["top_p"] = self.top_p
if self.base_url:
params["base_url"] = self.base_url
if self.api_key:
params["api_key"] = self.api_key
if self.timeout:
params["timeout"] = self.timeout
# ── Structured output ───────────────────────────────────────────
output_json = kwargs.pop("output_json", None)
output_pydantic = kwargs.pop("output_pydantic", None)
schema_model = output_json or output_pydantic
if schema_model:
from .model_capabilities import supports_structured_outputs
if supports_structured_outputs(self.model):
if hasattr(schema_model, 'model_json_schema'):
schema = schema_model.model_json_schema()
params['text'] = {
"format": {
"type": "json_schema",
"name": getattr(schema_model, '__name__', 'response'),
"schema": schema,
"strict": True,
}
}
# ── Pass through remaining kwargs (stream, etc.) ────────────────
# Filter out internal PraisonAI params that are not Responses API params
_internal = {
'output_json', 'output_pydantic',
'reflection', 'self_reflect', 'max_reflect', 'min_reflect', 'reflect_llm',
'verbose', 'markdown',
'agent_name', 'agent_role', 'agent_tools',
'task_name', 'task_description', 'task_id',
'execute_tool_fn', 'stream_callback', 'emit_events',
'console', 'reasoning_steps',
}
for k, v in kwargs.items():
if k not in _internal and v is not None:
params[k] = v
return params
def _call_responses_api(self, **params) -> Any:
"""
Call ``litellm.responses()`` synchronously with retry support.
Returns a ``ResponsesAPIResponse`` object.
"""
import litellm
return self._call_with_retry(litellm.responses, **params)
async def _call_responses_api_async(self, **params) -> Any:
"""
Call ``litellm.aresponses()`` asynchronously with retry support.
Returns a ``ResponsesAPIResponse`` object.
"""
import litellm
return await self._call_with_retry_async(litellm.aresponses, **params)
def _extract_from_responses_output(self, response) -> tuple:
"""
Parse a ``ResponsesAPIResponse`` into the same (text, tool_calls,
reasoning_content) tuple that the Chat Completions path produces.
The Responses API returns an ``output`` list where:
- items with type="message" contain text in content[].text
- items with type="function_call" contain tool call info
Unlike Chat Completions, text and tool calls are *always* separate
items, so ``content`` is never null when text is present.
"""
response_text = ""
tool_calls: List[Dict[str, Any]] = []
reasoning_content = None
output_items = getattr(response, 'output', None) or []
for item in output_items:
# Handle both dict and object access
if isinstance(item, dict):
item_type = item.get("type", "")
else:
item_type = getattr(item, "type", "")
if item_type == "message":
# Extract text from content blocks
if isinstance(item, dict):
content_list = item.get("content", [])
else:
content_list = getattr(item, "content", [])
for content_block in content_list:
if isinstance(content_block, dict):
block_type = content_block.get("type", "")
block_text = content_block.get("text", "") or ""
else:
block_type = getattr(content_block, "type", "")
block_text = getattr(content_block, "text", "") or ""
if block_type == "output_text":
response_text += block_text
elif block_type == "reasoning":
reasoning_content = (reasoning_content or "") + block_text
elif item_type == "function_call":
# Extract tool call information
if isinstance(item, dict):
call_id = item.get("call_id", item.get("id", f"tool_{len(tool_calls)}"))
fn_name = item.get("name", "")
fn_args = item.get("arguments", "{}")
else:
call_id = getattr(item, "call_id", None) or getattr(item, "id", f"tool_{len(tool_calls)}")
fn_name = getattr(item, "name", "")
fn_args = getattr(item, "arguments", "{}")
tool_calls.append({
"id": call_id,
"type": "function",
"function": {
"name": fn_name,
"arguments": fn_args if isinstance(fn_args, str) else json.dumps(fn_args),
}
})
elif item_type == "reasoning":
# Some models return top-level reasoning items
if isinstance(item, dict):
reasoning_text = item.get("summary", "") or item.get("text", "")
else:
reasoning_text = getattr(item, "summary", "") or getattr(item, "text", "")
if reasoning_text:
reasoning_content = (reasoning_content or "") + str(reasoning_text)
return response_text, tool_calls if tool_calls else None, reasoning_content
def _stream_responses_api(
self,
params: Dict[str, Any],
verbose: bool = True,
stream_callback: Optional[Callable] = None,
emit_events: bool = False,
current_time: Optional[float] = None,
) -> tuple:
"""
Stream a Responses API call, accumulating text and tool calls.
Returns the same (response_text, tool_calls) tuple as the Chat
Completions streaming path. Emits StreamEvents when configured.
"""
import litellm
params["stream"] = True
response_text = ""
# tool_calls accumulator: {output_index: {"id":..., "name":..., "arguments":...}}
_pending_tools: Dict[int, Dict[str, Any]] = {}
_emit = emit_events and stream_callback is not None
if _emit:
from ..streaming.events import StreamEvent, StreamEventType
for event in litellm.responses(**params):
# Access event type — handle both object and dict
if isinstance(event, dict):
evt_type = event.get("type", "")
else:
evt_type = getattr(event, "type", "")
# ── Text delta ──────────────────────────────────────────
if evt_type == "response.output_text.delta":
delta_text = (event.get("delta", "") if isinstance(event, dict)
else getattr(event, "delta", ""))
response_text += delta_text
if _emit and delta_text:
stream_callback(StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=time.perf_counter(),
content=delta_text,
is_reasoning=False,
))
# ── Reasoning delta ─────────────────────────────────────
elif evt_type == "response.reasoning_summary_text.delta":
delta_text = (event.get("delta", "") if isinstance(event, dict)
else getattr(event, "delta", ""))
if _emit and delta_text:
stream_callback(StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=time.perf_counter(),
content=delta_text,
is_reasoning=True,
))
# ── Function call arguments delta ───────────────────────
elif evt_type == "response.function_call_arguments.delta":
idx = (event.get("output_index", 0) if isinstance(event, dict)
else getattr(event, "output_index", 0))
delta_args = (event.get("delta", "") if isinstance(event, dict)
else getattr(event, "delta", ""))
if idx not in _pending_tools:
item_id = (event.get("item_id", f"tool_{idx}") if isinstance(event, dict)
else getattr(event, "item_id", f"tool_{idx}"))
_pending_tools[idx] = {
"id": item_id,
"name": "",
"arguments": "",
}
_pending_tools[idx]["arguments"] += delta_args
# ── Output item done — finalise tool call metadata ──────
elif evt_type == "response.output_item.done":
if isinstance(event, dict):
item = event.get("item", {})
idx = event.get("output_index", 0)
else:
item = getattr(event, "item", None) or {}
idx = getattr(event, "output_index", 0)
if isinstance(item, dict):
i_type = item.get("type", "")
else:
i_type = getattr(item, "type", "")
if i_type == "function_call":
if isinstance(item, dict):
call_id = item.get("call_id", item.get("id", f"tool_{idx}"))
fn_name = item.get("name", "")
fn_args = item.get("arguments", "")
else:
call_id = getattr(item, "call_id", None) or getattr(item, "id", f"tool_{idx}")
fn_name = getattr(item, "name", "")
fn_args = getattr(item, "arguments", "")
if idx in _pending_tools:
_pending_tools[idx]["id"] = call_id
_pending_tools[idx]["name"] = fn_name
# Prefer the accumulated delta args if available, else use final
if not _pending_tools[idx]["arguments"]:
_pending_tools[idx]["arguments"] = fn_args
else:
_pending_tools[idx] = {
"id": call_id,
"name": fn_name,
"arguments": fn_args,
}
# ── Build tool_calls list in output_index order ─────────────
tool_calls: List[Dict[str, Any]] = []
for idx in sorted(_pending_tools.keys()):
tc = _pending_tools[idx]
tool_calls.append({
"id": tc["id"],
"type": "function",
"function": {
"name": tc["name"],
"arguments": tc["arguments"],
},
})
return response_text, tool_calls if tool_calls else None
async def _stream_responses_api_async(
self,
params: Dict[str, Any],
stream_callback: Optional[Callable] = None,
emit_events: bool = False,
) -> tuple:
"""
Async streaming for Responses API.
Returns (response_text, tool_calls).
"""
import litellm
params["stream"] = True
response_text = ""
_pending_tools: Dict[int, Dict[str, Any]] = {}
_emit = emit_events and stream_callback is not None
if _emit:
from ..streaming.events import StreamEvent, StreamEventType
async for event in await litellm.aresponses(**params):
if isinstance(event, dict):
evt_type = event.get("type", "")
else:
evt_type = getattr(event, "type", "")
if evt_type == "response.output_text.delta":
delta_text = (event.get("delta", "") if isinstance(event, dict)
else getattr(event, "delta", ""))
response_text += delta_text
if _emit and delta_text:
stream_callback(StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=time.perf_counter(),
content=delta_text,
is_reasoning=False,
))
elif evt_type == "response.function_call_arguments.delta":
idx = (event.get("output_index", 0) if isinstance(event, dict)
else getattr(event, "output_index", 0))
delta_args = (event.get("delta", "") if isinstance(event, dict)
else getattr(event, "delta", ""))
if idx not in _pending_tools:
item_id = (event.get("item_id", f"tool_{idx}") if isinstance(event, dict)
else getattr(event, "item_id", f"tool_{idx}"))
_pending_tools[idx] = {"id": item_id, "name": "", "arguments": ""}
_pending_tools[idx]["arguments"] += delta_args
elif evt_type == "response.output_item.done":
if isinstance(event, dict):
item = event.get("item", {})
idx = event.get("output_index", 0)
else:
item = getattr(event, "item", None) or {}
idx = getattr(event, "output_index", 0)
if isinstance(item, dict):
i_type = item.get("type", "")
else:
i_type = getattr(item, "type", "")
if i_type == "function_call":
if isinstance(item, dict):
call_id = item.get("call_id", item.get("id", f"tool_{idx}"))
fn_name = item.get("name", "")
fn_args = item.get("arguments", "")
else:
call_id = getattr(item, "call_id", None) or getattr(item, "id", f"tool_{idx}")
fn_name = getattr(item, "name", "")
fn_args = getattr(item, "arguments", "")
if idx in _pending_tools:
_pending_tools[idx]["id"] = call_id
_pending_tools[idx]["name"] = fn_name
if not _pending_tools[idx]["arguments"]:
_pending_tools[idx]["arguments"] = fn_args
else:
_pending_tools[idx] = {"id": call_id, "name": fn_name, "arguments": fn_args}
tool_calls: List[Dict[str, Any]] = []
for idx in sorted(_pending_tools.keys()):
tc = _pending_tools[idx]
tool_calls.append({
"id": tc["id"],
"type": "function",
"function": {"name": tc["name"], "arguments": tc["arguments"]},
})
return response_text, tool_calls if tool_calls else None
def _prepare_response_logging(self, temperature: float, stream: bool, verbose: bool, markdown: bool, **kwargs) -> Optional[Dict[str, Any]]:
"""Prepare debug logging information for response methods"""
if logging.getLogger().getEffectiveLevel() == logging.DEBUG:
@@ -539,6 +539,229 @@ class OpenAIClient:
return result
# ─── Responses API helpers ──────────────────────────────────────────────
@staticmethod
def _supports_responses_api(model: str) -> bool:
"""
Returns True for OpenAI models that support the Responses API.
Mirrors LLM._supports_responses_api but works as a static method
on a plain model string.
"""
if not model:
return False
model_lower = model.lower()
# Exclude non-OpenAI providers
_non_openai = (
"ollama/", "ollama_chat/", "gemini/", "vertex_ai/",
"anthropic/", "claude", "deepseek/",
)
for prefix in _non_openai:
if model_lower.startswith(prefix):
return False
_openai_prefixes = (
"gpt-4o", "gpt-4-turbo", "gpt-4.1", "gpt-4.5",
"gpt-5",
"o1", "o3", "o4",
"chatgpt-4o",
"azure/gpt-4o", "azure/gpt-4-turbo", "azure/gpt-4.1",
"azure/gpt-5",
"azure/o1", "azure/o3", "azure/o4",
)
for prefix in _openai_prefixes:
if model_lower.startswith(prefix):
return True
# openai/ prefix
if model_lower.startswith("openai/"):
remainder = model_lower[len("openai/"):]
for prefix in _openai_prefixes:
if remainder.startswith(prefix):
return True
return False
def _use_responses_api(self, model: str) -> bool:
"""
Returns True if Responses API should be used for this call.
Only returns True when:
1. The model supports Responses API (OpenAI model)
2. No custom base_url is set (not a local server)
3. The openai SDK has the 'responses' attribute
"""
if self.base_url:
return False
if not self._supports_responses_api(model):
return False
try:
return hasattr(self.sync_client, 'responses')
except Exception:
return False
def _build_responses_input(
self,
messages: List[Dict[str, Any]],
model: str,
temperature: float = 1.0,
tools: Optional[List[Dict]] = None,
**kwargs,
) -> Dict[str, Any]:
"""
Transform Chat Completions-style messages and tools into
Responses API parameters.
Mapping:
- system message → ``instructions``
- remaining msgs → ``input`` (list of role/content items)
- tools are flattened from Chat Completions nested format
"""
params: Dict[str, Any] = {"model": model}
# ── Extract system instructions ──────────────────────────────────
instructions = None
input_items: List[Dict[str, Any]] = []
for msg in messages:
role = msg.get("role", "")
if role in ("system", "developer"):
content = msg.get("content", "")
if instructions is None:
instructions = content
else:
instructions += "\n" + content
else:
# Handle Chat Completions → Responses API format transforms
if role == "assistant" and msg.get("tool_calls"):
content = msg.get("content")
if content and content.strip():
input_items.append({"role": "assistant", "content": content})
for tc in msg["tool_calls"]:
fn = tc.get("function", tc) if isinstance(tc, dict) else tc
fn_name = fn.get("name", "") if isinstance(fn, dict) else getattr(fn, "name", "")
fn_args = fn.get("arguments", "{}") if isinstance(fn, dict) else getattr(fn, "arguments", "{}")
tc_id = tc.get("id", "") if isinstance(tc, dict) else getattr(tc, "id", "")
# Skip items with empty name — API rejects them
if not fn_name:
continue
input_items.append({
"type": "function_call",
"call_id": tc_id,
"name": fn_name,
"arguments": fn_args if isinstance(fn_args, str) else json.dumps(fn_args),
})
elif role == "tool":
input_items.append({
"type": "function_call_output",
"call_id": msg.get("tool_call_id", ""),
"output": msg.get("content", ""),
})
else:
input_items.append(msg)
if instructions:
params["instructions"] = instructions
params["input"] = input_items
# ── Transform tools to Responses API format ──────────────────────
if tools:
responses_tools = []
for tool in tools:
if isinstance(tool, dict) and "function" in tool:
fn = tool["function"]
responses_tool = {
"type": "function",
"name": fn.get("name", ""),
}
if "description" in fn:
responses_tool["description"] = fn["description"]
if "parameters" in fn:
responses_tool["parameters"] = fn["parameters"]
if fn.get("strict") is not None:
responses_tool["strict"] = fn["strict"]
responses_tools.append(responses_tool)
else:
responses_tools.append(tool)
params["tools"] = responses_tools
params["tool_choice"] = kwargs.pop("tool_choice", "auto")
# ── Scalar params ────────────────────────────────────────────────
if temperature is not None:
params["temperature"] = temperature
return params
def _responses_to_chat_completion(self, response) -> ChatCompletion:
"""
Wrap a Responses API response into a ChatCompletion dataclass
so that all downstream code (tool loop, display, callbacks)
works without modification.
"""
response_text = ""
tool_calls_list: List[ToolCall] = []
output_items = getattr(response, 'output', None) or []
for item in output_items:
item_type = getattr(item, "type", "") if not isinstance(item, dict) else item.get("type", "")
if item_type == "message":
content_list = getattr(item, "content", []) if not isinstance(item, dict) else item.get("content", [])
for content_block in content_list:
if isinstance(content_block, dict):
block_type = content_block.get("type", "")
block_text = content_block.get("text", "") or ""
else:
block_type = getattr(content_block, "type", "")
block_text = getattr(content_block, "text", "") or ""
if block_type == "output_text":
response_text += block_text
elif item_type == "function_call":
if isinstance(item, dict):
call_id = item.get("call_id", item.get("id", f"tool_{len(tool_calls_list)}"))
fn_name = item.get("name", "")
fn_args = item.get("arguments", "{}")
else:
call_id = getattr(item, "call_id", None) or getattr(item, "id", f"tool_{len(tool_calls_list)}")
fn_name = getattr(item, "name", "")
fn_args = getattr(item, "arguments", "{}")
tool_calls_list.append(ToolCall(
id=call_id,
type="function",
function={
"name": fn_name,
"arguments": fn_args if isinstance(fn_args, str) else json.dumps(fn_args),
}
))
# Build usage
raw_usage = getattr(response, 'usage', None)
usage = None
if raw_usage:
usage = CompletionUsage(
prompt_tokens=getattr(raw_usage, 'input_tokens', 0),
completion_tokens=getattr(raw_usage, 'output_tokens', 0),
total_tokens=getattr(raw_usage, 'total_tokens', 0),
)
message = ChatCompletionMessage(
content=response_text if response_text else None,
role="assistant",
tool_calls=tool_calls_list if tool_calls_list else None,
)
choice = Choice(
finish_reason="tool_calls" if tool_calls_list else "stop",
index=0,
message=message,
)
return ChatCompletion(
id=getattr(response, 'id', 'resp_unknown'),
choices=[choice],
created=int(time.time()),
model=getattr(response, 'model', ''),
usage=usage,
)
def _generate_tool_definition(self, func: Callable) -> Optional[Dict]:
"""Generate a tool definition from a callable function."""
try:
@@ -675,6 +898,44 @@ class OpenAIClient:
metadata={"model": model, "message_count": len(messages)}
))
# ── Responses API path ───────────────────────────────────────
if self._use_responses_api(model):
try:
resp_params = self._build_responses_input(
messages, model, temperature, tools, **kwargs
)
raw = self.sync_client.responses.create(**resp_params)
final_response = self._responses_to_chat_completion(raw)
# Display the response text if display_fn provided
response_text = final_response.choices[0].message.content or ""
if display_fn and response_text:
from rich.markdown import Markdown
console.print(Markdown(response_text))
# Emit stream events for compatibility
if _emit:
now = time.perf_counter()
if response_text:
stream_callback(StreamEvent(
type=StreamEventType.FIRST_TOKEN,
timestamp=now, content=response_text[:50]
))
stream_callback(StreamEvent(
type=StreamEventType.LAST_TOKEN,
timestamp=now
))
stream_callback(StreamEvent(
type=StreamEventType.STREAM_END,
timestamp=now, metadata={"responses_api": True}
))
return final_response
except Exception as e:
self.logger.warning(f"Responses API streaming failed, falling back: {e}")
# Fall through to Chat Completions streaming
# ── Chat Completions streaming path ─────────────────────────
# Create the response stream
response_stream = self.sync_client.chat.completions.create(
model=model,
@@ -897,6 +1158,49 @@ class OpenAIClient:
else:
stream_callback(event)
# ── Responses API path ───────────────────────────────────────
if self._use_responses_api(model):
try:
resp_params = self._build_responses_input(
messages, model, temperature, tools, **kwargs
)
raw = await self.async_client.responses.create(**resp_params)
final_response = self._responses_to_chat_completion(raw)
# Display the response text if display_fn provided
response_text = final_response.choices[0].message.content or ""
if display_fn and response_text:
from rich.markdown import Markdown
console.print(Markdown(response_text))
# Emit stream events for compatibility
if _emit:
now = time.perf_counter()
async def _emit_event(evt):
if asyncio.iscoroutinefunction(stream_callback):
await stream_callback(evt)
else:
stream_callback(evt)
if response_text:
await _emit_event(StreamEvent(
type=StreamEventType.FIRST_TOKEN,
timestamp=now, content=response_text[:50]
))
await _emit_event(StreamEvent(
type=StreamEventType.LAST_TOKEN,
timestamp=now
))
await _emit_event(StreamEvent(
type=StreamEventType.STREAM_END,
timestamp=now, metadata={"responses_api": True}
))
return final_response
except Exception as e:
self.logger.warning(f"Responses API async streaming failed, falling back: {e}")
# Fall through to Chat Completions streaming
# ── Chat Completions streaming path ─────────────────────────
# Create the response stream
response_stream = await self.async_client.chat.completions.create(
model=model,
@@ -1078,6 +1382,20 @@ class OpenAIClient:
Returns:
ChatCompletion object or stream iterator
"""
# ── Responses API path (non-streaming only) ────────────────────
if not stream and self._use_responses_api(model):
try:
resp_params = self._build_responses_input(
messages, model, temperature, tools,
tool_choice=tool_choice, **kwargs
)
raw = self.sync_client.responses.create(**resp_params)
return self._responses_to_chat_completion(raw)
except Exception as e:
self.logger.warning(f"Responses API failed, falling back to Chat Completions: {e}")
# Fall through to Chat Completions
# ── Chat Completions path ─────────────────────────────────────
params = {
"model": model,
"messages": messages,
@@ -1123,6 +1441,20 @@ class OpenAIClient:
Returns:
ChatCompletion object or async stream iterator
"""
# ── Responses API path (non-streaming only) ────────────────────
if not stream and self._use_responses_api(model):
try:
resp_params = self._build_responses_input(
messages, model, temperature, tools,
tool_choice=tool_choice, **kwargs
)
raw = await self.async_client.responses.create(**resp_params)
return self._responses_to_chat_completion(raw)
except Exception as e:
self.logger.warning(f"Responses API failed, falling back to Chat Completions: {e}")
# Fall through to Chat Completions
# ── Chat Completions path ─────────────────────────────────────
params = {
"model": model,
"messages": messages,
@@ -1156,6 +1488,8 @@ class OpenAIClient:
reasoning_steps: bool = False,
verbose: bool = True,
max_iterations: int = 10,
stream_callback: Optional[Callable] = None,
emit_events: bool = False,
**kwargs
) -> Optional[ChatCompletion]:
"""
@@ -1179,6 +1513,8 @@ class OpenAIClient:
reasoning_steps: Whether to show reasoning
verbose: Whether to show verbose output
max_iterations: Maximum tool calling iterations
stream_callback: Optional callback for StreamEvent emission
emit_events: Whether to emit StreamEvents (requires stream_callback)
**kwargs: Additional API parameters
Returns:
@@ -1208,6 +1544,8 @@ class OpenAIClient:
console=console,
display_fn=display_fn,
reasoning_steps=reasoning_steps,
stream_callback=stream_callback,
emit_events=emit_events,
**kwargs
)
else:
@@ -1302,6 +1640,19 @@ class OpenAIClient:
# Check for tool calls
tool_calls = getattr(final_response.choices[0].message, 'tool_calls', None)
# Emit llm_content for intermediate narrative display
# (gpt-4.1+ models produce text alongside tool calls)
response_content = getattr(final_response.choices[0].message, 'content', None)
if response_content and response_content.strip() and tool_calls:
try:
execute_sync_callback(
'llm_content',
content=response_content.strip(),
agent_name=None,
)
except Exception:
pass # Narrative display is optional
if tool_calls and execute_tool_fn:
# Convert ToolCall dataclass objects to dict for JSON serialization
serializable_tool_calls = []
@@ -1335,8 +1686,9 @@ class OpenAIClient:
# Always trigger callback for tool call tracking (even when verbose=False)
display_tool_call_fn = _get_display_tool_call()
# Execute the tool
tool_result = execute_tool_fn(function_name, arguments)
# Execute the tool (pass tool_call_id for event correlation)
_tool_call_id = tool_call.id if hasattr(tool_call, 'id') else tool_call.get('id')
tool_result = execute_tool_fn(function_name, arguments, tool_call_id=_tool_call_id)
results_str = json.dumps(tool_result) if tool_result else "Function returned an empty output"
# Trigger callback with structured parameters for status output
@@ -1377,6 +1729,8 @@ class OpenAIClient:
reasoning_steps: bool = False,
verbose: bool = True,
max_iterations: int = 10,
stream_callback: Optional[Callable] = None,
emit_events: bool = False,
**kwargs
) -> Optional[ChatCompletion]:
"""
@@ -1394,6 +1748,8 @@ class OpenAIClient:
reasoning_steps: Whether to show reasoning
verbose: Whether to show verbose output
max_iterations: Maximum tool calling iterations
stream_callback: Optional callback for StreamEvent emission (can be sync or async)
emit_events: Whether to emit StreamEvents (requires stream_callback)
**kwargs: Additional API parameters
Returns:
@@ -1419,6 +1775,8 @@ class OpenAIClient:
console=console,
display_fn=display_fn,
reasoning_steps=reasoning_steps,
stream_callback=stream_callback,
emit_events=emit_events,
**kwargs
)
else:
@@ -1480,6 +1838,20 @@ class OpenAIClient:
# Check for tool calls
tool_calls = getattr(final_response.choices[0].message, 'tool_calls', None)
# Emit llm_content for intermediate narrative display
# (gpt-4.1+ models produce text alongside tool calls)
response_content = getattr(final_response.choices[0].message, 'content', None)
if response_content and response_content.strip() and tool_calls:
try:
from ..main import execute_sync_callback as _esc
_esc(
'llm_content',
content=response_content.strip(),
agent_name=None,
)
except Exception:
pass # Narrative display is optional
if tool_calls and execute_tool_fn:
# Convert ToolCall dataclass objects to dict for JSON serialization
serializable_tool_calls = []
@@ -1517,15 +1889,16 @@ class OpenAIClient:
if verbose and console:
console.print(f"[dim]Arguments:[/dim] {arguments}")
# Execute the tool (async)
# Execute the tool (async) - pass tool_call_id for event correlation
_tool_call_id = tool_call.id if hasattr(tool_call, 'id') else tool_call.get('id')
if asyncio.iscoroutinefunction(execute_tool_fn):
tool_result = await execute_tool_fn(function_name, arguments)
tool_result = await execute_tool_fn(function_name, arguments, tool_call_id=_tool_call_id)
else:
# Run sync function in executor
loop = asyncio.get_event_loop()
tool_result = await loop.run_in_executor(
None,
lambda: execute_tool_fn(function_name, arguments)
lambda fn=function_name, args=arguments, tcid=_tool_call_id: execute_tool_fn(fn, args, tool_call_id=tcid)
)
results_str = json.dumps(tool_result) if tool_result else "Function returned an empty output"
@@ -1706,9 +2079,10 @@ class OpenAIClient:
if verbose:
yield f"\n[Calling function: {function_name}]"
# Execute the tool with error handling
# Execute the tool with error handling (pass tool_call_id for event correlation)
_tool_call_id = tool_call.id if hasattr(tool_call, 'id') else tool_call.get('id')
try:
tool_result = execute_tool_fn(function_name, arguments)
tool_result = execute_tool_fn(function_name, arguments, tool_call_id=_tool_call_id)
results_str = json.dumps(tool_result) if tool_result else "Function returned an empty output"
except Exception as e:
results_str = f"Error executing function: {str(e)}"
+63 -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
@@ -103,6 +106,20 @@ __all__ = [
'display_self_reflection',
]
# P3/G2: Supported callback types documentation
# Standard callbacks: tool_call, interaction, error, llm_start, llm_end
# Autonomy callbacks (PraisonAI unique):
# - autonomy_iteration: {iteration, max_iterations, stage}
# - autonomy_stage_change: {from_stage, to_stage}
# - autonomy_doom_loop: {iteration, recovery_action}
# - autonomy_complete: {completion_reason, iterations, duration_seconds}
# - retry: {attempt, max_attempts, error, retry_in_seconds}
SUPPORTED_CALLBACK_TYPES = [
'tool_call', 'interaction', 'error', 'llm_start', 'llm_end', 'llm_content',
'autonomy_iteration', 'autonomy_stage_change', 'autonomy_doom_loop', 'autonomy_complete',
'retry',
]
def register_display_callback(display_type: str, callback_fn, is_async: bool = False):
"""Register a synchronous or asynchronous callback function for a specific display type.
@@ -225,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
@@ -254,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):
@@ -268,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}"
@@ -289,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}[/]"
@@ -317,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"))
@@ -386,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)
@@ -405,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()
@@ -456,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()
@@ -487,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()
@@ -527,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))
@@ -540,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))
@@ -567,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))
@@ -581,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))
@@ -596,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:
@@ -245,7 +245,7 @@ class AutoMemory:
def __init__(
self,
memory: "FileMemory",
memory: Any,
enabled: bool = True,
extractor: Optional[AutoMemoryExtractor] = None,
min_importance: float = 0.6,
@@ -80,7 +80,8 @@ class DocsManager:
- Priority-based ordering
"""
DOCS_DIR_NAME = ".praisonai/docs"
from ..paths import DEFAULT_DIR_NAME as _DIR_NAME
DOCS_DIR_NAME = f"{_DIR_NAME}/docs"
SUPPORTED_EXTENSIONS = [".md", ".txt", ".rst"]
# Maximum characters per doc (context window management)
@@ -101,7 +102,11 @@ class DocsManager:
verbose: Verbosity level
"""
self.workspace_path = Path(workspace_path) if workspace_path else Path.cwd()
self.global_docs_path = Path(global_docs_path) if global_docs_path else Path.home() / ".praisonai" / "docs"
if global_docs_path:
self.global_docs_path = Path(global_docs_path)
else:
from ..paths import get_docs_dir
self.global_docs_path = get_docs_dir()
self.verbose = verbose
self._docs: Dict[str, Doc] = {}
@@ -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
@@ -140,7 +141,8 @@ class FileMemory:
if base_path:
self.base_path = Path(base_path)
else:
self.base_path = Path(".praisonai/memory")
from ..paths import get_project_data_dir
self.base_path = get_project_data_dir() / "memory"
self.user_path = self.base_path / user_id
self.episodic_path = self.user_path / "episodic"
@@ -159,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] = {}
@@ -317,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 = []
@@ -389,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
@@ -443,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."""
@@ -630,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({
@@ -641,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({
@@ -652,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))
@@ -793,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
@@ -815,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
@@ -851,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:
"""
@@ -869,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:
"""
@@ -887,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:
"""
@@ -1055,47 +1079,100 @@ 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
# -------------------------------------------------------------------------
def store_short_term(
self,
text: str,
metadata: Optional[Dict[str, Any]] = None,
**kwargs
) -> str:
"""Protocol-compliant alias for add_short_term."""
return self.add_short_term(text, metadata)
def store_long_term(
self,
text: str,
metadata: Optional[Dict[str, Any]] = None,
**kwargs
) -> str:
"""Protocol-compliant alias for add_long_term."""
return self.add_long_term(text, metadata)
def search_short_term(
self,
query: str,
limit: int = 5,
**kwargs
) -> List[Dict[str, Any]]:
"""Protocol-compliant alias for search (short_term only)."""
return self.search(query, memory_types=["short_term"], limit=limit)
def search_long_term(
self,
query: str,
limit: int = 5,
**kwargs
) -> List[Dict[str, Any]]:
"""Protocol-compliant alias for search (long_term only)."""
return self.search(query, memory_types=["long_term"], limit=limit)
def get_all_memories(self, **kwargs) -> List[Dict[str, Any]]:
"""Get all memories from short-term and long-term stores."""
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
@@ -1125,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
@@ -1165,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}'")
@@ -1237,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.
@@ -1259,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")
@@ -1517,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,
@@ -103,7 +103,8 @@ class HooksManager:
```
"""
CONFIG_FILE = ".praisonai/hooks.json"
from ..paths import DEFAULT_DIR_NAME as _DIR_NAME
CONFIG_FILE = f"{_DIR_NAME}/hooks.json"
def __init__(
self,
@@ -384,7 +385,8 @@ class HooksManager:
timeout: Global timeout in seconds
enabled: Whether hooks are enabled
"""
config_dir = self.workspace_path / ".praisonai"
from ..paths import DEFAULT_DIR_NAME
config_dir = self.workspace_path / DEFAULT_DIR_NAME
config_dir.mkdir(parents=True, exist_ok=True)
config = {
@@ -5,23 +5,30 @@ Provides learning capabilities for agents to capture patterns, preferences,
and insights from interactions to improve future responses.
Usage:
from praisonaiagents import Agent, MemoryConfig, LearnConfig
from praisonaiagents import Agent, LearnConfig
# Simple enable
agent = Agent(memory=MemoryConfig(learn=True))
# Simple enable (top-level param)
agent = Agent(learn=True)
# With specific capabilities
agent = Agent(memory=MemoryConfig(
learn=LearnConfig(
persona=True,
insights=True,
patterns=True,
)
# With specific capabilities and auto-learning
agent = Agent(learn=LearnConfig(
persona=True,
insights=True,
patterns=True,
mode="agentic", # Auto-extract learnings
))
# With database backend
agent = Agent(learn=LearnConfig(
backend="sqlite",
db_url="sqlite:///learn.db",
))
"""
__all__ = [
# Manager
"LearnManager",
# Stores
"PersonaStore",
"InsightStore",
"ThreadStore",
@@ -29,14 +36,22 @@ __all__ = [
"DecisionStore",
"FeedbackStore",
"ImprovementStore",
# Protocols
"LearnMode",
"LearnProtocol",
"AsyncLearnProtocol",
"LearnManagerProtocol",
]
def __getattr__(name: str):
"""Lazy loading for learn module components."""
# Manager
if name == "LearnManager":
from .manager import LearnManager
return LearnManager
# Stores
if name == "PersonaStore":
from .stores import PersonaStore
return PersonaStore
@@ -58,4 +73,19 @@ def __getattr__(name: str):
if name == "ImprovementStore":
from .stores import ImprovementStore
return ImprovementStore
# Protocols
if name == "LearnMode":
from .protocols import LearnMode
return LearnMode
if name == "LearnProtocol":
from .protocols import LearnProtocol
return LearnProtocol
if name == "AsyncLearnProtocol":
from .protocols import AsyncLearnProtocol
return AsyncLearnProtocol
if name == "LearnManagerProtocol":
from .protocols import LearnManagerProtocol
return LearnManagerProtocol
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -6,8 +6,9 @@ capturing and retrieving learned information.
"""
from typing import Any, Dict, List, Optional
import logging
from ...config.feature_configs import LearnConfig, LearnScope
from ...config.feature_configs import LearnConfig, LearnScope, LearnBackend
from .stores import (
PersonaStore,
InsightStore,
@@ -43,65 +44,163 @@ class LearnManager:
config: Optional[LearnConfig] = None,
user_id: Optional[str] = None,
store_path: Optional[str] = None,
custom_stores: Optional[Dict[str, Any]] = None,
):
"""
Initialize LearnManager.
Args:
config: LearnConfig for enabling/disabling learning capabilities
user_id: User identifier for scoping learnings
store_path: Custom path for storing learning data
custom_stores: Optional dict of custom store implementations.
Keys are store names (e.g., "domain", "skills").
Values must implement LearnProtocol (add, search, list_all, etc.)
Example:
```python
class MyDomainStore:
def add(self, content, metadata=None): ...
def search(self, query, limit=10): ...
def list_all(self, limit=100): ...
def get(self, entry_id): ...
def update(self, entry_id, content, metadata=None): ...
def delete(self, entry_id): ...
def clear(self): ...
manager = LearnManager(
config=LearnConfig(persona=True),
custom_stores={"domain": MyDomainStore()}
)
```
"""
self.config = config or LearnConfig()
self.user_id = user_id or "default"
self.store_path = store_path or self.config.store_path
# Resolve backend from config
self._backend = self._create_backend()
if self._backend is not None:
logging.info(
f"LearnManager using {self.config.backend} backend"
)
scope = self.config.scope
if isinstance(scope, LearnScope):
scope = scope.value
self._stores: Dict[str, Any] = {}
self._custom_stores = custom_stores or {}
if self.config.persona:
# Built-in stores are added first, custom stores can override them
if self.config.persona and "persona" not in self._custom_stores:
self._stores["persona"] = PersonaStore(
store_path=self._get_store_path("persona"),
user_id=self.user_id,
scope=scope,
backend=self._backend,
)
if self.config.insights:
if self.config.insights and "insights" not in self._custom_stores:
self._stores["insights"] = InsightStore(
store_path=self._get_store_path("insights"),
user_id=self.user_id,
scope=scope,
backend=self._backend,
)
if self.config.thread:
if self.config.thread and "threads" not in self._custom_stores:
self._stores["threads"] = ThreadStore(
store_path=self._get_store_path("threads"),
user_id=self.user_id,
scope=scope,
backend=self._backend,
)
if self.config.patterns:
if self.config.patterns and "patterns" not in self._custom_stores:
self._stores["patterns"] = PatternStore(
store_path=self._get_store_path("patterns"),
user_id=self.user_id,
scope=scope,
backend=self._backend,
)
if self.config.decisions:
if self.config.decisions and "decisions" not in self._custom_stores:
self._stores["decisions"] = DecisionStore(
store_path=self._get_store_path("decisions"),
user_id=self.user_id,
scope=scope,
backend=self._backend,
)
if self.config.feedback:
if self.config.feedback and "feedback" not in self._custom_stores:
self._stores["feedback"] = FeedbackStore(
store_path=self._get_store_path("feedback"),
user_id=self.user_id,
scope=scope,
backend=self._backend,
)
if self.config.improvements:
if self.config.improvements and "improvements" not in self._custom_stores:
self._stores["improvements"] = ImprovementStore(
store_path=self._get_store_path("improvements"),
user_id=self.user_id,
scope=scope,
backend=self._backend,
)
# Add custom stores last (they override built-in stores)
if self._custom_stores:
for name, store in self._custom_stores.items():
self._stores[name] = store
# PROPOSE mode: pending learnings queue
self._pending: List[LearnEntry] = []
def _create_backend(self) -> Any:
"""Create a storage backend from config.
Returns backend instance or None for FILE (default) backend.
Falls back to None (FILE) on error.
"""
backend = self.config.backend
backend_str = backend.value if isinstance(backend, LearnBackend) else str(backend)
if backend_str == "file":
return None # FILE uses BaseJSONStore default
if backend_str == "sqlite":
try:
from ...storage.backends import SQLiteBackend
db_path = self.config.db_url or f"{self.store_path or 'learn'}/learn.db"
# Strip sqlite:/// prefix if present
if db_path.startswith("sqlite:///"):
db_path = db_path[len("sqlite:///"):]
return SQLiteBackend(db_path=db_path, table_name="learn_store")
except Exception as e:
logging.warning(f"Failed to create SQLite backend: {e}. Falling back to FILE.")
return None
if backend_str == "redis":
try:
from ...storage.backends import RedisBackend
url = self.config.db_url or "redis://localhost:6379"
return RedisBackend(url=url, prefix="praison:learn:")
except Exception as e:
logging.warning(f"Failed to create Redis backend: {e}. Falling back to FILE.")
return None
if backend_str == "mongodb":
try:
from ...storage.backends import MongoDBBackend
url = self.config.db_url or "mongodb://localhost:27017/"
return MongoDBBackend(url=url, collection="praison_learn")
except Exception as e:
logging.warning(f"Failed to create MongoDB backend: {e}. Falling back to FILE.")
return None
logging.warning(f"Unknown learn backend '{backend_str}'. Falling back to FILE.")
return None
def _get_store_path(self, store_name: str) -> Optional[str]:
"""Get path for a specific store."""
@@ -216,6 +315,88 @@ class LearnManager:
for store_name, store in self._stores.items()
}
@property
def pending_learnings(self) -> List[LearnEntry]:
"""Get list of pending learnings awaiting approval (PROPOSE mode)."""
return list(self._pending)
def add_pending(
self,
content: str,
category: str = "persona",
metadata: Optional[Dict[str, Any]] = None,
) -> LearnEntry:
"""Add a learning to the pending queue for user approval.
Args:
content: The learning content
category: Store category (persona, insights, patterns, etc.)
metadata: Optional metadata
Returns:
The pending LearnEntry
"""
from datetime import datetime
entry_id = f"pending_{category}_{len(self._pending)}_{datetime.utcnow().timestamp()}"
entry = LearnEntry(
id=entry_id,
content=content,
metadata={**(metadata or {}), "category": category, "status": "pending"},
)
self._pending.append(entry)
return entry
def approve_learning(self, entry_id: str) -> bool:
"""Approve a pending learning — move it to the appropriate store.
Args:
entry_id: ID of the pending learning to approve
Returns:
True if approved and stored, False if not found
"""
for i, entry in enumerate(self._pending):
if entry.id == entry_id:
category = entry.metadata.get("category", "persona")
# Route to appropriate store
if category in self._stores:
self._stores[category].add(entry.content, entry.metadata)
elif "persona" in self._stores:
self._stores["persona"].add(entry.content, entry.metadata)
self._pending.pop(i)
return True
return False
def reject_learning(self, entry_id: str) -> bool:
"""Reject a pending learning — discard it.
Args:
entry_id: ID of the pending learning to reject
Returns:
True if found and rejected, False if not found
"""
for i, entry in enumerate(self._pending):
if entry.id == entry_id:
self._pending.pop(i)
return True
return False
def approve_all_learnings(self) -> int:
"""Approve all pending learnings at once.
Returns:
Number of learnings approved
"""
count = 0
while self._pending:
entry = self._pending[0]
if self.approve_learning(entry.id):
count += 1
else:
break # Safety
return count
def to_system_prompt_context(self) -> str:
"""
Generate context suitable for injection into system prompt.
@@ -275,3 +456,211 @@ class LearnManager:
# Note: threads are session-specific and not included in system prompt
return "\n".join(parts) if parts else ""
def process_conversation(
self,
messages: List[Dict[str, Any]],
llm: Optional[str] = None,
extract_only: bool = False,
) -> Dict[str, Any]:
"""
Process a conversation to extract learnings automatically.
Uses LLM to analyze the conversation and extract:
- User preferences (persona)
- Insights and observations
- Patterns in user behavior
Args:
messages: Conversation messages (list of {"role": ..., "content": ...})
llm: Optional LLM model to use for extraction (defaults to gpt-4o-mini)
Returns:
Dictionary with extracted learnings:
{
"persona": [...],
"insights": [...],
"patterns": [...],
"stored": {"persona": N, "insights": N, "patterns": N}
}
"""
if not messages:
return {"persona": [], "insights": [], "patterns": [], "stored": {}}
# Build conversation text
conversation_text = "\n".join([
f"{msg.get('role', 'unknown')}: {msg.get('content', '')}"
for msg in messages
])
# Use LLM to extract learnings
extraction_prompt = f"""Analyze this conversation and extract learnings.
CONVERSATION:
{conversation_text}
Extract the following (if present):
1. USER PREFERENCES: Things the user likes, dislikes, prefers, or their style
2. INSIGHTS: Observations about the user's domain, work, or context
3. PATTERNS: Recurring behaviors or request patterns
Return JSON:
{{
"persona": ["preference 1", "preference 2"],
"insights": ["insight 1", "insight 2"],
"patterns": ["pattern 1", "pattern 2"]
}}
Only include items that are clearly evident from the conversation.
Return empty arrays if nothing is found for a category."""
try:
# Lazy import to avoid circular dependency
from ...llm import LLM
import json
model = llm or "gpt-4o-mini"
llm_instance = LLM(model=model)
response = llm_instance.get_response(
prompt=extraction_prompt,
output_json=True,
)
# Parse response - handle both string and dict responses
if isinstance(response, str):
extracted = json.loads(response)
elif isinstance(response, dict):
extracted = response
else:
extracted = {"persona": [], "insights": [], "patterns": []}
# Store extracted learnings (unless extract_only)
stored = {"persona": 0, "insights": 0, "patterns": 0}
if not extract_only:
for preference in extracted.get("persona", []):
if preference and "persona" in self._stores:
self.capture_persona(preference)
stored["persona"] += 1
for insight in extracted.get("insights", []):
if insight and "insights" in self._stores:
self.capture_insight(insight, source="auto_extraction")
stored["insights"] += 1
for pattern in extracted.get("patterns", []):
if pattern and "patterns" in self._stores:
self.capture_pattern(pattern, pattern_type="auto_extracted")
stored["patterns"] += 1
return {
"persona": extracted.get("persona", []),
"insights": extracted.get("insights", []),
"patterns": extracted.get("patterns", []),
"stored": stored,
}
except Exception as e:
import logging
logging.warning(f"Failed to extract learnings: {e}")
return {"persona": [], "insights": [], "patterns": [], "stored": {}, "error": str(e)}
async def aprocess_conversation(
self,
messages: List[Dict[str, Any]],
llm: Optional[str] = None,
) -> Dict[str, Any]:
"""
Async version of process_conversation.
Args:
messages: Conversation messages
llm: Optional LLM model
Returns:
Dictionary with extracted learnings
"""
if not messages:
return {"persona": [], "insights": [], "patterns": [], "stored": {}}
# Build conversation text
conversation_text = "\n".join([
f"{msg.get('role', 'unknown')}: {msg.get('content', '')}"
for msg in messages
])
extraction_prompt = f"""Analyze this conversation and extract learnings.
CONVERSATION:
{conversation_text}
Extract the following (if present):
1. USER PREFERENCES: Things the user likes, dislikes, prefers, or their style
2. INSIGHTS: Observations about the user's domain, work, or context
3. PATTERNS: Recurring behaviors or request patterns
Return JSON:
{{
"persona": ["preference 1", "preference 2"],
"insights": ["insight 1", "insight 2"],
"patterns": ["pattern 1", "pattern 2"]
}}
Only include items that are clearly evident from the conversation.
Return empty arrays if nothing is found for a category."""
try:
# Lazy import to avoid circular dependency
from ...llm import LLM
import json
import asyncio
model = llm or "gpt-4o-mini"
llm_instance = LLM(model=model)
# Run sync LLM call in executor for async compatibility
loop = asyncio.get_event_loop()
response = await loop.run_in_executor(
None,
lambda: llm_instance.get_response(
prompt=extraction_prompt,
output_json=True,
)
)
# Parse response - handle both string and dict responses
if isinstance(response, str):
extracted = json.loads(response)
elif isinstance(response, dict):
extracted = response
else:
extracted = {"persona": [], "insights": [], "patterns": []}
stored = {"persona": 0, "insights": 0, "patterns": 0}
for preference in extracted.get("persona", []):
if preference and "persona" in self._stores:
self.capture_persona(preference)
stored["persona"] += 1
for insight in extracted.get("insights", []):
if insight and "insights" in self._stores:
self.capture_insight(insight, source="auto_extraction")
stored["insights"] += 1
for pattern in extracted.get("patterns", []):
if pattern and "patterns" in self._stores:
self.capture_pattern(pattern, pattern_type="auto_extracted")
stored["patterns"] += 1
return {
"persona": extracted.get("persona", []),
"insights": extracted.get("insights", []),
"patterns": extracted.get("patterns", []),
"stored": stored,
}
except Exception as e:
import logging
logging.warning(f"Failed to extract learnings: {e}")
return {"persona": [], "insights": [], "patterns": [], "stored": {}, "error": str(e)}
@@ -0,0 +1,266 @@
"""
Learn Protocol Definitions.
Provides Protocol interfaces for learning implementations.
Enables custom learning backends and extensibility.
"""
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
# Re-export LearnMode from feature_configs (canonical location)
# This avoids duplication while maintaining backward compatibility
from ...config.feature_configs import LearnMode
@runtime_checkable
class LearnProtocol(Protocol):
"""
Protocol for learning store implementations.
Enables custom backends (file, SQLite, Redis, MongoDB, etc.)
while maintaining a consistent interface.
Example:
```python
class RedisLearnStore:
def add(self, content: str, metadata=None) -> Dict[str, Any]:
# Store in Redis
return {"id": "...", "content": content}
def search(self, query: str, limit: int = 10) -> List[Dict[str, Any]]:
# Search Redis
return []
def list_all(self, limit: int = 100) -> List[Dict[str, Any]]:
return []
def delete(self, entry_id: str) -> bool:
return True
def clear(self) -> int:
return 0
```
"""
def add(
self,
content: str,
metadata: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
Add a new entry to the store.
Args:
content: The content to store
metadata: Optional metadata dictionary
Returns:
Dictionary with entry details including 'id'
"""
...
def search(
self,
query: str,
limit: int = 10,
) -> List[Dict[str, Any]]:
"""
Search entries by query.
Args:
query: Search query string
limit: Maximum results to return
Returns:
List of matching entries
"""
...
def list_all(
self,
limit: int = 100,
) -> List[Dict[str, Any]]:
"""
List all entries.
Args:
limit: Maximum entries to return
Returns:
List of all entries (most recent first)
"""
...
def get(
self,
entry_id: str,
) -> Optional[Dict[str, Any]]:
"""
Get entry by ID.
Args:
entry_id: Entry identifier
Returns:
Entry dict or None if not found
"""
...
def update(
self,
entry_id: str,
content: str,
metadata: Optional[Dict[str, Any]] = None,
) -> Optional[Dict[str, Any]]:
"""
Update an existing entry.
Args:
entry_id: Entry identifier
content: New content
metadata: Optional metadata to merge
Returns:
Updated entry or None if not found
"""
...
def delete(
self,
entry_id: str,
) -> bool:
"""
Delete an entry.
Args:
entry_id: Entry identifier
Returns:
True if deleted, False if not found
"""
...
def clear(self) -> int:
"""
Clear all entries.
Returns:
Number of entries cleared
"""
...
@runtime_checkable
class AsyncLearnProtocol(Protocol):
"""
Async Protocol for learning store implementations.
For backends that support async operations.
"""
async def aadd(
self,
content: str,
metadata: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Async add entry."""
...
async def asearch(
self,
query: str,
limit: int = 10,
) -> List[Dict[str, Any]]:
"""Async search entries."""
...
async def alist_all(
self,
limit: int = 100,
) -> List[Dict[str, Any]]:
"""Async list all entries."""
...
async def aget(
self,
entry_id: str,
) -> Optional[Dict[str, Any]]:
"""Async get entry by ID."""
...
async def aupdate(
self,
entry_id: str,
content: str,
metadata: Optional[Dict[str, Any]] = None,
) -> Optional[Dict[str, Any]]:
"""Async update entry."""
...
async def adelete(
self,
entry_id: str,
) -> bool:
"""Async delete entry."""
...
async def aclear(self) -> int:
"""Async clear all entries."""
...
@runtime_checkable
class LearnManagerProtocol(Protocol):
"""
Protocol for LearnManager implementations.
Defines the interface for managing learning across multiple stores.
"""
def capture_persona(self, content: str, category: str = "general") -> Optional[Dict[str, Any]]:
"""Capture user preference or profile information."""
...
def capture_insight(self, content: str, source: str = "interaction") -> Optional[Dict[str, Any]]:
"""Capture an observation or learning."""
...
def capture_pattern(self, pattern: str, pattern_type: str = "general") -> Optional[Dict[str, Any]]:
"""Capture a reusable knowledge pattern."""
...
def get_learning_context(self, limit_per_store: int = 5) -> Dict[str, List[Dict[str, Any]]]:
"""Get learning context from all enabled stores."""
...
def search(self, query: str, limit: int = 10) -> Dict[str, List[Dict[str, Any]]]:
"""Search across all enabled stores."""
...
def to_system_prompt_context(self) -> str:
"""Generate context for system prompt injection."""
...
def process_conversation(
self,
messages: List[Dict[str, Any]],
llm: Optional[str] = None,
) -> Dict[str, Any]:
"""
Process a conversation to extract learnings automatically.
Args:
messages: Conversation messages
llm: Optional LLM model to use for extraction
Returns:
Dictionary with extracted learnings
"""
...
__all__ = [
'LearnMode',
'LearnProtocol',
'AsyncLearnProtocol',
'LearnManagerProtocol',
]
@@ -73,12 +73,23 @@ class BaseStore(ABC):
self._backend = backend
self.store_path = store_path or self._default_path()
self._entries: Dict[str, LearnEntry] = {}
self._was_updated: bool = False
# DRY: Use BaseJSONStore for thread-safe storage
self._store = BaseJSONStore(
storage_path=self.store_path,
backend=backend,
)
# Fall back to FILE backend if connection fails (e.g., Redis unavailable)
try:
self._store = BaseJSONStore(
storage_path=self.store_path,
backend=backend,
)
except Exception as e:
import logging
logging.warning(f"Failed to connect to backend: {e}. Falling back to FILE.")
self._backend = None
self._store = BaseJSONStore(
storage_path=self.store_path,
backend=None,
)
self._load()
@property
@@ -87,6 +98,15 @@ class BaseStore(ABC):
"""Name of the store (used for file naming)."""
pass
@property
def was_updated(self) -> bool:
"""Whether this store has been modified since last reset."""
return self._was_updated
def reset_updated(self) -> None:
"""Reset the was_updated flag."""
self._was_updated = False
def _default_path(self) -> str:
"""Default storage path (uses centralized paths - DRY)."""
from ...paths import get_learn_dir, ensure_dir
@@ -106,7 +126,24 @@ class BaseStore(ABC):
self._store.save({k: v.to_dict() for k, v in self._entries.items()})
def add(self, content: str, metadata: Optional[Dict[str, Any]] = None) -> LearnEntry:
"""Add a new entry."""
"""Add a new entry with deduplication.
Checks for exact content matches to prevent duplicate learnings.
If a duplicate is found, returns the existing entry instead of creating a new one.
"""
# Deduplication: Check for exact content match
content_normalized = content.strip().lower()
for existing_entry in self._entries.values():
if existing_entry.content.strip().lower() == content_normalized:
# Update metadata if provided, but don't create duplicate
if metadata:
existing_entry.metadata.update(metadata)
existing_entry.updated_at = datetime.utcnow().isoformat()
self._save()
self._was_updated = True
return existing_entry
# No duplicate found, create new entry
entry_id = f"{self.store_name}_{len(self._entries)}_{datetime.utcnow().timestamp()}"
entry = LearnEntry(
id=entry_id,
@@ -115,6 +152,7 @@ class BaseStore(ABC):
)
self._entries[entry_id] = entry
self._save()
self._was_updated = True
return entry
def get(self, entry_id: str) -> Optional[LearnEntry]:
@@ -146,6 +184,7 @@ class BaseStore(ABC):
entry.metadata.update(metadata)
entry.updated_at = datetime.utcnow().isoformat()
self._save()
self._was_updated = True
return entry
def delete(self, entry_id: str) -> bool:
@@ -153,6 +192,7 @@ class BaseStore(ABC):
if entry_id in self._entries:
del self._entries[entry_id]
self._save()
self._was_updated = True
return True
return False
@@ -161,6 +201,7 @@ class BaseStore(ABC):
count = len(self._entries)
self._entries = {}
self._save()
self._was_updated = True
return count
@@ -113,7 +113,8 @@ class MCPConfigManager:
- Easy server management
"""
MCP_DIR_NAME = ".praisonai/mcp"
from ..paths import DEFAULT_DIR_NAME as _DIR_NAME
MCP_DIR_NAME = f"{_DIR_NAME}/mcp"
SUPPORTED_EXTENSIONS = [".json", ".mcpjson"]
def __init__(
@@ -131,7 +132,11 @@ class MCPConfigManager:
verbose: Verbosity level
"""
self.workspace_path = Path(workspace_path) if workspace_path else Path.cwd()
self.global_mcp_path = Path(global_mcp_path) if global_mcp_path else Path.home() / ".praisonai" / "mcp"
if global_mcp_path:
self.global_mcp_path = Path(global_mcp_path)
else:
from ..paths import get_mcp_dir
self.global_mcp_path = get_mcp_dir()
self.verbose = verbose
self._configs: Dict[str, MCPConfig] = {}
@@ -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)
@@ -226,15 +282,17 @@ class Memory:
self.embedding_dimensions = self._get_embedding_dimensions(self.embedding_model)
self._log_verbose(f"Using embedding dimensions: {self.embedding_dimensions}")
# Create .praisonai directory if it doesn't exist
os.makedirs(".praisonai", exist_ok=True)
# Create project data directory if it doesn't exist
from ..paths import get_project_data_dir
_project_data = str(get_project_data_dir())
os.makedirs(_project_data, exist_ok=True)
# Short-term DB
self.short_db = self.cfg.get("short_db", ".praisonai/short_term.db")
self.short_db = self.cfg.get("short_db", os.path.join(_project_data, "short_term.db"))
self._init_stm()
# Long-term DB
self.long_db = self.cfg.get("long_db", ".praisonai/long_term.db")
self.long_db = self.cfg.get("long_db", os.path.join(_project_data, "long_term.db"))
self._init_ltm()
# Conditionally init Mem0, MongoDB, or local RAG
@@ -245,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:
@@ -274,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 (
@@ -285,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 (
@@ -301,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."""
@@ -577,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}")
@@ -711,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:
@@ -737,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
@@ -811,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}")
@@ -1000,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 "{}")
@@ -1049,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.
@@ -1083,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)
@@ -1124,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)
@@ -1788,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 "{}")
@@ -1804,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 "{}")
@@ -1871,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
@@ -40,6 +40,9 @@ class MemoryProtocol(Protocol):
def search_long_term(self, query: str, limit: int = 5, **kwargs):
return self._data[:limit]
def get_all_memories(self, **kwargs):
return self._data
# Use in agent
memory: MemoryProtocol = InMemoryStore()
@@ -122,6 +125,15 @@ class MemoryProtocol(Protocol):
"""
...
def get_all_memories(self, **kwargs) -> List[Dict[str, Any]]:
"""
Get all memories from all backends.
Returns:
List of all memory entries (short-term and long-term combined)
"""
...
@runtime_checkable
class ResettableMemoryProtocol(MemoryProtocol, Protocol):
@@ -122,7 +122,8 @@ class RulesManager:
```
"""
RULES_DIR_NAME = ".praisonai/rules"
from ..paths import DEFAULT_DIR_NAME as _DIR_NAME
RULES_DIR_NAME = f"{_DIR_NAME}/rules"
SUPPORTED_EXTENSIONS = [".md", ".mdc", ".txt"]
# Root-level instruction files (like Claude, Codex, Gemini, Cursor, Windsurf)
@@ -165,7 +166,11 @@ class RulesManager:
verbose: Verbosity level
"""
self.workspace_path = Path(workspace_path) if workspace_path else Path.cwd()
self.global_rules_path = Path(global_rules_path) if global_rules_path else Path.home() / ".praisonai" / "rules"
if global_rules_path:
self.global_rules_path = Path(global_rules_path)
else:
from ..paths import get_rules_dir
self.global_rules_path = get_rules_dir()
self.verbose = verbose
self._rules: Dict[str, Rule] = {}
@@ -782,7 +782,8 @@ class WorkflowManager:
```
"""
WORKFLOWS_DIR = ".praisonai/workflows"
from ..paths import DEFAULT_DIR_NAME as _DIR_NAME
WORKFLOWS_DIR = f"{_DIR_NAME}/workflows"
SUPPORTED_EXTENSIONS = [".md", ".yaml", ".yml"]
def __init__(
@@ -1857,7 +1858,8 @@ class WorkflowManager:
def _get_checkpoints_dir(self) -> Path:
"""Get the checkpoints directory path."""
checkpoints_dir = self.workspace_path / ".praisonai" / "checkpoints"
from ..paths import DEFAULT_DIR_NAME
checkpoints_dir = self.workspace_path / DEFAULT_DIR_NAME / "checkpoints"
checkpoints_dir.mkdir(parents=True, exist_ok=True)
return checkpoints_dir
@@ -49,6 +49,15 @@ __all__ = [
"disable_trace_output",
"is_trace_output_enabled",
"get_trace_output",
# Editor output (for editor preset - beginner-friendly numbered steps)
"EditorOutput",
"enable_editor_output",
"disable_editor_output",
"is_editor_output_enabled",
"get_editor_output",
"TOOL_LABELS",
"BlockType",
"DisplayBlock",
]
@@ -112,4 +121,37 @@ def __getattr__(name: str):
from .trace import get_trace_output
return get_trace_output
# Editor output (for editor preset - beginner-friendly numbered steps)
if name == "EditorOutput":
from .editor import EditorOutput
return EditorOutput
if name == "enable_editor_output":
from .editor import enable_editor_output
return enable_editor_output
if name == "disable_editor_output":
from .editor import disable_editor_output
return disable_editor_output
if name == "is_editor_output_enabled":
from .editor import is_editor_output_enabled
return is_editor_output_enabled
if name == "get_editor_output":
from .editor import get_editor_output
return get_editor_output
if name == "TOOL_LABELS":
from .editor import TOOL_LABELS
return TOOL_LABELS
if name == "BlockType":
from .editor import BlockType
return BlockType
if name == "DisplayBlock":
from .editor import DisplayBlock
return DisplayBlock
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -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"]
@@ -80,6 +80,7 @@ class StatusOutput:
use_color: bool = True,
show_timestamps: bool = True, # NEW: control timestamp display
show_metrics: bool = False, # Enable metrics display for debug mode
show_tool_output: bool = True, # Show tool result output inline
):
self._file = file or sys.stderr # Use stderr to not interfere with agent output
self._format = format
@@ -87,6 +88,7 @@ class StatusOutput:
self._use_color = use_color
self._show_timestamps = show_timestamps
self._show_metrics = show_metrics
self._show_tool_output = show_tool_output
self._console = None
self._tool_start_times: Dict[str, float] = {}
self._lock = threading.Lock() # Per-sink lock for thread safety
@@ -120,12 +122,12 @@ class StatusOutput:
parts = []
for k, v in (redacted or {}).items():
if isinstance(v, str):
v_str = f'"{_truncate(v, 30)}"'
v_str = f'"{_truncate(v, 80)}"'
else:
v_str = _truncate(str(v), 30)
v_str = _truncate(str(v), 80)
parts.append(f"{k}={v_str}")
return _truncate(", ".join(parts), 80)
return _truncate(", ".join(parts), 200)
def agent_start(self, agent_name: str) -> None:
"""Record agent start."""
@@ -185,8 +187,8 @@ class StatusOutput:
"""Record LLM call end with optional metrics for debug mode."""
ts = time.time()
# Use latency_ms if provided, otherwise calculate from start time
if latency_ms is not None:
# Use latency_ms if provided and positive, otherwise calculate from start time
if latency_ms is not None and latency_ms > 0:
duration_ms = latency_ms
elif duration_ms is None and hasattr(self, '_llm_start_time'):
start_ts = self._llm_start_time
@@ -267,9 +269,13 @@ class StatusOutput:
args_str = self._format_args(self._pending_tool_args)
result_str = ""
if result_summary:
result_str = f" → {_truncate(result_summary, 50)}"
if self._show_tool_output:
if result_summary:
result_str = f" → {_truncate(result_summary, 50)}"
elif error_message:
result_str = f" → ✗ {_truncate(error_message, 50)}"
elif error_message:
# Always show errors even when output is hidden
result_str = f" → ✗ {_truncate(error_message, 50)}"
color = "cyan" if status == "ok" else "red"
@@ -285,9 +291,9 @@ class StatusOutput:
separator = "─" * 50
self._emit_text(separator, ts, "dim", show_timestamp=False)
self._emit_text("Final Output:", ts, "bold", show_timestamp=False)
# Print actual content without truncation for final output
# Print actual content to stdout (not stderr) so users see it
with self._lock: # Thread-safe output
print(content, file=self._file)
print(content)
def _emit_text(self, message: str, ts: float, style: str = None, show_timestamp: bool = True) -> None:
"""Emit a text line. Thread-safe for multi-agent execution."""
@@ -320,6 +326,7 @@ def enable_status_output(
use_color: bool = True,
show_timestamps: bool = True,
show_metrics: bool = False, # Enable metrics for debug mode
show_tool_output: bool = True, # Show tool result output inline
) -> StatusOutput:
"""
Enable actions output mode globally.
@@ -334,6 +341,7 @@ def enable_status_output(
use_color: Whether to use colored output (default: True)
show_timestamps: Whether to show timestamps (default: True)
show_metrics: Whether to show token/cost metrics (default: False)
show_tool_output: Whether to show tool result output inline (default: True)
Returns:
StatusOutput instance for programmatic access
@@ -350,6 +358,7 @@ def enable_status_output(
use_color=use_color,
show_timestamps=show_timestamps,
show_metrics=show_metrics,
show_tool_output=show_tool_output,
)
_status_output_enabled = True
Loaded 100 of 286 files, more files were not shown because too many files have changed in this diff. Show more