Compare commits

..
Author SHA1 Message Date
Mervin Praison 08581d68d4 Release v4.2.1 2026-01-31 17:13:10 +00:00
github-actions[bot] 5bdf515ac3 docs: auto-update api.md [skip ci] 2026-01-31 15:10:45 +00:00
Mervin Praison b02204e711 Release v4.2.0 2026-01-31 15:10:29 +00:00
github-actions[bot] 10e25b550d docs: auto-update api.md [skip ci] 2026-01-31 08:34:41 +00:00
Mervin Praison 03d7b6ad11 Release v4.1.0 2026-01-31 08:34:24 +00:00
Mervin Praison 3dd0bc877c Merge branch 'main' of https://github.com/MervinPraison/PraisonAI 2026-01-31 05:43:17 +00:00
Mervin Praison 037b2197f9 refactor: improve test isolation and mocking robustness by using importlib.reload and pytest.raises in unit tests. 2026-01-31 05:43:16 +00:00
github-actions[bot] 17968d7cc1 docs: auto-update api.md [skip ci] 2026-01-30 17:41:39 +00:00
Mervin Praison 17288679cc Release v4.0.0 2026-01-30 17:41:19 +00:00
Mervin Praison 01859f40da Release v3.12.3 2026-01-30 11:05:35 +00:00
github-actions[bot] dda4822e4c docs: auto-update api.md [skip ci] 2026-01-30 10:24:30 +00:00
Mervin Praison a14ca673da Release v3.12.2 2026-01-30 10:24:14 +00:00
github-actions[bot] 02d9d1e04f docs: auto-update api.md [skip ci] 2026-01-30 08:31:24 +00:00
Mervin Praison d3ee549ccc Release v3.12.1 2026-01-30 08:31:09 +00:00
github-actions[bot] 63fbd04af3 docs: auto-update api.md [skip ci] 2026-01-30 08:00:39 +00:00
Mervin Praison 51f95ad904 Release v3.12.0 2026-01-30 08:00:22 +00:00
Mervin Praison 44b800df0e Release v3.11.14 2026-01-30 05:27:32 +00:00
Mervin Praison 6e3a99b8b6 Release v3.11.13 2026-01-29 20:52:07 +00:00
Mervin Praison b7d53b8f11 Release v3.11.12 2026-01-29 18:23:32 +00:00
Mervin Praison 643fac5762 Release v3.11.11 2026-01-29 18:07:10 +00:00
Mervin Praison 92b71cbc8f Release v3.11.10 2026-01-29 17:59:29 +00:00
Mervin Praison d7555ba1b2 Release v3.11.9 2026-01-29 17:53:32 +00:00
github-actions[bot] 22dd13ec66 docs: auto-update api.md [skip ci] 2026-01-29 17:40:47 +00:00
Mervin Praison ba40e818e4 Release v3.11.8 2026-01-29 17:40:31 +00:00
Mervin Praison 12f829321c update 2026-01-29 16:39:37 +00:00
Mervin Praison 149ed5bd94 refactor(docker): move bot services to dedicated docker/bots/ folder
- Create docker/bots/ with own Dockerfile, docker-compose.yml, README
- Remove bot services from main docker-compose.yml
- Add .env.template with bot tokens
- Add README with setup instructions for Slack/Discord/Telegram
2026-01-29 16:16:31 +00:00
Mervin Praison 15f2937dd5 feat(docker): add bot Dockerfile and services
- Add Dockerfile.bot for Slack/Discord/Telegram bots
- Add slack-bot, discord-bot, telegram-bot services to docker-compose
- Add bot tokens to .env.template
- Secrets passed at runtime via environment variables
2026-01-29 16:15:23 +00:00
Mervin Praison d28199a18d feat(bots): fix asyncio error and add thread configuration
- Fix asyncio.run() error by using run_in_executor() for sync agent.chat()
- Add reply_in_thread config option (default: False for inline replies)
- Add thread_threshold for auto-threading long responses (default: 500 chars)
- Add INFO logging for message received/sent flow
2026-01-29 16:11:21 +00:00
Mervin Praison 0d3d6bb852 Release v3.11.4 2026-01-29 15:18:30 +00:00
Mervin Praison 5f93f83c9f fix: register bot/gateway/sandbox commands in CLI
- Add 'bot', 'gateway', 'sandbox', 'wizard', 'migrate', 'security', 'persistence' to LEGACY_COMMANDS in legacy.py
- Add same commands to special_commands list in main.py:797 (argparse fix)
- Fix handle_bot_command to parse list args from main.py
- 'praisonai bot slack --token $TOKEN' now works correctly
2026-01-29 14:23:40 +00:00
Mervin Praison c9786fbd1e Release v3.11.3 2026-01-29 14:17:18 +00:00
github-actions[bot] 95e8e38998 docs: auto-update api.md [skip ci] 2026-01-29 13:45:28 +00:00
Mervin Praison 73f8961e61 Release v3.11.2 2026-01-29 13:45:08 +00:00
github-actions[bot] 4caf10a982 docs: auto-update api.md [skip ci] 2026-01-29 12:31:08 +00:00
Mervin Praison fb42a1010a Release v3.11.1 2026-01-29 12:30:51 +00:00
github-actions[bot] fe60dd40af docs: auto-update api.md [skip ci] 2026-01-29 10:45:19 +00:00
Mervin Praison 7f37d754a7 Release v3.11.0 2026-01-29 10:45:00 +00:00
github-actions[bot] 26f60fb056 docs: auto-update api.md [skip ci] 2026-01-28 13:20:05 +00:00
Mervin Praison 0f0b640184 Release v3.10.27 2026-01-28 13:19:48 +00:00
Mervin Praison d857dfc6b7 Release v3.10.26 2026-01-27 15:07:21 +00:00
github-actions[bot] 0135a9e09f docs: auto-update api.md [skip ci] 2026-01-27 05:52:59 +00:00
Mervin Praison 847b5e6372 Release v3.10.25 2026-01-27 05:52:43 +00:00
github-actions[bot] 05a7265c4e docs: auto-update api.md [skip ci] 2026-01-26 18:39:42 +00:00
Mervin Praison de1734c29a Release v3.10.24 2026-01-26 18:39:28 +00:00
github-actions[bot] f0e91b11ae docs: auto-update api.md [skip ci] 2026-01-26 17:47:05 +00:00
Mervin Praison 6e1a9143e2 Release v3.10.23 2026-01-26 17:46:49 +00:00
github-actions[bot] 10d76d3a85 docs: auto-update api.md [skip ci] 2026-01-26 17:06:34 +00:00
Mervin Praison a38a9c06c6 Release v3.10.22 2026-01-26 17:06:19 +00:00
github-actions[bot] 9c90416ab5 docs: auto-update api.md [skip ci] 2026-01-26 08:08:44 +00:00
Mervin Praison efa5247344 Release v3.10.21 2026-01-26 08:08:18 +00:00
Mervin Praison 720213d82d Merge pull request #1100 from MervinPraison/pr-1099-fix
Pr 1099 fix
2026-01-25 00:47:27 +00:00
Loganaden Velvindron f6af16ef5e Fix range check 2026-01-25 00:46:37 +00:00
Loganaden Velvindron 8439b98ec2 Fix CVE-2026-22218 2026-01-25 00:45:51 +00:00
Loganaden Velvindron 92271fc118 Fix CVE-2026-22218
Allow building with chainlit 2.9.4 to fix CVE-2026-22218
2026-01-25 00:45:32 +00:00
Mervin Praison 1b43dc6a4b Update naming convention guide with list_X pattern and clarify decorator usage 2026-01-25 00:43:02 +00:00
Mervin Praison 929cefa55e Release v3.10.20 2026-01-25 00:36:03 +00:00
Mervin Praison 225843e39b Release v3.10.19 2026-01-25 00:25:39 +00:00
Mervin Praison 77913183a9 Release v3.10.18 2026-01-24 14:33:15 +00:00
Mervin Praison e5b5d747f4 Release v3.10.17 2026-01-24 09:26:02 +00:00
Mervin Praison 8cb779c51c Release v3.10.16 2026-01-24 07:32:55 +00:00
github-actions[bot] 020ca46c90 docs: auto-update api.md [skip ci] 2026-01-24 07:01:25 +00:00
Mervin Praison 557e243eed Release v3.10.15 2026-01-24 07:01:13 +00:00
Mervin Praison 0628204fce Update README.md 2026-01-23 14:46:55 +00:00
Mervin Praison 2a11bf5221 Release v3.10.14 2026-01-23 12:58:40 +00:00
github-actions[bot] ecc2182d24 docs: auto-update api.md [skip ci] 2026-01-23 12:16:33 +00:00
Mervin Praison c6b91b2d57 Release v3.10.13 2026-01-23 12:16:16 +00:00
github-actions[bot] a5ed17945f docs: auto-update api.md [skip ci] 2026-01-23 10:33:02 +00:00
Mervin Praison ff3ca3052d Release v3.10.12 2026-01-23 10:32:48 +00:00
github-actions[bot] 6f1f4a5363 docs: auto-update api.md [skip ci] 2026-01-23 07:32:54 +00:00
Mervin Praison dd9dfa5970 Release v3.10.11 2026-01-23 07:32:37 +00:00
Mervin Praison 5f4fc8fa2f Release v3.10.10 2026-01-23 05:18:03 +00:00
github-actions[bot] dd9dd5d5d9 docs: auto-update api.md [skip ci] 2026-01-23 03:47:00 +00:00
Mervin Praison d2c629b82c Release v3.10.9 2026-01-23 03:46:46 +00:00
Mervin Praison 609ac5f7a6 Release v3.10.8 2026-01-22 14:13:38 +00:00
Mervin Praison 75c72e909e Release v3.10.7 2026-01-22 13:47:01 +00:00
Mervin Praison 9492ff8de1 Release v3.10.6 2026-01-22 11:24:49 +00:00
Mervin Praison a0a3aa1a53 Release v3.10.5 2026-01-22 08:30:15 +00:00
Mervin Praison 6a20001551 Refactor port availability test to use socket.socket and relax import and profiling time assertions. 2026-01-22 07:49:51 +00:00
Mervin Praison 577295c147 Release v3.10.4 2026-01-22 07:06:28 +00:00
github-actions[bot] 06f64a551c docs: auto-update api.md [skip ci] 2026-01-22 05:16:01 +00:00
Mervin Praison 928b824180 Release v3.10.3 2026-01-22 05:15:48 +00:00
github-actions[bot] 1631973256 docs: auto-update api.md [skip ci] 2026-01-21 17:30:54 +00:00
Mervin Praison a9d7f6fd76 Release v3.10.2 2026-01-21 17:30:30 +00:00
Mervin Praison cc5360e60e Release v3.10.1 2026-01-21 15:01:51 +00:00
Mervin Praison 863dd9bc34 Release v3.10.0 2026-01-21 13:11:42 +00:00
Mervin Praison 8c39769971 Release v3.9.35 2026-01-21 05:41:54 +00:00
Mervin Praison 93904f8530 Release v3.9.34 2026-01-20 20:40:03 +00:00
Mervin Praison ffc3775446 Release v3.9.33 2026-01-20 19:01:01 +00:00
Mervin Praison 86e76b7a3e Release v3.9.32 2026-01-20 14:56:13 +00:00
Mervin Praison b18d6007c2 Release v3.9.31 2026-01-20 12:43:18 +00:00
github-actions[bot] c656af3166 docs: auto-update api.md [skip ci] 2026-01-20 12:20:08 +00:00
Mervin Praison 3c2fff05dd Release v3.9.30 2026-01-20 12:19:52 +00:00
github-actions[bot] 092097faa6 docs: auto-update api.md [skip ci] 2026-01-20 08:17:03 +00:00
Mervin Praison 068ddecb91 Release v3.9.29 2026-01-20 08:16:43 +00:00
github-actions[bot] 444d14cb54 docs: auto-update api.md [skip ci] 2026-01-20 06:59:39 +00:00
Mervin Praison 090316dce3 Release v3.9.28 2026-01-20 06:59:26 +00:00
Mervin Praison a40cddd402 Release v3.9.27 2026-01-19 19:44:12 +00:00
Mervin Praison 62472a2342 Release v3.9.26 2026-01-19 18:29:39 +00:00
Mervin Praison 35186a0e66 Release v3.9.25 2026-01-19 17:11:41 +00:00
Mervin Praison d08d98ca12 Release v3.9.24 2026-01-19 14:44:13 +00:00
Mervin Praison 2c9e8b46cb Release v3.9.23 2026-01-19 09:34:36 +00:00
Mervin Praison ce3b25c085 Release v3.9.22 2026-01-18 22:56:19 +00:00
github-actions[bot] a928467188 docs: auto-update api.md [skip ci] 2026-01-18 22:55:41 +00:00
Mervin Praison 8285afe789 Release v3.9.21 2026-01-18 22:55:28 +00:00
Mervin Praison b529110246 Release v3.9.20 2026-01-18 22:46:06 +00:00
Mervin Praison 2a004bc382 Release v3.9.19 2026-01-18 22:18:17 +00:00
Mervin Praison b3fecacb2b Release v3.9.18 2026-01-18 21:18:40 +00:00
Mervin Praison 6924077e36 Release v3.9.17 2026-01-18 20:22:41 +00:00
Mervin Praison 63101d7323 Release v3.9.16 2026-01-18 00:06:30 +00:00
Mervin Praison e9540b2a92 Release v3.9.15 2026-01-18 00:03:09 +00:00
Mervin Praison 75c1c62cec Release v3.9.14 2026-01-17 23:47:18 +00:00
Mervin Praison de4f251a02 Release v3.9.13 2026-01-17 23:45:53 +00:00
github-actions[bot] d4858b4d5c docs: auto-update api.md [skip ci] 2026-01-17 23:31:46 +00:00
Mervin Praison 03ca92a7d5 Release v3.9.12 2026-01-17 23:31:32 +00:00
Mervin Praison cca65dcdab Release v3.9.11 2026-01-17 22:02:52 +00:00
github-actions[bot] 93844d9509 docs: auto-update api.md [skip ci] 2026-01-17 21:12:18 +00:00
Mervin Praison 0849db227d Release v3.9.10 2026-01-17 21:12:06 +00:00
github-actions[bot] 914d34e57b docs: auto-update api.md [skip ci] 2026-01-17 16:42:13 +00:00
Mervin Praison be66a5e4e1 Release v3.9.9 2026-01-17 16:42:00 +00:00
github-actions[bot] 20a19f7958 docs: auto-update api.md [skip ci] 2026-01-17 07:49:09 +00:00
Mervin Praison 4128a67e10 Release v3.9.8 2026-01-17 07:48:54 +00:00
Mervin Praison 9d29c796c7 Release v3.9.7 2026-01-16 12:05:38 +00:00
Mervin Praison f058ce018f Release v3.9.6 2026-01-16 09:18:27 +00:00
Mervin Praison 09671d4bd3 Release v3.9.5 2026-01-16 08:00:39 +00:00
Mervin Praison c1dead194c Release v3.9.4 2026-01-16 06:33:39 +00:00
Mervin Praison 5d67f0fdcf Release v3.9.3 2026-01-16 05:23:44 +00:00
github-actions[bot] fb93b7d6d5 docs: auto-update api.md [skip ci] 2026-01-16 04:38:07 +00:00
Mervin Praison 657fd93743 Release v3.9.2 2026-01-16 04:37:53 +00:00
github-actions[bot] f0a2936b7f docs: auto-update api.md [skip ci] 2026-01-15 20:08:49 +00:00
Mervin Praison 58778492ca Release v3.9.1 2026-01-15 20:08:35 +00:00
Mervin Praison 08c444f3a8 Release v3.9.0 2026-01-15 16:05:47 +00:00
Mervin Praison 34c1b39e95 Release v3.8.22 2026-01-15 13:01:10 +00:00
Mervin Praison 5e4c58a9ac fix(ci): correct CLI commands - session list, queue list (not ls) 2026-01-15 11:06:32 +00:00
Mervin Praison 19a30ff3d9 fix(ci): fix Handoff test - use correct parameters 2026-01-15 11:00:32 +00:00
Mervin Praison 50f12c7682 fix(ci): correct PlanningConfig params (reasoning, auto_approve) 2026-01-15 10:57:50 +00:00
Mervin Praison 5a70b8abb9 fix(ci): correct ExecutionConfig params (max_iter, max_retry_limit) 2026-01-15 10:55:33 +00:00
Mervin Praison b7d4d219b9 fix(ci): correct MemoryConfig test to use backend param 2026-01-15 10:52:56 +00:00
Mervin Praison ff2d83ffdf Release v3.8.21 2026-01-15 10:50:27 +00:00
Mervin Praison 2d58caa741 fix(ci): correct import paths for Windows tests
- MemoryConfig from praisonaiagents.config (not memory)
- FileMemory from praisonaiagents.memory
- OutputConfig, ExecutionConfig, PlanningConfig from config
2026-01-15 10:50:25 +00:00
Mervin Praison d31cf379e5 feat(ci): expand Windows tests with Memory, Tools, Config, Handoff, async
Added tests for:
- Memory: FileMemory, MemoryConfig
- Tool decorator (@tool)
- Config classes: OutputConfig, ExecutionConfig, PlanningConfig
- Handoff between agents
- Async features (arun, astart availability)
- Multi-agent real API test
2026-01-15 10:47:11 +00:00
Mervin Praison 719ef04e7c fix(memory): make fcntl import conditional for Windows compatibility
fcntl is Unix-only; on Windows, skip file locking.
This fixes ModuleNotFoundError: No module named 'fcntl' on Windows.

- Add _HAS_FCNTL flag based on sys.platform
- Update _read_json and _write_json to conditionally use fcntl.flock
- File locking is acceptable to skip for single-process usage
2026-01-15 10:38:45 +00:00
Mervin Praison 4fb25a99d5 fix(ci): fix Windows workflow YAML syntax and secrets condition 2026-01-15 10:35:18 +00:00
Mervin Praison 0fdacff2cf feat(ci): add Windows CI test workflow
Tests key PraisonAI functionality on Windows:
- Core imports: Agent, Agents, Workflow, Task, WorkflowStep
- Object instantiation
- CLI commands: --help, session ls, queue ls
- Real API test with OPENAI_API_KEY

Timeout: 5 minutes
Runs on: windows-latest
Triggers: push/PR to main/develop + manual dispatch
2026-01-15 10:33:56 +00:00
Mervin Praison 6a1fa19c6e Release v3.8.20 2026-01-15 09:45:45 +00:00
Mervin Praison a4725a027a Release v3.8.19 2026-01-15 07:29:27 +00:00
Mervin Praison dc9a8f3443 Release v3.8.18 2026-01-15 04:41:16 +00:00
Mervin Praison 9421e28c8a Release v3.8.17 2026-01-13 18:29:06 +00:00
Mervin Praison c3d60c82ca feat: add debug output with token/cost metrics
- Added llm_end callback to openai_client.py and llm.py with:
  - tokens_in, tokens_out from usage
  - cost from litellm.completion_cost()
  - latency_ms timing
- Updated trace.py and status.py to display metrics
- Added show_metrics param to enable debug mode metrics display
- Agent.py passes metrics flag to enable_status_output

Debug output now shows:
  [17:18:22] Calling LLM (gpt-4o-mini)...
  [17:18:24]   │ 📊 gpt-4o-mini: 104→14 tokens [1.5s]
  [17:18:24] ▸ get_weather(city='Tokyo')
  [17:18:24] Calling LLM (gpt-4o-mini)...
  [17:18:25]   │ 📊 gpt-4o-mini: 133→8 tokens [0.7s]
  Response: The weather in Tokyo is sunny.
2026-01-13 17:20:06 +00:00
Mervin Praison 8c509b439f Release v3.8.16 2026-01-13 16:55:40 +00:00
Mervin Praison 3fbf1a353e fix: consistent status output for all LLM paths (OpenAI + LiteLLM)
- Fixed _execute_callback_and_display to always trigger callback
  (removed incorrect 'if not _using_custom_llm' guard)
- Added tool_call callback to llm.py for LiteLLM path
- Removed duplicate llm_start callback from llm.py method entry
- Reset AI call counter between agent runs
- Both sync LLM paths now show identical output

Verified output for both paths:
  ▸ AI → thinking...
    │ → get_weather(city="Tokyo") → sunny
  ▸ AI → responding...
  Final Output: ...
2026-01-13 16:45:08 +00:00
Mervin Praison e0f3a49e41 improve: status output UX - cleaner, user-friendly format
- Changed 'LLM' to 'AI' for user-friendliness
- Added context: 'thinking' (1st call) / 'responding' (2nd call)
- Tool calls now show inline with result: │ → tool(args) → result
- Fixed openai_client to pass structured params to callbacks

Output now looks like:
  ▸ AI → thinking...
    │ → get_weather(city="Tokyo") → sunny
  ▸ AI → responding...
  Final Output: ...
2026-01-13 16:36:51 +00:00
Mervin Praison 987b43877f fix: show all LLM calls in status output (including tool follow-ups)
- Added llm_start callback in openai_client.py while loop
- Each LLM API call now triggers callback for status display
- Removed duplicate callback from agent._chat_completion

Status output now correctly shows:
  ▸ Calling LLM (gpt-4o-mini)...       # 1st: decides to call tool
  │ → get_weather(city="Tokyo")
  ▸ Calling LLM (gpt-4o-mini)...       # 2nd: generates response
  Final Output: ...
2026-01-13 16:18:53 +00:00
Mervin Praison ebb383208e feat: add LLM call display to status/trace output
- Added llm_start callback in agent._chat_completion()
- Registered on_llm_start callback in enable_status_output()
- StatusOutput.llm_start() displays '▸ Calling LLM (model)...'
- Works for both LiteLLM and OpenAI client paths

Status output now shows:
  ▸ Calling LLM (openai/gpt-4o-mini)...
  │ → get_weather(city="Tokyo")
  Final Output: ...
2026-01-13 16:10:46 +00:00
Mervin Praison c9e4f0738a refactor: rename output modules for user-friendliness
- Rename actions.py → status.py (handles status preset, no timestamps)
- Rename old status.py → trace.py (handles trace preset, with timestamps)
- ActionsSink → StatusOutput
- StatusSink → TraceOutput
- enable_actions_mode → enable_status_output
- enable_status_mode → enable_trace_output

This makes internal code match user-facing preset names:
  output='status' → StatusOutput (▸ tool → result)
  output='trace' → TraceOutput ([HH:MM:SS] ▸ tool [0.2s])
2026-01-13 15:59:42 +00:00
Mervin Praison db0a24555d fix: status preset correctly hides timestamps, add LLM call methods
- Added show_timestamps param to ActionsSink (default True)
- Agent passes show_timestamps=False for status preset (simple_output=True)
- Added llm_start/llm_end methods to ActionsSink for future LLM tracking
- status: │ → tool() (no timestamps)
- trace: [HH:MM:SS] ▸ tool [0.2s] ✓ (with timestamps)
2026-01-13 15:51:51 +00:00
Mervin Praison e3fe35e2b6 fix: status preset now hides timestamps correctly
- Added show_timestamps param to ActionsSink
- Agent passes show_timestamps=False when simple_output=True
- status: │ → tool() (no timestamps)
- trace: [HH:MM:SS] ▸ tool [0.2s] ✓ (with timestamps)
2026-01-13 15:46:57 +00:00
Mervin Praison 74a7322b83 refactor: rename output presets for clarity
- Rename 'text' → 'status' (no timestamps, simple progress)
- Rename old 'status' → 'trace' (with timestamps, full trace)
- Change 'debug' = trace + metrics (no boxes, developer-focused)
- Keep 'verbose' = rich panels (interactive use)
- Add 'text' as backward compat alias → status

New hierarchy (most intuitive):
  silent → status → trace → debug → verbose → stream → json
2026-01-13 15:10:05 +00:00
Mervin Praison 7ceb7f8cb9 Release v3.8.14 2026-01-13 14:46:59 +00:00
Mervin Praison 84e5b6c693 fix: Agents panels now render Markdown (bold, headers, lists)
Changed Text() to Markdown() in agents.start() verbose output so
**bold**, # headers, - lists render properly in multi-agent panels.
2026-01-13 14:44:17 +00:00
Mervin Praison 5f00ddbc7c Release v3.8.13 2026-01-13 14:43:54 +00:00
Mervin Praison db551c5d65 feat: add Markdown rendering for status output mode
- Added use_markdown=True parameter to StatusSink
- response() now renders content with rich.markdown.Markdown
- Lazy import for performance, graceful fallback
2026-01-13 14:39:53 +00:00
Mervin Praison aed6027256 Release v3.8.12 2026-01-13 14:33:31 +00:00
Mervin Praison a9d709645a fix: respect explicit output config, don't override with TTY auto-verbose
- Added _has_explicit_output_config flag to track when user sets output=
- Modified agent.start() to skip TTY auto-verbose if user explicitly configured output
- Fixes issue where output='status' was overridden by TTY auto-verbose showing panels

Before: output='status' in TTY showed panels (wrong)
After: output='status' in TTY shows clean inline status (correct)
2026-01-13 14:30:56 +00:00
Mervin Praison a430e1b873 Release v3.8.11 2026-01-13 14:21:02 +00:00
Mervin Praison 8e0a40db56 Release v3.8.10 2026-01-13 13:55:31 +00:00
Mervin Praison 8d1e4637d4 feat: add status output mode for clean inline progress
- Add new 'status' preset showing [HH:MM:SS] ▸ tool → result ✓
- Create output/status.py with StatusSink class
- Add status_trace field to OutputConfig
- Update presets.py with full preset hierarchy
- Consolidate presets: text, status, verbose, debug, stream, json
- Add backward compatible aliases (plain→silent, minimal→silent, etc.)

Fixes confusing verbose output by providing clean alternative
2026-01-13 13:52:05 +00:00
Mervin Praison e11d62dc89 Release v3.8.9 2026-01-13 13:14:04 +00:00
Mervin Praison 13a9a3e07d Release v3.8.8 2026-01-13 13:06:26 +00:00
Mervin Praison b95f6b0665 Release v3.8.7 2026-01-13 12:55:43 +00:00
github-actions[bot] e8c4410014 docs: auto-update api.md [skip ci] 2026-01-13 09:30:27 +00:00
Mervin Praison 6c2d852ed1 Release v3.8.6 2026-01-13 09:30:11 +00:00
Mervin Praison 6f95ae8d9d Release v3.8.5 2026-01-13 07:04:53 +00:00
Mervin Praison 8e2dd09b3b Release v3.8.4 2026-01-13 06:17:09 +00:00
github-actions[bot] 8ecfad5c5a docs: auto-update api.md [skip ci] 2026-01-13 06:01:35 +00:00
Mervin Praison 35a88a0913 Release v3.8.3 2026-01-13 06:01:20 +00:00
Mervin Praison 6df3e32640 Release v3.8.2 2026-01-13 04:16:08 +00:00
github-actions[bot] fb36e1c50c docs: auto-update api.md [skip ci] 2026-01-12 17:00:39 +00:00
Mervin Praison ac5454cb00 Release v3.8.1 2026-01-12 17:00:20 +00:00
github-actions[bot] bbe95795d1 docs: auto-update api.md [skip ci] 2026-01-12 12:49:30 +00:00
Mervin Praison bdaf5340f9 Release v3.8.0 2026-01-12 12:49:16 +00:00
Mervin Praison 5579614001 Release v3.7.9 2026-01-12 09:10:46 +00:00
Mervin Praison 2c4dd0e2d4 Release v3.7.8 2026-01-12 07:39:39 +00:00
Mervin Praison fd23d62841 Release v3.7.7 2026-01-11 21:30:04 +00:00
Mervin Praison 5f3bcb78ac Release v3.7.6 2026-01-11 08:23:08 +00:00
Mervin Praison 3c40c2278e Release v3.7.5 2026-01-11 07:34:59 +00:00
Mervin Praison 1921b03aab chore: remove empty trace and comparison test files 2026-01-10 21:09:11 +00:00
Mervin Praison f33bf57662 Release v3.7.4 2026-01-10 21:06:16 +00:00
github-actions[bot] df21c419ea docs: auto-update api.md [skip ci] 2026-01-10 19:45:30 +00:00
Mervin Praison fe997d525c ci: add workflow to auto-update api.md on main branch push 2026-01-10 19:45:04 +00:00
Mervin Praison ca26988774 Release v3.7.3 2026-01-10 19:43:43 +00:00
Mervin Praison 3600594316 Before AI example generation 2026-01-10 16:54:16 +00:00
Mervin Praison 097cd42890 Before testing 2026-01-10 16:04:21 +00:00
Mervin Praison 595add2991 Before testing AI generation 2026-01-10 15:55:26 +00:00
Mervin Praison 03a3aa420b Release v3.7.2 2026-01-10 15:31:05 +00:00
Mervin Praison 9533abd8a4 Release v3.7.1 2026-01-10 14:29:43 +00:00
Mervin Praison 39c9e50875 Release v3.7.0 2026-01-10 13:09:09 +00:00
Mervin Praison 585b68d02c Release v3.6.2 2026-01-10 09:08:53 +00:00
Mervin Praison 66481cc51c Release v3.6.1 2026-01-09 17:16:27 +00:00
Mervin Praison 8e637daecd Release v3.6.0 2026-01-09 15:45:57 +00:00
Mervin Praison ba482895f7 Release v3.5.9 2026-01-09 15:02:31 +00:00
Mervin Praison ba09407981 Release v3.5.8 2026-01-09 13:15:51 +00:00
Mervin Praison b04bfaf5be Release v3.5.7 2026-01-09 11:52:45 +00:00
Mervin Praison abea33d2fb Release v3.5.6 2026-01-09 10:51:46 +00:00
Mervin Praison 6f8f6bcc86 fix: Allow test failures in comprehensive tests, add more flaky test ignores 2026-01-09 08:20:58 +00:00
Mervin Praison e823dad0c3 fix: Ignore more flaky timing-sensitive tests in comprehensive runner 2026-01-09 08:11:28 +00:00
Mervin Praison 8f6cd62a9f fix: Ignore flaky test_message_queue.py in comprehensive test runner 2026-01-09 08:02:46 +00:00
Mervin Praison f5850c3ff9 fix: Add pytest-timeout to comprehensive and framework test workflows 2026-01-09 07:56:34 +00:00
Mervin Praison 31e2f03e07 Release v3.5.5 2026-01-09 07:45:22 +00:00
Mervin Praison c53c33738d fix: Ignore flaky message_queue tests in smoke, allow failures 2026-01-09 07:39:30 +00:00
Mervin Praison efed927736 fix: Increase main job timeout to 10min, use 4 workers, add maxfail=10 2026-01-09 07:36:32 +00:00
Mervin Praison cf6a86854e fix: Reduce smoke tests to CLI tests only for faster execution 2026-01-09 07:27:14 +00:00
Mervin Praison ba054700d5 fix: Increase smoke timeout to 5min, use maxfail=5 2026-01-09 07:21:12 +00:00
Mervin Praison 83fb3f4c62 fix: Explicitly install prompt_toolkit for smoke tests 2026-01-09 07:17:00 +00:00
Mervin Praison b009a0c49d fix: Install praisonai with [chat] extras for prompt_toolkit 2026-01-09 07:13:42 +00:00
Mervin Praison 91c33594f4 fix: Install praisonai-agents with [all] extras for litellm
- All jobs now install praisonai-agents with [all] extras
- Fixes ModuleNotFoundError: No module named 'litellm'
2026-01-09 07:10:57 +00:00
Mervin Praison c54a9e12f5 fix: Ignore tests requiring optional deps in smoke job
- Ignore tests/unit/jobs (requires fastapi)
- Ignore tests/unit/api (requires fastapi)
- Ignore gradio and chainlit tests (require optional UI deps)
2026-01-09 07:09:11 +00:00
Mervin Praison 74ceda77ef fix: Install praisonai-agents in all CI jobs
- Smoke, main, openai-live, and all extended provider jobs now install
  praisonai-agents first before praisonai
- Fixes ModuleNotFoundError: No module named 'rich' in CI
2026-01-09 07:07:54 +00:00
Mervin Praison 8aa45391af fix: CI workflow syntax and gating plugin exclusions
- Fix test-optimized.yml: secrets cannot be used in job-level if conditions
- Use step-level conditionals with check-key output for all provider jobs
- Add comprehensive EXCLUDED_PATHS list in test_gating.py
- Add _is_excluded_path() function to prevent false provider detection
- Exclude _pytest_plugins, _meta, conftest, fixtures from provider scanning
- Add unit tests for excluded path detection
- Make openai-live job safe for forks (skips gracefully without key)
2026-01-09 07:06:05 +00:00
Mervin Praison 722056fd76 Release v3.5.4 2026-01-09 06:56:41 +00:00
Mervin Praison 8860b31282 Remove deprecated parameters from examples and fix Agent initialization
- Remove `verbose` parameter from Agents() calls across all examples
- Remove `markdown` parameter from Agent() initialization
- Change `model` to `llm` in observability example
- Remove unused `kind` parameter from obs.span()
- Fix thinking_budget initialization to use property setter instead of constructor
- Add missing `instructions` parameter to routellm_example.py Agent
2026-01-09 06:16:33 +00:00
Mervin Praison f5e17c697b Release v3.5.3 2026-01-08 21:52:44 +00:00
Mervin Praison d3f6eb4096 Release v3.5.2 2026-01-08 21:14:45 +00:00
Mervin Praison c691b0a4c0 Release v3.5.1 2026-01-08 20:12:14 +00:00
Mervin Praison b59675eeb4 Release v3.5.0 2026-01-08 19:55:06 +00:00
Mervin Praison 7e0739c6c4 Fix test_api.py to mock urllib.request.urlopen instead of subprocess.run 2026-01-08 16:14:35 +00:00
Mervin Praison 7519bc85d2 Fix test_api.py mock setup for stderr.read() 2026-01-08 16:03:38 +00:00
Mervin Praison c7471092d0 Update test_typer_cli.py: chat is now a Typer command, not legacy 2026-01-08 15:52:35 +00:00
Mervin Praison c344ee1bad Update test_chat_flag.py to not require deprecated chat_mode flag 2026-01-08 15:42:12 +00:00
Mervin Praison 77bbf1ae66 Fix test_approval_system.py to handle tuple return from parse_args() 2026-01-08 15:33:37 +00:00
Mervin Praison 695d4d8843 Add console.py to output module (was gitignored)
The output/ directory was in .gitignore, so console.py wasn't committed.
Force-adding it to fix the ModuleNotFoundError in CI.
2026-01-08 15:28:17 +00:00
Mervin Praison 08f0736db5 Fix async test infrastructure and CLI output module export
Core SDK fixes:
- Add local async test runner in conftest.py that works when pytest-asyncio is not installed
- Register 'asyncio' marker to remove PytestUnknownMarkWarning
- Add @requires_wrapper skip decorator for tests that depend on praisonai wrapper
- Add @requires_knowledge skip decorator for tests that need knowledge dependencies

Wrapper CLI fixes:
- Add missing set_output_controller export to cli/output/__init__.py
- Update test_doctor_cli.py to test actual CLI subcommands instead of non-existent options

Result: Core SDK unit tests pass (1181 passed, 21 skipped), wrapper integration tests pass (17 passed)
2026-01-08 15:07:35 +00:00
Mervin Praison 1e46093edf Release v3.4.1 2026-01-08 14:36:54 +00:00
Mervin Praison 1a71aac6b8 Release v3.4.0 2026-01-08 13:29:57 +00:00
Mervin Praison 287f42dfee Remove unused test and verification files 2026-01-08 12:50:16 +00:00
Mervin Praison 57b637948f Release v3.3.1 2026-01-08 12:28:06 +00:00
Mervin Praison 757d402cae Release v3.3.0 2026-01-08 10:33:27 +00:00
Mervin Praison 412783c5ed Release v3.2.1 2026-01-07 21:57:34 +00:00
Mervin Praison edbd122414 Release v3.2.0 2026-01-07 19:44:05 +00:00
Mervin Praison 0ac055cba5 Release v3.1.9 2026-01-07 17:41:47 +00:00
Mervin Praison 134327a9cc Release v3.1.8 2026-01-07 15:06:12 +00:00
Mervin Praison 17c5942a02 Release v3.1.7 2026-01-07 11:50:38 +00:00
Mervin Praison 951eb6b497 Release v3.1.6 2026-01-07 10:26:35 +00:00
Mervin Praison 1d0f08b20a Release v3.1.5 2026-01-07 07:48:57 +00:00
Mervin Praison 07ff93ed7f Release v3.1.4 2026-01-07 07:11:58 +00:00
Mervin Praison 2d5c982893 Release v3.1.3 2026-01-02 09:48:09 +00:00
Mervin Praison f5d4db4262 Release v3.1.2 2026-01-02 08:17:39 +00:00
Mervin Praison 87d691c616 Release v3.1.1 2026-01-02 07:54:02 +00:00
Mervin Praison 8ea15dee0f Release v3.1.0 2026-01-02 07:35:57 +00:00
Mervin Praison 4f56842e08 Release v3.0.9 2026-01-02 07:22:27 +00:00
Mervin Praison a24836aaee Release v3.0.8 2026-01-02 06:59:48 +00:00
Mervin Praison be9ce27615 Release v3.0.7 2026-01-02 06:43:56 +00:00
Mervin Praison 0159a1e082 Release v3.0.6 2026-01-02 06:18:14 +00:00
Mervin Praison 71a5076c07 Release v3.0.5 2026-01-02 06:06:01 +00:00
Mervin Praison 9815a9f8a8 Release v3.0.4 2026-01-02 05:45:18 +00:00
Mervin Praison 2ae44a05b0 Release v3.0.3 2026-01-02 05:17:33 +00:00
Mervin Praison 3386d25022 Release v3.0.2 2026-01-02 04:33:19 +00:00
Mervin Praison 6dca2ace8a feat(tui): queue editing + update_input method
## Queue Editing
- Added update_input method to QueueScheduler for editing queued runs
- Added update_input method to QueueManager
- Added handler for QueuePanelWidget.EditRequested in TUIApp
- Only queued (not running) items can be edited

## Queue-First Architecture Support
- Queue items can be edited before processing
- EditRequested message already existed in QueuePanelWidget

31 tests passing
2026-01-02 04:24:10 +00:00
Mervin Praison 63548739e8 feat(tui): session history persistence with SessionStore
## Session History Implementation
- Created SessionStore class for in-memory chat history persistence
- Added chat_history field to QueuedRun model
- Modified queue worker to inject chat_history into Agent
- Updated TUIApp to store user/assistant messages in SessionStore
- Pass chat_history to queue when submitting messages

## How It Works
1. User sends message -> stored in SessionStore
2. Queue submission includes chat_history from SessionStore
3. Worker injects chat_history into Agent.chat_history
4. Agent has full conversation context
5. Response stored back in SessionStore

## Verified
- Agent remembers previous messages in session
- History format correct for LLM (role + content)
- 31 tests passing
2026-01-02 04:22:26 +00:00
Mervin Praison 3e046537f4 fix(tui): sessions screen loading + update_sessions_screen method
- Call _update_sessions_screen() after pushing sessions screen
- Add _update_sessions_screen() method to load sessions from persistence
- Fallback to showing current session if persistence fails

31 tests passing
2026-01-02 04:02:55 +00:00
Mervin Praison 4a34677567 fix(tui): compose() NoneType error + add missing slash commands
## Fixes
1. ChatWidget.compose() now yields empty generator instead of None
2. Added 7 missing slash commands: /tokens, /plan, /map, /undo, /diff, /commit, /tools

## Commands Added
- /tokens - Show token usage breakdown
- /plan - Show/create task plans
- /map - Show repository map
- /undo - Undo last change
- /diff - Show diff of changes
- /commit - Commit changes
- /tools - Show available tools

31 tests passing
2026-01-02 04:01:25 +00:00
Mervin Praison 19e91cb669 fix(tui): proper scrollbar + non-blocking input
## Scrollbar Fix (VerticalScroll)
- ChatWidget now inherits from VerticalScroll (not ScrollableContainer)
- Messages mount directly to container (removed inner Vertical)
- scroll_end() called after adding messages and during streaming
- CSS has overflow-y: auto for proper scrollbar display

## Non-Blocking Input (@work decorator)
- Message submission uses run_worker() for background execution
- _process_message_submission decorated with @work
- User can type while agent is processing
- Multiple messages can be queued

## Root Causes Fixed
1. Inner Vertical container had overflow:hidden - removed it
2. Message handler was async blocking - now uses worker pattern

Based on Textual documentation:
- VerticalScroll: container with vertical layout and auto Y scrollbar
- @work decorator: runs coroutine in background without blocking UI

31 tests passing
2026-01-02 03:56:56 +00:00
Mervin Praison 5a6125021a fix(tui): add scrollbar to chat + fix session history
## Scrollbar Fix
- ChatWidget now inherits from ScrollableContainer
- Scrollbar visible in chat area (like queue panel)

## Session History Fix
- Pass session_id from TUI app to queue submit
- Pass session_id in agent_config
- Worker now passes session_id to Agent constructor
- Agent will now use session history for context

Flow: TUI.session_id → queue.submit() → worker → Agent(session_id=...)

31 tests passing
2026-01-02 03:44:55 +00:00
Mervin Praison da26887f24 feat(tui): NEWEST-AT-TOP rendering + fix @ typing blockage
## UX STRATEGY CHANGE (Stabilization)

### Newest-at-Top Rendering
- Messages now render NEWEST at TOP
- No scroll dependency - latest always visible
- Streaming updates visible immediately
- ChatWidget inherits from Vertical (not ScrollableContainer)

### @ Typing Not Blocked
- @ handler no longer mounts focus-stealing popup
- Users can freely type @path/to/file
- Manual path entry works without interruption

### Verified Working
- ✅ New messages appear at TOP
- ✅ Latest response always visible
- ✅ Streaming updates visible
- ✅ Can type while agent runs
- ✅ Queue increments immediately
- ✅ Backslash popup works
- ✅ @ does NOT block typing
- ✅ 31 tests passing
2026-01-02 03:38:13 +00:00
Mervin Praison 1b2f7139f9 docs(tui): update help screen with auto-trigger features
- Document @ auto-show file browser
- Document / auto-show command discovery
- Update backslash description to 'auto-show'
2026-01-02 03:25:17 +00:00
Mervin Praison aafbc86180 feat(tui): auto-trigger popups for backslash, @, and / commands
## Auto-Trigger Features (Claude Code-style UX)

### Backslash (\) Auto-Popup
- Typing \ immediately shows command popup
- Clears backslash from input
- No manual trigger needed

### @ File Autocomplete
- Typing @ shows file/directory popup
- Fuzzy search files in workspace
- Supports ~/ and absolute paths
- Reuses FileSearchService from at_mentions.py

### / Command Discovery
- Typing / shows command popup with filter
- Same unified command registry as backslash

## Queue Live Updates
- Queue panel updates IMMEDIATELY when message queued
- Updates on run completion and error
- Reactive queued_count and running_count

## Composer Never Disabled
- Input stays enabled during agent processing
- Users can queue multiple messages

## New Files
- widgets/file_popup.py: FilePopupWidget for @ autocomplete

## Tests
- 31 tests passing (10 new tests)
- TestAutoTriggerEvents: 4 tests
- TestFilePopup: 4 tests
- TestQueueLiveUpdates: 2 tests
2026-01-02 03:24:39 +00:00
Mervin Praison 3d4ca1b7c0 docs(tui): update help screen with safe keyboard defaults and backslash commands
## Help Screen Updates
- Document backslash (\) command popup
- Show safe keyboard defaults (no Ctrl/Fn by default)
- Add command mode (: prefix) section
- Add slash commands (/ prefix) section
- Document optional Ctrl/Fn key env vars

## Keyboard Shortcuts Documented
- \ - Show command popup
- Enter - Send message
- Shift+Enter - New line
- q - Quit (when not typing)
- ? - Help
- : - Command mode
- / - Search mode
- Escape - Cancel/Close
2026-01-02 03:07:50 +00:00
Mervin Praison 1180aa38bd feat(tui): add backslash command popup for Claude Code-style UX
## New Features

### Command Popup Widget (command_popup.py)
- CommandPopupWidget: Overlay showing all available commands
- Triggered by backslash (\) key - matches Claude Code UX
- Searchable/filterable command list
- Keyboard navigation (arrows, Enter, Escape)
- 16 default commands with aliases

### MainScreen Integration
- Backslash binding shows command popup
- Escape dismisses popup or cancels input
- Command selection executes command
- Popup layer CSS for proper overlay positioning

## Commands Available
- help, clear, model, cost, tokens, queue
- cancel, settings, sessions, tools, exit
- plan, undo, diff, commit, map

## Tests
- 21 tests passing
- TestCommandPopup: 4 new tests
- TestMainScreenBackslash: 3 new tests

## Files Changed
- NEW: widgets/command_popup.py
- screens/main.py: Add popup integration
- tests/unit/tui/test_tui_widgets.py: Add tests
2026-01-02 03:06:44 +00:00
Mervin Praison 65f65e5c7b feat(tui): command mode routing and safe keyboard defaults
## Changes

### Command System (: and / prefix)
- Both : and / now work as command prefixes
- Local command handling in MainScreen._handle_local_command()
- Commands: :quit, :clear, :help, :tools, :queue, :settings, :cancel
- Aliases work: :q, :cl, :h, :t, :qu

### Keyboard Defaults
- TUIApp bindings now hidden in footer (show=False)
- Ctrl+Q/C kept for emergency exit but not displayed
- MainScreen shows safe defaults: q, :, ?, Esc

### Help Text Updated
- Shows safe keyboard shortcuts
- Documents command mode usage
- Removed references to Ctrl/Fn keys

## Files Changed
- app.py: Hide Ctrl bindings in footer, update help text
- screens/main.py: Add _handle_local_command() for : commands
- widgets/composer.py: Support both / and : as command prefixes

## Tests
- 14 tests passing
- Command parsing verified
- TUI app startup verified
2026-01-02 02:48:50 +00:00
Mervin Praison f81a12d5e8 feat(tui): comprehensive UX fixes - safe keyboard defaults, auto-scroll, tools panel
BREAKING CHANGE: Keyboard shortcuts changed to safe defaults

## Changes

### 1. Auto-scroll Fix
- Use call_after_refresh() to ensure scroll happens after DOM updates
- Fixed in ChatWidget.add_message() and update_streaming()

### 2. Tools Panel Fix
- Added ScrollableContainer for tools list (no more truncation)
- Shows count of available tools
- All 13 tools now visible with scrolling

### 3. Safe Keyboard Defaults (NO Ctrl/Fn keys by default)
- Single-char shortcuts: q (quit), : (command mode), ? (help)
- Command mode: :quit, :clear, :help, :tools, :queue, :settings
- Ctrl keys (^Q, ^C, ^L) - DISABLED by default (opt-in)
- Function keys (F1-F5) - DISABLED by default (opt-in)

### 4. Configuration System
- New TUIConfig class in config.py
- Environment variables:
  - PRAISONAI_TUI_CTRL_KEYS=1 (enable Ctrl keys)
  - PRAISONAI_TUI_FN_KEYS=1 (enable Fn keys)
  - PRAISONAI_TUI_LEADER=; (custom leader key)

### 5. Non-blocking Input
- Queue accepts messages while agents are running
- Already implemented in previous commit

## Files Changed
- widgets/chat.py - Auto-scroll fix
- widgets/tool_panel.py - ScrollableContainer, show all tools
- widgets/composer.py - set_text() method for command mode
- screens/main.py - Safe default bindings, command mode actions
- config.py - NEW: TUIConfig, command parsing, help text

## Tests
- 14 tests passing
- New tests for TUIConfig and safe bindings
2026-01-02 02:39:16 +00:00
Mervin Praison cf6f5fbefe feat(tui): fix help screen, Enter key, and add TDD tests
Fixes:
- Add HelpScreen for F1 key (was missing, caused KeyError)
- Fix Enter key to send message using custom SubmitTextArea class
- Fix Shift+Enter to insert newline properly
- Register HelpScreen in TUIApp.SCREENS

Tests:
- Add TDD test suite for TUI widgets (12 tests)
- Test Enter key submission
- Test Shift+Enter newline
- Test F1 help screen
- Test Ctrl key bindings

All 13 default tools (ACP, LSP, Basic) load correctly.
Queue system tested and working with real API keys.
2026-01-02 02:23:02 +00:00
Mervin Praison 908d7710db feat(tui): improve TUI usability and add tools panel display
- Enter key now sends message (was Ctrl+Enter)
- Shift+Enter inserts newline without backslash
- Allow typing while response is generating (queue multiple messages)
- Display available tools in Tools panel
- Add edit/delete functionality to queue panel
- Auto-scroll already working in chat widget

TUI Architecture:
- TUIApp -> QueueManager -> WorkerPool -> Agent (from praisonaiagents)
- Tools stored in runtime registry to avoid JSON serialization issues
2026-01-02 02:08:22 +00:00
Mervin Praison 7d5b41e31f fix: use 'llm' parameter instead of 'model' for Agent initialization in queue worker
- Agent class uses 'llm' parameter, not 'model'
- This fixes 'unexpected keyword argument model' error in TUI mode
2026-01-01 19:57:31 +00:00
Mervin Praison e46a0bc9f3 Release v3.0.1 2025-12-31 19:29:02 +00:00
Mervin Praison 911483c92c Release v3.0.0 2025-12-31 18:55:59 +00:00
Mervin Praison 4ffb9d6e3f Release v2.9.2 2025-12-31 17:17:17 +00:00
Mervin Praison b686e5b00f Release v2.9.1 2025-12-31 16:42:54 +00:00
Mervin Praison d8d84ee3e3 Release v2.9.0 2025-12-31 15:49:07 +00:00
Mervin Praison 9f2c4d10b5 Release v2.8.9 2025-12-31 14:05:30 +00:00
Mervin Praison 42a86c831f Release v2.8.8 2025-12-31 12:46:10 +00:00
Mervin Praison 48443b11d6 Release v2.8.7 2025-12-31 12:28:44 +00:00
Mervin Praison 71745aa14a Release v2.8.6 2025-12-31 12:27:20 +00:00
Mervin Praison 4f309ec16d Release v2.8.5 2025-12-31 12:00:29 +00:00
Mervin Praison e48afc8993 Release v2.8.4 2025-12-31 11:39:37 +00:00
Mervin Praison 90950f8889 Release v2.8.3 2025-12-31 11:02:57 +00:00
Mervin Praison 56f36e2593 feat: bump version to 1.4.0 and add AI SDK integration dependencies
- Update package.json version from 1.3.6 to 1.4.0
- Update FEATURE_PARITY_TRACKER.json to v1.3.0 (Phase 5 Complete)
- Add AI SDK wrapper features: generateText, streamText, generateObject, streamObject, generateImage, embed, embedMany
- Add server adapters for http, express, hono, fastify, nest
- Add Next.js integration with createRouteHandler and createPagesHandler
- Add MCP client tools: createMCP, getMCPClient, mcpToolsToAITools
2025-12-31 01:39:39 +00:00
Mervin Praison 7aa67ad27c Release v2.8.2 2025-12-30 23:17:48 +00:00
Mervin Praison 307df443ef Release v2.8.1 2025-12-30 17:47:16 +00:00
Mervin Praison 6434a87b60 chore: remove completed AI SDK verification doc and bump version to 1.3.6
- Remove ai-sdk-backbone-verification.md (all tasks completed)
- Update package.json version from 1.3.5 to 1.3.6
- Refactor agent classes to use lazy-loaded AI SDK backend via resolveBackend()
- Convert direct createProvider() calls to async getProvider() pattern
- Add provider promise caching to prevent duplicate backend initialization
- Update ContextAgent, EnhancedAgent, ImageAgent, and PromptExpanderAgent classes
2025-12-30 17:15:40 +00:00
Mervin Praison 97d5b6db0d feat: add AI SDK backend support and embedding capabilities to Agent class
- Add AI SDK backend resolution with lazy initialization via getBackend()
- Add embed() method supporting single/batch text embeddings
- Add getBackendSource() to check backend type (ai-sdk/native/custom/legacy)
- Add getModel() to retrieve agent's model string
- Parse model string to extract provider/model ID for backend selection
- Use AI SDK backend for non-OpenAI providers (streaming and non-streaming)
- Export backend resolver an
2025-12-30 16:52:46 +00:00
Mervin Praison 4996a9f9fa Merge remote-tracking branch 'origin/pr-1097-fixes' 2025-12-30 16:15:49 +00:00
Mervin Praison 54f4a96cba test: add comprehensive ImportError tests for MCP optional import
- Add test for MCP class ImportError when mcp package unavailable
- Add test for SSEMCPClient ImportError when mcp unavailable
- Add test for HTTPStreamMCPClient ImportError when mcp unavailable
- Add test for HTTPStreamMCPClient ImportError when aiohttp unavailable
- Fix unused import lint warnings (remove sys, add noqa for MCP)

These tests verify that actionable error messages with install instructions
are shown when users try to use MCP functionality without the mcp package.
2025-12-30 16:14:15 +00:00
Mervin Praison e59be8f0fd Merge pull request #1096 from majiayu000/fix/issue-1091-mcp-optional-import
fix: make MCP package import optional to prevent errors when not installed
2025-12-30 16:09:06 +00:00
246 changed files with 1084 additions and 44458 deletions

No files matched your search

@@ -1,58 +0,0 @@
name: Update Feature Parity Tracker
on:
push:
branches: [ "main" ]
paths:
- 'src/praisonai-agents/praisonaiagents/**/*.py'
- 'src/praisonai-ts/src/**/*.ts'
- 'src/praisonai-rust/src/**/*.rs'
- '.github/workflows/update-parity-tracker.yml'
permissions:
contents: write
jobs:
update-parity-tracker:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: main
persist-credentials: true
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Generate parity trackers
run: |
cd $GITHUB_WORKSPACE
python3 src/praisonai/scripts/generate_parity_tracker.py
- name: Check for changes
id: check_changes
run: |
if git diff --quiet src/praisonai-ts/FEATURE_PARITY_TRACKER.json src/praisonai-ts/PARITY.md src/praisonai-rust/FEATURE_PARITY_TRACKER.json src/praisonai-rust/PARITY.md 2>/dev/null; then
echo "changes=false" >> $GITHUB_OUTPUT
echo "No changes to parity trackers"
else
echo "changes=true" >> $GITHUB_OUTPUT
echo "Parity trackers have been updated"
git diff src/praisonai-ts/FEATURE_PARITY_TRACKER.json | head -50
fi
- name: Commit and push changes
if: steps.check_changes.outputs.changes == 'true'
uses: stefanzweifel/git-auto-commit-action@v5
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>"
+1 -8
View File
@@ -84,11 +84,4 @@ issue_details.json
.mcpregistry_*
*.log
.internal
.praisonai
# Rust / Cargo
target/
Cargo.lock
**/*.rs.bk
*.pdb
.internal
+6 -17
View File
@@ -58,7 +58,6 @@ Methods:
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">generate_task</a>() -> 'Task'</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">get_available_tools</a>() -> List[Any]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">get_history_size</a>() -> int</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">get_learn_context</a>() -> str</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">get_memory_context</a>(query: Optional[str] = None) -> str</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">get_recommended_stage</a>(prompt: str) -> str</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">get_rules_context</a>(file_path: Optional[str] = None, include_manual: Optional[List[str]] = None) -> str</code>
@@ -82,8 +81,6 @@ Methods:
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">run</a>(prompt: str, **kwargs)</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">run_autonomous</a>(prompt: str, max_iterations: Optional[int] = None, timeout_seconds: Optional[float] = None, completion_promise: Optional[str] = None, clear_context: bool = False)</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">run_autonomous_async</a>(prompt: str, max_iterations: Optional[int] = None, timeout_seconds: Optional[float] = None, completion_promise: Optional[str] = None, clear_context: bool = False)</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">run_until</a>(prompt: str, criteria: str, threshold: float = 8.0, max_iterations: int = 5, mode: str = 'optimize', on_iteration: Optional[Callable[[Any], None]] = None, verbose: bool = False) -> 'EvaluationLoopResult'</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">run_until_async</a>(prompt: str, criteria: str, threshold: float = 8.0, max_iterations: int = 5, mode: str = 'optimize', on_iteration: Optional[Callable[[Any], None]] = None, verbose: bool = False) -> 'EvaluationLoopResult'</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">session_id</a>() -> Optional[str]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">skill_manager</a>()</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">start</a>(prompt: str = None, **kwargs)</code>
@@ -211,15 +208,10 @@ from praisonaiagents import Loop, Parallel, Pipeline, Repeat, Route, Task, Workf
Methods:
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">depends_on</a>()</code>
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">depends_on</a>(value)</code>
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">evaluate_when</a>(context: Dict[str, Any]) -> bool</code>
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">execute_callback</a>(task_output: TaskOutput) -> None</code>
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">execute_callback_sync</a>(task_output: TaskOutput) -> None</code>
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">get_next_task</a>(context: Dict[str, Any]) -> Optional[str]</code>
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">initialize_memory</a>()</code>
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">store_in_memory</a>(content: str, agent_name: str = None, task_id: str = None)</code>
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">to_dict</a>() -> Dict[str, Any]</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">loop</a>(step: Any = None, steps: Optional[List[Any]] = None, over: Optional[str] = None, from_csv: Optional[str] = None, from_file: Optional[str] = None, var_name: str = 'item', parallel: bool = False, max_workers: Optional[int] = None, output_variable: Optional[str] = None) -> Loop</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">parallel</a>(steps: List) -> Parallel</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">repeat</a>(step: Any, until: Optional[Callable[[WorkflowContext], bool]] = None, max_iterations: int = 10) -> Repeat</code>
@@ -576,7 +568,7 @@ Methods:
Types:
```python
from praisonaiagents import ContextManager
from praisonaiagents import ContextManager, ManagerConfig
```
Methods:
@@ -594,16 +586,20 @@ Methods:
* <code title="class ContextManager">ContextManager.<a href="./src/praisonai-agents/praisonaiagents/context/manager.py">reset</a>() -> None</code>
* <code title="class ContextManager">ContextManager.<a href="./src/praisonai-agents/praisonaiagents/context/manager.py">set_tool_budget</a>(tool_name: str, max_tokens: int, protected: bool = False) -> None</code>
* <code title="class ContextManager">ContextManager.<a href="./src/praisonai-agents/praisonaiagents/context/manager.py">truncate_tool_output</a>(tool_name: str, output: str) -> str</code>
* <code title="class ManagerConfig">ManagerConfig.<a href="./src/praisonai-agents/praisonaiagents/context/manager.py">from_env</a>() -> 'ManagerConfig'</code>
* <code title="class ManagerConfig">ManagerConfig.<a href="./src/praisonai-agents/praisonaiagents/context/manager.py">merge</a>(**overrides) -> 'ManagerConfig'</code>
* <code title="class ManagerConfig">ManagerConfig.<a href="./src/praisonai-agents/praisonaiagents/context/manager.py">to_dict</a>() -> Dict[str, Any]</code>
# Config
Types:
```python
from praisonaiagents import AutonomyLevel, CachingConfig, ExecutionConfig, ExecutionPreset, GuardrailConfig, HooksConfig, KnowledgeConfig, MemoryConfig, MultiAgentExecutionConfig, MultiAgentHooksConfig, MultiAgentMemoryConfig, MultiAgentOutputConfig, MultiAgentPlanningConfig, OutputConfig, OutputPreset, PlanningConfig, ReflectionConfig, SkillsConfig, TemplateConfig, WebConfig, WebSearchProvider
from praisonaiagents import AutonomyConfig, AutonomyLevel, CachingConfig, ExecutionConfig, ExecutionPreset, GuardrailConfig, HooksConfig, KnowledgeConfig, MemoryConfig, MultiAgentExecutionConfig, MultiAgentHooksConfig, MultiAgentMemoryConfig, MultiAgentOutputConfig, MultiAgentPlanningConfig, OutputConfig, OutputPreset, PlanningConfig, ReflectionConfig, SkillsConfig, TemplateConfig, WebConfig, WebSearchProvider
```
Methods:
* <code title="class AutonomyConfig">AutonomyConfig.<a href="./src/praisonai-agents/praisonaiagents/config/feature_configs.py">to_dict</a>() -> Dict[str, Any]</code>
* <code title="class CachingConfig">CachingConfig.<a href="./src/praisonai-agents/praisonaiagents/config/feature_configs.py">to_dict</a>() -> Dict[str, Any]</code>
* <code title="class ExecutionConfig">ExecutionConfig.<a href="./src/praisonai-agents/praisonaiagents/config/feature_configs.py">to_dict</a>() -> Dict[str, Any]</code>
* <code title="class GuardrailConfig">GuardrailConfig.<a href="./src/praisonai-agents/praisonaiagents/config/feature_configs.py">to_dict</a>() -> Dict[str, Any]</code>
@@ -888,16 +884,12 @@ 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 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>
* <code title="cli">praisonai plugins doctor <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
* <code title="cli">praisonai plugins enable <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
* <code title="cli">praisonai plugins info <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
* <code title="cli">praisonai plugins install <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
* <code title="cli">praisonai plugins list <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
* <code title="cli">praisonai plugins plugins-callback <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
* <code title="cli">praisonai plugins remove <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
* <code title="cli">praisonai profile imports <a href="./src/praisonai/praisonai/cli/commands/profile.py">--help</a></code>
* <code title="cli">praisonai profile optimize <a href="./src/praisonai/praisonai/cli/commands/profile.py">--help</a></code>
* <code title="cli">praisonai profile profile-callback <a href="./src/praisonai/praisonai/cli/commands/profile.py">--help</a></code>
@@ -1005,7 +997,6 @@ Methods:
* <code title="cli">praisonai traces list <a href="./src/praisonai/praisonai/cli/commands/traces.py">--help</a></code>
* <code title="cli">praisonai traces status <a href="./src/praisonai/praisonai/cli/commands/traces.py">--help</a></code>
* <code title="cli">praisonai train agents <a href="./src/praisonai/praisonai/cli/commands/train.py">--help</a></code>
* <code title="cli">praisonai train apply <a href="./src/praisonai/praisonai/cli/commands/train.py">--help</a></code>
* <code title="cli">praisonai train list <a href="./src/praisonai/praisonai/cli/commands/train.py">--help</a></code>
* <code title="cli">praisonai train llm <a href="./src/praisonai/praisonai/cli/commands/train.py">--help</a></code>
* <code title="cli">praisonai train show <a href="./src/praisonai/praisonai/cli/commands/train.py">--help</a></code>
@@ -1110,8 +1101,6 @@ export { // Adapters
OBSERVABILITY_TOOLS, // Global adapter management
setObservabilityAdapter, // Types
type SpanKind, ConsoleObservabilityAdapter, MemoryObservabilityAdapter, clearAdapterCache, createConsoleAdapter, createMemoryAdapter, createObservabilityAdapter, getObservabilityAdapter, getObservabilityToolInfo, hasObservabilityToolEnvVar, listObservabilityTools, noopAdapter, resetObservabilityAdapter, trace } from "./observability";
export { AgentApp, AgentAppConfig, AgentAppProtocol, AgentOS, AgentOSConfig, AgentOSProtocol, DEFAULT_AGENTOS_CONFIG, mergeConfig } from "./os";
export type { AgentAppOptions, AgentOSOptions } from "./os";
export { Plan, PlanStep, PlanStorage, PlanningAgent, TaskAgent, TodoItem, TodoList, createPlan, createPlanStorage, createPlanningAgent, createTaskAgent, createTodoList } from "./planning";
export { SkillManager, createSkillManager, parseSkillFile } from "./skills";
export { AgentTelemetry, PerformanceMonitor, TelemetryCollector, TelemetryIntegration, cleanupTelemetry, createAgentTelemetry, createConsoleSink, createHTTPSink, createPerformanceMonitor, createTelemetryIntegration, disableTelemetry, enableTelemetry, getTelemetry } from "./telemetry";
+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.4.4" \
"praisonai>=4.2.1" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.4.4" \
"praisonai>=4.2.1" \
"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.4.4" \
"praisonai>=4.2.1" \
"praisonai[ui]" \
"praisonai[crewai]"
-43
View File
@@ -1,43 +0,0 @@
# EvaluationLoop Examples
Examples demonstrating iterative evaluation and improvement patterns.
## Quick Start
```bash
pip install praisonaiagents
export OPENAI_API_KEY=your-key
```
## Examples
| File | Description |
|------|-------------|
| `basic_evaluation_loop.py` | Simple loop: Run → Judge → Improve |
| `multi_step_task_loop.py` | Sequential tasks with context passing |
| `recipe_optimization_loop.py` | CLI-based recipe optimization |
## Pattern
```
┌─────────┐ ┌─────────┐ ┌──────────┐
│ Agent │ ──► │ Judge │ ──► │ Feedback │
│ runs │ │ scores │ │ improves │
└─────────┘ └─────────┘ └──────────┘
▲ │
└───────────────────────────────┘
(repeat until 8.0+)
```
## Run Examples
```bash
# Basic loop
python basic_evaluation_loop.py
# Multi-step with context
python multi_step_task_loop.py
# CLI recipe loop
python recipe_optimization_loop.py "research AI trends"
```
@@ -1,73 +0,0 @@
"""
Basic EvaluationLoop Example
Demonstrates the iterative evaluation loop pattern:
Run agent → Judge output → Improve → Repeat until threshold met.
Usage:
pip install praisonaiagents
export OPENAI_API_KEY=your-key
python basic_evaluation_loop.py
"""
from praisonaiagents import Agent
from praisonaiagents.eval import EvaluationLoop
def on_iteration(result):
"""Callback showing progress after each iteration."""
status = "✅" if result.score >= 8.0 else "⚠️" if result.score >= 6.0 else "❌"
print(f"{status} Iteration {result.iteration}: {result.score:.1f}/10")
if result.findings:
print(f" → {result.findings[0][:60]}...")
def main():
# Create an agent
agent = Agent(
name="writer",
instructions="You are a technical content writer. Write clear, accurate explanations."
)
# Run evaluation loop
loop = EvaluationLoop(
agent=agent,
criteria="""
Evaluate the response on:
1. CLARITY: Is the explanation easy to understand?
2. ACCURACY: Is the technical content correct?
3. COMPLETENESS: Does it cover the key aspects?
4. EXAMPLES: Does it include practical examples?
Score 1-10 based on all criteria.
""",
threshold=8.0,
max_iterations=5,
on_iteration=on_iteration,
verbose=False,
)
print("╔════════════════════════════════════════════════════════════╗")
print("║ Basic EvaluationLoop Example ║")
print("║ Pattern: Run → Judge → Improve → Repeat ║")
print("║ Target: 8.0/10 score ║")
print("╚════════════════════════════════════════════════════════════╝\n")
result = loop.run("Explain how API rate limiting works and why it matters")
print("\n" + "─" * 60)
print("RESULTS:")
print("─" * 60)
print(f"Success: {result.success}")
print(f"Final Score: {result.final_score:.1f}/10")
print(f"Iterations: {result.num_iterations}")
print(f"Score History: {[f'{s:.1f}' for s in result.score_history]}")
print(f"Duration: {result.total_duration_seconds:.2f}s")
print("\n" + "─" * 60)
print("FINAL OUTPUT (first 500 chars):")
print("─" * 60)
print(result.final_output[:500] + "..." if len(result.final_output) > 500 else result.final_output)
if __name__ == "__main__":
main()
@@ -1,144 +0,0 @@
"""
Multi-Step Task Loop Example
Demonstrates evaluating sequential task steps where each step can
reference output from previous steps.
Pattern:
1. Research → Evaluate → Improve
2. Analyze (using research) → Evaluate → Improve
3. Report (using analysis) → Evaluate → Improve
Usage:
pip install praisonaiagents
export OPENAI_API_KEY=your-key
python multi_step_task_loop.py
"""
from praisonaiagents import Agent
from praisonaiagents.eval import EvaluationLoop
def on_iteration(result):
"""Show iteration progress."""
status = "✅" if result.score >= 8.0 else "⚠️" if result.score >= 6.0 else "❌"
print(f" {status} Iteration {result.iteration}: {result.score:.1f}/10")
def run_step(step_name: str, agent: Agent, task: str, criteria: str, context: str = "") -> dict:
"""Run a single step through an evaluation loop."""
print(f"\n{'─'*60}")
print(f"STEP: {step_name}")
print(f"{'─'*60}")
# Include context from previous step if provided
full_task = task
if context:
full_task = f"{task}\n\nContext from previous step:\n{context[:1000]}"
loop = EvaluationLoop(
agent=agent,
criteria=criteria,
threshold=8.0,
max_iterations=3,
on_iteration=on_iteration,
verbose=False,
)
result = loop.run(full_task)
print(f" → Final: {result.final_score:.1f}/10 ({result.num_iterations} iterations)")
return {
"name": step_name,
"output": result.final_output,
"score": result.final_score,
"success": result.success,
"iterations": result.num_iterations,
}
def main():
print("╔════════════════════════════════════════════════════════════╗")
print("║ Multi-Step Task Loop Example ║")
print("║ Pattern: Step1 → Step2 → Step3 (with context passing) ║")
print("║ Each step: Run → Judge → Improve until 8.0+ ║")
print("╚════════════════════════════════════════════════════════════╝")
# Create specialized agents
researcher = Agent(
name="researcher",
instructions="You are a research specialist. Find and summarize key information."
)
analyst = Agent(
name="analyst",
instructions="You are an analyst. Identify patterns, insights, and implications."
)
writer = Agent(
name="writer",
instructions="You are a report writer. Create clear, actionable summaries."
)
# Define task steps
steps = [
{
"name": "Research",
"agent": researcher,
"task": "Research the top 3 trends in AI for 2024-2025. Provide brief descriptions.",
"criteria": "Covers 3 distinct trends with accurate descriptions.",
},
{
"name": "Analysis",
"agent": analyst,
"task": "Analyze the implications of these AI trends for software development.",
"criteria": "Identifies actionable insights and potential challenges.",
},
{
"name": "Report",
"agent": writer,
"task": "Create a brief executive summary (3-4 bullet points) for a CTO.",
"criteria": "Concise, actionable, appropriate for executive audience.",
},
]
# Run all steps with context passing
results = []
context = ""
for step in steps:
result = run_step(
step_name=step["name"],
agent=step["agent"],
task=step["task"],
criteria=step["criteria"],
context=context,
)
results.append(result)
context = result["output"] # Pass output as context to next step
# Summary
print("\n" + "═"*60)
print("SUMMARY")
print("═"*60)
total_iterations = sum(r["iterations"] for r in results)
avg_score = sum(r["score"] for r in results) / len(results)
all_success = all(r["success"] for r in results)
for r in results:
status = "✅" if r["success"] else "❌"
print(f" {status} {r['name']}: {r['score']:.1f}/10 ({r['iterations']} iterations)")
print(f"\n Total Iterations: {total_iterations}")
print(f" Average Score: {avg_score:.1f}/10")
print(f" All Steps Passed: {'Yes' if all_success else 'No'}")
print("\n" + "─"*60)
print("FINAL REPORT OUTPUT:")
print("─"*60)
print(results[-1]["output"][:600])
if __name__ == "__main__":
main()
@@ -1,183 +0,0 @@
"""
Recipe Optimization Loop (CLI-based)
Demonstrates the 5-cycle recipe improvement pattern using CLI commands:
Create (once) → Run → Judge → Optimize (loop 5x)
Usage:
pip install praisonai
export OPENAI_API_KEY=your-key
export TAVILY_API_KEY=your-key
python recipe_optimization_loop.py "research AI trends in healthcare"
"""
import subprocess
import sys
import os
import json
from pathlib import Path
def run_cli(cmd: str, cwd: str = None) -> tuple[int, str, str]:
"""Run a CLI command and return (exit_code, stdout, stderr)."""
result = subprocess.run(
cmd,
shell=True,
capture_output=True,
text=True,
cwd=cwd,
)
return result.returncode, result.stdout, result.stderr
def create_recipe(goal: str, work_dir: str) -> str:
"""Create a recipe once. Returns recipe name."""
print(f"\n{'═'*60}")
print("STEP 1: CREATE RECIPE (once)")
print(f"{'═'*60}")
print(f"Goal: {goal}")
cmd = f'praisonai recipe create "{goal}" --no-optimize'
code, stdout, stderr = run_cli(cmd, cwd=work_dir)
if code != 0:
print(f"❌ Failed to create recipe: {stderr}")
return None
# Extract recipe name from output
for line in stdout.split('\n'):
if 'Created recipe:' in line:
recipe_name = line.split('Created recipe:')[1].strip()
print(f"✅ Created: {recipe_name}")
return recipe_name
print(f"✅ Recipe created")
return None
def run_recipe(recipe_name: str, trace_name: str, work_dir: str) -> tuple[bool, str]:
"""Run the recipe and save trace. Returns (success, output)."""
cmd = f'praisonai recipe run {recipe_name} --save --name {trace_name}'
code, stdout, stderr = run_cli(cmd, cwd=work_dir)
success = code == 0 and 'status: completed' in stdout.lower()
return success, stdout
def judge_recipe(trace_name: str, work_dir: str) -> tuple[float, str]:
"""Judge the trace. Returns (score, feedback)."""
cmd = f'praisonai recipe judge {trace_name}'
code, stdout, stderr = run_cli(cmd, cwd=work_dir)
# Extract score from output
score = 0.0
for line in stdout.split('\n'):
if 'Overall Score:' in line:
try:
score = float(line.split(':')[1].strip().split('/')[0])
except:
pass
return score, stdout
def optimize_recipe(recipe_name: str, iterations: int, work_dir: str) -> tuple[float, str]:
"""Optimize the recipe. Returns (final_score, output)."""
cmd = f'praisonai recipe optimize {recipe_name} --iterations {iterations}'
code, stdout, stderr = run_cli(cmd, cwd=work_dir)
# Extract final score
score = 0.0
for line in stdout.split('\n'):
if 'Final score:' in line or 'score:' in line.lower():
try:
score = float(line.split(':')[1].strip().split('/')[0])
except:
pass
return score, stdout
def run_optimization_loop(goal: str, num_cycles: int = 5, threshold: float = 8.0):
"""Run the full Create → Run → Judge → Optimize loop."""
print("╔════════════════════════════════════════════════════════════╗")
print("║ Recipe Optimization Loop (CLI-based) ║")
print("║ Pattern: Create (once) → Run → Judge → Optimize (loop) ║")
print(f"║ Cycles: {num_cycles} | Threshold: {threshold}/10 ║")
print("╚════════════════════════════════════════════════════════════╝")
# Create work directory
work_dir = Path("/tmp/recipe-optimization-loop")
work_dir.mkdir(parents=True, exist_ok=True)
os.chdir(work_dir)
# Step 1: Create recipe (once)
recipe_name = create_recipe(goal, str(work_dir))
if not recipe_name:
print("Failed to create recipe. Exiting.")
return
# Step 2-N: Run → Judge → Optimize loop
results = []
for cycle in range(1, num_cycles + 1):
print(f"\n{'─'*60}")
print(f"CYCLE {cycle}/{num_cycles}")
print(f"{'─'*60}")
trace_name = f"cycle-{cycle}"
# Run
print(f" 📝 Running recipe...")
success, run_output = run_recipe(recipe_name, trace_name, str(work_dir))
run_status = "✅" if success else "⚠️"
print(f" {run_status} Run complete")
# Judge
print(f" ⚖️ Judging output...")
score, judge_output = judge_recipe(trace_name, str(work_dir))
score_status = "✅" if score >= threshold else "⚠️" if score >= 6.0 else "❌"
print(f" {score_status} Score: {score}/10")
# Check if threshold met
if score >= threshold:
print(f" 🎉 Threshold {threshold}/10 reached!")
results.append({"cycle": cycle, "score": score, "status": "passed"})
break
# Optimize
print(f" 🔧 Optimizing recipe...")
opt_score, opt_output = optimize_recipe(recipe_name, 1, str(work_dir))
print(f" → Optimized score: {opt_score}/10")
results.append({
"cycle": cycle,
"initial_score": score,
"optimized_score": opt_score,
"status": "optimized" if opt_score > score else "no_change"
})
# Summary
print(f"\n{'═'*60}")
print("SUMMARY")
print(f"{'═'*60}")
for r in results:
if "initial_score" in r:
print(f" Cycle {r['cycle']}: {r['initial_score']} → {r['optimized_score']}/10")
else:
print(f" Cycle {r['cycle']}: {r['score']}/10 ✅ PASSED")
final_score = results[-1].get("optimized_score", results[-1].get("score", 0))
success = final_score >= threshold
print(f"\n Final Score: {final_score}/10")
print(f" Success: {'Yes ✅' if success else 'No ❌'}")
print(f" Recipe: {work_dir}/{recipe_name}")
return results
if __name__ == "__main__":
goal = sys.argv[1] if len(sys.argv) > 1 else "research latest AI trends and create a summary report"
run_optimization_loop(goal, num_cycles=5, threshold=8.0)
+1 -3
View File
@@ -89,6 +89,4 @@ graph.py
chroma_db/
.qodo
*_COMPARISON_ANALYSIS.md
*_REPORT.md
.praisonai
*_REPORT.md
@@ -1,9 +0,0 @@
{
"short_term_limit": 100,
"long_term_limit": 1000,
"episodic_retention_days": 30,
"enable_summarization": false,
"summarization_threshold": 50,
"importance_threshold": 0.7,
"auto_promote": true
}
@@ -1,21 +0,0 @@
{
"root_path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp6_tn0h1t",
"total_files": 0,
"total_tokens": 11,
"levels": 3,
"built_at": "2026-02-02T17:47:19.241146",
"nodes": {
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp6_tn0h1t": {
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp6_tn0h1t",
"level": 3,
"summary": "Project with 0 folders and 0 root files: ",
"children": [],
"token_count": 11,
"created_at": "2026-02-02T17:47:19.241224",
"metadata": {
"folder_count": 0,
"root_file_count": 0
}
}
}
}
@@ -1,35 +0,0 @@
{
"root_path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp7wxr57wm",
"total_files": 1,
"total_tokens": 16,
"levels": 2,
"built_at": "2026-02-02T17:47:19.243732",
"nodes": {
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp7wxr57wm/src/app.py": {
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp7wxr57wm/src/app.py",
"level": 1,
"summary": "# Application code",
"children": [],
"token_count": 5,
"created_at": "2026-02-02T17:47:19.243833",
"metadata": {
"filename": "app.py",
"relative_path": "src/app.py"
}
},
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp7wxr57wm/src": {
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp7wxr57wm/src",
"level": 2,
"summary": "Folder contains 1 files: # Application code",
"children": [
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp7wxr57wm/src/app.py"
],
"token_count": 11,
"created_at": "2026-02-02T17:47:19.243852",
"metadata": {
"folder_name": "src",
"file_count": 1
}
}
}
}
@@ -1,21 +0,0 @@
{
"root_path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp8qwfp4zl",
"total_files": 0,
"total_tokens": 11,
"levels": 3,
"built_at": "2026-02-02T17:49:37.074095",
"nodes": {
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp8qwfp4zl": {
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp8qwfp4zl",
"level": 3,
"summary": "Project with 0 folders and 0 root files: ",
"children": [],
"token_count": 11,
"created_at": "2026-02-02T17:49:37.074201",
"metadata": {
"folder_count": 0,
"root_file_count": 0
}
}
}
}
@@ -1,33 +0,0 @@
{
"root_path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp9nw2kfoj",
"total_files": 2,
"total_tokens": 14,
"levels": 1,
"built_at": "2026-02-02T17:47:19.242486",
"nodes": {
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp9nw2kfoj/utils.py": {
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp9nw2kfoj/utils.py",
"level": 1,
"summary": "def helper(): return True",
"children": [],
"token_count": 7,
"created_at": "2026-02-02T17:47:19.242582",
"metadata": {
"filename": "utils.py",
"relative_path": "utils.py"
}
},
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp9nw2kfoj/main.py": {
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp9nw2kfoj/main.py",
"level": 1,
"summary": "def main(): print('Hello')",
"children": [],
"token_count": 7,
"created_at": "2026-02-02T17:47:19.242630",
"metadata": {
"filename": "main.py",
"relative_path": "main.py"
}
}
}
}
@@ -1,35 +0,0 @@
{
"root_path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp_dbottar",
"total_files": 1,
"total_tokens": 16,
"levels": 2,
"built_at": "2026-02-02T17:49:37.077857",
"nodes": {
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp_dbottar/src/app.py": {
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp_dbottar/src/app.py",
"level": 1,
"summary": "# Application code",
"children": [],
"token_count": 5,
"created_at": "2026-02-02T17:49:37.077985",
"metadata": {
"filename": "app.py",
"relative_path": "src/app.py"
}
},
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp_dbottar/src": {
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp_dbottar/src",
"level": 2,
"summary": "Folder contains 1 files: # Application code",
"children": [
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp_dbottar/src/app.py"
],
"token_count": 11,
"created_at": "2026-02-02T17:49:37.078011",
"metadata": {
"folder_name": "src",
"file_count": 1
}
}
}
}
@@ -1,33 +0,0 @@
{
"root_path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpp5k48vv4",
"total_files": 2,
"total_tokens": 14,
"levels": 1,
"built_at": "2026-02-02T17:49:37.075925",
"nodes": {
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpp5k48vv4/utils.py": {
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpp5k48vv4/utils.py",
"level": 1,
"summary": "def helper(): return True",
"children": [],
"token_count": 7,
"created_at": "2026-02-02T17:49:37.076036",
"metadata": {
"filename": "utils.py",
"relative_path": "utils.py"
}
},
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpp5k48vv4/main.py": {
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpp5k48vv4/main.py",
"level": 1,
"summary": "def main(): print('Hello')",
"children": [],
"token_count": 7,
"created_at": "2026-02-02T17:49:37.076227",
"metadata": {
"filename": "main.py",
"relative_path": "main.py"
}
}
}
}
@@ -1,33 +0,0 @@
{
"root_path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpwwb01831",
"total_files": 2,
"total_tokens": 16,
"levels": 1,
"built_at": "2026-02-02T17:49:37.079504",
"nodes": {
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpwwb01831/javascript.js": {
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpwwb01831/javascript.js",
"level": 1,
"summary": "JavaScript web development",
"children": [],
"token_count": 7,
"created_at": "2026-02-02T17:49:37.079612",
"metadata": {
"filename": "javascript.js",
"relative_path": "javascript.js"
}
},
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpwwb01831/python.py": {
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpwwb01831/python.py",
"level": 1,
"summary": "Python programming language code",
"children": [],
"token_count": 9,
"created_at": "2026-02-02T17:49:37.079658",
"metadata": {
"filename": "python.py",
"relative_path": "python.py"
}
}
}
}
@@ -1,33 +0,0 @@
{
"root_path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpwzdvf3_7",
"total_files": 2,
"total_tokens": 16,
"levels": 1,
"built_at": "2026-02-02T17:47:19.245229",
"nodes": {
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpwzdvf3_7/javascript.js": {
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpwzdvf3_7/javascript.js",
"level": 1,
"summary": "JavaScript web development",
"children": [],
"token_count": 7,
"created_at": "2026-02-02T17:47:19.245344",
"metadata": {
"filename": "javascript.js",
"relative_path": "javascript.js"
}
},
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpwzdvf3_7/python.py": {
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpwzdvf3_7/python.py",
"level": 1,
"summary": "Python programming language code",
"children": [],
"token_count": 9,
"created_at": "2026-02-02T17:47:19.245395",
"metadata": {
"filename": "python.py",
"relative_path": "python.py"
}
}
}
}
@@ -1,177 +0,0 @@
#!/usr/bin/env python3
"""
Smoke Test: Condition Syntax Comparison
This script tests both condition syntaxes WITHOUT LLM calls (deterministic):
1. AgentFlow with when() string expressions
2. AgentTeam with Task.condition dict routing
Run: python examples/smoke_test_condition_syntax.py
"""
from praisonaiagents import AgentFlow, Task
from praisonaiagents.workflows import when
print("=" * 60)
print("SMOKE TEST: Condition Syntax Comparison")
print("=" * 60)
# ============================================================
# TEST 1: AgentFlow with when() - String Expression Syntax
# ============================================================
print("\n" + "=" * 60)
print("TEST 1: AgentFlow with when() - String Expression")
print("=" * 60)
def high_score_handler(ctx):
print(" ✅ HIGH SCORE PATH: Score was >= 50")
return {"output": "High score processed"}
def low_score_handler(ctx):
print(" ❌ LOW SCORE PATH: Score was < 50")
return {"output": "Low score processed"}
# AgentFlow uses STRING condition: "{{score}} >= 50"
flow = AgentFlow(
name="Score-Based Flow",
steps=[
when(
condition="{{score}} >= 50", # STRING expression
then_steps=[high_score_handler],
else_steps=[low_score_handler]
)
],
variables={"score": 75} # Pre-set score for deterministic test
)
print("\nAgentFlow Syntax:")
print(' when(condition="{{score}} >= 50", then_steps=[...], else_steps=[...])')
print(" Variables: {'score': 75}")
print("\nRunning AgentFlow...")
try:
result = flow.start("Process the score")
print("\n✅ AgentFlow completed successfully")
except Exception as e:
print(f"\n❌ AgentFlow failed: {e}")
# ============================================================
# TEST 2: AgentTeam with Task.condition - Dict Routing Syntax
# ============================================================
print("\n" + "=" * 60)
print("TEST 2: AgentTeam with Task.condition - Dict Routing")
print("=" * 60)
# Task uses DICT condition: {"approved": ["publish"], "rejected": ["edit"]}
# NOTE: This is just showing the syntax - no LLM call needed
review_task = Task(
name="review",
description="Review the content and decide: approved or rejected",
task_type="decision",
is_start=True,
condition={ # DICT routing
"approved": ["publish"],
"rejected": ["edit"]
}
)
print("\nAgentTeam/Task Syntax:")
print(' Task(condition={"approved": ["publish"], "rejected": ["edit"]})')
print(" task_type='decision' triggers LLM to output a decision value")
print("\nTask condition structure:")
print(f" review_task.condition = {review_task.condition}")
print(f" review_task.task_type = '{review_task.task_type}'")
print("\n✅ Task condition syntax validated (no LLM call needed)")
# ============================================================
# TEST 3: NEW UNIFIED SYNTAX - Task.when (same as AgentFlow!)
# ============================================================
print("\n" + "=" * 60)
print("TEST 3: NEW UNIFIED SYNTAX - Task.when (same as AgentFlow!)")
print("=" * 60)
# Task now supports the same string expression syntax as AgentFlow!
unified_task = Task(
name="score_check",
description="Check if score passes threshold",
when="{{score}} > 80", # SAME syntax as AgentFlow!
then_task="approve",
else_task="reject"
)
print("\nNEW Unified Task Syntax:")
print(' Task(when="{{score}} > 80", then_task="approve", else_task="reject")')
print("\nTask structure:")
print(f" unified_task.when = '{unified_task.when}'")
print(f" unified_task.then_task = '{unified_task.then_task}'")
print(f" unified_task.else_task = '{unified_task.else_task}'")
# Test evaluate_when method
print("\nTesting evaluate_when():")
result_high = unified_task.evaluate_when({"score": 90})
result_low = unified_task.evaluate_when({"score": 70})
print(f" evaluate_when({{'score': 90}}) = {result_high}")
print(f" evaluate_when({{'score': 70}}) = {result_low}")
# Test get_next_task method
print("\nTesting get_next_task():")
next_high = unified_task.get_next_task({"score": 90})
next_low = unified_task.get_next_task({"score": 70})
print(f" get_next_task({{'score': 90}}) = '{next_high}'")
print(f" get_next_task({{'score': 70}}) = '{next_low}'")
print("\n✅ NEW unified Task.when syntax works!")
# ============================================================
# SUMMARY: Syntax Differences
# ============================================================
print("\n" + "=" * 60)
print("SUMMARY: Syntax Differences (CONFUSING FOR USERS)")
print("=" * 60)
print("""
┌─────────────────────────────────────────────────────────────────┐
│ CONDITION SYNTAX COMPARISON │
├─────────────────────────────────────────────────────────────────┤
│ │
│ AGENTFLOW (Deterministic Pipelines) │
│ ───────────────────────────────────── │
│ Syntax: STRING expression with {{variable}} placeholders │
│ │
│ when( │
│ condition="{{score}} >= 50", ← STRING │
│ then_steps=[approve], │
│ else_steps=[reject] │
│ ) │
│ │
│ Supported: >, <, >=, <=, ==, !=, in, contains │
│ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ AGENTTEAM (Multi-Agent Task Graphs) │
│ ───────────────────────────────────── │
│ Syntax: DICT mapping decision values to next task names │
│ │
│ Task( │
│ condition={ ← DICT │
│ "approved": ["publish"], │
│ "rejected": ["edit"] │
│ }, │
│ task_type="decision" │
│ ) │
│ │
│ LLM outputs a decision value, Process routes by key lookup │
│ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 🚨 CONFUSION POINTS FOR BEGINNERS: │
│ │
│ 1. Same word "condition" means DIFFERENT things │
│ 2. AgentFlow: evaluates expression → boolean │
│ 3. AgentTeam: routes by key lookup → next task │
│ 4. No unified syntax for simple use cases │
│ 5. Task also has should_run (callable) - 3rd way! │
│ │
└─────────────────────────────────────────────────────────────────┘
""")
print("\n✅ Smoke test completed!")
@@ -1,175 +0,0 @@
"""
Streaming with Reasoning Content Example
This example demonstrates how to handle streaming events with reasoning content
from models like o1, o1-mini, and deepseek-reasoner.
The is_reasoning flag allows you to distinguish between:
- Reasoning/thinking content (is_reasoning=True)
- Final response content (is_reasoning=False)
"""
from praisonaiagents import Agent
from praisonaiagents.streaming import StreamEvent, StreamEventType, StreamMetrics
def create_styled_callback():
"""Create a callback that styles reasoning content differently."""
def callback(event: StreamEvent):
if event.type == StreamEventType.DELTA_TEXT:
if event.is_reasoning:
# Dim gray for reasoning content
print(f"\033[90m{event.content}\033[0m", end="", flush=True)
else:
# Normal for response content
print(event.content, end="", flush=True)
elif event.type == StreamEventType.STREAM_END:
print() # Newline at end
return callback
def create_collecting_callback():
"""Create a callback that collects reasoning and response separately."""
reasoning_parts = []
response_parts = []
def callback(event: StreamEvent):
if event.type == StreamEventType.DELTA_TEXT:
if event.is_reasoning:
reasoning_parts.append(event.content)
else:
response_parts.append(event.content)
def get_results():
return {
"reasoning": "".join(reasoning_parts),
"response": "".join(response_parts)
}
return callback, get_results
def example_basic_streaming():
"""Basic streaming with reasoning detection."""
print("=" * 60)
print("Example 1: Basic Streaming with Reasoning Detection")
print("=" * 60)
agent = Agent(
name="thinker",
instructions="Think step by step before answering.",
llm="gpt-4o-mini" # Change to o1-mini for actual reasoning
)
# Add styled callback
agent.stream_emitter.add_callback(create_styled_callback())
# Enable metrics
agent.stream_emitter.enable_metrics()
# Run with streaming
response = agent.start("What is 15 * 23?", stream=True)
# Show metrics
metrics = agent.stream_emitter.get_metrics()
if metrics:
print(f"\n📊 {metrics.format_summary()}")
return response
def example_collect_separately():
"""Collect reasoning and response content separately."""
print("\n" + "=" * 60)
print("Example 2: Collect Reasoning and Response Separately")
print("=" * 60)
agent = Agent(
name="analyzer",
instructions="Analyze the problem carefully.",
llm="gpt-4o-mini"
)
# Create collecting callback
callback, get_results = create_collecting_callback()
agent.stream_emitter.add_callback(callback)
# Run with streaming
response = agent.start("Explain why the sky is blue.", stream=True)
# Get separated results
results = get_results()
print(f"\n📝 Reasoning length: {len(results['reasoning'])} chars")
print(f"📝 Response length: {len(results['response'])} chars")
return response
def example_with_metrics():
"""Track streaming performance metrics."""
print("\n" + "=" * 60)
print("Example 3: Streaming with Performance Metrics")
print("=" * 60)
metrics = StreamMetrics()
def metrics_callback(event: StreamEvent):
metrics.update_from_event(event)
if event.type == StreamEventType.FIRST_TOKEN:
print("⚡ First token received!")
elif event.type == StreamEventType.DELTA_TEXT:
print(event.content, end="", flush=True)
elif event.type == StreamEventType.STREAM_END:
print()
agent = Agent(name="assistant", llm="gpt-4o-mini")
agent.stream_emitter.add_callback(metrics_callback)
response = agent.start("Write a haiku about coding.", stream=True)
print("\n📊 Performance Metrics:")
print(f" TTFT: {metrics.ttft * 1000:.0f}ms")
print(f" Stream Duration: {metrics.stream_duration * 1000:.0f}ms")
print(f" Total Time: {metrics.total_time * 1000:.0f}ms")
print(f" Tokens: {metrics.token_count}")
print(f" Speed: {metrics.tokens_per_second:.1f} tokens/sec")
return response
def example_multi_agent_context():
"""Show agent context in multi-agent scenarios."""
print("\n" + "=" * 60)
print("Example 4: Multi-Agent Context")
print("=" * 60)
def context_callback(event: StreamEvent):
if event.type == StreamEventType.DELTA_TEXT:
prefix = f"[{event.agent_id}] " if event.agent_id else ""
print(f"{prefix}{event.content}", end="", flush=True)
elif event.type == StreamEventType.STREAM_END:
print()
# Note: agent_id is populated when using multi-agent workflows
agent = Agent(name="helper", llm="gpt-4o-mini")
agent.stream_emitter.add_callback(context_callback)
response = agent.start("Say hello!", stream=True)
return response
if __name__ == "__main__":
print("🚀 PraisonAI Streaming with Reasoning Examples\n")
# Run examples
example_basic_streaming()
example_collect_separately()
example_with_metrics()
example_multi_agent_context()
print("\n✅ All examples completed!")
@@ -150,19 +150,10 @@ _LAZY_IMPORTS = {
'Parallel': ('praisonaiagents.workflows', 'Parallel'),
'Loop': ('praisonaiagents.workflows', 'Loop'),
'Repeat': ('praisonaiagents.workflows', 'Repeat'),
'If': ('praisonaiagents.workflows', 'If'),
'route': ('praisonaiagents.workflows', 'route'),
'parallel': ('praisonaiagents.workflows', 'parallel'),
'loop': ('praisonaiagents.workflows', 'loop'),
'repeat': ('praisonaiagents.workflows', 'repeat'),
'when': ('praisonaiagents.workflows', 'when'),
# Conditions (Protocol-driven condition evaluation)
'ConditionProtocol': ('praisonaiagents.conditions.protocols', 'ConditionProtocol'),
'RoutingConditionProtocol': ('praisonaiagents.conditions.protocols', 'RoutingConditionProtocol'),
'ExpressionCondition': ('praisonaiagents.conditions.evaluator', 'ExpressionCondition'),
'DictCondition': ('praisonaiagents.conditions.evaluator', 'DictCondition'),
'evaluate_condition': ('praisonaiagents.conditions.evaluator', 'evaluate_condition'),
# Handoff
'Handoff': ('praisonaiagents.agent.handoff', 'Handoff'),
@@ -430,46 +421,6 @@ _LAZY_IMPORTS = {
'ProviderStatus': ('praisonaiagents.llm.failover', 'ProviderStatus'),
'FailoverConfig': ('praisonaiagents.llm.failover', 'FailoverConfig'),
'FailoverManager': ('praisonaiagents.llm.failover', 'FailoverManager'),
# Plugins - Core classes
'PluginManager': ('praisonaiagents.plugins', 'PluginManager'),
'Plugin': ('praisonaiagents.plugins', 'Plugin'),
'PluginInfo': ('praisonaiagents.plugins', 'PluginInfo'),
'PluginHook': ('praisonaiagents.plugins', 'PluginHook'),
'FunctionPlugin': ('praisonaiagents.plugins', 'FunctionPlugin'),
'get_plugin_manager': ('praisonaiagents.plugins', 'get_plugin_manager'),
# Plugins - Protocols
'PluginProtocol': ('praisonaiagents.plugins', 'PluginProtocol'),
'ToolPluginProtocol': ('praisonaiagents.plugins', 'ToolPluginProtocol'),
'HookPluginProtocol': ('praisonaiagents.plugins', 'HookPluginProtocol'),
'AgentPluginProtocol': ('praisonaiagents.plugins', 'AgentPluginProtocol'),
'LLMPluginProtocol': ('praisonaiagents.plugins', 'LLMPluginProtocol'),
# Plugins - Single-file plugin support
'PluginMetadata': ('praisonaiagents.plugins', 'PluginMetadata'),
'PluginParseError': ('praisonaiagents.plugins', 'PluginParseError'),
'parse_plugin_header': ('praisonaiagents.plugins', 'parse_plugin_header'),
'parse_plugin_header_from_file': ('praisonaiagents.plugins', 'parse_plugin_header_from_file'),
'discover_plugins': ('praisonaiagents.plugins', 'discover_plugins'),
'load_plugin': ('praisonaiagents.plugins', 'load_plugin'),
'discover_and_load_plugins': ('praisonaiagents.plugins', 'discover_and_load_plugins'),
'get_default_plugin_dirs': ('praisonaiagents.plugins', 'get_default_plugin_dirs'),
'get_plugin_template': ('praisonaiagents.plugins', 'get_plugin_template'),
'ensure_plugin_dir': ('praisonaiagents.plugins', 'ensure_plugin_dir'),
# Config loader - for config-driven defaults
'get_config': ('praisonaiagents.config.loader', 'get_config'),
'get_default': ('praisonaiagents.config.loader', 'get_default'),
'get_plugins_config': ('praisonaiagents.config.loader', 'get_plugins_config'),
'get_defaults_config': ('praisonaiagents.config.loader', 'get_defaults_config'),
'apply_config_defaults': ('praisonaiagents.config.loader', 'apply_config_defaults'),
'validate_config': ('praisonaiagents.config.loader', 'validate_config'),
'get_config_path': ('praisonaiagents.config.loader', 'get_config_path'),
'ConfigValidationError': ('praisonaiagents.config.loader', 'ConfigValidationError'),
'PraisonConfig': ('praisonaiagents.config.loader', 'PraisonConfig'),
'PluginsConfig': ('praisonaiagents.config.loader', 'PluginsConfig'),
'DefaultsConfig': ('praisonaiagents.config.loader', 'DefaultsConfig'),
}
@@ -9,10 +9,6 @@ Environment Variables:
PRAISONAI_TELEMETRY_ENABLED: Enable telemetry (default: false, opt-in)
PRAISONAI_TELEMETRY_DISABLED: Disable telemetry (takes precedence)
DO_NOT_TRACK: Standard opt-out flag (takes precedence)
PRAISONAI_PLUGINS: Enable plugins (default: false)
- "true" or "1": Enable all discovered plugins
- "logging,metrics": Enable specific plugins (comma-separated)
- "false" or "0": Disable plugins
"""
import os
from typing import Optional
@@ -81,38 +77,6 @@ AUTO_INSTRUMENT = _str_to_bool(
)
# Plugins: Enable background plugins (hooks, metrics, logging)
# Note: Tools and guardrails work WITHOUT this flag - they are explicit
def _get_plugins_enabled() -> bool:
"""Determine if plugins should be auto-enabled based on env var."""
env_value = os.environ.get('PRAISONAI_PLUGINS', '').lower()
if not env_value:
return False
if env_value in ('true', '1', 'yes', 'on'):
return True
if env_value in ('false', '0', 'no', 'off'):
return False
# Treat as comma-separated list of plugin names (implies enabled)
return True
def _get_plugins_list() -> list:
"""Get list of specific plugins to enable from env var.
Returns:
List of plugin names, or empty list if all plugins enabled.
"""
env_value = os.environ.get('PRAISONAI_PLUGINS', '').lower()
if not env_value or env_value in ('true', '1', 'yes', 'on', 'false', '0', 'no', 'off'):
return []
# Parse comma-separated list
return [p.strip() for p in env_value.split(',') if p.strip()]
PLUGINS_ENABLED = _get_plugins_enabled()
PLUGINS_LIST = _get_plugins_list()
def missing_dependency_error(
package_name: str,
extra_name: Optional[str] = None,
@@ -150,7 +150,6 @@ if TYPE_CHECKING:
from ..task.task import Task
from .handoff import Handoff, HandoffConfig, HandoffResult
from ..rag.models import RAGResult, ContextPack
from ..eval.results import EvaluationLoopResult
class Agent:
# Class-level counter for generating unique display names for nameless agents
@@ -541,49 +540,6 @@ class Agent:
Agent._configure_logging()
Agent._logging_configured = True
# ============================================================
# CONFIG-DRIVEN DEFAULTS (apply before parameter resolution)
# Precedence: Explicit params > Config file > Built-in defaults
# Only applies when param is None (not explicitly set)
# ============================================================
from ..config.loader import apply_config_defaults, get_default
# Apply config defaults for LLM if not explicitly set
if llm is None and model is None:
config_model = get_default("model")
if config_model:
llm = config_model
# Apply config defaults for base_url if not explicitly set
if base_url is None:
config_base_url = get_default("base_url")
if config_base_url:
base_url = config_base_url
# Apply config defaults for feature params (memory, knowledge, etc.)
# These use apply_config_defaults which handles enabled=True/False logic
if memory is None:
memory = apply_config_defaults("memory", memory, MemoryConfig)
if knowledge is None:
knowledge = apply_config_defaults("knowledge", knowledge, KnowledgeConfig)
if planning is False: # Only override if explicitly False (default)
config_planning = apply_config_defaults("planning", None, PlanningConfig)
if config_planning:
planning = config_planning
if reflection is None:
reflection = apply_config_defaults("reflection", reflection, ReflectionConfig)
if web is None:
web = apply_config_defaults("web", web, WebConfig)
if output is None:
output = apply_config_defaults("output", output, OutputConfig)
if execution is None:
execution = apply_config_defaults("execution", execution, ExecutionConfig)
if caching is None:
caching = apply_config_defaults("caching", caching, CachingConfig)
if autonomy is None:
# AutonomyConfig is in agent/autonomy.py - use dict for config defaults
autonomy = apply_config_defaults("autonomy", autonomy, None)
# ============================================================
# CONSOLIDATED PARAMS EXTRACTION (agent-centric API)
# Uses unified resolver: Instance > Config > Array > String > Bool > Default
@@ -883,10 +839,7 @@ class Agent:
backend = _memory_config.backend
if hasattr(backend, 'value'):
backend = backend.value
# If learn is enabled, pass as dict to trigger full Memory class
if _memory_config.learn:
memory = _memory_config.to_dict()
elif backend == "file":
if backend == "file":
memory = True
elif _memory_config.config:
memory = _memory_config.config
@@ -898,24 +851,6 @@ class Agent:
elif memory is False:
memory = None
# ─────────────────────────────────────────────────────────────────────
# Resolve HISTORY from MemoryConfig - FAST PATH
# History is enabled via memory="history" preset or MemoryConfig(history=True)
# ─────────────────────────────────────────────────────────────────────
_history_enabled = False
_history_limit = 10
_history_session_id = None
# Check if memory config has history settings
if _memory_config is not None and isinstance(_memory_config, MemoryConfig):
if _memory_config.history:
_history_enabled = True
_history_limit = _memory_config.history_limit
# Use auto_save session if no explicit session and history is enabled
if _history_enabled and _history_session_id is None and auto_save:
_history_session_id = auto_save
# ─────────────────────────────────────────────────────────────────────
# Resolve KNOWLEDGE param - FAST PATH
# ─────────────────────────────────────────────────────────────────────
@@ -1150,19 +1085,9 @@ class Agent:
self.__stream_emitter = None # Will be initialized on first access
self._hooks_registry_param = hooks # Store for lazy init
# Handle llm= deprecation: model= is the preferred parameter name
# llm= still works but shows deprecation warning
if llm is not None and model is None:
import warnings
warnings.warn(
"Parameter 'llm' is deprecated, use 'model' instead. "
"Example: Agent(model='gpt-4o-mini') instead of Agent(llm='gpt-4o-mini')",
DeprecationWarning,
stacklevel=2
)
# model= is the preferred parameter (no warning)
if model is not None:
llm = model # model= takes precedence
# Handle model= alias for llm= (NO warnings - both are valid)
if llm is None and model is not None:
llm = model # model= is an alias for llm=
# Store rate limiter (optional, zero overhead when None)
self._rate_limiter = rate_limiter
@@ -1418,11 +1343,6 @@ Your Goal: {self.goal}
# Used when session_id is provided but no DB adapter
self._session_store = None
self._session_store_initialized = False
# History injection settings (for auto-injecting session history into context)
self._history_enabled = _history_enabled
self._history_limit = _history_limit
self._history_session_id = _history_session_id
# Agent-centric feature instances (lazy loaded for zero performance impact)
self._auto_memory = auto_memory
@@ -2368,99 +2288,6 @@ Summary:"""
)
return handoff_obj.execute_programmatic(self, prompt, context)
def run_until(
self,
prompt: str,
criteria: str,
threshold: float = 8.0,
max_iterations: int = 5,
mode: str = "optimize",
on_iteration: Optional[Callable[[Any], None]] = None,
verbose: bool = False,
) -> "EvaluationLoopResult":
"""
Run agent iteratively until output meets quality criteria.
This method implements the "Ralph Loop" pattern: run agent → judge output
→ improve based on feedback → repeat until threshold met.
Args:
prompt: The prompt to send to the agent
criteria: Evaluation criteria for the Judge (e.g., "Response is thorough")
threshold: Score threshold for success (default: 8.0, scale 1-10)
max_iterations: Maximum iterations before stopping (default: 5)
mode: "optimize" (stop on success) or "review" (run all iterations)
on_iteration: Optional callback called after each iteration
verbose: Enable verbose logging
Returns:
EvaluationLoopResult with iteration history and final score
Example:
```python
agent = Agent(name="analyzer", instructions="Analyze systems")
result = agent.run_until(
"Analyze the auth flow",
criteria="Analysis is thorough and actionable",
threshold=8.0,
)
print(result.final_score) # 8.5
print(result.success) # True
```
"""
from ..eval.loop import EvaluationLoop
loop = EvaluationLoop(
agent=self,
criteria=criteria,
threshold=threshold,
max_iterations=max_iterations,
mode=mode,
on_iteration=on_iteration,
verbose=verbose,
)
return loop.run(prompt)
async def run_until_async(
self,
prompt: str,
criteria: str,
threshold: float = 8.0,
max_iterations: int = 5,
mode: str = "optimize",
on_iteration: Optional[Callable[[Any], None]] = None,
verbose: bool = False,
) -> "EvaluationLoopResult":
"""
Async version of run_until().
Run agent iteratively until output meets quality criteria.
Args:
prompt: The prompt to send to the agent
criteria: Evaluation criteria for the Judge
threshold: Score threshold for success (default: 8.0)
max_iterations: Maximum iterations before stopping (default: 5)
mode: "optimize" (stop on success) or "review" (run all iterations)
on_iteration: Optional callback called after each iteration
verbose: Enable verbose logging
Returns:
EvaluationLoopResult with iteration history and final score
"""
from ..eval.loop import EvaluationLoop
loop = EvaluationLoop(
agent=self,
criteria=criteria,
threshold=threshold,
max_iterations=max_iterations,
mode=mode,
on_iteration=on_iteration,
verbose=verbose,
)
return await loop.run_async(prompt)
async def handoff_to_async(
self,
target_agent: 'Agent',
@@ -2667,8 +2494,8 @@ Summary:"""
Initialize RulesManager for persistent rules/instructions.
Automatically discovers rules from:
- ~/.praisonai/rules/ (global)
- .praisonai/rules/ (workspace)
- ~/.praison/rules/ (global)
- .praison/rules/ (workspace)
- Subdirectory rules
NOTE: This is called lazily via the rules_manager property for performance.
@@ -2758,19 +2585,8 @@ Summary:"""
self._memory_instance = FileMemory(user_id=mem_user_id)
elif isinstance(memory, dict):
# Configuration dict
provider = memory.get("provider", memory.get("backend", "file"))
learn_enabled = memory.get("learn", False)
# Use full Memory class if learn is enabled (requires LearnManager)
if learn_enabled:
try:
from ..memory.memory import Memory
self._memory_instance = Memory(memory)
except ImportError:
logging.warning("Memory with learn requires additional dependencies. Falling back to FileMemory (learn disabled).")
from ..memory.file_memory import FileMemory
self._memory_instance = FileMemory(user_id=memory.get("user_id", mem_user_id))
elif provider == "file":
provider = memory.get("provider", "file")
if provider == "file":
from ..memory.file_memory import FileMemory
self._memory_instance = FileMemory(
user_id=memory.get("user_id", mem_user_id),
@@ -2806,24 +2622,6 @@ Summary:"""
return ""
def get_learn_context(self) -> str:
"""
Get learning context for injection into system prompt.
Returns learned preferences, insights, and patterns when memory="learn"
is enabled. Returns empty string when learn is not enabled (zero overhead).
Returns:
Formatted learning context string, or empty string if learn not enabled
"""
if not self._memory_instance:
return ""
if hasattr(self._memory_instance, 'get_learn_context'):
return self._memory_instance.get_learn_context()
return ""
def store_memory(self, content: str, memory_type: str = "short_term", **kwargs):
"""
Store content in memory.
@@ -3464,11 +3262,6 @@ Your Goal: {self.goal}"""
# Display memory info to user if verbose
if self.verbose:
self._display_memory_info()
# Add learn context if learn is enabled (auto-inject when memory="learn")
learn_context = self.get_learn_context()
if learn_context:
system_prompt += f"\n\n## Learned Context (Patterns and insights from past interactions)\n{learn_context}"
# Add skills prompt if skills are configured
if self._skills or self._skills_dirs:
@@ -3639,19 +3432,7 @@ Your Goal: {self.goal}"""
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
# Inject session history if enabled (from persistent storage)
if self._history_enabled and self._session_store is not None:
try:
session_history = self._session_store.get_chat_history(
self._history_session_id,
max_messages=self._history_limit
)
if session_history:
messages.extend(session_history)
except Exception as e:
logging.debug(f"Failed to load session history: {e}")
# Add in-memory chat history (current conversation)
# Add chat history
if self.chat_history:
messages.extend(self.chat_history)
@@ -162,7 +162,7 @@ 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
self.output_dir = Path(".praison/prp") # Save in .praison/prp folder
self.setup_output_directories() # Create directories first
self.setup_logging() # Then setup logging
@@ -1136,7 +1136,7 @@ detailed, actionable implementation steps."""
filtered = []
exclude_patterns = [
'__pycache__', '.pytest_cache', '.git', 'node_modules',
'.venv', 'venv', 'dist', 'build', '.praisonai', '.chroma_db',
'.venv', 'venv', 'dist', 'build', '.praison', '.chroma_db',
'.ruff_cache', '.mypy_cache', 'context_engineering_output'
]
@@ -2263,7 +2263,7 @@ This PRP provides:
)
print(f"🐛 Debug mode: Comprehensive session report + individual files + final PRP saved")
else:
# Non-debug mode: Save ONLY the final comprehensive PRP directly in .praisonai/prp
# Non-debug mode: Save ONLY the final comprehensive PRP directly in .praison/prp
# Use clean filename: goal name + timestamp
final_output_file = self.output_dir / f"{safe_goal}_prp_{timestamp}.md"
final_output_file.parent.mkdir(parents=True, exist_ok=True)
@@ -513,9 +513,9 @@ class AgentTeam:
"use_embedding": True,
"storage": {
"type": "sqlite",
"path": "./.praisonai/memory.db"
"path": "./.praison/memory.db"
},
"rag_db_path": "./.praisonai/chroma_db"
"rag_db_path": "./.praison/chroma_db"
}
if _embedder:
if isinstance(_embedder, dict):
@@ -2345,7 +2345,7 @@ Context:
context=context if context else None,
# Inherit from original task if available
memory=original_task.memory if original_task else None,
on_task_complete=original_task.callback if original_task else None,
callback=original_task.callback if original_task else None,
guardrails=original_task.guardrail if original_task else None,
max_retries=original_task.max_retries if original_task else 3,
output_json=original_task.output_json if original_task else None,
@@ -141,19 +141,8 @@ class SubagentDelegator:
self._total_count: int = 0
self._task_counter: int = 0
# Note: asyncio.Semaphore() is created lazily via _get_semaphore() to support
# Python 3.9 where Semaphore() requires an event loop at creation time.
self.__semaphore: Optional[asyncio.Semaphore] = None
def _get_semaphore(self) -> asyncio.Semaphore:
"""Get the semaphore, creating it lazily if needed.
This deferred creation is required for Python 3.9 compatibility
where asyncio.Semaphore() calls get_event_loop() at creation time.
"""
if self.__semaphore is None:
self.__semaphore = asyncio.Semaphore(self.config.max_concurrent_subagents)
return self.__semaphore
# Semaphore for concurrency control
self._semaphore = asyncio.Semaphore(self.config.max_concurrent_subagents)
def get_available_agents(self) -> List[str]:
"""Get list of available agent names for delegation."""
@@ -215,7 +204,7 @@ class SubagentDelegator:
self._total_count += 1
# Execute with concurrency control
async with self._get_semaphore():
async with self._semaphore:
return await self._execute_task(task)
async def delegate_parallel(
@@ -51,17 +51,10 @@ class BackgroundRunner:
"""
self.config = config or BackgroundConfig()
self._tasks: Dict[str, BackgroundTask] = {}
# Lazily create asyncio.Semaphore() to avoid Python 3.9 event loop issues
self._semaphore: Optional[asyncio.Semaphore] = None
self._semaphore = asyncio.Semaphore(self.config.max_concurrent_tasks)
self._running = False
self._cleanup_task: Optional[asyncio.Task] = None
def _get_semaphore(self) -> asyncio.Semaphore:
"""Get or create the semaphore lazily (Python 3.9 compatible)."""
if self._semaphore is None:
self._semaphore = asyncio.Semaphore(self.config.max_concurrent_tasks)
return self._semaphore
@property
def tasks(self) -> List[BackgroundTask]:
"""Get all tasks."""
@@ -116,7 +109,7 @@ class BackgroundRunner:
# Create the execution coroutine
async def execute():
async with self._get_semaphore():
async with self._semaphore:
task.start()
logger.info(f"Background task started: {task.name} ({task.id})")
@@ -48,16 +48,7 @@ class BackgroundTask:
_cancel_event: Optional[asyncio.Event] = field(default=None, repr=False)
def __post_init__(self):
# Lazily create asyncio.Event() to avoid Python 3.9 event loop issues
self._cancel_event = None
# Simple flag for sync cancellation check (doesn't require event loop)
self._cancelled = False
def _get_cancel_event(self) -> asyncio.Event:
"""Get or create the cancel event lazily (Python 3.9 compatible)."""
if self._cancel_event is None:
self._cancel_event = asyncio.Event()
return self._cancel_event
self._cancel_event = asyncio.Event()
@property
def is_running(self) -> bool:
@@ -102,10 +93,7 @@ class BackgroundTask:
def cancel(self):
"""Request task cancellation."""
# Set simple flag (works without event loop)
self._cancelled = True
# Also set the event if it exists (for async waiters)
if self._cancel_event is not None:
if self._cancel_event:
self._cancel_event.set()
self.status = TaskStatus.CANCELLED
self.completed_at = datetime.now()
@@ -147,10 +135,6 @@ class BackgroundTask:
def should_cancel(self) -> bool:
"""Check if cancellation was requested."""
# Check simple flag first (works without event loop)
if self._cancelled:
return True
# Also check event if it exists
return self._cancel_event is not None and self._cancel_event.is_set()
def to_dict(self) -> Dict[str, Any]:
@@ -20,7 +20,7 @@ Usage:
# Create checkpoint service
service = CheckpointService(
workspace_dir="/path/to/project",
storage_dir="~/.praisonai/checkpoints"
storage_dir="~/.praison/checkpoints"
)
# Initialize shadow git
@@ -19,14 +19,6 @@ from .types import (
logger = logging.getLogger(__name__)
def _parse_iso_timestamp(timestamp: str) -> datetime:
"""Parse ISO format timestamp, handling 'Z' suffix for Python 3.9 compatibility."""
# Python 3.9's fromisoformat() doesn't support 'Z' suffix
if timestamp.endswith('Z'):
timestamp = timestamp[:-1] + '+00:00'
return datetime.fromisoformat(timestamp)
# Protected paths that should never be checkpointed
PROTECTED_PATHS = [
os.path.expanduser("~"),
@@ -51,7 +43,7 @@ class CheckpointService:
Example:
service = CheckpointService(
workspace_dir="/path/to/project",
storage_dir="~/.praisonai/checkpoints"
storage_dir="~/.praison/checkpoints"
)
await service.initialize()
@@ -283,7 +275,7 @@ class CheckpointService:
id=commit_hash,
short_id=commit_hash[:8],
message=message,
timestamp=_parse_iso_timestamp(timestamp)
timestamp=datetime.fromisoformat(timestamp)
)
self._checkpoints.append(checkpoint)
@@ -329,7 +321,7 @@ class CheckpointService:
id=checkpoint_id,
short_id=checkpoint_id[:8],
message=message,
timestamp=_parse_iso_timestamp(timestamp)
timestamp=datetime.fromisoformat(timestamp)
)
self._emit(CheckpointEvent.CHECKPOINT_RESTORED, checkpoint)
@@ -458,7 +450,7 @@ class CheckpointService:
id=parts[0],
short_id=parts[0][:8],
message=parts[1],
timestamp=_parse_iso_timestamp(parts[2])
timestamp=datetime.fromisoformat(parts[2])
))
return checkpoints
@@ -11,15 +11,6 @@ from typing import Optional, Dict, Any, List
from datetime import datetime
from pathlib import Path
from ..paths import get_checkpoints_dir
def _parse_iso_timestamp(timestamp: str) -> datetime:
"""Parse ISO format timestamp, handling 'Z' suffix for Python 3.9 compatibility."""
if timestamp.endswith('Z'):
timestamp = timestamp[:-1] + '+00:00'
return datetime.fromisoformat(timestamp)
class CheckpointEvent(str, Enum):
"""Events emitted by the checkpoint service."""
@@ -39,7 +30,7 @@ class CheckpointConfig:
max_checkpoints: int = 100 # Maximum checkpoints to keep
exclude_patterns: List[str] = field(default_factory=lambda: [
".git",
".praisonai",
".praison",
"__pycache__",
"*.pyc",
".env",
@@ -55,8 +46,8 @@ class CheckpointConfig:
if self.storage_dir:
self.storage_dir = os.path.expanduser(self.storage_dir)
else:
# Default storage in user's home directory (uses centralized paths - DRY)
self.storage_dir = str(get_checkpoints_dir())
# Default storage in user's home directory
self.storage_dir = os.path.expanduser("~/.praison/checkpoints")
def get_checkpoint_dir(self) -> str:
"""Get the checkpoint directory for this workspace."""
@@ -86,7 +77,7 @@ class Checkpoint:
id=commit_hash,
short_id=commit_hash[:8],
message=message,
timestamp=_parse_iso_timestamp(timestamp) if isinstance(timestamp, str) else timestamp
timestamp=datetime.fromisoformat(timestamp) if isinstance(timestamp, str) else timestamp
)
def to_dict(self) -> Dict[str, Any]:
@@ -1,25 +0,0 @@
"""
Conditions Module - Unified condition evaluation for workflows.
This module provides a protocol-driven approach to condition evaluation,
enabling DRY condition handling across AgentFlow and AgentTeam.
Exports:
- ConditionProtocol: Protocol interface for condition implementations
- ExpressionCondition: String-based conditions like "{{score}} > 80"
- DictCondition: Dict-based routing conditions
- evaluate_condition: Standalone function for condition evaluation
"""
from .protocols import ConditionProtocol
from .evaluator import (
ExpressionCondition,
DictCondition,
evaluate_condition,
)
__all__ = [
'ConditionProtocol',
'ExpressionCondition',
'DictCondition',
'evaluate_condition',
]
@@ -1,284 +0,0 @@
"""
Condition Evaluator - Shared condition evaluation logic.
This module provides the shared condition evaluation logic used by both
AgentFlow (string-based conditions) and AgentTeam (dict-based routing).
The implementation is extracted from workflows.py _evaluate_condition()
to enable DRY condition handling across the codebase.
"""
import re
import logging
from typing import Dict, Any, List, Optional
logger = logging.getLogger(__name__)
class ExpressionCondition:
"""
String-based condition evaluator for expressions like "{{score}} > 80".
Supports:
- Numeric comparison: "{{var}} > 80", "{{var}} >= 50", "{{var}} < 10"
- String equality: "{{var}} == approved", "{{var}} != rejected"
- Contains check: "error in {{message}}", "{{status}} contains success"
- Boolean: "{{flag}}" (truthy check)
- Nested property: "{{item.score}} >= 60"
Example:
```python
cond = ExpressionCondition("{{score}} > 80")
result = cond.evaluate({"score": 90}) # True
```
"""
def __init__(self, expression: str):
"""
Initialize with a condition expression.
Args:
expression: Condition string with {{var}} placeholders.
"""
self.expression = expression
def evaluate(self, context: Dict[str, Any]) -> bool:
"""
Evaluate the condition against the given context.
Args:
context: Dictionary containing variables for evaluation.
Special key 'previous_output' is also substituted.
Returns:
Boolean result of condition evaluation.
Returns False on evaluation errors (fail-safe).
"""
return evaluate_condition(
self.expression,
context,
previous_output=context.get('previous_output')
)
def __repr__(self) -> str:
return f"ExpressionCondition({self.expression!r})"
class DictCondition:
"""
Dict-based condition evaluator for routing decisions.
Used by AgentTeam for task routing based on decision keys.
Example:
```python
cond = DictCondition(
{"approved": ["publish"], "rejected": ["revise"]},
key="decision"
)
result = cond.evaluate({"decision": "approved"}) # True
targets = cond.get_target({"decision": "approved"}) # ["publish"]
```
"""
def __init__(self, routes: Dict[str, List[str]], key: str = "decision"):
"""
Initialize with routing configuration.
Args:
routes: Dict mapping decision values to target task lists.
key: Context key to read decision value from.
"""
self.routes = routes
self.key = key
def evaluate(self, context: Dict[str, Any]) -> bool:
"""
Check if the context contains a valid routing decision.
Args:
context: Dictionary containing the decision key.
Returns:
True if decision value matches a route key.
"""
decision = context.get(self.key, "")
if isinstance(decision, str):
decision = decision.lower()
return decision in self.routes
def get_target(self, context: Dict[str, Any]) -> List[str]:
"""
Get target tasks based on the decision value.
Args:
context: Dictionary containing the decision key.
Returns:
List of target task names, or empty list if no match.
"""
decision = context.get(self.key, "")
if isinstance(decision, str):
decision = decision.lower()
targets = self.routes.get(decision, [])
if isinstance(targets, str):
return [targets]
return targets if targets else []
def __repr__(self) -> str:
return f"DictCondition(routes={self.routes!r}, key={self.key!r})"
def evaluate_condition(
condition: str,
variables: Dict[str, Any],
previous_output: Optional[str] = None
) -> bool:
"""
Evaluate a condition expression with variable substitution.
This is the shared condition evaluation function used by both
AgentFlow and AgentTeam. It supports various condition formats.
Supported formats:
- Numeric comparison: "{{var}} > 80", "{{var}} >= 50", "{{var}} < 10"
- String equality: "{{var}} == approved", "{{var}} != rejected"
- Contains check: "error in {{message}}", "{{status}} contains success"
- Boolean: "{{flag}}" (truthy check)
- Nested property: "{{item.score}} >= 60"
Args:
condition: Condition expression with {{var}} placeholders.
variables: Dictionary containing variables for substitution.
previous_output: Optional output from previous step (substitutes {{previous_output}}).
Returns:
Boolean result of condition evaluation.
Returns False on evaluation errors (fail-safe).
Example:
```python
result = evaluate_condition("{{score}} > 80", {"score": 90}) # True
result = evaluate_condition("{{status}} == approved", {"status": "approved"}) # True
result = evaluate_condition("error in {{message}}", {"message": "An error occurred"}) # True
```
"""
# Substitute variables in condition
substituted = condition
# Handle nested property access like {{item.score}}
def get_nested_value(var_path: str, vars_dict: Dict[str, Any]) -> Any:
parts = var_path.split('.')
value = vars_dict
for part in parts:
if isinstance(value, dict):
value = value.get(part)
else:
return None
return value
# First substitute {{previous_output}} if provided (before general variable substitution)
if previous_output is not None:
substituted = substituted.replace("{{previous_output}}", str(previous_output))
# Find all {{var}} patterns and substitute
pattern = r'\{\{([^}]+)\}\}'
matches = re.findall(pattern, substituted) # Use substituted, not condition
for var_name in matches:
# Skip previous_output as it's already handled
if var_name == 'previous_output':
continue
if '.' in var_name:
# Nested property access
value = get_nested_value(var_name, variables)
else:
value = variables.get(var_name)
if value is None:
value = ""
# Replace the placeholder with the value
placeholder = f"{{{{{var_name}}}}}"
if isinstance(value, (int, float)):
substituted = substituted.replace(placeholder, str(value))
elif isinstance(value, bool):
substituted = substituted.replace(placeholder, str(value).lower())
else:
substituted = substituted.replace(placeholder, str(value))
# Now evaluate the substituted condition
try:
# Handle different condition formats
# Check if we have unsubstituted empty values in comparisons
# If a variable was missing, it becomes empty string which can cause issues
if ' > ' in substituted or ' < ' in substituted or ' >= ' in substituted or ' <= ' in substituted:
# Check for empty left side in comparison (missing variable)
if substituted.strip().startswith('>') or substituted.strip().startswith('<'):
return False
if substituted.strip().startswith('='):
return False
# Numeric comparisons: "90 > 80", "50 >= 50", "10 < 20", etc.
numeric_pattern = r'^(-?\d+(?:\.\d+)?)\s*(>|>=|<|<=|==|!=)\s*(-?\d+(?:\.\d+)?)$'
numeric_match = re.match(numeric_pattern, substituted.strip())
if numeric_match:
left = float(numeric_match.group(1))
op = numeric_match.group(2)
right = float(numeric_match.group(3))
if op == '>':
return left > right
if op == '>=':
return left >= right
if op == '<':
return left < right
if op == '<=':
return left <= right
if op == '==':
return left == right
if op == '!=':
return left != right
# String equality: "approved == approved", "status != rejected"
string_eq_pattern = r'^(.+?)\s*(==|!=)\s*(.+)$'
string_match = re.match(string_eq_pattern, substituted.strip())
if string_match:
left = string_match.group(1).strip()
op = string_match.group(2)
right = string_match.group(3).strip()
if op == '==':
return left == right
if op == '!=':
return left != right
# Contains check: "error in some message", "status contains success"
if ' in ' in substituted:
parts = substituted.split(' in ', 1)
if len(parts) == 2:
needle = parts[0].strip()
haystack = parts[1].strip()
return needle.lower() in haystack.lower()
if ' contains ' in substituted:
parts = substituted.split(' contains ', 1)
if len(parts) == 2:
haystack = parts[0].strip()
needle = parts[1].strip()
return needle.lower() in haystack.lower()
# Boolean check: truthy evaluation
# Handle "true", "false", "True", "False"
if substituted.strip().lower() == 'true':
return True
if substituted.strip().lower() == 'false':
return False
# Non-empty string is truthy
return bool(substituted.strip())
except Exception as e:
logger.warning(f"Condition evaluation failed for '{condition}': {e}")
return False
@@ -1,92 +0,0 @@
"""
Condition Protocol Definitions.
Provides Protocol interfaces that define the minimal contract for condition implementations.
This enables:
- Unified condition evaluation across AgentFlow and AgentTeam
- Custom condition implementations
- Type checking with static analyzers
- Mocking conditions in tests
These protocols are lightweight and have zero performance impact.
"""
from typing import Protocol, runtime_checkable, Dict, Any, List, Optional
@runtime_checkable
class ConditionProtocol(Protocol):
"""
Minimal Protocol for condition implementations.
This defines the essential interface that any condition must provide.
It enables unified condition evaluation across AgentFlow (string-based)
and AgentTeam (dict-based) systems.
Example:
```python
# Create a custom condition
class ScoreCondition:
def __init__(self, threshold: int):
self.threshold = threshold
def evaluate(self, context: Dict[str, Any]) -> bool:
return context.get("score", 0) > self.threshold
# Use in workflows
cond: ConditionProtocol = ScoreCondition(80)
result = cond.evaluate({"score": 90}) # True
```
"""
def evaluate(self, context: Dict[str, Any]) -> bool:
"""
Evaluate the condition against the given context.
Args:
context: Dictionary containing variables for evaluation.
May include workflow variables, previous outputs, etc.
Returns:
Boolean result of condition evaluation.
Returns False on evaluation errors (fail-safe).
"""
...
@runtime_checkable
class RoutingConditionProtocol(ConditionProtocol, Protocol):
"""
Extended Protocol for conditions that support routing to targets.
This extends ConditionProtocol with the ability to return target
tasks/steps based on the condition evaluation. Used primarily
by AgentTeam for task routing.
Example:
```python
class ApprovalCondition:
def __init__(self, routes: Dict[str, List[str]]):
self.routes = routes
def evaluate(self, context: Dict[str, Any]) -> bool:
decision = context.get("decision", "")
return decision in self.routes
def get_target(self, context: Dict[str, Any]) -> List[str]:
decision = context.get("decision", "")
return self.routes.get(decision, [])
```
"""
def get_target(self, context: Dict[str, Any]) -> List[str]:
"""
Get the target tasks/steps based on condition evaluation.
Args:
context: Dictionary containing variables for evaluation.
Returns:
List of target task/step names to route to.
Returns empty list if no match found.
"""
...
@@ -33,9 +33,6 @@ from dataclasses import dataclass, field
from typing import Dict, List, Any, Optional, Callable, Tuple, Union
from enum import Enum
# Import AutonomyConfig from canonical location (no circular dep)
from ..agent.autonomy import AutonomyConfig
class MemoryBackend(str, Enum):
"""Memory storage backends."""
@@ -168,10 +165,6 @@ class MemoryConfig:
# Continuous learning configuration
learn: Optional[Union[bool, LearnConfig]] = None
# History injection (auto-inject session history into context)
history: bool = False
history_limit: int = 10
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
learn_dict = None
@@ -179,9 +172,6 @@ class MemoryConfig:
learn_dict = LearnConfig().to_dict()
elif isinstance(self.learn, LearnConfig):
learn_dict = self.learn.to_dict()
elif isinstance(self.learn, dict):
# Pass through dict directly (user provided learn config as dict)
learn_dict = self.learn
return {
"backend": self.backend.value if isinstance(self.backend, MemoryBackend) else self.backend,
@@ -191,8 +181,6 @@ class MemoryConfig:
"claude_memory": self.claude_memory,
"config": self.config,
"learn": learn_dict,
"history": self.history,
"history_limit": self.history_limit,
}
@@ -755,7 +743,7 @@ class SkillsConfig:
# With config
Agent(skills=SkillsConfig(
paths=["./my-skill"],
dirs=["~/.praisonai/skills/"],
dirs=["~/.praison/skills/"],
auto_discover=True,
))
"""
@@ -956,6 +944,54 @@ class AutonomyLevel(str, Enum):
AUTO_EDIT = "auto_edit"
FULL_AUTO = "full_auto"
@dataclass
class AutonomyConfig:
"""
Configuration for agent autonomy behavior.
Controls escalation, doom-loop detection, and approval policies.
Usage:
# Simple enable
Agent(autonomy=True)
# With config
Agent(autonomy=AutonomyConfig(
level="auto_edit",
escalation_enabled=True,
doom_loop_detection=True,
max_consecutive_failures=3,
))
"""
# Autonomy level
level: Union[str, AutonomyLevel] = AutonomyLevel.SUGGEST
# Escalation pipeline
escalation_enabled: bool = True
escalation_threshold: int = 3
# Doom loop detection
doom_loop_detection: bool = True
max_consecutive_failures: int = 3
# Approval policies
require_approval_for_writes: bool = True
require_approval_for_shell: bool = True
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"level": self.level.value if isinstance(self.level, AutonomyLevel) else self.level,
"escalation_enabled": self.escalation_enabled,
"escalation_threshold": self.escalation_threshold,
"doom_loop_detection": self.doom_loop_detection,
"max_consecutive_failures": self.max_consecutive_failures,
"require_approval_for_writes": self.require_approval_for_writes,
"require_approval_for_shell": self.require_approval_for_shell,
}
# Type aliases for Union types used in Agent.__init__
MemoryParam = Union[bool, MemoryConfig, Any] # Any = MemoryManager instance
KnowledgeParam = Union[bool, List[str], KnowledgeConfig, Any] # Any = KnowledgeBase instance
@@ -969,7 +1005,7 @@ TemplateParam = TemplateConfig
CachingParam = Union[bool, CachingConfig]
HooksParam = Union[List[Any], HooksConfig]
SkillsParam = Union[List[str], SkillsConfig]
AutonomyParam = Union[bool, Dict[str, Any], "AutonomyConfig"]
AutonomyParam = Union[bool, Dict[str, Any], AutonomyConfig]
# =============================================================================
@@ -1,609 +0,0 @@
"""
Configuration Loader for PraisonAI Agents.
Loads configuration from multiple sources with precedence:
1. Explicit parameters (highest)
2. Environment variables
3. Config file (.praisonai/config.toml or praisonai.toml)
4. Defaults (lowest)
Zero Performance Impact:
- Config is loaded lazily on first access
- No file I/O at import time
- Cached after first load
Usage:
from praisonaiagents.config.loader import get_config, get_default
# Get entire config
config = get_config()
# Get specific default with fallback
model = get_default("model", "gpt-4o-mini")
memory_config = get_default("memory", {})
# Validate config file
errors = validate_config(config.to_dict())
"""
import os
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
from dataclasses import dataclass, field
# ============================================================================
# CONFIG VALIDATION SCHEMA
# ============================================================================
# Defines valid keys and types for config file validation
VALID_PLUGINS_KEYS = {
"enabled": (bool, list, str), # bool, list of plugin names, or "true"/"false"
"auto_discover": (bool,),
"directories": (list,),
}
VALID_DEFAULTS_KEYS = {
# LLM settings
"model": (str,),
"base_url": (str,),
"api_key": (str,),
# Feature flags
"allow_delegation": (bool,),
"allow_code_execution": (bool,),
"code_execution_mode": (str,),
# Feature configs (nested dicts with 'enabled' key)
"memory": (dict, bool),
"knowledge": (dict, bool),
"planning": (dict, bool),
"reflection": (dict, bool),
"guardrails": (dict, bool),
"web": (dict, bool),
"output": (dict, str), # dict or preset string
"execution": (dict, str), # dict or preset string
"caching": (dict, bool),
"autonomy": (dict, bool),
"skills": (dict, list),
"context": (dict, bool),
"hooks": (dict, list),
"templates": (dict,),
}
VALID_ROOT_KEYS = {"plugins", "defaults"}
class ConfigValidationError(Exception):
"""Raised when config file has validation errors."""
def __init__(self, errors: List[str]):
self.errors = errors
message = "Config validation failed:\n" + "\n".join(f" - {e}" for e in errors)
super().__init__(message)
# Global config cache (lazy loaded)
_config_cache: Optional[Dict[str, Any]] = None
_config_loaded: bool = False
@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/"
])
def to_dict(self) -> Dict[str, Any]:
return {
"enabled": self.enabled,
"auto_discover": self.auto_discover,
"directories": self.directories,
}
@dataclass
class DefaultsConfig:
"""Default configuration for Agent parameters."""
# LLM defaults
model: Optional[str] = None
base_url: Optional[str] = None
# Feature flags
allow_delegation: bool = False
allow_code_execution: bool = False
code_execution_mode: str = "safe"
# Feature configs (nested dicts)
memory: Optional[Dict[str, Any]] = None
knowledge: Optional[Dict[str, Any]] = None
planning: Optional[Dict[str, Any]] = None
reflection: Optional[Dict[str, Any]] = None
web: Optional[Dict[str, Any]] = None
output: Optional[Dict[str, Any]] = None
execution: Optional[Dict[str, Any]] = None
caching: Optional[Dict[str, Any]] = None
autonomy: Optional[Dict[str, Any]] = None
skills: Optional[Dict[str, Any]] = None
def to_dict(self) -> Dict[str, Any]:
return {
"model": self.model,
"base_url": self.base_url,
"allow_delegation": self.allow_delegation,
"allow_code_execution": self.allow_code_execution,
"code_execution_mode": self.code_execution_mode,
"memory": self.memory,
"knowledge": self.knowledge,
"planning": self.planning,
"reflection": self.reflection,
"web": self.web,
"output": self.output,
"execution": self.execution,
"caching": self.caching,
"autonomy": self.autonomy,
"skills": self.skills,
}
@dataclass
class PraisonConfig:
"""Root configuration for PraisonAI Agents."""
plugins: PluginsConfig = field(default_factory=PluginsConfig)
defaults: DefaultsConfig = field(default_factory=DefaultsConfig)
def to_dict(self) -> Dict[str, Any]:
return {
"plugins": self.plugins.to_dict(),
"defaults": self.defaults.to_dict(),
}
def _find_config_file() -> Optional[Path]:
"""Find config file in standard locations.
Search order:
1. .praisonai/config.toml (project-local)
2. praisonai.toml (project root)
3. ~/.praisonai/config.toml (user global)
Returns:
Path to config file if found, None otherwise
"""
# Project-local locations
cwd = Path.cwd()
local_paths = [
cwd / ".praisonai" / "config.toml",
cwd / "praisonai.toml",
]
for path in local_paths:
if path.exists():
return path
# User global location
home = Path.home()
global_path = home / ".praisonai" / "config.toml"
if global_path.exists():
return global_path
return None
def _parse_toml(path: Path) -> Dict[str, Any]:
"""Parse TOML file with fallback for Python < 3.11.
Args:
path: Path to TOML file
Returns:
Parsed config dict
"""
try:
# Python 3.11+ has built-in tomllib
import tomllib
with open(path, "rb") as f:
return tomllib.load(f)
except ImportError:
# Fallback for Python < 3.11
try:
import tomli
with open(path, "rb") as f:
return tomli.load(f)
except ImportError:
# No TOML parser available - return empty config
import logging
logging.debug(
"TOML parser not available. Install tomli for Python < 3.11: "
"pip install tomli"
)
return {}
def _load_config() -> Dict[str, Any]:
"""Load configuration from file.
Returns:
Config dict (empty if no config file found)
"""
config_path = _find_config_file()
if config_path is None:
return {}
try:
return _parse_toml(config_path)
except Exception as e:
import logging
logging.warning(f"Failed to load config from {config_path}: {e}")
return {}
def _dict_to_plugins_config(data: Dict[str, Any]) -> PluginsConfig:
"""Convert dict to PluginsConfig."""
return PluginsConfig(
enabled=data.get("enabled", False),
auto_discover=data.get("auto_discover", True),
directories=data.get("directories", [
"./.praisonai/plugins/",
"~/.praisonai/plugins/"
]),
)
def _dict_to_defaults_config(data: Dict[str, Any]) -> DefaultsConfig:
"""Convert dict to DefaultsConfig."""
return DefaultsConfig(
model=data.get("model"),
base_url=data.get("base_url"),
allow_delegation=data.get("allow_delegation", False),
allow_code_execution=data.get("allow_code_execution", False),
code_execution_mode=data.get("code_execution_mode", "safe"),
memory=data.get("memory"),
knowledge=data.get("knowledge"),
planning=data.get("planning"),
reflection=data.get("reflection"),
web=data.get("web"),
output=data.get("output"),
execution=data.get("execution"),
caching=data.get("caching"),
autonomy=data.get("autonomy"),
skills=data.get("skills"),
)
def _dict_to_praison_config(data: Dict[str, Any]) -> PraisonConfig:
"""Convert dict to PraisonConfig."""
plugins_data = data.get("plugins", {})
defaults_data = data.get("defaults", {})
return PraisonConfig(
plugins=_dict_to_plugins_config(plugins_data),
defaults=_dict_to_defaults_config(defaults_data),
)
def get_config(reload: bool = False) -> PraisonConfig:
"""Get the global configuration.
Loads config lazily on first access and caches it.
Args:
reload: Force reload from file
Returns:
PraisonConfig instance
"""
global _config_cache, _config_loaded
if not _config_loaded or reload:
raw_config = _load_config()
_config_cache = _dict_to_praison_config(raw_config)
_config_loaded = True
return _config_cache
def get_plugins_config() -> PluginsConfig:
"""Get plugins configuration.
Returns:
PluginsConfig instance
"""
return get_config().plugins
def get_defaults_config() -> DefaultsConfig:
"""Get defaults configuration.
Returns:
DefaultsConfig instance
"""
return get_config().defaults
def get_default(key: str, fallback: Any = None) -> Any:
"""Get a specific default value.
Args:
key: Config key (e.g., "model", "memory", "memory.backend")
fallback: Value to return if key not found
Returns:
Config value or fallback
"""
defaults = get_defaults_config()
# Handle nested keys like "memory.backend"
if "." in key:
parts = key.split(".")
value = getattr(defaults, parts[0], None)
if value is None:
return fallback
for part in parts[1:]:
if isinstance(value, dict):
value = value.get(part)
else:
return fallback
if value is None:
return fallback
return value
# Simple key
value = getattr(defaults, key, None)
return value if value is not None else fallback
def is_plugins_enabled() -> bool:
"""Check if plugins are enabled via config or env var.
Returns:
True if plugins should be enabled
"""
# Check env var first (highest precedence)
env_value = os.environ.get("PRAISONAI_PLUGINS", "").lower()
if env_value:
if env_value in ("true", "1", "yes", "on"):
return True
if env_value in ("false", "0", "no", "off"):
return False
# Treat as comma-separated list of plugin names
return True
# Check config file
plugins_config = get_plugins_config()
if isinstance(plugins_config.enabled, bool):
return plugins_config.enabled
if isinstance(plugins_config.enabled, list):
return len(plugins_config.enabled) > 0
return False
def get_enabled_plugins() -> Optional[List[str]]:
"""Get list of enabled plugins (if specific list provided).
Returns:
List of plugin names, or None if all plugins enabled
"""
# Check env var first
env_value = os.environ.get("PRAISONAI_PLUGINS", "").lower()
if env_value and env_value not in ("true", "1", "yes", "on", "false", "0", "no", "off"):
# Treat as comma-separated list
return [p.strip() for p in env_value.split(",") if p.strip()]
# Check config file
plugins_config = get_plugins_config()
if isinstance(plugins_config.enabled, list):
return plugins_config.enabled
return None # All plugins enabled
def clear_config_cache() -> None:
"""Clear the config cache (for testing)."""
global _config_cache, _config_loaded
_config_cache = None
_config_loaded = False
def validate_config(config: Dict[str, Any], raise_on_error: bool = False) -> List[str]:
"""Validate config file structure and types.
Args:
config: Raw config dict (from TOML parsing)
raise_on_error: If True, raise ConfigValidationError on errors
Returns:
List of validation error messages (empty if valid)
Example:
config = {"plugins": {"enabled": True}, "defaults": {"model": "gpt-4o"}}
errors = validate_config(config)
if errors:
print("Config errors:", errors)
"""
errors = []
# Check root keys
for key in config.keys():
if key not in VALID_ROOT_KEYS:
similar = _suggest_similar_key(key, VALID_ROOT_KEYS)
if similar:
errors.append(f"Unknown root key '{key}'. Did you mean '{similar}'?")
else:
errors.append(f"Unknown root key '{key}'. Valid keys: {', '.join(sorted(VALID_ROOT_KEYS))}")
# Validate [plugins] section
if "plugins" in config:
plugins = config["plugins"]
if not isinstance(plugins, dict):
errors.append(f"[plugins] must be a table, got {type(plugins).__name__}")
else:
for key, value in plugins.items():
if key not in VALID_PLUGINS_KEYS:
similar = _suggest_similar_key(key, VALID_PLUGINS_KEYS.keys())
if similar:
errors.append(f"[plugins] Unknown key '{key}'. Did you mean '{similar}'?")
else:
errors.append(f"[plugins] Unknown key '{key}'. Valid keys: {', '.join(sorted(VALID_PLUGINS_KEYS.keys()))}")
else:
valid_types = VALID_PLUGINS_KEYS[key]
if not isinstance(value, valid_types):
errors.append(f"[plugins.{key}] Expected {_format_types(valid_types)}, got {type(value).__name__}")
# Validate [defaults] section
if "defaults" in config:
defaults = config["defaults"]
if not isinstance(defaults, dict):
errors.append(f"[defaults] must be a table, got {type(defaults).__name__}")
else:
for key, value in defaults.items():
if key not in VALID_DEFAULTS_KEYS:
similar = _suggest_similar_key(key, VALID_DEFAULTS_KEYS.keys())
if similar:
errors.append(f"[defaults] Unknown key '{key}'. Did you mean '{similar}'?")
else:
errors.append(f"[defaults] Unknown key '{key}'. Valid keys: {', '.join(sorted(VALID_DEFAULTS_KEYS.keys()))}")
else:
valid_types = VALID_DEFAULTS_KEYS[key]
if not isinstance(value, valid_types):
errors.append(f"[defaults.{key}] Expected {_format_types(valid_types)}, got {type(value).__name__}")
# Validate nested feature configs
if isinstance(value, dict) and key in ("memory", "knowledge", "planning", "reflection",
"guardrails", "web", "output", "execution",
"caching", "autonomy", "context"):
nested_errors = _validate_feature_config(key, value)
errors.extend(nested_errors)
if raise_on_error and errors:
raise ConfigValidationError(errors)
return errors
def _suggest_similar_key(key: str, valid_keys) -> Optional[str]:
"""Suggest a similar valid key using simple string matching."""
key_lower = key.lower()
for valid in valid_keys:
# Exact match after lowercasing
if valid.lower() == key_lower:
return valid
# Prefix match
if valid.lower().startswith(key_lower) or key_lower.startswith(valid.lower()):
return valid
# Substring match
if key_lower in valid.lower() or valid.lower() in key_lower:
return valid
return None
def _format_types(types: tuple) -> str:
"""Format type tuple for error messages."""
type_names = []
for t in types:
if t is bool:
type_names.append("bool")
elif t is str:
type_names.append("str")
elif t is dict:
type_names.append("table")
elif t is list:
type_names.append("array")
else:
type_names.append(t.__name__)
return " or ".join(type_names)
def _validate_feature_config(feature: str, config: Dict[str, Any]) -> List[str]:
"""Validate a nested feature config (memory, knowledge, etc.)."""
errors = []
# Common valid keys for feature configs
common_keys = {"enabled", "backend", "provider", "preset", "verbose"}
# Feature-specific valid keys
feature_keys = {
"memory": {"enabled", "backend", "provider", "use_long_term", "use_short_term",
"user_id", "session_id", "db_path", "collection_name"},
"knowledge": {"enabled", "sources", "provider", "chunk_size", "chunk_overlap"},
"planning": {"enabled", "max_steps", "reasoning"},
"reflection": {"enabled", "max_reflect", "min_reflect", "llm"},
"guardrails": {"enabled", "max_retries", "validator"},
"web": {"enabled", "search", "fetch", "provider"},
"output": {"enabled", "verbose", "markdown", "stream", "metrics", "preset"},
"execution": {"enabled", "max_iter", "max_rpm", "max_execution_time", "preset"},
"caching": {"enabled", "prompt_caching", "ttl"},
"autonomy": {"enabled", "max_steps", "approval_required"},
"context": {"enabled", "max_tokens", "strategy"},
}
valid_keys = feature_keys.get(feature, common_keys)
for key in config.keys():
if key not in valid_keys:
similar = _suggest_similar_key(key, valid_keys)
if similar:
errors.append(f"[defaults.{feature}.{key}] Unknown key. Did you mean '{similar}'?")
# Don't error on unknown nested keys - allow flexibility
return errors
def get_config_path() -> Optional[Path]:
"""Get the path to the config file if it exists.
Returns:
Path to config file, or None if not found
"""
return _find_config_file()
# Convenience function to merge config defaults with explicit params
def apply_config_defaults(
param_name: str,
explicit_value: Any,
config_class: Optional[type] = None,
) -> Any:
"""Apply config defaults to a parameter if not explicitly set.
Args:
param_name: Name of the parameter (e.g., "memory", "knowledge")
explicit_value: Value explicitly passed by user (None if not set)
config_class: Optional config class to instantiate
Returns:
Resolved value with config defaults applied
Example:
# User passes memory=True, config has memory.backend="postgres"
# Result: MemoryConfig with backend="postgres"
memory = apply_config_defaults("memory", True, MemoryConfig)
"""
# If user explicitly passed a value, respect it
if explicit_value is not None:
# If True and we have config defaults, merge them
if explicit_value is True:
config_defaults = get_default(param_name)
if config_defaults and isinstance(config_defaults, dict):
if config_class:
# Check if enabled key exists and is True
if config_defaults.get("enabled", True):
return config_class(**{k: v for k, v in config_defaults.items() if k != "enabled"})
return config_defaults
return explicit_value
# Check if config has defaults for this param
config_defaults = get_default(param_name)
if config_defaults is None:
return None
# If config has enabled=True, return the config
if isinstance(config_defaults, dict):
if config_defaults.get("enabled", False):
if config_class:
return config_class(**{k: v for k, v in config_defaults.items() if k != "enabled"})
return True
return None
@@ -16,7 +16,6 @@ from typing import Any, Dict
# =============================================================================
MEMORY_PRESETS: Dict[str, Dict[str, Any]] = {
# Backend presets
"file": {"backend": "file"},
"sqlite": {"backend": "sqlite"},
"redis": {"backend": "redis"},
@@ -24,12 +23,6 @@ MEMORY_PRESETS: Dict[str, Dict[str, Any]] = {
"postgresql": {"backend": "postgres"},
"mem0": {"backend": "mem0"},
"mongodb": {"backend": "mongodb"},
# Feature presets
"learn": {"backend": "file", "learn": True}, # Enable Agent Learn with file backend
# History presets (auto-inject session history into context)
"history": {"backend": "file", "history": True, "history_limit": 10},
"session": {"backend": "file", "history": True, "history_limit": 10}, # Alias
"chat": {"backend": "file", "history": True, "history_limit": 20}, # Conversational
}
MEMORY_URL_SCHEMES: Dict[str, str] = {
@@ -1,156 +0,0 @@
# PraisonAI Agents Configuration File
# Copy this file to .praisonai/config.toml in your project directory
# or to ~/.praisonai/config.toml for global settings
# ============================================================================
# PLUGINS CONFIGURATION
# ============================================================================
# Enable background plugins (hooks, metrics, logging)
# Note: Tools and guardrails work WITHOUT enabling plugins
[plugins]
# Enable all plugins: true
# Enable specific plugins: ["logging", "metrics"]
# Disable: false
enabled = false
# Auto-discover plugins from default directories
auto_discover = true
# Plugin directories to scan
directories = [
"./.praisonai/plugins/",
"~/.praisonai/plugins/"
]
# ============================================================================
# DEFAULT SETTINGS
# ============================================================================
# These defaults are used when you pass True to a feature parameter
# Example: Agent(memory=True) will use [defaults.memory] settings
[defaults]
# Default LLM model
model = "gpt-4o-mini"
# Custom LLM endpoint (optional)
# base_url = "http://localhost:11434/v1"
# Feature flags
allow_delegation = false
allow_code_execution = false
code_execution_mode = "safe"
# ============================================================================
# MEMORY DEFAULTS
# ============================================================================
# Used when: Agent(memory=True)
[defaults.memory]
enabled = false
backend = "file" # file, sqlite, redis, postgres, mongodb
# user_id = "" # Auto-generated if empty
auto_memory = false
history = false
history_limit = 10
# Learning configuration
[defaults.memory.learn]
enabled = false
persona = true
insights = true
patterns = false
# ============================================================================
# KNOWLEDGE DEFAULTS
# ============================================================================
# Used when: Agent(knowledge=True)
[defaults.knowledge]
enabled = false
embedder = "openai"
chunking_strategy = "semantic"
chunk_size = 1000
chunk_overlap = 200
retrieval_k = 5
retrieval_threshold = 0.0
rerank = false
# ============================================================================
# PLANNING DEFAULTS
# ============================================================================
# Used when: Agent(planning=True)
[defaults.planning]
enabled = false
reasoning = false
auto_approve = false
read_only = false
# ============================================================================
# REFLECTION DEFAULTS
# ============================================================================
# Used when: Agent(reflection=True)
[defaults.reflection]
enabled = false
min_iterations = 1
max_iterations = 3
# ============================================================================
# WEB DEFAULTS
# ============================================================================
# Used when: Agent(web=True)
[defaults.web]
enabled = false
search = true
fetch = true
search_provider = "duckduckgo"
max_results = 5
# ============================================================================
# OUTPUT DEFAULTS
# ============================================================================
# Used when: Agent(output=True) or default output mode
[defaults.output]
preset = "silent" # silent, actions, verbose, json, stream, status
verbose = false
markdown = false
stream = false
metrics = false
# ============================================================================
# EXECUTION DEFAULTS
# ============================================================================
# Used when: Agent(execution=True) or default execution limits
[defaults.execution]
max_iter = 20
max_retry_limit = 2
# max_rpm = null
# max_execution_time = null
# ============================================================================
# CACHING DEFAULTS
# ============================================================================
# Used when: Agent(caching=True)
[defaults.caching]
enabled = true
prompt_caching = false
# ============================================================================
# AUTONOMY DEFAULTS
# ============================================================================
# Used when: Agent(autonomy=True)
[defaults.autonomy]
level = "suggest" # suggest, auto_edit, full_auto
escalation_enabled = true
escalation_threshold = 3
doom_loop_detection = true
max_consecutive_failures = 3
require_approval_for_writes = true
require_approval_for_shell = true
@@ -181,7 +181,7 @@ class ArtifactStoreProtocol(Protocol):
Example implementation in wrapper:
class FileSystemArtifactStore:
def store(self, content, metadata):
# Write to ~/.praisonai/runs/{run_id}/artifacts/
# Write to ~/.praison/runs/{run_id}/artifacts/
...
"""
@@ -323,7 +323,6 @@ class SmartBackend:
# Check if ripgrep is available
rg = self._get_ripgrep()
if not rg.is_available():
self._use_ripgrep = False
return False
# Count files if not already counted for this path
@@ -24,8 +24,7 @@ from pathlib import Path
from enum import Enum
from .models import (
ContextLedger, BudgetAllocation, OptimizerStrategy, OptimizationResult,
ContextConfig as ManagerConfig # Deprecated alias for backward compatibility
ContextLedger, BudgetAllocation, OptimizerStrategy, OptimizationResult
)
from .tokens import estimate_tokens_heuristic, estimate_messages_tokens, estimate_message_tokens
from .budgeter import ContextBudgeter
@@ -289,6 +288,180 @@ class SnapshotHookData:
"budget": self.budget.to_dict() if self.budget else None,
}
@dataclass
class ManagerConfig:
"""
Complete configuration for ContextManager.
Consolidates all context management settings with proper precedence.
"""
# Auto-compaction
auto_compact: bool = True
compact_threshold: float = 0.8
strategy: OptimizerStrategy = OptimizerStrategy.SMART
# Compression benefit check
compression_min_gain_pct: float = 5.0
compression_max_attempts: int = 3
# Budget
output_reserve: int = 8000
history_ratio: float = 0.6
default_tool_output_max: int = 10000
# Per-tool budgets
tool_budgets: Dict[str, PerToolBudget] = field(default_factory=dict)
protected_tools: List[str] = field(default_factory=list)
# LLM-powered summarization
llm_summarize: bool = False # Enable LLM-powered summarization
# Smart tool output summarization
smart_tool_summarize: bool = True # Summarize large tool outputs using LLM before truncating
tool_summarize_limits: Dict[str, int] = field(default_factory=dict) # Per-tool min_chars_to_summarize
# Estimation
estimation_mode: EstimationMode = EstimationMode.HEURISTIC
log_estimation_mismatch: bool = False
mismatch_threshold_pct: float = 15.0
# Monitoring
monitor_enabled: bool = False
monitor_path: str = "./context.txt"
monitor_format: Literal["human", "json"] = "human"
monitor_frequency: Literal["turn", "tool_call", "manual", "overflow"] = "turn"
monitor_write_mode: Literal["sync", "async"] = "sync"
redact_sensitive: bool = True
snapshot_timing: Literal["pre_optimization", "post_optimization", "both"] = "post_optimization"
# Path validation
allow_absolute_paths: bool = False
# Pruning
prune_after_tokens: int = 40000
keep_recent_turns: int = 5
# Source tracking
source: str = "defaults" # defaults, env, config_file, cli
def to_dict(self) -> Dict[str, Any]:
result = {
"auto_compact": self.auto_compact,
"compact_threshold": self.compact_threshold,
"strategy": self.strategy.value,
"compression_min_gain_pct": self.compression_min_gain_pct,
"compression_max_attempts": self.compression_max_attempts,
"output_reserve": self.output_reserve,
"history_ratio": self.history_ratio,
"default_tool_output_max": self.default_tool_output_max,
"tool_budgets": {k: v.to_dict() for k, v in self.tool_budgets.items()},
"protected_tools": self.protected_tools,
"estimation_mode": self.estimation_mode.value,
"log_estimation_mismatch": self.log_estimation_mismatch,
"mismatch_threshold_pct": self.mismatch_threshold_pct,
"monitor_enabled": self.monitor_enabled,
"monitor_path": self.monitor_path,
"monitor_format": self.monitor_format,
"monitor_frequency": self.monitor_frequency,
"monitor_write_mode": self.monitor_write_mode,
"redact_sensitive": self.redact_sensitive,
"snapshot_timing": self.snapshot_timing,
"allow_absolute_paths": self.allow_absolute_paths,
"prune_after_tokens": self.prune_after_tokens,
"keep_recent_turns": self.keep_recent_turns,
"llm_summarize": self.llm_summarize,
"smart_tool_summarize": self.smart_tool_summarize,
"tool_summarize_limits": self.tool_summarize_limits,
"source": self.source,
}
return result
@classmethod
def from_env(cls) -> "ManagerConfig":
"""Load config from environment variables."""
import os
def get_bool(key: str, default: bool) -> bool:
val = os.getenv(key, str(default)).lower()
return val in ("true", "1", "yes", "on")
def get_float(key: str, default: float) -> float:
try:
return float(os.getenv(key, str(default)))
except ValueError:
return default
def get_int(key: str, default: int) -> int:
try:
return int(os.getenv(key, str(default)))
except ValueError:
return default
strategy_str = os.getenv("PRAISONAI_CONTEXT_STRATEGY", "smart")
try:
strategy = OptimizerStrategy(strategy_str)
except ValueError:
strategy = OptimizerStrategy.SMART
estimation_str = os.getenv("PRAISONAI_CONTEXT_ESTIMATION_MODE", "heuristic")
try:
estimation_mode = EstimationMode(estimation_str)
except ValueError:
estimation_mode = EstimationMode.HEURISTIC
return cls(
auto_compact=get_bool("PRAISONAI_CONTEXT_AUTO_COMPACT", True),
compact_threshold=get_float("PRAISONAI_CONTEXT_THRESHOLD", 0.8),
strategy=strategy,
compression_min_gain_pct=get_float("PRAISONAI_CONTEXT_COMPRESSION_MIN_GAIN", 5.0),
compression_max_attempts=get_int("PRAISONAI_CONTEXT_COMPRESSION_MAX_ATTEMPTS", 3),
output_reserve=get_int("PRAISONAI_CONTEXT_OUTPUT_RESERVE", 8000),
default_tool_output_max=get_int("PRAISONAI_CONTEXT_TOOL_OUTPUT_MAX", 10000),
estimation_mode=estimation_mode,
log_estimation_mismatch=get_bool("PRAISONAI_CONTEXT_LOG_MISMATCH", False),
monitor_enabled=get_bool("PRAISONAI_CONTEXT_MONITOR", False),
monitor_path=os.getenv("PRAISONAI_CONTEXT_MONITOR_PATH", "./context.txt"),
monitor_format=os.getenv("PRAISONAI_CONTEXT_MONITOR_FORMAT", "human"),
monitor_frequency=os.getenv("PRAISONAI_CONTEXT_MONITOR_FREQUENCY", "turn"),
monitor_write_mode=os.getenv("PRAISONAI_CONTEXT_MONITOR_WRITE_MODE", "sync"),
redact_sensitive=get_bool("PRAISONAI_CONTEXT_REDACT", True),
source="env",
)
def merge(self, **overrides) -> "ManagerConfig":
"""Create new config with overrides applied."""
current = self.to_dict()
for key, value in overrides.items():
if value is not None and key in current:
current[key] = value
# Handle enum conversions
if isinstance(current.get("strategy"), str):
try:
current["strategy"] = OptimizerStrategy(current["strategy"])
except ValueError:
current["strategy"] = OptimizerStrategy.SMART
if isinstance(current.get("estimation_mode"), str):
try:
current["estimation_mode"] = EstimationMode(current["estimation_mode"])
except ValueError:
current["estimation_mode"] = EstimationMode.HEURISTIC
# Reconstruct tool budgets
tool_budgets = {}
for name, budget_dict in current.get("tool_budgets", {}).items():
if isinstance(budget_dict, dict):
tool_budgets[name] = PerToolBudget(**budget_dict)
elif isinstance(budget_dict, PerToolBudget):
tool_budgets[name] = budget_dict
current["tool_budgets"] = tool_budgets
return ManagerConfig(**current)
class ContextManager:
"""
Unified facade for context management.
@@ -272,7 +272,6 @@ class ContextConfig:
output_reserve: int = 8000
history_ratio: float = 0.6 # 60% of usable for history
tool_output_max: int = 10000 # Max tokens per tool output
default_tool_output_max: int = 10000 # Alias for tool_output_max (ManagerConfig compat)
# Pruning
prune_after_tokens: int = 40000
@@ -309,38 +308,6 @@ class ContextConfig:
])
aggregate_max_tokens: int = 4000 # Max tokens for aggregated context
# -------------------------------------------------------------------------
# Internal fields (from ManagerConfig consolidation)
# These are typically set by the system, not users directly
# -------------------------------------------------------------------------
# Compression benefit check
compression_min_gain_pct: float = 5.0
compression_max_attempts: int = 3
# Per-tool budgets (advanced)
tool_budgets: Dict[str, Any] = field(default_factory=dict) # tool_name -> PerToolBudget
tool_summarize_limits: Dict[str, int] = field(default_factory=dict) # Per-tool min_chars
# Estimation modes
estimation_mode: str = "heuristic" # heuristic, accurate, validated
log_estimation_mismatch: bool = False
mismatch_threshold_pct: float = 15.0
# Extended monitoring options
monitor_enabled: bool = False # Enable monitoring
monitor_path: str = "./context.txt" # Path for monitoring output
monitor_format: str = "human" # human, json
monitor_frequency: str = "turn" # turn, tool_call, manual, overflow
monitor_write_mode: str = "sync" # sync, async
redact_sensitive: bool = True
snapshot_timing: str = "post_optimization" # pre_optimization, post_optimization, both
allow_absolute_paths: bool = False
# Source tracking
source: str = "defaults" # defaults, env, config_file, cli
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
result = asdict(self)
@@ -371,73 +338,6 @@ class ContextConfig:
output_reserve=8000,
history_ratio=0.6,
)
@classmethod
def from_env(cls) -> "ContextConfig":
"""Load config from environment variables.
Reads PRAISONAI_CONTEXT_* environment variables.
"""
import os
def get_bool(key: str, default: bool) -> bool:
val = os.getenv(key, str(default)).lower()
return val in ("true", "1", "yes", "on")
def get_float(key: str, default: float) -> float:
try:
return float(os.getenv(key, str(default)))
except ValueError:
return default
def get_int(key: str, default: int) -> int:
try:
return int(os.getenv(key, str(default)))
except ValueError:
return default
strategy_str = os.getenv("PRAISONAI_CONTEXT_STRATEGY", "smart")
try:
strategy = OptimizerStrategy(strategy_str)
except ValueError:
strategy = OptimizerStrategy.SMART
return cls(
auto_compact=get_bool("PRAISONAI_CONTEXT_AUTO_COMPACT", True),
compact_threshold=get_float("PRAISONAI_CONTEXT_THRESHOLD", 0.8),
strategy=strategy,
compression_min_gain_pct=get_float("PRAISONAI_CONTEXT_COMPRESSION_MIN_GAIN", 5.0),
compression_max_attempts=get_int("PRAISONAI_CONTEXT_COMPRESSION_MAX_ATTEMPTS", 3),
output_reserve=get_int("PRAISONAI_CONTEXT_OUTPUT_RESERVE", 8000),
tool_output_max=get_int("PRAISONAI_CONTEXT_TOOL_OUTPUT_MAX", 10000),
default_tool_output_max=get_int("PRAISONAI_CONTEXT_TOOL_OUTPUT_MAX", 10000),
log_estimation_mismatch=get_bool("PRAISONAI_CONTEXT_LOG_MISMATCH", False),
monitor_enabled=get_bool("PRAISONAI_CONTEXT_MONITOR", False),
monitor_path=os.getenv("PRAISONAI_CONTEXT_MONITOR_PATH", "./context.txt"),
monitor_format=os.getenv("PRAISONAI_CONTEXT_MONITOR_FORMAT", "human"),
monitor_frequency=os.getenv("PRAISONAI_CONTEXT_MONITOR_FREQUENCY", "turn"),
monitor_write_mode=os.getenv("PRAISONAI_CONTEXT_MONITOR_WRITE_MODE", "sync"),
redact_sensitive=get_bool("PRAISONAI_CONTEXT_REDACT", True),
source="env",
)
def merge(self, **overrides) -> "ContextConfig":
"""Create new config with overrides applied."""
current = self.to_dict()
for key, value in overrides.items():
if value is not None and key in current:
current[key] = value
# Handle enum conversions
if isinstance(current.get("strategy"), str):
try:
current["strategy"] = OptimizerStrategy(current["strategy"])
except ValueError:
current["strategy"] = OptimizerStrategy.SMART
return ContextConfig(**{k: v for k, v in current.items()
if k in ContextConfig.__dataclass_fields__})
@runtime_checkable
@@ -76,13 +76,6 @@ __all__ = [
"count_tokens",
"needs_chunking",
"get_recommended_chunk_size",
# EvaluationLoop (iterative improvement)
"EvaluationLoop",
"EvaluationLoopConfig",
"IterationResult",
"EvaluationLoopResult",
"EvaluationLoopProtocol",
"EvaluationLoopResultProtocol",
]
_LAZY_IMPORTS = {
@@ -141,13 +134,6 @@ _LAZY_IMPORTS = {
"count_tokens": ("tokens", "count_tokens"),
"needs_chunking": ("tokens", "needs_chunking"),
"get_recommended_chunk_size": ("tokens", "get_recommended_chunk_size"),
# EvaluationLoop (iterative improvement)
"EvaluationLoop": ("loop", "EvaluationLoop"),
"EvaluationLoopConfig": ("loop", "EvaluationLoopConfig"),
"IterationResult": ("results", "IterationResult"),
"EvaluationLoopResult": ("results", "EvaluationLoopResult"),
"EvaluationLoopProtocol": ("protocols", "EvaluationLoopProtocol"),
"EvaluationLoopResultProtocol": ("protocols", "EvaluationLoopResultProtocol"),
}
@@ -1,276 +0,0 @@
"""
EvaluationLoop - Iterative evaluation and improvement loop for agents.
This module provides the EvaluationLoop class which implements the "Ralph Loop"
pattern: run agent → judge output → improve → repeat until threshold met.
Example:
from praisonaiagents import Agent
from praisonaiagents.eval import EvaluationLoop
agent = Agent(name="analyzer", instructions="Analyze systems")
loop = EvaluationLoop(agent=agent, criteria="Analysis is thorough")
result = loop.run("Analyze the auth flow")
print(result.final_score) # 8.5
print(result.success) # True
"""
import time
import logging
from typing import TYPE_CHECKING, Optional, Callable, Any, List
from dataclasses import dataclass
if TYPE_CHECKING:
from ..agent.agent import Agent
logger = logging.getLogger(__name__)
@dataclass
class EvaluationLoopConfig:
"""Configuration for EvaluationLoop."""
criteria: str = ""
threshold: float = 8.0
max_iterations: int = 5
mode: str = "optimize"
model: str = "gpt-4o-mini"
verbose: bool = False
class EvaluationLoop:
"""
Iterative evaluation loop that runs an agent, judges output, and improves.
The loop continues until either:
- The score meets or exceeds the threshold (success)
- Max iterations is reached (failure in optimize mode)
- All iterations complete (review mode always runs all)
Args:
agent: The Agent instance to evaluate
criteria: Evaluation criteria for the Judge
threshold: Score threshold for success (default: 8.0)
max_iterations: Maximum number of iterations (default: 5)
mode: "optimize" (stop on success) or "review" (run all iterations)
judge: Optional custom Judge instance
on_iteration: Optional callback called after each iteration
verbose: Enable verbose logging
model: LLM model for Judge (default: gpt-4o-mini)
Example:
loop = EvaluationLoop(
agent=my_agent,
criteria="Response is helpful and accurate",
threshold=8.0,
max_iterations=5,
on_iteration=lambda r: print(f"Score: {r.score}")
)
result = loop.run("Analyze the codebase")
"""
def __init__(
self,
agent: "Agent",
criteria: str,
threshold: float = 8.0,
max_iterations: int = 5,
mode: str = "optimize",
judge: Optional[Any] = None,
on_iteration: Optional[Callable[[Any], None]] = None,
verbose: bool = False,
model: str = "gpt-4o-mini",
):
self.agent = agent
self.criteria = criteria
self.threshold = threshold
self.max_iterations = max_iterations
self.mode = mode
self._judge = judge
self.on_iteration = on_iteration
self.verbose = verbose
self.model = model
if mode not in ("optimize", "review"):
raise ValueError(f"mode must be 'optimize' or 'review', got '{mode}'")
@property
def judge(self):
"""Lazy-load Judge to avoid import-time overhead."""
if self._judge is None:
from .judge import Judge
self._judge = Judge(
criteria=self.criteria,
model=self.model,
)
return self._judge
def _get_agent_output(self, prompt: str, iteration: int, feedback: str = "") -> str:
"""Get output from agent, optionally including feedback from previous iteration."""
if iteration == 1 or not feedback:
return str(self.agent.chat(prompt))
improved_prompt = f"{prompt}\n\nPrevious feedback to address:\n{feedback}"
return str(self.agent.chat(improved_prompt))
async def _get_agent_output_async(self, prompt: str, iteration: int, feedback: str = "") -> str:
"""Async version of _get_agent_output."""
if iteration == 1 or not feedback:
if hasattr(self.agent, 'chat_async'):
return str(await self.agent.chat_async(prompt))
return str(self.agent.chat(prompt))
improved_prompt = f"{prompt}\n\nPrevious feedback to address:\n{feedback}"
if hasattr(self.agent, 'chat_async'):
return str(await self.agent.chat_async(improved_prompt))
return str(self.agent.chat(improved_prompt))
def run(self, prompt: str) -> "EvaluationLoopResult":
"""
Run the evaluation loop synchronously.
Args:
prompt: The prompt to send to the agent
Returns:
EvaluationLoopResult with iteration results
"""
from .results import IterationResult, EvaluationLoopResult
start_time = time.time()
iterations: List[IterationResult] = []
feedback = ""
for i in range(1, self.max_iterations + 1):
if self.verbose:
logger.info(f"EvaluationLoop iteration {i}/{self.max_iterations}")
output = self._get_agent_output(prompt, i, feedback)
judge_result = self.judge.run(
output=output,
criteria=self.criteria,
)
findings = getattr(judge_result, 'suggestions', []) or []
iteration_result = IterationResult(
iteration=i,
output=output,
score=judge_result.score,
reasoning=judge_result.reasoning,
findings=findings,
)
iterations.append(iteration_result)
if self.on_iteration:
try:
self.on_iteration(iteration_result)
except Exception as e:
logger.warning(f"on_iteration callback error: {e}")
if self.mode == "optimize" and judge_result.score >= self.threshold:
if self.verbose:
logger.info(f"Threshold met at iteration {i}: {judge_result.score} >= {self.threshold}")
break
feedback = judge_result.reasoning
if findings:
feedback += "\nSuggestions:\n" + "\n".join(f"- {s}" for s in findings)
total_duration = time.time() - start_time
success = iterations[-1].score >= self.threshold if iterations else False
result = EvaluationLoopResult(
iterations=iterations,
success=success,
total_duration_seconds=total_duration,
threshold=self.threshold,
mode=self.mode,
)
if self.verbose:
result.print_summary()
return result
async def run_async(self, prompt: str) -> "EvaluationLoopResult":
"""
Run the evaluation loop asynchronously.
Args:
prompt: The prompt to send to the agent
Returns:
EvaluationLoopResult with iteration results
"""
from .results import IterationResult, EvaluationLoopResult
start_time = time.time()
iterations: List[IterationResult] = []
feedback = ""
for i in range(1, self.max_iterations + 1):
if self.verbose:
logger.info(f"EvaluationLoop iteration {i}/{self.max_iterations}")
output = await self._get_agent_output_async(prompt, i, feedback)
if hasattr(self.judge, 'run_async'):
judge_result = await self.judge.run_async(
output=output,
criteria=self.criteria,
)
else:
judge_result = self.judge.run(
output=output,
criteria=self.criteria,
)
findings = getattr(judge_result, 'suggestions', []) or []
iteration_result = IterationResult(
iteration=i,
output=output,
score=judge_result.score,
reasoning=judge_result.reasoning,
findings=findings,
)
iterations.append(iteration_result)
if self.on_iteration:
try:
self.on_iteration(iteration_result)
except Exception as e:
logger.warning(f"on_iteration callback error: {e}")
if self.mode == "optimize" and judge_result.score >= self.threshold:
if self.verbose:
logger.info(f"Threshold met at iteration {i}: {judge_result.score} >= {self.threshold}")
break
feedback = judge_result.reasoning
if findings:
feedback += "\nSuggestions:\n" + "\n".join(f"- {s}" for s in findings)
total_duration = time.time() - start_time
success = iterations[-1].score >= self.threshold if iterations else False
result = EvaluationLoopResult(
iterations=iterations,
success=success,
total_duration_seconds=total_duration,
threshold=self.threshold,
mode=self.mode,
)
if self.verbose:
result.print_summary()
return result
__all__ = [
'EvaluationLoop',
'EvaluationLoopConfig',
]
@@ -243,58 +243,4 @@ __all__ = [
'OptimizationRuleProtocol',
'JudgeProtocol',
'JudgeResultProtocol',
'EvaluationLoopProtocol',
'EvaluationLoopResultProtocol',
]
class EvaluationLoopResultProtocol(Protocol):
"""Protocol for evaluation loop results."""
@property
def success(self) -> bool:
"""Whether the loop achieved the threshold."""
...
@property
def final_score(self) -> float:
"""Get the final iteration's score."""
...
@property
def score_history(self) -> List[float]:
"""Get list of all scores across iterations."""
...
@property
def final_output(self) -> str:
"""Get the final iteration's output."""
...
class EvaluationLoopProtocol(Protocol):
"""Protocol for evaluation loops that iteratively improve agent outputs."""
def run(self, prompt: str) -> EvaluationLoopResultProtocol:
"""
Run the evaluation loop synchronously.
Args:
prompt: The prompt to send to the agent
Returns:
EvaluationLoopResultProtocol with iteration results
"""
...
async def run_async(self, prompt: str) -> EvaluationLoopResultProtocol:
"""
Run the evaluation loop asynchronously.
Args:
prompt: The prompt to send to the agent
Returns:
EvaluationLoopResultProtocol with iteration results
"""
...
@@ -547,189 +547,3 @@ class CriteriaResult:
print(f" Average Score: {self.avg_score:.2f}")
print(f" Pass Rate: {self.pass_rate:.1%}")
print(f" Status: {'PASSED' if self.passed else 'FAILED'}")
@dataclass
class IterationResult:
"""
Result from a single iteration of an evaluation loop.
Attributes:
iteration: The iteration number (1-indexed)
output: The agent's output for this iteration
score: Quality score (1-10)
reasoning: Explanation for the score
findings: List of findings/suggestions from this iteration
timestamp: When this iteration completed
metadata: Additional metadata
"""
iteration: int
output: str
score: float
reasoning: str
findings: List[str] = field(default_factory=list)
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
metadata: Dict[str, Any] = field(default_factory=dict)
@property
def passed(self) -> bool:
"""Check if this iteration passed (score >= 7.0)."""
return self.score >= 7.0
def to_dict(self) -> Dict[str, Any]:
"""Convert result to dictionary."""
return {
"iteration": self.iteration,
"output": self.output,
"score": self.score,
"reasoning": self.reasoning,
"findings": self.findings,
"timestamp": self.timestamp,
"passed": self.passed,
"metadata": self.metadata,
}
@dataclass
class EvaluationLoopResult:
"""
Result from running an evaluation loop.
Attributes:
iterations: List of IterationResult from each iteration
success: Whether the loop achieved the threshold
total_duration_seconds: Total time taken
threshold: The score threshold used
mode: The mode used ('optimize' or 'review')
metadata: Additional metadata
"""
iterations: List[IterationResult] = field(default_factory=list)
success: bool = False
total_duration_seconds: float = 0.0
threshold: float = 8.0
mode: str = "optimize"
metadata: Dict[str, Any] = field(default_factory=dict)
@property
def final_score(self) -> float:
"""Get the final iteration's score."""
if not self.iterations:
return 0.0
return self.iterations[-1].score
@property
def score_history(self) -> List[float]:
"""Get list of all scores across iterations."""
return [it.score for it in self.iterations]
@property
def final_output(self) -> str:
"""Get the final iteration's output."""
if not self.iterations:
return ""
return self.iterations[-1].output
@property
def accumulated_findings(self) -> List[str]:
"""Get all findings accumulated across iterations."""
findings = []
for it in self.iterations:
findings.extend(it.findings)
return findings
@property
def num_iterations(self) -> int:
"""Get the number of iterations completed."""
return len(self.iterations)
def to_dict(self) -> Dict[str, Any]:
"""Convert result to dictionary."""
return {
"success": self.success,
"final_score": self.final_score,
"score_history": self.score_history,
"final_output": self.final_output,
"accumulated_findings": self.accumulated_findings,
"num_iterations": self.num_iterations,
"total_duration_seconds": self.total_duration_seconds,
"threshold": self.threshold,
"mode": self.mode,
"iterations": [it.to_dict() for it in self.iterations],
"metadata": self.metadata,
}
def to_json(self) -> str:
"""Convert result to JSON string."""
return json.dumps(self.to_dict(), indent=2)
@property
def final_report(self) -> str:
"""Generate a markdown report of the evaluation loop."""
lines = [
"# Evaluation Loop Report",
"",
f"**Status**: {'✅ Success' if self.success else '❌ Failed'}",
f"**Final Score**: {self.final_score:.1f}/10",
f"**Threshold**: {self.threshold}/10",
f"**Iterations**: {self.num_iterations}",
f"**Duration**: {self.total_duration_seconds:.2f}s",
f"**Mode**: {self.mode}",
"",
"## Score History",
"",
]
for i, score in enumerate(self.score_history, 1):
status = "✅" if score >= self.threshold else "⬜"
lines.append(f"- Iteration {i}: {score:.1f}/10 {status}")
lines.append("")
lines.append("## Iteration Details")
lines.append("")
for it in self.iterations:
lines.append(f"### Iteration {it.iteration}")
lines.append(f"**Score**: {it.score:.1f}/10")
lines.append(f"**Reasoning**: {it.reasoning}")
if it.findings:
lines.append("**Findings**:")
for f in it.findings:
lines.append(f" - {f}")
lines.append("")
if self.accumulated_findings:
lines.append("## All Findings")
lines.append("")
for f in self.accumulated_findings:
lines.append(f"- {f}")
return "\n".join(lines)
def print_summary(self) -> None:
"""Print a summary of the evaluation loop results."""
try:
from rich.console import Console
from rich.table import Table
console = Console()
table = Table(title="Evaluation Loop Summary")
table.add_column("Metric", style="cyan")
table.add_column("Value", style="green" if self.success else "red")
table.add_row("Status", "✅ SUCCESS" if self.success else "❌ FAILED")
table.add_row("Final Score", f"{self.final_score:.1f}/10")
table.add_row("Threshold", f"{self.threshold}/10")
table.add_row("Iterations", str(self.num_iterations))
table.add_row("Duration", f"{self.total_duration_seconds:.2f}s")
table.add_row("Mode", self.mode)
table.add_row("Score History", " → ".join(f"{s:.1f}" for s in self.score_history))
console.print(table)
except ImportError:
print("Evaluation Loop Summary")
print(f" Status: {'SUCCESS' if self.success else 'FAILED'}")
print(f" Final Score: {self.final_score:.1f}/10")
print(f" Threshold: {self.threshold}/10")
print(f" Iterations: {self.num_iterations}")
print(f" Duration: {self.total_duration_seconds:.2f}s")
@@ -11,15 +11,7 @@ from typing import Optional, Dict, Any, List, Callable, Union, Literal
class HookEvent(str, Enum):
"""Event names for the hook system.
This enum is also aliased as PluginHook for backward compatibility.
All plugin lifecycle events are included here for DRY compliance.
"""
# Plugin/System lifecycle
ON_INIT = "on_init"
ON_SHUTDOWN = "on_shutdown"
"""Event names for the hook system."""
# Tool lifecycle
BEFORE_TOOL = "before_tool"
AFTER_TOOL = "after_tool"
@@ -41,8 +33,6 @@ class HookEvent(str, Enum):
ON_RETRY = "on_retry"
# Message lifecycle (for bot/channel integrations)
BEFORE_MESSAGE = "before_message"
AFTER_MESSAGE = "after_message"
MESSAGE_RECEIVED = "message_received"
MESSAGE_SENDING = "message_sending"
MESSAGE_SENT = "message_sent"
@@ -58,11 +48,6 @@ class HookEvent(str, Enum):
# Tool result persistence (for modifying tool results before storage)
TOOL_RESULT_PERSIST = "tool_result_persist"
# Permission/Config/Auth hooks
ON_PERMISSION_ASK = "on_permission_ask"
ON_CONFIG = "on_config"
ON_AUTH = "on_auth"
# Claude Code parity events
USER_PROMPT_SUBMIT = "user_prompt_submit" # When user submits a prompt
NOTIFICATION = "notification" # When notification is sent
@@ -416,7 +416,7 @@ class Knowledge:
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"
persist_dir = ".praison"
# Create persistent client config
base_config = {
@@ -862,7 +862,7 @@ 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")
state_dir = os.path.join(os.path.dirname(path) if os.path.isfile(path) else path, ".praison")
os.makedirs(state_dir, exist_ok=True)
state_file = os.path.join(state_dir, ".index_state.json")
@@ -1681,23 +1681,6 @@ Now provide your final answer using this result. Summarize the information natur
response_text = resp["choices"][0]["message"]["content"]
final_response = resp
# Emit StreamEvent for reasoning content if callback provided
if _emit and reasoning_content:
stream_callback(StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=time.perf_counter(),
content=reasoning_content,
is_reasoning=True
))
# Emit StreamEvent for response content if callback provided
if _emit and response_text:
stream_callback(StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=time.perf_counter(),
content=response_text,
is_reasoning=False
))
# Track token usage
if self.metrics:
self._track_token_usage(final_response, self.model)
@@ -764,14 +764,6 @@ class OpenAIClient:
if rc:
reasoning_content += rc
live.update(display_fn(f"{full_response_text}\n[Reasoning: {reasoning_content}]", start_time))
# Emit reasoning content as StreamEvent with is_reasoning=True
if _emit:
stream_callback(StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=time.perf_counter(),
content=rc,
is_reasoning=True
))
# Clear the last generating display with a blank line
console.print()
@@ -986,14 +978,6 @@ class OpenAIClient:
if rc:
reasoning_content += rc
live.update(display_fn(f"{full_response_text}\n[Reasoning: {reasoning_content}]", start_time))
# Emit reasoning content as StreamEvent with is_reasoning=True
if _emit:
stream_callback(StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=time.perf_counter(),
content=rc,
is_reasoning=True
))
# Clear the last generating display with a blank line
console.print()
@@ -1559,8 +1543,6 @@ class OpenAIClient:
reasoning_steps: bool = False,
verbose: bool = True,
max_iterations: int = 10,
stream_callback: Optional[Callable] = None,
emit_events: bool = False,
**kwargs
):
"""
@@ -1578,8 +1560,6 @@ 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 via callback
**kwargs: Additional API parameters
Yields:
@@ -1588,19 +1568,8 @@ class OpenAIClient:
# Format tools for OpenAI API
formatted_tools = self.format_tools(tools)
# Setup StreamEvent emission if enabled
_emit = emit_events and stream_callback is not None
if _emit:
from ..streaming.events import StreamEvent, StreamEventType
stream_callback(StreamEvent(
type=StreamEventType.REQUEST_START,
timestamp=time.perf_counter(),
metadata={"model": model, "provider": "openai"}
))
# Continue tool execution loop until no more tool calls are needed
iteration_count = 0
_first_token_emitted = False
while iteration_count < max_iterations:
try:
@@ -1625,20 +1594,6 @@ class OpenAIClient:
content = chunk.choices[0].delta.content
full_response_text += content
yield content
# Emit StreamEvent for text delta
if _emit:
if not _first_token_emitted:
stream_callback(StreamEvent(
type=StreamEventType.FIRST_TOKEN,
timestamp=time.perf_counter()
))
_first_token_emitted = True
stream_callback(StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=time.perf_counter(),
content=content,
is_reasoning=False
))
# Handle reasoning content if enabled
if reasoning_steps and chunk.choices and hasattr(chunk.choices[0].delta, "reasoning_content"):
@@ -1646,14 +1601,6 @@ class OpenAIClient:
if rc:
reasoning_content += rc
yield f"[Reasoning: {rc}]"
# Emit StreamEvent for reasoning content
if _emit:
stream_callback(StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=time.perf_counter(),
content=rc,
is_reasoning=True
))
# Process the complete response to check for tool calls
final_response = process_stream_chunks(chunks)
@@ -86,8 +86,7 @@ class RateLimiter:
else:
self._tokens = float('inf')
self._last_update = time.monotonic()
# Lazily create asyncio.Lock() to avoid Python 3.9 event loop issues
self._lock = None
self._lock = asyncio.Lock()
# Token-based limiting
if self.tokens_per_minute is not None:
@@ -96,8 +95,7 @@ class RateLimiter:
else:
self._api_tokens = float('inf')
self._api_tokens_last_update = time.monotonic()
# Lazily create asyncio.Lock() to avoid Python 3.9 event loop issues
self._api_tokens_lock = None
self._api_tokens_lock = asyncio.Lock()
# Default implementations (can be overridden for testing)
if self._get_time is None:
@@ -107,18 +105,6 @@ class RateLimiter:
if self._async_sleep is None:
self._async_sleep = asyncio.sleep
def _get_lock(self) -> asyncio.Lock:
"""Get or create the request lock lazily (Python 3.9 compatible)."""
if self._lock is None:
self._lock = asyncio.Lock()
return self._lock
def _get_api_tokens_lock(self) -> asyncio.Lock:
"""Get or create the API tokens lock lazily (Python 3.9 compatible)."""
if self._api_tokens_lock is None:
self._api_tokens_lock = asyncio.Lock()
return self._api_tokens_lock
@property
def _rate(self) -> float:
"""Requests per second."""
@@ -217,7 +203,7 @@ class RateLimiter:
if self.requests_per_minute is None:
return
async with self._get_lock():
async with self._lock:
self._refill()
wait = self._wait_time()
@@ -242,7 +228,7 @@ class RateLimiter:
if self.tokens_per_minute is None:
return
async with self._get_api_tokens_lock():
async with self._api_tokens_lock:
self._refill_api_tokens()
wait = self._wait_time_for_tokens(num_tokens)
@@ -25,10 +25,11 @@ def get_default_auth_filepath() -> str:
Returns:
Path to mcp-auth.json in user's praison config directory
"""
# Use centralized paths (DRY)
from ..paths import get_mcp_auth_path, ensure_dir, get_data_dir
ensure_dir(get_data_dir())
return str(get_mcp_auth_path())
# Use ~/.praison/ directory for config
home = Path.home()
praison_dir = home / ".praison"
praison_dir.mkdir(parents=True, exist_ok=True)
return str(praison_dir / "mcp-auth.json")
class MCPAuthStorage:
@@ -63,7 +64,7 @@ class MCPAuthStorage:
Initialize auth storage.
Args:
filepath: Path to auth storage file. Defaults to ~/.praisonai/mcp-auth.json
filepath: Path to auth storage file. Defaults to ~/.praison/mcp-auth.json
"""
self.filepath = filepath or get_default_auth_filepath()
self._ensure_directory()
@@ -7,14 +7,14 @@ Provides reusable documentation context support similar to:
- Project documentation for AI context
Features:
- Auto-discovery of docs from .praisonai/docs/
- Auto-discovery of docs from .praison/docs/
- Support for markdown, text, and code files
- @doc mentions for including specific docs
- Priority ordering for docs
- Character limits for context window management
Storage Structure:
.praisonai/docs/
.praison/docs/
├── project-overview.md # Project overview
├── architecture.md # Architecture decisions
├── api-reference.md # API documentation
@@ -74,13 +74,13 @@ class DocsManager:
Manages documentation context for AI agents.
Provides:
- Auto-discovery of docs from .praisonai/docs/
- Global docs from ~/.praisonai/docs/
- Auto-discovery of docs from .praison/docs/
- Global docs from ~/.praison/docs/
- @doc mentions for including specific docs
- Priority-based ordering
"""
DOCS_DIR_NAME = ".praisonai/docs"
DOCS_DIR_NAME = ".praison/docs"
SUPPORTED_EXTENSIONS = [".md", ".txt", ".rst"]
# Maximum characters per doc (context window management)
@@ -97,11 +97,11 @@ class DocsManager:
Args:
workspace_path: Path to workspace/project root
global_docs_path: Path to global docs (default: ~/.praisonai/docs)
global_docs_path: Path to global docs (default: ~/.praison/docs)
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"
self.global_docs_path = Path(global_docs_path) if global_docs_path else Path.home() / ".praison" / "docs"
self.verbose = verbose
self._docs: Dict[str, Doc] = {}
@@ -6,7 +6,7 @@ Provides short-term, long-term, entity, and episodic memory
without requiring any external packages.
Storage Structure:
.praisonai/memory/{user_id}/
.praison/memory/{user_id}/
├── config.json # Memory configuration
├── short_term.json # Rolling buffer (recent context)
├── long_term.json # Persistent facts/knowledge
@@ -129,7 +129,7 @@ class FileMemory:
Args:
user_id: Unique identifier for the user/session
base_path: Base directory for memory storage (default: .praisonai/memory)
base_path: Base directory for memory storage (default: .praison/memory)
config: Configuration overrides
verbose: Verbosity level (0=quiet, 1=info, 2+=debug)
"""
@@ -140,7 +140,7 @@ class FileMemory:
if base_path:
self.base_path = Path(base_path)
else:
self.base_path = Path(".praisonai/memory")
self.base_path = Path(".praison/memory")
self.user_path = self.base_path / user_id
self.episodic_path = self.user_path / "episodic"
@@ -6,12 +6,12 @@ allowing custom actions before/after agent operations.
Features:
- Pre/post hooks for read, write, command, prompt operations
- JSON configuration (.praisonai/hooks.json)
- JSON configuration (.praison/hooks.json)
- Script execution with timeout
- Exit code handling
- Lazy loading for performance
Configuration (.praisonai/hooks.json):
Configuration (.praison/hooks.json):
{
"hooks": {
"pre_write_code": "./scripts/lint.sh",
@@ -85,7 +85,7 @@ class HooksManager:
"""
Manages hook discovery, loading, and execution.
Hooks are configured in .praisonai/hooks.json and can execute
Hooks are configured in .praison/hooks.json and can execute
scripts before/after agent operations.
Example:
@@ -103,7 +103,7 @@ class HooksManager:
```
"""
CONFIG_FILE = ".praisonai/hooks.json"
CONFIG_FILE = ".praison/hooks.json"
def __init__(
self,
@@ -384,7 +384,7 @@ class HooksManager:
timeout: Global timeout in seconds
enabled: Whether hooks are enabled
"""
config_dir = self.workspace_path / ".praisonai"
config_dir = self.workspace_path / ".praison"
config_dir.mkdir(parents=True, exist_ok=True)
config = {
@@ -220,11 +220,10 @@ class LearnManager:
"""
Generate context suitable for injection into system prompt.
Returns a formatted string with relevant learnings from all enabled stores.
Returns a formatted string with relevant learnings.
"""
parts = []
# 1. Persona - User preferences and profile
if "persona" in self._stores:
personas = self.get_persona_context(limit=5)
if personas:
@@ -232,7 +231,6 @@ class LearnManager:
for p in personas:
parts.append(f"- {p}")
# 2. Insights - Observations and learnings
if "insights" in self._stores:
insights = self.get_insights_context(limit=5)
if insights:
@@ -240,38 +238,4 @@ class LearnManager:
for i in insights:
parts.append(f"- {i}")
# 3. Patterns - Reusable knowledge patterns
if "patterns" in self._stores:
entries = self._stores["patterns"].list_all(limit=3)
if entries:
parts.append("\nKnown Patterns:")
for e in entries:
parts.append(f"- {e.content}")
# 4. Decisions - Past decision records (for consistency)
if "decisions" in self._stores:
entries = self._stores["decisions"].list_all(limit=3)
if entries:
parts.append("\nPast Decisions:")
for e in entries:
parts.append(f"- {e.content}")
# 5. Feedback - User feedback signals
if "feedback" in self._stores:
entries = self._stores["feedback"].list_all(limit=3)
if entries:
parts.append("\nUser Feedback:")
for e in entries:
parts.append(f"- {e.content}")
# 6. Improvements - Self-improvement proposals
if "improvements" in self._stores:
entries = self._stores["improvements"].list_all(limit=3)
if entries:
parts.append("\nImprovement Areas:")
for e in entries:
parts.append(f"- {e.content}")
# Note: threads are session-specific and not included in system prompt
return "\n".join(parts) if parts else ""
@@ -88,10 +88,9 @@ class BaseStore(ABC):
pass
def _default_path(self) -> str:
"""Default storage path (uses centralized paths - DRY)."""
from ...paths import get_learn_dir, ensure_dir
base = get_learn_dir() / self.scope / self.user_id
ensure_dir(base)
"""Default storage path."""
base = Path.home() / ".praison" / "learn" / self.scope / self.user_id
base.mkdir(parents=True, exist_ok=True)
return str(base / f"{self.store_name}.json")
def _load(self) -> None:
@@ -6,13 +6,13 @@ Provides JSON-based MCP server configuration support similar to:
- Windsurf MCP configuration
Features:
- Auto-discovery of MCP configs from .praisonai/mcp/
- Auto-discovery of MCP configs from .praison/mcp/
- JSON config files for MCP servers
- Environment variable support
- Easy server management
Storage Structure:
.praisonai/mcp/
.praison/mcp/
├── filesystem.json # Filesystem MCP server
├── brave-search.json # Brave search MCP server
└── postgres.json # PostgreSQL MCP server
@@ -108,12 +108,12 @@ class MCPConfigManager:
Manages MCP server configurations for AI agents.
Provides:
- Auto-discovery of MCP configs from .praisonai/mcp/
- Global configs from ~/.praisonai/mcp/
- Auto-discovery of MCP configs from .praison/mcp/
- Global configs from ~/.praison/mcp/
- Easy server management
"""
MCP_DIR_NAME = ".praisonai/mcp"
MCP_DIR_NAME = ".praison/mcp"
SUPPORTED_EXTENSIONS = [".json", ".mcpjson"]
def __init__(
@@ -127,11 +127,11 @@ class MCPConfigManager:
Args:
workspace_path: Path to workspace/project root
global_mcp_path: Path to global MCP configs (default: ~/.praisonai/mcp)
global_mcp_path: Path to global MCP configs (default: ~/.praison/mcp)
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"
self.global_mcp_path = Path(global_mcp_path) if global_mcp_path else Path.home() / ".praison" / "mcp"
self.verbose = verbose
self._configs: Dict[str, MCPConfig] = {}
@@ -226,15 +226,15 @@ 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 .praison directory if it doesn't exist
os.makedirs(".praison", 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", ".praison/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", ".praison/long_term.db")
self._init_ltm()
# Conditionally init Mem0, MongoDB, or local RAG
@@ -18,7 +18,7 @@ Features:
- Symlink support for shared rules
Storage Structure:
.praisonai/rules/
.praison/rules/
├── global.md # Always applied (global)
├── python.md # Glob: **/*.py
├── testing.md # Glob: **/*.test.*
@@ -99,9 +99,9 @@ class RulesManager:
Manages rules/instructions for AI agents.
Supports multiple rule sources:
- Global rules (~/.praisonai/rules/)
- Workspace rules (.praisonai/rules/)
- Subdirectory rules (subdir/.praisonai/rules/)
- Global rules (~/.praison/rules/)
- Workspace rules (.praison/rules/)
- Subdirectory rules (subdir/.praison/rules/)
- Root instruction files (CLAUDE.md, AGENTS.md, GEMINI.md, .cursorrules, .windsurfrules)
Example:
@@ -122,7 +122,7 @@ class RulesManager:
```
"""
RULES_DIR_NAME = ".praisonai/rules"
RULES_DIR_NAME = ".praison/rules"
SUPPORTED_EXTENSIONS = [".md", ".mdc", ".txt"]
# Root-level instruction files (like Claude, Codex, Gemini, Cursor, Windsurf)
@@ -161,11 +161,11 @@ class RulesManager:
Args:
workspace_path: Path to workspace/project root
global_rules_path: Path to global rules (default: ~/.praisonai/rules)
global_rules_path: Path to global rules (default: ~/.praison/rules)
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"
self.global_rules_path = Path(global_rules_path) if global_rules_path else Path.home() / ".praison" / "rules"
self.verbose = verbose
self._rules: Dict[str, Rule] = {}
@@ -457,7 +457,7 @@ class RulesManager:
if rule:
self._rules[f"root:{rule.name}"] = rule
# 4. Load workspace rules from .praisonai/rules/
# 4. Load workspace rules from .praison/rules/
workspace_rules_dir = self.workspace_path / self.RULES_DIR_NAME.replace("/", os.sep)
workspace_rules = self._discover_rules_in_dir(workspace_rules_dir)
for rule in workspace_rules:
@@ -485,7 +485,7 @@ class RulesManager:
stop_at = git_root if git_root else self.workspace_path
current = Path.cwd()
while current != stop_at and current != current.parent:
# Check .praisonai/rules/
# Check .praison/rules/
subdir_rules_dir = current / self.RULES_DIR_NAME.replace("/", os.sep)
subdir_rules = self._discover_rules_in_dir(subdir_rules_dir)
for rule in subdir_rules:
@@ -6,14 +6,14 @@ allowing users to define and execute complex task sequences.
Features:
- YAML/Markdown workflow definitions
- Auto-discovery from .praisonai/workflows/
- Auto-discovery from .praison/workflows/
- Step-by-step execution with context passing
- Variable substitution
- Conditional steps
- Lazy loading for performance
Storage Structure:
.praisonai/workflows/
.praison/workflows/
├── deploy.md # Deployment workflow
├── test.md # Testing workflow
└── review.md # Code review workflow
@@ -731,7 +731,7 @@ class WorkflowManager:
Workflows are defined in markdown files with YAML frontmatter
and step definitions.
Example workflow file (.praisonai/workflows/deploy.md):
Example workflow file (.praison/workflows/deploy.md):
```markdown
---
name: Deploy to Production
@@ -782,7 +782,7 @@ class WorkflowManager:
```
"""
WORKFLOWS_DIR = ".praisonai/workflows"
WORKFLOWS_DIR = ".praison/workflows"
SUPPORTED_EXTENSIONS = [".md", ".yaml", ".yml"]
def __init__(
@@ -1857,7 +1857,7 @@ class WorkflowManager:
def _get_checkpoints_dir(self) -> Path:
"""Get the checkpoints directory path."""
checkpoints_dir = self.workspace_path / ".praisonai" / "checkpoints"
checkpoints_dir = self.workspace_path / ".praison" / "checkpoints"
checkpoints_dir.mkdir(parents=True, exist_ok=True)
return checkpoints_dir
@@ -1,365 +0,0 @@
"""
Centralized Path Utilities for PraisonAI Agents.
All persistent data uses ~/.praisonai/ by default.
Override with PRAISONAI_HOME environment variable.
This module provides a single source of truth for all data storage paths,
eliminating hardcoded paths throughout the codebase (DRY principle).
Usage:
from praisonaiagents.paths import get_data_dir, get_sessions_dir
# Get user data directory
data_dir = get_data_dir() # ~/.praisonai/
# Get specific subdirectories
sessions_dir = get_sessions_dir() # ~/.praisonai/sessions/
# Override with environment variable
# export PRAISONAI_HOME=/custom/path
# data_dir = get_data_dir() # /custom/path/
Backward Compatibility:
If ~/.praisonai/ doesn't exist but ~/.praison/ does, the legacy
path will be used with a deprecation warning. Run 'praisonai migrate-data'
to migrate to the new location.
"""
import os
import warnings
from pathlib import Path
from typing import Dict, Optional, Union
# Environment variable for override
ENV_VAR = "PRAISONAI_HOME"
# Default directory name (branded)
DEFAULT_DIR_NAME = ".praisonai"
# Legacy directory name (for backward compat)
LEGACY_DIR_NAME = ".praison"
# Cache for data dir to avoid repeated filesystem checks
_data_dir_cache: Optional[Path] = None
def _clear_cache() -> None:
"""Clear the data dir cache. Used for testing."""
global _data_dir_cache
_data_dir_cache = None
def get_data_dir() -> Path:
"""
Get PraisonAI data directory.
Priority:
1. PRAISONAI_HOME env var
2. ~/.praisonai/ (default)
3. ~/.praison/ (legacy fallback with warning)
Returns:
Path to data directory
Example:
>>> from praisonaiagents.paths import get_data_dir
>>> data_dir = get_data_dir()
>>> print(data_dir)
/home/user/.praisonai
"""
global _data_dir_cache
# Check env var first (always takes precedence, no caching)
env_path = os.environ.get(ENV_VAR)
if env_path:
return Path(env_path).expanduser()
# Return cached value if available
if _data_dir_cache is not None:
return _data_dir_cache
home = Path.home()
# Check new location first
new_path = home / DEFAULT_DIR_NAME
if new_path.exists():
_data_dir_cache = new_path
return new_path
# Check legacy location (backward compat)
legacy_path = home / LEGACY_DIR_NAME
if legacy_path.exists():
warnings.warn(
f"Using legacy data directory {legacy_path}. "
f"Run 'praisonai migrate-data' to migrate to {new_path}.",
DeprecationWarning,
stacklevel=2
)
_data_dir_cache = legacy_path
return legacy_path
# Default to new location (will be created when needed)
_data_dir_cache = new_path
return new_path
def get_sessions_dir() -> Path:
"""
Get sessions directory.
Returns:
Path to ~/.praisonai/sessions/
"""
return get_data_dir() / "sessions"
def get_skills_dir() -> Path:
"""
Get user skills directory.
Returns:
Path to ~/.praisonai/skills/
"""
return get_data_dir() / "skills"
def get_plugins_dir() -> Path:
"""
Get user plugins directory.
Returns:
Path to ~/.praisonai/plugins/
"""
return get_data_dir() / "plugins"
def get_mcp_dir() -> Path:
"""
Get MCP config directory.
Returns:
Path to ~/.praisonai/mcp/
"""
return get_data_dir() / "mcp"
def get_docs_dir() -> Path:
"""
Get docs directory.
Returns:
Path to ~/.praisonai/docs/
"""
return get_data_dir() / "docs"
def get_rules_dir() -> Path:
"""
Get rules directory.
Returns:
Path to ~/.praisonai/rules/
"""
return get_data_dir() / "rules"
def get_permissions_dir() -> Path:
"""
Get permissions directory.
Returns:
Path to ~/.praisonai/permissions/
"""
return get_data_dir() / "permissions"
def get_storage_dir() -> Path:
"""
Get generic storage directory.
Returns:
Path to ~/.praisonai/storage/
"""
return get_data_dir() / "storage"
def get_checkpoints_dir() -> Path:
"""
Get checkpoints directory.
Returns:
Path to ~/.praisonai/checkpoints/
"""
return get_data_dir() / "checkpoints"
def get_snapshots_dir() -> Path:
"""
Get snapshots directory.
Returns:
Path to ~/.praisonai/snapshots/
"""
return get_data_dir() / "snapshots"
def get_learn_dir() -> Path:
"""
Get learn directory for learning stores.
Returns:
Path to ~/.praisonai/learn/
"""
return get_data_dir() / "learn"
def get_cache_dir() -> Path:
"""
Get cache directory (disposable data).
Returns:
Path to ~/.praisonai/cache/
"""
return get_data_dir() / "cache"
def get_mcp_auth_path() -> Path:
"""
Get path to MCP auth storage file.
Returns:
Path to ~/.praisonai/mcp-auth.json
"""
return get_data_dir() / "mcp-auth.json"
def get_memory_dir() -> Path:
"""
Get memory directory for short/long term databases.
Returns:
Path to ~/.praisonai/memory/
"""
return get_data_dir() / "memory"
def get_workflows_dir() -> Path:
"""
Get workflows directory.
Returns:
Path to ~/.praisonai/workflows/
"""
return get_data_dir() / "workflows"
def get_summaries_dir() -> Path:
"""
Get summaries directory for RAG.
Returns:
Path to ~/.praisonai/summaries/
"""
return get_data_dir() / "summaries"
def get_prp_dir() -> Path:
"""
Get PRP (Prompt Response Pair) output directory.
Returns:
Path to ~/.praisonai/prp/
"""
return get_data_dir() / "prp"
def get_runs_dir() -> Path:
"""
Get runs directory for artifacts.
Returns:
Path to ~/.praisonai/runs/
"""
return get_data_dir() / "runs"
def get_project_data_dir(project_path: Optional[Union[str, Path]] = None) -> Path:
"""
Get project-level data directory.
Args:
project_path: Project root (defaults to cwd)
Returns:
Path to .praisonai/ in project
Example:
>>> from praisonaiagents.paths import get_project_data_dir
>>> project_dir = get_project_data_dir("/path/to/project")
>>> print(project_dir)
/path/to/project/.praisonai
"""
if project_path is None:
base = Path.cwd()
elif isinstance(project_path, str):
base = Path(project_path)
else:
base = project_path
return base / DEFAULT_DIR_NAME
def ensure_dir(path: Union[str, Path]) -> Path:
"""
Ensure a directory exists, creating it if necessary.
Args:
path: Directory path to ensure exists
Returns:
Path object for the directory
Example:
>>> from praisonaiagents.paths import ensure_dir, get_sessions_dir
>>> sessions = ensure_dir(get_sessions_dir())
"""
if isinstance(path, str):
path = Path(path)
path.mkdir(parents=True, exist_ok=True)
return path
def get_all_paths() -> Dict[str, Path]:
"""
Get all PraisonAI data paths.
Returns:
Dictionary mapping path names to Path objects
Example:
>>> from praisonaiagents.paths import get_all_paths
>>> paths = get_all_paths()
>>> for name, path in paths.items():
... print(f"{name}: {path}")
"""
return {
"data_dir": get_data_dir(),
"sessions": get_sessions_dir(),
"skills": get_skills_dir(),
"plugins": get_plugins_dir(),
"mcp": get_mcp_dir(),
"docs": get_docs_dir(),
"rules": get_rules_dir(),
"permissions": get_permissions_dir(),
"storage": get_storage_dir(),
"checkpoints": get_checkpoints_dir(),
"snapshots": get_snapshots_dir(),
"learn": get_learn_dir(),
"cache": get_cache_dir(),
"mcp_auth": get_mcp_auth_path(),
"memory": get_memory_dir(),
"workflows": get_workflows_dir(),
"summaries": get_summaries_dir(),
"prp": get_prp_dir(),
"runs": get_runs_dir(),
}
@@ -14,12 +14,10 @@ from typing import Any, Callable, Dict, List, Optional
from .rules import PermissionRule, PermissionAction, PermissionResult, PersistentApproval
from .doom_loop import DoomLoopDetector
from ..paths import get_permissions_dir
logger = logging.getLogger(__name__)
# Default storage path (uses centralized paths - DRY)
DEFAULT_PERMISSIONS_DIR = str(get_permissions_dir())
# Default storage path
DEFAULT_PERMISSIONS_DIR = os.path.expanduser("~/.praison/permissions")
class PermissionManager:
@@ -36,21 +36,16 @@ class PlanStorage:
todos_dir: Directory for todo files
"""
DEFAULT_BASE_PATH = ".praisonai" # Updated to use branded path
DEFAULT_BASE_PATH = ".praison"
def __init__(self, base_path: Optional[str] = None):
"""
Initialize PlanStorage.
Args:
base_path: Base directory for storage. Defaults to .praisonai/
base_path: Base directory for storage. Defaults to .praison/
"""
# Use centralized paths for project-level data (DRY)
if base_path is None:
from ..paths import get_project_data_dir
self.base_path = str(get_project_data_dir())
else:
self.base_path = base_path
self.base_path = base_path or self.DEFAULT_BASE_PATH
self.plans_dir = os.path.join(self.base_path, "plans")
self.todos_dir = os.path.join(self.base_path, "todos")
@@ -37,16 +37,10 @@ Usage:
"""
__all__ = [
# Easy enable API
"enable",
"disable",
"list_plugins",
"is_enabled",
# Core
"PluginManager",
"Plugin",
"PluginHook",
"PluginType",
"PluginInfo",
"FunctionPlugin",
"get_plugin_manager",
@@ -70,170 +64,6 @@ __all__ = [
]
# ============================================================================
# EASY ENABLE API
# ============================================================================
# These functions provide a simple way to enable/disable plugins globally.
# Zero performance impact when not called - plugins only load on enable().
# ============================================================================
# Global state for plugin system (lazy initialized)
_plugins_enabled: bool = False
_enabled_plugin_names: list = None # None = all, list = specific
def enable(plugins: list = None) -> None:
"""Enable the plugin system.
Discovers and enables plugins from default directories.
This is the main entry point for using plugins.
Args:
plugins: Optional list of plugin names to enable.
If None, enables all discovered plugins.
Examples:
# Enable all discovered plugins
from praisonaiagents import plugins
plugins.enable()
# Enable specific plugins only
plugins.enable(["logging", "metrics"])
Note:
- Tools and guardrails work WITHOUT calling enable()
- Only background plugins (hooks, metrics, logging) need enable()
- Can also be enabled via PRAISONAI_PLUGINS env var
- Can also be enabled via .praisonai/config.toml
"""
global _plugins_enabled, _enabled_plugin_names
_plugins_enabled = True
_enabled_plugin_names = plugins # None = all, list = specific
# Get plugin manager and auto-discover
from .manager import get_plugin_manager
manager = get_plugin_manager()
# Auto-discover plugins from default directories
manager.auto_discover_plugins()
# Enable specific plugins or all
if plugins is not None:
# Enable only specified plugins
for name in plugins:
manager.enable(name)
else:
# Enable all discovered plugins
for plugin_info in manager.list_plugins():
manager.enable(plugin_info.get("name", ""))
import logging
logging.debug(f"Plugins enabled: {plugins if plugins else 'all'}")
def disable(plugins: list = None) -> None:
"""Disable plugins.
Args:
plugins: Optional list of plugin names to disable.
If None, disables all plugins.
Examples:
# Disable all plugins
plugins.disable()
# Disable specific plugins
plugins.disable(["logging"])
"""
global _plugins_enabled, _enabled_plugin_names
from .manager import get_plugin_manager
manager = get_plugin_manager()
if plugins is not None:
# Disable specific plugins
for name in plugins:
manager.disable(name)
else:
# Disable all plugins
_plugins_enabled = False
_enabled_plugin_names = None
for plugin_info in manager.list_plugins():
manager.disable(plugin_info.get("name", ""))
def list_plugins() -> list:
"""List all discovered plugins.
Returns:
List of plugin info dicts with name, version, enabled status.
Example:
from praisonaiagents import plugins
all_plugins = plugins.list_plugins()
for p in all_plugins:
print(f"{p['name']} v{p['version']} - {'enabled' if p['enabled'] else 'disabled'}")
"""
from .manager import get_plugin_manager
manager = get_plugin_manager()
# Auto-discover if not already done
if not manager._plugins and not manager._single_file_plugins:
manager.auto_discover_plugins()
# Combine registered plugins and single-file plugins
result = []
# Add registered plugins (Plugin class instances)
for info in manager.list_plugins():
if hasattr(info, 'name'):
# PluginInfo object
result.append({
"name": info.name,
"version": getattr(info, 'version', '1.0.0'),
"description": getattr(info, 'description', ''),
"enabled": manager.is_enabled(info.name),
"type": "registered",
})
elif isinstance(info, dict):
# Already a dict
info["enabled"] = manager.is_enabled(info.get("name", ""))
info["type"] = "registered"
result.append(info)
# Add single-file plugins
for name, meta in manager._single_file_plugins.items():
result.append({
"name": name,
"version": meta.get("version", "1.0.0"),
"description": meta.get("description", ""),
"enabled": manager.is_enabled(name),
"type": "single_file",
})
return result
def is_enabled(name: str = None) -> bool:
"""Check if plugins are enabled.
Args:
name: Optional plugin name to check. If None, checks if system is enabled.
Returns:
True if enabled, False otherwise.
"""
global _plugins_enabled
if name is None:
return _plugins_enabled
from .manager import get_plugin_manager
manager = get_plugin_manager()
return manager.is_enabled(name)
def __getattr__(name: str):
"""Lazy load module components."""
# Core classes
@@ -261,10 +91,6 @@ def __getattr__(name: str):
from .plugin import FunctionPlugin
return FunctionPlugin
if name == "PluginType":
from .plugin import PluginType
return PluginType
# Protocols
if name == "PluginProtocol":
from .protocols import PluginProtocol
@@ -5,8 +5,8 @@ Discovers and loads plugins from directories.
Plugins are simple Python files with WordPress-style docstring headers.
Default plugin directories (in precedence order):
1. Project: ./.praisonai/plugins/
2. User: ~/.praisonai/plugins/
1. Project: ./.praison/plugins/
2. User: ~/.praison/plugins/
Usage:
from praisonaiagents.plugins.discovery import discover_plugins, load_plugin
@@ -25,7 +25,6 @@ from pathlib import Path
from typing import Any, Dict, List, Optional
from .parser import parse_plugin_header_from_file, PluginParseError
from ..paths import get_plugins_dir, get_project_data_dir
logger = logging.getLogger(__name__)
@@ -33,26 +32,25 @@ logger = logging.getLogger(__name__)
def get_default_plugin_dirs() -> List[Path]:
"""Get default plugin directory locations.
Uses centralized paths.py for consistent path management.
Returns directories in precedence order (high to low):
1. Project: ./.praisonai/plugins/
2. User: ~/.praisonai/plugins/
1. Project: ./.praison/plugins/
2. User: ~/.praison/plugins/
Returns:
List of existing plugin directories
"""
dirs = []
cwd = Path.cwd()
# Project-level directory (use centralized path)
project_data_dir = get_project_data_dir()
project_plugins = project_data_dir / "plugins"
if project_plugins.exists() and project_plugins.is_dir():
dirs.append(project_plugins)
# Project-level directory
project_dir = cwd / ".praison" / "plugins"
if project_dir.exists() and project_dir.is_dir():
dirs.append(project_dir)
# User-level directory (use centralized path)
user_plugins = get_plugins_dir()
if user_plugins.exists() and user_plugins.is_dir():
dirs.append(user_plugins)
# User-level directory
user_dir = Path.home() / ".praison" / "plugins"
if user_dir.exists() and user_dir.is_dir():
dirs.append(user_dir)
return dirs
@@ -255,13 +253,12 @@ def unload_plugin(module_name: str) -> bool:
def ensure_plugin_dir() -> Path:
"""Ensure the user plugin directory exists.
Creates ~/.praisonai/plugins/ if it doesn't exist.
Uses centralized paths.py for consistent path management.
Creates ~/.praison/plugins/ if it doesn't exist.
Returns:
Path to the user plugin directory
"""
user_dir = get_plugins_dir()
user_dir = Path.home() / ".praison" / "plugins"
user_dir.mkdir(parents=True, exist_ok=True)
return user_dir
@@ -2,16 +2,14 @@
Plugin Manager for PraisonAI Agents.
Provides dynamic plugin discovery, loading, and lifecycle management.
Thread-safe and async-safe for multi-agent environments.
"""
import asyncio
import importlib.util
import logging
import os
import sys
import threading
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, Callable, Dict, List, Optional, Type
from .plugin import Plugin, PluginHook, PluginInfo, FunctionPlugin
@@ -22,9 +20,6 @@ class PluginManager:
"""
Manages plugin discovery, loading, and execution.
Thread-safe for concurrent access in multi-agent environments.
Supports both sync and async hook execution.
Example:
manager = PluginManager()
@@ -34,18 +29,14 @@ class PluginManager:
# Register a plugin directly
manager.register(MyPlugin())
# Execute hooks (sync)
# Execute hooks
args = manager.execute_hook(PluginHook.BEFORE_TOOL, "bash", {"cmd": "ls"})
# Execute hooks (async)
args = await manager.async_execute_hook(PluginHook.BEFORE_TOOL, "bash", {"cmd": "ls"})
"""
def __init__(self):
self._plugins: Dict[str, Plugin] = {}
self._enabled: Dict[str, bool] = {}
self._single_file_plugins: Dict[str, Dict[str, Any]] = {} # WordPress-style plugins
self._lock = threading.RLock() # Thread safety for multi-agent environments
def register(self, plugin: Plugin) -> bool:
"""
@@ -275,33 +266,6 @@ class PluginManager:
return result
async def async_execute_hook(
self,
hook: PluginHook,
*args,
**kwargs
) -> Any:
"""
Execute a hook across all enabled plugins asynchronously.
Async-safe for use in async agent contexts.
Args:
hook: The hook to execute
*args: Arguments to pass to hook
**kwargs: Keyword arguments to pass to hook
Returns:
The result (may be modified by plugins)
"""
# Use the sync version in an executor to avoid blocking
# This is safe because execute_hook is thread-safe with _lock
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None,
lambda: self.execute_hook(hook, *args, **kwargs)
)
def get_all_tools(self) -> List[Dict[str, Any]]:
"""Get all tools from all enabled plugins."""
tools = []
@@ -400,8 +364,8 @@ class PluginManager:
Auto-discover and load plugins from default directories.
Scans:
- ./.praisonai/plugins/ (project-level)
- ~/.praisonai/plugins/ (user-level)
- ./.praison/plugins/ (project-level)
- ~/.praison/plugins/ (user-level)
Returns:
Number of plugins loaded
@@ -2,7 +2,6 @@
Plugin definitions for PraisonAI Agents.
Provides base plugin class and hook definitions.
PluginHook is now an alias for HookEvent (DRY compliance).
"""
import logging
@@ -11,26 +10,68 @@ from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Callable, Dict, List, Optional
# Import HookEvent at module level for alias (DRY)
from ..hooks.types import HookEvent
logger = logging.getLogger(__name__)
# PluginHook is now an alias for HookEvent - single source of truth
PluginHook = HookEvent
class PluginType(str, Enum):
"""Types of plugins that can be registered.
class PluginHook(str, Enum):
"""Available plugin hooks."""
Used to categorize plugins and enable type-specific discovery.
"""
TOOL = "tool" # Provides tools for agents
HOOK = "hook" # Intercepts lifecycle events
SKILL = "skill" # Provides agent capabilities (SKILL.md)
POLICY = "policy" # Provides execution rules
GUARDRAIL = "guardrail" # Provides validation rules
INTEGRATION = "integration" # External tool integrations
# Lifecycle hooks
ON_INIT = "on_init"
ON_SHUTDOWN = "on_shutdown"
# Agent hooks
BEFORE_AGENT = "before_agent"
AFTER_AGENT = "after_agent"
# Tool hooks
BEFORE_TOOL = "before_tool"
AFTER_TOOL = "after_tool"
# Message hooks (for bot/channel integrations)
BEFORE_MESSAGE = "before_message"
AFTER_MESSAGE = "after_message"
MESSAGE_RECEIVED = "message_received"
MESSAGE_SENDING = "message_sending"
MESSAGE_SENT = "message_sent"
# LLM hooks
BEFORE_LLM = "before_llm"
AFTER_LLM = "after_llm"
# Session hooks
SESSION_START = "session_start"
SESSION_END = "session_end"
# Gateway hooks
GATEWAY_START = "gateway_start"
GATEWAY_STOP = "gateway_stop"
# Compaction hooks (memory management)
BEFORE_COMPACTION = "before_compaction"
AFTER_COMPACTION = "after_compaction"
# Tool result persistence (for modifying tool results before storage)
TOOL_RESULT_PERSIST = "tool_result_persist"
# Permission hooks
ON_PERMISSION_ASK = "on_permission_ask"
# Config hooks
ON_CONFIG = "on_config"
# Auth hooks
ON_AUTH = "on_auth"
# Error hooks
ON_ERROR = "on_error"
ON_RETRY = "on_retry"
# Claude Code parity hooks
USER_PROMPT_SUBMIT = "user_prompt_submit" # When user submits a prompt
NOTIFICATION = "notification" # When notification is sent
SUBAGENT_STOP = "subagent_stop" # When subagent completes
SETUP = "setup" # On initialization/maintenance
@dataclass
@@ -18,42 +18,21 @@ class Process:
DEFAULT_RETRY_LIMIT = 3 # Predefined retry limit in a common place
VALIDATION_FAILURE_DECISIONS = ["invalid", "retry", "failed", "error", "unsuccessful", "fail", "errors", "reject", "rejected", "incomplete"] # Decision strings that trigger validation feedback
def __init__(
self,
tasks: Dict[str, Task],
agents: List[Agent],
manager_llm: Optional[str] = None,
verbose: bool = False,
max_iter: int = 10,
output: Optional[str] = None,
):
def __init__(self, tasks: Dict[str, Task], agents: List[Agent], manager_llm: Optional[str] = None, verbose: bool = False, max_iter: int = 10):
logging.debug(f"=== Initializing Process ===")
logging.debug(f"Number of tasks: {len(tasks)}")
logging.debug(f"Number of agents: {len(agents)}")
logging.debug(f"Manager LLM: {manager_llm}")
logging.debug(f"Verbose mode: {verbose}")
logging.debug(f"Max iterations: {max_iter}")
self.tasks = tasks
self.agents = agents
self.manager_llm = manager_llm
self.verbose = verbose
self.max_iter = max_iter
self.task_retry_counter: Dict[str, int] = {} # Initialize retry counter
self.workflow_finished = False # ADDED: Workflow finished flag
# Resolve verbose from output= param (takes precedence) or legacy verbose= param
if output is not None:
# output= takes precedence over verbose=
from ..config.presets import OUTPUT_PRESETS
preset = OUTPUT_PRESETS.get(output, {})
self._verbose = preset.get("verbose", False)
else:
# Backward compat: use legacy verbose= param
self._verbose = verbose
# Keep self.verbose as alias for backward compat
self.verbose = self._verbose
logging.debug(f"Verbose mode: {self._verbose}")
def _create_llm_instance(self):
"""Create and return a configured LLM instance for manager tasks."""
@@ -116,7 +95,7 @@ class Process:
agent=loop_task.agent,
name=f"{loop_task.name}_{task_count}" if loop_task.name else task_desc,
expected_output=getattr(loop_task, 'expected_output', None),
on_task_complete=loop_task.callback, # Inherit callback from parent loop task
callback=loop_task.callback, # Inherit callback from parent loop task
is_start=(task_count == 1),
task_type="task"
)
@@ -142,7 +121,7 @@ class Process:
agent=loop_task.agent,
name=f"{loop_task.name}_{i+1}" if loop_task.name else line.strip(),
expected_output=getattr(loop_task, 'expected_output', None),
on_task_complete=loop_task.callback, # Inherit callback from parent loop task
callback=loop_task.callback, # Inherit callback from parent loop task
is_start=(i == 0),
task_type="task"
)
@@ -936,7 +915,7 @@ Provide a JSON with the structure:
agent=start_task.agent,
name=f"{start_task.name}_{task_count}" if start_task.name else task_desc,
expected_output=getattr(start_task, 'expected_output', None),
on_task_complete=start_task.callback, # Inherit callback from parent loop task
callback=start_task.callback, # Inherit callback from parent loop task
is_start=(task_count == 1),
task_type="decision", # Change to decision type
next_tasks=inherited_next_tasks, # Inherit parent's next tasks
@@ -970,7 +949,7 @@ Provide a JSON with the structure:
agent=start_task.agent,
name=f"{start_task.name}_{i+1}" if start_task.name else line.strip(),
expected_output=getattr(start_task, 'expected_output', None),
on_task_complete=start_task.callback, # Inherit callback from parent loop task
callback=start_task.callback, # Inherit callback from parent loop task
is_start=(i == 0),
task_type="task",
condition={
@@ -1054,7 +1033,7 @@ Tasks by type:
agent=current_task.agent,
name=f"{current_task.name}_{i+1}" if current_task.name else task_desc,
expected_output=getattr(current_task, 'expected_output', None),
on_task_complete=current_task.callback, # Inherit callback from parent loop task
callback=current_task.callback, # Inherit callback from parent loop task
is_start=(i == 0),
task_type="task",
condition={
@@ -1079,7 +1058,7 @@ Tasks by type:
agent=current_task.agent,
name=f"{current_task.name}_{i+1}" if current_task.name else line.strip(),
expected_output=getattr(current_task, 'expected_output', None),
on_task_complete=current_task.callback, # Inherit callback from parent loop task
callback=current_task.callback, # Inherit callback from parent loop task
is_start=(i == 0),
task_type="task",
condition={
@@ -92,7 +92,7 @@ class RetrievalConfig:
vector_store_provider: str = "chroma"
vector_store_config: Dict[str, Any] = field(default_factory=dict)
collection_name: Optional[str] = None
persist_path: str = ".praisonai"
persist_path: str = ".praison"
# Embedding configuration
embedder_provider: Optional[str] = None
@@ -233,7 +233,7 @@ class RetrievalConfig:
vector_store_provider=data.get("vector_store_provider", "chroma"),
vector_store_config=data.get("vector_store_config", {}),
collection_name=data.get("collection_name"),
persist_path=data.get("persist_path", ".praisonai"),
persist_path=data.get("persist_path", ".praison"),
embedder_provider=data.get("embedder_provider"),
embedder_model=data.get("embedder_model"),
auto_keywords=auto_keywords,
@@ -99,7 +99,7 @@ class HierarchicalSummarizer:
def __init__(
self,
llm=None,
persist_path: str = ".praisonai/summaries",
persist_path: str = ".praison/summaries",
verbose: bool = False,
):
"""
@@ -83,7 +83,7 @@ class Session:
default_memory_config = {
"provider": "rag",
"use_embedding": False, # Disable embeddings to avoid OpenAI API key requirement
"rag_db_path": f".praisonai/sessions/{self.session_id}/chroma_db"
"rag_db_path": f".praison/sessions/{self.session_id}/chroma_db"
}
if memory_config:
default_memory_config.update(memory_config)
@@ -94,7 +94,7 @@ class Session:
self.knowledge_config = default_knowledge_config
# Create session directory
os.makedirs(f".praisonai/sessions/{self.session_id}", exist_ok=True)
os.makedirs(f".praison/sessions/{self.session_id}", exist_ok=True)
# Initialize components lazily
self._memory = None
@@ -22,7 +22,7 @@ Usage:
)
agent.start("What did I say before?") # Remembers!
Default storage: ~/.praisonai/sessions/{session_id}.json
Default storage: ~/.praison/sessions/{session_id}.json
"""
from typing import TYPE_CHECKING
@@ -86,7 +86,7 @@ class Session:
default_memory_config = {
"provider": "rag",
"use_embedding": False,
"rag_db_path": f".praisonai/sessions/{self.session_id}/chroma_db"
"rag_db_path": f".praison/sessions/{self.session_id}/chroma_db"
}
if memory_config:
default_memory_config.update(memory_config)
@@ -95,7 +95,7 @@ class Session:
default_knowledge_config = knowledge_config or {}
self.knowledge_config = default_knowledge_config
os.makedirs(f".praisonai/sessions/{self.session_id}", exist_ok=True)
os.makedirs(f".praison/sessions/{self.session_id}", exist_ok=True)
self._memory = None
self._knowledge = None
@@ -17,12 +17,10 @@ from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from ..paths import get_sessions_dir
logger = logging.getLogger(__name__)
# Default session directory (uses centralized paths - DRY)
DEFAULT_SESSION_DIR = str(get_sessions_dir())
# Default session directory
DEFAULT_SESSION_DIR = os.path.expanduser("~/.praison/sessions")
# Default limits
DEFAULT_MAX_MESSAGES = 100
@@ -213,7 +211,7 @@ class DefaultSessionStore:
Initialize session store.
Args:
session_dir: Directory for session files. Defaults to ~/.praisonai/sessions/
session_dir: Directory for session files. Defaults to ~/.praison/sessions/
max_messages: Maximum messages to keep per session.
lock_timeout: Timeout for file lock acquisition.
"""
@@ -5,16 +5,14 @@ from typing import List, Optional
from .parser import find_skill_md, read_properties
from .models import SkillProperties
from ..paths import get_skills_dir, get_project_data_dir
def get_default_skill_dirs() -> List[Path]:
"""Get default skill directory locations.
Uses centralized paths.py for consistent path management.
Returns directories in precedence order (high to low):
1. Project: ./.praisonai/skills/ or ./.claude/skills/
2. User: ~/.praisonai/skills/
1. Project: ./.praison/skills/ or ./.claude/skills/
2. User: ~/.praison/skills/
3. System: /etc/praison/skills/ (admin-managed)
Returns:
@@ -23,21 +21,19 @@ def get_default_skill_dirs() -> List[Path]:
dirs = []
cwd = Path.cwd()
# Project-level directories (use centralized path)
project_data_dir = get_project_data_dir()
project_skills = project_data_dir / "skills"
if project_skills.exists() and project_skills.is_dir():
dirs.append(project_skills)
# Also check .claude/skills for compatibility
claude_skills = cwd / ".claude" / "skills"
if claude_skills.exists() and claude_skills.is_dir():
dirs.append(claude_skills)
# Project-level directories
project_dirs = [
cwd / ".praison" / "skills",
cwd / ".claude" / "skills",
]
for d in project_dirs:
if d.exists() and d.is_dir():
dirs.append(d)
# User-level directory (use centralized path)
user_skills = get_skills_dir()
if user_skills.exists() and user_skills.is_dir():
dirs.append(user_skills)
# User-level directory
user_dir = Path.home() / ".praison" / "skills"
if user_dir.exists() and user_dir.is_dir():
dirs.append(user_dir)
# System-level directory (Unix-like systems)
system_dir = Path("/etc/praison/skills")
@@ -19,7 +19,7 @@ class SkillManager:
manager = SkillManager()
# Discover skills from directories
manager.discover(["./skills", "~/.praisonai/skills"])
manager.discover(["./skills", "~/.praison/skills"])
# Get XML for system prompt
prompt_xml = manager.to_prompt()
@@ -14,12 +14,10 @@ import uuid
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from ..paths import get_snapshots_dir
logger = logging.getLogger(__name__)
# Default snapshot directory (uses centralized paths - DRY)
DEFAULT_SNAPSHOT_DIR = str(get_snapshots_dir())
# Default snapshot directory
DEFAULT_SNAPSHOT_DIR = os.path.expanduser("~/.praison/snapshots")
@dataclass
@@ -19,13 +19,8 @@ import logging
from pathlib import Path
from typing import Any, Dict, List, Optional
from ..paths import get_storage_dir
logger = logging.getLogger(__name__)
# Default storage directory (uses centralized paths - DRY)
DEFAULT_STORAGE_DIR = str(get_storage_dir())
class FileBackend:
"""
@@ -36,7 +31,7 @@ class FileBackend:
Example:
```python
backend = FileBackend(storage_dir="~/.praisonai/storage")
backend = FileBackend(storage_dir="~/.praison/data")
backend.save("session_123", {"messages": []})
data = backend.load("session_123")
```
@@ -44,7 +39,7 @@ class FileBackend:
def __init__(
self,
storage_dir: str = None,
storage_dir: str = "~/.praison/storage",
suffix: str = ".json",
pretty: bool = True,
):
@@ -56,7 +51,7 @@ class FileBackend:
suffix: File suffix (default: .json)
pretty: Use pretty-printed JSON
"""
self.storage_dir = Path(os.path.expanduser(storage_dir or DEFAULT_STORAGE_DIR))
self.storage_dir = Path(os.path.expanduser(storage_dir))
self.suffix = suffix
self.pretty = pretty
self._lock = threading.Lock()
@@ -167,7 +162,7 @@ class SQLiteBackend:
Example:
```python
backend = SQLiteBackend(db_path="~/.praisonai/data.db")
backend = SQLiteBackend(db_path="~/.praison/data.db")
backend.save("session_123", {"messages": []})
data = backend.load("session_123")
```
@@ -175,7 +170,7 @@ class SQLiteBackend:
def __init__(
self,
db_path: str = "~/.praisonai/storage.db",
db_path: str = "~/.praison/storage.db",
table_name: str = "praison_storage",
auto_create: bool = True,
):
@@ -473,10 +468,10 @@ def get_backend(
Example:
```python
# File backend (default)
backend = get_backend("file", storage_dir="~/.praisonai/data")
backend = get_backend("file", storage_dir="~/.praison/data")
# SQLite backend
backend = get_backend("sqlite", db_path="~/.praisonai/data.db")
backend = get_backend("sqlite", db_path="~/.praison/data.db")
# Redis backend
backend = get_backend("redis", url="redis://localhost:6379")
@@ -43,10 +43,6 @@ class StreamEvent:
tool_call: Tool call data for DELTA_TOOL_CALL events
metadata: Additional event-specific metadata
error: Error message for ERROR events
is_reasoning: Whether this content is reasoning/thinking (default False)
agent_id: Optional agent identifier for multi-agent scenarios
session_id: Optional session identifier for tracking
run_id: Optional run identifier for correlation
"""
type: StreamEventType
timestamp: float = field(default_factory=time.perf_counter)
@@ -54,10 +50,6 @@ class StreamEvent:
tool_call: Optional[Dict[str, Any]] = None
metadata: Optional[Dict[str, Any]] = None
error: Optional[str] = None
is_reasoning: bool = False
agent_id: Optional[str] = None
session_id: Optional[str] = None
run_id: Optional[str] = None
def __repr__(self) -> str:
parts = [f"StreamEvent({self.type.value}"]
@@ -47,14 +47,12 @@ class Task:
name: Optional[str] = None,
tools: Optional[List[Any]] = None,
context: Optional[List[Union[str, List, 'Task']]] = None,
depends_on: Optional[List[Union[str, List, 'Task']]] = None, # Alias for context (clearer semantics)
async_execution: Optional[bool] = False,
config: Optional[Dict[str, Any]] = None,
output_file: Optional[str] = None,
output_json: Optional[Type[BaseModel]] = None,
output_pydantic: Optional[Type[BaseModel]] = None,
callback: Optional[Union[Callable[[TaskOutput], Any], Callable[[TaskOutput], Coroutine[Any, Any, Any]]]] = None,
on_task_complete: Optional[Union[Callable[[TaskOutput], Any], Callable[[TaskOutput], Coroutine[Any, Any, Any]]]] = None,
status: str = "not started",
result: Optional[TaskOutput] = None,
create_directory: Optional[bool] = False,
@@ -81,7 +79,6 @@ class Task:
# ============================================================
skip_on_failure: bool = False, # Allow workflow to continue if this task fails
retry_delay: float = 0.0, # Seconds between retries (supports exponential backoff)
on_error: str = "stop", # Flow control: "stop", "continue", or "retry"
# ============================================================
# NEW PARAMS FROM WORKFLOWSTEP (Phase 4 Consolidation)
# ============================================================
@@ -96,22 +93,8 @@ class Task:
loop_var: str = "item",
# Consolidated config objects (from Task)
execution: Optional[Any] = None,
routing: Optional[Dict[str, List[str]]] = None, # Renamed from condition for clarity
routing: Optional[Any] = None,
output_config: Optional[Any] = None,
# ============================================================
# UNIFIED OUTPUT PARAMETER (consolidates all output configs)
# ============================================================
# Single output param that handles: file path (str), Pydantic model, TaskOutputConfig
output: Optional[Any] = None,
# ============================================================
# UNIFIED CONDITION SYNTAX (same as AgentFlow)
# ============================================================
# String-based condition expression (e.g., "{{score}} > 80")
when: Optional[str] = None,
# Task to route to when condition is True
then_task: Optional[str] = None,
# Task to route to when condition is False
else_task: Optional[str] = None,
# Feature configs (from Task)
autonomy: Optional[Any] = None,
knowledge: Optional[Any] = None,
@@ -153,36 +136,13 @@ class Task:
self.expected_output = expected_output if expected_output is not None else "Complete the task successfully"
self.agent = agent
self.tools = tools if tools else []
# Handle depends_on as alias for context (depends_on takes precedence)
if depends_on is not None:
self.context = depends_on
else:
self.context = context if context else []
# Store depends_on as property alias for context
self._depends_on = self.context
self.context = context if context else []
self.async_execution = async_execution
self.config = config if config else {}
self.output_file = output_file
self.output_json = output_json
self.output_pydantic = output_pydantic
# Handle callback/on_task_complete: on_task_complete is canonical, callback is deprecated
if callback is not None and on_task_complete is None:
import warnings
warnings.warn(
"Parameter 'callback' is deprecated, use 'on_task_complete' instead. "
"Example: Task(on_task_complete=my_fn) instead of Task(callback=my_fn)",
DeprecationWarning,
stacklevel=2
)
self.callback = callback
elif callback is not None and on_task_complete is not None:
raise ValueError(
"Cannot specify both 'callback' and 'on_task_complete'. "
"Use 'on_task_complete' only (callback is deprecated)."
)
else:
# on_task_complete takes precedence (or both are None)
self.callback = on_task_complete
self.callback = callback
self.status = status
self.result = result
self.create_directory = create_directory
@@ -196,17 +156,7 @@ class Task:
self.quality_check = quality_check
self.rerun = rerun # Assigning the rerun parameter
self.retain_full_context = retain_full_context
# Handle guardrail/guardrails: guardrails (plural) is canonical, guardrail (singular) is deprecated
if guardrail is not None and guardrails is None:
import warnings
warnings.warn(
"Parameter 'guardrail' is deprecated, use 'guardrails' instead. "
"Example: Task(guardrails=my_fn) instead of Task(guardrail=my_fn)",
DeprecationWarning,
stacklevel=2
)
# guardrails takes precedence over guardrail
self.guardrail = guardrails if guardrails is not None else guardrail
self.guardrail = guardrail if guardrail is not None else guardrails # guardrails is alias
self.max_retries = max_retries
self.retry_count = retry_count
self._guardrail_fn = None
@@ -232,90 +182,8 @@ class Task:
self.loop_var = loop_var
# Consolidated config objects (from Task)
self.execution = execution
# ============================================================
# RESOLVE EXECUTION CONFIG FIELDS TO TASK PROPERTIES
# Precedence: Direct params (if non-default) > Config object > Defaults
# We check if direct param equals its default to determine if user set it
# ============================================================
if execution is not None:
# Resolve async_exec -> async_execution
# Only apply config if direct param was NOT explicitly set (is default False)
if hasattr(execution, 'async_exec'):
# async_execution default is False, so if it's False, use config value
if not async_execution: # direct param is False (default)
self.async_execution = execution.async_exec
# Resolve quality_check
# quality_check default is True, so if it's True, use config value
if hasattr(execution, 'quality_check'):
if quality_check: # direct param is True (default)
self.quality_check = execution.quality_check
# Resolve max_retries
# max_retries default is 3, so if it's 3, use config value
if hasattr(execution, 'max_retries'):
if max_retries == 3: # direct param is 3 (default)
self.max_retries = execution.max_retries
# Resolve rerun
# rerun default is False, so if it's False, use config value
if hasattr(execution, 'rerun'):
if not rerun: # direct param is False (default)
self.rerun = execution.rerun
# Resolve on_error (config takes precedence for backward compat)
if hasattr(execution, 'on_error'):
self.on_error = execution.on_error
else:
self.on_error = on_error
else:
self.on_error = on_error
# Handle routing parameter - use routing if provided, else fall back to condition
if routing is not None:
self.condition = routing
self.routing = self.condition # Alias for backward compat
self.routing = routing
self.output_config = output_config
# ============================================================
# UNIFIED OUTPUT PARAMETER RESOLUTION
# ============================================================
# Resolve unified 'output' param (consolidates output_file, output_json, output_pydantic, output_config)
# Priority: unified 'output' param > individual params (for backward compat)
if output is not None:
if isinstance(output, str):
# String = file path
self.output_file = output
elif hasattr(output, 'model_fields') or hasattr(output, '__fields__'):
# Pydantic model class (v2 or v1)
if isinstance(output, type):
# It's a class, use for output_json
self.output_json = output
else:
# It's an instance, use for output_pydantic
self.output_pydantic = type(output)
elif hasattr(output, 'file') or hasattr(output, 'variable'):
# TaskOutputConfig or similar config object
if hasattr(output, 'file') and output.file:
self.output_file = output.file
if hasattr(output, 'json_model') and output.json_model:
self.output_json = output.json_model
if hasattr(output, 'pydantic_model') and output.pydantic_model:
self.output_pydantic = output.pydantic_model
if hasattr(output, 'variable') and output.variable:
self.output_variable = output.variable
elif isinstance(output, dict):
# Dict config
if 'file' in output:
self.output_file = output['file']
if 'json_model' in output or 'json' in output:
self.output_json = output.get('json_model') or output.get('json')
if 'pydantic_model' in output or 'pydantic' in output:
self.output_pydantic = output.get('pydantic_model') or output.get('pydantic')
if 'variable' in output:
self.output_variable = output.get('variable')
# Store the original output param for reference
self.output = output
# ============================================================
# UNIFIED CONDITION SYNTAX (same as AgentFlow)
# ============================================================
self.when = when
self.then_task = then_task
self.else_task = else_task
# Feature configs (from Task)
self.autonomy = autonomy
self.knowledge = knowledge
@@ -325,11 +193,7 @@ class Task:
self.hooks = hooks
self.caching = caching
# Output variable name - for storing output in workflow variables
# Only set from param if explicitly provided (unified output may have set it)
if output_variable is not None:
self.output_variable = output_variable
elif not hasattr(self, 'output_variable'):
self.output_variable = None
self.output_variable = output_variable
# Set logger level based on config verbose level
verbose = self.config.get("verbose", 0)
@@ -394,16 +258,6 @@ class Task:
# Initialize guardrail
self._setup_guardrail()
@property
def depends_on(self):
"""Alias for context - returns the list of dependent tasks."""
return self.context
@depends_on.setter
def depends_on(self, value):
"""Alias for context - sets the list of dependent tasks."""
self.context = value
def _setup_guardrail(self):
"""Setup the guardrail function based on the provided guardrail parameter."""
if self.guardrail is None:
@@ -462,40 +316,6 @@ class Task:
def __str__(self):
return f"Task(name='{self.name if self.name else 'None'}', description='{self.description}', agent='{self.agent.name if self.agent else 'None'}', status='{self.status}')"
def to_dict(self) -> Dict[str, Any]:
"""Serialize the Task to a dictionary.
Returns:
Dictionary representation of the Task.
"""
return {
"id": self.id,
"name": self.name,
"description": self.description,
"action": self.action,
"expected_output": self.expected_output,
"status": self.status,
"task_type": self.task_type,
"tools": self.tools,
"context": [str(c) if hasattr(c, '__str__') else c for c in self.context] if self.context else [],
"next_tasks": self.next_tasks,
"condition": self.condition,
"routing": self.routing,
"is_start": self.is_start,
"max_retries": self.max_retries,
"async_execution": self.async_execution,
"output_file": self.output_file,
"output_variable": self.output_variable,
"retain_full_context": self.retain_full_context,
"loop_over": self.loop_over,
"loop_var": self.loop_var,
"when": self.when,
"then_task": self.then_task,
"else_task": self.else_task,
"agent_config": self.agent_config,
"variables": self.variables,
}
def initialize_memory(self):
"""Initialize memory if config exists but memory doesn't"""
if not self.memory and self.config.get('memory_config'):
@@ -812,51 +632,4 @@ Context:
if asyncio.iscoroutinefunction(self.callback):
await self.callback(task_output)
else:
self.callback(task_output)
def evaluate_when(self, context: Dict[str, Any]) -> bool:
"""
Evaluate the 'when' condition against the given context.
Uses the shared evaluate_condition function from the conditions module
for DRY compliance with AgentFlow condition evaluation.
Args:
context: Dictionary containing variables for evaluation.
May include workflow variables, previous outputs, etc.
Returns:
True if condition is met or no condition is set.
False if condition is not met.
"""
if self.when is None:
return True
from ..conditions.evaluator import evaluate_condition
return evaluate_condition(
self.when,
context,
previous_output=context.get('previous_output')
)
def get_next_task(self, context: Dict[str, Any]) -> Optional[str]:
"""
Get the next task name based on the 'when' condition evaluation.
This provides a simple routing mechanism for Task objects,
similar to AgentFlow's when() function.
Args:
context: Dictionary containing variables for evaluation.
Returns:
then_task if condition is True, else_task if False.
None if no routing is configured.
"""
if self.when is None and self.then_task is None and self.else_task is None:
return None
if self.evaluate_when(context):
return self.then_task
else:
return self.else_task
self.callback(task_output)
@@ -43,8 +43,8 @@ def internet_search(query: str, max_results: int = 5, retries: int = MAX_RETRIES
results = []
ddgs = DDGS()
# Try text search (use positional 'query' for v8+ compatibility)
search_results = list(ddgs.text(query, max_results=max_results))
# Try text search
search_results = list(ddgs.text(keywords=query, max_results=max_results))
for result in search_results:
results.append({
@@ -4,7 +4,7 @@ Mentions Parser for PraisonAI Agents.
Provides @mention syntax support similar to Cursor IDE:
- @file:path/to/file.py - Include file content
- @web:query - Search the web
- @doc:name - Include doc from .praisonai/docs/
- @doc:name - Include doc from .praison/docs/
- @rule:name - Include specific rule
Usage:
@@ -30,7 +30,7 @@ class MentionsParser:
Supports:
- @file:path - Include file content
- @web:query - Web search (requires web search tool)
- @doc:name - Include doc from .praisonai/docs/
- @doc:name - Include doc from .praison/docs/
- @rule:name - Include specific rule
- @url:https://... - Fetch URL content
"""
@@ -213,8 +213,8 @@ class ToolRegistry:
"""Discover and load tools from single-file plugins.
Scans default plugin directories for WordPress-style plugins:
- ./.praisonai/plugins/ (project-level)
- ~/.praisonai/plugins/ (user-level)
- ./.praison/plugins/ (project-level)
- ~/.praison/plugins/ (user-level)
Returns:
Number of plugins loaded
@@ -333,8 +333,8 @@ def discover_plugins() -> int:
"""Discover and load tools from single-file plugins.
Scans default plugin directories for WordPress-style plugins:
- ./.praisonai/plugins/ (project-level)
- ~/.praisonai/plugins/ (user-level)
- ./.praison/plugins/ (project-level)
- ~/.praison/plugins/ (user-level)
Returns:
Number of plugins loaded
@@ -226,8 +226,7 @@ def _search_duckduckgo(query: str, max_results: int = 5) -> List[Dict[str, Any]]
try:
results = []
ddgs = DDGS()
# Use positional 'query' (v8+) instead of keywords= (deprecated)
search_results = list(ddgs.text(query, max_results=max_results))
search_results = list(ddgs.text(keywords=query, max_results=max_results))
for result in search_results:
results.append({
@@ -6,14 +6,14 @@ allowing users to define and execute complex task sequences.
Features:
- YAML/Markdown workflow definitions
- Auto-discovery from .praisonai/workflows/
- Auto-discovery from .praison/workflows/
- Step-by-step execution with context passing
- Variable substitution
- Conditional steps
- Lazy loading for performance
Storage Structure:
.praisonai/workflows/
.praison/workflows/
├── deploy.md # Deployment workflow
├── test.md # Testing workflow
└── review.md # Code review workflow
@@ -2696,9 +2696,6 @@ CONCISE SUMMARY:"""
) -> bool:
"""Evaluate a condition expression with variable substitution.
This method delegates to the shared evaluate_condition() function
from praisonaiagents.conditions for DRY condition handling.
Supported formats:
- Numeric comparison: "{{var}} > 80", "{{var}} >= 50", "{{var}} < 10"
- String equality: "{{var}} == approved", "{{var}} != rejected"
@@ -2714,8 +2711,115 @@ CONCISE SUMMARY:"""
Returns:
Boolean result of condition evaluation
"""
from praisonaiagents.conditions.evaluator import evaluate_condition
return evaluate_condition(condition, variables, previous_output)
import re
# Substitute variables in condition
substituted = condition
# Handle nested property access like {{item.score}}
def get_nested_value(var_path: str, vars_dict: Dict[str, Any]) -> Any:
parts = var_path.split('.')
value = vars_dict
for part in parts:
if isinstance(value, dict):
value = value.get(part)
else:
return None
return value
# Find all {{var}} patterns and substitute
pattern = r'\{\{([^}]+)\}\}'
matches = re.findall(pattern, condition)
for var_name in matches:
if '.' in var_name:
# Nested property access
value = get_nested_value(var_name, variables)
else:
value = variables.get(var_name)
if value is None:
value = ""
# Replace the placeholder with the value
placeholder = f"{{{{{var_name}}}}}"
if isinstance(value, (int, float)):
substituted = substituted.replace(placeholder, str(value))
elif isinstance(value, bool):
substituted = substituted.replace(placeholder, str(value).lower())
else:
substituted = substituted.replace(placeholder, str(value))
# Also substitute {{previous_output}}
if previous_output:
substituted = substituted.replace("{{previous_output}}", str(previous_output))
# Now evaluate the substituted condition
try:
# Handle different condition formats
# Numeric comparisons: "90 > 80", "50 >= 50", "10 < 20", etc.
numeric_pattern = r'^(-?\d+(?:\.\d+)?)\s*(>|>=|<|<=|==|!=)\s*(-?\d+(?:\.\d+)?)$'
numeric_match = re.match(numeric_pattern, substituted.strip())
if numeric_match:
left = float(numeric_match.group(1))
op = numeric_match.group(2)
right = float(numeric_match.group(3))
if op == '>':
return left > right
if op == '>=':
return left >= right
if op == '<':
return left < right
if op == '<=':
return left <= right
if op == '==':
return left == right
if op == '!=':
return left != right
# String equality: "approved == approved", "status != rejected"
string_eq_pattern = r'^(.+?)\s*(==|!=)\s*(.+)$'
string_match = re.match(string_eq_pattern, substituted.strip())
if string_match:
left = string_match.group(1).strip()
op = string_match.group(2)
right = string_match.group(3).strip()
if op == '==':
return left == right
if op == '!=':
return left != right
# Contains check: "error in some message", "status contains success"
if ' in ' in substituted:
parts = substituted.split(' in ', 1)
if len(parts) == 2:
needle = parts[0].strip()
haystack = parts[1].strip()
return needle.lower() in haystack.lower()
if ' contains ' in substituted:
parts = substituted.split(' contains ', 1)
if len(parts) == 2:
haystack = parts[0].strip()
needle = parts[1].strip()
return needle.lower() in haystack.lower()
# Boolean check: truthy evaluation
# Handle "true", "false", "True", "False"
if substituted.strip().lower() == 'true':
return True
if substituted.strip().lower() == 'false':
return False
# Non-empty string is truthy
return bool(substituted.strip())
except Exception as e:
logger.warning(f"Condition evaluation failed for '{condition}': {e}")
return False
def _execute_include(
self,
@@ -2904,7 +3008,7 @@ class WorkflowManager:
Workflows are defined in markdown files with YAML frontmatter
and step definitions.
Example workflow file (.praisonai/workflows/deploy.md):
Example workflow file (.praison/workflows/deploy.md):
```markdown
---
name: Deploy to Production
@@ -2955,7 +3059,7 @@ class WorkflowManager:
```
"""
WORKFLOWS_DIR = ".praisonai/workflows"
WORKFLOWS_DIR = ".praison/workflows"
SUPPORTED_EXTENSIONS = [".md", ".yaml", ".yml"]
def __init__(
@@ -3398,17 +3502,13 @@ class WorkflowManager:
context_parts = []
# Use getattr with defaults for workflow-specific attributes that may not exist on Task
context_from = getattr(step, 'context_from', None)
retain_full_context = getattr(step, 'retain_full_context', False)
if context_from:
if step.context_from:
# Include only specified steps
for step_name in context_from:
for step_name in step.context_from:
for prev_result in results:
if prev_result["step"] == step_name and prev_result.get("output"):
context_parts.append(f"## {step_name} Output:\n{prev_result['output']}")
elif retain_full_context:
elif step.retain_full_context:
# Include all previous outputs
for prev_result in results:
if prev_result.get("output") and prev_result.get("status") == "success":
@@ -3767,21 +3867,18 @@ class WorkflowManager:
# Handle branching
next_step_idx = None
# Use getattr with defaults for workflow-specific attributes that may not exist on Task
branch_condition = getattr(step, 'branch_condition', None)
next_steps = getattr(step, 'next_steps', None)
if branch_condition and step_result["output"]:
if step.branch_condition and step_result["output"]:
# Evaluate branch condition based on output
output_lower = str(step_result["output"]).lower()
for branch_key, branch_targets in branch_condition.items():
for branch_key, branch_targets in step.branch_condition.items():
if branch_key.lower() in output_lower or output_lower.startswith(branch_key.lower()):
if branch_targets and branch_targets[0] in step_lookup:
next_step_idx, _ = step_lookup[branch_targets[0]]
break
elif next_steps:
elif step.next_steps:
# Use explicit next_steps
if next_steps[0] in step_lookup:
next_step_idx, _ = step_lookup[next_steps[0]]
if step.next_steps[0] in step_lookup:
next_step_idx, _ = step_lookup[step.next_steps[0]]
# Move to next step
if next_step_idx is not None:
@@ -4246,7 +4343,7 @@ class WorkflowManager:
def _get_checkpoints_dir(self) -> Path:
"""Get the checkpoints directory path."""
checkpoints_dir = self.workspace_path / ".praisonai" / "checkpoints"
checkpoints_dir = self.workspace_path / ".praison" / "checkpoints"
checkpoints_dir.mkdir(parents=True, exist_ok=True)
return checkpoints_dir
+1 -8
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.4.4"
version = "1.2.1"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -57,13 +57,6 @@ api = [
"uvicorn>=0.34.0"
]
# AgentOS dependencies (alias for api + auth for deployment)
os = [
"fastapi>=0.115.0",
"uvicorn>=0.34.0",
"PyJWT>=2.8.0"
]
# Telemetry dependencies
telemetry = [
"posthog>=3.0.0"
@@ -1,7 +1,6 @@
from praisonaiagents import (
Agent,
Task,
AgentTeam,
Agents,
error_logs,
register_display_callback,
@@ -148,7 +147,7 @@ def main():
markdown=True
)
# Create tasks with on_task_complete callbacks
# Create tasks with callbacks
task1 = Task(
name="research_task",
description="""Analyze 2024's AI advancements.
@@ -156,7 +155,7 @@ def main():
expected_output="""A detailed report on 2024 AI advancements""",
agent=researcher,
tools=[internet_search_tool],
on_task_complete=task_callback
callback=task_callback
)
task2 = Task(
@@ -167,7 +166,7 @@ def main():
expected_output="A blog post of at least 4 paragraphs",
agent=writer,
context=[task1],
on_task_complete=task_callback,
callback=task_callback,
tools=[]
)
@@ -176,7 +175,7 @@ def main():
description="""Create a json object with a title of "My Task" and content of "My content".""",
expected_output="""JSON output with title and content""",
agent=researcher,
on_task_complete=task_callback
callback=task_callback
)
task4 = Task(
@@ -187,7 +186,7 @@ def main():
context=[task2],
output_file='test.txt',
create_directory=True,
on_task_complete=task_callback
callback=task_callback
)
# Create and run agents manager
@@ -6,13 +6,12 @@ Requires OPENAI_API_KEY environment variable.
"""
import os
import pytest
import sys
# Skip entire module if no API key
pytestmark = pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY"),
reason="OPENAI_API_KEY not set"
)
# Skip if no API key
if not os.environ.get("OPENAI_API_KEY"):
print("Skipping integration tests - OPENAI_API_KEY not set")
sys.exit(0)
def test_guardrail_string_preset():
@@ -1,256 +0,0 @@
"""
Integration tests comparing Task.condition vs AgentFlow when() syntax.
This test file validates both condition syntaxes work correctly and
documents the differences for analysis.
"""
import pytest
class TestAgentFlowConditionSyntax:
"""Test AgentFlow's when()/if_() string-based condition syntax."""
def test_agentflow_numeric_condition(self):
"""AgentFlow uses string expressions: '{{score}} > 80'"""
from praisonaiagents import AgentFlow
from praisonaiagents.workflows import if_
results = []
def approve(ctx):
results.append("approved")
return {"output": "approved"}
def reject(ctx):
results.append("rejected")
return {"output": "rejected"}
# Test passing condition
flow = AgentFlow(
steps=[
if_(
condition="{{score}} > 80",
then_steps=[approve],
else_steps=[reject]
)
],
variables={"score": 90}
)
flow.start("test")
assert "approved" in results
# Test failing condition
results.clear()
flow2 = AgentFlow(
steps=[
if_(
condition="{{score}} > 80",
then_steps=[approve],
else_steps=[reject]
)
],
variables={"score": 70}
)
flow2.start("test")
assert "rejected" in results
def test_agentflow_string_condition(self):
"""AgentFlow string equality: '{{status}} == approved'"""
from praisonaiagents import AgentFlow
from praisonaiagents.workflows import when
results = []
def process_approved(ctx):
results.append("processed_approved")
return {"output": "done"}
def process_rejected(ctx):
results.append("processed_rejected")
return {"output": "done"}
flow = AgentFlow(
steps=[
when(
condition="{{status}} == approved",
then_steps=[process_approved],
else_steps=[process_rejected]
)
],
variables={"status": "approved"}
)
flow.start("test")
assert "processed_approved" in results
class TestTaskConditionSyntax:
"""Test Task's Dict-based condition syntax for routing."""
def test_task_dict_condition_routing(self):
"""Task uses Dict[str, List[str]]: {'approved': ['next_task']}"""
from praisonaiagents import Task
# Task condition is a dict mapping decision values to next task names
task = Task(
name="review_task",
description="Review and decide",
task_type="decision",
condition={
"approved": ["publish_task"],
"rejected": ["revise_task"]
}
)
# Verify condition structure
assert isinstance(task.condition, dict)
assert "approved" in task.condition
assert task.condition["approved"] == ["publish_task"]
assert task.condition["rejected"] == ["revise_task"]
def test_task_should_run_callable(self):
"""Task also supports should_run callable for conditional execution."""
from praisonaiagents import Task
def check_condition(ctx):
return ctx.get("score", 0) > 80
task = Task(
name="conditional_task",
description="Only run if score > 80",
should_run=check_condition
)
assert task.should_run is not None
assert callable(task.should_run)
# Test the callable
assert task.should_run({"score": 90}) is True
assert task.should_run({"score": 70}) is False
class TestSyntaxComparison:
"""Compare the two syntaxes side-by-side."""
def test_document_syntax_differences(self):
"""Document the syntax differences for analysis."""
# ============================================================
# AGENTFLOW SYNTAX (Deterministic Pipelines)
# ============================================================
# Uses: String expressions with {{variable}} placeholders
# Location: workflows/workflows.py - when(), if_(), If class
# Evaluation: evaluate_condition() in conditions/evaluator.py
#
# Example:
# when(
# condition="{{score}} > 80", # STRING expression
# then_steps=[approve],
# else_steps=[reject]
# )
#
# Supported formats:
# - Numeric: "{{var}} > 80", "{{var}} >= 50"
# - String: "{{var}} == approved"
# - Contains: "error in {{message}}"
# - Boolean: "{{flag}}"
# - Nested: "{{item.score}} >= 60"
agentflow_condition = "{{score}} > 80" # STRING
# ============================================================
# TASK/AGENTTEAM SYNTAX (Multi-Agent Task Graphs)
# ============================================================
# Uses: Dict[str, List[str]] mapping decision values to task names
# Location: task/task.py - Task.condition parameter
# Evaluation: process/process.py - matches decision_str to dict keys
#
# Example:
# Task(
# condition={
# "approved": ["publish_task"], # DICT routing
# "rejected": ["revise_task"]
# }
# )
#
# How it works:
# 1. Task with task_type="decision" returns a decision value
# 2. Process looks up decision value in condition dict
# 3. Routes to the corresponding next task(s)
task_condition = {"approved": ["publish_task"], "rejected": ["revise_task"]} # DICT
# ============================================================
# KEY DIFFERENCES (CONFUSING FOR USERS)
# ============================================================
#
# | Aspect | AgentFlow | Task/AgentTeam |
# |-----------------|--------------------|-----------------------|
# | Condition Type | STRING expression | DICT routing map |
# | Evaluation | Expression parsing | Key lookup |
# | Purpose | Branch on value | Route to next task |
# | Variables | {{var}} syntax | decision_str from LLM |
# | Flexibility | Any comparison | Exact key match |
#
# CONFUSION POINTS:
# 1. Same word "condition" means different things
# 2. AgentFlow: condition="{{x}} > 5" (evaluates expression)
# 3. Task: condition={"yes": ["task_a"]} (routes by key)
# 4. No unified syntax for beginners
assert isinstance(agentflow_condition, str)
assert isinstance(task_condition, dict)
# This test documents the confusion - both use "condition" but differently
class TestRealWorldScenarios:
"""Test real-world scenarios with both syntaxes."""
def test_agentflow_score_based_routing(self):
"""Real scenario: Route based on numeric score."""
from praisonaiagents import AgentFlow
from praisonaiagents.workflows import when
execution_log = []
def high_score_handler(ctx):
execution_log.append("high_score")
return {"output": "Premium service"}
def low_score_handler(ctx):
execution_log.append("low_score")
return {"output": "Standard service"}
# AgentFlow: Uses string expression
flow = AgentFlow(
steps=[
when(
condition="{{customer_score}} >= 80",
then_steps=[high_score_handler],
else_steps=[low_score_handler]
)
],
variables={"customer_score": 85}
)
result = flow.start("Assign service tier")
assert "high_score" in execution_log
def test_task_decision_routing(self):
"""Real scenario: Task decision routes to next task."""
from praisonaiagents import Task
# Task: Uses dict for routing
review_task = Task(
name="content_review",
description="Review content and decide: approved or rejected",
task_type="decision",
condition={
"approved": ["publish_content"],
"rejected": ["request_revision"]
}
)
# The condition dict maps LLM decision output to next task names
assert review_task.task_type == "decision"
assert "approved" in review_task.condition
assert "rejected" in review_task.condition
@@ -172,20 +172,15 @@ class TestWorkflowDictParams:
assert workflow.memory is not None
def test_workflowstep_context_dict(self):
"""Task accepts context as dict (via TaskContextConfig)."""
"""Task accepts context as dict."""
from praisonaiagents.workflows.workflows import Task
from praisonaiagents.workflows.workflow_configs import TaskContextConfig
step = Task(
name="step1",
action="do something",
context=TaskContextConfig(from_steps=["step0"], retain_full=True),
context={"from_steps": ["step0"], "retain_full": True},
)
# context stores the TaskContextConfig object
assert hasattr(step.context, 'from_steps')
assert step.context.from_steps == ["step0"]
# retain_full_context is a separate param on Task
assert step.context.retain_full is True
assert step.context_from == ["step0"]
assert step.retain_full_context is True
def test_workflowstep_output_dict(self):
"""Task accepts output as dict."""
@@ -193,35 +188,31 @@ class TestWorkflowDictParams:
step = Task(
name="step1",
action="do something",
output={"file": "output.txt", "variable": "result"},
)
assert step.output_file == "output.txt"
assert step.output_variable == "result"
def test_workflowstep_execution_dict(self):
"""Task accepts execution as dict (params passed directly)."""
"""Task accepts execution as dict."""
from praisonaiagents.workflows.workflows import Task
step = Task(
name="step1",
action="do something",
max_retries=5,
quality_check=True,
execution={"max_retries": 5, "quality_check": True},
)
assert step.max_retries == 5
assert step.quality_check is True
def test_workflowstep_routing_dict(self):
"""Task accepts routing as dict (via next_tasks param)."""
"""Task accepts routing as dict."""
from praisonaiagents.workflows.workflows import Task
step = Task(
name="step1",
action="do something",
next_tasks=["step2", "step3"],
routing={"next_steps": ["step2", "step3"]},
)
assert step.next_tasks == ["step2", "step3"]
assert step.next_steps == ["step2", "step3"]
# =============================================================================
@@ -306,11 +297,9 @@ class TestBackwardCompatibility:
step = Task(
name="step1",
action="do something",
context=["step0", "step_prev"],
)
# context stores the list directly
assert step.context == ["step0", "step_prev"]
assert step.context_from == ["step0", "step_prev"]
# =============================================================================
@@ -1,275 +0,0 @@
"""Integration tests for EvaluationLoop with mocked LLM."""
import pytest
from unittest.mock import MagicMock, patch
class TestEvaluationLoopIntegration:
"""Integration tests for EvaluationLoop."""
def test_evaluation_loop_with_agent_and_judge(self):
"""Test full integration with Agent and Judge (mocked LLM)."""
from praisonaiagents import Agent
from praisonaiagents.eval import EvaluationLoop, EvaluationLoopResult
with patch('praisonaiagents.agent.agent.Agent.chat') as mock_chat, \
patch('litellm.completion') as mock_completion:
mock_chat.side_effect = [
"First attempt at analysis",
"Improved analysis with more detail",
"Final comprehensive analysis with all requirements",
]
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = """
SCORE: 9
REASONING: Excellent comprehensive analysis
SUGGESTIONS: None needed
"""
mock_completion.return_value = mock_response
agent = Agent(name="test_agent", instructions="Analyze systems")
loop = EvaluationLoop(
agent=agent,
criteria="Analysis is thorough",
threshold=8.0,
max_iterations=3,
)
result = loop.run("Analyze the auth flow")
assert isinstance(result, EvaluationLoopResult)
assert result.final_score == 9.0
assert result.success is True
def test_agent_run_until_method(self):
"""Test Agent.run_until() convenience method."""
from praisonaiagents import Agent
from praisonaiagents.eval.results import EvaluationLoopResult
with patch('praisonaiagents.agent.agent.Agent.chat') as mock_chat, \
patch('litellm.completion') as mock_completion:
mock_chat.return_value = "Good analysis output"
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = """
SCORE: 8.5
REASONING: Good quality analysis
SUGGESTIONS: Consider edge cases
"""
mock_completion.return_value = mock_response
agent = Agent(name="test_agent", instructions="Analyze systems")
result = agent.run_until(
"Analyze the codebase",
criteria="Analysis is thorough",
threshold=8.0,
)
assert isinstance(result, EvaluationLoopResult)
assert result.success is True
assert result.final_score == 8.5
def test_evaluation_loop_iteration_callback(self):
"""Test that iteration callback is called correctly."""
from praisonaiagents import Agent
from praisonaiagents.eval import EvaluationLoop
with patch('praisonaiagents.agent.agent.Agent.chat') as mock_chat, \
patch('litellm.completion') as mock_completion:
mock_chat.return_value = "Output"
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "SCORE: 9\nREASONING: Good"
mock_completion.return_value = mock_response
callback_results = []
def on_iteration(result):
callback_results.append(result)
agent = Agent(name="test", instructions="Test")
loop = EvaluationLoop(
agent=agent,
criteria="Be good",
threshold=8.0,
on_iteration=on_iteration,
)
loop.run("Test prompt")
assert len(callback_results) == 1
assert callback_results[0].score == 9.0
def test_evaluation_loop_multiple_iterations(self):
"""Test loop runs multiple iterations when score is below threshold."""
from praisonaiagents import Agent
from praisonaiagents.eval import EvaluationLoop
with patch('praisonaiagents.agent.agent.Agent.chat') as mock_chat, \
patch('litellm.completion') as mock_completion:
mock_chat.side_effect = ["Output 1", "Output 2", "Output 3"]
responses = [
"SCORE: 5\nREASONING: Needs work\nSUGGESTIONS: Improve detail",
"SCORE: 7\nREASONING: Better\nSUGGESTIONS: Add examples",
"SCORE: 9\nREASONING: Excellent\nSUGGESTIONS: None",
]
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = responses[0]
call_count = [0]
def side_effect(*args, **kwargs):
idx = min(call_count[0], len(responses) - 1)
mock_response.choices[0].message.content = responses[idx]
call_count[0] += 1
return mock_response
mock_completion.side_effect = side_effect
agent = Agent(name="test", instructions="Test")
loop = EvaluationLoop(
agent=agent,
criteria="Be thorough",
threshold=8.0,
max_iterations=5,
)
result = loop.run("Test prompt")
assert result.success is True
assert len(result.iterations) == 3
assert result.score_history == [5.0, 7.0, 9.0]
def test_evaluation_loop_max_iterations_reached(self):
"""Test loop stops at max iterations even if threshold not met."""
from praisonaiagents import Agent
from praisonaiagents.eval import EvaluationLoop
with patch('praisonaiagents.agent.agent.Agent.chat') as mock_chat, \
patch('litellm.completion') as mock_completion:
mock_chat.return_value = "Output"
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "SCORE: 5\nREASONING: Poor"
mock_completion.return_value = mock_response
agent = Agent(name="test", instructions="Test")
loop = EvaluationLoop(
agent=agent,
criteria="Be thorough",
threshold=8.0,
max_iterations=3,
)
result = loop.run("Test prompt")
assert result.success is False
assert len(result.iterations) == 3
assert result.final_score == 5.0
def test_evaluation_loop_review_mode(self):
"""Test review mode runs all iterations regardless of score."""
from praisonaiagents import Agent
from praisonaiagents.eval import EvaluationLoop
with patch('praisonaiagents.agent.agent.Agent.chat') as mock_chat, \
patch('litellm.completion') as mock_completion:
mock_chat.return_value = "Output"
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "SCORE: 10\nREASONING: Perfect"
mock_completion.return_value = mock_response
agent = Agent(name="test", instructions="Test")
loop = EvaluationLoop(
agent=agent,
criteria="Be thorough",
threshold=8.0,
max_iterations=3,
mode="review",
)
result = loop.run("Test prompt")
assert result.success is True
assert len(result.iterations) == 3
class TestEvaluationLoopResultSerialization:
"""Tests for result serialization."""
def test_result_to_json(self):
"""Test EvaluationLoopResult.to_json()."""
from praisonaiagents.eval.results import IterationResult, EvaluationLoopResult
import json
iterations = [
IterationResult(iteration=1, output="Out1", score=7.0, reasoning="OK"),
IterationResult(iteration=2, output="Out2", score=9.0, reasoning="Good"),
]
result = EvaluationLoopResult(
iterations=iterations,
success=True,
total_duration_seconds=5.0,
)
json_str = result.to_json()
parsed = json.loads(json_str)
assert parsed["success"] is True
assert parsed["final_score"] == 9.0
assert parsed["score_history"] == [7.0, 9.0]
assert len(parsed["iterations"]) == 2
def test_result_final_report(self):
"""Test EvaluationLoopResult.final_report property."""
from praisonaiagents.eval.results import IterationResult, EvaluationLoopResult
iterations = [
IterationResult(
iteration=1,
output="First output",
score=6.0,
reasoning="Needs improvement",
findings=["Add more detail"]
),
IterationResult(
iteration=2,
output="Better output",
score=8.5,
reasoning="Good quality",
findings=[]
),
]
result = EvaluationLoopResult(
iterations=iterations,
success=True,
total_duration_seconds=3.5,
threshold=8.0,
)
report = result.final_report
assert "# Evaluation Loop Report" in report
assert "Success" in report or "✅" in report
assert "8.5" in report
assert "Iteration 1" in report
assert "Iteration 2" in report
assert "Add more detail" in report
@@ -15,18 +15,15 @@ import time
# Skip entire module if MCP package is not installed
try:
import mcp
from praisonaiagents.mcp import MCP
from praisonaiagents import Agent
MCP_AVAILABLE = True
except ImportError:
MCP_AVAILABLE = False
MCP = None
Agent = None
@pytest.mark.integration
@pytest.mark.skipif(not MCP_AVAILABLE, reason="MCP package not installed. Install with: pip install praisonaiagents[mcp]")
@pytest.mark.skipif(not MCP_AVAILABLE, reason="MCP package not installed")
class TestMultiAgentMCP:
"""Integration tests for multiple agents with MCP."""
@@ -11,9 +11,6 @@ Tests the HTTPStreamTransport implementation including:
import pytest
# Skip all tests in this module if MCP is not installed
pytest.importorskip("mcp", reason="MCP module not installed. Install with: pip install praisonaiagents[mcp]")
class TestHTTPStreamTransportInit:
"""Test HTTPStreamTransport initialization."""
@@ -6,9 +6,6 @@ These tests define the expected behavior of ToolsMCPServer class.
import pytest
from unittest.mock import patch
# Skip all tests in this module if MCP is not installed
pytest.importorskip("mcp", reason="MCP module not installed. Install with: pip install praisonaiagents[mcp]")
class TestToolsMCPServerInit:
"""Test ToolsMCPServer initialization."""
@@ -262,14 +262,6 @@ class TestWebSocketKeepalive:
def test_ping_interval_configurable(self):
"""Test that ping interval is configurable."""
import asyncio
# Create event loop for asyncio.Queue() used in __init__
try:
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
from praisonaiagents.mcp.mcp_websocket import WebSocketTransport
transport = WebSocketTransport(
@@ -339,7 +339,7 @@ class TestRulesManager:
@pytest.fixture
def rules_dir(self, tmp_path):
"""Create a temp rules directory."""
rules_path = tmp_path / ".praisonai" / "rules"
rules_path = tmp_path / ".praison" / "rules"
rules_path.mkdir(parents=True)
return rules_path
@@ -363,7 +363,7 @@ class TestRulesManager:
assert "**/*.py" in rule.globs
# Verify file was created
rule_file = tmp_path / ".praisonai" / "rules" / "python_style.md"
rule_file = tmp_path / ".praison" / "rules" / "python_style.md"
assert rule_file.exists()
def test_load_rule_with_frontmatter(self, rules_dir, tmp_path):
@@ -378,10 +378,10 @@ priority: 10
# Test Rule
- Rule content here
"""
(rules_dir / "unique_frontmatter_test.md").write_text(rule_content)
(rules_dir / "test_rule.md").write_text(rule_content)
manager = RulesManager(workspace_path=str(tmp_path))
rule = manager.get_rule_by_name("unique_frontmatter_test")
rule = manager.get_rule_by_name("test_rule")
assert rule is not None
assert rule.description == "Test rule"
@@ -252,7 +252,7 @@ class TestAiDecisionActivation(unittest.TestCase):
def test_ai_decision_rule_loaded(self):
"""Test that ai_decision rules are loaded."""
with tempfile.TemporaryDirectory() as tmpdir:
rules_dir = Path(tmpdir) / ".praisonai" / "rules"
rules_dir = Path(tmpdir) / ".praison" / "rules"
rules_dir.mkdir(parents=True)
rule_content = """---
@@ -273,7 +273,7 @@ activation: ai_decision
def test_evaluate_ai_decision_without_llm(self):
"""Test ai_decision evaluation without LLM (defaults to True)."""
with tempfile.TemporaryDirectory() as tmpdir:
rules_dir = Path(tmpdir) / ".praisonai" / "rules"
rules_dir = Path(tmpdir) / ".praison" / "rules"
rules_dir.mkdir(parents=True)
rule_content = """---
@@ -365,7 +365,7 @@ class TestWorkflows(unittest.TestCase):
from praisonaiagents.memory import WorkflowManager
with tempfile.TemporaryDirectory() as tmpdir:
workflows_dir = Path(tmpdir) / ".praisonai" / "workflows"
workflows_dir = Path(tmpdir) / ".praison" / "workflows"
workflows_dir.mkdir(parents=True)
workflow_content = """---
@@ -401,7 +401,7 @@ Build application
from praisonaiagents.memory import WorkflowManager
with tempfile.TemporaryDirectory() as tmpdir:
workflows_dir = Path(tmpdir) / ".praisonai" / "workflows"
workflows_dir = Path(tmpdir) / ".praison" / "workflows"
workflows_dir.mkdir(parents=True)
workflow_content = """---
@@ -462,7 +462,7 @@ class TestHooks(unittest.TestCase):
from praisonaiagents.memory import HooksManager
with tempfile.TemporaryDirectory() as tmpdir:
config_dir = Path(tmpdir) / ".praisonai"
config_dir = Path(tmpdir) / ".praison"
config_dir.mkdir()
config = {
@@ -526,7 +526,7 @@ class TestHooks(unittest.TestCase):
timeout=60
)
config_path = Path(tmpdir) / ".praisonai" / "hooks.json"
config_path = Path(tmpdir) / ".praison" / "hooks.json"
self.assertTrue(config_path.exists())
config = json.loads(config_path.read_text())
Loaded 100 of 246 files, more files were not shown because too many files have changed in this diff. Show more