Compare commits

...
Author SHA1 Message Date
praison 402d7ed9fc Release v4.6.39 2026-05-19 06:28:39 +01:00
701e8e6a6a fix: wrapper layer architectural fixes for concurrency safety (#1680) (#1681)
* fix: wrapper layer architectural fixes for concurrency safety (#1680)

- Replace _run_praisonai event loop creation with _async_bridge.run_sync to prevent deadlocks in nested async contexts
- Remove module-level OpenAI client LRU cache that evicts in-use clients; move to per-instance ownership in BaseAutoGenerator
- Replace inbuilt_tools custom lazy-import scaffolding with existing _load_optional pattern to eliminate DRY violations

Fixes #1680

Co-authored-by: MervinPraison <[email protected]>

* test: align lazy-loading tests with per-instance OpenAI client ownership

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/682c9dfc-e131-45a2-816e-05044bc4a04a

Co-authored-by: MervinPraison <[email protected]>

* fix: address reviewer feedback on concurrency and error handling

- Fix race condition in OpenAI client initialization/teardown with proper locking
- Fix AttributeError in __del__ method for partially initialized objects
- Fix RuntimeError swallowing in InteractiveRuntime exception handling to preserve fail-fast semantics
- Fix PRAISONAI_TOOLS_AVAILABLE coupling to allow tools with just praisonai_tools package
- Add missing trailing newline to inbuilt_tools/__init__.py

Addresses feedback from CodeRabbit, Greptile, and Copilot code reviews.

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-18 16:35:04 +01:00
MervinPraison ef09341cc0 docs: auto-update feature parity trackers [skip ci] 2026-05-18 15:34:14 +00:00
Jonathan Hill 3a4d7e11c8 fix: guard against empty/filtered LLM responses in core LLM paths (#1682)
Add null checks before accessing choices[0].message in four files:
- openai_client.py: sync/async tool-call paths and responses-API path
- guardrails.py: check/aguard result extraction
- rag.py: query_rag and aquery_rag answer extraction
- code_agent.py: generate/review/explain/refactor/fix methods
2026-05-18 16:33:32 +01:00
92644518c0 fix: wrapper layer architectural fixes for concurrency safety (#1673)
* fix: wrapper layer architectural fixes for concurrency safety

- Fix InMemoryJobStore: Add locks to all read methods to prevent inconsistent snapshots under concurrency
- Fix scheduler retry: Replace uninterruptible time.sleep with Event.wait for responsive stop()
- Fix ToolRegistry: Add threading.Lock synchronization to match other registries

Fixes #1672

Co-authored-by: MervinPraison <[email protected]>

* fix: address reviewer feedback - dead code, missing lock, unused import, tests

- Remove broken try-except in _get_lock() (loop var was unused, second call
  would have re-raised the same RuntimeError) - per Greptile P2 inline comment
- Protect get_stats() with list() snapshot to prevent RuntimeError on concurrent
  dict mutation - per Greptile P1 finding
- Remove unused `import time` from agent_scheduler.py (time.sleep was replaced
  with Event.wait() in the PR changes)
- Add newline at end of tool_registry.py
- Fix existing retry tests that patched time.sleep (now gone) to mock
  _stop_event.wait() instead; add two new tests that verify Event.wait behavior
- Add TestToolRegistryThreadSafety tests for concurrent register/read/clear
- Add TestInMemoryJobStoreConcurrency tests for concurrent async saves/reads
  and get_stats() snapshot correctness

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/82f4b33b-1b67-4451-be2c-4f53c57bdcf1

Co-authored-by: MervinPraison <[email protected]>

* fix: address reviewer feedback - dead code, missing lock, unused import, tests

Fixes identified by Greptile, CodeRabbit, and Copilot reviewers:

1. InMemoryJobStore.get_stats() - Added missing async lock protection
   - Method now takes snapshot under lock to prevent concurrent mutation
   - Made get_stats() async to match other store methods

2. Simplified _get_lock() implementation
   - Removed dead try-except code that had unused 'loop' variable
   - Kept lazy initialization pattern for Python 3.9 compatibility

3. Validated ToolRegistry threading safety
   - Confirmed threading.Lock is correct (not asyncio.Lock)
   - ToolRegistry is used in sync contexts, not async
   - Thread safety tests pass

All syntax checks and focused tests pass.

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

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-17 04:17:48 +01:00
cd54574b20 fix: Resolve architectural gaps in registry fragmentation and singleton patterns (#1675)
* fix: Resolve architectural gaps in registry fragmentation and singleton patterns

This commit addresses three critical architectural violations identified in issue #1674:

**Gap 1 - Registry Consolidation**: Unified four different registry patterns under canonical PluginRegistry:
- Enhanced PluginRegistry with alias support for consistent naming
- Migrated LLMProviderRegistry from class-level singleton to PluginRegistry inheritance
- Converted cli_backends from module-level dict to PluginRegistry pattern
- Refactored StoreRegistry to inherit from PluginRegistry instead of duplicating logic

**Gap 2 - Singleton Elimination**: Removed process-global singletons for multi-agent safety:
- Converted Profiler from __new__ singleton to per-instance with context-aware fallback
- Replaced LLMProviderRegistry._instance with module-level default pattern
- Added create_exec_approval_manager() for per-agent ExecApprovalManager instances
- Refactored telemetry init to avoid process-wide os.environ mutation

**Gap 3 - Async Surface**: Added complete async interface to PraisonAIDB adapter:
- Implemented aon_* async versions of all lifecycle hooks (aon_user_message, etc.)
- Added async context manager support (__aenter__, __aexit__, aclose)
- Graceful fallback: async methods detect store capabilities and use asyncio.to_thread for sync stores

All changes maintain backward compatibility while enabling proper multi-agent isolation and async safety.

Fixes #1674

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

Co-authored-by: Claude <[email protected]>

* fix: Resolve Profiler compatibility issue with per-instance delegation pattern

- Renamed Profiler class to _ProfilerImpl to avoid circular reference
- Created ProfilerCompat delegation wrapper for backward compatibility
- Added context-aware profiler support via get_profiler()
- Fixed all decorators and class-level API calls to work correctly
- Maintains full backward compatibility while enabling per-agent isolation

Resolves critical compatibility issue identified by Copilot review

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-05-17 04:16:33 +01:00
68035af76d fix(tests): BotOS integration tests use registry unregister (#1669)
* fix(tests): unregister mock bot platforms via BotPlatformRegistry

Co-authored-by: Cursor <[email protected]>

* fix(tests): add exception handling to _unregister_bot_platform helper

Prevents cleanup failures from masking primary test errors by catching
KeyError/ValueError exceptions during platform unregistration.

Addresses Gemini reviewer feedback.

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

---------

Co-authored-by: praison <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-05-15 07:49:31 +01:00
MervinPraison c989f2c31e docs: auto-update feature parity trackers [skip ci] 2026-05-14 18:16:52 +00:00
a2c51a0760 fix(praisonaiagents): export execute_code via lazy tools loader (#1666)
* fix(tools): lazy-export execute_code as module function

Co-authored-by: Cursor <[email protected]>

* test(tools): add regression test for execute_code lazy export

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/a9d10f35-24e5-4e53-a064-043ecb6a0b08

Co-authored-by: MervinPraison <[email protected]>

* fix(tools): extend lazy-export pattern to all python code tools

Maps analyze_code, format_code, lint_code, disassemble_code, and python_tools
to direct function imports (None) instead of PythonTools class instantiation.
This ensures consistent lazy loading, fixes module alias imports, and avoids
unnecessary dependency checks until functions are actually called.

Addresses reviewer feedback for architectural consistency.

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

---------

Co-authored-by: praison <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-05-14 19:16:21 +01:00
7a601b5373 fix(tests): align framework and datastore mocks with current APIs (#1665)
* fix(tests): patch get_default_registry and session store loader

Co-authored-by: Cursor <[email protected]>

* test: scope datastore impl cache reset via patch.object

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/2a49bd60-577c-45b9-8236-3ee18f853a01

Co-authored-by: MervinPraison <[email protected]>

---------

Co-authored-by: praison <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-14 19:16:16 +01:00
12f430e29c fix(tests): preserve provider API keys from the environment (#1664)
* fix(tests): only set placeholder API keys when unset

Co-authored-by: Cursor <[email protected]>

* test: add focused coverage for setup_test_environment fixture

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/0bafd91e-1df1-4422-8ac1-e4a0a8f8ee8c

Co-authored-by: MervinPraison <[email protected]>

* test: dedupe provider key list in fixture coverage

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/0bafd91e-1df1-4422-8ac1-e4a0a8f8ee8c

Co-authored-by: MervinPraison <[email protected]>

* fix(tests): use existence check instead of truthiness for env vars

Addresses issue identified by all reviewers: the previous check
'if not os.environ.get(key)' treated empty strings as missing and
would overwrite them, inconsistent with the intent to preserve
any pre-existing environment variable values.

Now uses 'if key not in os.environ' which only sets placeholders
when the key is truly absent, aligning with the teardown logic.

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

* test: avoid hardcoded fixture test path default

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/0bafd91e-1df1-4422-8ac1-e4a0a8f8ee8c

Co-authored-by: MervinPraison <[email protected]>

* test: clarify fixture helper naming in env setup tests

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/0bafd91e-1df1-4422-8ac1-e4a0a8f8ee8c

Co-authored-by: MervinPraison <[email protected]>

---------

Co-authored-by: praison <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-05-14 19:15:51 +01:00
c3c78fbc7b docs: verify pytest failure categories (#1663)
* docs: add verification notes for pytest failure categories

Co-authored-by: Cursor <[email protected]>

* docs: fix verification command path assumptions

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/8421f477-fa95-4465-a7b1-0c1c6da79fa2

Co-authored-by: MervinPraison <[email protected]>

* fix: address reviewer feedback on pytest verification guide

- Add missing executable verification commands for sections 4 & 5
- Fix conftest.py to only set placeholder API keys when unset/empty
- Align implementation with documented behavior for mixed testing

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

* Delete docs/testing/VERIFY_TEST_FAILURE_CATEGORIES.md

---------

Co-authored-by: praison <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-05-14 19:14:43 +01:00
bf120c8dca fix(tests): bot _BUILTIN_PLATFORMS uses lazy loaders (#1667)
* fix(tests): expect lazy loader callables in bot registry

Co-authored-by: Cursor <[email protected]>

* fix(tests): improve bot registry tests with identity checks

- Replace weak __name__ assertions with robust identity checks
- Use 'is' operator instead of name comparison for loader validation
- Addresses Gemini Code Assist feedback for more reliable tests

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

* test(bots): split loader-shape and adapter-identity assertions

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/b57884c2-8621-4f00-94e1-85eeb82a1cd5

Co-authored-by: MervinPraison <[email protected]>

---------

Co-authored-by: praison <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-14 19:12:37 +01:00
praisonai-triage-agent[bot]andMervinPraison 13d073b02a fix: move scripts folder from root to appropriate location (#1661)
* fix: move scripts folder from root to appropriate location (fixes #1659)

- Move all smoke test scripts from /scripts to src/praisonai/tests/integration/
- Update documentation in moved files to reflect new paths
- Per AGENTS.md architecture, these bot/BotOS integration tests belong in praisonai wrapper tests
- Preserves git history with git mv command

Co-authored-by: MervinPraison <[email protected]>

* fix: add __test__ = False to smoke test files to prevent pytest collection

- Prevents accidental pytest discovery in environments with different collection rules
- Follows Python testing best practices for non-test files in test directories
- Addresses Copilot's review feedback for explicit test exclusion

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-05-13 10:33:57 +01:00
praison adcfa29941 Release v4.6.38 2026-05-13 10:28:46 +01:00
7efd0075da fix: resolve architectural gaps in src/praisonai/praisonai (#1658)
* fix: resolve architectural gaps in src/praisonai/praisonai (fixes #1657)

- Fix framework adapter dispatch kwargs mismatch by adding required keyword-only arguments to AutoGenAdapter, AutoGenV4Adapter, and AG2Adapter run() methods
- Fix InteractiveRuntime lifecycle management by keeping runtime alive during agent execution instead of stopping it prematurely
- Consolidate tools.py loading paths by making ToolResolver the single source of truth and replacing duplicate loading logic in agents_generator.py
- Remove unused load_tools_from_tools_py() method
- Update ToolResolver to use _safe_loader for consistent security model

Co-authored-by: MervinPraison <[email protected]>

* fix: define tools_path in ToolResolver local loader logging

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/bee41da9-2153-41cd-9834-439aa753a848

Co-authored-by: MervinPraison <[email protected]>

* fix: resolve critical NameError and event loop leak

- Fix NameError in ToolResolver._load_local_tools() for undefined tools_path variable
- Fix event loop leak in AgentsGenerator when InteractiveRuntime startup fails
- Change autogen_v4/ag2 adapters to raise NotImplementedError instead of returning strings

🤖 Generated with Claude Code

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-13 10:11:11 +01:00
MervinPraison 9ec90361d6 docs: auto-update feature parity trackers [skip ci] 2026-05-13 06:04:03 +00:00
2e5540a41b fix: check agent server readiness timeout return value (fixes #1631) (#1655)
* fix: check agent server readiness timeout return value (fixes #1631)

- Add return value checking for ready_event.wait() to detect timeouts
- Log warning when server doesn't become ready within timeout period
- Make timeout configurable via PRAISONAI_SERVER_READY_TIMEOUT env var
- Add comprehensive regression tests for timeout behavior

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>

* fix: address reviewer feedback - add error handling for invalid timeout env var

- Add try/catch for float() conversion to prevent ValueError crashes
- Prevent real server spawning in unit tests via Thread.start mocking
- Add test for invalid env var fallback behavior
- Preserve other env vars during testing (avoid clear=True)

Addresses feedback from Gemini, CodeRabbit, Qodo, and Copilot reviewers.

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

* fix: use monkeypatch.delenv and move import time to module level

- Replace patch.dict(os.environ, {"": ""}) in test_default_timeout_value
  with monkeypatch.delenv so the test exercises the true "env var unset"
  default path rather than the ValueError fallback path
- Move `import time` from inside test function body to module-level imports

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/5c472f85-484f-4a03-b47a-9307639ecd0a

Co-authored-by: MervinPraison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-13 07:03:37 +01:00
7fcd6af4c6 fix(claw): add .env.example and document TAVILY_API_KEY requirement (#1649)
* fix(claw): add .env.example and document TAVILY_API_KEY requirement (fixes #1648)

- Add comprehensive .env.example at repository root with all required and optional environment variables
- Document TAVILY_API_KEY requirement in README.md Claw section with link to get free API key
- Fixes silent failures on first run by providing clear variable reference template
- Improves developer onboarding experience for new Claw users

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>

* fix(env): use LOGLEVEL key in root .env.example

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/7fe15070-18b9-4586-ba5e-a70c271db412

Co-authored-by: MervinPraison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-12 14:06:07 +01:00
MervinPraison 1fb71c46b4 docs: auto-update feature parity trackers [skip ci] 2026-05-12 13:05:59 +00:00
praisonai-triage-agent[bot]andpraisonai-triage-agent[bot] ca46eb1297 fix: replace deprecated datetime.utcnow() with datetime.now(timezone.utc) (fixes #1647) (#1650)
- Replace datetime.utcnow() calls across multiple components
- Update imports to include timezone module
- Ensure Python 3.12+ compatibility and prevent 3.14 runtime errors
- Fixed 10 critical files including CLI, jobs, agents, memory, and context components

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
2026-05-12 14:05:16 +01:00
Mervin Praisonandpraisonai-bot 422212105a fix(cli): graceful BudgetExceededError handling (wrapper-only, no Agent param bloat) (#1645)
Re-implements the CLI UX half of reverted PR #1635 without the core SDK
changes that violated AGENTS.md.

## What changed
- Add _execute_agent_with_budget_handling(agent, method_name, *args, **kwargs)
  helper on PraisonAIApp: calls agent.<method_name>(...) and catches
  BudgetExceededError to print a single-line actionable CLI message and
  exit(1) instead of leaking a raw traceback.
- Route all 17 agent.start(prompt) / agent.chat(prompt) / auto_rag.chat(prompt)
  call sites in the direct-prompt dispatch through the helper. Covers every
  display mode: silent, quiet, verbose, debug, jsonl, json, flow, editor, default.

## What is NOT changed (intentionally)
- Zero edits to praisonaiagents (core SDK).
- NO new 'max_budget=' parameter on Agent.__init__.
- Users continue to configure budgets via the already-consolidated
  execution=ExecutionConfig(max_budget=...) pattern per AGENTS.md §5.3
  'Parameter Consolidation'.

## Why wrapper-only
AGENTS.md §4.1 reserves core SDK for protocols/hooks/adapters — CLI UX
belongs in the wrapper. AGENTS.md §5.3 explicitly consolidates execution-
related knobs (max_iter, max_rpm, max_budget, rate_limiter, ...) into
ExecutionConfig precisely to avoid adding more top-level Agent params.

## Error message
Points users to the correct API:
    Budget limit exceeded: <reason>.
    Hint: set budget via execution=ExecutionConfig(max_budget=1.00) on your Agent.

Closes #1627

Co-authored-by: praisonai-bot <[email protected]>
2026-05-09 22:59:05 +01:00
Mervin Praisonandpraisonai-bot b2611e5d61 feat(examples): add scientific writing examples (CAJAL re-implementation) (#1644)
Re-implements the CAJAL / scientific-paper functionality that was reverted
from the core SDK (PR #1611 / revert #1641) as proper examples per AGENTS.md:

- examples/scientific_writing/scientific_writer.py
  Single-agent pattern: regular Agent + @tool functions, no subclass.
  Uses the CAJAL-4B HuggingFace model via litellm.

- examples/scientific_writing/multi_agent_paper.py
  Multi-agent workflow: Literature Reviewer -> Methodologist -> Writer
  composed with AgentTeam + sequential Tasks (no custom Agent classes).

- examples/scientific_writing/agents.yaml
  YAML parity for the multi-agent workflow (AGENTS.md §7 '3-way' rule).

Why this location:
- AGENTS.md §4.1: core SDK has no heavy domain implementations.
- AGENTS.md §4.4: compose specialised Agents + tools, don't subclass Agent.
- AGENTS.md §6.1: domain-specific behaviour belongs in @tool functions.
- Wrapper / praisonai-tools were alternatives; examples/ keeps it
  discoverable and avoids bloating either package.

Co-authored-by: praisonai-bot <[email protected]>
2026-05-09 22:53:51 +01:00
Mervin Praison 78f0a71e71 Revert "fix: resolve BudgetExceededError CLI traceback issues (fixes #1627) (…" (#1642)
This reverts commit 2e05f2c0f2.
2026-05-09 22:43:59 +01:00
MervinPraison e01f63dba2 docs: auto-update feature parity trackers [skip ci] 2026-05-09 21:42:35 +00:00
Mervin Praison 99638a9129 Revert "feat: Integrate CAJAL Scientific Writer Agent (#1611)" (#1641)
This reverts commit a41a8a22d6.
2026-05-09 22:42:08 +01:00
MervinPraison a410bc88bd docs: auto-update api.md [skip ci] 2026-05-09 21:40:20 +00:00
7bcdfafb4d feat(N4): inbound dead-letter queue for failed bot messages (#1600)
* feat(N4): inbound dead-letter queue for failed bot messages

Persist failed agent.chat() calls to a SQLite-backed DLQ so user
messages are never silently lost on transient LLM failures.

- praisonai/bots/_dlq.py: InboundDLQ + DLQEntry (sqlite3 stdlib only,
  WAL mode, atomic writes via per-instance threading.Lock; TTL +
  max_size eviction; async replay with success/failure callback).
- praisonai/bots/_session.py: BotSessionManager(dlq=) optional param;
  chat() now enqueues on agent.chat() exception then re-raises (so
  adapter still shows user a friendly error). Default OFF preserves
  legacy behaviour bit-for-bit.
- praisonai/bots/__init__.py: lazy exports for InboundDLQ, DLQEntry,
  BotSessionManager.
- praisonai/cli/commands/bot.py: 'praisonai bot dlq {list,replay,purge}'
  subcommand group with --path / --limit / --yes flags.
- tests/unit/bots/test_dlq.py: 11 unit tests (basic/persistence/bounds/
  TTL/replay/wired-failure-path).
- scripts/smoke_n4_real.py: real agentic E2E — patch agent.chat to fail
  once, assert DLQ holds entry, replay through real LLM (anthropic/
  claude-haiku-4-5) returns expected answer.

Test results:
  11/11 DLQ unit tests pass
  102/102 bot/session/gateway regression pass (no W1 here, single
    pre-existing unrelated test deselected)
  Real agentic: 'What is 2+2?' fails, lands in DLQ, replay -> '4'

Docs: features/inbound-dlq.mdx with mermaid (Dark Red agents / Teal
tools / white text), CardGroup, Tabs, ParamField, Expandable,
AccordionGroup, Tip/Warning/Info/Check/Badge per Mintlify.

Closes N4.

* fix(dlq): address critical P1/P2 issues from architecture review

- Fix non-atomic SQLite enqueue operations by using explicit transactions
- Fix event loop blocking by wrapping DLQ calls in run_in_executor
- Fix replay ordering to process oldest entries first (not newest)
- Fix CLI exit codes for user cancellation and failed replays
- Fix smoke test to fail fast when no supported API key is found

Resolves critical issues identified by Greptile, CodeRabbit, Gemini reviewers

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

---------

Co-authored-by: Cascade <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: praison <[email protected]>
2026-05-09 22:40:08 +01:00
a41a8a22d6 feat: Integrate CAJAL Scientific Writer Agent (#1611)
* feat: integrate CAJAL Scientific Writer Agent for specialized paper generation

- Add ScientificWriterAgent class with CAJAL model support
- Implement PaperSection and ScientificPaper data structures
- Add LaTeX formatting capabilities for academic output
- Create comprehensive example showing multi-agent scientific workflows
- Add unit tests for all new functionality
- Follow protocol-driven design with lazy loading
- Support both CAJAL and standard models for flexibility

Fixes #1610

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>

* fix: resolve unit test failures and bugs in ScientificWriterAgent

- Move `from .agent import Agent` to module level so @patch decorators
  in tests can resolve the attribute (fixes 8 failing unit tests)
- Add `import re` at module level; remove duplicate in-method imports
- Fix broken regex in _extract_section: `#{{{name}}}` was generating
  literal curly braces, now uses `##?\s+{re.escape(name)}` which
  correctly matches markdown section headers
- Fix misleading _get_cajal_model docstring/comment
- Remove root-level test_cajal_integration.py artifact (belongs in tests/)

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/37052ea9-0c0b-4ee3-9083-cb1af5f12d9a

Co-authored-by: MervinPraison <[email protected]>

* fix: improve _extract_section regex to handle indented markdown headers

Allow optional whitespace before section headers in the lookahead
(n\s*##?\s instead of \n##?\s) so that indented LLM output like
'        ## Introduction' correctly terminates the preceding section.
Without this, the abstract section would greedily capture all
subsequent sections until end-of-string.

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/37052ea9-0c0b-4ee3-9083-cb1af5f12d9a

Co-authored-by: MervinPraison <[email protected]>

* fix: resolve __getattr__ infinite recursion in ScientificWriterAgent

Adds guard to prevent infinite recursion when self.agent is unset during
initialization failures. This was the final P1 issue identified by Greptile.

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-05-09 22:40:05 +01:00
praisonai-triage-agent[bot]andMervin Praison c18450bb35 fix: eliminate singleton registries and optimize scheduler async handling (fixes #1638) (#1639)
* fix: eliminate singleton registries and optimize scheduler async handling (fixes #1638)

* Replace singleton pattern with dependency injection across 3 registries
* Create generic PluginRegistry base class for DRY implementation
* Convert FrameworkAdapterRegistry to use dependency injection
* Convert ExternalAgentRegistry to use dependency injection
* Convert bot platforms registry to use dependency injection
* Replace asyncio.run() in scheduler hot path with _async_bridge
* Maintain backward compatibility for all existing APIs
* Add entry-points support consistently across all registries

Resolves all three architectural gaps:
1. No more process-global singleton violations
2. Unified registry pattern eliminates code duplication
3. Scheduler uses persistent event loop instead of per-call overhead

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

Co-Authored-By: MervinPraison <[email protected]>

* fix: resolve registry backward compatibility and performance issues

- Fix eager loading regression in bots/_registry.py by implementing lazy registry access
- Fix get_platform_registry() to return {name: class} instead of {name: name} for backward compatibility
- Fix thread-safety race condition in PluginRegistry.resolve() error handling
- Update stale docstring in integrations/registry.py to use get_default_registry()

Addresses all P1 and P2 issues identified by code review bots.

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-05-09 22:40:03 +01:00
9955d65f82 fix: replace unsafe eval() with safe AST parser in README example (#1626)
* fix: replace unsafe eval() with safe AST parser in README example (fixes #1625)

- Replace eval(expression) with AST-based arithmetic parser in custom tools example
- Add comprehensive security warning about eval(), exec(), subprocess usage
- Prevent arbitrary code execution while preserving arithmetic functionality
- Blocks malicious inputs like __import__('os').system('whoami')

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>

* docs: normalize README calculator error handling and blockquote formatting

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/f86d8d08-03ae-4f02-8c4b-21dc53e084dc

Co-authored-by: MervinPraison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-09 22:40:00 +01:00
80c78071fc fix: resolve critical correctness, concurrency, and safety gaps (#1637)
* fix: resolve critical correctness, concurrency, and safety gaps (fixes #1636)

- Fix NameError: define missing AGENTOPS_AVAILABLE flag and add lazy import with error handling
- Fix data race: acquire _stats_lock around all counter mutations and reads in async scheduler
- Fix security bypass: route public tool-loading methods through _safe_loader.py

Co-authored-by: MervinPraison <[email protected]>

* fix: address reviewer feedback - clean up docstring, ruff warnings, and lock consistency

- Remove stale FileNotFoundError from load_tools_from_module docstring
- Add noqa comments for broad Exception catches in agentops blocks
- Fix misleading comment in get_stats() and make is_running snapshot consistent
- Replace deprecated asyncio.get_event_loop() with get_running_loop()
- Add None guard for _stats_lock in stop() method to match get_stats() pattern

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: praison <[email protected]>
2026-05-09 22:39:57 +01:00
2e05f2c0f2 fix: resolve BudgetExceededError CLI traceback issues (fixes #1627) (#1635)
* fix: resolve BudgetExceededError CLI traceback issues (fixes #1627)

- Add clean error handling for BudgetExceededError in CLI
  * Wrap agent execution calls with graceful error handling
  * Show user-friendly error messages instead of raw tracebacks
  * Exit with code 1 and provide clear remediation guidance

- Add max_budget parameter to Agent constructor
  * Accept max_budget directly as convenience parameter
  * Automatically create/update ExecutionConfig with budget setting
  * Maintain full backward compatibility
  * Add proper parameter documentation

Both fixes follow architecture guidelines and provide intuitive UX.

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

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>

* fix: address review feedback for budget handling and import safety

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/16ae1b3d-1c3c-4051-9465-a718a6746a90

Co-authored-by: MervinPraison <[email protected]>

* fix: resolve print shadowing and simplify budget error message

- Import rich print as rich_print to avoid shadowing builtin
- Consolidate multi-line error output into single actionable message
- Update guidance to mention new max_budget convenience parameter

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

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

* fix: resolve print shadowing and simplify budget error message

- Remove dangerous ImportError fallback in budget error handling
- Use dataclasses.replace() instead of mutating shared ExecutionConfig
- Simplify budget error to single actionable message
- Address CodeRabbit, Greptile, and Copilot review feedback

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-05-09 22:39:55 +01:00
82 changed files with 4110 additions and 2062 deletions

No files matched your search

+43
View File
@@ -0,0 +1,43 @@
# PraisonAI Environment Variables
# Copy this file to .env and update with your actual values
# === Required ===
OPENAI_API_KEY=sk-... # OpenAI key for LLM calls. Get yours at https://platform.openai.com
# === Required for Claw web-search tool ===
TAVILY_API_KEY=tvly-... # Get yours at https://app.tavily.com (free tier available)
# === Optional: Claw dashboard security ===
PRAISONAI_ALLOW_LOCAL_TOOLS=0 # Set to 1 to enable local shell tools (security risk)
# === Optional: Alternative LLM Providers ===
ANTHROPIC_API_KEY=sk-ant-... # Anthropic Claude models
GOOGLE_API_KEY=... # Google Gemini models
GROQ_API_KEY=gsk_... # Groq (fast inference)
COHERE_API_KEY=... # Cohere models
AZURE_OPENAI_API_KEY=... # Azure OpenAI
AZURE_OPENAI_ENDPOINT=... # Azure OpenAI endpoint
# === Optional: Memory backends ===
MEM0_API_KEY=... # Mem0 memory service
REDIS_URL=redis://localhost:6379 # Redis for state management
# === Optional: Observability ===
LANGFUSE_SECRET_KEY=sk-lf-... # Langfuse tracing
LANGFUSE_PUBLIC_KEY=pk-lf-... # Langfuse public key
# === Optional: Bot platforms ===
TELEGRAM_BOT_TOKEN=... # Telegram bot token
DISCORD_BOT_TOKEN=... # Discord bot token
SLACK_BOT_TOKEN=xoxb-... # Slack bot token
SLACK_APP_TOKEN=xapp-... # Slack app token
WHATSAPP_ACCESS_TOKEN=... # WhatsApp bot access token
WHATSAPP_PHONE_NUMBER_ID=... # WhatsApp phone number ID
# === Optional: Database ===
DATABASE_URL=sqlite:///~/.praison/database.sqlite # Database connection string
# === Optional: Development ===
LOGLEVEL=INFO # Logging level (DEBUG, INFO, WARNING, ERROR)
CHAINLIT_AUTH_SECRET=... # Chainlit authentication secret
DEBUG=false # Enable debug mode
+39 -2
View File
@@ -294,8 +294,35 @@ def search(query: str) -> str:
@tool
def calculate(expression: str) -> float:
"""Evaluate a math expression."""
return eval(expression)
"""Safely evaluate a numeric arithmetic expression."""
import ast
import operator
# Define allowed operations
_OPS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.Pow: operator.pow,
ast.USub: operator.neg,
ast.UAdd: operator.pos,
}
def _safe_eval(node):
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value
elif isinstance(node, ast.BinOp) and type(node.op) in _OPS:
return _OPS[type(node.op)](_safe_eval(node.left), _safe_eval(node.right))
elif isinstance(node, ast.UnaryOp) and type(node.op) in _OPS:
return _OPS[type(node.op)](_safe_eval(node.operand))
else:
raise ValueError("Unsupported expression")
try:
return _safe_eval(ast.parse(expression, mode="eval").body)
except (ValueError, SyntaxError, TypeError, ZeroDivisionError, OverflowError):
raise ValueError("Invalid arithmetic expression")
agent = Agent(
instructions="You are a helpful assistant",
@@ -304,6 +331,7 @@ agent = Agent(
agent.start("Search for AI news and calculate 15*4")
```
> ⚠️ **Security Note:** Never use `eval()`, `exec()`, or `subprocess` in tool functions that process LLM-generated or user-supplied input. Always validate and sanitize inputs to prevent code injection attacks.
> 📖 [Full tools docs](https://docs.praison.ai/docs/tools/tools) — BaseTool, tool packages, 100+ built-in tools
### 5. Persistence (Databases)
@@ -330,6 +358,15 @@ pip install "praisonai[claw]"
praisonai claw
```
#### Required Environment Variables
Copy `.env.example` to `.env` and configure the following variables:
| Variable | Required | Description |
|----------|----------|-------------|
| `OPENAI_API_KEY` | Yes | OpenAI API key for all LLM calls |
| `TAVILY_API_KEY` | Yes (Claw) | Tavily key for the built-in web-search tool. Get one free at https://app.tavily.com |
Open **http://localhost:8082** — the dashboard comes with 13 built-in pages: Chat, Agents, Memory, Knowledge, Channels, Guardrails, Cron, and more. Add messaging channels directly from the UI.
> 📖 [Full Claw docs](https://docs.praison.ai/docs/concepts/claw) — platform tokens, CLI options, Docker, and YAML agent mode
+3
View File
@@ -787,6 +787,9 @@ Methods:
* <code title="cli">praisonai bot email <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot install-daemon <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot linear <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot list <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot purge <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot replay <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot slack <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot start <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot telegram <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
+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.6.37" \
"praisonai>=4.6.39" \
"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.6.37" \
"praisonai>=4.6.39" \
"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.6.37" \
"praisonai>=4.6.39" \
"praisonai[ui]" \
"praisonai[crewai]"
+40
View File
@@ -0,0 +1,40 @@
# Scientific paper workflow — YAML equivalent of multi_agent_paper.py
# Run with:
# praisonai agents run --file examples/scientific_writing/agents.yaml
framework: praisonai
topic: "Climate change effects on coral reef biodiversity"
roles:
literature_reviewer:
role: "Literature Reviewer"
goal: "Survey recent academic work on {topic} and produce an APA review."
backstory: "Expert in academic literature surveying and citation hygiene."
llm: "gpt-4o-mini"
tasks:
review_literature:
description: "Produce a concise literature review with 5-8 APA citations."
expected_output: "APA-formatted literature review."
methodology_designer:
role: "Methodology Designer"
goal: "Design a reproducible research methodology for {topic}."
backstory: "Specialises in rigorous experimental design."
llm: "gpt-4o-mini"
tasks:
design_methodology:
description: "Write a reproducible methods section in LaTeX."
expected_output: "LaTeX methods section."
scientific_writer:
role: "Scientific Writer"
goal: "Assemble the final LaTeX paper on {topic}."
backstory: >-
Specialised in LaTeX-formatted academic papers, fine-tuned on
scientific literature (CAJAL-4B checkpoint).
llm: "huggingface/Agnuxo/CAJAL-4B-P2PCLAW"
tasks:
write_paper:
description: >-
Combine the literature review and methodology into a full
scientific paper on {topic}. Use LaTeX formatting.
expected_output: "Complete LaTeX paper."
@@ -0,0 +1,73 @@
"""Multi-agent scientific paper workflow (Researcher -> Methodologist -> Writer).
Demonstrates the agent-centric pattern from AGENTS.md: compose specialised
Agents with ``AgentTeam`` + sequential ``Task``s instead of creating custom
Agent subclasses. The writer uses the CAJAL-4B HuggingFace model; the
reviewer and methodologist use general-purpose LLMs.
Usage:
python examples/scientific_writing/multi_agent_paper.py
"""
from praisonaiagents import Agent, AgentTeam, Task
from scientific_writer import ( # noqa: E402 — example imports a sibling file
build_scientific_writer,
format_citation,
format_latex_section,
)
literature_reviewer = Agent(
name="Literature Reviewer",
instructions=(
"You are an expert at surveying academic literature. "
"Produce a concise literature review with 5-8 key citations in APA."
),
tools=[format_citation],
)
methodology_designer = Agent(
name="Methodology Designer",
instructions=(
"You design rigorous research methodologies. "
"Output a clear, reproducible methods section."
),
tools=[format_latex_section],
)
scientific_writer = build_scientific_writer()
def run(topic: str) -> str:
"""Run the 3-agent workflow for a given research ``topic``."""
t1 = Task(
name="review_literature",
description=f"Review literature on: {topic}",
agent=literature_reviewer,
expected_output="APA literature review.",
)
t2 = Task(
name="design_methodology",
description=f"Design research methodology for: {topic}",
agent=methodology_designer,
expected_output="LaTeX methods section.",
)
t3 = Task(
name="write_paper",
description=(
f"Combine the literature review and methodology into a full "
f"scientific paper on: {topic}. Use LaTeX formatting."
),
agent=scientific_writer,
expected_output="Complete LaTeX paper.",
)
team = AgentTeam(
agents=[literature_reviewer, methodology_designer, scientific_writer],
tasks=[t1, t2, t3],
)
return team.start()
if __name__ == "__main__":
result = run("Transformer architectures for protein folding prediction")
print(result)
@@ -0,0 +1,85 @@
"""Scientific Paper Writer — single-agent example using the CAJAL model.
CAJAL (``Agnuxo/CAJAL-4B-P2PCLAW``) is a 4B-parameter HuggingFace model
specialised for LaTeX-formatted academic writing. This example shows how
to use it with a regular ``Agent`` and ``@tool`` functions — no new Agent
subclass is needed. Run 3 ways: Python (this file), CLI, or YAML.
Requirements:
pip install "praisonaiagents[llm]" # for litellm / HuggingFace
export HUGGINGFACE_API_KEY=... # or run a local inference server
Usage:
python examples/scientific_writing/scientific_writer.py
"""
from praisonaiagents import Agent, tool
@tool
def format_latex_section(title: str, content: str) -> str:
"""Wrap prose content in a LaTeX ``\\section{}`` block.
Args:
title: Section heading (e.g. ``"Introduction"``).
content: The body text.
Returns:
LaTeX-formatted section string.
"""
return f"\\section{{{title}}}\n{content}\n"
@tool
def format_citation(authors: str, year: int, title: str, venue: str) -> str:
"""Render a single APA-style citation line.
Args:
authors: ``"Smith, J. & Jones, A."``
year: Publication year.
title: Paper title.
venue: Journal / conference name.
Returns:
One APA-formatted citation string.
"""
return f"{authors} ({year}). {title}. *{venue}*."
SCIENTIFIC_INSTRUCTIONS = """You are a specialised scientific paper writer.
Produce high-quality academic content with:
- Clear structure (Abstract, Introduction, Methodology, Results, Discussion,
Conclusion, References)
- Rigorous methodology and precise language
- Proper APA citations and LaTeX formatting
- Honest limitations and future work sections
Use ``format_latex_section`` to wrap each section in LaTeX, and
``format_citation`` to build the References list.
"""
def build_scientific_writer(
model: str = "huggingface/Agnuxo/CAJAL-4B-P2PCLAW",
) -> Agent:
"""Construct a scientific-writing Agent.
Override ``model`` for any HuggingFace / local / API model — the default
targets the CAJAL-4B checkpoint for LaTeX-style academic output.
"""
return Agent(
name="Scientific Writer",
instructions=SCIENTIFIC_INSTRUCTIONS,
llm=model,
tools=[format_latex_section, format_citation],
)
if __name__ == "__main__":
agent = build_scientific_writer()
paper = agent.start(
"Write a short scientific paper (Abstract + Introduction + Conclusion) "
"on 'Climate change effects on coral reef biodiversity'. "
"Use LaTeX formatting and include at least 3 APA citations."
)
print(paper)
@@ -164,7 +164,8 @@ Follow best practices and coding standards."""
messages=messages,
**{k: v for k, v in kwargs.items() if k != "model"}
)
if not response.choices or response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
code = response.choices[0].message.content
self._log("Code generated successfully", "green")
return code
@@ -204,7 +205,8 @@ Follow best practices and coding standards."""
messages=messages,
**{k: v for k, v in kwargs.items() if k != "model"}
)
if not response.choices or response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
code = response.choices[0].message.content
self._log("Code generated successfully", "green")
return code
@@ -365,6 +367,8 @@ Provide clear, actionable feedback."""
**{k: v for k, v in kwargs.items() if k != "model"}
)
if not response.choices or response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
review = response.choices[0].message.content
self._log("Review completed", "green")
return review
@@ -405,6 +409,8 @@ Include:
**{k: v for k, v in kwargs.items() if k != "model"}
)
if not response.choices or response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
explanation = response.choices[0].message.content
self._log("Explanation completed", "green")
return explanation
@@ -451,6 +457,8 @@ Only output the refactored code."""
**{k: v for k, v in kwargs.items() if k != "model"}
)
if not response.choices or response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
refactored = response.choices[0].message.content
self._log("Refactoring completed", "green")
return refactored
@@ -495,6 +503,8 @@ Ensure the fix:
**{k: v for k, v in kwargs.items() if k != "model"}
)
if not response.choices or response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
fixed = response.choices[0].message.content
self._log("Fix completed", "green")
return fixed
@@ -126,7 +126,24 @@ class _AgentServerRegistry:
thread = threading.Thread(target=run_server, daemon=True)
thread.start()
ready_event.wait(timeout=5.0) # Deterministic wait instead of sleep(0.5)
# Check for configurable timeout via environment variable
try:
timeout = float(os.environ.get("PRAISONAI_SERVER_READY_TIMEOUT", "5.0"))
except ValueError:
logger.warning("Invalid PRAISONAI_SERVER_READY_TIMEOUT value. Using default 5.0s.")
timeout = 5.0
became_ready = ready_event.wait(timeout=timeout)
if not became_ready:
logger.warning(
"Agent server on port %s did not become ready within %.1fs. "
"Proceeding, but some features may not work correctly. "
"Check server logs for startup errors.",
port,
timeout,
)
return True
@@ -7,7 +7,7 @@ Inspired by Agno's SessionContextStore pattern.
from dataclasses import dataclass, field, asdict
from typing import Any, Dict, List, Optional
from datetime import datetime
from datetime import datetime, timezone
import json
import logging
from praisonaiagents._logging import get_logger
@@ -97,8 +97,8 @@ class SessionContextTracker:
self._state = SessionState(
session_id=self.session_id,
created_at=datetime.utcnow().isoformat() + "Z",
updated_at=datetime.utcnow().isoformat() + "Z",
created_at=datetime.now(timezone.utc).isoformat(),
updated_at=datetime.now(timezone.utc).isoformat(),
)
def _generate_session_id(self) -> str:
@@ -163,7 +163,7 @@ class SessionContextTracker:
def _mark_updated(self) -> None:
"""Update the updated_at timestamp."""
self._state.updated_at = datetime.utcnow().isoformat() + "Z"
self._state.updated_at = datetime.now(timezone.utc).isoformat()
self._state.turn_count += 1
def to_context_string(self) -> str:
@@ -257,7 +257,7 @@ Current messages take precedence if there's any conflict with this summary.
self._state = SessionState(
session_id=self.session_id,
created_at=self._state.created_at,
updated_at=datetime.utcnow().isoformat() + "Z",
updated_at=datetime.now(timezone.utc).isoformat(),
)
def to_dict(self) -> Dict[str, Any]:
@@ -12,7 +12,7 @@ import os
import logging
from praisonaiagents._logging import get_logger
from dataclasses import dataclass, field, asdict
from datetime import datetime
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
logger = get_logger(__name__)
@@ -39,7 +39,7 @@ class GradeResult:
input_text: str = ""
output: str = ""
expected_output: Optional[str] = None
timestamp: str = field(default_factory=lambda: datetime.utcnow().isoformat())
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
@@ -55,7 +55,7 @@ class GradeResult:
input_text=data.get("input_text", ""),
output=data.get("output", ""),
expected_output=data.get("expected_output"),
timestamp=data.get("timestamp", datetime.utcnow().isoformat()),
timestamp=data.get("timestamp", datetime.now(timezone.utc).isoformat()),
)
class BaseLLMGrader:
@@ -907,6 +907,8 @@ class OpenAIClient:
final_response = self._responses_to_chat_completion(raw)
# Display the response text if display_fn provided
if not final_response.choices or final_response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
response_text = final_response.choices[0].message.content or ""
if display_fn and response_text:
from rich.markdown import Markdown
@@ -1167,6 +1169,8 @@ class OpenAIClient:
final_response = self._responses_to_chat_completion(raw)
# Display the response text if display_fn provided
if not final_response.choices or final_response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
response_text = final_response.choices[0].message.content or ""
if display_fn and response_text:
from rich.markdown import Markdown
@@ -1638,8 +1642,10 @@ class OpenAIClient:
)
# Check for tool calls
if not final_response.choices or final_response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
tool_calls = getattr(final_response.choices[0].message, 'tool_calls', None)
# Emit llm_content for intermediate narrative display
# (gpt-4.1+ models produce text alongside tool calls)
response_content = getattr(final_response.choices[0].message, 'content', None)
@@ -1835,10 +1841,12 @@ class OpenAIClient:
if not final_response:
return None
# Check for tool calls
if not final_response.choices or final_response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
tool_calls = getattr(final_response.choices[0].message, 'tool_calls', None)
# Emit llm_content for intermediate narrative display
# (gpt-4.1+ models produce text alongside tool calls)
response_content = getattr(final_response.choices[0].message, 'content', None)
@@ -2037,6 +2045,8 @@ class OpenAIClient:
return
# Check for tool calls
if not final_response.choices or final_response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
tool_calls = getattr(final_response.choices[0].message, 'tool_calls', None)
if tool_calls and execute_tool_fn:
@@ -7,7 +7,7 @@ import threading
from typing import Any, Dict, List, Optional, Union, Literal
import logging
from praisonaiagents._logging import get_logger
from datetime import datetime
from datetime import datetime, timezone
# Decomposed memory functionality - imported as mixins for backward compatibility
from .storage import StorageMixin
@@ -692,7 +692,7 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
"_id": ident,
"content": text,
"metadata": metadata,
"created_at": datetime.utcnow(),
"created_at": datetime.now(timezone.utc),
"memory_type": "short_term"
}
self.mongo_short_term.insert_one(doc)
@@ -918,7 +918,7 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
"_id": ident,
"content": text,
"metadata": metadata,
"created_at": datetime.utcnow(),
"created_at": datetime.now(timezone.utc),
"memory_type": "long_term"
}
@@ -1441,14 +1441,14 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
self.mem0_client.add(text, user_id=user_id, metadata=meta)
elif self.use_mongodb and hasattr(self, "mongo_users"):
try:
from datetime import datetime
from datetime import datetime, timezone
ident = str(time.time_ns())
doc = {
"_id": ident,
"user_id": user_id,
"content": text,
"metadata": meta,
"created_at": datetime.utcnow()
"created_at": datetime.now(timezone.utc)
}
self.mongo_users.insert_one(doc)
self._log_verbose(f"Successfully stored user memory for {user_id}")
@@ -75,12 +75,12 @@ TOOL_MAPPINGS = {
# Python Tools
'execute_code': ('.python_tools', 'PythonTools'),
'analyze_code': ('.python_tools', 'PythonTools'),
'format_code': ('.python_tools', 'PythonTools'),
'lint_code': ('.python_tools', 'PythonTools'),
'disassemble_code': ('.python_tools', 'PythonTools'),
'python_tools': ('.python_tools', 'PythonTools'),
'execute_code': ('.python_tools', None),
'analyze_code': ('.python_tools', None),
'format_code': ('.python_tools', None),
'lint_code': ('.python_tools', None),
'disassemble_code': ('.python_tools', None),
'python_tools': ('.python_tools', None),
# Chain of Thought Training Tools
@@ -326,7 +326,7 @@ def __getattr__(name: str) -> Any:
if name in [
'duckduckgo', 'internet_search', 'searxng_search', 'searxng',
'scrape_page', 'extract_links', 'crawl', 'extract_text',
'execute_command', 'list_processes', 'kill_process', 'get_system_info',
'execute_command', 'execute_code', 'analyze_code', 'format_code', 'lint_code', 'disassemble_code', 'list_processes', 'kill_process', 'get_system_info',
'tavily', 'tavily_search', 'tavily_extract', 'tavily_crawl', 'tavily_map',
'tavily_search_async', 'tavily_extract_async',
'ydc', 'ydc_search', 'ydc_contents', 'ydc_news', 'ydc_images',
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.6.37"
version = "1.6.39"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -0,0 +1,128 @@
"""Test for agent server readiness timeout warning functionality."""
import time
import threading
import logging
import pytest
from unittest.mock import patch, MagicMock
from praisonaiagents.agents.agents import _AgentServerRegistry
def test_server_readiness_timeout_emits_warning(caplog):
"""A server that does not start in time must produce a WARNING log entry."""
registry = _AgentServerRegistry()
# Create a mock FastAPI app that won't signal readiness
mock_app = MagicMock()
# Register the app but don't set the ready event
with patch.dict(registry._apps, {8000: mock_app}):
with patch.dict(registry._ready_events, {8000: threading.Event()}):
with patch.dict(registry._started, {8000: False}):
with patch("threading.Thread.start"): # Prevent real server spawning
with caplog.at_level(logging.WARNING):
# Use a very short timeout to ensure the test doesn't hang
with patch.dict("os.environ", {"PRAISONAI_SERVER_READY_TIMEOUT": "0.1"}):
result = registry.start_server_if_needed(8000, host="127.0.0.1")
# The method should still return True but log a warning
assert result is True
assert "8000" in caplog.text
assert "did not become ready" in caplog.text.lower()
def test_server_readiness_timeout_configurable_via_env():
"""The timeout should be configurable via PRAISONAI_SERVER_READY_TIMEOUT."""
registry = _AgentServerRegistry()
# Create a mock FastAPI app
mock_app = MagicMock()
# Register the app but don't set the ready event
with patch.dict(registry._apps, {8001: mock_app}):
with patch.dict(registry._ready_events, {8001: threading.Event()}):
with patch.dict(registry._started, {8001: False}):
with patch("threading.Thread.start"): # Prevent real server spawning
# Test with custom timeout
with patch.dict("os.environ", {"PRAISONAI_SERVER_READY_TIMEOUT": "0.05"}):
start_time = time.time()
result = registry.start_server_if_needed(8001, host="127.0.0.1")
duration = time.time() - start_time
# Should respect the custom timeout and complete quickly
assert result is True
assert duration < 1.0 # Should be much less than default 5s
def test_server_readiness_success_no_warning(caplog):
"""A server that starts in time should not produce any warning."""
registry = _AgentServerRegistry()
# Create a mock FastAPI app
mock_app = MagicMock()
ready_event = threading.Event()
# Register the app and signal readiness immediately
with patch.dict(registry._apps, {8002: mock_app}):
with patch.dict(registry._ready_events, {8002: ready_event}):
with patch.dict(registry._started, {8002: False}):
with patch("threading.Thread.start"): # Prevent real server spawning
# Set the event immediately to simulate quick startup
ready_event.set()
with caplog.at_level(logging.WARNING):
result = registry.start_server_if_needed(8002, host="127.0.0.1")
# Should succeed without warnings
assert result is True
assert "did not become ready" not in caplog.text.lower()
def test_default_timeout_value(monkeypatch):
"""Test that the default timeout is 5.0 seconds when env var is not set."""
registry = _AgentServerRegistry()
# Create a mock FastAPI app
mock_app = MagicMock()
ready_event = threading.Event()
# Register the app but don't set the ready event
with patch.dict(registry._apps, {8003: mock_app}):
with patch.dict(registry._ready_events, {8003: ready_event}):
with patch.dict(registry._started, {8003: False}):
with patch("threading.Thread.start"): # Prevent real server spawning
# Remove only the specific env var so other env vars (PATH etc.) remain intact
monkeypatch.delenv("PRAISONAI_SERVER_READY_TIMEOUT", raising=False)
with patch("threading.Event.wait") as mock_wait:
mock_wait.return_value = False
result = registry.start_server_if_needed(8003, host="127.0.0.1")
# Should have called wait with default timeout of 5.0
mock_wait.assert_called_once_with(timeout=5.0)
assert result is True
def test_invalid_timeout_env_var_fallback(caplog):
"""Test that invalid PRAISONAI_SERVER_READY_TIMEOUT falls back to default with warning."""
registry = _AgentServerRegistry()
# Create a mock FastAPI app
mock_app = MagicMock()
ready_event = threading.Event()
# Register the app but don't set the ready event
with patch.dict(registry._apps, {8004: mock_app}):
with patch.dict(registry._ready_events, {8004: ready_event}):
with patch.dict(registry._started, {8004: False}):
with patch("threading.Thread.start"): # Prevent real server spawning
# Test with invalid timeout value
with patch.dict("os.environ", {"PRAISONAI_SERVER_READY_TIMEOUT": "invalid"}):
with caplog.at_level(logging.WARNING):
with patch("threading.Event.wait") as mock_wait:
mock_wait.return_value = False
result = registry.start_server_if_needed(8004, host="127.0.0.1")
# Should have fallen back to 5.0 and logged warning
mock_wait.assert_called_once_with(timeout=5.0)
assert result is True
assert "Invalid PRAISONAI_SERVER_READY_TIMEOUT" in caplog.text
@@ -21,6 +21,16 @@ import pytest
from unittest.mock import MagicMock
def _unregister_bot_platform(name: str) -> None:
"""Remove a runtime-registered bot platform (tests only)."""
from praisonai.bots._registry import get_default_bot_registry
try:
get_default_bot_registry().unregister(name.lower())
except (KeyError, ValueError):
pass
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Helper: Mock adapter that simulates a platform bot
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -59,9 +69,10 @@ class TestBotWithMockAdapter:
"""Integration tests for Bot class with a mock platform adapter."""
def setup_method(self):
from praisonai.bots._registry import register_platform, _custom_platforms
from praisonai.bots._registry import register_platform
register_platform("mockbot", MockPlatformAdapter)
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
self._cleanup = lambda: _unregister_bot_platform("mockbot")
def teardown_method(self):
self._cleanup()
@@ -148,10 +159,14 @@ class TestBotOSLifecycle:
"""Integration tests for BotOS multi-bot orchestration."""
def setup_method(self):
from praisonai.bots._registry import register_platform, _custom_platforms
from praisonai.bots._registry import register_platform
register_platform("mock1", MockPlatformAdapter)
register_platform("mock2", MockPlatformAdapter)
self._cleanup = lambda: (_custom_platforms.pop("mock1", None), _custom_platforms.pop("mock2", None))
self._cleanup = lambda: (
_unregister_bot_platform("mock1"),
_unregister_bot_platform("mock2"),
)
def teardown_method(self):
self._cleanup()
@@ -212,9 +227,10 @@ class TestRealAgentInBot:
"""Tests with a real praisonaiagents.Agent inside Bot."""
def setup_method(self):
from praisonai.bots._registry import register_platform, _custom_platforms
from praisonai.bots._registry import register_platform
register_platform("mockbot", MockPlatformAdapter)
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
self._cleanup = lambda: _unregister_bot_platform("mockbot")
def teardown_method(self):
self._cleanup()
@@ -246,9 +262,10 @@ class TestAgentTeamInBotOS:
"""AgentTeam (multi-agent) inside Bot and BotOS."""
def setup_method(self):
from praisonai.bots._registry import register_platform, _custom_platforms
from praisonai.bots._registry import register_platform
register_platform("mockbot", MockPlatformAdapter)
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
self._cleanup = lambda: _unregister_bot_platform("mockbot")
def teardown_method(self):
self._cleanup()
@@ -300,9 +317,10 @@ class TestAgentFlowInBotOS:
"""AgentFlow (deterministic workflow) inside Bot and BotOS."""
def setup_method(self):
from praisonai.bots._registry import register_platform, _custom_platforms
from praisonai.bots._registry import register_platform
register_platform("mockbot", MockPlatformAdapter)
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
self._cleanup = lambda: _unregister_bot_platform("mockbot")
def teardown_method(self):
self._cleanup()
@@ -355,9 +373,8 @@ class TestPlatformExtensibility:
"""Third-party platform registration and usage."""
def teardown_method(self):
from praisonai.bots._registry import _custom_platforms
_custom_platforms.pop("custom_chat", None)
_custom_platforms.pop("my_platform", None)
_unregister_bot_platform("custom_chat")
_unregister_bot_platform("my_platform")
def test_register_and_use_custom_platform(self):
from praisonai.bots._registry import register_platform, resolve_adapter
@@ -389,7 +406,7 @@ class TestPlatformExtensibility:
def test_resolve_unknown_platform_raises(self):
from praisonai.bots._registry import resolve_adapter
with pytest.raises(ValueError, match="Unknown platform"):
with pytest.raises(ValueError, match=r"Unknown .*plugin"):
resolve_adapter("nonexistent_xyz")
@@ -401,9 +418,10 @@ class TestBotOSFromConfig:
"""BotOS.from_config() YAML config loading."""
def setup_method(self):
from praisonai.bots._registry import register_platform, _custom_platforms
from praisonai.bots._registry import register_platform
register_platform("mockbot", MockPlatformAdapter)
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
self._cleanup = lambda: _unregister_bot_platform("mockbot")
def teardown_method(self):
self._cleanup()
@@ -459,7 +477,8 @@ platforms:
def test_from_config_multi_platform(self):
from praisonai.bots import BotOS
from praisonai.bots._registry import register_platform, _custom_platforms
from praisonai.bots._registry import register_platform
register_platform("mock2", MockPlatformAdapter)
yaml_content = """\
@@ -484,7 +503,7 @@ platforms:
assert "mock2" in botos.list_bots()
finally:
os.unlink(path)
_custom_platforms.pop("mock2", None)
_unregister_bot_platform("mock2")
def test_from_config_invalid_raises(self):
from praisonai.bots import BotOS
@@ -361,3 +361,11 @@ class TestProfileExports:
from praisonaiagents.tools import AUTONOMY_PROFILE
assert AUTONOMY_PROFILE is not None
def test_import_execute_code_uses_module_function(self):
"""execute_code should resolve to python_tools.execute_code function."""
from praisonaiagents.tools import execute_code
from praisonaiagents.tools.python_tools import execute_code as execute_code_impl
assert callable(execute_code)
assert execute_code is execute_code_impl
+204 -171
View File
@@ -21,16 +21,16 @@ wheels = [
[[package]]
name = "aiohappyeyeballs"
version = "2.4.4"
version = "2.6.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7f/55/e4373e888fdacb15563ef6fa9fa8c8252476ea071e96fb46defac9f18bf2/aiohappyeyeballs-2.4.4.tar.gz", hash = "sha256:5fdd7d87889c63183afc18ce9271f9b0a7d32c2303e394468dd45d514a757745", size = 21977, upload-time = "2024-11-30T18:44:00.701Z" }
sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/74/fbb6559de3607b3300b9be3cc64e97548d55678e44623db17820dbd20002/aiohappyeyeballs-2.4.4-py3-none-any.whl", hash = "sha256:a980909d50efcd44795c4afeca523296716d50cd756ddca6af8c65b996e27de8", size = 14756, upload-time = "2024-11-30T18:43:39.849Z" },
{ url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" },
]
[[package]]
name = "aiohttp"
version = "3.11.11"
version = "3.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
@@ -42,80 +42,123 @@ dependencies = [
{ name = "propcache" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fe/ed/f26db39d29cd3cb2f5a3374304c713fe5ab5a0e4c8ee25a0c45cc6adf844/aiohttp-3.11.11.tar.gz", hash = "sha256:bb49c7f1e6ebf3821a42d81d494f538107610c3a705987f53068546b0e90303e", size = 7669618, upload-time = "2024-12-18T21:20:50.191Z" }
sdist = { url = "https://files.pythonhosted.org/packages/45/4a/064321452809dae953c1ed6e017504e72551a26b6f5708a5a80e4bf556ff/aiohttp-3.13.4.tar.gz", hash = "sha256:d97a6d09c66087890c2ab5d49069e1e570583f7ac0314ecf98294c1b6aaebd38", size = 7859748, upload-time = "2026-03-28T17:19:40.6Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/75/7d/ff2e314b8f9e0b1df833e2d4778eaf23eae6b8cc8f922495d110ddcbf9e1/aiohttp-3.11.11-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a60804bff28662cbcf340a4d61598891f12eea3a66af48ecfdc975ceec21e3c8", size = 708550, upload-time = "2024-12-18T21:17:26.358Z" },
{ url = "https://files.pythonhosted.org/packages/09/b8/aeb4975d5bba233d6f246941f5957a5ad4e3def8b0855a72742e391925f2/aiohttp-3.11.11-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b4fa1cb5f270fb3eab079536b764ad740bb749ce69a94d4ec30ceee1b5940d5", size = 468430, upload-time = "2024-12-18T21:17:30.826Z" },
{ url = "https://files.pythonhosted.org/packages/9c/5b/5b620279b3df46e597008b09fa1e10027a39467387c2332657288e25811a/aiohttp-3.11.11-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:731468f555656767cda219ab42e033355fe48c85fbe3ba83a349631541715ba2", size = 455593, upload-time = "2024-12-18T21:17:34.195Z" },
{ url = "https://files.pythonhosted.org/packages/d8/75/0cdf014b816867d86c0bc26f3d3e3f194198dbf33037890beed629cd4f8f/aiohttp-3.11.11-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb23d8bb86282b342481cad4370ea0853a39e4a32a0042bb52ca6bdde132df43", size = 1584635, upload-time = "2024-12-18T21:17:37.288Z" },
{ url = "https://files.pythonhosted.org/packages/df/2f/95b8f4e4dfeb57c1d9ad9fa911ede35a0249d75aa339edd2c2270dc539da/aiohttp-3.11.11-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f047569d655f81cb70ea5be942ee5d4421b6219c3f05d131f64088c73bb0917f", size = 1632363, upload-time = "2024-12-18T21:17:40.821Z" },
{ url = "https://files.pythonhosted.org/packages/39/cb/70cf69ea7c50f5b0021a84f4c59c3622b2b3b81695f48a2f0e42ef7eba6e/aiohttp-3.11.11-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd7659baae9ccf94ae5fe8bfaa2c7bc2e94d24611528395ce88d009107e00c6d", size = 1668315, upload-time = "2024-12-18T21:17:42.574Z" },
{ url = "https://files.pythonhosted.org/packages/2f/cc/3a3fc7a290eabc59839a7e15289cd48f33dd9337d06e301064e1e7fb26c5/aiohttp-3.11.11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af01e42ad87ae24932138f154105e88da13ce7d202a6de93fafdafb2883a00ef", size = 1589546, upload-time = "2024-12-18T21:17:45.477Z" },
{ url = "https://files.pythonhosted.org/packages/15/b4/0f7b0ed41ac6000e283e7332f0f608d734b675a8509763ca78e93714cfb0/aiohttp-3.11.11-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5854be2f3e5a729800bac57a8d76af464e160f19676ab6aea74bde18ad19d438", size = 1544581, upload-time = "2024-12-18T21:17:48.475Z" },
{ url = "https://files.pythonhosted.org/packages/58/b9/4d06470fd85c687b6b0e31935ef73dde6e31767c9576d617309a2206556f/aiohttp-3.11.11-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6526e5fb4e14f4bbf30411216780c9967c20c5a55f2f51d3abd6de68320cc2f3", size = 1529256, upload-time = "2024-12-18T21:17:51.356Z" },
{ url = "https://files.pythonhosted.org/packages/61/a2/6958b1b880fc017fd35f5dfb2c26a9a50c755b75fd9ae001dc2236a4fb79/aiohttp-3.11.11-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:85992ee30a31835fc482468637b3e5bd085fa8fe9392ba0bdcbdc1ef5e9e3c55", size = 1536592, upload-time = "2024-12-18T21:17:54.164Z" },
{ url = "https://files.pythonhosted.org/packages/0f/dd/b974012a9551fd654f5bb95a6dd3f03d6e6472a17e1a8216dd42e9638d6c/aiohttp-3.11.11-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:88a12ad8ccf325a8a5ed80e6d7c3bdc247d66175afedbe104ee2aaca72960d8e", size = 1607446, upload-time = "2024-12-18T21:17:57.109Z" },
{ url = "https://files.pythonhosted.org/packages/e0/d3/6c98fd87e638e51f074a3f2061e81fcb92123bcaf1439ac1b4a896446e40/aiohttp-3.11.11-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0a6d3fbf2232e3a08c41eca81ae4f1dff3d8f1a30bae415ebe0af2d2458b8a33", size = 1628809, upload-time = "2024-12-18T21:17:59.931Z" },
{ url = "https://files.pythonhosted.org/packages/a8/2e/86e6f85cbca02be042c268c3d93e7f35977a0e127de56e319bdd1569eaa8/aiohttp-3.11.11-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:84a585799c58b795573c7fa9b84c455adf3e1d72f19a2bf498b54a95ae0d194c", size = 1564291, upload-time = "2024-12-18T21:18:02.828Z" },
{ url = "https://files.pythonhosted.org/packages/0b/8d/1f4ef3503b767717f65e1f5178b0173ab03cba1a19997ebf7b052161189f/aiohttp-3.11.11-cp310-cp310-win32.whl", hash = "sha256:bfde76a8f430cf5c5584553adf9926534352251d379dcb266ad2b93c54a29745", size = 416601, upload-time = "2024-12-18T21:18:05.872Z" },
{ url = "https://files.pythonhosted.org/packages/ad/86/81cb83691b5ace3d9aa148dc42bacc3450d749fc88c5ec1973573c1c1779/aiohttp-3.11.11-cp310-cp310-win_amd64.whl", hash = "sha256:0fd82b8e9c383af11d2b26f27a478640b6b83d669440c0a71481f7c865a51da9", size = 442007, upload-time = "2024-12-18T21:18:07.593Z" },
{ url = "https://files.pythonhosted.org/packages/34/ae/e8806a9f054e15f1d18b04db75c23ec38ec954a10c0a68d3bd275d7e8be3/aiohttp-3.11.11-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ba74ec819177af1ef7f59063c6d35a214a8fde6f987f7661f4f0eecc468a8f76", size = 708624, upload-time = "2024-12-18T21:18:10.575Z" },
{ url = "https://files.pythonhosted.org/packages/c7/e0/313ef1a333fb4d58d0c55a6acb3cd772f5d7756604b455181049e222c020/aiohttp-3.11.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4af57160800b7a815f3fe0eba9b46bf28aafc195555f1824555fa2cfab6c1538", size = 468507, upload-time = "2024-12-18T21:18:12.224Z" },
{ url = "https://files.pythonhosted.org/packages/a9/60/03455476bf1f467e5b4a32a465c450548b2ce724eec39d69f737191f936a/aiohttp-3.11.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ffa336210cf9cd8ed117011085817d00abe4c08f99968deef0013ea283547204", size = 455571, upload-time = "2024-12-18T21:18:15.506Z" },
{ url = "https://files.pythonhosted.org/packages/be/f9/469588603bd75bf02c8ffb8c8a0d4b217eed446b49d4a767684685aa33fd/aiohttp-3.11.11-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81b8fe282183e4a3c7a1b72f5ade1094ed1c6345a8f153506d114af5bf8accd9", size = 1685694, upload-time = "2024-12-18T21:18:17.512Z" },
{ url = "https://files.pythonhosted.org/packages/88/b9/1b7fa43faf6c8616fa94c568dc1309ffee2b6b68b04ac268e5d64b738688/aiohttp-3.11.11-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3af41686ccec6a0f2bdc66686dc0f403c41ac2089f80e2214a0f82d001052c03", size = 1743660, upload-time = "2024-12-18T21:18:20.878Z" },
{ url = "https://files.pythonhosted.org/packages/2a/8b/0248d19dbb16b67222e75f6aecedd014656225733157e5afaf6a6a07e2e8/aiohttp-3.11.11-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:70d1f9dde0e5dd9e292a6d4d00058737052b01f3532f69c0c65818dac26dc287", size = 1785421, upload-time = "2024-12-18T21:18:22.948Z" },
{ url = "https://files.pythonhosted.org/packages/c4/11/f478e071815a46ca0a5ae974651ff0c7a35898c55063305a896e58aa1247/aiohttp-3.11.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:249cc6912405917344192b9f9ea5cd5b139d49e0d2f5c7f70bdfaf6b4dbf3a2e", size = 1675145, upload-time = "2024-12-18T21:18:24.788Z" },
{ url = "https://files.pythonhosted.org/packages/26/5d/284d182fecbb5075ae10153ff7374f57314c93a8681666600e3a9e09c505/aiohttp-3.11.11-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0eb98d90b6690827dcc84c246811feeb4e1eea683c0eac6caed7549be9c84665", size = 1619804, upload-time = "2024-12-18T21:18:26.602Z" },
{ url = "https://files.pythonhosted.org/packages/1b/78/980064c2ad685c64ce0e8aeeb7ef1e53f43c5b005edcd7d32e60809c4992/aiohttp-3.11.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ec82bf1fda6cecce7f7b915f9196601a1bd1a3079796b76d16ae4cce6d0ef89b", size = 1654007, upload-time = "2024-12-18T21:18:29.669Z" },
{ url = "https://files.pythonhosted.org/packages/21/8d/9e658d63b1438ad42b96f94da227f2e2c1d5c6001c9e8ffcc0bfb22e9105/aiohttp-3.11.11-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9fd46ce0845cfe28f108888b3ab17abff84ff695e01e73657eec3f96d72eef34", size = 1650022, upload-time = "2024-12-18T21:18:33.249Z" },
{ url = "https://files.pythonhosted.org/packages/85/fd/a032bf7f2755c2df4f87f9effa34ccc1ef5cea465377dbaeef93bb56bbd6/aiohttp-3.11.11-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd176afcf8f5d2aed50c3647d4925d0db0579d96f75a31e77cbaf67d8a87742d", size = 1732899, upload-time = "2024-12-18T21:18:35.225Z" },
{ url = "https://files.pythonhosted.org/packages/c5/0c/c2b85fde167dd440c7ba50af2aac20b5a5666392b174df54c00f888c5a75/aiohttp-3.11.11-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ec2aa89305006fba9ffb98970db6c8221541be7bee4c1d027421d6f6df7d1ce2", size = 1755142, upload-time = "2024-12-18T21:18:37.48Z" },
{ url = "https://files.pythonhosted.org/packages/bc/78/91ae1a3b3b3bed8b893c5d69c07023e151b1c95d79544ad04cf68f596c2f/aiohttp-3.11.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:92cde43018a2e17d48bb09c79e4d4cb0e236de5063ce897a5e40ac7cb4878773", size = 1692736, upload-time = "2024-12-18T21:18:40.967Z" },
{ url = "https://files.pythonhosted.org/packages/77/89/a7ef9c4b4cdb546fcc650ca7f7395aaffbd267f0e1f648a436bec33c9b95/aiohttp-3.11.11-cp311-cp311-win32.whl", hash = "sha256:aba807f9569455cba566882c8938f1a549f205ee43c27b126e5450dc9f83cc62", size = 416418, upload-time = "2024-12-18T21:18:44.281Z" },
{ url = "https://files.pythonhosted.org/packages/fc/db/2192489a8a51b52e06627506f8ac8df69ee221de88ab9bdea77aa793aa6a/aiohttp-3.11.11-cp311-cp311-win_amd64.whl", hash = "sha256:ae545f31489548c87b0cced5755cfe5a5308d00407000e72c4fa30b19c3220ac", size = 442509, upload-time = "2024-12-18T21:18:47.323Z" },
{ url = "https://files.pythonhosted.org/packages/69/cf/4bda538c502f9738d6b95ada11603c05ec260807246e15e869fc3ec5de97/aiohttp-3.11.11-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e595c591a48bbc295ebf47cb91aebf9bd32f3ff76749ecf282ea7f9f6bb73886", size = 704666, upload-time = "2024-12-18T21:18:49.254Z" },
{ url = "https://files.pythonhosted.org/packages/46/7b/87fcef2cad2fad420ca77bef981e815df6904047d0a1bd6aeded1b0d1d66/aiohttp-3.11.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ea1b59dc06396b0b424740a10a0a63974c725b1c64736ff788a3689d36c02d2", size = 464057, upload-time = "2024-12-18T21:18:51.375Z" },
{ url = "https://files.pythonhosted.org/packages/5a/a6/789e1f17a1b6f4a38939fbc39d29e1d960d5f89f73d0629a939410171bc0/aiohttp-3.11.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8811f3f098a78ffa16e0ea36dffd577eb031aea797cbdba81be039a4169e242c", size = 455996, upload-time = "2024-12-18T21:18:53.11Z" },
{ url = "https://files.pythonhosted.org/packages/b7/dd/485061fbfef33165ce7320db36e530cd7116ee1098e9c3774d15a732b3fd/aiohttp-3.11.11-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7227b87a355ce1f4bf83bfae4399b1f5bb42e0259cb9405824bd03d2f4336a", size = 1682367, upload-time = "2024-12-18T21:18:55.053Z" },
{ url = "https://files.pythonhosted.org/packages/e9/d7/9ec5b3ea9ae215c311d88b2093e8da17e67b8856673e4166c994e117ee3e/aiohttp-3.11.11-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d40f9da8cabbf295d3a9dae1295c69975b86d941bc20f0a087f0477fa0a66231", size = 1736989, upload-time = "2024-12-18T21:18:56.933Z" },
{ url = "https://files.pythonhosted.org/packages/d6/fb/ea94927f7bfe1d86178c9d3e0a8c54f651a0a655214cce930b3c679b8f64/aiohttp-3.11.11-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ffb3dc385f6bb1568aa974fe65da84723210e5d9707e360e9ecb51f59406cd2e", size = 1793265, upload-time = "2024-12-18T21:19:00.174Z" },
{ url = "https://files.pythonhosted.org/packages/40/7f/6de218084f9b653026bd7063cd8045123a7ba90c25176465f266976d8c82/aiohttp-3.11.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8f5f7515f3552d899c61202d99dcb17d6e3b0de777900405611cd747cecd1b8", size = 1691841, upload-time = "2024-12-18T21:19:02.3Z" },
{ url = "https://files.pythonhosted.org/packages/77/e2/992f43d87831cbddb6b09c57ab55499332f60ad6fdbf438ff4419c2925fc/aiohttp-3.11.11-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3499c7ffbfd9c6a3d8d6a2b01c26639da7e43d47c7b4f788016226b1e711caa8", size = 1619317, upload-time = "2024-12-18T21:19:04.33Z" },
{ url = "https://files.pythonhosted.org/packages/96/74/879b23cdd816db4133325a201287c95bef4ce669acde37f8f1b8669e1755/aiohttp-3.11.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8e2bf8029dbf0810c7bfbc3e594b51c4cc9101fbffb583a3923aea184724203c", size = 1641416, upload-time = "2024-12-18T21:19:09.842Z" },
{ url = "https://files.pythonhosted.org/packages/30/98/b123f6b15d87c54e58fd7ae3558ff594f898d7f30a90899718f3215ad328/aiohttp-3.11.11-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b6212a60e5c482ef90f2d788835387070a88d52cf6241d3916733c9176d39eab", size = 1646514, upload-time = "2024-12-18T21:19:12.154Z" },
{ url = "https://files.pythonhosted.org/packages/d7/38/257fda3dc99d6978ab943141d5165ec74fd4b4164baa15e9c66fa21da86b/aiohttp-3.11.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d119fafe7b634dbfa25a8c597718e69a930e4847f0b88e172744be24515140da", size = 1702095, upload-time = "2024-12-18T21:19:15.51Z" },
{ url = "https://files.pythonhosted.org/packages/0c/f4/ddab089053f9fb96654df5505c0a69bde093214b3c3454f6bfdb1845f558/aiohttp-3.11.11-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:6fba278063559acc730abf49845d0e9a9e1ba74f85f0ee6efd5803f08b285853", size = 1734611, upload-time = "2024-12-18T21:19:18.849Z" },
{ url = "https://files.pythonhosted.org/packages/c3/d6/f30b2bc520c38c8aa4657ed953186e535ae84abe55c08d0f70acd72ff577/aiohttp-3.11.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:92fc484e34b733704ad77210c7957679c5c3877bd1e6b6d74b185e9320cc716e", size = 1694576, upload-time = "2024-12-18T21:19:21.257Z" },
{ url = "https://files.pythonhosted.org/packages/bc/97/b0a88c3f4c6d0020b34045ee6d954058abc870814f6e310c4c9b74254116/aiohttp-3.11.11-cp312-cp312-win32.whl", hash = "sha256:9f5b3c1ed63c8fa937a920b6c1bec78b74ee09593b3f5b979ab2ae5ef60d7600", size = 411363, upload-time = "2024-12-18T21:19:23.122Z" },
{ url = "https://files.pythonhosted.org/packages/7f/23/cc36d9c398980acaeeb443100f0216f50a7cfe20c67a9fd0a2f1a5a846de/aiohttp-3.11.11-cp312-cp312-win_amd64.whl", hash = "sha256:1e69966ea6ef0c14ee53ef7a3d68b564cc408121ea56c0caa2dc918c1b2f553d", size = 437666, upload-time = "2024-12-18T21:19:26.425Z" },
{ url = "https://files.pythonhosted.org/packages/49/d1/d8af164f400bad432b63e1ac857d74a09311a8334b0481f2f64b158b50eb/aiohttp-3.11.11-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:541d823548ab69d13d23730a06f97460f4238ad2e5ed966aaf850d7c369782d9", size = 697982, upload-time = "2024-12-18T21:19:28.454Z" },
{ url = "https://files.pythonhosted.org/packages/92/d1/faad3bf9fa4bfd26b95c69fc2e98937d52b1ff44f7e28131855a98d23a17/aiohttp-3.11.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:929f3ed33743a49ab127c58c3e0a827de0664bfcda566108989a14068f820194", size = 460662, upload-time = "2024-12-18T21:19:31.077Z" },
{ url = "https://files.pythonhosted.org/packages/db/61/0d71cc66d63909dabc4590f74eba71f91873a77ea52424401c2498d47536/aiohttp-3.11.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0882c2820fd0132240edbb4a51eb8ceb6eef8181db9ad5291ab3332e0d71df5f", size = 452950, upload-time = "2024-12-18T21:19:33.108Z" },
{ url = "https://files.pythonhosted.org/packages/07/db/6d04bc7fd92784900704e16b745484ef45b77bd04e25f58f6febaadf7983/aiohttp-3.11.11-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b63de12e44935d5aca7ed7ed98a255a11e5cb47f83a9fded7a5e41c40277d104", size = 1665178, upload-time = "2024-12-18T21:19:36.556Z" },
{ url = "https://files.pythonhosted.org/packages/54/5c/e95ade9ae29f375411884d9fd98e50535bf9fe316c9feb0f30cd2ac8f508/aiohttp-3.11.11-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aa54f8ef31d23c506910c21163f22b124facb573bff73930735cf9fe38bf7dff", size = 1717939, upload-time = "2024-12-18T21:19:40.081Z" },
{ url = "https://files.pythonhosted.org/packages/6f/1c/1e7d5c5daea9e409ed70f7986001b8c9e3a49a50b28404498d30860edab6/aiohttp-3.11.11-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a344d5dc18074e3872777b62f5f7d584ae4344cd6006c17ba12103759d407af3", size = 1775125, upload-time = "2024-12-18T21:19:43.578Z" },
{ url = "https://files.pythonhosted.org/packages/5d/66/890987e44f7d2f33a130e37e01a164168e6aff06fce15217b6eaf14df4f6/aiohttp-3.11.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b7fb429ab1aafa1f48578eb315ca45bd46e9c37de11fe45c7f5f4138091e2f1", size = 1677176, upload-time = "2024-12-18T21:19:46.239Z" },
{ url = "https://files.pythonhosted.org/packages/8f/dc/e2ba57d7a52df6cdf1072fd5fa9c6301a68e1cd67415f189805d3eeb031d/aiohttp-3.11.11-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c341c7d868750e31961d6d8e60ff040fb9d3d3a46d77fd85e1ab8e76c3e9a5c4", size = 1603192, upload-time = "2024-12-18T21:19:48.341Z" },
{ url = "https://files.pythonhosted.org/packages/6c/9e/8d08a57de79ca3a358da449405555e668f2c8871a7777ecd2f0e3912c272/aiohttp-3.11.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ed9ee95614a71e87f1a70bc81603f6c6760128b140bc4030abe6abaa988f1c3d", size = 1618296, upload-time = "2024-12-18T21:19:50.479Z" },
{ url = "https://files.pythonhosted.org/packages/56/51/89822e3ec72db352c32e7fc1c690370e24e231837d9abd056490f3a49886/aiohttp-3.11.11-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:de8d38f1c2810fa2a4f1d995a2e9c70bb8737b18da04ac2afbf3971f65781d87", size = 1616524, upload-time = "2024-12-18T21:19:52.542Z" },
{ url = "https://files.pythonhosted.org/packages/2c/fa/e2e6d9398f462ffaa095e84717c1732916a57f1814502929ed67dd7568ef/aiohttp-3.11.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a9b7371665d4f00deb8f32208c7c5e652059b0fda41cf6dbcac6114a041f1cc2", size = 1685471, upload-time = "2024-12-18T21:19:54.683Z" },
{ url = "https://files.pythonhosted.org/packages/ae/5f/6bb976e619ca28a052e2c0ca7b0251ccd893f93d7c24a96abea38e332bf6/aiohttp-3.11.11-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:620598717fce1b3bd14dd09947ea53e1ad510317c85dda2c9c65b622edc96b12", size = 1715312, upload-time = "2024-12-18T21:19:56.824Z" },
{ url = "https://files.pythonhosted.org/packages/79/c1/756a7e65aa087c7fac724d6c4c038f2faaa2a42fe56dbc1dd62a33ca7213/aiohttp-3.11.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf8d9bfee991d8acc72d060d53860f356e07a50f0e0d09a8dfedea1c554dd0d5", size = 1672783, upload-time = "2024-12-18T21:19:59.314Z" },
{ url = "https://files.pythonhosted.org/packages/73/ba/a6190ebb02176c7f75e6308da31f5d49f6477b651a3dcfaaaca865a298e2/aiohttp-3.11.11-cp313-cp313-win32.whl", hash = "sha256:9d73ee3725b7a737ad86c2eac5c57a4a97793d9f442599bea5ec67ac9f4bdc3d", size = 410229, upload-time = "2024-12-18T21:20:02.469Z" },
{ url = "https://files.pythonhosted.org/packages/b8/62/c9fa5bafe03186a0e4699150a7fed9b1e73240996d0d2f0e5f70f3fdf471/aiohttp-3.11.11-cp313-cp313-win_amd64.whl", hash = "sha256:c7a06301c2fb096bdb0bd25fe2011531c1453b9f2c163c8031600ec73af1cc99", size = 436081, upload-time = "2024-12-18T21:20:04.557Z" },
{ url = "https://files.pythonhosted.org/packages/2c/05/6817e0390eb47b0867cf8efdb535298191662192281bc3ca62a0cb7973eb/aiohttp-3.13.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6290fe12fe8cefa6ea3c1c5b969d32c010dfe191d4392ff9b599a3f473cbe722", size = 753094, upload-time = "2026-03-28T17:14:59.928Z" },
{ url = "https://files.pythonhosted.org/packages/b4/c1/e5b7f25f6dd1ab57da92aa9d226b2c8b56f223dd20475d3ddfddaba86ab8/aiohttp-3.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7520d92c0e8fbbe63f36f20a5762db349ff574ad38ad7bc7732558a650439845", size = 505213, upload-time = "2026-03-28T17:15:01.989Z" },
{ url = "https://files.pythonhosted.org/packages/b4/e5/8f42033c7ce98b54dfd3791f03e60231cfe4a2db4471b5fc188df2b8a6ad/aiohttp-3.13.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d2710ae1e1b81d0f187883b6e9d66cecf8794b50e91aa1e73fc78bfb5503b5d9", size = 498580, upload-time = "2026-03-28T17:15:03.879Z" },
{ url = "https://files.pythonhosted.org/packages/8c/a4/bbc989f5362066b81930da1a66084a859a971d03faab799dc59a3ce3a220/aiohttp-3.13.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:717d17347567ded1e273aa09918650dfd6fd06f461549204570c7973537d4123", size = 1692718, upload-time = "2026-03-28T17:15:05.541Z" },
{ url = "https://files.pythonhosted.org/packages/1c/72/3775116969931f151be116689d2ae6ddafff2ec2887d8f9b4e7043f32e74/aiohttp-3.13.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:383880f7b8de5ac208fa829c7038d08e66377283b2de9e791b71e06e803153c2", size = 1660714, upload-time = "2026-03-28T17:15:08.23Z" },
{ url = "https://files.pythonhosted.org/packages/a1/e8/d2f1a2da2743e32fe348ebf8a4c59caad14a92f5f18af616fd33381275e1/aiohttp-3.13.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1867087e2c1963db1216aedf001efe3b129835ed2b05d97d058176a6d08b5726", size = 1744152, upload-time = "2026-03-28T17:15:10.828Z" },
{ url = "https://files.pythonhosted.org/packages/4c/a6/575886f417ac3c08e462f2ca237cc49f436bd992ca3f7ff95b7dd9c44205/aiohttp-3.13.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6234bf416a38d687c3ab7f79934d7fb2a42117a5b9813aca07de0a5398489023", size = 1836278, upload-time = "2026-03-28T17:15:12.537Z" },
{ url = "https://files.pythonhosted.org/packages/4a/4c/0051d4550fb9e8b5ca4e0fe1ccd58652340915180c5164999e6741bf2083/aiohttp-3.13.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3cdd3393130bf6588962441ffd5bde1d3ea2d63a64afa7119b3f3ba349cebbe7", size = 1687953, upload-time = "2026-03-28T17:15:14.248Z" },
{ url = "https://files.pythonhosted.org/packages/c9/54/841e87b8c51c2adc01a3ceb9919dc45c7899fe4c21deb70aada734ea5a38/aiohttp-3.13.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d0dbc6c76befa76865373d6aa303e480bb8c3486e7763530f7f6e527b471118", size = 1572484, upload-time = "2026-03-28T17:15:15.911Z" },
{ url = "https://files.pythonhosted.org/packages/da/f1/21cbf5f7fa1e267af6301f886cab9b314f085e4d0097668d189d165cd7da/aiohttp-3.13.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:10fb7b53262cf4144a083c9db0d2b4d22823d6708270a9970c4627b248c6064c", size = 1662851, upload-time = "2026-03-28T17:15:17.822Z" },
{ url = "https://files.pythonhosted.org/packages/40/15/bcad6b68d7bef27ae7443288215767263c7753ede164267cf6cf63c94a87/aiohttp-3.13.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:eb10ce8c03850e77f4d9518961c227be569e12f71525a7e90d17bca04299921d", size = 1671984, upload-time = "2026-03-28T17:15:19.561Z" },
{ url = "https://files.pythonhosted.org/packages/ff/fa/ab316931afc7a73c7f493bb1b30fbd61e28ec2d3ea50353336e76293e8ec/aiohttp-3.13.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7c65738ac5ae32b8feef699a4ed0dc91a0c8618b347781b7461458bbcaaac7eb", size = 1713880, upload-time = "2026-03-28T17:15:21.589Z" },
{ url = "https://files.pythonhosted.org/packages/1c/45/314e8e64c7f328174964b6db511dd5e9e60c9121ab5457bc2c908b7d03a4/aiohttp-3.13.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:6b335919ffbaf98df8ff3c74f7a6decb8775882632952fd1810a017e38f15aee", size = 1560315, upload-time = "2026-03-28T17:15:23.66Z" },
{ url = "https://files.pythonhosted.org/packages/18/e7/93d5fa06fe00219a81466577dacae9e3732f3b4f767b12b2e2cc8c35c970/aiohttp-3.13.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:ec75fc18cb9f4aca51c2cbace20cf6716e36850f44189644d2d69a875d5e0532", size = 1735115, upload-time = "2026-03-28T17:15:25.77Z" },
{ url = "https://files.pythonhosted.org/packages/19/9f/f64b95392ddd4e204fd9ab7cd33dd18d14ac9e4b86866f1f6a69b7cda83d/aiohttp-3.13.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:463fa18a95c5a635d2b8c09babe240f9d7dbf2a2010a6c0b35d8c4dff2a0e819", size = 1673916, upload-time = "2026-03-28T17:15:27.526Z" },
{ url = "https://files.pythonhosted.org/packages/52/c1/bb33be79fd285c69f32e5b074b299cae8847f748950149c3965c1b3b3adf/aiohttp-3.13.4-cp310-cp310-win32.whl", hash = "sha256:13168f5645d9045522c6cef818f54295376257ed8d02513a37c2ef3046fc7a97", size = 440277, upload-time = "2026-03-28T17:15:29.173Z" },
{ url = "https://files.pythonhosted.org/packages/23/f9/7cf1688da4dd0885f914ee40bc8e1dce776df98fe6518766de975a570538/aiohttp-3.13.4-cp310-cp310-win_amd64.whl", hash = "sha256:a7058af1f53209fdf07745579ced525d38d481650a989b7aa4a3b484b901cdab", size = 463015, upload-time = "2026-03-28T17:15:30.802Z" },
{ url = "https://files.pythonhosted.org/packages/d4/7e/cb94129302d78c46662b47f9897d642fd0b33bdfef4b73b20c6ced35aa4c/aiohttp-3.13.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8ea0c64d1bcbf201b285c2246c51a0c035ba3bbd306640007bc5844a3b4658c1", size = 760027, upload-time = "2026-03-28T17:15:33.022Z" },
{ url = "https://files.pythonhosted.org/packages/5e/cd/2db3c9397c3bd24216b203dd739945b04f8b87bb036c640da7ddb63c75ef/aiohttp-3.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6f742e1fa45c0ed522b00ede565e18f97e4cf8d1883a712ac42d0339dfb0cce7", size = 508325, upload-time = "2026-03-28T17:15:34.714Z" },
{ url = "https://files.pythonhosted.org/packages/36/a3/d28b2722ec13107f2e37a86b8a169897308bab6a3b9e071ecead9d67bd9b/aiohttp-3.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dcfb50ee25b3b7a1222a9123be1f9f89e56e67636b561441f0b304e25aaef8f", size = 502402, upload-time = "2026-03-28T17:15:36.409Z" },
{ url = "https://files.pythonhosted.org/packages/fa/d6/acd47b5f17c4430e555590990a4746efbcb2079909bb865516892bf85f37/aiohttp-3.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3262386c4ff370849863ea93b9ea60fd59c6cf56bf8f93beac625cf4d677c04d", size = 1771224, upload-time = "2026-03-28T17:15:38.223Z" },
{ url = "https://files.pythonhosted.org/packages/98/af/af6e20113ba6a48fd1cd9e5832c4851e7613ef50c7619acdaee6ec5f1aff/aiohttp-3.13.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:473bb5aa4218dd254e9ae4834f20e31f5a0083064ac0136a01a62ddbae2eaa42", size = 1731530, upload-time = "2026-03-28T17:15:39.988Z" },
{ url = "https://files.pythonhosted.org/packages/81/16/78a2f5d9c124ad05d5ce59a9af94214b6466c3491a25fb70760e98e9f762/aiohttp-3.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56423766399b4c77b965f6aaab6c9546617b8994a956821cc507d00b91d978c", size = 1827925, upload-time = "2026-03-28T17:15:41.944Z" },
{ url = "https://files.pythonhosted.org/packages/2a/1f/79acf0974ced805e0e70027389fccbb7d728e6f30fcac725fb1071e63075/aiohttp-3.13.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8af249343fafd5ad90366a16d230fc265cf1149f26075dc9fe93cfd7c7173942", size = 1923579, upload-time = "2026-03-28T17:15:44.071Z" },
{ url = "https://files.pythonhosted.org/packages/af/53/29f9e2054ea6900413f3b4c3eb9d8331f60678ec855f13ba8714c47fd48d/aiohttp-3.13.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bc0a5cf4f10ef5a2c94fdde488734b582a3a7a000b131263e27c9295bd682d9", size = 1767655, upload-time = "2026-03-28T17:15:45.911Z" },
{ url = "https://files.pythonhosted.org/packages/f3/57/462fe1d3da08109ba4aa8590e7aed57c059af2a7e80ec21f4bac5cfe1094/aiohttp-3.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5c7ff1028e3c9fc5123a865ce17df1cb6424d180c503b8517afbe89aa566e6be", size = 1630439, upload-time = "2026-03-28T17:15:48.11Z" },
{ url = "https://files.pythonhosted.org/packages/d7/4b/4813344aacdb8127263e3eec343d24e973421143826364fa9fc847f6283f/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ba5cf98b5dcb9bddd857da6713a503fa6d341043258ca823f0f5ab7ab4a94ee8", size = 1745557, upload-time = "2026-03-28T17:15:50.13Z" },
{ url = "https://files.pythonhosted.org/packages/d4/01/1ef1adae1454341ec50a789f03cfafe4c4ac9c003f6a64515ecd32fe4210/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d85965d3ba21ee4999e83e992fecb86c4614d6920e40705501c0a1f80a583c12", size = 1741796, upload-time = "2026-03-28T17:15:52.351Z" },
{ url = "https://files.pythonhosted.org/packages/22/04/8cdd99af988d2aa6922714d957d21383c559835cbd43fbf5a47ddf2e0f05/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:49f0b18a9b05d79f6f37ddd567695943fcefb834ef480f17a4211987302b2dc7", size = 1805312, upload-time = "2026-03-28T17:15:54.407Z" },
{ url = "https://files.pythonhosted.org/packages/fb/7f/b48d5577338d4b25bbdbae35c75dbfd0493cb8886dc586fbfb2e90862239/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7f78cb080c86fbf765920e5f1ef35af3f24ec4314d6675d0a21eaf41f6f2679c", size = 1621751, upload-time = "2026-03-28T17:15:56.564Z" },
{ url = "https://files.pythonhosted.org/packages/bc/89/4eecad8c1858e6d0893c05929e22343e0ebe3aec29a8a399c65c3cc38311/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:67a3ec705534a614b68bbf1c70efa777a21c3da3895d1c44510a41f5a7ae0453", size = 1826073, upload-time = "2026-03-28T17:15:58.489Z" },
{ url = "https://files.pythonhosted.org/packages/f5/5c/9dc8293ed31b46c39c9c513ac7ca152b3c3d38e0ea111a530ad12001b827/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d6630ec917e85c5356b2295744c8a97d40f007f96a1c76bf1928dc2e27465393", size = 1760083, upload-time = "2026-03-28T17:16:00.677Z" },
{ url = "https://files.pythonhosted.org/packages/1e/19/8bbf6a4994205d96831f97b7d21a0feed120136e6267b5b22d229c6dc4dc/aiohttp-3.13.4-cp311-cp311-win32.whl", hash = "sha256:54049021bc626f53a5394c29e8c444f726ee5a14b6e89e0ad118315b1f90f5e3", size = 439690, upload-time = "2026-03-28T17:16:02.902Z" },
{ url = "https://files.pythonhosted.org/packages/0c/f5/ac409ecd1007528d15c3e8c3a57d34f334c70d76cfb7128a28cffdebd4c1/aiohttp-3.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:c033f2bc964156030772d31cbf7e5defea181238ce1f87b9455b786de7d30145", size = 463824, upload-time = "2026-03-28T17:16:05.058Z" },
{ url = "https://files.pythonhosted.org/packages/1e/bd/ede278648914cabbabfdf95e436679b5d4156e417896a9b9f4587169e376/aiohttp-3.13.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ee62d4471ce86b108b19c3364db4b91180d13fe3510144872d6bad5401957360", size = 752158, upload-time = "2026-03-28T17:16:06.901Z" },
{ url = "https://files.pythonhosted.org/packages/90/de/581c053253c07b480b03785196ca5335e3c606a37dc73e95f6527f1591fe/aiohttp-3.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c0fd8f41b54b58636402eb493afd512c23580456f022c1ba2db0f810c959ed0d", size = 501037, upload-time = "2026-03-28T17:16:08.82Z" },
{ url = "https://files.pythonhosted.org/packages/fa/f9/a5ede193c08f13cc42c0a5b50d1e246ecee9115e4cf6e900d8dbd8fd6acb/aiohttp-3.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4baa48ce49efd82d6b1a0be12d6a36b35e5594d1dd42f8bfba96ea9f8678b88c", size = 501556, upload-time = "2026-03-28T17:16:10.63Z" },
{ url = "https://files.pythonhosted.org/packages/d6/10/88ff67cd48a6ec36335b63a640abe86135791544863e0cfe1f065d6cef7a/aiohttp-3.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d738ebab9f71ee652d9dbd0211057690022201b11197f9a7324fd4dba128aa97", size = 1757314, upload-time = "2026-03-28T17:16:12.498Z" },
{ url = "https://files.pythonhosted.org/packages/8b/15/fdb90a5cf5a1f52845c276e76298c75fbbcc0ac2b4a86551906d54529965/aiohttp-3.13.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0ce692c3468fa831af7dceed52edf51ac348cebfc8d3feb935927b63bd3e8576", size = 1731819, upload-time = "2026-03-28T17:16:14.558Z" },
{ url = "https://files.pythonhosted.org/packages/ec/df/28146785a007f7820416be05d4f28cc207493efd1e8c6c1068e9bdc29198/aiohttp-3.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e08abcfe752a454d2cb89ff0c08f2d1ecd057ae3e8cc6d84638de853530ebab", size = 1793279, upload-time = "2026-03-28T17:16:16.594Z" },
{ url = "https://files.pythonhosted.org/packages/10/47/689c743abf62ea7a77774d5722f220e2c912a77d65d368b884d9779ef41b/aiohttp-3.13.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5977f701b3fff36367a11087f30ea73c212e686d41cd363c50c022d48b011d8d", size = 1891082, upload-time = "2026-03-28T17:16:18.71Z" },
{ url = "https://files.pythonhosted.org/packages/b0/b6/f7f4f318c7e58c23b761c9b13b9a3c9b394e0f9d5d76fbc6622fa98509f6/aiohttp-3.13.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54203e10405c06f8b6020bd1e076ae0fe6c194adcee12a5a78af3ffa3c57025e", size = 1773938, upload-time = "2026-03-28T17:16:21.125Z" },
{ url = "https://files.pythonhosted.org/packages/aa/06/f207cb3121852c989586a6fc16ff854c4fcc8651b86c5d3bd1fc83057650/aiohttp-3.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:358a6af0145bc4dda037f13167bef3cce54b132087acc4c295c739d05d16b1c3", size = 1579548, upload-time = "2026-03-28T17:16:23.588Z" },
{ url = "https://files.pythonhosted.org/packages/6c/58/e1289661a32161e24c1fe479711d783067210d266842523752869cc1d9c2/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:898ea1850656d7d61832ef06aa9846ab3ddb1621b74f46de78fbc5e1a586ba83", size = 1714669, upload-time = "2026-03-28T17:16:25.713Z" },
{ url = "https://files.pythonhosted.org/packages/96/0a/3e86d039438a74a86e6a948a9119b22540bae037d6ba317a042ae3c22711/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7bc30cceb710cf6a44e9617e43eebb6e3e43ad855a34da7b4b6a73537d8a6763", size = 1754175, upload-time = "2026-03-28T17:16:28.18Z" },
{ url = "https://files.pythonhosted.org/packages/f4/30/e717fc5df83133ba467a560b6d8ef20197037b4bb5d7075b90037de1018e/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4a31c0c587a8a038f19a4c7e60654a6c899c9de9174593a13e7cc6e15ff271f9", size = 1762049, upload-time = "2026-03-28T17:16:30.941Z" },
{ url = "https://files.pythonhosted.org/packages/e4/28/8f7a2d4492e336e40005151bdd94baf344880a4707573378579f833a64c1/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2062f675f3fe6e06d6113eb74a157fb9df58953ffed0cdb4182554b116545758", size = 1570861, upload-time = "2026-03-28T17:16:32.953Z" },
{ url = "https://files.pythonhosted.org/packages/78/45/12e1a3d0645968b1c38de4b23fdf270b8637735ea057d4f84482ff918ad9/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d1ba8afb847ff80626d5e408c1fdc99f942acc877d0702fe137015903a220a9", size = 1790003, upload-time = "2026-03-28T17:16:35.468Z" },
{ url = "https://files.pythonhosted.org/packages/eb/0f/60374e18d590de16dcb39d6ff62f39c096c1b958e6f37727b5870026ea30/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b08149419994cdd4d5eecf7fd4bc5986b5a9380285bcd01ab4c0d6bfca47b79d", size = 1737289, upload-time = "2026-03-28T17:16:38.187Z" },
{ url = "https://files.pythonhosted.org/packages/02/bf/535e58d886cfbc40a8b0013c974afad24ef7632d645bca0b678b70033a60/aiohttp-3.13.4-cp312-cp312-win32.whl", hash = "sha256:fc432f6a2c4f720180959bc19aa37259651c1a4ed8af8afc84dd41c60f15f791", size = 434185, upload-time = "2026-03-28T17:16:40.735Z" },
{ url = "https://files.pythonhosted.org/packages/1e/1a/d92e3325134ebfff6f4069f270d3aac770d63320bd1fcd0eca023e74d9a8/aiohttp-3.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:6148c9ae97a3e8bff9a1fc9c757fa164116f86c100468339730e717590a3fb77", size = 461285, upload-time = "2026-03-28T17:16:42.713Z" },
{ url = "https://files.pythonhosted.org/packages/e3/ac/892f4162df9b115b4758d615f32ec63d00f3084c705ff5526630887b9b42/aiohttp-3.13.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:63dd5e5b1e43b8fb1e91b79b7ceba1feba588b317d1edff385084fcc7a0a4538", size = 745744, upload-time = "2026-03-28T17:16:44.67Z" },
{ url = "https://files.pythonhosted.org/packages/97/a9/c5b87e4443a2f0ea88cb3000c93a8fdad1ee63bffc9ded8d8c8e0d66efc6/aiohttp-3.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:746ac3cc00b5baea424dacddea3ec2c2702f9590de27d837aa67004db1eebc6e", size = 498178, upload-time = "2026-03-28T17:16:46.766Z" },
{ url = "https://files.pythonhosted.org/packages/94/42/07e1b543a61250783650df13da8ddcdc0d0a5538b2bd15cef6e042aefc61/aiohttp-3.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bda8f16ea99d6a6705e5946732e48487a448be874e54a4f73d514660ff7c05d3", size = 498331, upload-time = "2026-03-28T17:16:48.9Z" },
{ url = "https://files.pythonhosted.org/packages/20/d6/492f46bf0328534124772d0cf58570acae5b286ea25006900650f69dae0e/aiohttp-3.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b061e7b5f840391e3f64d0ddf672973e45c4cfff7a0feea425ea24e51530fc2", size = 1744414, upload-time = "2026-03-28T17:16:50.968Z" },
{ url = "https://files.pythonhosted.org/packages/e2/4d/e02627b2683f68051246215d2d62b2d2f249ff7a285e7a858dc47d6b6a14/aiohttp-3.13.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b252e8d5cd66184b570d0d010de742736e8a4fab22c58299772b0c5a466d4b21", size = 1719226, upload-time = "2026-03-28T17:16:53.173Z" },
{ url = "https://files.pythonhosted.org/packages/7b/6c/5d0a3394dd2b9f9aeba6e1b6065d0439e4b75d41f1fb09a3ec010b43552b/aiohttp-3.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20af8aad61d1803ff11152a26146d8d81c266aa8c5aa9b4504432abb965c36a0", size = 1782110, upload-time = "2026-03-28T17:16:55.362Z" },
{ url = "https://files.pythonhosted.org/packages/0d/2d/c20791e3437700a7441a7edfb59731150322424f5aadf635602d1d326101/aiohttp-3.13.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:13a5cc924b59859ad2adb1478e31f410a7ed46e92a2a619d6d1dd1a63c1a855e", size = 1884809, upload-time = "2026-03-28T17:16:57.734Z" },
{ url = "https://files.pythonhosted.org/packages/c8/94/d99dbfbd1924a87ef643833932eb2a3d9e5eee87656efea7d78058539eff/aiohttp-3.13.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:534913dfb0a644d537aebb4123e7d466d94e3be5549205e6a31f72368980a81a", size = 1764938, upload-time = "2026-03-28T17:17:00.221Z" },
{ url = "https://files.pythonhosted.org/packages/49/61/3ce326a1538781deb89f6cf5e094e2029cd308ed1e21b2ba2278b08426f6/aiohttp-3.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:320e40192a2dcc1cf4b5576936e9652981ab596bf81eb309535db7e2f5b5672f", size = 1570697, upload-time = "2026-03-28T17:17:02.985Z" },
{ url = "https://files.pythonhosted.org/packages/b6/77/4ab5a546857bb3028fbaf34d6eea180267bdab022ee8b1168b1fcde4bfdd/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9e587fcfce2bcf06526a43cb705bdee21ac089096f2e271d75de9c339db3100c", size = 1702258, upload-time = "2026-03-28T17:17:05.28Z" },
{ url = "https://files.pythonhosted.org/packages/79/63/d8f29021e39bc5af8e5d5e9da1b07976fb9846487a784e11e4f4eeda4666/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9eb9c2eea7278206b5c6c1441fdd9dc420c278ead3f3b2cc87f9b693698cc500", size = 1740287, upload-time = "2026-03-28T17:17:07.712Z" },
{ url = "https://files.pythonhosted.org/packages/55/3a/cbc6b3b124859a11bc8055d3682c26999b393531ef926754a3445b99dfef/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:29be00c51972b04bf9d5c8f2d7f7314f48f96070ca40a873a53056e652e805f7", size = 1753011, upload-time = "2026-03-28T17:17:10.053Z" },
{ url = "https://files.pythonhosted.org/packages/e0/30/836278675205d58c1368b21520eab9572457cf19afd23759216c04483048/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90c06228a6c3a7c9f776fe4fc0b7ff647fffd3bed93779a6913c804ae00c1073", size = 1566359, upload-time = "2026-03-28T17:17:12.433Z" },
{ url = "https://files.pythonhosted.org/packages/50/b4/8032cc9b82d17e4277704ba30509eaccb39329dc18d6a35f05e424439e32/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a533ec132f05fd9a1d959e7f34184cd7d5e8511584848dab85faefbaac573069", size = 1785537, upload-time = "2026-03-28T17:17:14.721Z" },
{ url = "https://files.pythonhosted.org/packages/17/7d/5873e98230bde59f493bf1f7c3e327486a4b5653fa401144704df5d00211/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1c946f10f413836f82ea4cfb90200d2a59578c549f00857e03111cf45ad01ca5", size = 1740752, upload-time = "2026-03-28T17:17:17.387Z" },
{ url = "https://files.pythonhosted.org/packages/7b/f2/13e46e0df051494d7d3c68b7f72d071f48c384c12716fc294f75d5b1a064/aiohttp-3.13.4-cp313-cp313-win32.whl", hash = "sha256:48708e2706106da6967eff5908c78ca3943f005ed6bcb75da2a7e4da94ef8c70", size = 433187, upload-time = "2026-03-28T17:17:19.523Z" },
{ url = "https://files.pythonhosted.org/packages/ea/c0/649856ee655a843c8f8664592cfccb73ac80ede6a8c8db33a25d810c12db/aiohttp-3.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:74a2eb058da44fa3a877a49e2095b591d4913308bb424c418b77beb160c55ce3", size = 459778, upload-time = "2026-03-28T17:17:21.964Z" },
{ url = "https://files.pythonhosted.org/packages/6d/29/6657cc37ae04cacc2dbf53fb730a06b6091cc4cbe745028e047c53e6d840/aiohttp-3.13.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:e0a2c961fc92abeff61d6444f2ce6ad35bb982db9fc8ff8a47455beacf454a57", size = 749363, upload-time = "2026-03-28T17:17:24.044Z" },
{ url = "https://files.pythonhosted.org/packages/90/7f/30ccdf67ca3d24b610067dc63d64dcb91e5d88e27667811640644aa4a85d/aiohttp-3.13.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:153274535985a0ff2bff1fb6c104ed547cec898a09213d21b0f791a44b14d933", size = 499317, upload-time = "2026-03-28T17:17:26.199Z" },
{ url = "https://files.pythonhosted.org/packages/93/13/e372dd4e68ad04ee25dafb050c7f98b0d91ea643f7352757e87231102555/aiohttp-3.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:351f3171e2458da3d731ce83f9e6b9619e325c45cbd534c7759750cabf453ad7", size = 500477, upload-time = "2026-03-28T17:17:28.279Z" },
{ url = "https://files.pythonhosted.org/packages/e5/fe/ee6298e8e586096fb6f5eddd31393d8544f33ae0792c71ecbb4c2bef98ac/aiohttp-3.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f989ac8bc5595ff761a5ccd32bdb0768a117f36dd1504b1c2c074ed5d3f4df9c", size = 1737227, upload-time = "2026-03-28T17:17:30.587Z" },
{ url = "https://files.pythonhosted.org/packages/b0/b9/a7a0463a09e1a3fe35100f74324f23644bfc3383ac5fd5effe0722a5f0b7/aiohttp-3.13.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d36fc1709110ec1e87a229b201dd3ddc32aa01e98e7868083a794609b081c349", size = 1694036, upload-time = "2026-03-28T17:17:33.29Z" },
{ url = "https://files.pythonhosted.org/packages/57/7c/8972ae3fb7be00a91aee6b644b2a6a909aedb2c425269a3bfd90115e6f8f/aiohttp-3.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42adaeea83cbdf069ab94f5103ce0787c21fb1a0153270da76b59d5578302329", size = 1786814, upload-time = "2026-03-28T17:17:36.035Z" },
{ url = "https://files.pythonhosted.org/packages/93/01/c81e97e85c774decbaf0d577de7d848934e8166a3a14ad9f8aa5be329d28/aiohttp-3.13.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:92deb95469928cc41fd4b42a95d8012fa6df93f6b1c0a83af0ffbc4a5e218cde", size = 1866676, upload-time = "2026-03-28T17:17:38.441Z" },
{ url = "https://files.pythonhosted.org/packages/5a/5f/5b46fe8694a639ddea2cd035bf5729e4677ea882cb251396637e2ef1590d/aiohttp-3.13.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0c7c07c4257ef3a1df355f840bc62d133bcdef5c1c5ba75add3c08553e2eed", size = 1740842, upload-time = "2026-03-28T17:17:40.783Z" },
{ url = "https://files.pythonhosted.org/packages/20/a2/0d4b03d011cca6b6b0acba8433193c1e484efa8d705ea58295590fe24203/aiohttp-3.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f062c45de8a1098cb137a1898819796a2491aec4e637a06b03f149315dff4d8f", size = 1566508, upload-time = "2026-03-28T17:17:43.235Z" },
{ url = "https://files.pythonhosted.org/packages/98/17/e689fd500da52488ec5f889effd6404dece6a59de301e380f3c64f167beb/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:76093107c531517001114f0ebdb4f46858ce818590363e3e99a4a2280334454a", size = 1700569, upload-time = "2026-03-28T17:17:46.165Z" },
{ url = "https://files.pythonhosted.org/packages/d8/0d/66402894dbcf470ef7db99449e436105ea862c24f7ea4c95c683e635af35/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6f6ec32162d293b82f8b63a16edc80769662fbd5ae6fbd4936d3206a2c2cc63b", size = 1707407, upload-time = "2026-03-28T17:17:48.825Z" },
{ url = "https://files.pythonhosted.org/packages/2f/eb/af0ab1a3650092cbd8e14ef29e4ab0209e1460e1c299996c3f8288b3f1ff/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5903e2db3d202a00ad9f0ec35a122c005e85d90c9836ab4cda628f01edf425e2", size = 1752214, upload-time = "2026-03-28T17:17:51.206Z" },
{ url = "https://files.pythonhosted.org/packages/5a/bf/72326f8a98e4c666f292f03c385545963cc65e358835d2a7375037a97b57/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2d5bea57be7aca98dbbac8da046d99b5557c5cf4e28538c4c786313078aca09e", size = 1562162, upload-time = "2026-03-28T17:17:53.634Z" },
{ url = "https://files.pythonhosted.org/packages/67/9f/13b72435f99151dd9a5469c96b3b5f86aa29b7e785ca7f35cf5e538f74c0/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:bcf0c9902085976edc0232b75006ef38f89686901249ce14226b6877f88464fb", size = 1768904, upload-time = "2026-03-28T17:17:55.991Z" },
{ url = "https://files.pythonhosted.org/packages/18/bc/28d4970e7d5452ac7776cdb5431a1164a0d9cf8bd2fffd67b4fb463aa56d/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3295f98bfeed2e867cab588f2a146a9db37a85e3ae9062abf46ba062bd29165", size = 1723378, upload-time = "2026-03-28T17:17:58.348Z" },
{ url = "https://files.pythonhosted.org/packages/53/74/b32458ca1a7f34d65bdee7aef2036adbe0438123d3d53e2b083c453c24dd/aiohttp-3.13.4-cp314-cp314-win32.whl", hash = "sha256:a598a5c5767e1369d8f5b08695cab1d8160040f796c4416af76fd773d229b3c9", size = 438711, upload-time = "2026-03-28T17:18:00.728Z" },
{ url = "https://files.pythonhosted.org/packages/40/b2/54b487316c2df3e03a8f3435e9636f8a81a42a69d942164830d193beb56a/aiohttp-3.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:c555db4bc7a264bead5a7d63d92d41a1122fcd39cc62a4db815f45ad46f9c2c8", size = 464977, upload-time = "2026-03-28T17:18:03.367Z" },
{ url = "https://files.pythonhosted.org/packages/47/fb/e41b63c6ce71b07a59243bb8f3b457ee0c3402a619acb9d2c0d21ef0e647/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45abbbf09a129825d13c18c7d3182fecd46d9da3cfc383756145394013604ac1", size = 781549, upload-time = "2026-03-28T17:18:05.779Z" },
{ url = "https://files.pythonhosted.org/packages/97/53/532b8d28df1e17e44c4d9a9368b78dcb6bf0b51037522136eced13afa9e8/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:74c80b2bc2c2adb7b3d1941b2b60701ee2af8296fc8aad8b8bc48bc25767266c", size = 514383, upload-time = "2026-03-28T17:18:08.096Z" },
{ url = "https://files.pythonhosted.org/packages/1b/1f/62e5d400603e8468cd635812d99cb81cfdc08127a3dc474c647615f31339/aiohttp-3.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c97989ae40a9746650fa196894f317dafc12227c808c774929dda0ff873a5954", size = 518304, upload-time = "2026-03-28T17:18:10.642Z" },
{ url = "https://files.pythonhosted.org/packages/90/57/2326b37b10896447e3c6e0cbef4fe2486d30913639a5cfd1332b5d870f82/aiohttp-3.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dae86be9811493f9990ef44fff1685f5c1a3192e9061a71a109d527944eed551", size = 1893433, upload-time = "2026-03-28T17:18:13.121Z" },
{ url = "https://files.pythonhosted.org/packages/d2/b4/a24d82112c304afdb650167ef2fe190957d81cbddac7460bedd245f765aa/aiohttp-3.13.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1db491abe852ca2fa6cc48a3341985b0174b3741838e1341b82ac82c8bd9e871", size = 1755901, upload-time = "2026-03-28T17:18:16.21Z" },
{ url = "https://files.pythonhosted.org/packages/9e/2d/0883ef9d878d7846287f036c162a951968f22aabeef3ac97b0bea6f76d5d/aiohttp-3.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e5d701c0aad02a7dce72eef6b93226cf3734330f1a31d69ebbf69f33b86666e", size = 1876093, upload-time = "2026-03-28T17:18:18.703Z" },
{ url = "https://files.pythonhosted.org/packages/ad/52/9204bb59c014869b71971addad6778f005daa72a96eed652c496789d7468/aiohttp-3.13.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8ac32a189081ae0a10ba18993f10f338ec94341f0d5df8fff348043962f3c6f8", size = 1970815, upload-time = "2026-03-28T17:18:21.858Z" },
{ url = "https://files.pythonhosted.org/packages/d6/b5/e4eb20275a866dde0f570f411b36c6b48f7b53edfe4f4071aa1b0728098a/aiohttp-3.13.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98e968cdaba43e45c73c3f306fca418c8009a957733bac85937c9f9cf3f4de27", size = 1816223, upload-time = "2026-03-28T17:18:24.729Z" },
{ url = "https://files.pythonhosted.org/packages/d8/23/e98075c5bb146aa61a1239ee1ac7714c85e814838d6cebbe37d3fe19214a/aiohttp-3.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca114790c9144c335d538852612d3e43ea0f075288f4849cf4b05d6cd2238ce7", size = 1649145, upload-time = "2026-03-28T17:18:27.269Z" },
{ url = "https://files.pythonhosted.org/packages/d6/c1/7bad8be33bb06c2bb224b6468874346026092762cbec388c3bdb65a368ee/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ea2e071661ba9cfe11eabbc81ac5376eaeb3061f6e72ec4cc86d7cdd1ffbdbbb", size = 1816562, upload-time = "2026-03-28T17:18:29.847Z" },
{ url = "https://files.pythonhosted.org/packages/5c/10/c00323348695e9a5e316825969c88463dcc24c7e9d443244b8a2c9cf2eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:34e89912b6c20e0fd80e07fa401fd218a410aa1ce9f1c2f1dad6db1bd0ce0927", size = 1800333, upload-time = "2026-03-28T17:18:32.269Z" },
{ url = "https://files.pythonhosted.org/packages/84/43/9b2147a1df3559f49bd723e22905b46a46c068a53adb54abdca32c4de180/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0e217cf9f6a42908c52b46e42c568bd57adc39c9286ced31aaace614b6087965", size = 1820617, upload-time = "2026-03-28T17:18:35.238Z" },
{ url = "https://files.pythonhosted.org/packages/a9/7f/b3481a81e7a586d02e99387b18c6dafff41285f6efd3daa2124c01f87eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0c296f1221e21ba979f5ac1964c3b78cfde15c5c5f855ffd2caab337e9cd9182", size = 1643417, upload-time = "2026-03-28T17:18:37.949Z" },
{ url = "https://files.pythonhosted.org/packages/8f/72/07181226bc99ce1124e0f89280f5221a82d3ae6a6d9d1973ce429d48e52b/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d99a9d168ebaffb74f36d011750e490085ac418f4db926cce3989c8fe6cb6b1b", size = 1849286, upload-time = "2026-03-28T17:18:40.534Z" },
{ url = "https://files.pythonhosted.org/packages/1a/e6/1b3566e103eca6da5be4ae6713e112a053725c584e96574caf117568ffef/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cb19177205d93b881f3f89e6081593676043a6828f59c78c17a0fd6c1fbed2ba", size = 1782635, upload-time = "2026-03-28T17:18:43.073Z" },
{ url = "https://files.pythonhosted.org/packages/37/58/1b11c71904b8d079eb0c39fe664180dd1e14bebe5608e235d8bfbadc8929/aiohttp-3.13.4-cp314-cp314t-win32.whl", hash = "sha256:c606aa5656dab6552e52ca368e43869c916338346bfaf6304e15c58fb113ea30", size = 472537, upload-time = "2026-03-28T17:18:46.286Z" },
{ url = "https://files.pythonhosted.org/packages/bc/8f/87c56a1a1977d7dddea5b31e12189665a140fdb48a71e9038ff90bb564ec/aiohttp-3.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:014dcc10ec8ab8db681f0d68e939d1e9286a5aa2b993cbbdb0db130853e02144", size = 506381, upload-time = "2026-03-28T17:18:48.74Z" },
]
[[package]]
name = "aiosignal"
version = "1.3.2"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "frozenlist" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ba/b5/6d55e80f6d8a08ce22b982eafa278d823b541c925f11ee774b0b9c43473d/aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54", size = 19424, upload-time = "2024-12-13T17:10:40.86Z" }
sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/6a/bc7e17a3e87a2985d3e8f4da4cd0f481060eb78fb08596c42be62c90a4d9/aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5", size = 7597, upload-time = "2024-12-13T17:10:38.469Z" },
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
]
[[package]]
@@ -1392,18 +1435,17 @@ wheels = [
[[package]]
name = "httpx"
version = "0.27.2"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
{ name = "sniffio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/78/82/08f8c936781f67d9e6b9eeb8a0c8b4e406136ea4c3d1f89a5db71d42e0e6/httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2", size = 144189, upload-time = "2024-08-27T12:54:01.334Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/95/9377bcb415797e44274b51d46e3249eba641711cf3348050f76ee7b15ffc/httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0", size = 76395, upload-time = "2024-08-27T12:53:59.653Z" },
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[package.optional-dependencies]
@@ -1509,14 +1551,14 @@ wheels = [
[[package]]
name = "jinja2"
version = "3.1.5"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/af/92/b3130cbbf5591acf9ade8708c365f3238046ac7cb8ccba6e81abccb0ccff/jinja2-3.1.5.tar.gz", hash = "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb", size = 244674, upload-time = "2024-12-21T18:30:22.828Z" }
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/0f/2ba5fbcd631e3e88689309dbe978c5769e883e4b84ebfe7da30b43275c5a/jinja2-3.1.5-py3-none-any.whl", hash = "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb", size = 134596, upload-time = "2024-12-21T18:30:19.133Z" },
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
@@ -1797,13 +1839,12 @@ wheels = [
[[package]]
name = "litellm"
version = "1.81.1"
version = "1.83.14"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
{ name = "click" },
{ name = "fastuuid" },
{ name = "grpcio" },
{ name = "httpx" },
{ name = "importlib-metadata" },
{ name = "jinja2" },
@@ -1814,9 +1855,9 @@ dependencies = [
{ name = "tiktoken" },
{ name = "tokenizers" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c5/2b/299d54f95e02e9ed551c186e881a4ac0eaa5a948a6be93ecaa26a748f1be/litellm-1.81.1.tar.gz", hash = "sha256:9c758db8abff04a2f1f43582d042080e36f245fe34cfbafe2f8b7ca8f1de29b6", size = 13487469, upload-time = "2026-01-21T12:55:58.271Z" }
sdist = { url = "https://files.pythonhosted.org/packages/8d/7c/c095649380adc96c8630273c1768c2ad1e74aa2ee1dd8dd05d218a60569f/litellm-1.83.14.tar.gz", hash = "sha256:24aef9b47cdc424c833e32f3727f411741c690832cd1fe4405e0077144fe09c9", size = 14836599, upload-time = "2026-04-26T03:16:10.176Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/73/8d100c4e48935f6a381df60f894ca9c063ea412ce354fbe7a17770ad4092/litellm-1.81.1-py3-none-any.whl", hash = "sha256:503512a8a7f3cddf9d8fed6182c14f1e77c5655635fe67b09efb09c75234bb87", size = 11795146, upload-time = "2026-01-21T12:55:55.613Z" },
{ url = "https://files.pythonhosted.org/packages/7f/5c/1b5691575420135e90578543b2bf219497caa33cfd0af64cb38f30288450/litellm-1.83.14-py3-none-any.whl", hash = "sha256:92b11ba2a32cf80707ddf388d18526696c7999a21b418c5e3b6eda1243d2cfdb", size = 16457054, upload-time = "2026-04-26T03:16:05.72Z" },
]
[[package]]
@@ -2488,7 +2529,7 @@ wheels = [
[[package]]
name = "openai"
version = "2.15.0"
version = "2.24.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -2500,9 +2541,9 @@ dependencies = [
{ name = "tqdm" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/94/f4/4690ecb5d70023ce6bfcfeabfe717020f654bde59a775058ec6ac4692463/openai-2.15.0.tar.gz", hash = "sha256:42eb8cbb407d84770633f31bf727d4ffb4138711c670565a41663d9439174fba", size = 627383, upload-time = "2026-01-09T22:10:08.603Z" }
sdist = { url = "https://files.pythonhosted.org/packages/55/13/17e87641b89b74552ed408a92b231283786523edddc95f3545809fab673c/openai-2.24.0.tar.gz", hash = "sha256:1e5769f540dbd01cb33bc4716a23e67b9d695161a734aff9c5f925e2bf99a673", size = 658717, upload-time = "2026-02-24T20:02:07.958Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/df/c306f7375d42bafb379934c2df4c2fa3964656c8c782bac75ee10c102818/openai-2.15.0-py3-none-any.whl", hash = "sha256:6ae23b932cd7230f7244e52954daa6602716d6b9bf235401a107af731baea6c3", size = 1067879, upload-time = "2026-01-09T22:10:06.446Z" },
{ url = "https://files.pythonhosted.org/packages/c9/30/844dc675ee6902579b8eef01ed23917cc9319a1c9c0c14ec6e39340c96d0/openai-2.24.0-py3-none-any.whl", hash = "sha256:fed30480d7d6c884303287bde864980a4b137b60553ffbcf9ab4a233b7a73d94", size = 1120122, upload-time = "2026-02-24T20:02:05.669Z" },
]
[[package]]
@@ -2951,7 +2992,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.6.37"
version = "1.6.39"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -2964,6 +3005,7 @@ dependencies = [
[package.optional-dependencies]
all = [
{ name = "ast-grep-cli" },
{ name = "chonkie" },
{ name = "chromadb" },
{ name = "crawl4ai" },
@@ -3055,8 +3097,8 @@ requires-dist = [
{ name = "fastapi", marker = "extra == 'api'", specifier = ">=0.115.0" },
{ name = "fastapi", marker = "extra == 'mcp'", specifier = ">=0.115.0" },
{ name = "fastapi", marker = "extra == 'os'", specifier = ">=0.115.0" },
{ name = "litellm", marker = "extra == 'llm'", specifier = ">=1.81.0,<=1.82.6" },
{ name = "litellm", marker = "extra == 'memory'", specifier = ">=1.81.0,<=1.82.6" },
{ name = "litellm", marker = "extra == 'llm'", specifier = ">=1.83.14" },
{ name = "litellm", marker = "extra == 'memory'", specifier = ">=1.83.14" },
{ name = "markitdown", extras = ["all"], marker = "extra == 'knowledge'", specifier = ">=0.1.0" },
{ name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.20.0" },
{ name = "mem0ai", marker = "extra == 'knowledge'", specifier = ">=0.1.0" },
@@ -3069,6 +3111,7 @@ requires-dist = [
{ name = "posthog", marker = "extra == 'telemetry'", specifier = ">=3.0.0" },
{ name = "praisonaiagents", extras = ["api"], marker = "extra == 'all'" },
{ name = "praisonaiagents", extras = ["auth"], marker = "extra == 'all'" },
{ name = "praisonaiagents", extras = ["autonomy"], marker = "extra == 'all'" },
{ name = "praisonaiagents", extras = ["crawl"], marker = "extra == 'all'" },
{ name = "praisonaiagents", extras = ["graph"], marker = "extra == 'all'" },
{ name = "praisonaiagents", extras = ["knowledge"], marker = "extra == 'all'" },
@@ -3076,6 +3119,7 @@ requires-dist = [
{ name = "praisonaiagents", extras = ["mcp"], marker = "extra == 'all'" },
{ name = "praisonaiagents", extras = ["memory"], marker = "extra == 'all'" },
{ name = "praisonaiagents", extras = ["mongodb"], marker = "extra == 'all'" },
{ name = "praisonaiagents", extras = ["os"], marker = "extra == 'all'" },
{ name = "praisonaiagents", extras = ["search"], marker = "extra == 'all'" },
{ name = "praisonaiagents", extras = ["telemetry"], marker = "extra == 'all'" },
{ name = "pydantic", specifier = ">=2.10.0" },
@@ -3586,11 +3630,11 @@ wheels = [
[[package]]
name = "python-dotenv"
version = "1.0.1"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115, upload-time = "2024-01-23T06:33:00.505Z" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863, upload-time = "2024-01-23T06:32:58.246Z" },
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
@@ -4378,104 +4422,93 @@ wheels = [
[[package]]
name = "tiktoken"
version = "0.8.0"
version = "0.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "regex" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/37/02/576ff3a6639e755c4f70997b2d315f56d6d71e0d046f4fb64cb81a3fb099/tiktoken-0.8.0.tar.gz", hash = "sha256:9ccbb2740f24542534369c5635cfd9b2b3c2490754a78ac8831d99f89f94eeb2", size = 35107, upload-time = "2024-10-03T22:44:04.196Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c9/ba/a35fad753bbca8ba0cc1b0f3402a70256a110ced7ac332cf84ba89fc87ab/tiktoken-0.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b07e33283463089c81ef1467180e3e00ab00d46c2c4bbcef0acab5f771d6695e", size = 1039905, upload-time = "2024-10-03T22:43:17.292Z" },
{ url = "https://files.pythonhosted.org/packages/91/05/13dab8fd7460391c387b3e69e14bf1e51ff71fe0a202cd2933cc3ea93fb6/tiktoken-0.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9269348cb650726f44dd3bbb3f9110ac19a8dcc8f54949ad3ef652ca22a38e21", size = 982417, upload-time = "2024-10-03T22:43:19.437Z" },
{ url = "https://files.pythonhosted.org/packages/e9/98/18ec4a8351a6cf4537e40cd6e19a422c10cce1ef00a2fcb716e0a96af58b/tiktoken-0.8.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e13f37bc4ef2d012731e93e0fef21dc3b7aea5bb9009618de9a4026844e560", size = 1144915, upload-time = "2024-10-03T22:43:21.385Z" },
{ url = "https://files.pythonhosted.org/packages/2e/28/cf3633018cbcc6deb7805b700ccd6085c9a5a7f72b38974ee0bffd56d311/tiktoken-0.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f13d13c981511331eac0d01a59b5df7c0d4060a8be1e378672822213da51e0a2", size = 1177221, upload-time = "2024-10-03T22:43:23.325Z" },
{ url = "https://files.pythonhosted.org/packages/57/81/8a5be305cbd39d4e83a794f9e80c7f2c84b524587b7feb27c797b2046d51/tiktoken-0.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6b2ddbc79a22621ce8b1166afa9f9a888a664a579350dc7c09346a3b5de837d9", size = 1237398, upload-time = "2024-10-03T22:43:24.71Z" },
{ url = "https://files.pythonhosted.org/packages/dc/da/8d1cc3089a83f5cf11c2e489332752981435280285231924557350523a59/tiktoken-0.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:d8c2d0e5ba6453a290b86cd65fc51fedf247e1ba170191715b049dac1f628005", size = 884215, upload-time = "2024-10-03T22:43:26.793Z" },
{ url = "https://files.pythonhosted.org/packages/f6/1e/ca48e7bfeeccaf76f3a501bd84db1fa28b3c22c9d1a1f41af9fb7579c5f6/tiktoken-0.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d622d8011e6d6f239297efa42a2657043aaed06c4f68833550cac9e9bc723ef1", size = 1039700, upload-time = "2024-10-03T22:43:28.315Z" },
{ url = "https://files.pythonhosted.org/packages/8c/f8/f0101d98d661b34534769c3818f5af631e59c36ac6d07268fbfc89e539ce/tiktoken-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2efaf6199717b4485031b4d6edb94075e4d79177a172f38dd934d911b588d54a", size = 982413, upload-time = "2024-10-03T22:43:29.807Z" },
{ url = "https://files.pythonhosted.org/packages/ac/3c/2b95391d9bd520a73830469f80a96e3790e6c0a5ac2444f80f20b4b31051/tiktoken-0.8.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5637e425ce1fc49cf716d88df3092048359a4b3bbb7da762840426e937ada06d", size = 1144242, upload-time = "2024-10-04T04:42:53.66Z" },
{ url = "https://files.pythonhosted.org/packages/01/c4/c4a4360de845217b6aa9709c15773484b50479f36bb50419c443204e5de9/tiktoken-0.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fb0e352d1dbe15aba082883058b3cce9e48d33101bdaac1eccf66424feb5b47", size = 1176588, upload-time = "2024-10-03T22:43:31.136Z" },
{ url = "https://files.pythonhosted.org/packages/f8/a3/ef984e976822cd6c2227c854f74d2e60cf4cd6fbfca46251199914746f78/tiktoken-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:56edfefe896c8f10aba372ab5706b9e3558e78db39dd497c940b47bf228bc419", size = 1237261, upload-time = "2024-10-03T22:43:32.75Z" },
{ url = "https://files.pythonhosted.org/packages/1e/86/eea2309dc258fb86c7d9b10db536434fc16420feaa3b6113df18b23db7c2/tiktoken-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:326624128590def898775b722ccc327e90b073714227175ea8febbc920ac0a99", size = 884537, upload-time = "2024-10-03T22:43:34.592Z" },
{ url = "https://files.pythonhosted.org/packages/c1/22/34b2e136a6f4af186b6640cbfd6f93400783c9ef6cd550d9eab80628d9de/tiktoken-0.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:881839cfeae051b3628d9823b2e56b5cc93a9e2efb435f4cf15f17dc45f21586", size = 1039357, upload-time = "2024-10-03T22:43:36.362Z" },
{ url = "https://files.pythonhosted.org/packages/04/d2/c793cf49c20f5855fd6ce05d080c0537d7418f22c58e71f392d5e8c8dbf7/tiktoken-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fe9399bdc3f29d428f16a2f86c3c8ec20be3eac5f53693ce4980371c3245729b", size = 982616, upload-time = "2024-10-03T22:43:37.658Z" },
{ url = "https://files.pythonhosted.org/packages/b3/a1/79846e5ef911cd5d75c844de3fa496a10c91b4b5f550aad695c5df153d72/tiktoken-0.8.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9a58deb7075d5b69237a3ff4bb51a726670419db6ea62bdcd8bd80c78497d7ab", size = 1144011, upload-time = "2024-10-03T22:43:39.092Z" },
{ url = "https://files.pythonhosted.org/packages/26/32/e0e3a859136e95c85a572e4806dc58bf1ddf651108ae8b97d5f3ebe1a244/tiktoken-0.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2908c0d043a7d03ebd80347266b0e58440bdef5564f84f4d29fb235b5df3b04", size = 1175432, upload-time = "2024-10-03T22:43:40.323Z" },
{ url = "https://files.pythonhosted.org/packages/c7/89/926b66e9025b97e9fbabeaa59048a736fe3c3e4530a204109571104f921c/tiktoken-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:294440d21a2a51e12d4238e68a5972095534fe9878be57d905c476017bff99fc", size = 1236576, upload-time = "2024-10-03T22:43:41.516Z" },
{ url = "https://files.pythonhosted.org/packages/45/e2/39d4aa02a52bba73b2cd21ba4533c84425ff8786cc63c511d68c8897376e/tiktoken-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:d8f3192733ac4d77977432947d563d7e1b310b96497acd3c196c9bddb36ed9db", size = 883824, upload-time = "2024-10-03T22:43:43.33Z" },
{ url = "https://files.pythonhosted.org/packages/e3/38/802e79ba0ee5fcbf240cd624143f57744e5d411d2e9d9ad2db70d8395986/tiktoken-0.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:02be1666096aff7da6cbd7cdaa8e7917bfed3467cd64b38b1f112e96d3b06a24", size = 1039648, upload-time = "2024-10-03T22:43:45.22Z" },
{ url = "https://files.pythonhosted.org/packages/b1/da/24cdbfc302c98663fbea66f5866f7fa1048405c7564ab88483aea97c3b1a/tiktoken-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94ff53c5c74b535b2cbf431d907fc13c678bbd009ee633a2aca269a04389f9a", size = 982763, upload-time = "2024-10-03T22:43:46.571Z" },
{ url = "https://files.pythonhosted.org/packages/e4/f0/0ecf79a279dfa41fc97d00adccf976ecc2556d3c08ef3e25e45eb31f665b/tiktoken-0.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b231f5e8982c245ee3065cd84a4712d64692348bc609d84467c57b4b72dcbc5", size = 1144417, upload-time = "2024-10-03T22:43:48.633Z" },
{ url = "https://files.pythonhosted.org/packages/ab/d3/155d2d4514f3471a25dc1d6d20549ef254e2aa9bb5b1060809b1d3b03d3a/tiktoken-0.8.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4177faa809bd55f699e88c96d9bb4635d22e3f59d635ba6fd9ffedf7150b9953", size = 1175108, upload-time = "2024-10-03T22:43:50.568Z" },
{ url = "https://files.pythonhosted.org/packages/19/eb/5989e16821ee8300ef8ee13c16effc20dfc26c777d05fbb6825e3c037b81/tiktoken-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5376b6f8dc4753cd81ead935c5f518fa0fbe7e133d9e25f648d8c4dabdd4bad7", size = 1236520, upload-time = "2024-10-03T22:43:51.759Z" },
{ url = "https://files.pythonhosted.org/packages/40/59/14b20465f1d1cb89cfbc96ec27e5617b2d41c79da12b5e04e96d689be2a7/tiktoken-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:18228d624807d66c87acd8f25fc135665617cab220671eb65b50f5d70fa51f69", size = 883849, upload-time = "2024-10-03T22:43:53.999Z" },
{ url = "https://files.pythonhosted.org/packages/89/b3/2cb7c17b6c4cf8ca983204255d3f1d95eda7213e247e6947a0ee2c747a2c/tiktoken-0.12.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3de02f5a491cfd179aec916eddb70331814bd6bf764075d39e21d5862e533970", size = 1051991, upload-time = "2025-10-06T20:21:34.098Z" },
{ url = "https://files.pythonhosted.org/packages/27/0f/df139f1df5f6167194ee5ab24634582ba9a1b62c6b996472b0277ec80f66/tiktoken-0.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b6cfb6d9b7b54d20af21a912bfe63a2727d9cfa8fbda642fd8322c70340aad16", size = 995798, upload-time = "2025-10-06T20:21:35.579Z" },
{ url = "https://files.pythonhosted.org/packages/ef/5d/26a691f28ab220d5edc09b9b787399b130f24327ef824de15e5d85ef21aa/tiktoken-0.12.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:cde24cdb1b8a08368f709124f15b36ab5524aac5fa830cc3fdce9c03d4fb8030", size = 1129865, upload-time = "2025-10-06T20:21:36.675Z" },
{ url = "https://files.pythonhosted.org/packages/b2/94/443fab3d4e5ebecac895712abd3849b8da93b7b7dec61c7db5c9c7ebe40c/tiktoken-0.12.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:6de0da39f605992649b9cfa6f84071e3f9ef2cec458d08c5feb1b6f0ff62e134", size = 1152856, upload-time = "2025-10-06T20:21:37.873Z" },
{ url = "https://files.pythonhosted.org/packages/54/35/388f941251b2521c70dd4c5958e598ea6d2c88e28445d2fb8189eecc1dfc/tiktoken-0.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6faa0534e0eefbcafaccb75927a4a380463a2eaa7e26000f0173b920e98b720a", size = 1195308, upload-time = "2025-10-06T20:21:39.577Z" },
{ url = "https://files.pythonhosted.org/packages/f8/00/c6681c7f833dd410576183715a530437a9873fa910265817081f65f9105f/tiktoken-0.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:82991e04fc860afb933efb63957affc7ad54f83e2216fe7d319007dab1ba5892", size = 1255697, upload-time = "2025-10-06T20:21:41.154Z" },
{ url = "https://files.pythonhosted.org/packages/5f/d2/82e795a6a9bafa034bf26a58e68fe9a89eeaaa610d51dbeb22106ba04f0a/tiktoken-0.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:6fb2995b487c2e31acf0a9e17647e3b242235a20832642bb7a9d1a181c0c1bb1", size = 879375, upload-time = "2025-10-06T20:21:43.201Z" },
{ url = "https://files.pythonhosted.org/packages/de/46/21ea696b21f1d6d1efec8639c204bdf20fde8bafb351e1355c72c5d7de52/tiktoken-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e227c7f96925003487c33b1b32265fad2fbcec2b7cf4817afb76d416f40f6bb", size = 1051565, upload-time = "2025-10-06T20:21:44.566Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d9/35c5d2d9e22bb2a5f74ba48266fb56c63d76ae6f66e02feb628671c0283e/tiktoken-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c06cf0fcc24c2cb2adb5e185c7082a82cba29c17575e828518c2f11a01f445aa", size = 995284, upload-time = "2025-10-06T20:21:45.622Z" },
{ url = "https://files.pythonhosted.org/packages/01/84/961106c37b8e49b9fdcf33fe007bb3a8fdcc380c528b20cc7fbba80578b8/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f18f249b041851954217e9fd8e5c00b024ab2315ffda5ed77665a05fa91f42dc", size = 1129201, upload-time = "2025-10-06T20:21:47.074Z" },
{ url = "https://files.pythonhosted.org/packages/6a/d0/3d9275198e067f8b65076a68894bb52fd253875f3644f0a321a720277b8a/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:47a5bc270b8c3db00bb46ece01ef34ad050e364b51d406b6f9730b64ac28eded", size = 1152444, upload-time = "2025-10-06T20:21:48.139Z" },
{ url = "https://files.pythonhosted.org/packages/78/db/a58e09687c1698a7c592e1038e01c206569b86a0377828d51635561f8ebf/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:508fa71810c0efdcd1b898fda574889ee62852989f7c1667414736bcb2b9a4bd", size = 1195080, upload-time = "2025-10-06T20:21:49.246Z" },
{ url = "https://files.pythonhosted.org/packages/9e/1b/a9e4d2bf91d515c0f74afc526fd773a812232dd6cda33ebea7f531202325/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a1af81a6c44f008cba48494089dd98cccb8b313f55e961a52f5b222d1e507967", size = 1255240, upload-time = "2025-10-06T20:21:50.274Z" },
{ url = "https://files.pythonhosted.org/packages/9d/15/963819345f1b1fb0809070a79e9dd96938d4ca41297367d471733e79c76c/tiktoken-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:3e68e3e593637b53e56f7237be560f7a394451cb8c11079755e80ae64b9e6def", size = 879422, upload-time = "2025-10-06T20:21:51.734Z" },
{ url = "https://files.pythonhosted.org/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8", size = 1050728, upload-time = "2025-10-06T20:21:52.756Z" },
{ url = "https://files.pythonhosted.org/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b", size = 994049, upload-time = "2025-10-06T20:21:53.782Z" },
{ url = "https://files.pythonhosted.org/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37", size = 1129008, upload-time = "2025-10-06T20:21:54.832Z" },
{ url = "https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad", size = 1152665, upload-time = "2025-10-06T20:21:56.129Z" },
{ url = "https://files.pythonhosted.org/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5", size = 1194230, upload-time = "2025-10-06T20:21:57.546Z" },
{ url = "https://files.pythonhosted.org/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3", size = 1254688, upload-time = "2025-10-06T20:21:58.619Z" },
{ url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694, upload-time = "2025-10-06T20:21:59.876Z" },
{ url = "https://files.pythonhosted.org/packages/00/61/441588ee21e6b5cdf59d6870f86beb9789e532ee9718c251b391b70c68d6/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3", size = 1050802, upload-time = "2025-10-06T20:22:00.96Z" },
{ url = "https://files.pythonhosted.org/packages/1f/05/dcf94486d5c5c8d34496abe271ac76c5b785507c8eae71b3708f1ad9b45a/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160", size = 993995, upload-time = "2025-10-06T20:22:02.788Z" },
{ url = "https://files.pythonhosted.org/packages/a0/70/5163fe5359b943f8db9946b62f19be2305de8c3d78a16f629d4165e2f40e/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa", size = 1128948, upload-time = "2025-10-06T20:22:03.814Z" },
{ url = "https://files.pythonhosted.org/packages/0c/da/c028aa0babf77315e1cef357d4d768800c5f8a6de04d0eac0f377cb619fa/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be", size = 1151986, upload-time = "2025-10-06T20:22:05.173Z" },
{ url = "https://files.pythonhosted.org/packages/a0/5a/886b108b766aa53e295f7216b509be95eb7d60b166049ce2c58416b25f2a/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a", size = 1194222, upload-time = "2025-10-06T20:22:06.265Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f8/4db272048397636ac7a078d22773dd2795b1becee7bc4922fe6207288d57/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3", size = 1255097, upload-time = "2025-10-06T20:22:07.403Z" },
{ url = "https://files.pythonhosted.org/packages/8e/32/45d02e2e0ea2be3a9ed22afc47d93741247e75018aac967b713b2941f8ea/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697", size = 879117, upload-time = "2025-10-06T20:22:08.418Z" },
{ url = "https://files.pythonhosted.org/packages/ce/76/994fc868f88e016e6d05b0da5ac24582a14c47893f4474c3e9744283f1d5/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16", size = 1050309, upload-time = "2025-10-06T20:22:10.939Z" },
{ url = "https://files.pythonhosted.org/packages/f6/b8/57ef1456504c43a849821920d582a738a461b76a047f352f18c0b26c6516/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a", size = 993712, upload-time = "2025-10-06T20:22:12.115Z" },
{ url = "https://files.pythonhosted.org/packages/72/90/13da56f664286ffbae9dbcfadcc625439142675845baa62715e49b87b68b/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27", size = 1128725, upload-time = "2025-10-06T20:22:13.541Z" },
{ url = "https://files.pythonhosted.org/packages/05/df/4f80030d44682235bdaecd7346c90f67ae87ec8f3df4a3442cb53834f7e4/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb", size = 1151875, upload-time = "2025-10-06T20:22:14.559Z" },
{ url = "https://files.pythonhosted.org/packages/22/1f/ae535223a8c4ef4c0c1192e3f9b82da660be9eb66b9279e95c99288e9dab/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e", size = 1194451, upload-time = "2025-10-06T20:22:15.545Z" },
{ url = "https://files.pythonhosted.org/packages/78/a7/f8ead382fce0243cb625c4f266e66c27f65ae65ee9e77f59ea1653b6d730/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25", size = 1253794, upload-time = "2025-10-06T20:22:16.624Z" },
{ url = "https://files.pythonhosted.org/packages/93/e0/6cc82a562bc6365785a3ff0af27a2a092d57c47d7a81d9e2295d8c36f011/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f", size = 878777, upload-time = "2025-10-06T20:22:18.036Z" },
{ url = "https://files.pythonhosted.org/packages/72/05/3abc1db5d2c9aadc4d2c76fa5640134e475e58d9fbb82b5c535dc0de9b01/tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646", size = 1050188, upload-time = "2025-10-06T20:22:19.563Z" },
{ url = "https://files.pythonhosted.org/packages/e3/7b/50c2f060412202d6c95f32b20755c7a6273543b125c0985d6fa9465105af/tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88", size = 993978, upload-time = "2025-10-06T20:22:20.702Z" },
{ url = "https://files.pythonhosted.org/packages/14/27/bf795595a2b897e271771cd31cb847d479073497344c637966bdf2853da1/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff", size = 1129271, upload-time = "2025-10-06T20:22:22.06Z" },
{ url = "https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830", size = 1151216, upload-time = "2025-10-06T20:22:23.085Z" },
{ url = "https://files.pythonhosted.org/packages/75/0d/881866647b8d1be4d67cb24e50d0c26f9f807f994aa1510cb9ba2fe5f612/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b", size = 1194860, upload-time = "2025-10-06T20:22:24.602Z" },
{ url = "https://files.pythonhosted.org/packages/b3/1e/b651ec3059474dab649b8d5b69f5c65cd8fcd8918568c1935bd4136c9392/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b", size = 1254567, upload-time = "2025-10-06T20:22:25.671Z" },
{ url = "https://files.pythonhosted.org/packages/80/57/ce64fd16ac390fafde001268c364d559447ba09b509181b2808622420eec/tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3", size = 921067, upload-time = "2025-10-06T20:22:26.753Z" },
{ url = "https://files.pythonhosted.org/packages/ac/a4/72eed53e8976a099539cdd5eb36f241987212c29629d0a52c305173e0a68/tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365", size = 1050473, upload-time = "2025-10-06T20:22:27.775Z" },
{ url = "https://files.pythonhosted.org/packages/e6/d7/0110b8f54c008466b19672c615f2168896b83706a6611ba6e47313dbc6e9/tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e", size = 993855, upload-time = "2025-10-06T20:22:28.799Z" },
{ url = "https://files.pythonhosted.org/packages/5f/77/4f268c41a3957c418b084dd576ea2fad2e95da0d8e1ab705372892c2ca22/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63", size = 1129022, upload-time = "2025-10-06T20:22:29.981Z" },
{ url = "https://files.pythonhosted.org/packages/4e/2b/fc46c90fe5028bd094cd6ee25a7db321cb91d45dc87531e2bdbb26b4867a/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0", size = 1150736, upload-time = "2025-10-06T20:22:30.996Z" },
{ url = "https://files.pythonhosted.org/packages/28/c0/3c7a39ff68022ddfd7d93f3337ad90389a342f761c4d71de99a3ccc57857/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a", size = 1194908, upload-time = "2025-10-06T20:22:32.073Z" },
{ url = "https://files.pythonhosted.org/packages/ab/0d/c1ad6f4016a3968c048545f5d9b8ffebf577774b2ede3e2e352553b685fe/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0", size = 1253706, upload-time = "2025-10-06T20:22:33.385Z" },
{ url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" },
]
[[package]]
name = "tokenizers"
version = "0.20.3"
version = "0.22.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huggingface-hub" },
]
sdist = { url = "https://files.pythonhosted.org/packages/da/25/b1681c1c30ea3ea6e584ae3fffd552430b12faa599b558c4c4783f56d7ff/tokenizers-0.20.3.tar.gz", hash = "sha256:2278b34c5d0dd78e087e1ca7f9b1dcbf129d80211afa645f214bd6e051037539", size = 340513, upload-time = "2024-11-05T17:34:10.403Z" }
sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/51/421bb0052fc4333f7c1e3231d8c6607552933d919b628c8fabd06f60ba1e/tokenizers-0.20.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:31ccab28dbb1a9fe539787210b0026e22debeab1662970f61c2d921f7557f7e4", size = 2674308, upload-time = "2024-11-05T17:30:25.423Z" },
{ url = "https://files.pythonhosted.org/packages/a6/e9/f651f8d27614fd59af387f4dfa568b55207e5fac8d06eec106dc00b921c4/tokenizers-0.20.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c6361191f762bda98c773da418cf511cbaa0cb8d0a1196f16f8c0119bde68ff8", size = 2559363, upload-time = "2024-11-05T17:30:28.841Z" },
{ url = "https://files.pythonhosted.org/packages/e3/e8/0e9f81a09ab79f409eabfd99391ca519e315496694671bebca24c3e90448/tokenizers-0.20.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f128d5da1202b78fa0a10d8d938610472487da01b57098d48f7e944384362514", size = 2892896, upload-time = "2024-11-05T17:30:30.429Z" },
{ url = "https://files.pythonhosted.org/packages/b0/72/15fdbc149e05005e99431ecd471807db2241983deafe1e704020f608f40e/tokenizers-0.20.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79c4121a2e9433ad7ef0769b9ca1f7dd7fa4c0cd501763d0a030afcbc6384481", size = 2802785, upload-time = "2024-11-05T17:30:32.045Z" },
{ url = "https://files.pythonhosted.org/packages/26/44/1f8aea48f9bb117d966b7272484671b33a509f6217a8e8544d79442c90db/tokenizers-0.20.3-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7850fde24197fe5cd6556e2fdba53a6d3bae67c531ea33a3d7c420b90904141", size = 3086060, upload-time = "2024-11-05T17:30:34.11Z" },
{ url = "https://files.pythonhosted.org/packages/2e/83/82ba40da99870b3a0b801cffaf4f099f088a84c7e07d32cc6ca751ce08e6/tokenizers-0.20.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b357970c095dc134978a68c67d845a1e3803ab7c4fbb39195bde914e7e13cf8b", size = 3096760, upload-time = "2024-11-05T17:30:36.276Z" },
{ url = "https://files.pythonhosted.org/packages/f3/46/7a025404201d937f86548928616c0a164308aa3998e546efdf798bf5ee9c/tokenizers-0.20.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a333d878c4970b72d6c07848b90c05f6b045cf9273fc2bc04a27211721ad6118", size = 3380165, upload-time = "2024-11-05T17:30:37.642Z" },
{ url = "https://files.pythonhosted.org/packages/aa/49/15fae66ac62e49255eeedbb7f4127564b2c3f3aef2009913f525732d1a08/tokenizers-0.20.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1fd9fee817f655a8f50049f685e224828abfadd436b8ff67979fc1d054b435f1", size = 2994038, upload-time = "2024-11-05T17:30:40.075Z" },
{ url = "https://files.pythonhosted.org/packages/f4/64/693afc9ba2393c2eed85c02bacb44762f06a29f0d1a5591fa5b40b39c0a2/tokenizers-0.20.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9e7816808b402129393a435ea2a509679b41246175d6e5e9f25b8692bfaa272b", size = 8977285, upload-time = "2024-11-05T17:30:42.095Z" },
{ url = "https://files.pythonhosted.org/packages/be/7e/6126c18694310fe07970717929e889898767c41fbdd95b9078e8aec0f9ef/tokenizers-0.20.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ba96367db9d8a730d3a1d5996b4b7babb846c3994b8ef14008cd8660f55db59d", size = 9294890, upload-time = "2024-11-05T17:30:44.563Z" },
{ url = "https://files.pythonhosted.org/packages/71/7d/5e3307a1091c8608a1e58043dff49521bc19553c6e9548c7fac6840cc2c4/tokenizers-0.20.3-cp310-none-win32.whl", hash = "sha256:ee31ba9d7df6a98619426283e80c6359f167e2e9882d9ce1b0254937dbd32f3f", size = 2196883, upload-time = "2024-11-05T17:30:46.792Z" },
{ url = "https://files.pythonhosted.org/packages/47/62/aaf5b2a526b3b10c20985d9568ff8c8f27159345eaef3347831e78cd5894/tokenizers-0.20.3-cp310-none-win_amd64.whl", hash = "sha256:a845c08fdad554fe0871d1255df85772f91236e5fd6b9287ef8b64f5807dbd0c", size = 2381637, upload-time = "2024-11-05T17:30:48.156Z" },
{ url = "https://files.pythonhosted.org/packages/c6/93/6742ef9206409d5ce1fdf44d5ca1687cdc3847ba0485424e2c731e6bcf67/tokenizers-0.20.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:585b51e06ca1f4839ce7759941e66766d7b060dccfdc57c4ca1e5b9a33013a90", size = 2674224, upload-time = "2024-11-05T17:30:49.972Z" },
{ url = "https://files.pythonhosted.org/packages/aa/14/e75ece72e99f6ef9ae07777ca9fdd78608f69466a5cecf636e9bd2f25d5c/tokenizers-0.20.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:61cbf11954f3b481d08723ebd048ba4b11e582986f9be74d2c3bdd9293a4538d", size = 2558991, upload-time = "2024-11-05T17:30:51.666Z" },
{ url = "https://files.pythonhosted.org/packages/46/54/033b5b2ba0c3ae01e026c6f7ced147d41a2fa1c573d00a66cb97f6d7f9b3/tokenizers-0.20.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef820880d5e4e8484e2fa54ff8d297bb32519eaa7815694dc835ace9130a3eea", size = 2892476, upload-time = "2024-11-05T17:30:53.505Z" },
{ url = "https://files.pythonhosted.org/packages/e6/b0/cc369fb3297d61f3311cab523d16d48c869dc2f0ba32985dbf03ff811041/tokenizers-0.20.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:67ef4dcb8841a4988cd00dd288fb95dfc8e22ed021f01f37348fd51c2b055ba9", size = 2802775, upload-time = "2024-11-05T17:30:55.229Z" },
{ url = "https://files.pythonhosted.org/packages/1a/74/62ad983e8ea6a63e04ed9c5be0b605056bf8aac2f0125f9b5e0b3e2b89fa/tokenizers-0.20.3-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff1ef8bd47a02b0dc191688ccb4da53600df5d4c9a05a4b68e1e3de4823e78eb", size = 3086138, upload-time = "2024-11-05T17:30:57.332Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ac/4637ba619db25094998523f9e6f5b456e1db1f8faa770a3d925d436db0c3/tokenizers-0.20.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:444d188186eab3148baf0615b522461b41b1f0cd58cd57b862ec94b6ac9780f1", size = 3098076, upload-time = "2024-11-05T17:30:59.455Z" },
{ url = "https://files.pythonhosted.org/packages/58/ce/9793f2dc2ce529369807c9c74e42722b05034af411d60f5730b720388c7d/tokenizers-0.20.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:37c04c032c1442740b2c2d925f1857885c07619224a533123ac7ea71ca5713da", size = 3379650, upload-time = "2024-11-05T17:31:01.264Z" },
{ url = "https://files.pythonhosted.org/packages/50/f6/2841de926bc4118af996eaf0bdf0ea5b012245044766ffc0347e6c968e63/tokenizers-0.20.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:453c7769d22231960ee0e883d1005c93c68015025a5e4ae56275406d94a3c907", size = 2994005, upload-time = "2024-11-05T17:31:02.985Z" },
{ url = "https://files.pythonhosted.org/packages/a3/b2/00915c4fed08e9505d37cf6eaab45b12b4bff8f6719d459abcb9ead86a4b/tokenizers-0.20.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4bb31f7b2847e439766aaa9cc7bccf7ac7088052deccdb2275c952d96f691c6a", size = 8977488, upload-time = "2024-11-05T17:31:04.424Z" },
{ url = "https://files.pythonhosted.org/packages/e9/ac/1c069e7808181ff57bcf2d39e9b6fbee9133a55410e6ebdaa89f67c32e83/tokenizers-0.20.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:843729bf0f991b29655a069a2ff58a4c24375a553c70955e15e37a90dd4e045c", size = 9294935, upload-time = "2024-11-05T17:31:06.882Z" },
{ url = "https://files.pythonhosted.org/packages/50/47/722feb70ee68d1c4412b12d0ea4acc2713179fd63f054913990f9e259492/tokenizers-0.20.3-cp311-none-win32.whl", hash = "sha256:efcce3a927b1e20ca694ba13f7a68c59b0bd859ef71e441db68ee42cf20c2442", size = 2197175, upload-time = "2024-11-05T17:31:09.385Z" },
{ url = "https://files.pythonhosted.org/packages/75/68/1b4f928b15a36ed278332ac75d66d7eb65d865bf344d049c452c18447bf9/tokenizers-0.20.3-cp311-none-win_amd64.whl", hash = "sha256:88301aa0801f225725b6df5dea3d77c80365ff2362ca7e252583f2b4809c4cc0", size = 2381616, upload-time = "2024-11-05T17:31:10.685Z" },
{ url = "https://files.pythonhosted.org/packages/07/00/92a08af2a6b0c88c50f1ab47d7189e695722ad9714b0ee78ea5e1e2e1def/tokenizers-0.20.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:49d12a32e190fad0e79e5bdb788d05da2f20d8e006b13a70859ac47fecf6ab2f", size = 2667951, upload-time = "2024-11-05T17:31:12.356Z" },
{ url = "https://files.pythonhosted.org/packages/ec/9a/e17a352f0bffbf415cf7d73756f5c73a3219225fc5957bc2f39d52c61684/tokenizers-0.20.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:282848cacfb9c06d5e51489f38ec5aa0b3cd1e247a023061945f71f41d949d73", size = 2555167, upload-time = "2024-11-05T17:31:13.839Z" },
{ url = "https://files.pythonhosted.org/packages/27/37/d108df55daf4f0fcf1f58554692ff71687c273d870a34693066f0847be96/tokenizers-0.20.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abe4e08c7d0cd6154c795deb5bf81d2122f36daf075e0c12a8b050d824ef0a64", size = 2898389, upload-time = "2024-11-05T17:31:15.12Z" },
{ url = "https://files.pythonhosted.org/packages/b2/27/32f29da16d28f59472fa7fb38e7782069748c7e9ab9854522db20341624c/tokenizers-0.20.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca94fc1b73b3883c98f0c88c77700b13d55b49f1071dfd57df2b06f3ff7afd64", size = 2795866, upload-time = "2024-11-05T17:31:16.857Z" },
{ url = "https://files.pythonhosted.org/packages/29/4e/8a9a3c89e128c4a40f247b501c10279d2d7ade685953407c4d94c8c0f7a7/tokenizers-0.20.3-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef279c7e239f95c8bdd6ff319d9870f30f0d24915b04895f55b1adcf96d6c60d", size = 3085446, upload-time = "2024-11-05T17:31:18.392Z" },
{ url = "https://files.pythonhosted.org/packages/b4/3b/a2a7962c496ebcd95860ca99e423254f760f382cd4bd376f8895783afaf5/tokenizers-0.20.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16384073973f6ccbde9852157a4fdfe632bb65208139c9d0c0bd0176a71fd67f", size = 3094378, upload-time = "2024-11-05T17:31:20.329Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f4/a8a33f0192a1629a3bd0afcad17d4d221bbf9276da4b95d226364208d5eb/tokenizers-0.20.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:312d522caeb8a1a42ebdec87118d99b22667782b67898a76c963c058a7e41d4f", size = 3385755, upload-time = "2024-11-05T17:31:21.778Z" },
{ url = "https://files.pythonhosted.org/packages/9e/65/c83cb3545a65a9eaa2e13b22c93d5e00bd7624b354a44adbdc93d5d9bd91/tokenizers-0.20.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2b7cb962564785a83dafbba0144ecb7f579f1d57d8c406cdaa7f32fe32f18ad", size = 2997679, upload-time = "2024-11-05T17:31:23.134Z" },
{ url = "https://files.pythonhosted.org/packages/55/e9/a80d4e592307688a67c7c59ab77e03687b6a8bd92eb5db763a2c80f93f57/tokenizers-0.20.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:124c5882ebb88dadae1fc788a582299fcd3a8bd84fc3e260b9918cf28b8751f5", size = 8989296, upload-time = "2024-11-05T17:31:24.953Z" },
{ url = "https://files.pythonhosted.org/packages/90/af/60c957af8d2244321124e893828f1a4817cde1a2d08d09d423b73f19bd2f/tokenizers-0.20.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2b6e54e71f84c4202111a489879005cb14b92616a87417f6c102c833af961ea2", size = 9303621, upload-time = "2024-11-05T17:31:27.341Z" },
{ url = "https://files.pythonhosted.org/packages/be/a9/96172310ee141009646d63a1ca267c099c462d747fe5ef7e33f74e27a683/tokenizers-0.20.3-cp312-none-win32.whl", hash = "sha256:83d9bfbe9af86f2d9df4833c22e94d94750f1d0cd9bfb22a7bb90a86f61cdb1c", size = 2188979, upload-time = "2024-11-05T17:31:29.483Z" },
{ url = "https://files.pythonhosted.org/packages/bd/68/61d85ae7ae96dde7d0974ff3538db75d5cdc29be2e4329cd7fc51a283e22/tokenizers-0.20.3-cp312-none-win_amd64.whl", hash = "sha256:44def74cee574d609a36e17c8914311d1b5dbcfe37c55fd29369d42591b91cf2", size = 2380725, upload-time = "2024-11-05T17:31:31.315Z" },
{ url = "https://files.pythonhosted.org/packages/07/19/36e9eaafb229616cb8502b42030fa7fe347550e76cb618de71b498fc3222/tokenizers-0.20.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e0b630e0b536ef0e3c8b42c685c1bc93bd19e98c0f1543db52911f8ede42cf84", size = 2666813, upload-time = "2024-11-05T17:31:32.783Z" },
{ url = "https://files.pythonhosted.org/packages/b9/c7/e2ce1d4f756c8a62ef93fdb4df877c2185339b6d63667b015bf70ea9d34b/tokenizers-0.20.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a02d160d2b19bcbfdf28bd9a4bf11be4cb97d0499c000d95d4c4b1a4312740b6", size = 2555354, upload-time = "2024-11-05T17:31:34.208Z" },
{ url = "https://files.pythonhosted.org/packages/7c/cf/5309c2d173a6a67f9ec8697d8e710ea32418de6fd8541778032c202a1c3e/tokenizers-0.20.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e3d80d89b068bc30034034b5319218c7c0a91b00af19679833f55f3becb6945", size = 2897745, upload-time = "2024-11-05T17:31:35.733Z" },
{ url = "https://files.pythonhosted.org/packages/2c/e5/af3078e32f225e680e69d61f78855880edb8d53f5850a1834d519b2b103f/tokenizers-0.20.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:174a54910bed1b089226512b4458ea60d6d6fd93060254734d3bc3540953c51c", size = 2794385, upload-time = "2024-11-05T17:31:37.497Z" },
{ url = "https://files.pythonhosted.org/packages/0b/a7/bc421fe46650cc4eb4a913a236b88c243204f32c7480684d2f138925899e/tokenizers-0.20.3-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:098b8a632b8656aa5802c46689462c5c48f02510f24029d71c208ec2c822e771", size = 3084580, upload-time = "2024-11-05T17:31:39.456Z" },
{ url = "https://files.pythonhosted.org/packages/c6/22/97e1e95ee81f75922c9f569c23cb2b1fdc7f5a7a29c4c9fae17e63f751a6/tokenizers-0.20.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:78c8c143e3ae41e718588281eb3e212c2b31623c9d6d40410ec464d7d6221fb5", size = 3093581, upload-time = "2024-11-05T17:31:41.224Z" },
{ url = "https://files.pythonhosted.org/packages/d5/14/f0df0ee3b9e516121e23c0099bccd7b9f086ba9150021a750e99b16ce56f/tokenizers-0.20.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b26b0aadb18cd8701077362ba359a06683662d5cafe3e8e8aba10eb05c037f1", size = 3385934, upload-time = "2024-11-05T17:31:43.811Z" },
{ url = "https://files.pythonhosted.org/packages/66/52/7a171bd4929e3ffe61a29b4340fe5b73484709f92a8162a18946e124c34c/tokenizers-0.20.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07d7851a72717321022f3774e84aa9d595a041d643fafa2e87fbc9b18711dac0", size = 2997311, upload-time = "2024-11-05T17:31:46.224Z" },
{ url = "https://files.pythonhosted.org/packages/7c/64/f1993bb8ebf775d56875ca0d50a50f2648bfbbb143da92fe2e6ceeb4abd5/tokenizers-0.20.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:bd44e48a430ada902c6266a8245f5036c4fe744fcb51f699999fbe82aa438797", size = 8988601, upload-time = "2024-11-05T17:31:47.907Z" },
{ url = "https://files.pythonhosted.org/packages/d6/3f/49fa63422159bbc2f2a4ac5bfc597d04d4ec0ad3d2ef46649b5e9a340e37/tokenizers-0.20.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a4c186bb006ccbe1f5cc4e0380d1ce7806f5955c244074fd96abc55e27b77f01", size = 9303950, upload-time = "2024-11-05T17:31:50.674Z" },
{ url = "https://files.pythonhosted.org/packages/66/11/79d91aeb2817ad1993ef61c690afe73e6dbedbfb21918b302ef5a2ba9bfb/tokenizers-0.20.3-cp313-none-win32.whl", hash = "sha256:6e19e0f1d854d6ab7ea0c743d06e764d1d9a546932be0a67f33087645f00fe13", size = 2188941, upload-time = "2024-11-05T17:31:53.334Z" },
{ url = "https://files.pythonhosted.org/packages/c2/ff/ac8410f868fb8b14b5e619efa304aa119cb8a40bd7df29fc81a898e64f99/tokenizers-0.20.3-cp313-none-win_amd64.whl", hash = "sha256:d50ede425c7e60966a9680d41b58b3a0950afa1bb570488e2972fa61662c4273", size = 2380269, upload-time = "2024-11-05T17:31:54.796Z" },
{ url = "https://files.pythonhosted.org/packages/29/cd/ff1586dd572aaf1637d59968df3f6f6532fa255f4638fbc29f6d27e0b690/tokenizers-0.20.3-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e919f2e3e68bb51dc31de4fcbbeff3bdf9c1cad489044c75e2b982a91059bd3c", size = 2672044, upload-time = "2024-11-05T17:33:07.796Z" },
{ url = "https://files.pythonhosted.org/packages/b5/9e/7a2c00abbc8edb021ee0b1f12aab76a7b7824b49f94bcd9f075d0818d4b0/tokenizers-0.20.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b8e9608f2773996cc272156e305bd79066163a66b0390fe21750aff62df1ac07", size = 2558841, upload-time = "2024-11-05T17:33:09.542Z" },
{ url = "https://files.pythonhosted.org/packages/8e/c1/6af62ef61316f33ecf785bbb2bee4292f34ea62b491d4480ad9b09acf6b6/tokenizers-0.20.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:39270a7050deaf50f7caff4c532c01b3c48f6608d42b3eacdebdc6795478c8df", size = 2897936, upload-time = "2024-11-05T17:33:11.413Z" },
{ url = "https://files.pythonhosted.org/packages/9a/0b/c076b2ff3ee6dc70c805181fbe325668b89cfee856f8dfa24cc9aa293c84/tokenizers-0.20.3-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e005466632b1c5d2d2120f6de8aa768cc9d36cd1ab7d51d0c27a114c91a1e6ee", size = 3082688, upload-time = "2024-11-05T17:33:13.538Z" },
{ url = "https://files.pythonhosted.org/packages/0a/60/56510124933136c2e90879e1c81603cfa753ae5a87830e3ef95056b20d8f/tokenizers-0.20.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a07962340b36189b6c8feda552ea1bfeee6cf067ff922a1d7760662c2ee229e5", size = 2998924, upload-time = "2024-11-05T17:33:16.249Z" },
{ url = "https://files.pythonhosted.org/packages/68/60/4107b618b7b9155cb34ad2e0fc90946b7e71f041b642122fb6314f660688/tokenizers-0.20.3-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:55046ad3dd5f2b3c67501fcc8c9cbe3e901d8355f08a3b745e9b57894855f85b", size = 8989514, upload-time = "2024-11-05T17:33:18.161Z" },
{ url = "https://files.pythonhosted.org/packages/e8/bd/48475818e614b73316baf37ac1e4e51b578bbdf58651812d7e55f43b88d8/tokenizers-0.20.3-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:efcf0eb939988b627558aaf2b9dc3e56d759cad2e0cfa04fcab378e4b48fc4fd", size = 9303476, upload-time = "2024-11-05T17:33:21.251Z" },
{ url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" },
{ url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" },
{ url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" },
{ url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" },
{ url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" },
{ url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" },
{ url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" },
{ url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" },
{ url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" },
{ url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" },
{ url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" },
{ url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" },
{ url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" },
{ url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" },
{ url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" },
{ url = "https://files.pythonhosted.org/packages/84/04/655b79dbcc9b3ac5f1479f18e931a344af67e5b7d3b251d2dcdcd7558592/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:753d47ebd4542742ef9261d9da92cd545b2cacbb48349a1225466745bb866ec4", size = 3282301, upload-time = "2026-01-05T10:40:34.858Z" },
{ url = "https://files.pythonhosted.org/packages/46/cd/e4851401f3d8f6f45d8480262ab6a5c8cb9c4302a790a35aa14eeed6d2fd/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e10bf9113d209be7cd046d40fbabbaf3278ff6d18eb4da4c500443185dc1896c", size = 3161308, upload-time = "2026-01-05T10:40:40.737Z" },
{ url = "https://files.pythonhosted.org/packages/6f/6e/55553992a89982cd12d4a66dddb5e02126c58677ea3931efcbe601d419db/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:64d94e84f6660764e64e7e0b22baa72f6cd942279fdbb21d46abd70d179f0195", size = 3718964, upload-time = "2026-01-05T10:40:46.56Z" },
{ url = "https://files.pythonhosted.org/packages/59/8c/b1c87148aa15e099243ec9f0cf9d0e970cc2234c3257d558c25a2c5304e6/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f01a9c019878532f98927d2bacb79bbb404b43d3437455522a00a30718cdedb5", size = 3373542, upload-time = "2026-01-05T10:40:52.803Z" },
]
[[package]]
@@ -1,6 +1,6 @@
{
"version": "1.5.87",
"lastUpdated": "2026-05-08",
"lastUpdated": "2026-05-18",
"generatedBy": "praisonai._dev.parity.generator",
"sourceOfTruth": "Python SDK (praisonaiagents)",
"status": "PARITY_ACHIEVED",
@@ -1725,6 +1725,7 @@
"planning": [],
"plugins": [
"PluginRegistry",
"ToolDefinition",
"disable_plugins",
"enable_plugins",
"get_plugin_manager",
@@ -1749,7 +1750,6 @@
"tools": [
"FunctionTool",
"Tool",
"ToolDefinition",
"ToolRegistry",
"ToolResult"
],
+1 -1
View File
@@ -1,6 +1,6 @@
{
"version": "1.5.87",
"lastUpdated": "2026-05-08",
"lastUpdated": "2026-05-18",
"generatedBy": "praisonai._dev.parity.generator",
"sourceOfTruth": "Python SDK (praisonaiagents)",
"summary": {
+1 -1
View File
@@ -1,6 +1,6 @@
# Feature Parity Tracker
> **Version:** 1.5.87 | **Last Updated:** 2026-05-08
> **Version:** 1.5.87 | **Last Updated:** 2026-05-18
> **Source of Truth:** Python SDK (praisonaiagents)
## Summary
+39 -2
View File
@@ -294,8 +294,35 @@ def search(query: str) -> str:
@tool
def calculate(expression: str) -> float:
"""Evaluate a math expression."""
return eval(expression)
"""Safely evaluate a numeric arithmetic expression."""
import ast
import operator
# Define allowed operations
_OPS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.Pow: operator.pow,
ast.USub: operator.neg,
ast.UAdd: operator.pos,
}
def _safe_eval(node):
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value
elif isinstance(node, ast.BinOp) and type(node.op) in _OPS:
return _OPS[type(node.op)](_safe_eval(node.left), _safe_eval(node.right))
elif isinstance(node, ast.UnaryOp) and type(node.op) in _OPS:
return _OPS[type(node.op)](_safe_eval(node.operand))
else:
raise ValueError("Unsupported expression")
try:
return _safe_eval(ast.parse(expression, mode="eval").body)
except (ValueError, SyntaxError, TypeError, ZeroDivisionError, OverflowError):
raise ValueError("Invalid arithmetic expression")
agent = Agent(
instructions="You are a helpful assistant",
@@ -304,6 +331,7 @@ agent = Agent(
agent.start("Search for AI news and calculate 15*4")
```
> ⚠️ **Security Note:** Never use `eval()`, `exec()`, or `subprocess` in tool functions that process LLM-generated or user-supplied input. Always validate and sanitize inputs to prevent code injection attacks.
> 📖 [Full tools docs](https://docs.praison.ai/docs/tools/tools) — BaseTool, tool packages, 100+ built-in tools
### 5. Persistence (Databases)
@@ -330,6 +358,15 @@ pip install "praisonai[claw]"
praisonai claw
```
#### Required Environment Variables
Copy `.env.example` to `.env` and configure the following variables:
| Variable | Required | Description |
|----------|----------|-------------|
| `OPENAI_API_KEY` | Yes | OpenAI API key for all LLM calls |
| `TAVILY_API_KEY` | Yes (Claw) | Tavily key for the built-in web-search tool. Get one free at https://app.tavily.com |
Open **http://localhost:8082** — the dashboard comes with 13 built-in pages: Chat, Agents, Memory, Knowledge, Channels, Guardrails, Cron, and more. Add messaging channels directly from the UI.
> 📖 [Full Claw docs](https://docs.praison.ai/docs/concepts/claw) — platform tokens, CLI options, Docker, and YAML agent mode
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.37.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.37.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.39.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.39.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
+36 -9
View File
@@ -36,10 +36,34 @@ __all__ = [
_telemetry_lock = threading.Lock()
_telemetry_initialized = False
def _get_telemetry_defaults() -> dict[str, str]:
"""Get telemetry environment defaults without mutating os.environ.
Returns:
Dict of environment variable defaults for telemetry
"""
import os
defaults = {}
# Respect any value the user already set
if "OTEL_SDK_DISABLED" not in os.environ:
langfuse_configured = bool(
os.getenv("LANGFUSE_PUBLIC_KEY")
or os.path.exists(os.path.expanduser("~/.praisonai/langfuse.env"))
)
defaults["OTEL_SDK_DISABLED"] = "false" if langfuse_configured else "true"
if "EC_TELEMETRY" not in os.environ:
defaults["EC_TELEMETRY"] = "false"
return defaults
def _ensure_telemetry_defaults() -> None:
"""Apply telemetry env defaults exactly once, on first observability use.
Thread-safe implementation using double-checked locking pattern.
DEPRECATED: Use _apply_telemetry_defaults() with explicit config instead.
"""
global _telemetry_initialized
if _telemetry_initialized: # fast path, OK without lock
@@ -47,18 +71,21 @@ def _ensure_telemetry_defaults() -> None:
with _telemetry_lock:
if _telemetry_initialized:
return
import os
# Respect any value the user already set
if "OTEL_SDK_DISABLED" not in os.environ:
langfuse_configured = bool(
os.getenv("LANGFUSE_PUBLIC_KEY")
or os.path.exists(os.path.expanduser("~/.praisonai/langfuse.env"))
)
os.environ["OTEL_SDK_DISABLED"] = "false" if langfuse_configured else "true"
os.environ.setdefault("EC_TELEMETRY", "false") # respect user overrides
_apply_telemetry_defaults(_get_telemetry_defaults())
_telemetry_initialized = True
def _apply_telemetry_defaults(env_vars: dict[str, str]) -> None:
"""Apply telemetry defaults to os.environ.
Args:
env_vars: Dict of environment variables to set
"""
import os
for key, value in env_vars.items():
os.environ.setdefault(key, value)
# Lazy loading for heavy imports
def __getattr__(name):
"""Lazy load heavy modules to improve import time."""
+228
View File
@@ -0,0 +1,228 @@
"""Generic plugin registry implementation for PraisonAI.
This module provides a unified registry pattern to replace the divergent singleton
implementations across the codebase. It supports both built-in and entry-point
based plugin discovery with thread-safe registration and dependency injection.
"""
from __future__ import annotations
import threading
from importlib.metadata import entry_points
from typing import Callable, Dict, Generic, Optional, Type, TypeVar
import logging
T = TypeVar("T")
logger = logging.getLogger(__name__)
class PluginRegistry(Generic[T]):
"""Generic plugin registry: builtins + entry points + runtime register().
This replaces the singleton pattern with dependency injection while maintaining
the same functionality. Supports:
- Built-in plugins with lazy loading
- Entry points discovery
- Runtime registration
- Alias support for alternative names
- Thread-safe operations
"""
def __init__(
self,
*,
entry_point_group: str,
builtins: Optional[Dict[str, Callable[[], Type[T]]]] = None
) -> None:
"""Initialize the registry.
Args:
entry_point_group: Entry points group name for plugin discovery
builtins: Dict of name -> loader function for built-in plugins
"""
self._entry_point_group = entry_point_group
self._items: Dict[str, Type[T]] = {}
self._aliases: Dict[str, str] = {} # alias -> canonical name
self._lock = threading.Lock()
# Load built-in plugins with error handling
if builtins:
for name, loader in builtins.items():
try:
self._items[name] = loader()
except ImportError:
# Built-in plugin dependencies not available, skip
pass
except Exception:
# Log other errors but don't crash initialization
logger.warning("Failed to load built-in plugin %r", name, exc_info=True)
self._load_entry_points()
def _load_entry_points(self) -> None:
"""Load plugins from entry points."""
try:
for ep in entry_points(group=self._entry_point_group):
try:
plugin_class = ep.load()
with self._lock:
self._items[ep.name] = plugin_class
except Exception:
# Do not break plugin dispatch because one plugin is broken
logger.warning(
"Failed to load plugin %r from entry point",
ep.name,
exc_info=True,
)
except Exception:
# entry_points() might not be available in older Python versions
logger.debug("Entry points not available for group %s", self._entry_point_group)
def register(
self,
name: str,
cls: Type[T],
*,
aliases: Optional[list[str]] = None
) -> None:
"""Register a plugin at runtime.
Args:
name: Plugin name
cls: Plugin class
aliases: Optional list of alias names for this plugin
"""
with self._lock:
canonical_name = name.lower()
self._items[canonical_name] = cls
# Register aliases
if aliases:
for alias in aliases:
normalized_alias = alias.lower()
self._aliases[normalized_alias] = canonical_name
def unregister(self, name: str) -> bool:
"""Unregister a plugin.
Args:
name: Plugin name
Returns:
True if plugin was found and removed, False otherwise
"""
with self._lock:
normalized_name = name.lower()
# Check if it's an alias
if normalized_name in self._aliases:
del self._aliases[normalized_name]
return True
# Check if it's a canonical name
if normalized_name in self._items:
# Remove all aliases pointing to this plugin
aliases_to_remove = [
alias for alias, canonical in self._aliases.items()
if canonical == normalized_name
]
for alias in aliases_to_remove:
del self._aliases[alias]
del self._items[normalized_name]
return True
return False
def resolve(self, name: str) -> Type[T]:
"""Resolve a plugin name to its class.
Args:
name: Plugin name
Returns:
Plugin class
Raises:
ValueError: If plugin is not found
"""
with self._lock:
normalized_name = name.lower()
# Resolve alias to canonical name
canonical_name = self._aliases.get(normalized_name, normalized_name)
cls = self._items.get(canonical_name)
# Capture available plugins snapshot while holding lock
# to avoid race condition between check and error message
if cls is None:
available_plugins = sorted(self._items.keys())
available_aliases = sorted(self._aliases.keys())
available_snapshot = available_plugins + available_aliases
else:
available_snapshot = None
if cls is None:
raise ValueError(
f"Unknown {self._entry_point_group} plugin: {name!r}. "
f"Available: {available_snapshot}"
)
return cls
def create(self, name: str, *args, **kwargs) -> T:
"""Create an instance of the specified plugin.
Args:
name: Plugin name
*args, **kwargs: Arguments to pass to plugin constructor
Returns:
Plugin instance
Raises:
ValueError: If plugin is not found
"""
cls = self.resolve(name)
return cls(*args, **kwargs)
def list_names(self) -> list[str]:
"""List all registered plugin names.
Returns:
Sorted list of plugin names
"""
with self._lock:
return sorted(self._items.keys())
def is_available(self, name: str) -> bool:
"""Check if a plugin is available.
Args:
name: Plugin name
Returns:
True if plugin exists and can be created
"""
try:
self.resolve(name)
return True
except ValueError:
return False
def list_aliases(self) -> Dict[str, str]:
"""List all aliases and their target plugins.
Returns:
Dict mapping alias -> canonical name
"""
with self._lock:
return dict(self._aliases)
def list_all_names(self) -> list[str]:
"""List all names including aliases.
Returns:
Sorted list of all registered names and aliases
"""
with self._lock:
return sorted(list(self._items.keys()) + list(self._aliases.keys()))
+68 -83
View File
@@ -19,7 +19,7 @@ import difflib
# Import new architecture components
from .framework_adapters.base import FrameworkAdapter
from .framework_adapters.registry import FrameworkAdapterRegistry
from .framework_adapters.registry import FrameworkAdapterRegistry, get_default_registry
from .tool_registry import ToolRegistry
# Import availability flags
@@ -43,10 +43,12 @@ except ImportError:
# Check for additional framework availability
AG2_AVAILABLE = False
PRAISONAI_AVAILABLE = False
AGENTOPS_AVAILABLE = False
try:
import importlib.util
AG2_AVAILABLE = importlib.util.find_spec("ag2") is not None
PRAISONAI_AVAILABLE = importlib.util.find_spec("praisonaiagents") is not None
AGENTOPS_AVAILABLE = importlib.util.find_spec("agentops") is not None
except ImportError:
pass
@@ -179,7 +181,7 @@ def _resolve_yaml_cli_backend(cli_backend_config, logger):
class AgentsGenerator:
def __init__(self, agent_file, framework, config_list, log_level=None, agent_callback=None, task_callback=None, agent_yaml=None, tools=None, cli_config=None):
def __init__(self, agent_file, framework, config_list, log_level=None, agent_callback=None, task_callback=None, agent_yaml=None, tools=None, cli_config=None, adapter_registry=None):
"""
Initialize the AgentsGenerator object.
@@ -193,6 +195,7 @@ class AgentsGenerator:
agent_yaml (str, optional): The content of the YAML file. Defaults to None.
tools (dict, optional): A dictionary containing the tools to be used for the agents. Defaults to None.
cli_config (dict, optional): CLI configuration to override YAML settings. Defaults to None.
adapter_registry (FrameworkAdapterRegistry, optional): Registry for framework adapters. Defaults to process default.
Attributes:
agent_file (str): The path to the agent file.
@@ -233,6 +236,10 @@ class AgentsGenerator:
self.tool_registry = ToolRegistry()
self.tool_registry.register_builtin_autogen_adapters()
# DI-friendly: tests/multi-tenant runtimes pass their own registry;
# CLI users get the process default.
self._adapter_registry = adapter_registry or get_default_registry()
# Get framework adapter (availability already validated at CLI entry)
self.framework_adapter = self._get_framework_adapter(framework)
@@ -249,8 +256,7 @@ class AgentsGenerator:
Raises:
ValueError: If framework is not supported
"""
adapter_registry = FrameworkAdapterRegistry.get_instance()
return adapter_registry.create(framework)
return self._adapter_registry.create(framework)
def _merge_cli_config(self, config, cli_config):
"""
@@ -375,20 +381,19 @@ class AgentsGenerator:
def load_tools_from_module(self, module_path):
"""
Loads tools from a specified module path.
Load function tools from a user-supplied module (gated by PRAISONAI_ALLOW_LOCAL_TOOLS).
Parameters:
module_path (str): The path to the module containing the tools.
Returns:
dict: A dictionary containing the names of the tools as keys and the corresponding functions or objects as values.
Raises:
FileNotFoundError: If the specified module path does not exist.
Returns an empty dict if the module cannot be loaded (path missing, loading blocked by PRAISONAI_ALLOW_LOCAL_TOOLS, or any other load error).
"""
spec = importlib.util.spec_from_file_location("tools_module", module_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
from ._safe_loader import load_user_module
module = load_user_module(module_path, name="tools_module")
if module is None:
return {}
return {name: obj for name, obj in inspect.getmembers(module, self.is_function_or_decorated)}
def _extract_tool_classes(self, module):
@@ -411,21 +416,13 @@ class AgentsGenerator:
def load_tools_from_module_class(self, module_path):
"""
Loads tools from a specified module path containing classes that inherit from BaseTool
or are part of langchain_community.tools package.
Load BaseTool / langchain tool classes from a user-supplied module (gated by PRAISONAI_ALLOW_LOCAL_TOOLS).
"""
spec = importlib.util.spec_from_file_location("tools_module", module_path)
module = importlib.util.module_from_spec(spec)
try:
spec.loader.exec_module(module)
return {name: obj() for name, obj in inspect.getmembers(module,
lambda x: inspect.isclass(x) and (
x.__module__.startswith('langchain_community.tools') or
(PRAISONAI_TOOLS_AVAILABLE and BaseTool and issubclass(x, BaseTool))
) and x is not BaseTool)}
except ImportError as e:
self.logger.warning(f"Error loading tools from {module_path}: {e}")
from ._safe_loader import load_user_module
module = load_user_module(module_path, name="tools_module")
if module is None:
return {}
return self._extract_tool_classes(module)
def load_tools_from_package(self, package_path):
"""
@@ -451,36 +448,6 @@ class AgentsGenerator:
tools_dict[name] = obj
return tools_dict
def load_tools_from_tools_py(self):
"""
Imports and returns all contents from tools.py file.
Uses the tool registry instead of global namespace pollution.
Returns:
list: A list of callable functions with proper formatting
"""
tools_list = []
try:
# Try to import tools.py from current directory using safe loading
from ._safe_loader import load_user_module
module = load_user_module("tools.py", name="tools")
if module is None:
self.logger.debug("tools.py not found or local tools loading disabled")
return tools_list
# Register functions in the tool registry instead of globals()
registered_tools = self.tool_registry.register_from_module(module)
tools_list = [self.tool_registry.get_function(name) for name in registered_tools]
self.logger.debug(f"Loaded {len(tools_list)} tool functions from tools.py")
self.logger.debug(f"Registered tools: {registered_tools}")
except FileNotFoundError:
self.logger.debug("tools.py not found in current directory")
except Exception as e:
self.logger.warning(f"Error loading tools from tools.py: {e}")
return tools_list
def generate_crew_and_kickoff(self):
"""
@@ -590,12 +557,10 @@ class AgentsGenerator:
tools_py_path = os.path.join(root_directory, 'tools.py')
tools_dir_path = Path(root_directory) / 'tools'
# Use consolidated ToolResolver for tools.py loading
tools_dict.update(self.tool_resolver.get_local_tool_classes())
if os.path.isfile(tools_py_path):
from ._safe_loader import load_user_module
module = load_user_module(tools_py_path, name="tools_module")
if module is not None:
tools_dict.update(self._extract_tool_classes(module))
self.logger.debug("tools.py exists in the root directory. Loading tools.py and skipping tools folder.")
self.logger.debug("tools.py exists in the root directory. Loading tools.py and skipping tools folder.")
elif tools_dir_path.is_dir():
from ._safe_loader import load_user_module
for py_file in tools_dir_path.glob("*.py"):
@@ -784,7 +749,11 @@ class AgentsGenerator:
result = "### Output ###\n" + response[-1].summary if hasattr(response[-1], 'summary') else ""
if AGENTOPS_AVAILABLE:
agentops.end_session("Success")
import agentops
try:
agentops.end_session("Success")
except Exception as e: # noqa: BLE001 -- agentops errors must not crash the caller
self.logger.warning(f"agentops.end_session failed: {e}")
return result
@@ -1171,7 +1140,11 @@ class AgentsGenerator:
result = f"### Task Output ###\n{response}"
if AGENTOPS_AVAILABLE:
agentops.end_session("Success")
import agentops
try:
agentops.end_session("Success")
except Exception as e: # noqa: BLE001 -- agentops errors must not crash the caller
self.logger.warning(f"agentops.end_session failed: {e}")
return result
@@ -1190,8 +1163,8 @@ class AgentsGenerator:
# Use existing tool resolver instance
# Load tools from local tools.py (backward compat)
tools_list = self.load_tools_from_tools_py()
# Load tools from local tools.py (backward compat) - use consolidated ToolResolver
tools_list = self.tool_resolver.get_local_callables()
self.logger.debug(f"Loaded tools from tools.py: {tools_list}")
# Initialize InteractiveRuntime for ACP/LSP if enabled globally
@@ -1199,9 +1172,9 @@ class AgentsGenerator:
acp_enabled = global_config.get('acp', False)
lsp_enabled = global_config.get('lsp', False)
interactive_runtime = None
interactive_loop = None
if acp_enabled or lsp_enabled:
runtime_started = False
try:
import asyncio
from praisonai.cli.features.interactive_runtime import InteractiveRuntime, RuntimeConfig
@@ -1217,27 +1190,34 @@ class AgentsGenerator:
interactive_runtime = InteractiveRuntime(runtime_config)
self.logger.info(f"Starting InteractiveRuntime (ACP: {acp_enabled}, LSP: {lsp_enabled})")
# Create a scoped event loop instead of modifying process globals
interactive_loop = asyncio.new_event_loop()
try:
interactive_loop.run_until_complete(interactive_runtime.start())
centric_tools = create_agent_centric_tools(interactive_runtime)
self.logger.info(f"Loaded {len(centric_tools)} InteractiveRuntime tools")
tools_list.extend(centric_tools)
finally:
try:
interactive_loop.run_until_complete(interactive_runtime.stop())
except Exception as stop_error:
self.logger.warning(f"Error stopping InteractiveRuntime: {stop_error}")
finally:
interactive_loop.close()
# Runs on the persistent background loop; safe from sync and async callers.
# run_sync raises RuntimeError early if called from inside a running loop
# so the bug is loud instead of a deadlock.
from ._async_bridge import run_sync
run_sync(interactive_runtime.start())
runtime_started = True
centric_tools = create_agent_centric_tools(interactive_runtime)
self.logger.info(f"Loaded {len(centric_tools)} InteractiveRuntime tools")
tools_list.extend(centric_tools)
except ImportError as e:
self.logger.warning(f"Failed to load InteractiveRuntime components: {e}")
interactive_runtime = None
except RuntimeError:
# Don't swallow RuntimeError from run_sync - preserve fail-fast semantics
raise
except Exception as e:
if runtime_started and interactive_runtime is not None:
try:
from ._async_bridge import run_sync
run_sync(interactive_runtime.stop())
except Exception as stop_error:
self.logger.error(
f"Error stopping partially started InteractiveRuntime: {stop_error}"
)
self.logger.error(f"Error starting InteractiveRuntime: {e}")
interactive_runtime = None
# Create agents from config
for role, details in config['roles'].items():
@@ -1471,14 +1451,19 @@ class AgentsGenerator:
self.logger.debug(f"Result: {response}")
result = response if response else ""
finally:
if interactive_runtime and interactive_loop:
if interactive_runtime:
try:
self.logger.info("Stopping InteractiveRuntime...")
interactive_loop.run_until_complete(interactive_runtime.stop())
from ._async_bridge import run_sync
run_sync(interactive_runtime.stop())
except Exception as e:
self.logger.error(f"Error stopping InteractiveRuntime: {e}")
if AGENTOPS_AVAILABLE:
agentops.end_session("Success")
import agentops
try:
agentops.end_session("Success")
except Exception as e: # noqa: BLE001 -- agentops errors must not crash the caller
self.logger.warning(f"agentops.end_session failed: {e}")
return result
@@ -250,10 +250,21 @@ class AsyncAgentScheduler:
self.is_running = False
logger.info("Async agent scheduler stopped")
logger.info(f"Execution stats - Total: {self._execution_count}, Success: {self._success_count}, Failed: {self._failure_count}")
# Log final stats with consistent snapshot
if self._stats_lock is not None:
async with self._stats_lock:
total = self._execution_count
ok = self._success_count
fail = self._failure_count
else:
total = self._execution_count
ok = self._success_count
fail = self._failure_count
logger.info(f"Execution stats - Total: {total}, Success: {ok}, Failed: {fail}")
return True
def get_stats(self) -> Dict[str, Any]:
async def get_stats(self) -> Dict[str, Any]:
"""
Get current execution statistics (synchronous, best-effort).
@@ -264,12 +275,26 @@ class AsyncAgentScheduler:
Returns:
Dictionary with execution stats
"""
# Primitives are created lazily in _ensure_async_primitives(); if not yet
# initialized there are no concurrent writers, so a direct read is safe.
if self._stats_lock is not None:
async with self._stats_lock:
total = self._execution_count
ok = self._success_count
fail = self._failure_count
running = self.is_running
else:
total = self._execution_count
ok = self._success_count
fail = self._failure_count
running = self.is_running
return {
"is_running": self.is_running,
"total_executions": self._execution_count,
"successful_executions": self._success_count,
"failed_executions": self._failure_count,
"success_rate": (self._success_count / self._execution_count * 100) if self._execution_count > 0 else 0
"is_running": running,
"total_executions": total,
"successful_executions": ok,
"failed_executions": fail,
"success_rate": (ok / total * 100) if total > 0 else 0
}
async def get_stats_async(self) -> Dict[str, Any]:
@@ -327,11 +352,8 @@ class AsyncAgentScheduler:
async def _execute_with_retry(self, max_retries: int):
"""Execute agent with retry logic."""
# Ensure async primitives are available
if self._stats_lock is None:
self._ensure_async_primitives()
# Atomically increment execution count
self._ensure_async_primitives() # guarantees _stats_lock is bound to current loop
async with self._stats_lock:
self._execution_count += 1
@@ -344,7 +366,6 @@ class AsyncAgentScheduler:
logger.info(f"Async agent execution successful on attempt {attempt + 1}")
logger.info(f"Result: {result}")
# Atomically increment success count
async with self._stats_lock:
self._success_count += 1
safe_call(self.on_success, result)
@@ -359,7 +380,6 @@ class AsyncAgentScheduler:
logger.info(f"Waiting {wait_time}s before async retry...")
await asyncio.sleep(wait_time)
# Atomically increment failure count
async with self._stats_lock:
self._failure_count += 1
logger.error(f"Async agent execution failed after {max_retries} attempts")
+41 -48
View File
@@ -15,9 +15,8 @@ from typing import Any, Dict, List, Optional, Type, TypeVar
import os
import json
import yaml
from rich import print
import threading
from collections import OrderedDict
from rich import print
from praisonai._logging import get_logger
# Type variable for Pydantic models
@@ -59,10 +58,6 @@ def _load_optional(key: str, loader):
return _optional_cache[key]
# Bounded LRU cache for OpenAI clients (one per (api_key, base_url) tuple)
_OPENAI_CLIENT_CACHE_MAX = 8
_openai_clients: "OrderedDict[tuple, object]" = OrderedDict()
_openai_clients_lock = threading.Lock()
# --- CrewAI lazy loading ---
@@ -199,38 +194,6 @@ def _check_openai_available() -> bool:
return result is not None
def _get_openai_client(api_key: str = None, base_url: str = None):
"""Lazy load OpenAI client with bounded LRU cache (thread-safe, multi-tenant).
Multi-tenant safe: each (api_key, base_url) tuple gets its own cached client.
Bounded by _OPENAI_CLIENT_CACHE_MAX with proper LRU eviction.
"""
key = (api_key or os.environ.get("OPENAI_API_KEY"), base_url)
with _openai_clients_lock:
# Check if client exists and update LRU position
client = _openai_clients.get(key)
if client is not None:
_openai_clients.move_to_end(key)
return client
# Create new client
try:
from openai import OpenAI
except ImportError as e:
raise ImportError("Install with: pip install openai") from e
client = OpenAI(api_key=key[0], base_url=key[1])
_openai_clients[key] = client
# Bound the cache; close the LRU victim
if len(_openai_clients) > _OPENAI_CLIENT_CACHE_MAX:
_, victim = _openai_clients.popitem(last=False)
try:
victim.close()
except Exception:
pass # Best-effort cleanup
return client
# Use namespaced logger; root logger is configured only by the CLI
@@ -478,6 +441,38 @@ class BaseAutoGenerator:
'api_key': ep.api_key
}
]
self._openai_client = None # lazy, per-instance
self._openai_client_lock = threading.Lock()
def _get_openai_client(self):
"""Get or create the OpenAI client for this instance."""
if self._openai_client is None:
with self._openai_client_lock:
if self._openai_client is None:
try:
from openai import OpenAI
except ImportError as e:
raise ImportError("Install with: pip install openai") from e
cfg = self.config_list[0]
self._openai_client = OpenAI(
api_key=cfg.get("api_key") or os.environ.get("OPENAI_API_KEY"),
base_url=cfg.get("base_url"),
)
return self._openai_client
def close(self):
"""Close the OpenAI client if it exists."""
if not hasattr(self, '_openai_client_lock'):
return # Object was never fully initialized
with self._openai_client_lock:
client = getattr(self, '_openai_client', None)
self._openai_client = None
if client is not None:
client.close()
def __del__(self):
"""Best-effort cleanup, but the canonical path is explicit close()."""
self.close()
def _structured_completion(self, response_model: Type[T], messages: List[Dict], **kwargs) -> T:
"""
@@ -514,10 +509,7 @@ class BaseAutoGenerator:
# Fallback to OpenAI SDK (uses beta.chat.completions.parse)
if _check_openai_available():
client = _get_openai_client(
api_key=self.config_list[0].get('api_key'),
base_url=self.config_list[0].get('base_url')
)
client = self._get_openai_client()
response = client.beta.chat.completions.parse(
model=model_name,
messages=messages,
@@ -628,7 +620,8 @@ class AutoGenerator(BaseAutoGenerator):
def __init__(self, topic="Movie Story writing about AI", agent_file="test.yaml",
framework="crewai", config_list: Optional[List[Dict]] = None,
pattern: str = "sequential", single_agent: bool = False):
pattern: str = "sequential", single_agent: bool = False,
adapter_registry=None):
"""
Initialize the AutoGenerator class with the specified topic, agent file, and framework.
@@ -646,15 +639,15 @@ class AutoGenerator(BaseAutoGenerator):
super().__init__(config_list=config_list)
# Validate framework availability using adapter registry
from .framework_adapters.registry import FrameworkAdapterRegistry
from .framework_adapters.registry import get_default_registry
registry = FrameworkAdapterRegistry.get_instance()
self._adapter_registry = adapter_registry or get_default_registry()
try:
adapter = registry.create(framework)
adapter = self._adapter_registry.create(framework)
except ValueError as e:
raise ImportError(
f"Unknown framework '{framework}'. Available frameworks: "
f"{', '.join(registry.list_registered())}"
f"{', '.join(self._adapter_registry.list_registered())}"
) from e
# Use safe fallbacks for new adapter attributes
@@ -1536,4 +1529,4 @@ Generate a workflow for: {self.topic}
with open(full_path, 'w') as f:
yaml.dump(workflow_yaml, f, default_flow_style=False, sort_keys=False)
return full_path
return full_path
+11
View File
@@ -17,6 +17,8 @@ if TYPE_CHECKING:
from .agentmail import AgentMailBot
from .bot import Bot
from .botos import BotOS
from ._session import BotSessionManager
from ._dlq import InboundDLQ, DLQEntry
from ._slack_approval import SlackApproval
from ._telegram_approval import TelegramApproval
from ._discord_approval import DiscordApproval
@@ -74,11 +76,20 @@ def __getattr__(name: str):
if name == "BotSessionManager":
from ._session import BotSessionManager
return BotSessionManager
# N4 — inbound dead-letter queue
if name == "InboundDLQ":
from ._dlq import InboundDLQ
return InboundDLQ
if name == "DLQEntry":
from ._dlq import DLQEntry
return DLQEntry
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
"TelegramBot", "DiscordBot", "SlackBot", "WhatsAppBot", "LinearBot", "EmailBot", "AgentMailBot",
"Bot", "BotOS",
"BotSessionManager",
"InboundDLQ", "DLQEntry",
"SlackApproval", "TelegramApproval", "DiscordApproval",
"WebhookApproval", "HTTPApproval",
# W1
+299
View File
@@ -0,0 +1,299 @@
"""
Inbound Dead-Letter Queue (N4) for PraisonAI bot adapters.
When ``BotSessionManager.chat()`` raises (LLM 5xx, transient timeout,
provider rate-limit, etc.) the user message is normally lost. This
module persists those failed inbound messages so an operator can
inspect, retry, or purge them later.
Design constraints (per PraisonAI principles):
- Wrapper-only — heavy code stays out of the core SDK.
- Lazy: ``sqlite3`` is stdlib so no extra dependency.
- Default OFF — ``BotSessionManager`` works exactly as before unless
a ``dlq=InboundDLQ(...)`` is passed.
- Bounded: TTL + ``max_size`` prevent unbounded disk growth.
- Thread-safe: a per-instance ``threading.Lock`` guards SQLite writes
(sqlite3 connections are not thread-safe by default).
- Reuses ``_resilience.BackoffPolicy`` for retry timing on replay.
Storage schema::
entries(id INTEGER PRIMARY KEY AUTOINCREMENT,
ts REAL,
platform TEXT, user_id TEXT, prompt TEXT,
chat_id TEXT, thread_id TEXT, user_name TEXT,
error TEXT,
attempts INTEGER DEFAULT 0)
Public API:
- ``InboundDLQ(path, *, max_size=10_000, ttl_seconds=7*86400)``
- ``enqueue(platform, user_id, prompt, error, ...)``
- ``size()`` / ``list(limit=100)`` / ``purge()``
- ``evict_expired()``
- ``replay(handler)`` — async, drops entries handler returns ``True`` for.
"""
from __future__ import annotations
import logging
import sqlite3
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Awaitable, Callable, List, Optional, Union
logger = logging.getLogger(__name__)
# 7 days default — long enough for ops to notice and act, short enough
# not to blow up disk on chronic LLM failures.
_DEFAULT_TTL_SECONDS = 7 * 86400
_DEFAULT_MAX_SIZE = 10_000
@dataclass(frozen=True)
class DLQEntry:
"""A single failed inbound message awaiting replay."""
id: int
ts: float
platform: str
user_id: str
prompt: str
chat_id: str
thread_id: str
user_name: str
error: str
attempts: int
# Replay handler signature: takes an entry, returns True on success
# (entry will be deleted) or False to keep it for later retry.
ReplayHandler = Callable[[DLQEntry], Awaitable[bool]]
class InboundDLQ:
"""SQLite-backed dead-letter queue for failed inbound bot messages.
Args:
path: SQLite file path. Created if missing; parent dirs created.
max_size: Maximum entries kept; oldest evicted when exceeded.
ttl_seconds: Entries older than this are evicted on the next
``enqueue()`` or ``evict_expired()`` call.
Example::
from praisonai.bots import InboundDLQ, BotSessionManager
dlq = InboundDLQ(path="~/.praisonai/dlq.sqlite")
mgr = BotSessionManager(platform="telegram", dlq=dlq)
# Later, an admin replays:
async def my_handler(entry):
try:
await mgr.chat(agent, entry.user_id, entry.prompt)
return True
except Exception:
return False
await dlq.replay(my_handler)
"""
def __init__(
self,
path: Union[str, Path],
*,
max_size: int = _DEFAULT_MAX_SIZE,
ttl_seconds: int = _DEFAULT_TTL_SECONDS,
) -> None:
self.path = Path(path).expanduser()
self.max_size = int(max_size)
self.ttl_seconds = int(ttl_seconds)
self._lock = threading.Lock()
self.path.parent.mkdir(parents=True, exist_ok=True)
self._init_schema()
# ── Schema ──────────────────────────────────────────────────────
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(str(self.path))
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
return conn
def _init_schema(self) -> None:
with self._lock, self._connect() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts REAL NOT NULL,
platform TEXT NOT NULL,
user_id TEXT NOT NULL,
prompt TEXT NOT NULL,
chat_id TEXT DEFAULT '',
thread_id TEXT DEFAULT '',
user_name TEXT DEFAULT '',
error TEXT DEFAULT '',
attempts INTEGER DEFAULT 0
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_ts ON entries(ts)")
# ── Mutations ───────────────────────────────────────────────────
def enqueue(
self,
*,
platform: str,
user_id: str,
prompt: str,
error: str,
chat_id: str = "",
thread_id: str = "",
user_name: str = "",
) -> int:
"""Persist a failed inbound message. Returns its row id.
Runs ``evict_expired`` and ``_evict_overflow`` as side-effects so
TTL and ``max_size`` invariants are kept tight without a
background sweeper.
"""
with self._lock, self._connect() as conn:
try:
conn.execute("BEGIN IMMEDIATE")
self._evict_expired_locked(conn)
cur = conn.execute(
"""
INSERT INTO entries(ts, platform, user_id, prompt,
chat_id, thread_id, user_name, error)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
time.time(), platform, user_id, prompt,
chat_id, thread_id, user_name, error,
),
)
self._evict_overflow_locked(conn)
conn.commit()
return int(cur.lastrowid)
except Exception:
conn.rollback()
raise
def purge(self) -> int:
"""Delete all entries. Returns count removed."""
with self._lock, self._connect() as conn:
n = conn.execute("SELECT COUNT(*) FROM entries").fetchone()[0]
conn.execute("DELETE FROM entries")
return int(n)
def evict_expired(self) -> int:
"""Drop entries older than ``ttl_seconds``. Returns count removed."""
with self._lock, self._connect() as conn:
return self._evict_expired_locked(conn)
def _evict_expired_locked(self, conn: sqlite3.Connection) -> int:
cutoff = time.time() - self.ttl_seconds
cur = conn.execute("DELETE FROM entries WHERE ts <= ?", (cutoff,))
return int(cur.rowcount or 0)
def _evict_overflow_locked(self, conn: sqlite3.Connection) -> int:
n = conn.execute("SELECT COUNT(*) FROM entries").fetchone()[0]
if n <= self.max_size:
return 0
excess = n - self.max_size
cur = conn.execute(
"DELETE FROM entries WHERE id IN "
"(SELECT id FROM entries ORDER BY ts ASC LIMIT ?)",
(excess,),
)
return int(cur.rowcount or 0)
# ── Queries ─────────────────────────────────────────────────────
def size(self) -> int:
with self._lock, self._connect() as conn:
return int(conn.execute("SELECT COUNT(*) FROM entries").fetchone()[0])
def list(self, limit: int = 100) -> List[DLQEntry]:
"""Return up to ``limit`` entries, newest first."""
with self._lock, self._connect() as conn:
rows = conn.execute(
"""
SELECT id, ts, platform, user_id, prompt,
chat_id, thread_id, user_name, error, attempts
FROM entries
ORDER BY ts DESC
LIMIT ?
""",
(int(limit),),
).fetchall()
return [DLQEntry(*r) for r in rows]
def _list_oldest_first(self, limit: int = 100) -> List[DLQEntry]:
"""Return up to ``limit`` entries, oldest first for replay."""
with self._lock, self._connect() as conn:
rows = conn.execute(
"""
SELECT id, ts, platform, user_id, prompt,
chat_id, thread_id, user_name, error, attempts
FROM entries
ORDER BY ts ASC
LIMIT ?
""",
(int(limit),),
).fetchall()
return [DLQEntry(*r) for r in rows]
# ── Replay ──────────────────────────────────────────────────────
async def replay(
self,
handler: ReplayHandler,
*,
limit: Optional[int] = None,
) -> tuple[int, int]:
"""Re-deliver entries via ``handler``.
``handler(entry)`` must return ``True`` on success (entry is
deleted) or ``False`` to keep the entry for later. Exceptions
from the handler are caught and treated as ``False`` (entry is
kept and ``attempts`` incremented).
Returns ``(succeeded, failed)`` counts.
"""
# Fetch entries oldest-first for correct replay order
entries = self._list_oldest_first(limit=limit if limit is not None else self.max_size)
succeeded = failed = 0
for entry in entries:
try:
ok = await handler(entry)
except Exception as e: # pragma: no cover — defensive
logger.warning("DLQ replay handler raised: %s", e)
ok = False
if ok:
self._delete(entry.id)
succeeded += 1
else:
self._increment_attempts(entry.id)
failed += 1
return succeeded, failed
def _delete(self, entry_id: int) -> None:
with self._lock, self._connect() as conn:
conn.execute("DELETE FROM entries WHERE id = ?", (entry_id,))
def _increment_attempts(self, entry_id: int) -> None:
with self._lock, self._connect() as conn:
conn.execute(
"UPDATE entries SET attempts = attempts + 1 WHERE id = ?",
(entry_id,),
)
def __repr__(self) -> str:
return (
f"InboundDLQ(path={str(self.path)!r}, "
f"size={self.size()}, "
f"max_size={self.max_size}, ttl={self.ttl_seconds}s)"
)
__all__ = ["InboundDLQ", "DLQEntry", "ReplayHandler"]
+100 -43
View File
@@ -7,34 +7,111 @@ Extensible: third-party platforms can register via ``register_platform()``.
from __future__ import annotations
from typing import Any, Dict, List, Type
import threading
from typing import Any, Dict, List, Type, Optional
# Lazy references: (module_path, class_name)
_BUILTIN_PLATFORMS: Dict[str, tuple] = {
"telegram": ("praisonai.bots.telegram", "TelegramBot"),
"discord": ("praisonai.bots.discord", "DiscordBot"),
"slack": ("praisonai.bots.slack", "SlackBot"),
"whatsapp": ("praisonai.bots.whatsapp", "WhatsAppBot"),
"linear": ("praisonai.bots.linear", "LinearBot"),
"email": ("praisonai.bots.email", "EmailBot"),
"agentmail": ("praisonai.bots.agentmail", "AgentMailBot"),
from .._registry import PluginRegistry
def _telegram_loader():
import importlib
mod = importlib.import_module("praisonai.bots.telegram")
return getattr(mod, "TelegramBot")
def _discord_loader():
import importlib
mod = importlib.import_module("praisonai.bots.discord")
return getattr(mod, "DiscordBot")
def _slack_loader():
import importlib
mod = importlib.import_module("praisonai.bots.slack")
return getattr(mod, "SlackBot")
def _whatsapp_loader():
import importlib
mod = importlib.import_module("praisonai.bots.whatsapp")
return getattr(mod, "WhatsAppBot")
def _linear_loader():
import importlib
mod = importlib.import_module("praisonai.bots.linear")
return getattr(mod, "LinearBot")
def _email_loader():
import importlib
mod = importlib.import_module("praisonai.bots.email")
return getattr(mod, "EmailBot")
def _agentmail_loader():
import importlib
mod = importlib.import_module("praisonai.bots.agentmail")
return getattr(mod, "AgentMailBot")
# Built-in bot platforms with lazy loading
_BUILTIN_PLATFORMS = {
"telegram": _telegram_loader,
"discord": _discord_loader,
"slack": _slack_loader,
"whatsapp": _whatsapp_loader,
"linear": _linear_loader,
"email": _email_loader,
"agentmail": _agentmail_loader,
}
# Custom platforms registered at runtime
_custom_platforms: Dict[str, Any] = {}
class BotPlatformRegistry(PluginRegistry):
"""Registry for bot platform adapters."""
def __init__(self):
super().__init__(
entry_point_group="praisonai.bots",
builtins=_BUILTIN_PLATFORMS
)
# Default registry (lazy, module-private)
_default_registry: Optional[BotPlatformRegistry] = None
_default_lock = threading.Lock()
def get_default_bot_registry() -> BotPlatformRegistry:
"""Return the process-default bot registry. Prefer DI; use this only at the edge."""
global _default_registry
if _default_registry is None:
with _default_lock:
if _default_registry is None:
_default_registry = BotPlatformRegistry()
return _default_registry
# Backward compatibility API - lazy loading to preserve original behavior
_bot_registry = None
def _get_lazy_registry():
"""Get registry lazily to avoid eager loading at module import."""
global _bot_registry
if _bot_registry is None:
_bot_registry = get_default_bot_registry()
return _bot_registry
def get_platform_registry() -> Dict[str, Any]:
"""Return the combined registry of all known platforms.
Keys are platform names, values are either:
- A class (custom platforms, already resolved)
- A (module, classname) tuple (builtins, lazy-resolved on use)
Backward compatibility function that returns original format:
{name: class_or_tuple} to preserve external caller contracts.
"""
combined: Dict[str, Any] = {}
combined.update(_BUILTIN_PLATFORMS)
combined.update(_custom_platforms)
return combined
registry = _get_lazy_registry()
result = {}
for name in registry.list_names():
try:
# Return the resolved class to maintain original API contract
result[name] = registry.resolve(name)
except ValueError:
# Skip broken registrations
pass
return result
def register_platform(name: str, adapter_class: Type) -> None:
@@ -44,12 +121,12 @@ def register_platform(name: str, adapter_class: Type) -> None:
name: Platform identifier (lowercase).
adapter_class: The bot adapter class.
"""
_custom_platforms[name.lower()] = adapter_class
_get_lazy_registry().register(name.lower(), adapter_class)
def list_platforms() -> List[str]:
"""List all registered platform names."""
return sorted(set(list(_BUILTIN_PLATFORMS.keys()) + list(_custom_platforms.keys())))
return _get_lazy_registry().list_names()
def resolve_adapter(name: str) -> Type:
@@ -64,24 +141,4 @@ def resolve_adapter(name: str) -> Type:
Raises:
ValueError: If the platform is not registered.
"""
key = name.lower()
# Custom platforms are already classes
if key in _custom_platforms:
cls = _custom_platforms[key]
if isinstance(cls, type):
return cls
# Builtins are lazy (module, classname) tuples
if key in _BUILTIN_PLATFORMS:
ref = _BUILTIN_PLATFORMS[key]
if isinstance(ref, tuple):
module_path, class_name = ref
import importlib
mod = importlib.import_module(module_path)
return getattr(mod, class_name)
raise ValueError(
f"Unknown platform: {name!r}. "
f"Available: {', '.join(list_platforms())}"
)
return _get_lazy_registry().resolve(name.lower())
+34 -3
View File
@@ -62,6 +62,7 @@ class BotSessionManager:
max_history: int = 100,
store: Optional[Any] = None,
platform: str = "",
dlq: Optional[Any] = None,
identity_resolver: Optional[Any] = None,
) -> None:
self._histories: Dict[str, List[Dict[str, Any]]] = {}
@@ -71,6 +72,10 @@ class BotSessionManager:
self._store = store
self._platform = platform
self._last_active: Dict[str, float] = {}
# N4: optional inbound DLQ — when set, failed agent.chat() calls
# are persisted for later replay. Default ``None`` preserves
# legacy behaviour (exception bubbles up untouched).
self._dlq = dlq
# W1: optional cross-platform identity resolver. When set, the
# session key is the resolver-returned unified user id, so the
# same human pinging from multiple platforms shares one history.
@@ -172,6 +177,13 @@ class BotSessionManager:
Uses both a per-user lock (serialise same user) and a per-agent
lock (prevent concurrent history swaps on a shared Agent).
N4 — Inbound DLQ: if a ``dlq`` was passed to ``__init__`` and
``agent.chat()`` raises, the failing message is persisted to
the dead-letter queue **before** the exception is re-raised.
This makes the error visible to the caller (so the bot adapter
can log / show the user a friendly message) while preserving
the message for later replay.
"""
self._last_active[self._storage_key(user_id)] = time.monotonic()
user_lock = self._get_lock(user_id)
@@ -209,9 +221,6 @@ class BotSessionManager:
# W1 robustness: hold ``agent_lock`` across the FULL LLM call
# (not only the history swap) so concurrent users on a shared
# Agent instance never observe each other's chat_history.
# Throughput on a shared agent is then bounded by the LLM's
# serial latency — this is correct: a single Agent's
# ``chat_history`` is mutable and cannot be safely interleaved.
async with agent_lock:
saved_history = agent.chat_history
agent.chat_history = user_history
@@ -226,6 +235,28 @@ class BotSessionManager:
)
# Capture updated history before restoring caller's.
updated_history = agent.chat_history
except Exception as exc:
# N4: persist the failed inbound message before bubbling.
if self._dlq is not None:
try:
await loop.run_in_executor(
None,
lambda: self._dlq.enqueue(
platform=self._platform,
user_id=user_id,
prompt=prompt,
error=f"{type(exc).__name__}: {exc}",
chat_id=chat_id,
thread_id=thread_id,
user_name=user_name,
)
)
except Exception as dlq_exc: # pragma: no cover — defensive
logger.error(
"Failed to enqueue inbound DLQ entry: %s", dlq_exc
)
agent.chat_history = saved_history
raise
finally:
agent.chat_history = saved_history
@@ -86,6 +86,8 @@ Only respond with the JSON, no other text."""
)
import json
if not response.choices or response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
result_text = response.choices[0].message.content
result_data = json.loads(result_text)
@@ -160,6 +162,8 @@ Only respond with the JSON, no other text."""
)
import json
if not response.choices or response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
result_text = response.choices[0].message.content
result_data = json.loads(result_text)
+2 -2
View File
@@ -103,7 +103,7 @@ def rag_query(
**kwargs
)
answer = response.choices[0].message.content if response.choices else ""
answer = response.choices[0].message.content if response.choices and response.choices[0].message is not None else ""
usage = None
if hasattr(response, 'usage') and response.usage:
@@ -188,7 +188,7 @@ async def arag_query(
**kwargs
)
answer = response.choices[0].message.content if response.choices else ""
answer = response.choices[0].message.content if response.choices and response.choices[0].message is not None else ""
usage = None
if hasattr(response, 'usage') and response.usage:
+117
View File
@@ -556,6 +556,123 @@ def bot_install_daemon(
raise typer.Exit(1)
# ── N4: Inbound DLQ subcommand group ─────────────────────────────────
dlq_app = typer.Typer(
help="Inspect / replay / purge the inbound dead-letter queue.",
no_args_is_help=True,
)
app.add_typer(dlq_app, name="dlq")
def _resolve_dlq_path(path: Optional[str]) -> str:
import os
if path:
return os.path.expanduser(path)
return os.path.expanduser(
os.environ.get("PRAISONAI_DLQ_PATH", "~/.praisonai/dlq.sqlite")
)
@dlq_app.command("list")
def dlq_list(
path: Optional[str] = typer.Option(
None, "--path", "-p", help="Path to DLQ sqlite file."
),
limit: int = typer.Option(20, "--limit", "-n", help="Max entries to show."),
):
"""List entries in the inbound DLQ (newest first)."""
from praisonai.bots import InboundDLQ
dlq = InboundDLQ(path=_resolve_dlq_path(path))
entries = dlq.list(limit=limit)
if not entries:
typer.echo(f"DLQ empty (path={dlq.path})")
raise typer.Exit(0)
typer.echo(f"DLQ {dlq.path} — {dlq.size()} entries (showing {len(entries)}):")
for e in entries:
prompt_preview = (e.prompt[:60] + "…") if len(e.prompt) > 60 else e.prompt
typer.echo(
f" [{e.id:>5}] {e.platform:<10} user={e.user_id:<16} "
f"attempts={e.attempts} err={e.error[:50]} prompt={prompt_preview!r}"
)
@dlq_app.command("purge")
def dlq_purge(
path: Optional[str] = typer.Option(None, "--path", "-p"),
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
):
"""Delete all entries from the DLQ."""
from praisonai.bots import InboundDLQ
dlq = InboundDLQ(path=_resolve_dlq_path(path))
n = dlq.size()
if n == 0:
typer.echo("DLQ already empty.")
raise typer.Exit(0)
if not yes:
confirm = typer.confirm(f"Delete all {n} entries from {dlq.path}?")
if not confirm:
raise typer.Exit(0)
removed = dlq.purge()
typer.echo(f"Purged {removed} entries.")
@dlq_app.command("replay")
def dlq_replay(
config: str = typer.Option(
..., "--config", "-c",
help="Path to bot YAML config (provides agent + platform).",
),
path: Optional[str] = typer.Option(None, "--path", "-p"),
limit: int = typer.Option(50, "--limit", "-n"),
):
"""Replay DLQ entries through the configured agent.
Loads the bot YAML, instantiates the agent, and re-runs each entry
through ``BotSessionManager.chat()``. Successful entries are removed;
failed entries are kept for the next attempt.
"""
import asyncio
from praisonai.bots import InboundDLQ, BotSessionManager
from praisonai.cli.features.bots_cli import _load_bot_config, _build_agent
cfg = _load_bot_config(config)
agent = _build_agent(cfg)
platform = cfg.get("platform", "")
dlq = InboundDLQ(path=_resolve_dlq_path(path))
if dlq.size() == 0:
typer.echo("DLQ empty — nothing to replay.")
raise typer.Exit(0)
mgr = BotSessionManager(platform=platform)
async def replayer(entry):
try:
await mgr.chat(
agent, entry.user_id, entry.prompt,
chat_id=entry.chat_id,
thread_id=entry.thread_id,
user_name=entry.user_name,
)
return True
except Exception as e:
typer.echo(f" entry {entry.id} failed: {e}")
return False
succeeded, failed = asyncio.run(dlq.replay(replayer, limit=limit))
typer.echo(f"Replay done: succeeded={succeeded}, failed={failed}, "
f"remaining={dlq.size()}")
# Return non-zero exit code if any failures occurred and entries remain
if failed > 0 and dlq.size() > 0:
raise typer.Exit(1)
@app.callback(invoke_without_command=True)
def bot_callback(ctx: typer.Context):
"""Show bot help if no subcommand provided."""
@@ -16,7 +16,7 @@ import pstats
import time
import platform
from dataclasses import dataclass, field
from datetime import datetime
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
from .request import ExecutionRequest
@@ -409,7 +409,7 @@ class ProfileReport:
def __post_init__(self):
"""Set timestamp if not provided."""
if not self.timestamp:
self.timestamp = datetime.utcnow().isoformat() + "Z"
self.timestamp = datetime.now(timezone.utc).isoformat()
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
@@ -15,7 +15,7 @@ import subprocess
import sys
import time
from dataclasses import dataclass, field
from datetime import datetime
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
@@ -113,7 +113,7 @@ class ProfilerResult:
callees: Dict[str, List[str]] = field(default_factory=dict)
import_times: List[Tuple[str, float]] = field(default_factory=list)
metadata: Dict[str, Any] = field(default_factory=dict)
timestamp: str = field(default_factory=lambda: datetime.utcnow().isoformat() + "Z")
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
def to_dict(self) -> Dict[str, Any]:
return {
@@ -304,7 +304,7 @@ class QueryProfiler:
"platform": platform.platform(),
"praisonai_version": praisonai_version,
"model": model or "default",
"timestamp": datetime.utcnow().isoformat() + "Z",
"timestamp": datetime.now(timezone.utc).isoformat(),
}
def _extract_function_stats(self) -> List[FunctionStats]:
@@ -11,7 +11,7 @@ import subprocess
import sys
import time
from dataclasses import dataclass, field
from datetime import datetime
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
@@ -85,7 +85,7 @@ class SuiteResult:
startup_cold_ms: float = 0.0
startup_warm_ms: float = 0.0
import_analysis: List[Dict[str, Any]] = field(default_factory=list)
timestamp: str = field(default_factory=lambda: datetime.utcnow().isoformat() + "Z")
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
@@ -182,7 +182,7 @@ class ProfileSuiteRunner:
"python_version": platform.python_version(),
"platform": platform.platform(),
"praisonai_version": praisonai_version,
"timestamp": datetime.utcnow().isoformat() + "Z",
"timestamp": datetime.now(timezone.utc).isoformat(),
}
def _measure_startup(self) -> tuple:
+53 -28
View File
@@ -4065,6 +4065,31 @@ Do NOT add any explanations or formatting."""
return results[-1].get("output", "") if results else ""
def _execute_agent_with_budget_handling(self, agent, method_name, *args, **kwargs):
"""Run ``agent.<method_name>(*args, **kwargs)`` with a graceful
BudgetExceededError handler.
Wrapper-only fix (no core SDK changes). Users configure budgets via
``execution=ExecutionConfig(max_budget=...)`` on the Agent — per
AGENTS.md §5.3 there is NO top-level ``max_budget=`` parameter on
Agent.__init__ (avoids parameter bloat).
When the budget is hit this prints a single-line actionable error
message and exits with code 1 instead of leaking a raw traceback.
Any other exception is re-raised unchanged.
"""
from praisonaiagents.errors import BudgetExceededError
try:
return getattr(agent, method_name)(*args, **kwargs)
except BudgetExceededError as e:
from rich import print as rich_print
rich_print(
f"[red]Budget limit exceeded: {e!s}. "
"Hint: set budget via "
"execution=ExecutionConfig(max_budget=1.00) on your Agent.[/red]"
)
sys.exit(1)
def _extract_cli_config_for_yaml(self):
"""
Extract CLI configuration that should be passed to YAML processing.
@@ -4611,9 +4636,9 @@ Do NOT add any explanations or formatting."""
from rich.panel import Panel
with Live(Panel(Spinner("dots", text="Generating..."), border_style="cyan"), refresh_per_second=10, transient=True):
result = auto_rag.chat(prompt)
result = self._execute_agent_with_budget_handling(auto_rag, 'chat', prompt)
else:
result = auto_rag.chat(prompt)
result = self._execute_agent_with_budget_handling(auto_rag, 'chat', prompt)
else:
# Resolve display mode from CLI flags
display_mode = self._resolve_display_mode()
@@ -4621,16 +4646,16 @@ Do NOT add any explanations or formatting."""
if display_mode == 'silent':
# -qq: No output at all, exit code only
if hasattr(agent, 'start'):
result = agent.start(prompt)
result = self._execute_agent_with_budget_handling(agent, 'start', prompt)
else:
result = agent.chat(prompt)
result = self._execute_agent_with_budget_handling(agent, 'chat', prompt)
elif display_mode == 'quiet':
# -q: Result only, no spinners or status
if hasattr(agent, 'start'):
result = agent.start(prompt)
result = self._execute_agent_with_budget_handling(agent, 'start', prompt)
else:
result = agent.chat(prompt)
result = self._execute_agent_with_budget_handling(agent, 'chat', prompt)
if result is not None:
output = getattr(result, 'output', None) or (str(result) if result else None)
if output:
@@ -4642,15 +4667,15 @@ Do NOT add any explanations or formatting."""
from praisonaiagents.output.status import enable_status_output, disable_status_output
enable_status_output(show_timestamps=True, show_metrics=True)
if hasattr(agent, 'start'):
result = agent.start(prompt)
result = self._execute_agent_with_budget_handling(agent, 'start', prompt)
else:
result = agent.chat(prompt)
result = self._execute_agent_with_budget_handling(agent, 'chat', prompt)
disable_status_output()
except ImportError:
if hasattr(agent, 'start'):
result = agent.start(prompt)
result = self._execute_agent_with_budget_handling(agent, 'start', prompt)
else:
result = agent.chat(prompt)
result = self._execute_agent_with_budget_handling(agent, 'chat', prompt)
elif display_mode == 'debug':
# -vv: SDK TraceOutput with markdown rendering
@@ -4658,15 +4683,15 @@ Do NOT add any explanations or formatting."""
from praisonaiagents.output.trace import enable_trace_output, disable_trace_output
enable_trace_output(use_markdown=True)
if hasattr(agent, 'start'):
result = agent.start(prompt)
result = self._execute_agent_with_budget_handling(agent, 'start', prompt)
else:
result = agent.chat(prompt)
result = self._execute_agent_with_budget_handling(agent, 'chat', prompt)
disable_trace_output()
except ImportError:
if hasattr(agent, 'start'):
result = agent.start(prompt)
result = self._execute_agent_with_budget_handling(agent, 'start', prompt)
else:
result = agent.chat(prompt)
result = self._execute_agent_with_budget_handling(agent, 'chat', prompt)
elif display_mode == 'jsonl':
# --output jsonl: JSONL structured output for CI/CD
@@ -4688,9 +4713,9 @@ Do NOT add any explanations or formatting."""
start_time = time.time()
if hasattr(agent, 'start'):
result = agent.start(prompt)
result = self._execute_agent_with_budget_handling(agent, 'start', prompt)
else:
result = agent.chat(prompt)
result = self._execute_agent_with_budget_handling(agent, 'chat', prompt)
# Emit final result
reason = getattr(result, 'completion_reason', None) if hasattr(result, 'completion_reason') else 'complete'
@@ -4709,9 +4734,9 @@ Do NOT add any explanations or formatting."""
import json as json_mod
start_time = time.time()
if hasattr(agent, 'start'):
result = agent.start(prompt)
result = self._execute_agent_with_budget_handling(agent, 'start', prompt)
else:
result = agent.chat(prompt)
result = self._execute_agent_with_budget_handling(agent, 'chat', prompt)
output = result.output if hasattr(result, 'output') else str(result)
envelope = {
@@ -4732,15 +4757,15 @@ Do NOT add any explanations or formatting."""
flow = track_workflow()
flow.start()
if hasattr(agent, 'start'):
result = agent.start(prompt)
result = self._execute_agent_with_budget_handling(agent, 'start', prompt)
else:
result = agent.chat(prompt)
result = self._execute_agent_with_budget_handling(agent, 'chat', prompt)
flow.stop()
except ImportError:
if hasattr(agent, 'start'):
result = agent.start(prompt)
result = self._execute_agent_with_budget_handling(agent, 'start', prompt)
else:
result = agent.chat(prompt)
result = self._execute_agent_with_budget_handling(agent, 'chat', prompt)
elif display_mode == 'editor':
# --output editor: User-friendly step-by-step format
@@ -4750,9 +4775,9 @@ Do NOT add any explanations or formatting."""
# Run agent
if hasattr(agent, 'start'):
result = agent.start(prompt)
result = self._execute_agent_with_budget_handling(agent, 'start', prompt)
else:
result = agent.chat(prompt)
result = self._execute_agent_with_budget_handling(agent, 'chat', prompt)
# SDK callbacks (interaction, llm_content) handle display —
# no explicit editor.output() needed here.
@@ -4774,15 +4799,15 @@ Do NOT add any explanations or formatting."""
from praisonaiagents.output.status import enable_status_output, disable_status_output
enable_status_output(show_timestamps=False, show_metrics=False)
if hasattr(agent, 'start'):
result = agent.start(prompt)
result = self._execute_agent_with_budget_handling(agent, 'start', prompt)
else:
result = agent.chat(prompt)
result = self._execute_agent_with_budget_handling(agent, 'chat', prompt)
disable_status_output()
except ImportError:
if hasattr(agent, 'start'):
result = agent.start(prompt)
result = self._execute_agent_with_budget_handling(agent, 'start', prompt)
else:
result = agent.chat(prompt)
result = self._execute_agent_with_budget_handling(agent, 'chat', prompt)
# ===== POST-PROCESSING WITH NEW FEATURES =====
@@ -9,7 +9,7 @@ import os
import secrets
import threading
from dataclasses import dataclass, field
from datetime import datetime
from datetime import datetime, timezone
from typing import Dict, Optional
@@ -20,7 +20,7 @@ def generate_run_id() -> str:
Format: run_YYYYMMDD_HHMMSS_<random>
Example: run_20241231_143022_a1b2c3
"""
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
random_suffix = secrets.token_hex(3) # 6 characters
return f"run_{timestamp}_{random_suffix}"
@@ -68,7 +68,7 @@ class RunContext:
"""
run_id: str = field(default_factory=generate_run_id)
trace_id: str = field(default_factory=generate_trace_id)
start_time: datetime = field(default_factory=datetime.utcnow)
start_time: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
# Agent tracking
agents: Dict[str, str] = field(default_factory=dict) # name -> agent_id
@@ -7,7 +7,7 @@ Provides persistence and resume functionality for sessions.
import json
import shutil
from dataclasses import dataclass, field
from datetime import datetime
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, TYPE_CHECKING
@@ -129,7 +129,7 @@ class SessionManager:
session_dir = self._get_session_dir(session_id)
session_dir.mkdir(parents=True, exist_ok=True)
now = datetime.utcnow()
now = datetime.now(timezone.utc)
metadata = SessionMetadata(
session_id=session_id,
run_id=context.run_id,
@@ -206,7 +206,7 @@ class SessionManager:
metadata = self._load_metadata(session_id)
if metadata:
metadata.event_count += 1
metadata.updated_at = datetime.utcnow()
metadata.updated_at = datetime.now(timezone.utc)
self._save_metadata(metadata)
def get(self, session_id: str) -> Optional[SessionMetadata]:
@@ -365,7 +365,7 @@ class SessionManager:
metadata = self._load_metadata(session_id)
if metadata:
metadata.status = status
metadata.updated_at = datetime.utcnow()
metadata.updated_at = datetime.now(timezone.utc)
self._save_metadata(metadata)
@@ -8,10 +8,36 @@ Plugin registry for CLI backend implementations following AGENTS.md patterns:
import threading
from typing import Dict, Callable, Any, Optional, Union
from .._registry import PluginRegistry
# Thread-safe registry for CLI backend factories
_REGISTRY: Dict[str, Callable[[], Any]] = {}
_REGISTRY_LOCK = threading.Lock()
# CLI Backend Registry using canonical PluginRegistry
def _get_builtin_cli_backend_loaders() -> Dict[str, Callable[[], Any]]:
"""Get built-in CLI backend loaders."""
def claude_loader():
def factory():
from .claude import ClaudeCodeBackend
return ClaudeCodeBackend()
return factory
return {
"claude-code": claude_loader
}
# Global CLI backend registry instance
_cli_backend_registry: Optional[PluginRegistry] = None
_registry_lock = threading.Lock()
def _get_cli_backend_registry() -> PluginRegistry:
"""Get the CLI backend registry instance."""
global _cli_backend_registry
if _cli_backend_registry is None:
with _registry_lock:
if _cli_backend_registry is None:
_cli_backend_registry = PluginRegistry(
entry_point_group="praisonai.cli_backends",
builtins=_get_builtin_cli_backend_loaders()
)
return _cli_backend_registry
def register_cli_backend(backend_id: str, factory: Callable[[], Any]) -> None:
@@ -21,14 +47,18 @@ def register_cli_backend(backend_id: str, factory: Callable[[], Any]) -> None:
backend_id: Unique identifier (e.g., "claude-code", "codex-cli")
factory: Factory function that returns a CliBackendProtocol instance
"""
with _REGISTRY_LOCK:
_REGISTRY[backend_id] = factory
# Wrap factory in a loader function for PluginRegistry
def factory_loader():
return factory
registry = _get_cli_backend_registry()
registry.register(backend_id, factory_loader)
def list_cli_backends() -> list[str]:
"""List all registered CLI backend IDs."""
with _REGISTRY_LOCK:
return list(_REGISTRY.keys())
registry = _get_cli_backend_registry()
return registry.list_names()
def resolve_cli_backend(
@@ -47,12 +77,15 @@ def resolve_cli_backend(
Raises:
ValueError: If backend_id is not registered
"""
with _REGISTRY_LOCK:
if backend_id not in _REGISTRY:
available = list(_REGISTRY.keys())
raise ValueError(f"Unknown CLI backend: {backend_id}. Available: {available}")
factory = _REGISTRY[backend_id]
registry = _get_cli_backend_registry()
try:
# Get the factory loader and create the factory
factory_loader = registry.resolve(backend_id)
factory = factory_loader()
except ValueError:
available = registry.list_names()
raise ValueError(f"Unknown CLI backend: {backend_id}. Available: {available}")
# Create instance with factory
backend = factory()
@@ -77,24 +110,4 @@ def resolve_cli_backend(
return backend
def _register_builtin_backends() -> None:
"""Register built-in CLI backends (lazy loaded)."""
# Only register if not already done
with _REGISTRY_LOCK:
if "claude-code" in _REGISTRY:
return
def claude_factory():
from .claude import ClaudeCodeBackend
return ClaudeCodeBackend()
register_cli_backend("claude-code", claude_factory)
# Auto-register built-in backends on first access
def __getattr__(name: str):
"""Lazy registration trigger."""
_register_builtin_backends()
raise AttributeError(f"No attribute {name}")
# Registration happens lazily via __getattr__ on first attribute access
# Built-in backends are now registered via _get_builtin_cli_backend_loaders()
+156
View File
@@ -554,6 +554,162 @@ class PraisonAIDB:
return traces[: max(limit, 0)]
return traces
# ========================================================================
# Async Surface for async-safe agents
# ========================================================================
async def aon_agent_start(
self,
session_id: str,
name: str,
agent_id: str = "",
metadata: Optional[Dict[str, Any]] = None
) -> None:
"""Async version of on_agent_start."""
self._init_stores()
if self._state_store:
if hasattr(self._state_store, "async_set_agent_state"):
await self._state_store.async_set_agent_state(
session_id,
agent_id or name,
{"status": "started", "metadata": metadata or {}}
)
else:
import asyncio
await asyncio.to_thread(
self._state_store.set_agent_state,
session_id,
agent_id or name,
{"status": "started", "metadata": metadata or {}}
)
async def aon_user_message(
self,
session_id: str,
content: str,
metadata: Optional[Dict[str, Any]] = None
) -> None:
"""Async version of on_user_message."""
self._init_stores()
if self._conversation_store:
from ..persistence.conversation.models import ConversationMessage
msg = ConversationMessage(
role="user",
content=content,
metadata=metadata or {}
)
if hasattr(self._conversation_store, "async_add_message"):
await self._conversation_store.async_add_message(session_id, msg)
else:
import asyncio
await asyncio.to_thread(self._conversation_store.add_message, session_id, msg)
async def aon_agent_message(
self,
session_id: str,
content: str,
metadata: Optional[Dict[str, Any]] = None
) -> None:
"""Async version of on_agent_message."""
self._init_stores()
if self._conversation_store:
from ..persistence.conversation.models import ConversationMessage
msg = ConversationMessage(
role="assistant",
content=content,
metadata=metadata or {}
)
if hasattr(self._conversation_store, "async_add_message"):
await self._conversation_store.async_add_message(session_id, msg)
else:
import asyncio
await asyncio.to_thread(self._conversation_store.add_message, session_id, msg)
async def aon_tool_call(
self,
session_id: str,
tool_name: str,
arguments: Dict[str, Any],
result: Any = None,
metadata: Optional[Dict[str, Any]] = None
) -> None:
"""Async version of on_tool_call."""
self._init_stores()
if self._conversation_store:
if hasattr(self._conversation_store, "async_add_tool_call"):
await self._conversation_store.async_add_tool_call(
session_id, tool_name, arguments, result, metadata
)
elif hasattr(self._conversation_store, "add_tool_call"):
import asyncio
await asyncio.to_thread(
self._conversation_store.add_tool_call,
session_id, tool_name, arguments, result, metadata
)
async def aon_agent_end(
self,
session_id: str,
name: str,
agent_id: str = "",
metadata: Optional[Dict[str, Any]] = None
) -> None:
"""Async version of on_agent_end."""
self._init_stores()
if self._state_store:
if hasattr(self._state_store, "async_set_agent_state"):
await self._state_store.async_set_agent_state(
session_id,
agent_id or name,
{"status": "ended", "metadata": metadata or {}}
)
else:
import asyncio
await asyncio.to_thread(
self._state_store.set_agent_state,
session_id,
agent_id or name,
{"status": "ended", "metadata": metadata or {}}
)
async def aclose(self) -> None:
"""Async version of close."""
stores = [self._conversation_store, self._state_store, self._knowledge_store]
for store in stores:
if store is None:
continue
if hasattr(store, "async_close"):
await store.async_close()
else:
import asyncio
await asyncio.to_thread(store.close)
async def __aenter__(self):
"""Async context manager entry."""
self._init_stores()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Async context manager exit."""
await self.aclose()
def __enter__(self):
"""Sync context manager entry."""
self._init_stores()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Sync context manager exit."""
self.close()
def close(self) -> None:
"""Close all database connections."""
if self._conversation_store:
+1 -1
View File
@@ -57,7 +57,7 @@ class CloudDeployer:
file.write("FROM python:3.11-slim\n")
file.write("WORKDIR /app\n")
file.write("COPY . .\n")
file.write("RUN pip install flask praisonai==4.6.37 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==4.6.39 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
@@ -5,7 +5,7 @@ Provides lazy-loaded integration with AutoGen v0.2, AutoGen v0.4, and AG2 framew
"""
import logging
from typing import Dict, List, Any
from typing import Dict, List, Any, Optional, Callable
from .base import BaseFrameworkAdapter
logger = logging.getLogger(__name__)
@@ -26,7 +26,17 @@ class AutoGenAdapter(BaseFrameworkAdapter):
except ImportError:
return False
def run(self, config: Dict[str, Any], llm_config: List[Dict], topic: str) -> str:
def run(
self,
config: Dict[str, Any],
llm_config: List[Dict],
topic: str,
*,
tools_dict: Optional[Dict[str, Any]] = None,
agent_callback: Optional[Callable] = None,
task_callback: Optional[Callable] = None,
cli_config: Optional[Dict[str, Any]] = None,
) -> str:
"""
Run AutoGen v0.2 with given configuration.
@@ -34,6 +44,10 @@ class AutoGenAdapter(BaseFrameworkAdapter):
config: AutoGen configuration with agents
llm_config: LLM configuration list
topic: Topic for the tasks
tools_dict: Available tools dictionary
agent_callback: Callback for agent events
task_callback: Callback for task events
cli_config: CLI configuration
Returns:
Execution result as string
@@ -110,7 +124,17 @@ class AutoGenV4Adapter(BaseFrameworkAdapter):
except ImportError:
return False
def run(self, config: Dict[str, Any], llm_config: List[Dict], topic: str) -> str:
def run(
self,
config: Dict[str, Any],
llm_config: List[Dict],
topic: str,
*,
tools_dict: Optional[Dict[str, Any]] = None,
agent_callback: Optional[Callable] = None,
task_callback: Optional[Callable] = None,
cli_config: Optional[Dict[str, Any]] = None,
) -> str:
"""
Run AutoGen v0.4 with given configuration.
@@ -118,6 +142,10 @@ class AutoGenV4Adapter(BaseFrameworkAdapter):
config: AutoGen v0.4 configuration with agents
llm_config: LLM configuration list
topic: Topic for the tasks
tools_dict: Available tools dictionary
agent_callback: Callback for agent events
task_callback: Callback for task events
cli_config: CLI configuration
Returns:
Execution result as string
@@ -128,7 +156,7 @@ class AutoGenV4Adapter(BaseFrameworkAdapter):
# For now, return a proper error message instead of delegating
# TODO: Implement full AutoGen v0.4 adapter logic
logger.warning("AutoGen v0.4 adapter is not yet fully implemented")
return "### AutoGen v0.4 Output ###\nAutoGen v0.4 adapter is not yet fully implemented. Please use 'autogen' framework for AutoGen v0.2 support."
raise NotImplementedError("AutoGen v0.4 adapter is not yet fully implemented. Please use 'autogen' framework for AutoGen v0.2 support.")
class AG2Adapter(BaseFrameworkAdapter):
@@ -148,7 +176,17 @@ class AG2Adapter(BaseFrameworkAdapter):
except Exception:
return False
def run(self, config: Dict[str, Any], llm_config: List[Dict], topic: str) -> str:
def run(
self,
config: Dict[str, Any],
llm_config: List[Dict],
topic: str,
*,
tools_dict: Optional[Dict[str, Any]] = None,
agent_callback: Optional[Callable] = None,
task_callback: Optional[Callable] = None,
cli_config: Optional[Dict[str, Any]] = None,
) -> str:
"""
Run AG2 with given configuration.
@@ -156,6 +194,10 @@ class AG2Adapter(BaseFrameworkAdapter):
config: AG2 configuration with agents
llm_config: LLM configuration list
topic: Topic for the tasks
tools_dict: Available tools dictionary
agent_callback: Callback for agent events
task_callback: Callback for task events
cli_config: CLI configuration
Returns:
Execution result as string
@@ -166,4 +208,4 @@ class AG2Adapter(BaseFrameworkAdapter):
# For now, return a proper error message instead of delegating
# TODO: Implement full AG2 adapter logic
logger.warning("AG2 adapter is not yet fully implemented")
return "### AG2 Output ###\nAG2 adapter is not yet fully implemented. Please use 'autogen' framework for AutoGen/AG2 support."
raise NotImplementedError("AG2 adapter is not yet fully implemented. Please use 'autogen' framework for AutoGen/AG2 support.")
@@ -3,148 +3,68 @@ Framework Adapter Registry for PraisonAI.
Provides a registry pattern for managing framework adapters with entry points support,
enabling dynamic registration and discovery of framework adapters.
Mirrors the design of integrations/registry.py for consistency.
Uses dependency injection instead of singleton pattern.
"""
from __future__ import annotations
import threading
from importlib.metadata import entry_points
from typing import Dict, Type, Optional
import logging
from .base import FrameworkAdapter
from .._registry import PluginRegistry
logger = logging.getLogger(__name__)
class FrameworkAdapterRegistry:
def _crewai_loader():
from .crewai_adapter import CrewAIAdapter
return CrewAIAdapter
def _autogen_loader():
from .autogen_adapter import AutoGenAdapter
return AutoGenAdapter
def _autogen_v4_loader():
from .autogen_adapter import AutoGenV4Adapter
return AutoGenV4Adapter
def _ag2_loader():
from .autogen_adapter import AG2Adapter
return AG2Adapter
def _praisonai_loader():
from .praisonai_adapter import PraisonAIAdapter
return PraisonAIAdapter
# Built-in framework adapters with lazy loading
_BUILTIN_ADAPTERS = {
"crewai": _crewai_loader,
"autogen": _autogen_loader,
"autogen_v4": _autogen_v4_loader,
"ag2": _ag2_loader,
"praisonai": _praisonai_loader,
}
class FrameworkAdapterRegistry(PluginRegistry[FrameworkAdapter]):
"""
Registry for framework adapters.
Provides centralized management of framework adapters with support
for dynamic registration, entry points discovery, and availability checking.
Uses singleton pattern to ensure consistent state across the application.
Uses dependency injection pattern instead of singleton.
"""
_instance: Optional["FrameworkAdapterRegistry"] = None
_instance_lock = threading.Lock()
def __init__(self) -> None:
"""Initialize the registry with built-in adapters."""
self._adapters: Dict[str, Type[FrameworkAdapter]] = {}
self._lock = threading.Lock()
self._register_builtin()
self._register_entry_points()
@classmethod
def get_instance(cls) -> "FrameworkAdapterRegistry":
"""
Get the singleton registry instance.
Returns:
FrameworkAdapterRegistry: The singleton registry
"""
if cls._instance is None:
with cls._instance_lock:
if cls._instance is None:
cls._instance = cls()
return cls._instance
def _register_builtin(self) -> None:
"""Register built-in framework adapters with lazy imports."""
# Lazy, optional imports - mirrors integrations/registry.py pattern
try:
from .crewai_adapter import CrewAIAdapter
self._adapters["crewai"] = CrewAIAdapter
except ImportError:
pass
try:
from .autogen_adapter import AutoGenAdapter, AutoGenV4Adapter, AG2Adapter
self._adapters["autogen"] = AutoGenAdapter
self._adapters["autogen_v4"] = AutoGenV4Adapter
self._adapters["ag2"] = AG2Adapter
except ImportError:
pass
try:
from .praisonai_adapter import PraisonAIAdapter
self._adapters["praisonai"] = PraisonAIAdapter
except ImportError:
pass
def _register_entry_points(self) -> None:
"""Register framework adapters from entry points."""
try:
for ep in entry_points(group="praisonai.framework_adapters"):
try:
adapter_class = ep.load()
self._adapters[ep.name] = adapter_class
except Exception:
# Do not break framework dispatch because one plugin is broken.
# Surface via structured logging instead of swallowing silently.
logger.warning(
"Failed to load framework adapter %r from entry point",
ep.name,
exc_info=True,
)
except Exception:
# entry_points() might not be available in older Python versions
# or in certain packaging environments
logger.debug("Entry points not available for framework adapters")
def register(self, name: str, adapter_class: Type[FrameworkAdapter]) -> None:
"""
Register a new framework adapter.
Args:
name: Unique name for the adapter
adapter_class: The adapter class (must implement FrameworkAdapter protocol)
"""
# Note: We don't enforce strict type checking here since FrameworkAdapter is a Protocol
# and isinstance() doesn't work with Protocols. The runtime will catch typing issues.
with self._lock:
self._adapters[name] = adapter_class
def unregister(self, name: str) -> bool:
"""
Unregister a framework adapter.
Args:
name: Name of the adapter to unregister
Returns:
bool: True if the adapter was found and removed, False otherwise
"""
with self._lock:
return self._adapters.pop(name, None) is not None
def create(self, name: str) -> FrameworkAdapter:
"""
Create an instance of the specified framework adapter.
Args:
name: Name of the adapter to create
Returns:
FrameworkAdapter: Instance of the adapter
Raises:
ValueError: If the adapter is not found
"""
with self._lock:
adapter_class = self._adapters.get(name)
if adapter_class is None:
raise ValueError(
f"Unsupported framework: {name}. "
f"Registered: {sorted(self._adapters)}"
)
return adapter_class()
super().__init__(
entry_point_group="praisonai.framework_adapters",
builtins=_BUILTIN_ADAPTERS
)
# Backward compatibility aliases - delegate to parent methods
def list_registered(self) -> list[str]:
"""
List all registered framework adapter names.
@@ -152,8 +72,7 @@ class FrameworkAdapterRegistry:
Returns:
list[str]: Sorted list of registered adapter names
"""
with self._lock:
return sorted(self._adapters)
return self.list_names()
def is_available(self, name: str) -> bool:
"""
@@ -174,4 +93,19 @@ class FrameworkAdapterRegistry:
return adapter.is_available()
except Exception:
logger.warning("is_available() raised for adapter %r", name, exc_info=True)
return False
return False
# Default registry (lazy, module-private). NOT exposed as a singleton getter.
_default_registry: Optional[FrameworkAdapterRegistry] = None
_default_lock = threading.Lock()
def get_default_registry() -> FrameworkAdapterRegistry:
"""Return the process-default registry. Prefer DI; use this only at the edge."""
global _default_registry
if _default_registry is None:
with _default_lock:
if _default_registry is None:
_default_registry = FrameworkAdapterRegistry()
return _default_registry
@@ -5,7 +5,7 @@ Provides early validation of framework availability to fail fast at CLI entry
rather than inside run() methods after expensive setup work.
"""
from .registry import FrameworkAdapterRegistry
from .registry import get_default_registry
# Install hints for common frameworks
@@ -26,7 +26,7 @@ def assert_framework_available(name: str) -> None:
Raises:
ImportError: If framework is not available with actionable install hint
"""
registry = FrameworkAdapterRegistry.get_instance()
registry = get_default_registry()
if not registry.is_available(name):
hint = _INSTALL_HINTS.get(name, f"pip install {name}")
@@ -256,17 +256,42 @@ class ExecApprovalManager:
logger.debug("Expired approval request: %s", rid)
# ── Module-level singleton accessor ──────────────────────────────────
# ── Manager accessor (non-singleton) ─────────────────────────────────
_global_manager: Optional[ExecApprovalManager] = None
# Default manager for CLI/backward compatibility
_default_manager: Optional[ExecApprovalManager] = None
_manager_lock = threading.Lock()
def get_exec_approval_manager(ttl: float = 300.0) -> ExecApprovalManager:
"""Get or create the global :class:`ExecApprovalManager` singleton."""
global _global_manager
if _global_manager is None:
def get_default_exec_approval_manager(ttl: float = 300.0) -> ExecApprovalManager:
"""Get the default exec approval manager (for CLI and backward compatibility).
Args:
ttl: Seconds before approval requests auto-expire (default 5 min).
Returns:
The default ExecApprovalManager instance.
"""
global _default_manager
if _default_manager is None:
with _manager_lock:
if _global_manager is None:
_global_manager = ExecApprovalManager(ttl=ttl)
return _global_manager
if _default_manager is None:
_default_manager = ExecApprovalManager(ttl=ttl)
return _default_manager
def get_exec_approval_manager(ttl: float = 300.0) -> ExecApprovalManager:
"""Backward compatibility alias for get_default_exec_approval_manager."""
return get_default_exec_approval_manager(ttl)
def create_exec_approval_manager(ttl: float = 300.0) -> ExecApprovalManager:
"""Create a new exec approval manager instance (for per-agent use).
Args:
ttl: Seconds before approval requests auto-expire (default 5 min).
Returns:
A new ExecApprovalManager instance.
"""
return ExecApprovalManager(ttl=ttl)
@@ -1,49 +1,33 @@
# Lazy availability checks using find_spec (no actual import)
# This avoids the ~3200ms crewai import at CLI startup
"""Lazy access to autogen_tools, with `find_spec` startup probes for crewai/autogen."""
import importlib.util
from praisonai.auto import _load_optional
CREWAI_AVAILABLE = importlib.util.find_spec("crewai") is not None
AUTOGEN_AVAILABLE = importlib.util.find_spec("autogen") is not None
PRAISONAI_TOOLS_AVAILABLE = False
PRAISONAI_TOOLS_PACKAGE_AVAILABLE = importlib.util.find_spec("praisonai_tools") is not None
def _load_autogen_tools():
# Load the autogen_tools module unconditionally since it can work with just praisonai_tools
import importlib
return importlib.import_module(__name__ + ".autogen_tools")
# Guard against recursive imports
_importing_autogen_tools = False
_autogen_tools_module = None
# Lazy import helper - only imports when actually needed
def _get_autogen_tools():
"""Lazy import autogen_tools only when needed."""
global PRAISONAI_TOOLS_AVAILABLE, _importing_autogen_tools, _autogen_tools_module
# Return cached module if already imported
if _autogen_tools_module is not None:
return _autogen_tools_module
# Prevent recursive import
if _importing_autogen_tools:
return None
if CREWAI_AVAILABLE or AUTOGEN_AVAILABLE:
try:
_importing_autogen_tools = True
from . import autogen_tools
_autogen_tools_module = autogen_tools
PRAISONAI_TOOLS_AVAILABLE = True
return autogen_tools
except ImportError:
pass
finally:
_importing_autogen_tools = False
return None
"""Thread-safe, single-shot lazy import. Negative result is cached too."""
return _load_optional("inbuilt_autogen_tools", _load_autogen_tools)
# For backward compatibility, provide __getattr__ for lazy access
def _praisonai_tools_available() -> bool:
"""Read-only accessor — never mutate this from inside a function."""
return PRAISONAI_TOOLS_PACKAGE_AVAILABLE or _get_autogen_tools() is not None
# Backward-compat: keep the constant, computed lazily on attribute access.
def __getattr__(name):
"""Lazy load autogen_tools exports on demand."""
# Avoid recursion during import
if _importing_autogen_tools:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
if name == "PRAISONAI_TOOLS_AVAILABLE":
return _praisonai_tools_available()
tools_module = _get_autogen_tools()
if tools_module and hasattr(tools_module, name):
if tools_module is not None and hasattr(tools_module, name):
return getattr(tools_module, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -11,10 +11,10 @@ Features:
- Backward compatibility with existing get_available_integrations()
Usage:
from praisonai.integrations.registry import ExternalAgentRegistry
from praisonai.integrations.registry import get_default_registry
# Get singleton registry
registry = ExternalAgentRegistry.get_instance()
# Get default registry (or inject custom for multi-tenant)
registry = get_default_registry()
# Register custom integration
registry.register('my-agent', MyCustomIntegration)
@@ -30,67 +30,50 @@ import threading
from typing import Dict, Type, Optional, Any, List
from .base import BaseCLIIntegration
from .._registry import PluginRegistry
class ExternalAgentRegistry:
def _claude_code_loader():
from .claude_code import ClaudeCodeIntegration
return ClaudeCodeIntegration
def _gemini_cli_loader():
from .gemini_cli import GeminiCLIIntegration
return GeminiCLIIntegration
def _codex_cli_loader():
from .codex_cli import CodexCLIIntegration
return CodexCLIIntegration
def _cursor_cli_loader():
from .cursor_cli import CursorCLIIntegration
return CursorCLIIntegration
# Built-in external agent integrations with lazy loading
_BUILTIN_INTEGRATIONS = {
"claude": _claude_code_loader,
"gemini": _gemini_cli_loader,
"codex": _codex_cli_loader,
"cursor": _cursor_cli_loader,
}
class ExternalAgentRegistry(PluginRegistry[BaseCLIIntegration]):
"""
Registry for external CLI integrations.
Provides centralized management of external agent integrations
with support for dynamic registration and availability checking.
Uses singleton pattern to ensure consistent state across the application.
Uses dependency injection pattern instead of singleton.
"""
_instance: Optional['ExternalAgentRegistry'] = None
_instance_lock = threading.Lock()
def __init__(self):
"""Initialize the registry with built-in integrations."""
self._integrations: Dict[str, Type[BaseCLIIntegration]] = {}
self._lock = threading.Lock()
self._register_builtin_integrations()
@classmethod
def get_instance(cls) -> 'ExternalAgentRegistry':
"""
Get the singleton registry instance.
Returns:
ExternalAgentRegistry: The singleton registry
"""
if cls._instance is None:
with cls._instance_lock:
if cls._instance is None:
cls._instance = cls()
return cls._instance
def _register_builtin_integrations(self):
"""Register built-in integrations."""
# Lazy imports to avoid circular dependencies and performance impact
try:
from .claude_code import ClaudeCodeIntegration
self._integrations['claude'] = ClaudeCodeIntegration
except ImportError:
pass
try:
from .gemini_cli import GeminiCLIIntegration
self._integrations['gemini'] = GeminiCLIIntegration
except ImportError:
pass
try:
from .codex_cli import CodexCLIIntegration
self._integrations['codex'] = CodexCLIIntegration
except ImportError:
pass
try:
from .cursor_cli import CursorCLIIntegration
self._integrations['cursor'] = CursorCLIIntegration
except ImportError:
pass
super().__init__(
entry_point_group="praisonai.external_agents",
builtins=_BUILTIN_INTEGRATIONS
)
def register(self, name: str, integration_class: Type[BaseCLIIntegration]) -> None:
"""
@@ -108,24 +91,19 @@ class ExternalAgentRegistry:
f"Integration class {integration_class.__name__} must inherit from BaseCLIIntegration"
)
# Thread-safe registration
with self._lock:
self._integrations[name] = integration_class
# Delegate to parent
super().register(name, integration_class)
def unregister(self, name: str) -> bool:
# Backward compatibility methods
def list_registered(self) -> List[str]:
"""
Unregister an external agent integration.
List all registered integration names.
Args:
name: Name of the integration to unregister
Returns:
bool: True if the integration was found and removed, False otherwise
List[str]: List of registered integration names
"""
# Thread-safe unregistration with atomic check-then-delete
with self._lock:
return self._integrations.pop(name, None) is not None
return self.list_names()
def create(self, name: str, **kwargs: Any) -> Optional[BaseCLIIntegration]:
"""
Create an instance of the specified integration.
@@ -137,23 +115,10 @@ class ExternalAgentRegistry:
Returns:
BaseCLIIntegration: Instance of the integration, or None if not found
"""
with self._lock:
integration_class = self._integrations.get(name)
if integration_class is None:
try:
return super().create(name, **kwargs)
except ValueError:
return None
return integration_class(**kwargs)
def list_registered(self) -> List[str]:
"""
List all registered integration names.
Returns:
List[str]: List of registered integration names
"""
with self._lock:
return list(self._integrations.keys())
async def get_available(self) -> Dict[str, bool]:
"""
@@ -165,8 +130,9 @@ class ExternalAgentRegistry:
import inspect
availability = {}
# Get snapshot of all items from parent class
with self._lock:
snapshot = list(self._integrations.items())
snapshot = list(self._items.items())
for name, integration_class in snapshot:
try:
@@ -201,15 +167,30 @@ class ExternalAgentRegistry:
return [name for name, available in availability.items() if available]
# Factory functions for convenient access
# Default registry (lazy, module-private). NOT exposed as a singleton getter.
_default_registry: Optional[ExternalAgentRegistry] = None
_default_lock = threading.Lock()
def get_default_registry() -> ExternalAgentRegistry:
"""Return the process-default registry. Prefer DI; use this only at the edge."""
global _default_registry
if _default_registry is None:
with _default_lock:
if _default_registry is None:
_default_registry = ExternalAgentRegistry()
return _default_registry
# Factory functions for convenient access - using default registry
def get_registry() -> ExternalAgentRegistry:
"""
Get the singleton external agent registry.
Get the default external agent registry.
Returns:
ExternalAgentRegistry: The singleton registry instance
ExternalAgentRegistry: The default registry instance
"""
return ExternalAgentRegistry.get_instance()
return get_default_registry()
def register_integration(name: str, integration_class: Type[BaseCLIIntegration]) -> None:
@@ -220,7 +201,7 @@ def register_integration(name: str, integration_class: Type[BaseCLIIntegration])
name: Unique name for the integration
integration_class: The integration class (must inherit from BaseCLIIntegration)
"""
registry = get_registry()
registry = get_default_registry()
registry.register(name, integration_class)
@@ -235,7 +216,7 @@ def create_integration(name: str, **kwargs: Any) -> Optional[BaseCLIIntegration]
Returns:
BaseCLIIntegration: Instance of the integration, or None if not found
"""
registry = get_registry()
registry = get_default_registry()
return registry.create(name, **kwargs)
@@ -249,7 +230,7 @@ def get_available_integrations() -> Dict[str, bool]:
Dict[str, bool]: Mapping of integration name to availability status
"""
import asyncio
registry = get_registry()
registry = get_default_registry()
from .._async_bridge import run_sync
return run_sync(registry.get_available())
+7 -7
View File
@@ -7,7 +7,7 @@ Defines the data structures for job submission, status, and results.
import uuid
from enum import Enum
from typing import Optional, Dict, Any, List
from datetime import datetime
from datetime import datetime, timezone
from pydantic import BaseModel, Field, field_validator
import socket
import ipaddress
@@ -177,7 +177,7 @@ class Job(BaseModel):
run_id: Optional[str] = Field(None)
# Timestamps
created_at: datetime = Field(default_factory=datetime.utcnow)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
started_at: Optional[datetime] = Field(None)
completed_at: Optional[datetime] = Field(None)
@@ -194,7 +194,7 @@ class Job(BaseModel):
"""Calculate job duration."""
if self.started_at is None:
return None
end_time = self.completed_at or datetime.utcnow()
end_time = self.completed_at or datetime.now(timezone.utc)
return (end_time - self.started_at).total_seconds()
@property
@@ -247,26 +247,26 @@ class Job(BaseModel):
def start(self):
"""Mark job as started."""
self.status = JobStatus.RUNNING
self.started_at = datetime.utcnow()
self.started_at = datetime.now(timezone.utc)
def succeed(self, result: Any, metrics: Optional[Dict[str, Any]] = None):
"""Mark job as succeeded."""
self.status = JobStatus.SUCCEEDED
self.result = result
self.metrics = metrics
self.completed_at = datetime.utcnow()
self.completed_at = datetime.now(timezone.utc)
self.progress_percentage = 100.0
def fail(self, error: str):
"""Mark job as failed."""
self.status = JobStatus.FAILED
self.error = error
self.completed_at = datetime.utcnow()
self.completed_at = datetime.now(timezone.utc)
def cancel(self):
"""Mark job as cancelled."""
self.status = JobStatus.CANCELLED
self.completed_at = datetime.utcnow()
self.completed_at = datetime.now(timezone.utc)
self._cancel_requested = True
def update_progress(
+2 -2
View File
@@ -7,7 +7,7 @@ Provides HTTP endpoints for job management.
import asyncio
import logging
from typing import Optional
from datetime import datetime
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException, Header, Request, Query, Response
from sse_starlette.sse import EventSourceResponse
@@ -304,7 +304,7 @@ def create_router(store: JobStore, executor: JobExecutor) -> APIRouter:
# Send heartbeat
yield {
"event": "heartbeat",
"data": f'{{"timestamp": "{datetime.utcnow().isoformat()}"}}'
"data": f'{{"timestamp": "{datetime.now(timezone.utc).isoformat()}"}}'
}
finally:
executor.unregister_progress_callback(job_id)
+22 -14
View File
@@ -83,8 +83,9 @@ class InMemoryJobStore(JobStore):
def _get_lock(self) -> asyncio.Lock:
"""Get the asyncio lock, creating it lazily if needed.
This deferred creation is required for Python 3.9 compatibility
where asyncio.Lock() calls get_event_loop() at creation time.
Deferred creation avoids touching the event loop at __init__ time,
which keeps the store safe to instantiate from sync contexts on
Python 3.9 (where asyncio.Lock() implicitly calls get_event_loop()).
"""
if self.__lock is None:
self.__lock = asyncio.Lock()
@@ -125,14 +126,14 @@ class InMemoryJobStore(JobStore):
async def get(self, job_id: str) -> Optional[Job]:
"""Get a job by ID."""
return self._jobs.get(job_id)
async with self._get_lock():
return self._jobs.get(job_id)
async def get_by_idempotency_key(self, key: str) -> Optional[Job]:
"""Get a job by idempotency key."""
job_id = self._idempotency_keys.get(key)
if job_id:
return self._jobs.get(job_id)
return None
async with self._get_lock():
job_id = self._idempotency_keys.get(key)
return self._jobs.get(job_id) if job_id else None
async def list_jobs(
self,
@@ -142,9 +143,10 @@ class InMemoryJobStore(JobStore):
offset: int = 0
) -> List[Job]:
"""List jobs with optional filters."""
jobs = list(self._jobs.values())
async with self._get_lock():
jobs = list(self._jobs.values())
# Apply filters
# Apply filters outside the lock - values() snapshot is already taken
if status:
jobs = [j for j in jobs if j.status == status]
if session_id:
@@ -162,7 +164,8 @@ class InMemoryJobStore(JobStore):
session_id: Optional[str] = None
) -> int:
"""Count jobs matching filters."""
jobs = list(self._jobs.values())
async with self._get_lock():
jobs = list(self._jobs.values())
if status:
jobs = [j for j in jobs if j.status == status]
@@ -201,15 +204,20 @@ class InMemoryJobStore(JobStore):
return len(to_remove)
def get_stats(self) -> Dict[str, Any]:
async def get_stats(self) -> Dict[str, Any]:
"""Get store statistics."""
async with self._get_lock():
# Take a snapshot to avoid RuntimeError if the dict is mutated concurrently
jobs_snapshot = list(self._jobs.values())
idempotency_count = len(self._idempotency_keys)
status_counts = {}
for job in self._jobs.values():
for job in jobs_snapshot:
status_counts[job.status.value] = status_counts.get(job.status.value, 0) + 1
return {
"total_jobs": len(self._jobs),
"idempotency_keys": len(self._idempotency_keys),
"total_jobs": len(jobs_snapshot),
"idempotency_keys": idempotency_count,
"max_jobs": self._max_jobs,
"status_counts": status_counts
}
+124 -182
View File
@@ -22,6 +22,7 @@ Example:
from typing import Any, Callable, Dict, List, Optional, Type, Union
import threading
from .._registry import PluginRegistry
# Type aliases
@@ -30,171 +31,46 @@ ProviderFactory = Callable[[str, Optional[Dict[str, Any]]], Any]
ProviderType = Union[ProviderClass, ProviderFactory]
class LLMProviderRegistry:
class LLMProviderRegistry(PluginRegistry[ProviderType]):
"""
Registry for LLM providers.
Manages registration and resolution of LLM providers by name.
Supports lazy loading, aliases, and isolated instances.
Thread-safe for concurrent operations.
Now inherits from PluginRegistry to eliminate duplication.
"""
_instance: Optional["LLMProviderRegistry"] = None
_instance_lock = threading.Lock()
def __init__(self):
"""Initialize an empty registry."""
self._providers: Dict[str, ProviderType] = {}
self._aliases: Dict[str, str] = {} # alias -> canonical name
self._lock = threading.RLock() # RLock for re-entrant calls
# Register built-in providers during initialization
_register_builtin_providers(self)
@classmethod
def get_instance(cls) -> "LLMProviderRegistry":
"""
Get the singleton registry instance.
"""Initialize LLM provider registry."""
# Get built-in provider loaders
builtins = _get_builtin_provider_loaders()
Returns:
LLMProviderRegistry: The singleton registry instance
"""
if cls._instance is None:
with cls._instance_lock:
if cls._instance is None:
cls._instance = cls()
return cls._instance
# Initialize parent with entry points and builtins
super().__init__(
entry_point_group="praisonai.llm_providers",
builtins=builtins
)
# Register aliases for built-in providers
self._register_builtin_aliases()
def register(
self,
name: str,
provider: ProviderType,
*,
override: bool = False,
aliases: Optional[List[str]] = None
) -> None:
"""
Register a provider by name.
def _register_builtin_aliases(self) -> None:
"""Register aliases for built-in providers."""
aliases_map = [
("openai", ("oai",)),
("anthropic", ("claude",)),
("google", ("gemini", "google_genai")),
]
Args:
name: Provider name (e.g., 'cloudflare', 'ollama')
provider: Provider class or factory function
override: Allow overwriting existing registration
aliases: Additional names that resolve to this provider
Raises:
ValueError: If name is already registered (unless override=True)
"""
with self._lock:
normalized_name = name.lower()
# Check for existing registration
if normalized_name in self._providers and not override:
raise ValueError(
f"Provider '{name}' is already registered. "
f"Use override=True to replace it."
)
# Check if this canonical name conflicts with existing alias
if normalized_name in self._aliases and not override:
existing_target = self._aliases[normalized_name]
raise ValueError(
f"Cannot register provider '{name}' - it conflicts with existing alias "
f"(currently points to '{existing_target}'). Use override=True to replace it."
)
# If override=True, clean up any existing alias before registering canonical name
if override and normalized_name in self._aliases:
del self._aliases[normalized_name]
self._providers[normalized_name] = provider
# Register aliases
if aliases:
for alias in aliases:
normalized_alias = alias.lower()
# Check collision with existing provider name
if normalized_alias in self._providers and not override:
raise ValueError(
f"Alias '{alias}' conflicts with existing provider name. "
f"Use override=True to replace it."
)
# Check collision with existing alias
if normalized_alias in self._aliases and not override:
existing_target = self._aliases[normalized_alias]
raise ValueError(
f"Alias '{alias}' is already registered (points to '{existing_target}'). "
f"Use override=True to replace it."
)
self._aliases[normalized_alias] = normalized_name
for name, aliases in aliases_map:
if name in self._items: # Only add aliases if the provider loaded
for alias in aliases:
self._aliases[alias.lower()] = name.lower()
def unregister(self, name: str) -> bool:
"""
Unregister a provider by name.
Args:
name: Provider name to unregister
Returns:
True if provider was unregistered, False if not found
"""
with self._lock:
normalized_name = name.lower()
# Check if it's an alias
if normalized_name in self._aliases:
del self._aliases[normalized_name]
return True
# Check if it's a canonical name
if normalized_name in self._providers:
# Remove all aliases pointing to this provider
aliases_to_remove = [
alias for alias, canonical in self._aliases.items()
if canonical == normalized_name
]
for alias in aliases_to_remove:
del self._aliases[alias]
del self._providers[normalized_name]
return True
return False
def has(self, name: str) -> bool:
"""
Check if a provider is registered.
Args:
name: Provider name to check
Returns:
True if provider is registered
"""
with self._lock:
normalized_name = name.lower()
return normalized_name in self._providers or normalized_name in self._aliases
def list(self) -> List[str]:
"""
List all registered provider names (canonical names only).
Returns:
List of provider names
"""
with self._lock:
return list(self._providers.keys())
def list_all(self) -> List[str]:
"""
List all names including aliases.
Returns:
List of all registered names and aliases
"""
with self._lock:
return list(self._providers.keys()) + list(self._aliases.keys())
def resolve(
def resolve_provider(
self,
name: str,
model_id: str,
@@ -214,23 +90,69 @@ class LLMProviderRegistry:
Raises:
ValueError: If provider not found
"""
with self._lock:
normalized_name = name.lower()
# Resolve alias to canonical name
canonical_name = self._aliases.get(normalized_name, normalized_name)
provider = self._providers.get(canonical_name)
if provider is None:
available = list(self._providers.keys()) # Don't call self.list() to avoid double-locking
raise ValueError(
f"Unknown provider: '{name}'. "
f"Available providers: {', '.join(available) if available else 'none'}. "
f"Register a custom provider with register_llm_provider('{name}', YourProviderClass)."
)
provider_cls = self.resolve(name)
# Create instance outside the lock
return provider(model_id, config)
return provider_cls(model_id, config)
def register_provider(
self,
name: str,
provider: ProviderType,
*,
override: bool = False,
aliases: Optional[List[str]] = None
) -> None:
"""
Register a provider by name with override validation.
Args:
name: Provider name (e.g., 'cloudflare', 'ollama')
provider: Provider class or factory function
override: Allow overwriting existing registration
aliases: Additional names that resolve to this provider
Raises:
ValueError: If name is already registered (unless override=True)
"""
# Check for existing registration if override is False
if not override and self.is_available(name):
raise ValueError(
f"Provider '{name}' is already registered. "
f"Use override=True to replace it."
)
# Check alias conflicts if override is False
if not override and aliases:
for alias in aliases:
if self.is_available(alias):
raise ValueError(
f"Alias '{alias}' is already registered. "
f"Use override=True to replace it."
)
# If override=True, unregister existing entries first
if override:
self.unregister(name)
if aliases:
for alias in aliases:
self.unregister(alias)
# Use parent class registration
self.register(name, provider, aliases=aliases)
# Inherit unregister, is_available, list_names from parent
# Add backwards compatibility aliases
def has(self, name: str) -> bool:
"""Check if a provider is registered (alias for is_available)."""
return self.is_available(name)
def list(self) -> List[str]:
"""List provider names (alias for list_names)."""
return self.list_names()
def list_all(self) -> List[str]:
"""List all names including aliases (alias for list_all_names)."""
return self.list_all_names()
def get(self, name: str) -> Optional[ProviderType]:
"""
@@ -242,30 +164,40 @@ class LLMProviderRegistry:
Returns:
Provider class/factory or None
"""
with self._lock:
normalized_name = name.lower()
canonical_name = self._aliases.get(normalized_name, normalized_name)
return self._providers.get(canonical_name)
try:
return self.resolve(name)
except ValueError:
return None
# ============================================================================
# Default Registry Singleton
# Default Registry Instance (No Singleton)
# ============================================================================
# Default module-level registry instance
_default_llm_registry: Optional[LLMProviderRegistry] = None
_default_llm_lock = threading.Lock()
def get_default_llm_registry() -> LLMProviderRegistry:
"""
Get the default global LLM provider registry.
This is the registry used by create_llm_provider() when no custom registry
is specified. Uses the thread-safe singleton pattern.
is specified. Uses lazy initialization pattern.
"""
return LLMProviderRegistry.get_instance()
global _default_llm_registry
if _default_llm_registry is None:
with _default_llm_lock:
if _default_llm_registry is None:
_default_llm_registry = LLMProviderRegistry()
return _default_llm_registry
def _register_builtin_providers(registry: LLMProviderRegistry) -> None:
def _get_builtin_provider_loaders() -> Dict[str, Callable[[], ProviderType]]:
"""
Register built-in providers to a registry.
Get built-in provider loaders.
Returns a dict of name -> loader function that creates provider factories.
Uses lazy loading to avoid importing heavy dependencies at module load time.
"""
# Built-in adapter that wraps LiteLLM so create_llm_provider works out of the box.
@@ -324,13 +256,22 @@ def _register_builtin_providers(registry: LLMProviderRegistry) -> None:
return _LiteLLMProvider(model_id, cfg)
return factory
# Build loader dict for the canonical PluginRegistry
loaders: Dict[str, Callable[[], ProviderType]] = {}
# Cover the providers parse_model_string() already special-cases.
for name, aliases in [
("openai", ("oai",)),
("anthropic", ("claude",)),
("google", ("gemini", "google_genai")),
]:
registry.register(name, _make_litellm_factory(name), aliases=list(aliases))
# Use local function to capture name correctly in closure
def make_loader(provider_name):
return lambda: _make_litellm_factory(provider_name)
loaders[name] = make_loader(name)
# Note: aliases will be registered separately via PluginRegistry.register()
return loaders
def register_llm_provider(
@@ -354,7 +295,7 @@ def register_llm_provider(
register_llm_provider("cloudflare", CloudflareProvider)
"""
get_default_llm_registry().register(name, provider, override=override, aliases=aliases)
get_default_llm_registry().register_provider(name, provider, override=override, aliases=aliases)
def unregister_llm_provider(name: str) -> bool:
@@ -456,12 +397,12 @@ def create_llm_provider(
name = input_value["name"]
model_id = input_value.get("model_id", "default")
provider_config = input_value.get("config") or config
return reg.resolve(name, model_id, provider_config)
return reg.resolve_provider(name, model_id, provider_config)
# Case 3: String - parse and resolve
if isinstance(input_value, str):
parsed = parse_model_string(input_value)
return reg.resolve(parsed["provider_id"], parsed["model_id"], config)
return reg.resolve_provider(parsed["provider_id"], parsed["model_id"], config)
raise ValueError(
f"Invalid provider input. Expected string, provider instance, or spec dict. "
@@ -471,5 +412,6 @@ def create_llm_provider(
def _reset_default_registry() -> None:
"""Reset the default registry (mainly for testing)."""
with LLMProviderRegistry._instance_lock:
LLMProviderRegistry._instance = None
global _default_llm_registry
with _default_llm_lock:
_default_llm_registry = None
+77 -91
View File
@@ -14,11 +14,12 @@ except ImportError:
from importlib_metadata import entry_points
import logging
from .._registry import PluginRegistry
logger = logging.getLogger(__name__)
class StoreRegistry:
class StoreRegistry(PluginRegistry[Callable[..., Any]]):
"""Registry for persistence store factories."""
def __init__(self, kind: str, entry_point_group: str):
@@ -30,12 +31,14 @@ class StoreRegistry:
entry_point_group: The entry point group for external registrations
"""
self._kind = kind
self._factories: Dict[str, Callable[..., Any]] = {}
self._aliases: Dict[str, str] = {}
self._lock = threading.Lock()
self._register_entry_points(entry_point_group)
# Initialize parent with entry points
super().__init__(entry_point_group=entry_point_group)
# Load built-in stores based on kind
self._register_builtin_stores()
def register(self, name: str, factory: Callable, *, aliases=()) -> None:
def register_store(self, name: str, factory: Callable, *, aliases=()) -> None:
"""
Register a store factory.
@@ -44,12 +47,16 @@ class StoreRegistry:
factory: Factory function that creates store instances
aliases: Optional list of alias names for this backend
"""
with self._lock:
normalized_name = name.lower()
self._factories[normalized_name] = factory
for alias in aliases:
normalized_alias = alias.lower()
self._aliases[normalized_alias] = normalized_name
# Wrap the factory in a loader function for PluginRegistry
def factory_loader():
return factory
# Use parent's register method with aliases
super().register(name, factory_loader, aliases=list(aliases))
def register(self, name: str, factory: Callable, *, aliases=()) -> None:
"""Backwards compatibility alias for register_store."""
self.register_store(name, factory, aliases=aliases)
def create(self, name: str, **kwargs) -> Any:
"""
@@ -65,15 +72,11 @@ class StoreRegistry:
Raises:
ValueError: If backend is not registered
"""
normalized_name = name.lower()
with self._lock:
canonical = self._aliases.get(normalized_name, normalized_name)
factory = self._factories.get(canonical)
if factory is None:
# Get available backends for error message inside lock
available_backends = sorted(self._factories.keys())
if factory is None:
try:
factory_loader = self.resolve(name)
factory = factory_loader()
except ValueError:
available_backends = self.list_names()
raise ValueError(
f"Unknown {self._kind} backend: {name}. "
f"Registered: {available_backends}"
@@ -82,32 +85,20 @@ class StoreRegistry:
return factory(**kwargs)
def list_registered(self) -> list[str]:
"""List all registered backend names."""
with self._lock:
return sorted(self._factories.keys())
"""List all registered backend names (alias for list_names)."""
return self.list_names()
def list_aliases(self) -> Dict[str, str]:
"""List all aliases and their target backends."""
with self._lock:
return dict(self._aliases)
def _register_entry_points(self, group: str) -> None:
"""Register backends from entry points."""
try:
for ep in entry_points(group=group):
try:
self.register(ep.name, ep.load())
except Exception:
logger.warning(
"Failed to load %s backend %r from entry point",
self._kind, ep.name, exc_info=True
)
except Exception:
# entry_points() may fail in some environments
pass
def _register_builtin_stores(self) -> None:
"""Register built-in stores based on kind."""
if self._kind == "conversation":
_register_builtin_conversation_stores(self)
elif self._kind == "knowledge":
_register_builtin_knowledge_stores(self)
elif self._kind == "state":
_register_builtin_state_stores(self)
def _register_builtin_conversation_stores():
def _register_builtin_conversation_stores(registry: StoreRegistry):
"""Register built-in conversation stores with lazy imports."""
def _postgres(url=None, **kwargs):
@@ -155,24 +146,24 @@ def _register_builtin_conversation_stores():
return TursoConversationStore(url=url, **kwargs)
# Register all backends with aliases
CONVERSATION_STORES.register("postgres", _postgres,
registry.register("postgres", _postgres,
aliases=("neon", "cockroachdb", "crdb", "cockroach", "xata"))
CONVERSATION_STORES.register("async_postgres", _async_postgres,
registry.register("async_postgres", _async_postgres,
aliases=("asyncpg", "postgres_async"))
CONVERSATION_STORES.register("mysql", _mysql)
CONVERSATION_STORES.register("async_mysql", _async_mysql,
registry.register("mysql", _mysql)
registry.register("async_mysql", _async_mysql,
aliases=("aiomysql", "mysql_async"))
CONVERSATION_STORES.register("sqlite", _sqlite)
CONVERSATION_STORES.register("async_sqlite", _async_sqlite,
registry.register("sqlite", _sqlite)
registry.register("async_sqlite", _async_sqlite,
aliases=("aiosqlite", "sqlite_async"))
CONVERSATION_STORES.register("json", _json)
CONVERSATION_STORES.register("singlestore", _singlestore)
CONVERSATION_STORES.register("supabase", _supabase)
CONVERSATION_STORES.register("surrealdb", _surrealdb)
CONVERSATION_STORES.register("turso", _turso, aliases=("libsql",))
registry.register("json", _json)
registry.register("singlestore", _singlestore)
registry.register("supabase", _supabase)
registry.register("surrealdb", _surrealdb)
registry.register("turso", _turso, aliases=("libsql",))
def _register_builtin_knowledge_stores():
def _register_builtin_knowledge_stores(registry: StoreRegistry):
"""Register built-in knowledge stores with lazy imports."""
def _chroma(url=None, path=None, **kwargs):
@@ -259,37 +250,37 @@ def _register_builtin_knowledge_stores():
from .knowledge.cosmosdb_vector import CosmosDBVectorKnowledgeStore
return CosmosDBVectorKnowledgeStore(url=url, **kwargs)
KNOWLEDGE_STORES.register("chroma", _chroma, aliases=("chromadb",))
KNOWLEDGE_STORES.register("qdrant", _qdrant)
KNOWLEDGE_STORES.register("pinecone", _pinecone)
KNOWLEDGE_STORES.register("weaviate", _weaviate)
KNOWLEDGE_STORES.register("lancedb", _lancedb)
KNOWLEDGE_STORES.register("milvus", _milvus)
KNOWLEDGE_STORES.register("pgvector", _pgvector)
KNOWLEDGE_STORES.register("redis", _redis_vector)
KNOWLEDGE_STORES.register("cassandra", _cassandra)
KNOWLEDGE_STORES.register("clickhouse", _clickhouse)
KNOWLEDGE_STORES.register("mongodb_vector", _mongodb_vector,
registry.register("chroma", _chroma, aliases=("chromadb",))
registry.register("qdrant", _qdrant)
registry.register("pinecone", _pinecone)
registry.register("weaviate", _weaviate)
registry.register("lancedb", _lancedb)
registry.register("milvus", _milvus)
registry.register("pgvector", _pgvector)
registry.register("redis", _redis_vector)
registry.register("cassandra", _cassandra)
registry.register("clickhouse", _clickhouse)
registry.register("mongodb_vector", _mongodb_vector,
aliases=("mongodb_atlas", "mongo_vector"))
# Register missing backends that were supported in old factory
KNOWLEDGE_STORES.register("couchbase", _couchbase)
KNOWLEDGE_STORES.register("singlestore_vector", _singlestore_vector,
registry.register("couchbase", _couchbase)
registry.register("singlestore_vector", _singlestore_vector,
aliases=("singlestore_v",))
KNOWLEDGE_STORES.register("surrealdb_vector", _surrealdb_vector,
registry.register("surrealdb_vector", _surrealdb_vector,
aliases=("surrealdb_v",))
KNOWLEDGE_STORES.register("upstash_vector", _upstash_vector,
registry.register("upstash_vector", _upstash_vector,
aliases=("upstash_v",))
KNOWLEDGE_STORES.register("lightrag", _lightrag)
KNOWLEDGE_STORES.register("langchain", _langchain_adapter,
registry.register("lightrag", _lightrag)
registry.register("langchain", _langchain_adapter,
aliases=("langchain_adapter",))
KNOWLEDGE_STORES.register("llamaindex", _llamaindex_adapter,
registry.register("llamaindex", _llamaindex_adapter,
aliases=("llama_index", "llamaindex_adapter"))
KNOWLEDGE_STORES.register("cosmosdb", _cosmosdb_vector,
registry.register("cosmosdb", _cosmosdb_vector,
aliases=("cosmos", "azure_cosmos", "cosmosdb_vector"))
def _register_builtin_state_stores():
def _register_builtin_state_stores(registry: StoreRegistry):
"""Register built-in state stores with lazy imports."""
def _redis(url=None, **kwargs):
@@ -330,23 +321,18 @@ def _register_builtin_state_stores():
raise ValueError("GCS state store requires 'bucket_name' option")
return GCSStateStore(bucket_name=bucket, **kwargs)
STATE_STORES.register("redis", _redis)
STATE_STORES.register("dynamodb", _dynamodb)
STATE_STORES.register("firestore", _firestore)
STATE_STORES.register("mongodb", _mongodb)
STATE_STORES.register("async_mongodb", _async_mongodb,
registry.register("redis", _redis)
registry.register("dynamodb", _dynamodb)
registry.register("firestore", _firestore)
registry.register("mongodb", _mongodb)
registry.register("async_mongodb", _async_mongodb,
aliases=("motor", "mongodb_async"))
STATE_STORES.register("upstash", _upstash)
STATE_STORES.register("memory", _memory)
STATE_STORES.register("gcs", _gcs)
registry.register("upstash", _upstash)
registry.register("memory", _memory)
registry.register("gcs", _gcs)
# Create global registry instances
# Create global registry instances - built-in stores are registered automatically via __init__
CONVERSATION_STORES = StoreRegistry("conversation", "praisonai.conversation_stores")
KNOWLEDGE_STORES = StoreRegistry("knowledge", "praisonai.knowledge_stores")
STATE_STORES = StoreRegistry("state", "praisonai.state_stores")
# Register built-in stores
_register_builtin_conversation_stores()
_register_builtin_knowledge_stores()
_register_builtin_state_stores()
STATE_STORES = StoreRegistry("state", "praisonai.state_stores")
File diff suppressed because it is too large. Load diff
@@ -6,12 +6,12 @@ at regular intervals, enabling 24/7 autonomous agent operations.
"""
import threading
import time
import logging
from datetime import datetime
from typing import Optional, Dict, Any, Callable
from .base import ScheduleParser, PraisonAgentExecutor
from .shared import backoff_delay
logger = logging.getLogger(__name__)
@@ -292,9 +292,11 @@ class AgentScheduler:
logger.error(f"Agent execution failed on attempt {attempt + 1}: {e}")
if attempt < max_retries - 1:
wait_time = 30 * (attempt + 1) # Exponential backoff
wait_time = backoff_delay(attempt, initial=30.0, cap=300.0)
logger.debug(f"Waiting {wait_time}s before retry...")
time.sleep(wait_time)
if self._stop_event.wait(wait_time):
# stop() was called during backoff — exit immediately
return
if not success:
self._failure_count += 1
+6 -3
View File
@@ -64,7 +64,8 @@ def safe_call(cb, *args) -> None:
Supports both sync and async callables. When called from a running event
loop with a coroutine-returning callback, schedules it on the loop; when
called from sync code, runs it to completion via asyncio.run.
called from sync code, runs it via the shared persistent loop instead
of creating + tearing down a fresh one per call.
"""
if cb is None:
return
@@ -78,9 +79,11 @@ def safe_call(cb, *args) -> None:
try:
loop = asyncio.get_running_loop()
except RuntimeError:
# No running loop, use asyncio.run
# No loop here: dispatch to the shared persistent loop instead
# of creating + tearing down a fresh one.
try:
asyncio.run(result)
from .._async_bridge import run_sync
run_sync(result)
except Exception as e:
log.error("Scheduler async callback raised: %s", e)
else:
+24 -12
View File
@@ -7,6 +7,7 @@ for both builtin and user tools.
"""
import logging
import threading
from typing import Dict, Callable, List, Optional, Any
import inspect
@@ -19,45 +20,54 @@ class ToolRegistry:
def __init__(self):
self._functions: Dict[str, Callable] = {}
self._autogen_adapters: Dict[str, Callable] = {}
self._lock = threading.Lock()
def register_function(self, name: str, func: Callable) -> None:
"""Register a function tool."""
if not callable(func):
raise ValueError(f"Tool {name} must be callable")
self._functions[name] = func
with self._lock:
self._functions[name] = func
logger.debug(f"Registered function tool: {name}")
def register_autogen_adapter(self, tool_type_name: str, adapter: Callable) -> None:
"""Register an AutoGen-specific tool adapter."""
if not callable(adapter):
raise ValueError(f"AutoGen adapter for {tool_type_name} must be callable")
self._autogen_adapters[tool_type_name] = adapter
with self._lock:
self._autogen_adapters[tool_type_name] = adapter
logger.debug(f"Registered AutoGen adapter: {tool_type_name}")
def get_function(self, name: str) -> Optional[Callable]:
"""Get a function tool by name."""
return self._functions.get(name)
with self._lock:
return self._functions.get(name)
def get_autogen_adapter(self, tool_type_name: str) -> Optional[Callable]:
"""Get an AutoGen adapter by tool type name."""
return self._autogen_adapters.get(tool_type_name)
with self._lock:
return self._autogen_adapters.get(tool_type_name)
def list_functions(self) -> List[str]:
"""List all registered function tool names."""
return list(self._functions.keys())
with self._lock:
return list(self._functions.keys())
def list_autogen_adapters(self) -> List[str]:
"""List all registered AutoGen adapter names."""
return list(self._autogen_adapters.keys())
with self._lock:
return list(self._autogen_adapters.keys())
def get_functions_dict(self) -> Dict[str, Callable]:
"""Get a copy of all registered functions."""
return dict(self._functions)
with self._lock:
return dict(self._functions)
def clear(self) -> None:
"""Clear all registered tools."""
self._functions.clear()
self._autogen_adapters.clear()
with self._lock:
self._functions.clear()
self._autogen_adapters.clear()
logger.debug("Cleared tool registry")
def register_from_module(self, module: Any) -> List[str]:
@@ -75,7 +85,7 @@ class ToolRegistry:
if (not name.startswith('_') and
callable(obj) and
not inspect.isclass(obj)):
self.register_function(name, obj)
self.register_function(name, obj) # This already acquires the lock
registered.append(name)
logger.debug(f"Registered {len(registered)} functions from module: {registered}")
@@ -107,8 +117,10 @@ class ToolRegistry:
def __len__(self) -> int:
"""Return total number of registered tools."""
return len(self._functions) + len(self._autogen_adapters)
with self._lock:
return len(self._functions) + len(self._autogen_adapters)
def __contains__(self, name: str) -> bool:
"""Check if a tool function is registered."""
return name in self._functions
with self._lock:
return name in self._functions
+59 -21
View File
@@ -30,6 +30,7 @@ import threading
from pathlib import Path
from typing import Any, Callable, Dict, List, Mapping, Optional
from types import MappingProxyType
from ._safe_loader import load_user_module
logger = logging.getLogger(__name__)
@@ -74,31 +75,16 @@ class ToolResolver:
if self._local_tools_loaded: # Double-check inside lock
return self._local_tools_cache
# Security: Require explicit opt-in for local tools loading
if os.environ.get("PRAISONAI_ALLOW_LOCAL_TOOLS", "").lower() != "true":
logger.debug("Local tools loading disabled. Set PRAISONAI_ALLOW_LOCAL_TOOLS=true to enable.")
self._local_tools_cache = MappingProxyType({})
self._local_tools_loaded = True
return self._local_tools_cache
tools_path = Path(self._tools_py_path)
if not tools_path.exists():
logger.debug(f"No local tools.py found at {tools_path}")
self._local_tools_cache = MappingProxyType({})
self._local_tools_loaded = True
return self._local_tools_cache
try:
spec = importlib.util.spec_from_file_location("tools", str(tools_path))
if spec is None or spec.loader is None:
logger.warning(f"Could not load spec for {tools_path}")
# Use the same safe loader as other tools.py loading paths
module = load_user_module(self._tools_py_path, name="tools")
if module is None:
logger.debug(f"Local tools loading disabled or tools.py not found at {self._tools_py_path}")
self._local_tools_cache = MappingProxyType({})
self._local_tools_loaded = True
return self._local_tools_cache
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# Build cache locally, then freeze
cache: Dict[str, Callable] = {}
for name, obj in inspect.getmembers(module):
@@ -108,13 +94,13 @@ class ToolResolver:
cache[name] = obj
logger.debug(f"Loaded local tool: {name}")
logger.info(f"Loaded {len(cache)} tools from {tools_path}")
logger.info(f"Loaded {len(cache)} tools from {self._tools_py_path}")
# Create immutable view to prevent concurrent modification
self._local_tools_cache = MappingProxyType(cache)
except Exception as e:
logger.warning(f"Error loading tools from {tools_path}: {e}")
logger.warning(f"Error loading tools from {self._tools_py_path}: {e}")
self._local_tools_cache = MappingProxyType({})
self._local_tools_loaded = True
@@ -381,6 +367,58 @@ class ToolResolver:
with self._local_tools_lock:
self._local_tools_cache = MappingProxyType({})
self._local_tools_loaded = False
def get_local_callables(self) -> List[Callable]:
"""Get functions exposed by tools.py (path A semantics).
Returns:
List of callable functions from tools.py
"""
local_tools = self._load_local_tools()
return list(local_tools.values())
def get_local_tool_classes(self) -> Dict[str, Any]:
"""Get BaseTool/langchain class instances from tools.py (path B semantics).
Returns:
Dictionary mapping class names to instantiated tool objects
"""
try:
# Use the same safe loader to get the module
module = load_user_module(self._tools_py_path, name="tools_module")
if module is None:
return {}
# Import the necessary classes (matching agents_generator.py logic)
BaseTool = None
PRAISONAI_TOOLS_AVAILABLE = False
try:
from praisonai_tools import BaseTool
PRAISONAI_TOOLS_AVAILABLE = True
except ImportError:
try:
from praisonai.tools import BaseTool
PRAISONAI_TOOLS_AVAILABLE = True
except ImportError:
pass
result = {}
for name, obj in inspect.getmembers(module,
lambda x: inspect.isclass(x) and (
x.__module__.startswith('langchain_community.tools') or
(PRAISONAI_TOOLS_AVAILABLE and BaseTool and issubclass(x, BaseTool))
) and x is not BaseTool):
try:
result[name] = obj()
logger.debug(f"Loaded local tool class: {name}")
except Exception as e:
logger.warning(f"Error instantiating tool class {name}: {e}")
continue
return result
except Exception as e:
logger.warning(f"Error loading tool classes from {self._tools_py_path}: {e}")
return {}
# Convenience functions that construct resolver explicitly (no global singleton)
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.6.37"
__version__ = "4.6.39"
+2 -2
View File
@@ -12,9 +12,9 @@ dependencies = [
"rich>=13.7",
"markdown>=3.5",
"pyparsing>=3.0.0",
"praisonaiagents>=1.6.37",
"praisonaiagents>=1.6.39",
"python-dotenv>=0.19.0",
"litellm>=1.81.0,<=1.82.6",
"litellm>=1.83.14,<2",
"PyYAML>=6.0",
"mcp>=1.20.0",
"typer>=0.9.0",
+13 -5
View File
@@ -129,7 +129,12 @@ def temp_directory(tmp_path):
@pytest.fixture(autouse=True)
def setup_test_environment(request):
"""Setup test environment before each test."""
"""Setup test environment before each test.
For non-live unit/mock tests, fills missing provider API keys with placeholders
so imports and guardrails do not crash. Keys already present in the environment
(e.g. from ~/.bashrc or CI secrets) are never overwritten.
"""
# Only set test API keys for non-real tests
# Real tests (marked with @pytest.mark.real) should use actual environment variables
is_real_test = False
@@ -155,19 +160,22 @@ def setup_test_environment(request):
original_values = {}
if not is_real_test:
# Set test environment variables only for mock tests
# Placeholder keys only where unset — preserves keys from the parent shell
# (e.g. ~/.bashrc / ~/.zshrc) so local agentic unit tests can call real APIs.
test_keys = {
'OPENAI_API_KEY': 'test-key',
'ANTHROPIC_API_KEY': 'test-key',
'ANTHROPIC_API_KEY': 'test-key',
'GOOGLE_API_KEY': 'test-key',
'XAI_API_KEY': 'test-key',
'GROQ_API_KEY': 'test-key',
'COHERE_API_KEY': 'test-key',
}
for key, value in test_keys.items():
original_values[key] = os.environ.get(key)
os.environ[key] = value
# Only set placeholder when the key is absent (preserves explicit empty strings)
if key not in os.environ:
os.environ[key] = value
yield
@@ -0,0 +1,123 @@
#!/usr/bin/env python3
from __future__ import annotations
"""N4 — Real agentic smoke test for inbound DLQ.
This is not a pytest test file - it's an integration smoke test.
Scenario:
1. Real Agent (anthropic/claude-haiku-4-5 or gemini/2.5-flash).
2. First chat attempt fails (we patch agent.chat to raise once).
3. BotSessionManager enqueues to DLQ.
4. Operator replays the DLQ — second attempt hits the **real** LLM
and produces a real reply.
5. Assert DLQ is empty and the reply contains the expected fact.
Run::
cd /path/to/PraisonAI
PYTHONPATH=src/praisonai-agents:src/praisonai python src/praisonai/tests/integration/smoke_n4_real.py
"""
# Prevent pytest from treating this as a test file
__test__ = False
import asyncio
import os
import sys
import tempfile
from pathlib import Path
def _pick_model() -> str:
if os.getenv("ANTHROPIC_API_KEY"):
return "anthropic/claude-haiku-4-5"
if os.getenv("GOOGLE_API_KEY"):
return "gemini/gemini-2.5-flash"
raise RuntimeError(
"No supported API key found. Set ANTHROPIC_API_KEY or GOOGLE_API_KEY to run smoke tests."
)
async def main() -> int:
from praisonaiagents import Agent
from praisonai.bots import BotSessionManager, InboundDLQ
model = _pick_model()
print(f"Model: {model}")
agent = Agent(
name="N4Tester",
instructions="You are a helpful assistant. Reply with one short sentence.",
llm=model,
)
with tempfile.TemporaryDirectory() as tmp:
dlq_path = Path(tmp) / "dlq.sqlite"
dlq = InboundDLQ(path=dlq_path)
mgr = BotSessionManager(platform="telegram", dlq=dlq)
# 1) Force first chat to fail by monkey-patching agent.chat.
original_chat = agent.chat
def failing_chat_first_time(prompt, *a, **kw):
agent.chat = original_chat # next call goes to real LLM
raise RuntimeError("simulated LLM 503")
agent.chat = failing_chat_first_time
prompt = "What is 2 plus 2? Answer with a single digit."
print(f"\n[1] Sending failing message: {prompt!r}")
try:
await mgr.chat(
agent, "tg-1", prompt,
chat_id="100", user_name="OpsTester",
)
print("UNEXPECTED: first call did not raise.")
return 1
except RuntimeError as e:
print(f" Caught expected error: {e}")
# 2) DLQ must hold one entry.
if dlq.size() != 1:
print(f"FAIL: expected DLQ size 1, got {dlq.size()}")
return 1
print(f" DLQ size after fail: {dlq.size()} ✅")
# 3) Replay through the (now-restored) real LLM.
print("\n[2] Replaying DLQ via real LLM …")
seen_replies = []
async def replayer(entry):
try:
reply = await mgr.chat(
agent, entry.user_id, entry.prompt,
chat_id=entry.chat_id,
user_name=entry.user_name,
)
seen_replies.append(reply)
return True
except Exception as e:
print(f" replay failed: {e}")
return False
succeeded, failed = await dlq.replay(replayer)
print(f" succeeded={succeeded}, failed={failed}, "
f"remaining={dlq.size()}")
if succeeded != 1 or failed != 0 or dlq.size() != 0:
print("FAIL: replay outcome wrong.")
return 1
reply = seen_replies[0]
print(f"\n[Real LLM reply] {reply}\n")
if reply and "4" in reply:
print("PASS: DLQ → replay → real LLM produced expected '4'.")
return 0
print("FAIL: reply did not contain expected answer.")
return 1
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
@@ -1,6 +1,10 @@
#!/usr/bin/env python3
from __future__ import annotations
"""W1 real agentic smoke test (high-level BotOS API, no manual mgr).
This is not a pytest test file - it's an integration smoke test.
Uses the user-facing API only — ``Bot``, ``BotOS``, ``InMemoryIdentityResolver`` —
and exercises the same cross-platform-continuity scenario as
``smoke_w1_real.py`` but without touching ``BotSessionManager`` directly.
@@ -13,10 +17,11 @@ wire-up from ``BotOS(identity_resolver=...)`` → ``Bot._identity_resolver``
Run::
# From the repository root:
PYTHONPATH=src/praisonai-agents:src/praisonai python scripts/smoke_w1_botos_real.py
PYTHONPATH=src/praisonai-agents:src/praisonai python src/praisonai/tests/integration/smoke_w1_botos_real.py
"""
from __future__ import annotations
# Prevent pytest from treating this as a test file
__test__ = False
import asyncio
import os
@@ -1,13 +1,18 @@
#!/usr/bin/env python3
from __future__ import annotations
"""W1 real agentic smoke test (bypasses pytest conftest gating).
This is not a pytest test file - it's an integration smoke test.
Runs::
cd src/praisonai
python ../../scripts/smoke_w1_real.py
cd /path/to/PraisonAI
PYTHONPATH=src/praisonai-agents:src/praisonai python src/praisonai/tests/integration/smoke_w1_real.py
"""
from __future__ import annotations
# Prevent pytest from treating this as a test file
__test__ = False
import asyncio
import os
@@ -1,6 +1,10 @@
#!/usr/bin/env python3
from __future__ import annotations
"""W1 deep-validation smoke test.
This is not a pytest test file - it's an integration smoke test.
Exercises every robustness dimension of W1 with REAL LLM calls:
T1. 3-platform unification (telegram → discord → slack, one user)
@@ -12,11 +16,12 @@ Exercises every robustness dimension of W1 with REAL LLM calls:
Run::
cd /Users/praison/worktrees/hermes-parity
PYTHONPATH=src/praisonai-agents:src/praisonai python scripts/smoke_w1_robust.py
cd /path/to/PraisonAI
PYTHONPATH=src/praisonai-agents:src/praisonai python src/praisonai/tests/integration/smoke_w1_robust.py
"""
from __future__ import annotations
# Prevent pytest from treating this as a test file
__test__ = False
import asyncio
import os
@@ -22,14 +22,17 @@ class TestAgentMailPlatformRegistry:
from praisonai.bots._registry import _BUILTIN_PLATFORMS
assert "agentmail" in _BUILTIN_PLATFORMS
assert _BUILTIN_PLATFORMS["agentmail"] == ("praisonai.bots.agentmail", "AgentMailBot")
loader = _BUILTIN_PLATFORMS["agentmail"]
assert callable(loader)
assert loader.__name__ == "_agentmail_loader"
def test_resolve_adapter_returns_agentmail_bot(self):
"""resolve_adapter('agentmail') should return AgentMailBot class."""
from praisonai.bots import AgentMailBot
from praisonai.bots._registry import resolve_adapter
cls = resolve_adapter("agentmail")
assert cls.__name__ == "AgentMailBot"
assert cls is AgentMailBot
def test_list_platforms_includes_agentmail(self):
"""list_platforms() should include 'agentmail'."""
+160
View File
@@ -0,0 +1,160 @@
"""N4 — Inbound Dead-Letter Queue for BotSessionManager.
Tests written first (TDD). Implementation in praisonai/bots/_dlq.py.
"""
from __future__ import annotations
import pytest
# ─── Smoke / basic API ───────────────────────────────────────────────
class TestDLQBasic:
def test_import_and_construct(self, tmp_path):
from praisonai.bots import InboundDLQ
dlq = InboundDLQ(path=tmp_path / "dlq.sqlite")
assert dlq.path == tmp_path / "dlq.sqlite"
assert dlq.size() == 0
def test_enqueue_then_size_one(self, tmp_path):
from praisonai.bots import InboundDLQ
dlq = InboundDLQ(path=tmp_path / "dlq.sqlite")
dlq.enqueue(
platform="telegram",
user_id="12345",
prompt="hello",
error="LLMTimeout",
)
assert dlq.size() == 1
def test_list_returns_entries(self, tmp_path):
from praisonai.bots import InboundDLQ
dlq = InboundDLQ(path=tmp_path / "dlq.sqlite")
dlq.enqueue(platform="discord", user_id="u1", prompt="hi", error="X")
dlq.enqueue(platform="discord", user_id="u2", prompt="hey", error="Y")
entries = dlq.list()
assert len(entries) == 2
# newest first
assert entries[0].user_id in {"u1", "u2"}
def test_purge_clears_queue(self, tmp_path):
from praisonai.bots import InboundDLQ
dlq = InboundDLQ(path=tmp_path / "dlq.sqlite")
dlq.enqueue(platform="t", user_id="u", prompt="p", error="e")
assert dlq.size() == 1
dlq.purge()
assert dlq.size() == 0
# ─── Persistence ─────────────────────────────────────────────────────
class TestDLQPersistence:
def test_survives_restart(self, tmp_path):
from praisonai.bots import InboundDLQ
path = tmp_path / "dlq.sqlite"
dlq1 = InboundDLQ(path=path)
dlq1.enqueue(platform="t", user_id="u", prompt="hi", error="e")
dlq2 = InboundDLQ(path=path)
assert dlq2.size() == 1
# ─── Bounds & TTL ────────────────────────────────────────────────────
class TestDLQBounds:
def test_max_size_drops_oldest(self, tmp_path):
from praisonai.bots import InboundDLQ
dlq = InboundDLQ(path=tmp_path / "dlq.sqlite", max_size=3)
for i in range(5):
dlq.enqueue(
platform="t", user_id=f"u{i}",
prompt=f"msg{i}", error="boom",
)
assert dlq.size() == 3
# Newest 3 retained
users = {e.user_id for e in dlq.list()}
assert users == {"u2", "u3", "u4"}
def test_ttl_evicts_old(self, tmp_path):
from praisonai.bots import InboundDLQ
dlq = InboundDLQ(
path=tmp_path / "dlq.sqlite",
ttl_seconds=0, # everything is "expired" immediately
)
dlq.enqueue(platform="t", user_id="u", prompt="p", error="e")
# Enqueue itself runs eviction
dlq.evict_expired()
assert dlq.size() == 0
# ─── Replay ──────────────────────────────────────────────────────────
class TestDLQReplay:
@pytest.mark.asyncio
async def test_replay_invokes_callback_per_entry(self, tmp_path):
from praisonai.bots import InboundDLQ
dlq = InboundDLQ(path=tmp_path / "dlq.sqlite")
dlq.enqueue(platform="t", user_id="u1", prompt="m1", error="e")
dlq.enqueue(platform="t", user_id="u2", prompt="m2", error="e")
seen = []
async def replayer(entry):
seen.append((entry.user_id, entry.prompt))
return True # success → drop entry
succeeded, failed = await dlq.replay(replayer)
assert succeeded == 2
assert failed == 0
assert dlq.size() == 0
assert {x[0] for x in seen} == {"u1", "u2"}
@pytest.mark.asyncio
async def test_replay_keeps_failed_entries(self, tmp_path):
from praisonai.bots import InboundDLQ
dlq = InboundDLQ(path=tmp_path / "dlq.sqlite")
dlq.enqueue(platform="t", user_id="u", prompt="m", error="e")
async def failing_replayer(entry):
return False
succeeded, failed = await dlq.replay(failing_replayer)
assert succeeded == 0
assert failed == 1
assert dlq.size() == 1 # entry retained
# ─── Wired into BotSessionManager.chat() ─────────────────────────────
class TestSessionManagerEnqueuesOnFailure:
@pytest.mark.asyncio
async def test_chat_enqueues_when_agent_raises(self, tmp_path):
from praisonai.bots import BotSessionManager, InboundDLQ
class FailingAgent:
chat_history = []
def chat(self, prompt):
raise RuntimeError("LLM 503")
dlq = InboundDLQ(path=tmp_path / "dlq.sqlite")
mgr = BotSessionManager(platform="telegram", dlq=dlq)
with pytest.raises(RuntimeError):
await mgr.chat(FailingAgent(), "12345", "hello")
assert dlq.size() == 1
e = dlq.list()[0]
assert e.platform == "telegram"
assert e.user_id == "12345"
assert e.prompt == "hello"
assert "LLM 503" in e.error
@pytest.mark.asyncio
async def test_chat_no_dlq_when_no_dlq_configured(self, tmp_path):
"""Default behaviour preserved: no DLQ, error bubbles up."""
from praisonai.bots import BotSessionManager
class FailingAgent:
chat_history = []
def chat(self, prompt):
raise RuntimeError("LLM 503")
mgr = BotSessionManager(platform="telegram") # no dlq=
with pytest.raises(RuntimeError):
await mgr.chat(FailingAgent(), "12345", "hello")
# No exception about DLQ; nothing persisted (default behaviour)
@@ -24,14 +24,17 @@ class TestEmailPlatformRegistry:
from praisonai.bots._registry import _BUILTIN_PLATFORMS
assert "email" in _BUILTIN_PLATFORMS
assert _BUILTIN_PLATFORMS["email"] == ("praisonai.bots.email", "EmailBot")
loader = _BUILTIN_PLATFORMS["email"]
assert callable(loader)
assert loader.__name__ == "_email_loader"
def test_resolve_adapter_returns_email_bot(self):
"""Verify resolve_adapter('email') returns EmailBot class."""
from praisonai.bots import EmailBot
from praisonai.bots._registry import resolve_adapter
EmailBot = resolve_adapter("email")
assert EmailBot.__name__ == "EmailBot"
cls = resolve_adapter("email")
assert cls is EmailBot
def test_list_platforms_includes_email(self):
"""Verify list_platforms() includes email."""
@@ -1,5 +1,7 @@
"""Unit tests for jobs store."""
import asyncio
import threading
import pytest
from praisonai.jobs.models import Job, JobStatus
@@ -237,3 +239,74 @@ class TestInMemoryJobStore:
assert "status_counts" in stats
assert stats["status_counts"]["queued"] == 1
assert stats["status_counts"]["running"] == 1
class TestInMemoryJobStoreConcurrency:
"""Concurrency tests for InMemoryJobStore."""
@pytest.mark.asyncio
async def test_concurrent_saves_no_data_loss(self):
"""Concurrent save() calls must not lose jobs."""
store = InMemoryJobStore(max_jobs=1000)
jobs = [Job(prompt=f"Job {i}") for i in range(50)]
await asyncio.gather(*[store.save(job) for job in jobs])
count = await store.count()
assert count == 50
@pytest.mark.asyncio
async def test_concurrent_reads_while_writing(self):
"""list_jobs() and count() must not raise while saves are in flight."""
store = InMemoryJobStore(max_jobs=1000)
errors = []
async def writer():
for i in range(20):
await store.save(Job(prompt=f"job {i}"))
async def reader():
for _ in range(30):
try:
await store.list_jobs()
await store.count()
except Exception as e:
errors.append(e)
await asyncio.gather(writer(), reader(), reader())
assert errors == [], f"Concurrent read/write raised: {errors}"
@pytest.mark.asyncio
async def test_get_stats_consistent_snapshot(self):
"""get_stats() must not raise RuntimeError with concurrent mutations."""
store = InMemoryJobStore(max_jobs=1000)
errors = []
async def writer():
for i in range(30):
await store.save(Job(prompt=f"job {i}"))
def stats_reader():
for _ in range(50):
try:
store.get_stats()
except RuntimeError as e:
errors.append(e)
# Run stats reader in a thread while async writer runs
t = threading.Thread(target=stats_reader)
t.start()
await writer()
t.join()
assert errors == [], f"get_stats raised RuntimeError: {errors}"
@pytest.mark.asyncio
async def test_get_stats_returns_correct_total(self):
"""get_stats() total_jobs must match actual job count."""
store = InMemoryJobStore(max_jobs=100)
for i in range(5):
await store.save(Job(prompt=f"Job {i}"))
stats = store.get_stats()
assert stats["total_jobs"] == 5
@@ -169,8 +169,7 @@ class TestAgentSchedulerExecution:
with pytest.raises(Exception, match="Agent error"):
scheduler.execute_once()
@patch('time.sleep')
def test_execute_with_retry_success_first_try(self, mock_sleep):
def test_execute_with_retry_success_first_try(self):
"""Test _execute_with_retry() succeeds on first try."""
mock_agent = Mock()
mock_agent.start = Mock(return_value="Success")
@@ -180,10 +179,8 @@ class TestAgentSchedulerExecution:
assert scheduler._success_count == 1
assert scheduler._failure_count == 0
mock_sleep.assert_not_called()
@patch('time.sleep')
def test_execute_with_retry_success_on_retry(self, mock_sleep):
def test_execute_with_retry_success_on_retry(self):
"""Test _execute_with_retry() succeeds on retry."""
mock_agent = Mock()
mock_agent.start = Mock(side_effect=[
@@ -192,37 +189,56 @@ class TestAgentSchedulerExecution:
])
scheduler = AgentScheduler(mock_agent, "Test task")
# Mock Event.wait() to return False immediately (not stopped, backoff skipped)
scheduler._stop_event.wait = Mock(return_value=False)
scheduler._execute_with_retry(max_retries=3)
assert scheduler._success_count == 1
assert scheduler._failure_count == 0
assert mock_agent.start.call_count == 2
@patch('time.sleep')
def test_execute_with_retry_all_fail(self, mock_sleep):
def test_execute_with_retry_all_fail(self):
"""Test _execute_with_retry() when all retries fail."""
mock_agent = Mock()
mock_agent.start = Mock(side_effect=Exception("Always fails"))
scheduler = AgentScheduler(mock_agent, "Test task")
# Mock Event.wait() to return False immediately (not stopped, backoff skipped)
scheduler._stop_event.wait = Mock(return_value=False)
scheduler._execute_with_retry(max_retries=3)
assert scheduler._success_count == 0
assert scheduler._failure_count == 1
assert mock_agent.start.call_count == 3
@patch('time.sleep')
def test_execute_with_retry_exponential_backoff(self, mock_sleep):
"""Test _execute_with_retry() uses exponential backoff."""
def test_execute_with_retry_backoff_uses_stop_event(self):
"""Test _execute_with_retry() uses Event.wait() for backoff, not time.sleep()."""
mock_agent = Mock()
mock_agent.start = Mock(side_effect=Exception("Always fails"))
scheduler = AgentScheduler(mock_agent, "Test task")
scheduler._stop_event.wait = Mock(return_value=False)
scheduler._execute_with_retry(max_retries=3)
# Should sleep 30s, then 60s (exponential backoff)
assert mock_sleep.call_count == 2
mock_sleep.assert_has_calls([call(30), call(60)])
# Should call Event.wait() twice (between attempt 1->2 and 2->3)
assert scheduler._stop_event.wait.call_count == 2
# Each wait should be called with a positive backoff delay
for call_args in scheduler._stop_event.wait.call_args_list:
wait_time = call_args[0][0]
assert wait_time > 0, "backoff delay must be positive"
def test_execute_with_retry_stops_immediately_on_stop_event(self):
"""Test that _execute_with_retry() exits when stop() is called during backoff."""
mock_agent = Mock()
mock_agent.start = Mock(side_effect=Exception("Always fails"))
scheduler = AgentScheduler(mock_agent, "Test task")
# Mock Event.wait() to return True (stop was signaled)
scheduler._stop_event.wait = Mock(return_value=True)
scheduler._execute_with_retry(max_retries=3)
# Should stop after first failure + backoff signal, not complete all retries
assert mock_agent.start.call_count == 1
class TestAgentSchedulerCallbacks:
@@ -239,14 +255,15 @@ class TestAgentSchedulerCallbacks:
on_success.assert_called_once_with("Success result")
@patch('time.sleep')
def test_on_failure_callback_invoked(self, mock_sleep):
def test_on_failure_callback_invoked(self):
"""Test on_failure callback is invoked on failure."""
mock_agent = Mock()
mock_agent.start = Mock(side_effect=Exception("Error"))
on_failure = Mock()
scheduler = AgentScheduler(mock_agent, "Test task", on_failure=on_failure)
# Mock Event.wait() to return False immediately (not stopped, backoff skipped)
scheduler._stop_event.wait = Mock(return_value=False)
scheduler._execute_with_retry(max_retries=2)
on_failure.assert_called_once()
@@ -170,7 +170,7 @@ class TestStopTimeoutCancelPath:
scheduler._ensure_async_primitives()
# Simulate a task that raises a plain Exception when awaited after cancel
future = asyncio.get_event_loop().create_future()
future = asyncio.get_running_loop().create_future()
future.set_exception(RuntimeError("task blew up"))
scheduler._task = future
scheduler._stop_event.set()
@@ -273,7 +273,7 @@ class TestAsyncAgentSchedulerLifecycle:
@pytest.mark.asyncio
async def test_get_stats_initial_state(self):
scheduler = _make_scheduler()
stats = scheduler.get_stats()
stats = await scheduler.get_stats()
assert stats["is_running"] is False
assert stats["total_executions"] == 0
assert stats["successful_executions"] == 0
@@ -10,10 +10,13 @@ class TestPraisonAISessionDataStore:
def test_init_with_default_store(self):
"""Test initialization with default hierarchical store."""
with patch('praisonai.ui._aiui_datastore.get_hierarchical_session_store') as mock_get_store:
import praisonai.ui._aiui_datastore as _ds
with patch("praisonaiagents.session.get_hierarchical_session_store") as mock_get_store, \
patch.object(_ds, "_impl_cls", None):
mock_store = Mock()
mock_get_store.return_value = mock_store
adapter = PraisonAISessionDataStore()
assert adapter._store == mock_store
@@ -231,4 +234,4 @@ class TestPraisonAISessionDataStore:
# Removed test_import_fallback - was a no-op test that passed regardless of behavior
# After Blocker 1 fix, imports now fail loudly instead of falling back silently
# After Blocker 1 fix, imports now fail loudly instead of falling back silently
@@ -114,46 +114,70 @@ class TestSafeStringSubstitution:
class TestThreadSafety:
"""Test thread-safe patterns added in the wrapper layer."""
def test_get_openai_client_returns_same_instance_for_same_key(self):
"""Multiple threads requesting the same key must get the same client object."""
def test_get_openai_client_returns_same_instance_per_generator(self):
"""Multiple threads on one generator must get the same client object."""
import threading
import types
import sys
import praisonai.auto as auto
import unittest.mock as mock
auto._openai_client = None
auto._openai_client_key = None
class DummyOpenAI:
def __init__(self, *args, **kwargs):
pass
def close(self):
pass
generator = auto.BaseAutoGenerator(config_list=[{
"model": "gpt-4o-mini",
"api_key": "test-thread-key",
"base_url": None,
}])
results = []
errors = []
def call_client():
try:
client = auto._get_openai_client(api_key="test-thread-key")
client = generator._get_openai_client()
results.append(id(client))
except Exception as exc:
errors.append(exc)
threads = [threading.Thread(target=call_client) for _ in range(8)]
for t in threads:
t.start()
for t in threads:
t.join()
with mock.patch.dict(sys.modules, {"openai": types.SimpleNamespace(OpenAI=DummyOpenAI)}):
threads = [threading.Thread(target=call_client) for _ in range(8)]
for t in threads:
t.start()
for t in threads:
t.join()
assert not errors, f"Unexpected errors: {errors}"
assert len(results) == 8
# All threads must receive the exact same cached instance
assert len(set(results)) == 1, "Got different client instances across threads"
def test_get_openai_client_rebuilds_on_key_change(self):
"""Changing the (api_key, base_url) pair must produce a new client."""
def test_get_openai_client_is_not_shared_across_generators(self):
"""Different generator instances must not share a client."""
import types
import sys
import praisonai.auto as auto
import unittest.mock as mock
auto._openai_client = None
auto._openai_client_key = None
class DummyOpenAI:
def __init__(self, *args, **kwargs):
pass
client_a = auto._get_openai_client(api_key="key-a")
client_b = auto._get_openai_client(api_key="key-b")
def close(self):
pass
assert id(client_a) != id(client_b), "Different keys must yield different clients"
generator_a = auto.BaseAutoGenerator(config_list=[{"model": "gpt-4o-mini", "api_key": "key-a", "base_url": None}])
generator_b = auto.BaseAutoGenerator(config_list=[{"model": "gpt-4o-mini", "api_key": "key-b", "base_url": None}])
with mock.patch.dict(sys.modules, {"openai": types.SimpleNamespace(OpenAI=DummyOpenAI)}):
client_a = generator_a._get_openai_client()
client_b = generator_b._get_openai_client()
assert id(client_a) != id(client_b), "Different generators must own distinct clients"
def test_get_openai_client_no_root_logger_mutation(self):
"""Importing auto must not add any new handlers to the root logger."""
@@ -220,4 +244,3 @@ class TestThreadSafety:
assert main_mod._typer_commands_cache is None, (
"Cache must remain None after failure to allow retries"
)
@@ -13,7 +13,6 @@ from unittest.mock import patch, MagicMock
try:
from praisonai.framework_adapters.validators import assert_framework_available
from praisonai.framework_adapters.registry import FrameworkAdapterRegistry
except ImportError as e:
pytest.skip(f"Could not import framework_adapters: {e}", allow_module_level=True)
@@ -22,7 +21,7 @@ class TestAssertFrameworkAvailableRaises:
"""Tests that ImportError is raised for unavailable frameworks."""
def test_raises_import_error_for_missing_framework(self):
with patch.object(FrameworkAdapterRegistry, "get_instance") as mock_get:
with patch("praisonai.framework_adapters.validators.get_default_registry") as mock_get:
mock_registry = MagicMock()
mock_registry.is_available.return_value = False
mock_get.return_value = mock_registry
@@ -31,7 +30,7 @@ class TestAssertFrameworkAvailableRaises:
assert_framework_available("crewai")
def test_error_message_contains_framework_name(self):
with patch.object(FrameworkAdapterRegistry, "get_instance") as mock_get:
with patch("praisonai.framework_adapters.validators.get_default_registry") as mock_get:
mock_registry = MagicMock()
mock_registry.is_available.return_value = False
mock_get.return_value = mock_registry
@@ -40,7 +39,7 @@ class TestAssertFrameworkAvailableRaises:
assert_framework_available("crewai")
def test_error_message_contains_install_hint(self):
with patch.object(FrameworkAdapterRegistry, "get_instance") as mock_get:
with patch("praisonai.framework_adapters.validators.get_default_registry") as mock_get:
mock_registry = MagicMock()
mock_registry.is_available.return_value = False
mock_get.return_value = mock_registry
@@ -49,7 +48,7 @@ class TestAssertFrameworkAvailableRaises:
assert_framework_available("crewai")
def test_crewai_hint_mentions_praisonai_extra(self):
with patch.object(FrameworkAdapterRegistry, "get_instance") as mock_get:
with patch("praisonai.framework_adapters.validators.get_default_registry") as mock_get:
mock_registry = MagicMock()
mock_registry.is_available.return_value = False
mock_get.return_value = mock_registry
@@ -60,7 +59,7 @@ class TestAssertFrameworkAvailableRaises:
assert "praisonai[crewai]" in error_msg or "pip install crewai" in error_msg
def test_autogen_hint_mentions_pyautogen(self):
with patch.object(FrameworkAdapterRegistry, "get_instance") as mock_get:
with patch("praisonai.framework_adapters.validators.get_default_registry") as mock_get:
mock_registry = MagicMock()
mock_registry.is_available.return_value = False
mock_get.return_value = mock_registry
@@ -70,7 +69,7 @@ class TestAssertFrameworkAvailableRaises:
assert "autogen" in str(exc_info.value).lower()
def test_unknown_framework_generic_hint(self):
with patch.object(FrameworkAdapterRegistry, "get_instance") as mock_get:
with patch("praisonai.framework_adapters.validators.get_default_registry") as mock_get:
mock_registry = MagicMock()
mock_registry.is_available.return_value = False
mock_get.return_value = mock_registry
@@ -84,7 +83,7 @@ class TestAssertFrameworkAvailableSucceeds:
"""Tests that no exception is raised for available frameworks."""
def test_no_error_when_framework_available(self):
with patch.object(FrameworkAdapterRegistry, "get_instance") as mock_get:
with patch("praisonai.framework_adapters.validators.get_default_registry") as mock_get:
mock_registry = MagicMock()
mock_registry.is_available.return_value = True
mock_get.return_value = mock_registry
@@ -93,7 +92,7 @@ class TestAssertFrameworkAvailableSucceeds:
assert_framework_available("crewai")
def test_returns_none_for_available_framework(self):
with patch.object(FrameworkAdapterRegistry, "get_instance") as mock_get:
with patch("praisonai.framework_adapters.validators.get_default_registry") as mock_get:
mock_registry = MagicMock()
mock_registry.is_available.return_value = True
mock_get.return_value = mock_registry
@@ -0,0 +1,81 @@
import os
from types import SimpleNamespace
import pytest
from tests import conftest as test_conftest
PROVIDER_API_KEYS = (
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"GOOGLE_API_KEY",
"XAI_API_KEY",
"GROQ_API_KEY",
"COHERE_API_KEY",
)
class MockRequest:
def __init__(self, markers=None, fspath=__file__):
markers = markers or []
self.node = SimpleNamespace(iter_markers=lambda: markers)
self.fspath = fspath
def _run_fixture(request):
"""Execute the fixture generator directly with a synthetic request object."""
fixture_impl = test_conftest.setup_test_environment.__wrapped__
fixture_gen = fixture_impl(request)
next(fixture_gen)
return fixture_gen
def _teardown_fixture(fixture_gen):
with pytest.raises(StopIteration):
next(fixture_gen)
def test_setup_environment_preserves_existing_provider_key_and_restores_missing_keys():
with pytest.MonkeyPatch.context() as mp:
mp.setenv("OPENAI_API_KEY", "real-openai-key")
mp.delenv("ANTHROPIC_API_KEY", raising=False)
fixture_gen = _run_fixture(MockRequest())
assert os.environ["OPENAI_API_KEY"] == "real-openai-key"
assert os.environ["ANTHROPIC_API_KEY"] == "test-key"
_teardown_fixture(fixture_gen)
assert os.environ["OPENAI_API_KEY"] == "real-openai-key"
assert "ANTHROPIC_API_KEY" not in os.environ
def test_setup_environment_fills_placeholders_only_when_keys_are_missing():
with pytest.MonkeyPatch.context() as mp:
for key in PROVIDER_API_KEYS:
mp.delenv(key, raising=False)
fixture_gen = _run_fixture(MockRequest())
for key in PROVIDER_API_KEYS:
assert os.environ[key] == "test-key"
_teardown_fixture(fixture_gen)
for key in PROVIDER_API_KEYS:
assert key not in os.environ
def test_setup_environment_skips_placeholder_keys_for_provider_marked_tests():
marker = SimpleNamespace(name="provider_openai")
with pytest.MonkeyPatch.context() as mp:
mp.delenv("OPENAI_API_KEY", raising=False)
fixture_gen = _run_fixture(MockRequest(markers=[marker]))
assert "OPENAI_API_KEY" not in os.environ
_teardown_fixture(fixture_gen)
@@ -0,0 +1,161 @@
"""Unit tests for ToolRegistry, focusing on thread-safety."""
import threading
import pytest
from praisonai.tool_registry import ToolRegistry
class TestToolRegistryBasic:
"""Basic functionality tests for ToolRegistry."""
def test_register_and_get_function(self):
registry = ToolRegistry()
func = lambda x: x
registry.register_function("my_func", func)
assert registry.get_function("my_func") is func
def test_register_non_callable_raises(self):
registry = ToolRegistry()
with pytest.raises(ValueError):
registry.register_function("bad", "not_callable")
def test_get_missing_function_returns_none(self):
registry = ToolRegistry()
assert registry.get_function("missing") is None
def test_register_and_get_autogen_adapter(self):
registry = ToolRegistry()
adapter = lambda: None
registry.register_autogen_adapter("MyTool", adapter)
assert registry.get_autogen_adapter("MyTool") is adapter
def test_list_functions(self):
registry = ToolRegistry()
registry.register_function("a", lambda: None)
registry.register_function("b", lambda: None)
names = registry.list_functions()
assert sorted(names) == ["a", "b"]
def test_list_autogen_adapters(self):
registry = ToolRegistry()
registry.register_autogen_adapter("T1", lambda: None)
registry.register_autogen_adapter("T2", lambda: None)
names = registry.list_autogen_adapters()
assert sorted(names) == ["T1", "T2"]
def test_get_functions_dict_returns_copy(self):
registry = ToolRegistry()
f = lambda: None
registry.register_function("f", f)
d = registry.get_functions_dict()
assert d == {"f": f}
# Mutating the returned dict does not affect the registry
d["extra"] = lambda: None
assert "extra" not in registry.get_functions_dict()
def test_clear(self):
registry = ToolRegistry()
registry.register_function("f", lambda: None)
registry.register_autogen_adapter("A", lambda: None)
registry.clear()
assert len(registry) == 0
def test_len(self):
registry = ToolRegistry()
assert len(registry) == 0
registry.register_function("f", lambda: None)
assert len(registry) == 1
registry.register_autogen_adapter("A", lambda: None)
assert len(registry) == 2
def test_contains(self):
registry = ToolRegistry()
registry.register_function("f", lambda: None)
assert "f" in registry
assert "missing" not in registry
class TestToolRegistryThreadSafety:
"""Thread-safety tests for ToolRegistry."""
def test_concurrent_writes_no_error(self):
"""Concurrent register_function calls must not raise."""
registry = ToolRegistry()
errors = []
def writer(n):
try:
for i in range(50):
registry.register_function(f"func_{n}_{i}", lambda: None)
except Exception as e:
errors.append(e)
threads = [threading.Thread(target=writer, args=(t,)) for t in range(5)]
for t in threads:
t.start()
for t in threads:
t.join()
assert errors == [], f"Concurrent writes raised: {errors}"
assert len(registry) == 250 # 5 threads * 50 registrations
def test_concurrent_reads_writes_no_error(self):
"""Concurrent reads and writes must not raise RuntimeError."""
registry = ToolRegistry()
# Pre-populate
for i in range(20):
registry.register_function(f"pre_{i}", lambda: None)
errors = []
def writer():
try:
for i in range(30):
registry.register_function(f"w_{i}", lambda: None)
except Exception as e:
errors.append(e)
def reader():
try:
for _ in range(100):
registry.list_functions()
registry.get_functions_dict()
except Exception as e:
errors.append(e)
threads = [threading.Thread(target=writer) for _ in range(3)]
threads += [threading.Thread(target=reader) for _ in range(3)]
for t in threads:
t.start()
for t in threads:
t.join()
assert errors == [], f"Concurrent read/write raised: {errors}"
def test_concurrent_clear_and_register(self):
"""clear() racing with register_function() must not corrupt state."""
registry = ToolRegistry()
errors = []
def writer():
try:
for i in range(50):
registry.register_function(f"w_{i}", lambda: None)
except Exception as e:
errors.append(e)
def clearer():
try:
for _ in range(20):
registry.clear()
except Exception as e:
errors.append(e)
threads = [threading.Thread(target=writer) for _ in range(3)]
threads += [threading.Thread(target=clearer) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join()
assert errors == [], f"Concurrent clear/register raised: {errors}"
+385 -361
View File
File diff suppressed because it is too large. Load diff