Compare commits

...
31 Commits
Author SHA1 Message Date
praisonai-triage-agent[bot]andMervin Praison 251470489f fix: address valkey integration issues from code review
- Fix JSON encoding consistency in ValkeyStateStore to prevent type confusion
- Add TTL guards for ExpirySet/ExpiryType to prevent cryptic RuntimeError
- Add warning for score threshold limitation (cosine distance only)
- Fix metadata persistence in KnowledgeDocument (now properly round-tripped)
- Improve exception handling - only catch specific 'index not found' errors
- Fix URL parameter handling in registry factories (reject with clear error)
- Add input validation for vector dimensions and k parameter

Co-authored-by: Mervin Praison <[email protected]>
2026-05-20 17:45:00 +00:00
Edward Liang dbf8b0d6e1 feat: Add Valkey Integration
Signed-off-by: Edward Liang <[email protected]>
2026-05-20 10:23:20 -07:00
praisonandCursor c83aa5a35d fix: return from unified achat path and wire async tool execution
Unified dispatch completed the LLM call but never finalised the response,
causing achat to loop until timeout. Add _finalize_unified_achat_response,
use execute_tool_async in _execute_unified_achat_completion, fix LLMError
session_id via context, and add unit tests for single-dispatch return.

Co-authored-by: Cursor <[email protected]>
2026-05-20 16:10:57 +01:00
MervinPraison 9543799f95 docs: auto-update api.md [skip ci] 2026-05-20 14:26:39 +00:00
Mervin Praison 7719a47892 Merge pull request #1700 from MervinPraison/claude/issue-1698-20260520-0959
feat: implement canonical HERMES_ONLY_TOOLS filter module + diagnostics
2026-05-20 15:25:37 +01:00
praisonai-triage-agent[bot]andMervin Praison 7873afd6c9 refactor: rename HERMES functions and files to user-friendly ALLOWED_TOOLS naming
- Rename hermes_filter.py to allowed_tools_filter.py
- Rename list_tools_with_hermes_filter to list_tools_with_allowed_filter
- Update all imports and references across codebase
- Maintain backward compatibility aliases for HERMES naming
- Update documentation and test files
- Keep HERMES_ONLY_TOOLS env var as backward compatibility alias

This makes the API more intuitive and self-documenting while preserving
all existing functionality and backward compatibility.

Co-authored-by: Mervin Praison <[email protected]>
2026-05-20 14:16:15 +00:00
praisonai-triage-agent[bot]andMervin Praison 8d05e93c21 feat: implement user-friendly ALLOWED_TOOLS naming with backward compatibility
- Rename HERMES_ONLY_TOOLS to ALLOWED_TOOLS for better UX
- Maintain full backward compatibility (ALLOWED_TOOLS takes precedence)
- Add AllowedToolsFilter class with HermesToolFilter alias
- Update all documentation and examples to use ALLOWED_TOOLS
- Fix all reviewer feedback from CodeRabbit, Greptile, and Copilot:
  * Fixed f-string logging issue (use %s formatting)
  * Corrected test diagnostics for unknown vs dropped tools
  * Added CI mode pinning in dev tests for stability
  * Improved .env.example with failure semantics documentation

Addresses user feedback on naming convention while following AGENTS.md
architecture guidelines for protocol-driven, backward-compatible design.

Co-authored-by: Mervin Praison <[email protected]>
2026-05-20 11:23:51 +00:00
praison 8c2ef4102b Release v4.6.43 2026-05-20 12:10:09 +01:00
MervinPraison 5796583019 docs: auto-update feature parity trackers [skip ci] 2026-05-20 11:04:29 +00:00
Mervin Praison 11c7e370e1 Merge pull request #1702 from MervinPraison/refactor/a2ui-thin-adapter-agui-bridge
Replace custom A2UI with optional Google SDK adapter + AG-UI stream bridge
2026-05-20 12:04:00 +01:00
Mervin Praison 92e4540e81 Merge pull request #1691 from MervinPraison/claude/issue-1688-20260519-0816
fix: resolve architectural gaps in praisonai wrapper layer
2026-05-20 12:02:55 +01:00
copilot-swe-agent[bot]andMervinPraison 69e0e91623 Changes before error encountered
Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/65dccaa6-3ed8-4679-a815-f70e82ec2771

Co-authored-by: MervinPraison <[email protected]>
2026-05-20 11:02:01 +00:00
MervinPraison 613309f39b docs: auto-update api.md [skip ci] 2026-05-20 10:58:14 +00:00
Mervin Praison 6dbb645ba6 Merge pull request #1701 from MervinPraison/claude/issue-1699-20260520-0959
fix: Windows packaging/daemon entry point diagnostics (fixes #1699)
2026-05-20 11:57:47 +01:00
praison a4428fd0d0 Replace custom A2UI module with optional Google SDK adapter.
Remove ~3,400 lines of duplicated A2UI builder code and delegate to
a2ui-agent-sdk via a thin lazy adapter, send_a2ui_messages tool, and
A2UI facade. Bridge agent stream_emitter tool events into AG-UI SSE.
2026-05-20 11:52:54 +01:00
praisonai-triage-agent[bot]andMervin Praison 348904557c chore: address review nits in hermes filter changes
- Fix missing list_tools_with_hermes_filter export in __all__
- Preserve ValueError in CLI and registry exception handling for strict filter semantics
- Fix test diagnostics to properly validate unknown vs dropped tools
- Update .env.example with failure semantics documentation

Co-authored-by: Mervin Praison <[email protected]>
2026-05-20 10:21:58 +00:00
praisonai-triage-agent[bot]andMervin Praison 9a5c1bce9e fix: address reviewer feedback on packaging diagnostics
- Replace deprecated pkg_resources with importlib.metadata
- Improve editable install detection for modern pip (PEP 660)
- Honor configured timeout in subprocess calls instead of hardcoding 10s
- Remove fragile stdout version string matching
- Add error handling for site-packages probe failures
- Remove CI continue-on-error to make packaging diagnostics a required gate
- Clean up unused imports

Co-authored-by: Mervin Praison <[email protected]>
2026-05-20 10:18:10 +00:00
copilot-swe-agent[bot]andMervinPraison fee8e581f4 chore: address review nits in hermes filter changes
Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/09bd25c6-ca4e-42a7-a2e8-bab20aa009b9

Co-authored-by: MervinPraison <[email protected]>
2026-05-20 10:17:25 +00:00
copilot-swe-agent[bot]andMervinPraison 0d43cd98f2 fix: enforce strict Hermes filter semantics and stabilize tests
Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/09bd25c6-ca4e-42a7-a2e8-bab20aa009b9

Co-authored-by: MervinPraison <[email protected]>
2026-05-20 10:14:36 +00:00
copilot-swe-agent[bot]andMervinPraison fdcbdc7640 fix: correct windows workflow checkout indentation
Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/c289a3d3-14d8-4a60-9604-c7acc7dd5267

Co-authored-by: MervinPraison <[email protected]>
2026-05-20 10:14:35 +00:00
praisonai-triage-agent[bot]andpraisonai-triage-agent[bot] d6a1a26685 fix: Windows packaging/daemon entry point diagnostics (fixes #1699)
Implements comprehensive diagnostics for Windows daemon invocation issues
where 'python -m praisonai' and 'praisonai' console script have inconsistent
behavior, addressing newcomer perception that OpenClaw is broken on Windows.

Added deliverables:

1. Single canonical daemon recipe per OS:
   - Generates absolute paths for venv installations
   - Fallback commands with explicit interpreter paths
   - Windows encoding safety recommendations

2. Runtime self-check (praisonai doctor packaging):
   - Package structure validation (__main__.py presence)
   - Python module execution test (python -m praisonai)
   - Console script execution test (praisonai command)
   - Windows daemon context analysis (venv/PATH/encoding)
   - Packaging metadata inspection (editable vs regular install)

3. Enhanced Windows CI testing:
   - Added packaging/daemon entry point validation
   - Tests both execution methods in Windows environment

4. Architectural compliance:
   - Core vs Wrapper separation maintained
   - No new dependencies added
   - Follows existing doctor check patterns
   - Backward compatible

The doctor packaging command now provides:
- Absolute daemon command paths
- Virtual environment validation
- Microsoft Store Python warnings
- PYTHONIOENCODING recommendations
- Root cause classification for install types

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
2026-05-20 10:07:51 +00:00
praisonai-triage-agent[bot]andpraisonai-triage-agent[bot] 31967f6a65 feat: implement canonical HERMES_ONLY_TOOLS filter module
- Add HermesToolFilter class with comprehensive diagnostics
- Integrate filter into tool registry and CLI tool loading
- Implement semantics: unset=all tools, empty=error, whitelist=filtered
- Handle unknown tools: warn+strip in dev, strict fail in CI
- Add startup diagnostics logging as specified in issue
- Include comprehensive unit and integration tests
- Update .env.example with configuration guidance

Fixes #1698

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

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
2026-05-20 10:06:33 +00:00
praisonai-triage-agent[bot]andMervin Praison 456c4d5853 docs: fix stale documentation in tool_resolver.py
- Update comment from 'construct resolver explicitly' to 'use cached default resolver'
- Fix all convenience function docstrings to reflect singleton behavior
- Add CWD-anchoring behavior documentation with mitigation guidance
- Clarify that default resolver is cached and CWD-dependent

Addresses feedback from Greptile and Copilot reviewers regarding
misleading documentation after singleton pattern introduction.

Co-authored-by: Mervin Praison <[email protected]>
2026-05-19 12:51:32 +00:00
praison efc01ab3d8 Release v4.6.42 2026-05-19 13:43:10 +01:00
Mervin Praison 88c86db05d Merge pull request #1679 from MervinPraison/claude/issue-1678-20260517-0813
fix: wrapper layer architectural fixes for concurrency safety
2026-05-19 13:42:02 +01:00
copilot-swe-agent[bot]andMervinPraison 12d1811f0c Merge main and resolve inbuilt_tools conflict
Co-authored-by: MervinPraison <[email protected]>
2026-05-19 11:30:00 +00:00
praisonai-triage-agent[bot]andMervinPraison 6012d36dfd fix: resolve architectural gaps in praisonai wrapper layer (fixes #1688)
- Remove duplicate profiler definitions at lines 955-1098 in profiler.py
  • Eliminates dead code and redundant _default_lock/_default_profiler allocations
  • Preserves single canonical ProfilerCompat class with all methods
- Rename conflicting TaskDetails to WorkflowStepDetails in auto.py
  • Resolves namespace collision between agent-task and workflow-step schemas
  • Updates WorkflowParallelDetails to reference renamed class
- Add lazy singleton for ToolResolver in tool_resolver.py
  • Replaces per-call instantiation with process-level cached instance
  • Maintains explicit resolver parameter for dependency injection
  • Follows same pattern as profiler.py for consistency

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

Co-authored-by: MervinPraison <[email protected]>
2026-05-19 08:22:00 +00:00
praisonai-triage-agent[bot]andMervin Praison 0f17585315 fix: resolve merge conflict in inbuilt_tools/__init__.py
- Use centralized is_available() for CREWAI_AVAILABLE and AUTOGEN_AVAILABLE
- Preserve PRAISONAI_TOOLS_PACKAGE_AVAILABLE and associated functions from main
- Maintain backward compatibility with __getattr__ implementation

Co-authored-by: Mervin Praison <[email protected]>
2026-05-19 08:19:14 +00:00
praisonai-triage-agent[bot]andMervin Praison 5900d8bba6 fix: resolve task tool resolution and code quality issues
- CrewAI adapter: properly resolve task tools from tools_dict
- Remove unused loop variable in task context setup
- Fix closure binding pattern in AutoGen adapter tool registration

Co-authored-by: Mervin Praison <[email protected]>
2026-05-17 08:33:16 +00:00
copilot-swe-agent[bot]andMervinPraison 75e9085eb4 fix: tighten wrapper adapter safety and dedupe availability checks
Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/a82f93c3-7254-434f-9cfb-ad1b04d60512

Co-authored-by: MervinPraison <[email protected]>
2026-05-17 08:29:12 +00:00
praisonai-triage-agent[bot]andClaude bfba25efaf fix: wrapper layer architectural fixes for concurrency safety (#1678)
Addresses three critical architectural issues in the praisonai wrapper layer:

1. **Framework adapter regression**: Fixed dead code preventing AutoGen v0.4/AG2
   - AutoGenV4Adapter now fully implements execution instead of NotImplementedError
   - AG2Adapter restored with complete LLMConfig + GroupChat implementation
   - CrewAI adapter enhanced with all missing YAML fields (llm, function_calling_llm, max_iter, etc.)
   - PraisonAI adapter improved with ToolResolver and InteractiveRuntime integration

2. **Duplicated availability detection**: Consolidated into single source of truth
   - New _framework_availability.py provides thread-safe, cached detection
   - Removed 4 duplicate implementations across modules
   - Consistent AG2 detection (checks ag2 package + autogen namespace)

3. **Unbounded profiler dict**: Fixed memory leak in long-running deployments
   - _files_accessed now uses OrderedDict with LRU eviction
   - Bounded by same max_records limit as other profiler buffers

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

Co-Authored-By: Claude <[email protected]>
2026-05-17 08:24:31 +00:00
73 changed files with 5579 additions and 4276 deletions

No files matched your search

+8
View File
@@ -37,6 +37,14 @@ WHATSAPP_PHONE_NUMBER_ID=... # WhatsApp phone number ID
# === Optional: Database ===
DATABASE_URL=sqlite:///~/.praison/database.sqlite # Database connection string
# === Optional: Tool Security ===
# ALLOWED_TOOLS=search,send_message # Whitelist specific tools to prevent name collisions
# Useful in multi-environment setups with overlapping tool names
# Leave unset to use all tools (with collision warnings)
# Do NOT set to empty string: that's treated as configuration error
# In CI, unknown tool names in the whitelist cause strict startup failure
# HERMES_ONLY_TOOLS=search,send_message # (Deprecated) Backward compatibility alias for ALLOWED_TOOLS
# === Optional: Development ===
LOGLEVEL=INFO # Logging level (DEBUG, INFO, WARNING, ERROR)
CHAINLIT_AUTH_SECRET=... # Chainlit authentication secret
+14 -2
View File
@@ -26,8 +26,8 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
@@ -176,6 +176,18 @@ jobs:
run: praisonai queue list
continue-on-error: true
# ==================== PACKAGING TESTS ====================
- name: Test packaging/daemon entry points
run: |
echo "Testing both entry point methods..."
python -m praisonai --version
praisonai --version
- name: Test doctor packaging command
run: |
echo "Testing doctor packaging diagnostics..."
praisonai doctor packaging --json
# ==================== REAL API TESTS ====================
- name: Real API test - Agent run
if: env.OPENAI_API_KEY != ''
+2
View File
@@ -183,6 +183,7 @@ Methods:
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">list_available_tools</a>(context: Optional[Dict[str, Any]] = None) -> List[Union[BaseTool, Callable]]</code>
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">list_base_tools</a>() -> List[BaseTool]</code>
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">list_tools</a>() -> List[str]</code>
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">list_tools_with_allowed_filter</a>(context: Optional[Dict[str, Any]] = None) -> List[str]</code>
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">register</a>(tool: Union[BaseTool, Callable], name: Optional[str] = None, overwrite: bool = False) -> None</code>
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">unregister</a>(name: str) -> bool</code>
* <code title="class Tools">Tools.<a href="./src/praisonai-agents/praisonaiagents/tools/tools.py">internet_search</a>(*args, **kwargs)</code>
@@ -857,6 +858,7 @@ Methods:
* <code title="cli">praisonai doctor env <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor mcp <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor network <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor packaging <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor performance <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor selftest <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor tools <a href="./src/praisonai/praisonai/cli/commands/doctor.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.41" \
"praisonai>=4.6.43" \
"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.41" \
"praisonai>=4.6.43" \
"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.41" \
"praisonai>=4.6.43" \
"praisonai[ui]" \
"praisonai[crewai]"
@@ -753,11 +753,11 @@ Your Goal: {self.goal}"""
# Create LLMError with contextual metadata
error = LLMError(
str(e),
model_name=model_name,
agent_id=self.name,
str(e),
model_name=model_name,
agent_id=self.name,
is_retryable=is_retryable,
session_id=session_id
context={"session_id": session_id},
)
# Call error hook if available for error interception
@@ -902,7 +902,7 @@ Your Goal: {self.goal}"""
max_tokens=getattr(self, 'max_tokens', None),
stream=stream,
response_format=response_format,
execute_tool_fn=getattr(self, 'execute_tool', None),
execute_tool_fn=getattr(self, 'execute_tool_async', None),
console=self.console if (self.verbose or stream) else None,
display_fn=self._display_generating if self.verbose else None,
stream_callback=stream_callback,
@@ -935,6 +935,151 @@ Your Goal: {self.goal}"""
logging.error(f"Unified async chat completion failed: {e}")
raise
async def _finalize_unified_achat_response(
self,
response,
original_prompt,
temperature,
tools,
output_json,
output_pydantic,
reasoning_steps,
stream,
messages,
chat_history_length,
start_time,
task_name=None,
task_description=None,
task_id=None,
):
"""Post-process unified achat completion (parity with sync _chat_impl)."""
if not response:
self._truncate_chat_history(chat_history_length)
return None
if hasattr(response, "choices") and response.choices:
msg = response.choices[0].message
content = getattr(msg, "content", None)
response_text = content.strip() if content else ""
if (
reasoning_steps
and getattr(msg, "reasoning_content", None)
):
response_text = msg.reasoning_content.strip()
else:
extracted = self._extract_llm_response_content(response)
response_text = (extracted or "").strip() if isinstance(extracted, str) else str(extracted or "")
if output_json or output_pydantic:
self._append_to_chat_history({"role": "assistant", "content": response_text})
self._persist_message("assistant", response_text)
try:
validated_response = self._apply_guardrail_with_retry(
response_text, original_prompt, temperature, tools,
task_name, task_description, task_id,
)
self._execute_callback_and_display(
original_prompt, validated_response, time.time() - start_time,
task_name, task_description, task_id,
)
return await self._atrigger_after_agent_hook(
original_prompt, validated_response, start_time,
)
except Exception as e:
logging.error(f"Agent {self.name}: Guardrail validation failed for JSON output: {e}")
self._truncate_chat_history(chat_history_length)
return None
if not self.self_reflect:
self._append_to_chat_history({"role": "assistant", "content": response_text})
self._persist_message("assistant", response_text)
try:
validated_response = self._apply_guardrail_with_retry(
response_text, original_prompt, temperature, tools,
task_name, task_description, task_id,
)
self._execute_callback_and_display(
original_prompt, validated_response, time.time() - start_time,
task_name, task_description, task_id,
)
return await self._atrigger_after_agent_hook(
original_prompt, validated_response, start_time,
)
except Exception as e:
logging.error(f"Agent {self.name}: Guardrail validation failed: {e}")
self._truncate_chat_history(chat_history_length)
return None
# Self-reflection (legacy OpenAI path)
reflection_count = 0
while True:
reflection_prompt = f"""
Reflect on your previous response: '{response_text}'.
{self.reflect_prompt if self.reflect_prompt else "Identify any flaws, improvements, or actions."}
Provide a "satisfactory" status ('yes' or 'no').
Output MUST be JSON with 'reflection' and 'satisfactory'.
"""
reflection_messages = messages + [
{"role": "assistant", "content": response_text},
{"role": "user", "content": reflection_prompt},
]
try:
if self._openai_client is None:
self._append_to_chat_history({"role": "assistant", "content": response_text})
return await self._atrigger_after_agent_hook(
original_prompt, response_text, start_time,
)
reflection_response = await self._openai_client.async_client.beta.chat.completions.parse(
model=self.reflect_llm if self.reflect_llm else self.llm,
messages=reflection_messages,
temperature=temperature,
response_format=_get_display_functions()['ReflectionOutput'],
)
reflection_output = reflection_response.choices[0].message.parsed
if reflection_output.satisfactory == "yes" and reflection_count >= self.min_reflect - 1:
break
if reflection_count >= self.max_reflect - 1:
break
regenerate_messages = reflection_messages + [
{
"role": "assistant",
"content": (
f"Self Reflection: {reflection_output.reflection} "
f"Satisfactory?: {reflection_output.satisfactory}"
),
},
{"role": "user", "content": "Now regenerate your response using the reflection you made"},
]
new_response = await self._openai_client.async_client.chat.completions.create(
model=self.llm,
messages=regenerate_messages,
temperature=temperature,
)
response_text = new_response.choices[0].message.content
reflection_count += 1
except Exception as e:
logging.error("Reflection parsing failed.", exc_info=True)
reflection_count += 1
if reflection_count >= self.max_reflect:
break
try:
validated_response = self._apply_guardrail_with_retry(
response_text, original_prompt, temperature, tools,
task_name, task_description, task_id,
)
self._execute_callback_and_display(
original_prompt, validated_response, time.time() - start_time,
task_name, task_description, task_id,
)
return await self._atrigger_after_agent_hook(
original_prompt, validated_response, start_time,
)
except Exception as e:
logging.error(f"Agent {self.name}: Guardrail validation failed for OpenAI client: {e}")
self._truncate_chat_history(chat_history_length)
return None
def _execute_callback_and_display(self, prompt: str, response: str, generation_time: float, task_name=None, task_description=None, task_id=None):
"""Helper method to execute callbacks and display interaction.
@@ -2042,8 +2187,22 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
task_id=task_id,
response_format=response_format
)
# Continue to handle structured outputs and other processing
# Don't return immediately - fall through to existing logic
return await self._finalize_unified_achat_response(
response=response,
original_prompt=original_prompt,
temperature=temperature,
tools=tools,
output_json=output_json,
output_pydantic=output_pydantic,
reasoning_steps=reasoning_steps,
stream=stream,
messages=messages,
chat_history_length=chat_history_length,
start_time=start_time,
task_name=task_name,
task_description=task_description,
task_id=task_id,
)
else:
# LEGACY: Check if OpenAI client is available
if self._openai_client is None:
@@ -0,0 +1,257 @@
"""
Tool whitelist filter module with diagnostics.
This module provides a canonical implementation of the ALLOWED_TOOLS filter
that prevents tool name collisions in multi-environment agent systems by
whitelisting only specified tools.
The filter is designed to solve the "tool shadowing" problem where multiple
modules register tools with overlapping or ambiguous names, causing agents
to invoke the wrong implementation.
Environment Variables:
ALLOWED_TOOLS: Primary variable (comma-separated tool names)
HERMES_ONLY_TOOLS: Backward compatibility alias for ALLOWED_TOOLS
Usage:
from praisonaiagents.allowed_tools_filter import AllowedToolsFilter
# Create filter from environment variable
filter = AllowedToolsFilter()
# Filter tools
available_tools = {"search", "send_message", "extract_pdf"}
filtered_tools = filter.filter_tools(available_tools)
# Get diagnostics
filter.log_diagnostics()
"""
import os
import logging
from typing import Any, Dict, List, Optional, Set, Union
logger = logging.getLogger(__name__)
class AllowedToolsFilter:
"""
Canonical tool whitelist filter with diagnostics and backward compatibility.
This filter implements the semantics for tool filtering:
- ALLOWED_TOOLS unset (or HERMES_ONLY_TOOLS): all tools visible (with warning about collisions)
- ALLOWED_TOOLS empty string: error - must specify tools or unset
- ALLOWED_TOOLS with values: only whitelisted tools visible
- Unknown tools in list: warn and strip in dev, strict fail in CI
Backward Compatibility:
- Supports both ALLOWED_TOOLS and HERMES_ONLY_TOOLS
- ALLOWED_TOOLS takes precedence if both are set
"""
def __init__(self, env_var_name: str = "ALLOWED_TOOLS"):
"""
Initialize the filter with backward compatibility support.
Args:
env_var_name: Primary environment variable name (default: "ALLOWED_TOOLS")
"""
# Support both new (ALLOWED_TOOLS) and legacy (HERMES_ONLY_TOOLS) naming
self.primary_var = env_var_name
self.legacy_var = "HERMES_ONLY_TOOLS"
# ALLOWED_TOOLS takes precedence over HERMES_ONLY_TOOLS for backward compatibility
self.env_value = os.environ.get(self.primary_var) or os.environ.get(self.legacy_var)
self.env_var_name = self.primary_var if os.environ.get(self.primary_var) else self.legacy_var
self.is_ci = os.environ.get("CI", "").lower() in ("true", "1", "yes")
self._whitelist: Optional[Set[str]] = self._parse_whitelist()
self._diagnostics: Dict[str, Any] = {}
def _parse_whitelist(self) -> Optional[Set[str]]:
"""
Parse the environment variable value into a whitelist set.
Returns:
None if unset, Set of tool names if set, raises ValueError for empty string
"""
if self.env_value is None:
return None
# Empty string is an error - must be explicit
if self.env_value.strip() == "":
raise ValueError(
f"{self.env_var_name} cannot be empty. Either unset it for all tools "
"or provide a comma-separated list of tool names."
)
# Parse comma-separated values, strip whitespace
tools = {name.strip() for name in self.env_value.split(",") if name.strip()}
if not tools:
raise ValueError(
f"{self.env_var_name} contains no valid tool names. "
"Provide comma-separated tool names or unset the variable."
)
return tools
def filter_tools(self, available_tools: Union[Set[str], List[str], Dict[str, Any]]) -> Set[str]:
"""
Filter tools based on ALLOWED_TOOLS whitelist.
Args:
available_tools: Available tools as set, list, or dict with tool names as keys
Returns:
Filtered set of tool names
Raises:
ValueError: If unknown tools in CI mode or configuration errors
"""
# Convert input to set of tool names
if isinstance(available_tools, dict):
tool_names = set(available_tools.keys())
else:
tool_names = set(available_tools)
# Store for diagnostics
self._diagnostics["registered_before_filter"] = sorted(tool_names)
# If no whitelist, return all tools (with warning)
if self._whitelist is None:
logger.warning(
"Tool whitelist is unset. All %d tools are visible. "
"Consider using ALLOWED_TOOLS to prevent tool name collisions.",
len(tool_names)
)
self._diagnostics["registered_after_filter"] = sorted(tool_names)
self._diagnostics["dropped_tools"] = []
self._diagnostics["unknown_tools"] = []
return tool_names
# Find intersection and unknown tools
available_whitelisted = tool_names.intersection(self._whitelist)
unknown_tools = self._whitelist.difference(tool_names)
dropped_tools = tool_names.difference(self._whitelist)
# Store for diagnostics
self._diagnostics["registered_after_filter"] = sorted(available_whitelisted)
self._diagnostics["dropped_tools"] = sorted(dropped_tools)
self._diagnostics["unknown_tools"] = sorted(unknown_tools)
# Handle unknown tools based on environment
if unknown_tools:
unknown_list = sorted(unknown_tools)
if self.is_ci:
raise ValueError(
f"Unknown tools in {self.env_var_name}: {unknown_list}. "
"All specified tools must be available in CI mode."
)
else:
logger.warning(
"Unknown tools in %s will be ignored: %s",
self.env_var_name,
unknown_list
)
if dropped_tools:
logger.info(
"Dropped %d tools not in %s whitelist: %s",
len(dropped_tools),
self.env_var_name,
sorted(dropped_tools)
)
logger.info(
"%s filtered tools: %d available, %d after filter",
self.env_var_name,
len(tool_names),
len(available_whitelisted)
)
return available_whitelisted
def log_diagnostics(self) -> None:
"""Log startup diagnostics section as specified in the issue."""
if not self._diagnostics:
logger.warning("No diagnostics available. Call filter_tools() first.")
return
logger.info("=" * 50)
logger.info("ALLOWED_TOOLS FILTER DIAGNOSTICS")
logger.info("=" * 50)
logger.info("%s=%s", self.env_var_name, self.env_value or "<unset>")
logger.info("RegisteredBeforeFilter=%s", self._diagnostics.get("registered_before_filter", []))
logger.info("RegisteredAfterFilter=%s", self._diagnostics.get("registered_after_filter", []))
logger.info("DroppedTools=%s", self._diagnostics.get("dropped_tools", []))
logger.info("UnknownTools=%s", self._diagnostics.get("unknown_tools", []))
logger.info("IsCI=%s", self.is_ci)
logger.info("=" * 50)
def get_diagnostics(self) -> Dict[str, Any]:
"""
Get diagnostics data as a dictionary.
Returns:
Dictionary with diagnostics information
"""
return {
"env_var_name": self.env_var_name,
"env_value": self.env_value,
"is_ci": self.is_ci,
"whitelist": list(self._whitelist) if self._whitelist else None,
**self._diagnostics
}
def is_enabled(self) -> bool:
"""
Check if filtering is enabled.
Returns:
True if tool whitelist is set and filtering is active
"""
return self._whitelist is not None
def get_whitelist(self) -> Optional[Set[str]]:
"""
Get the current whitelist.
Returns:
Set of whitelisted tool names, or None if not set
"""
return self._whitelist.copy() if self._whitelist else None
def filter_tools_with_allowed_tools(
available_tools: Union[Set[str], List[str], Dict[str, Any]],
env_var_name: str = "ALLOWED_TOOLS",
log_diagnostics: bool = True
) -> Set[str]:
"""
Convenience function to filter tools with ALLOWED_TOOLS.
Args:
available_tools: Available tools to filter
env_var_name: Environment variable name (default: "ALLOWED_TOOLS")
log_diagnostics: Whether to log diagnostics (default: True)
Returns:
Filtered set of tool names
"""
filter_instance = AllowedToolsFilter(env_var_name)
filtered = filter_instance.filter_tools(available_tools)
if log_diagnostics:
filter_instance.log_diagnostics()
return filtered
# Backward compatibility aliases
filter_tools_with_hermes = filter_tools_with_allowed_tools
hermes_filter = filter_tools_with_allowed_tools
apply_hermes_filter = filter_tools_with_allowed_tools
# Legacy class alias for backward compatibility
HermesToolFilter = AllowedToolsFilter
@@ -42,6 +42,7 @@ class MemoryBackend(str, Enum):
FILE = "file"
SQLITE = "sqlite"
REDIS = "redis"
VALKEY = "valkey"
POSTGRES = "postgres"
MEM0 = "mem0"
MONGODB = "mongodb"
@@ -346,13 +346,15 @@ class _LazyStorageModule:
_BACKENDS = {
"RedisBackend": "praisonai.storage",
"MongoDBBackend": "praisonai.storage",
"MongoDBBackend": "praisonai.storage",
"PostgreSQLBackend": "praisonai.storage",
"DynamoDBBackend": "praisonai.storage",
"ValkeyBackend": "praisonai.storage",
"ValkeySearchBackend": "praisonai.storage",
# Legacy names for compatibility
"RedisStorageAdapter": "praisonai.storage",
"MongoDBStorageAdapter": "praisonai.storage",
"PostgreSQLStorageAdapter": "praisonai.storage",
"PostgreSQLStorageAdapter": "praisonai.storage",
"DynamoDBStorageAdapter": "praisonai.storage",
}
@@ -390,7 +392,7 @@ def get_backend(
Factory function to get a storage backend.
Args:
backend_type: Type of backend ("file", "sqlite", "redis", "mongodb", "postgresql", "dynamodb")
backend_type: Type of backend ("file", "sqlite", "redis", "mongodb", "postgresql", "dynamodb", "valkey")
**kwargs: Backend-specific arguments
Returns:
@@ -431,9 +433,13 @@ def get_backend(
# Lazy load from wrapper
backend_class = getattr(_heavy_backends, "DynamoDBBackend")
return backend_class(**kwargs)
elif backend_type == "valkey":
# Lazy load from wrapper
backend_class = getattr(_heavy_backends, "ValkeyBackend")
return backend_class(**kwargs)
else:
raise ValueError(f"Unknown backend type: {backend_type}. "
f"Supported: file, sqlite, redis, mongodb, postgresql, dynamodb")
f"Supported: file, sqlite, redis, mongodb, postgresql, dynamodb, valkey")
def __getattr__(name: str):
"""
@@ -442,7 +448,8 @@ def __getattr__(name: str):
This allows `from praisonaiagents.storage.backends import RedisBackend` to work
while maintaining lazy loading.
"""
if name in {"RedisBackend", "MongoDBBackend", "PostgreSQLBackend", "DynamoDBBackend"}:
if name in {"RedisBackend", "MongoDBBackend", "PostgreSQLBackend", "DynamoDBBackend",
"ValkeyBackend", "ValkeySearchBackend"}:
return getattr(_heavy_backends, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -10,7 +10,7 @@ _tools_lazy_cache = {}
# Export core tool items for organized imports (lightweight)
from .base import BaseTool, ToolResult, ToolValidationError, validate_tool
from .decorator import tool, FunctionTool
from .registry import get_registry, register_tool, get_tool, add_tool, has_tool, remove_tool, list_tools, list_available_tools, ToolRegistry
from .registry import get_registry, register_tool, get_tool, add_tool, has_tool, remove_tool, list_tools, list_available_tools, list_tools_with_allowed_filter, list_tools_with_hermes_filter, ToolRegistry
from .tools import Tools
# Export Injected type directly for easy access
@@ -363,7 +363,7 @@ __all__ = list(TOOL_MAPPINGS.keys()) + [
'BaseTool', 'ToolResult', 'ToolValidationError', 'validate_tool',
'tool', 'FunctionTool',
'get_registry', 'register_tool', 'get_tool', 'add_tool', 'has_tool', 'remove_tool',
'list_tools', 'list_available_tools', 'ToolRegistry',
'list_tools', 'list_available_tools', 'list_tools_with_allowed_filter', 'list_tools_with_hermes_filter', 'ToolRegistry',
'Tools',
# Validation and retry protocols
'ValidationResult', 'ToolValidatorProtocol', 'AsyncToolValidatorProtocol', 'PassthroughValidator',
@@ -0,0 +1,52 @@
"""
A2UI tools — optional declarative UI via Google's a2ui-agent-sdk.
Usage:
from praisonaiagents import Agent
from praisonaiagents.tools.a2ui_tools import send_a2ui_messages
agent = Agent(name="assistant", tools=[send_a2ui_messages])
"""
from __future__ import annotations
import json
from typing import Any, Dict, List, Union
from praisonaiagents.tools.decorator import tool
@tool
def send_a2ui_messages(messages: Union[List[Dict[str, Any]], str]) -> Dict[str, Any]:
"""
Send validated A2UI JSON messages to a connected A2UI renderer client.
Pass a list of A2UI message dicts (createSurface, updateComponents, etc.)
or a JSON string. Requires: pip install praisonaiagents[a2ui]
Args:
messages: A2UI message list or JSON string from the model.
Returns:
Dict with mime_type and serialised messages for A2A/AG-UI clients.
"""
from praisonaiagents.ui.a2ui.adapter import create_a2ui_part
if isinstance(messages, str):
try:
messages = json.loads(messages)
except json.JSONDecodeError as exc:
raise ValueError(f"messages JSON string is not valid JSON: {exc}") from exc
if not isinstance(messages, list):
raise ValueError("messages must be a list of A2UI message dicts or a JSON string")
part = create_a2ui_part({"messages": messages})
data = getattr(getattr(part, "root", part), "data", messages)
metadata = getattr(getattr(part, "root", part), "metadata", {}) or {}
return {
"mime_type": metadata.get("mimeType", "application/json+a2ui"),
"messages": messages,
"a2ui_part": data,
}
@@ -184,6 +184,41 @@ class ToolRegistry:
return available
def list_tools_with_allowed_filter(self, context: Optional[Dict[str, Any]] = None) -> List[str]:
"""List tool names filtered by ALLOWED_TOOLS environment variable.
This applies the canonical ALLOWED_TOOLS filter to prevent tool
name collisions in multi-environment agent systems.
Args:
context: Optional context for availability checks
Returns:
List of filtered tool names
"""
try:
# Lazy import to avoid circular dependencies
from ..allowed_tools_filter import filter_tools_with_allowed_tools
# Get all available tool names
all_tools = self.list_tools()
# Apply ALLOWED_TOOLS filter with diagnostics
filtered_tools = filter_tools_with_allowed_tools(all_tools, log_diagnostics=True)
return sorted(filtered_tools)
except ImportError:
logging.warning("ALLOWED_TOOLS filter not available, returning all tools")
return self.list_tools()
except ValueError:
# Preserve strict ALLOWED_TOOLS semantics (e.g., CI unknown tools, empty value)
raise
except Exception as e:
logging.error("Error applying ALLOWED_TOOLS filter: %s", e)
# Fallback to all tools on error
return self.list_tools()
def get_all(self) -> Dict[str, Union[BaseTool, Callable]]:
"""Get all registered tools as a dict. Thread-safe."""
with self._lock:
@@ -381,3 +416,24 @@ def list_available_tools(context: Optional[Dict[str, Any]] = None) -> List[Union
List of available tools
"""
return get_registry().list_available_tools(context)
def list_tools_with_allowed_filter(context: Optional[Dict[str, Any]] = None) -> List[str]:
"""List tool names filtered by ALLOWED_TOOLS from the global registry.
This is the canonical entry point for applying ALLOWED_TOOLS filtering
across the PraisonAI ecosystem.
Args:
context: Optional context for availability checks
Returns:
List of filtered tool names
"""
return get_registry().list_tools_with_allowed_filter(context)
# Backward compatibility alias
def list_tools_with_hermes_filter(context: Optional[Dict[str, Any]] = None) -> List[str]:
"""Backward compatibility alias for list_tools_with_allowed_filter."""
return list_tools_with_allowed_filter(context)
@@ -1,126 +1,41 @@
"""
A2UI (Agent-to-User Interface) Protocol Integration for PraisonAI Agents
A2UI integration via Google's a2ui-agent-sdk (optional).
This module provides A2UI protocol support, enabling PraisonAI Agents
to generate rich, interactive UIs declaratively via JSON.
A2UI is Google's open standard that allows agents to "speak UI" by sending
declarative JSON describing UI components, which clients then render natively.
Usage:
from praisonaiagents.ui.a2ui import (
# Message types
CreateSurfaceMessage,
UpdateComponentsMessage,
UpdateDataModelMessage,
DeleteSurfaceMessage,
# Component types
TextComponent,
ImageComponent,
ButtonComponent,
CardComponent,
RowComponent,
ColumnComponent,
# Extension helpers
create_a2ui_part,
is_a2ui_part,
)
Declarative agent UI uses the official SDK — not reimplemented in PraisonAI core.
Install: pip install praisonaiagents[a2ui]
"""
from praisonaiagents.ui.a2ui.types import (
# Data binding
PathBinding,
resolve_string_or_path,
# Action types
ActionContext,
Action,
# Children types
ChildrenTemplate,
# Component types
TextComponent,
ImageComponent,
IconComponent,
ButtonComponent,
CardComponent,
RowComponent,
ColumnComponent,
ListComponent,
TextFieldComponent,
CheckBoxComponent,
SliderComponent,
DateTimeInputComponent,
# Message types
CreateSurfaceMessage,
UpdateComponentsMessage,
UpdateDataModelMessage,
DeleteSurfaceMessage,
# Data part
A2UIDataPart,
)
from praisonaiagents.ui.a2ui.extension import (
A2UI_MIME_TYPE,
A2UI_EXTENSION_URI,
STANDARD_CATALOG_ID,
from praisonaiagents.ui.a2ui.adapter import (
create_a2ui_part,
generate_a2ui_system_prompt,
get_schema_manager,
is_a2ui_part,
get_a2ui_agent_extension,
AgentExtension,
)
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.agent import A2UIAgent
from praisonaiagents.ui.a2ui.templates import (
ChatTemplate,
ListTemplate,
FormTemplate,
DashboardTemplate,
parse_a2ui_response,
)
__all__ = [
# Data binding
"PathBinding",
"resolve_string_or_path",
# Action types
"ActionContext",
"Action",
# Children types
"ChildrenTemplate",
# Component types
"TextComponent",
"ImageComponent",
"IconComponent",
"ButtonComponent",
"CardComponent",
"RowComponent",
"ColumnComponent",
"ListComponent",
"TextFieldComponent",
"CheckBoxComponent",
"SliderComponent",
"DateTimeInputComponent",
# Message types
"CreateSurfaceMessage",
"UpdateComponentsMessage",
"UpdateDataModelMessage",
"DeleteSurfaceMessage",
# Data part
"A2UIDataPart",
# Extension
"A2UI_MIME_TYPE",
"A2UI_EXTENSION_URI",
"STANDARD_CATALOG_ID",
"A2UI",
"create_a2ui_part",
"generate_a2ui_system_prompt",
"get_schema_manager",
"is_a2ui_part",
"get_a2ui_agent_extension",
"AgentExtension",
# Surface builder
"Surface",
# Agent wrapper
"A2UIAgent",
# Templates
"ChatTemplate",
"ListTemplate",
"FormTemplate",
"DashboardTemplate",
"parse_a2ui_response",
]
class A2UI:
"""
Optional A2UI facade (lazy — imports a2ui-agent-sdk on first use).
Example:
from praisonaiagents.ui import A2UI
part = A2UI.create_part({"createSurface": {...}})
prompt = A2UI.system_prompt("You are a helpful assistant.")
"""
create_part = staticmethod(create_a2ui_part)
is_part = staticmethod(is_a2ui_part)
parse_response = staticmethod(parse_a2ui_response)
schema_manager = staticmethod(get_schema_manager)
system_prompt = staticmethod(generate_a2ui_system_prompt)
@@ -0,0 +1,89 @@
"""
Thin adapter over Google's a2ui-agent-sdk (optional dependency).
Install: pip install praisonaiagents[a2ui]
Spec: https://github.com/google/A2UI
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
_A2UI_INSTALL_HINT = (
"A2UI support requires the optional dependency. "
"Install with: pip install praisonaiagents[a2ui]"
)
def _import(name: str):
try:
import importlib
return importlib.import_module(name)
except ImportError as exc:
raise ImportError(
f"{_A2UI_INSTALL_HINT} "
f"(failed to import '{name}'; "
"if already installed, ensure a2a-sdk>=0.3.0,<1.0.0 and not a2a-sdk 1.x)"
) from exc
def create_a2ui_part(a2ui_data: Dict[str, Any]) -> Any:
"""Wrap A2UI payload for A2A transport (MIME: application/json+a2ui)."""
parts = _import("a2ui.a2a.parts")
return parts.create_a2ui_part(a2ui_data)
def is_a2ui_part(part: Any) -> bool:
"""Return True if an A2A part contains A2UI data."""
parts = _import("a2ui.a2a.parts")
return parts.is_a2ui_part(part)
def parse_a2ui_response(text: str) -> List[Any]:
"""Parse and split LLM text into A2A parts (text + validated A2UI JSON)."""
parser = _import("a2ui.parser.parser")
return parser.parse_response(text)
def get_schema_manager(
version: str = "0.9",
catalogs: Optional[List[Any]] = None,
accepts_inline_catalogs: bool = False,
) -> Any:
"""Create an A2uiSchemaManager for LLM system prompts and validation."""
constants = _import("a2ui.schema.constants")
manager = _import("a2ui.schema.manager")
basic = _import("a2ui.basic_catalog.provider")
version_const = constants.VERSION_0_9 if version == "0.9" else version
catalog_configs = catalogs
if catalog_configs is None:
catalog_configs = [basic.BasicCatalog.get_config(version=version_const)]
return manager.A2uiSchemaManager(
version=version_const,
catalogs=catalog_configs,
accepts_inline_catalogs=accepts_inline_catalogs,
)
def generate_a2ui_system_prompt(
role_description: str,
workflow_description: str = "",
ui_description: str = "",
*,
version: str = "0.9",
include_schema: bool = True,
include_examples: bool = True,
) -> str:
"""Build an LLM system prompt with embedded A2UI schema and examples."""
schema_manager = get_schema_manager(version=version)
return schema_manager.generate_system_prompt(
role_description=role_description,
workflow_description=workflow_description,
ui_description=ui_description,
include_schema=include_schema,
include_examples=include_examples,
)
@@ -1,358 +0,0 @@
"""
A2UI Agent Integration
Provides A2UIAgent wrapper for generating A2UI responses from PraisonAI agents.
"""
import uuid
from typing import Any, Dict, List, Optional, TYPE_CHECKING
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.types import (
PathBinding,
A2UIDataPart,
)
from praisonaiagents.ui.a2ui.extension import (
create_a2ui_part,
STANDARD_CATALOG_ID,
)
if TYPE_CHECKING:
from praisonaiagents import Agent
class A2UIAgent:
"""
A2UI wrapper for PraisonAI agents.
Provides methods to generate A2UI responses from agent interactions.
Example:
>>> from praisonaiagents import Agent
>>> from praisonaiagents.ui.a2ui import A2UIAgent
>>>
>>> agent = Agent(name="Assistant", role="Helper", goal="Help users")
>>> a2ui_agent = A2UIAgent(agent=agent)
>>>
>>> # Render a simple text response
>>> messages = a2ui_agent.render_text("Hello, World!")
>>>
>>> # Render a list of items
>>> messages = a2ui_agent.render_list(
... title="Results",
... items=[{"name": "Item 1"}, {"name": "Item 2"}]
... )
"""
def __init__(
self,
agent: "Agent",
surface_id: Optional[str] = None,
catalog_id: str = STANDARD_CATALOG_ID,
):
"""
Initialize A2UIAgent wrapper.
Args:
agent: The PraisonAI Agent to wrap
surface_id: Unique ID for the UI surface (auto-generated if not provided)
catalog_id: The component catalog to use
"""
self.agent = agent
self.surface_id = surface_id or f"surface-{uuid.uuid4().hex[:8]}"
self.catalog_id = catalog_id
self._surface: Optional[Surface] = None
def get_surface(self) -> Surface:
"""
Get the underlying Surface object.
Creates a new Surface if one doesn't exist.
Returns:
The Surface object
"""
if self._surface is None:
self._surface = Surface(
surface_id=self.surface_id,
catalog_id=self.catalog_id,
)
return self._surface
def _reset_surface(self) -> Surface:
"""Reset and return a new Surface."""
self._surface = Surface(
surface_id=self.surface_id,
catalog_id=self.catalog_id,
)
return self._surface
# =========================================================================
# Rendering methods
# =========================================================================
def render_text(
self,
text: str,
title: Optional[str] = None,
usage_hint: str = "body",
) -> List[Dict[str, Any]]:
"""
Render a simple text response as A2UI.
Args:
text: The text content to display
title: Optional title heading
usage_hint: Text style hint (h1, h2, body, caption)
Returns:
List of A2UI message dictionaries
"""
surface = self._reset_surface()
children = []
if title:
surface.text(id="title", text=title, usage_hint="h1")
children.append("title")
surface.text(id="content", text=text, usage_hint=usage_hint)
children.append("content")
# Add root column
surface.column(id="root", children=children)
return surface.to_messages()
def render_list(
self,
title: str,
items: List[Dict[str, Any]],
item_title_key: str = "title",
item_description_key: str = "description",
item_image_key: Optional[str] = None,
item_action: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""
Render a list of items as A2UI cards.
Args:
title: List title
items: List of item dictionaries
item_title_key: Key for item title in each dict
item_description_key: Key for item description
item_image_key: Optional key for item image URL
item_action: Optional action name for item buttons
Returns:
List of A2UI message dictionaries
"""
surface = self._reset_surface()
# Title
surface.text(id="list-title", text=title, usage_hint="h1")
# Create card for each item
card_ids = []
for i, item in enumerate(items):
card_id = f"card-{i}"
content_id = f"card-content-{i}"
title_id = f"item-title-{i}"
desc_id = f"item-desc-{i}"
# Item title
surface.text(
id=title_id,
text=item.get(item_title_key, f"Item {i+1}"),
usage_hint="h3"
)
# Item description
surface.text(
id=desc_id,
text=item.get(item_description_key, ""),
usage_hint="body"
)
content_children = [title_id, desc_id]
# Optional action button
if item_action:
btn_id = f"btn-{i}"
btn_text_id = f"btn-text-{i}"
surface.text(id=btn_text_id, text="Select")
surface.button(
id=btn_id,
child=btn_text_id,
action_name=item_action,
action_context=[{"key": "index", "value": i}],
primary=True
)
content_children.append(btn_id)
# Card content column
surface.column(id=content_id, children=content_children)
# Card
surface.card(id=card_id, child=content_id)
card_ids.append(card_id)
# List column
surface.column(id="item-list", children=card_ids)
# Root column
surface.column(id="root", children=["list-title", "item-list"])
# Set data
surface.set_data("items", items)
return surface.to_messages()
def render_form(
self,
title: str,
fields: List[Dict[str, str]],
submit_action: str = "submit",
submit_label: str = "Submit",
) -> List[Dict[str, Any]]:
"""
Render a form as A2UI.
Args:
title: Form title
fields: List of field definitions with id, label, type
submit_action: Action name for submit button
submit_label: Label for submit button
Returns:
List of A2UI message dictionaries
"""
surface = self._reset_surface()
# Title
surface.text(id="form-title", text=title, usage_hint="h1")
# Fields
field_ids = []
form_data = {}
for field in fields:
field_id = field.get("id", f"field-{len(field_ids)}")
label = field.get("label", field_id)
field_type = field.get("type", "text")
surface.text_field(
id=field_id,
label=label,
text=PathBinding(path=f"/{field_id}"),
field_type=field_type,
)
field_ids.append(field_id)
form_data[field_id] = field.get("default", "")
# Submit button
surface.text(id="submit-text", text=submit_label)
# Build action context from fields
action_context = [
{"key": f["id"], "value": {"path": f"/{f['id']}"}}
for f in fields
]
surface.button(
id="submit-btn",
child="submit-text",
action_name=submit_action,
action_context=action_context,
primary=True,
)
field_ids.append("submit-btn")
# Form column
surface.column(id="form-fields", children=field_ids)
# Root column
surface.column(id="root", children=["form-title", "form-fields"])
# Set initial form data
for key, value in form_data.items():
surface.set_data(key, value)
return surface.to_messages()
# =========================================================================
# Agent interaction methods
# =========================================================================
async def chat_async(self, message: str) -> str:
"""
Send a message to the agent and get a response.
Args:
message: The message to send
Returns:
The agent's response text
"""
# Use the agent's chat method
if hasattr(self.agent, 'chat'):
response = self.agent.chat(message)
return response
elif hasattr(self.agent, 'run'):
response = self.agent.run(message)
return response
else:
raise NotImplementedError("Agent does not have chat or run method")
def chat(self, message: str) -> str:
"""
Send a message to the agent and get a response (sync).
Args:
message: The message to send
Returns:
The agent's response text
"""
if hasattr(self.agent, 'chat'):
return self.agent.chat(message)
elif hasattr(self.agent, 'run'):
return self.agent.run(message)
else:
raise NotImplementedError("Agent does not have chat or run method")
# =========================================================================
# Export methods
# =========================================================================
def to_messages(self) -> List[Dict[str, Any]]:
"""
Get the current surface as A2UI messages.
Returns:
List of A2UI message dictionaries
"""
return self.get_surface().to_messages()
def to_json(self, indent: Optional[int] = None) -> str:
"""
Get the current surface as JSON string.
Args:
indent: JSON indentation level
Returns:
JSON string of A2UI messages
"""
return self.get_surface().to_json(indent=indent)
def to_a2a_part(self) -> A2UIDataPart:
"""
Get the current surface as an A2A DataPart.
Returns:
A2UIDataPart with A2UI MIME type
"""
messages = self.to_messages()
# For A2A, we send messages as a list
return create_a2ui_part({"messages": messages})
@@ -1,115 +0,0 @@
"""
A2UI Extension for A2A Protocol
Provides helpers for integrating A2UI with the A2A (Agent-to-Agent) protocol.
"""
from dataclasses import dataclass
from typing import Any, Dict, Optional
from praisonaiagents.ui.a2ui.types import A2UIDataPart
# =============================================================================
# Constants
# =============================================================================
A2UI_EXTENSION_URI = "https://a2ui.org/a2a-extension/a2ui/v0.9"
A2UI_MIME_TYPE = "application/json+a2ui"
STANDARD_CATALOG_ID = "https://raw.githubusercontent.com/google/A2UI/refs/heads/main/specification/0.9/json/standard_catalog_definition.json"
# =============================================================================
# Agent Extension Type
# =============================================================================
@dataclass
class AgentExtension:
"""
A2A Agent Extension configuration.
Describes an extension that an agent supports.
"""
uri: str
description: str = ""
params: Optional[Dict[str, Any]] = None
# =============================================================================
# Helper Functions
# =============================================================================
def create_a2ui_part(a2ui_data: Dict[str, Any]) -> A2UIDataPart:
"""
Create an A2UI data part for A2A integration.
Wraps A2UI data in an A2UIDataPart with the appropriate MIME type.
Args:
a2ui_data: The A2UI data dictionary (e.g., createSurface, updateComponents)
Returns:
An A2UIDataPart with the A2UI MIME type in metadata
Example:
>>> part = create_a2ui_part({"createSurface": {"surfaceId": "main", "catalogId": "..."}})
>>> part.metadata["mimeType"]
'application/json+a2ui'
"""
return A2UIDataPart(
data=a2ui_data,
metadata={"mimeType": A2UI_MIME_TYPE}
)
def is_a2ui_part(part: A2UIDataPart) -> bool:
"""
Check if a data part contains A2UI data.
Args:
part: The data part to check
Returns:
True if the part contains A2UI data (has A2UI MIME type), False otherwise
Example:
>>> part = create_a2ui_part({"createSurface": {...}})
>>> is_a2ui_part(part)
True
"""
if not isinstance(part, A2UIDataPart):
return False
if part.metadata is None:
return False
return part.metadata.get("mimeType") == A2UI_MIME_TYPE
def get_a2ui_agent_extension(
accepts_inline_custom_catalog: bool = False,
) -> AgentExtension:
"""
Create the A2UI AgentExtension configuration.
Returns an AgentExtension that can be added to an A2A Agent Card
to indicate A2UI support.
Args:
accepts_inline_custom_catalog: Whether the agent accepts inline custom catalogs
Returns:
The configured A2UI AgentExtension
Example:
>>> ext = get_a2ui_agent_extension()
>>> ext.uri
'https://a2ui.org/a2a-extension/a2ui/v0.9'
"""
params: Optional[Dict[str, Any]] = None
if accepts_inline_custom_catalog:
params = {"acceptsInlineCustomCatalog": True}
return AgentExtension(
uri=A2UI_EXTENSION_URI,
description="Provides agent-driven UI using the A2UI JSON format.",
params=params
)
@@ -1,364 +0,0 @@
"""
A2UI Surface Builder
Provides a fluent API for building A2UI surfaces with components and data.
"""
import json
from typing import Any, Dict, List, Optional
from praisonaiagents.ui.a2ui.extension import STANDARD_CATALOG_ID
from praisonaiagents.ui.a2ui.types import (
Component,
TextComponent,
ImageComponent,
ButtonComponent,
CardComponent,
RowComponent,
ColumnComponent,
ListComponent,
TextFieldComponent,
Action,
ActionContext,
PathBinding,
CreateSurfaceMessage,
UpdateComponentsMessage,
UpdateDataModelMessage,
StringOrPath,
ChildrenType,
)
class Surface:
"""
A2UI Surface builder for creating rich agent-generated UIs.
Provides a fluent API for adding components and data, then
generating the A2UI JSON messages.
Example:
>>> surface = Surface(surface_id="main")
>>> surface.text(id="title", text="Hello World", usage_hint="h1")
>>> surface.button(id="submit", child="submit-text", action_name="submit")
>>> surface.set_data("title", "Hello World")
>>> messages = surface.to_messages()
"""
def __init__(
self,
surface_id: str,
catalog_id: str = STANDARD_CATALOG_ID,
):
"""
Initialize a new Surface.
Args:
surface_id: Unique identifier for this surface
catalog_id: The component catalog to use (defaults to standard catalog)
"""
self.surface_id = surface_id
self.catalog_id = catalog_id
self.components: List[Component] = []
self.data: Dict[str, Any] = {}
def add(self, component: Component) -> "Surface":
"""
Add a component to the surface.
Args:
component: The component to add
Returns:
self for method chaining
"""
self.components.append(component)
return self
def set_data(self, key: str, value: Any) -> "Surface":
"""
Set a value in the data model.
Args:
key: The key in the data model
value: The value to set
Returns:
self for method chaining
"""
self.data[key] = value
return self
# =========================================================================
# Helper methods for common components
# =========================================================================
def text(
self,
id: str,
text: StringOrPath,
usage_hint: Optional[str] = None,
weight: Optional[float] = None,
) -> "Surface":
"""
Add a Text component.
Args:
id: Component ID
text: Text content or path binding
usage_hint: Style hint (h1, h2, h3, body, caption)
weight: Flex weight
Returns:
self for method chaining
"""
return self.add(TextComponent(
id=id,
text=text,
usage_hint=usage_hint,
weight=weight,
))
def image(
self,
id: str,
url: StringOrPath,
fit: Optional[str] = None,
usage_hint: Optional[str] = None,
weight: Optional[float] = None,
) -> "Surface":
"""
Add an Image component.
Args:
id: Component ID
url: Image URL or path binding
fit: Image fit (contain, cover, fill, none, scale-down)
usage_hint: Size hint (icon, avatar, smallFeature, etc.)
weight: Flex weight
Returns:
self for method chaining
"""
return self.add(ImageComponent(
id=id,
url=url,
fit=fit,
usage_hint=usage_hint,
weight=weight,
))
def button(
self,
id: str,
child: str,
action_name: Optional[str] = None,
action_context: Optional[List[Dict[str, Any]]] = None,
primary: bool = False,
weight: Optional[float] = None,
) -> "Surface":
"""
Add a Button component.
Args:
id: Component ID
child: Child component ID (usually a Text component)
action_name: Name of the action to trigger
action_context: List of {key, value} dicts for action context
primary: Whether this is a primary button
weight: Flex weight
Returns:
self for method chaining
"""
action = None
if action_name:
context = []
if action_context:
for ctx in action_context:
value = ctx.get("value")
if isinstance(value, dict) and "path" in value:
value = PathBinding(path=value["path"])
context.append(ActionContext(key=ctx["key"], value=value))
action = Action(name=action_name, context=context)
return self.add(ButtonComponent(
id=id,
child=child,
action=action,
primary=primary,
weight=weight,
))
def card(
self,
id: str,
child: str,
weight: Optional[float] = None,
) -> "Surface":
"""
Add a Card component.
Args:
id: Component ID
child: Child component ID
weight: Flex weight
Returns:
self for method chaining
"""
return self.add(CardComponent(
id=id,
child=child,
weight=weight,
))
def row(
self,
id: str,
children: ChildrenType,
weight: Optional[float] = None,
) -> "Surface":
"""
Add a Row (horizontal layout) component.
Args:
id: Component ID
children: List of child IDs or ChildrenTemplate
weight: Flex weight
Returns:
self for method chaining
"""
return self.add(RowComponent(
id=id,
children=children,
weight=weight,
))
def column(
self,
id: str,
children: ChildrenType,
weight: Optional[float] = None,
) -> "Surface":
"""
Add a Column (vertical layout) component.
Args:
id: Component ID
children: List of child IDs or ChildrenTemplate
weight: Flex weight
Returns:
self for method chaining
"""
return self.add(ColumnComponent(
id=id,
children=children,
weight=weight,
))
def list(
self,
id: str,
children: ChildrenType,
direction: str = "vertical",
weight: Optional[float] = None,
) -> "Surface":
"""
Add a List component.
Args:
id: Component ID
children: List of child IDs or ChildrenTemplate for dynamic lists
direction: "vertical" or "horizontal"
weight: Flex weight
Returns:
self for method chaining
"""
return self.add(ListComponent(
id=id,
children=children,
direction=direction,
weight=weight,
))
def text_field(
self,
id: str,
label: StringOrPath,
text: StringOrPath,
field_type: str = "text",
weight: Optional[float] = None,
) -> "Surface":
"""
Add a TextField input component.
Args:
id: Component ID
label: Field label
text: Text value or path binding
field_type: Input type (text, number, email, etc.)
weight: Flex weight
Returns:
self for method chaining
"""
return self.add(TextFieldComponent(
id=id,
label=label,
text=text,
field_type=field_type,
weight=weight,
))
# =========================================================================
# Message generation
# =========================================================================
def to_messages(self) -> List[Dict[str, Any]]:
"""
Generate A2UI messages for this surface.
Returns:
List of A2UI message dictionaries
"""
messages: List[Dict[str, Any]] = []
# 1. CreateSurface message
create_msg = CreateSurfaceMessage(
surface_id=self.surface_id,
catalog_id=self.catalog_id,
)
messages.append(create_msg.to_dict())
# 2. UpdateComponents message (if we have components)
if self.components:
update_msg = UpdateComponentsMessage(
surface_id=self.surface_id,
components=self.components,
)
messages.append(update_msg.to_dict())
# 3. UpdateDataModel message (if we have data)
if self.data:
data_msg = UpdateDataModelMessage(
surface_id=self.surface_id,
value=self.data,
)
messages.append(data_msg.to_dict())
return messages
def to_json(self, indent: Optional[int] = None) -> str:
"""
Generate A2UI messages as a JSON string.
Args:
indent: JSON indentation level (None for compact)
Returns:
JSON string of A2UI messages
"""
return json.dumps(self.to_messages(), indent=indent)
@@ -1,17 +0,0 @@
"""
A2UI Templates
Pre-built UI templates for common use cases.
"""
from praisonaiagents.ui.a2ui.templates.chat import ChatTemplate
from praisonaiagents.ui.a2ui.templates.list import ListTemplate
from praisonaiagents.ui.a2ui.templates.form import FormTemplate
from praisonaiagents.ui.a2ui.templates.dashboard import DashboardTemplate
__all__ = [
"ChatTemplate",
"ListTemplate",
"FormTemplate",
"DashboardTemplate",
]
@@ -1,122 +0,0 @@
"""
Chat Template for A2UI
Provides a conversational chat UI template.
"""
from typing import Any, Dict, List, Optional
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.extension import STANDARD_CATALOG_ID
class ChatTemplate:
"""
Chat UI template for conversational interfaces.
Creates a chat-style UI with user and agent messages.
Example:
>>> template = ChatTemplate(surface_id="chat")
>>> template.add_user_message("Hello!")
>>> template.add_agent_message("Hi there! How can I help?")
>>> messages = template.to_messages()
"""
def __init__(
self,
surface_id: str = "chat",
catalog_id: str = STANDARD_CATALOG_ID,
title: Optional[str] = None,
):
"""
Initialize ChatTemplate.
Args:
surface_id: Unique ID for the surface
catalog_id: Component catalog to use
title: Optional title for the chat
"""
self.surface_id = surface_id
self.catalog_id = catalog_id
self.title = title
self.messages: List[Dict[str, Any]] = []
def add_user_message(self, content: str) -> "ChatTemplate":
"""
Add a user message.
Args:
content: Message content
Returns:
self for chaining
"""
self.messages.append({
"role": "user",
"content": content,
})
return self
def add_agent_message(self, content: str) -> "ChatTemplate":
"""
Add an agent message.
Args:
content: Message content
Returns:
self for chaining
"""
self.messages.append({
"role": "agent",
"content": content,
})
return self
def to_messages(self) -> List[Dict[str, Any]]:
"""
Generate A2UI messages for the chat.
Returns:
List of A2UI message dictionaries
"""
surface = Surface(
surface_id=self.surface_id,
catalog_id=self.catalog_id,
)
children = []
# Title if provided
if self.title:
surface.text(id="chat-title", text=self.title, usage_hint="h1")
children.append("chat-title")
# Message components
msg_ids = []
for i, msg in enumerate(self.messages):
msg_id = f"msg-{i}"
role = msg["role"]
content = msg["content"]
# Style based on role
if role == "user":
surface.text(id=msg_id, text=f"You: {content}", usage_hint="body")
else:
surface.text(id=msg_id, text=f"Agent: {content}", usage_hint="body")
msg_ids.append(msg_id)
# Messages column
if msg_ids:
surface.column(id="messages", children=msg_ids)
children.append("messages")
# Root column
surface.column(id="root", children=children)
# Set data
surface.set_data("messages", self.messages)
return surface.to_messages()
@@ -1,128 +0,0 @@
"""
Dashboard Template for A2UI
Provides a dashboard UI template with multiple panels.
"""
from typing import Any, Dict, List, Optional
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.extension import STANDARD_CATALOG_ID
class DashboardTemplate:
"""
Dashboard UI template with multiple panels.
Creates a dashboard layout with cards for each panel.
Example:
>>> template = DashboardTemplate(surface_id="dashboard", title="My Dashboard")
>>> template.add_panel(id="stats", title="Statistics", content="100 items")
>>> template.add_panel(id="chart", title="Chart", content="[Chart here]")
>>> messages = template.to_messages()
"""
def __init__(
self,
surface_id: str = "dashboard",
title: str = "Dashboard",
catalog_id: str = STANDARD_CATALOG_ID,
columns: int = 2,
):
"""
Initialize DashboardTemplate.
Args:
surface_id: Unique ID for the surface
title: Dashboard title
catalog_id: Component catalog to use
columns: Number of columns in the grid
"""
self.surface_id = surface_id
self.title = title
self.catalog_id = catalog_id
self.columns = columns
self.panels: List[Dict[str, Any]] = []
def add_panel(
self,
id: str,
title: str,
content: str,
weight: Optional[float] = None,
) -> "DashboardTemplate":
"""
Add a panel to the dashboard.
Args:
id: Panel ID
title: Panel title
content: Panel content text
weight: Optional flex weight
Returns:
self for chaining
"""
self.panels.append({
"id": id,
"title": title,
"content": content,
"weight": weight,
})
return self
def to_messages(self) -> List[Dict[str, Any]]:
"""
Generate A2UI messages for the dashboard.
Returns:
List of A2UI message dictionaries
"""
surface = Surface(
surface_id=self.surface_id,
catalog_id=self.catalog_id,
)
# Title
surface.text(id="dashboard-title", text=self.title, usage_hint="h1")
# Create cards for each panel
card_ids = []
for panel in self.panels:
panel_id = panel["id"]
content_id = f"{panel_id}-content"
title_id = f"{panel_id}-title"
text_id = f"{panel_id}-text"
# Panel title
surface.text(id=title_id, text=panel["title"], usage_hint="h3")
# Panel content
surface.text(id=text_id, text=panel["content"], usage_hint="body")
# Panel content column
surface.column(id=content_id, children=[title_id, text_id])
# Panel card
surface.card(id=panel_id, child=content_id, weight=panel.get("weight"))
card_ids.append(panel_id)
# Arrange panels in rows based on columns setting
row_ids = []
for i in range(0, len(card_ids), self.columns):
row_cards = card_ids[i:i + self.columns]
row_id = f"row-{i // self.columns}"
surface.row(id=row_id, children=row_cards)
row_ids.append(row_id)
# Panels container
surface.column(id="panels", children=row_ids)
# Root column
surface.column(id="root", children=["dashboard-title", "panels"])
# Set data
surface.set_data("panels", self.panels)
return surface.to_messages()
@@ -1,205 +0,0 @@
"""
Form Template for A2UI
Provides a form UI template for data collection.
"""
from typing import Any, Dict, List
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.types import PathBinding
from praisonaiagents.ui.a2ui.extension import STANDARD_CATALOG_ID
class FormTemplate:
"""
Form UI template for data collection.
Creates a form with various input fields and a submit button.
Example:
>>> template = FormTemplate(surface_id="form", title="Contact")
>>> template.add_text_field(id="name", label="Name")
>>> template.add_email_field(id="email", label="Email")
>>> template.set_submit_action("submit_form", "Send")
>>> messages = template.to_messages()
"""
def __init__(
self,
surface_id: str = "form",
title: str = "Form",
catalog_id: str = STANDARD_CATALOG_ID,
):
"""
Initialize FormTemplate.
Args:
surface_id: Unique ID for the surface
title: Form title
catalog_id: Component catalog to use
"""
self.surface_id = surface_id
self.title = title
self.catalog_id = catalog_id
self.fields: List[Dict[str, Any]] = []
self.submit_action = "submit"
self.submit_label = "Submit"
def add_text_field(
self,
id: str,
label: str,
default: str = "",
required: bool = False,
) -> "FormTemplate":
"""
Add a text input field.
Args:
id: Field ID
label: Field label
default: Default value
required: Whether field is required
Returns:
self for chaining
"""
self.fields.append({
"id": id,
"label": label,
"type": "text",
"default": default,
"required": required,
})
return self
def add_number_field(
self,
id: str,
label: str,
default: float = 0,
required: bool = False,
) -> "FormTemplate":
"""
Add a number input field.
Args:
id: Field ID
label: Field label
default: Default value
required: Whether field is required
Returns:
self for chaining
"""
self.fields.append({
"id": id,
"label": label,
"type": "number",
"default": default,
"required": required,
})
return self
def add_email_field(
self,
id: str,
label: str,
default: str = "",
required: bool = False,
) -> "FormTemplate":
"""
Add an email input field.
Args:
id: Field ID
label: Field label
default: Default value
required: Whether field is required
Returns:
self for chaining
"""
self.fields.append({
"id": id,
"label": label,
"type": "email",
"default": default,
"required": required,
})
return self
def set_submit_action(
self,
action: str,
label: str = "Submit",
) -> "FormTemplate":
"""
Set the submit button action.
Args:
action: Action name
label: Button label
Returns:
self for chaining
"""
self.submit_action = action
self.submit_label = label
return self
def to_messages(self) -> List[Dict[str, Any]]:
"""
Generate A2UI messages for the form.
Returns:
List of A2UI message dictionaries
"""
surface = Surface(
surface_id=self.surface_id,
catalog_id=self.catalog_id,
)
# Title
surface.text(id="form-title", text=self.title, usage_hint="h1")
# Fields
field_ids = []
for field in self.fields:
field_id = field["id"]
surface.text_field(
id=field_id,
label=field["label"],
text=PathBinding(path=f"/{field_id}"),
field_type=field["type"],
)
field_ids.append(field_id)
# Set default value
surface.set_data(field_id, field.get("default", ""))
# Submit button
surface.text(id="submit-text", text=self.submit_label)
action_context = [
{"key": f["id"], "value": {"path": f"/{f['id']}"}}
for f in self.fields
]
surface.button(
id="submit-btn",
child="submit-text",
action_name=self.submit_action,
action_context=action_context,
primary=True,
)
field_ids.append("submit-btn")
# Form fields column
surface.column(id="fields", children=field_ids)
# Root column
surface.column(id="root", children=["form-title", "fields"])
return surface.to_messages()
@@ -1,147 +0,0 @@
"""
List Template for A2UI
Provides a list UI template for displaying items.
"""
from typing import Any, Dict, List, Optional
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.extension import STANDARD_CATALOG_ID
class ListTemplate:
"""
List UI template for displaying items.
Creates a list of cards with title, description, and optional image.
Example:
>>> template = ListTemplate(surface_id="list", title="Results")
>>> template.add_item(title="Item 1", description="First item")
>>> template.add_item(title="Item 2", description="Second item")
>>> messages = template.to_messages()
"""
def __init__(
self,
surface_id: str = "list",
title: str = "Items",
catalog_id: str = STANDARD_CATALOG_ID,
):
"""
Initialize ListTemplate.
Args:
surface_id: Unique ID for the surface
title: List title
catalog_id: Component catalog to use
"""
self.surface_id = surface_id
self.title = title
self.catalog_id = catalog_id
self.items: List[Dict[str, Any]] = []
def add_item(
self,
title: str,
description: str = "",
image_url: Optional[str] = None,
action: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> "ListTemplate":
"""
Add an item to the list.
Args:
title: Item title
description: Item description
image_url: Optional image URL
action: Optional action name for item button
metadata: Optional additional metadata
Returns:
self for chaining
"""
item = {
"title": title,
"description": description,
}
if image_url:
item["image_url"] = image_url
if action:
item["action"] = action
if metadata:
item.update(metadata)
self.items.append(item)
return self
def to_messages(self) -> List[Dict[str, Any]]:
"""
Generate A2UI messages for the list.
Returns:
List of A2UI message dictionaries
"""
surface = Surface(
surface_id=self.surface_id,
catalog_id=self.catalog_id,
)
# Title
surface.text(id="list-title", text=self.title, usage_hint="h1")
# Create cards for each item
card_ids = []
for i, item in enumerate(self.items):
card_id = f"card-{i}"
content_id = f"content-{i}"
title_id = f"title-{i}"
desc_id = f"desc-{i}"
# Item title
surface.text(id=title_id, text=item["title"], usage_hint="h3")
# Item description
surface.text(id=desc_id, text=item.get("description", ""), usage_hint="body")
content_children = [title_id, desc_id]
# Optional image
if item.get("image_url"):
img_id = f"img-{i}"
surface.image(id=img_id, url=item["image_url"], usage_hint="smallFeature")
content_children.insert(0, img_id)
# Optional action button
if item.get("action"):
btn_id = f"btn-{i}"
btn_text_id = f"btn-text-{i}"
surface.text(id=btn_text_id, text="Select")
surface.button(
id=btn_id,
child=btn_text_id,
action_name=item["action"],
action_context=[{"key": "index", "value": i}],
primary=True,
)
content_children.append(btn_id)
# Card content
surface.column(id=content_id, children=content_children)
# Card
surface.card(id=card_id, child=content_id)
card_ids.append(card_id)
# Items column
surface.column(id="items", children=card_ids)
# Root column
surface.column(id="root", children=["list-title", "items"])
# Set data
surface.set_data("items", self.items)
return surface.to_messages()
@@ -1,523 +0,0 @@
"""
A2UI Protocol Types
Pydantic-style classes for the A2UI (Agent-to-User Interface) protocol.
Based on the A2UI Specification v0.9.
"""
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Union
from enum import Enum
# =============================================================================
# Data Binding Types
# =============================================================================
@dataclass
class PathBinding:
"""
A reference to a value in the data model.
Used for data binding in A2UI components.
"""
path: str
def to_dict(self) -> Dict[str, str]:
return {"path": self.path}
# Type alias for values that can be literal or path-bound
StringOrPath = Union[str, PathBinding]
NumberOrPath = Union[int, float, PathBinding]
BooleanOrPath = Union[bool, PathBinding]
def resolve_string_or_path(value: StringOrPath) -> Union[str, Dict[str, str]]:
"""Resolve a StringOrPath to its serialized form."""
if isinstance(value, PathBinding):
return value.to_dict()
return value
def resolve_number_or_path(value: NumberOrPath) -> Union[int, float, Dict[str, str]]:
"""Resolve a NumberOrPath to its serialized form."""
if isinstance(value, PathBinding):
return value.to_dict()
return value
def resolve_boolean_or_path(value: BooleanOrPath) -> Union[bool, Dict[str, str]]:
"""Resolve a BooleanOrPath to its serialized form."""
if isinstance(value, PathBinding):
return value.to_dict()
return value
# =============================================================================
# Action Types
# =============================================================================
@dataclass
class ActionContext:
"""
A key-value pair for action context.
Used to pass data from UI to action handlers.
"""
key: str
value: Union[str, int, float, bool, PathBinding]
def to_dict(self) -> Dict[str, Any]:
if isinstance(self.value, PathBinding):
return {"key": self.key, "value": self.value.to_dict()}
return {"key": self.key, "value": self.value}
@dataclass
class Action:
"""
An action triggered by a UI component (e.g., button click).
"""
name: str
context: List[ActionContext] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {
"name": self.name,
"context": [c.to_dict() for c in self.context]
}
# =============================================================================
# Children Types
# =============================================================================
@dataclass
class ChildrenTemplate:
"""
A template for generating dynamic children from a data model list.
"""
component_id: str
path: str
def to_dict(self) -> Dict[str, str]:
return {
"componentId": self.component_id,
"path": self.path
}
# Type alias for children - either explicit list or template
ChildrenType = Union[List[str], ChildrenTemplate]
def resolve_children(children: ChildrenType) -> Union[List[str], Dict[str, str]]:
"""Resolve children to serialized form."""
if isinstance(children, ChildrenTemplate):
return children.to_dict()
return children
# =============================================================================
# Component Types
# =============================================================================
class UsageHint(str, Enum):
"""Text usage hints for styling."""
H1 = "h1"
H2 = "h2"
H3 = "h3"
H4 = "h4"
H5 = "h5"
CAPTION = "caption"
BODY = "body"
class ImageFit(str, Enum):
"""Image fit options."""
CONTAIN = "contain"
COVER = "cover"
FILL = "fill"
NONE = "none"
SCALE_DOWN = "scale-down"
class ImageUsageHint(str, Enum):
"""Image usage hints."""
ICON = "icon"
AVATAR = "avatar"
SMALL_FEATURE = "smallFeature"
MEDIUM_FEATURE = "mediumFeature"
LARGE_FEATURE = "largeFeature"
HEADER = "header"
@dataclass
class TextComponent:
"""Text display component."""
id: str
text: StringOrPath
usage_hint: Optional[str] = None
weight: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"id": self.id,
"component": "Text",
"text": resolve_string_or_path(self.text)
}
if self.usage_hint:
result["usageHint"] = self.usage_hint
if self.weight is not None:
result["weight"] = self.weight
return result
@dataclass
class ImageComponent:
"""Image display component."""
id: str
url: StringOrPath
fit: Optional[str] = None
usage_hint: Optional[str] = None
weight: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"id": self.id,
"component": "Image",
"url": resolve_string_or_path(self.url)
}
if self.fit:
result["fit"] = self.fit
if self.usage_hint:
result["usageHint"] = self.usage_hint
if self.weight is not None:
result["weight"] = self.weight
return result
@dataclass
class IconComponent:
"""Icon display component."""
id: str
name: StringOrPath
weight: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"id": self.id,
"component": "Icon",
"name": resolve_string_or_path(self.name)
}
if self.weight is not None:
result["weight"] = self.weight
return result
@dataclass
class ButtonComponent:
"""Button component with action."""
id: str
child: str
action: Optional[Action] = None
primary: bool = False
weight: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"id": self.id,
"component": "Button",
"child": self.child
}
if self.primary:
result["primary"] = True
if self.action:
result["action"] = self.action.to_dict()
if self.weight is not None:
result["weight"] = self.weight
return result
@dataclass
class CardComponent:
"""Card container component."""
id: str
child: str
weight: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"id": self.id,
"component": "Card",
"child": self.child
}
if self.weight is not None:
result["weight"] = self.weight
return result
@dataclass
class RowComponent:
"""Horizontal layout component."""
id: str
children: ChildrenType
weight: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"id": self.id,
"component": "Row",
"children": resolve_children(self.children)
}
if self.weight is not None:
result["weight"] = self.weight
return result
@dataclass
class ColumnComponent:
"""Vertical layout component."""
id: str
children: ChildrenType
weight: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"id": self.id,
"component": "Column",
"children": resolve_children(self.children)
}
if self.weight is not None:
result["weight"] = self.weight
return result
@dataclass
class ListComponent:
"""List component for repeating items."""
id: str
children: ChildrenType
direction: str = "vertical"
weight: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"id": self.id,
"component": "List",
"direction": self.direction,
"children": resolve_children(self.children)
}
if self.weight is not None:
result["weight"] = self.weight
return result
@dataclass
class TextFieldComponent:
"""Text input field component."""
id: str
label: StringOrPath
text: StringOrPath
field_type: str = "text"
weight: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"id": self.id,
"component": "TextField",
"label": resolve_string_or_path(self.label),
"text": resolve_string_or_path(self.text),
"type": self.field_type
}
if self.weight is not None:
result["weight"] = self.weight
return result
@dataclass
class CheckBoxComponent:
"""Checkbox input component."""
id: str
label: StringOrPath
checked: BooleanOrPath = False
weight: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"id": self.id,
"component": "CheckBox",
"label": resolve_string_or_path(self.label),
"checked": resolve_boolean_or_path(self.checked)
}
if self.weight is not None:
result["weight"] = self.weight
return result
@dataclass
class SliderComponent:
"""Slider input component."""
id: str
min_value: NumberOrPath
max_value: NumberOrPath
value: NumberOrPath
weight: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"id": self.id,
"component": "Slider",
"min": resolve_number_or_path(self.min_value),
"max": resolve_number_or_path(self.max_value),
"value": resolve_number_or_path(self.value)
}
if self.weight is not None:
result["weight"] = self.weight
return result
@dataclass
class DateTimeInputComponent:
"""Date/time input component."""
id: str
label: StringOrPath
value: StringOrPath
enable_date: bool = True
enable_time: bool = False
weight: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"id": self.id,
"component": "DateTimeInput",
"label": resolve_string_or_path(self.label),
"value": resolve_string_or_path(self.value),
"enableDate": self.enable_date,
"enableTime": self.enable_time
}
if self.weight is not None:
result["weight"] = self.weight
return result
# Type alias for any component
Component = Union[
TextComponent,
ImageComponent,
IconComponent,
ButtonComponent,
CardComponent,
RowComponent,
ColumnComponent,
ListComponent,
TextFieldComponent,
CheckBoxComponent,
SliderComponent,
DateTimeInputComponent,
]
# =============================================================================
# Message Types
# =============================================================================
@dataclass
class CreateSurfaceMessage:
"""
Message to create a new UI surface.
Signals the client to create a new surface and begin rendering it.
"""
surface_id: str
catalog_id: str
def to_dict(self) -> Dict[str, Any]:
return {
"createSurface": {
"surfaceId": self.surface_id,
"catalogId": self.catalog_id
}
}
@dataclass
class UpdateComponentsMessage:
"""
Message to update surface components.
Updates a surface with a new set of components.
"""
surface_id: str
components: List[Component]
def to_dict(self) -> Dict[str, Any]:
return {
"updateComponents": {
"surfaceId": self.surface_id,
"components": [c.to_dict() for c in self.components]
}
}
@dataclass
class UpdateDataModelMessage:
"""
Message to update the data model.
Updates the data model for an existing surface.
"""
surface_id: str
value: Any
path: str = "/"
op: str = "replace"
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"updateDataModel": {
"surfaceId": self.surface_id,
"path": self.path,
"op": self.op,
"value": self.value
}
}
return result
@dataclass
class DeleteSurfaceMessage:
"""
Message to delete a surface.
Signals the client to delete the surface.
"""
surface_id: str
def to_dict(self) -> Dict[str, Any]:
return {
"deleteSurface": {
"surfaceId": self.surface_id
}
}
# =============================================================================
# Data Part Type
# =============================================================================
@dataclass
class A2UIDataPart:
"""
A2UI data part for A2A integration.
Wraps A2UI data with the appropriate MIME type.
"""
data: Dict[str, Any]
metadata: Optional[Dict[str, Any]] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {"data": self.data}
if self.metadata:
result["metadata"] = self.metadata
return result
@@ -4,6 +4,8 @@ AG-UI Event Streaming
Provides utilities for streaming PraisonAI agent responses as AG-UI events.
"""
import asyncio
import json
import uuid
from dataclasses import dataclass, field
from typing import Any, AsyncIterator, Dict, Iterator, List, Optional, Set
@@ -256,6 +258,47 @@ async def async_stream_response(
yield create_run_error_event(str(e))
def stream_event_to_agui_events(
event: Any,
message_id: str,
buffer: EventBuffer,
) -> List[BaseEvent]:
"""Convert a PraisonAI StreamEvent into zero or more AG-UI events."""
from praisonaiagents.streaming.events import StreamEventType
if event.type == StreamEventType.DELTA_TEXT and event.content:
if not buffer.current_text_message_id:
buffer.start_text_message()
return [
TextMessageStartEvent(message_id=buffer.current_text_message_id, role="assistant"),
TextMessageContentEvent(
message_id=buffer.current_text_message_id, delta=event.content
),
]
return [
TextMessageContentEvent(
message_id=buffer.current_text_message_id, delta=event.content
)
]
if event.type == StreamEventType.TOOL_CALL_START and event.tool_call:
tool_call_id = event.tool_call.get("id") or str(uuid.uuid4())
tool_name = event.tool_call.get("name", "tool")
arguments = event.tool_call.get("arguments", {})
args_str = json.dumps(arguments) if isinstance(arguments, dict) else str(arguments)
parent_id = buffer.get_parent_message_id_for_tool_call() or message_id
buffer.start_tool_call(tool_call_id)
return list(create_tool_call_events(tool_call_id, tool_name, args_str, parent_id))
if event.type == StreamEventType.TOOL_CALL_RESULT and event.tool_call:
tool_call_id = event.tool_call.get("id") or str(uuid.uuid4())
content = event.content if event.content is not None else str(event.tool_call.get("result", ""))
buffer.end_tool_call(tool_call_id)
return [create_tool_result_event(tool_call_id, str(content), message_id)]
return []
async def async_stream_agent_response(
agent,
user_input: str,
@@ -278,37 +321,63 @@ async def async_stream_agent_response(
Yields:
AG-UI events
"""
import asyncio
# Emit run started
yield create_run_started_event(thread_id, run_id)
loop = asyncio.get_running_loop()
queue: asyncio.Queue = asyncio.Queue()
buffer = EventBuffer()
message_id = str(uuid.uuid4())
try:
# Check if agent has async chat method
if hasattr(agent, 'achat'):
response = await agent.achat(user_input)
elif hasattr(agent, 'chat'):
# Run sync chat in executor
loop = asyncio.get_event_loop()
response = await loop.run_in_executor(None, agent.chat, user_input)
else:
text_started = False
text_ended = False
original_emitter = getattr(agent, "stream_emitter", None)
def on_stream_event(event: Any) -> None:
loop.call_soon_threadsafe(queue.put_nowait, ("stream", event))
if original_emitter is not None:
original_emitter.add_callback(on_stream_event)
async def run_chat() -> Any:
try:
if hasattr(agent, "achat"):
return await agent.achat(user_input, stream=True)
if hasattr(agent, "chat"):
return await loop.run_in_executor(
None, lambda: agent.chat(user_input, stream=True)
)
raise ValueError("Agent must have 'chat' or 'achat' method")
# Emit text message events
yield TextMessageStartEvent(message_id=message_id, role="assistant")
if response:
finally:
loop.call_soon_threadsafe(queue.put_nowait, ("done", None))
chat_task = asyncio.create_task(run_chat())
try:
while True:
kind, payload = await queue.get()
if kind == "done":
break
for agui_event in stream_event_to_agui_events(payload, message_id, buffer):
if isinstance(agui_event, TextMessageStartEvent):
text_started = True
yield agui_event
response = await chat_task
if text_started and buffer.current_text_message_id and not text_ended:
yield TextMessageEndEvent(message_id=buffer.current_text_message_id)
text_ended = True
elif not text_started and response:
yield TextMessageStartEvent(message_id=message_id, role="assistant")
yield TextMessageContentEvent(message_id=message_id, delta=str(response))
yield TextMessageEndEvent(message_id=message_id)
# Emit run finished
yield TextMessageEndEvent(message_id=message_id)
yield create_run_finished_event(thread_id, run_id)
except Exception as e:
yield create_run_error_event(str(e))
finally:
if original_emitter is not None:
original_emitter.remove_callback(on_stream_event)
async def async_stream_agents_response(
@@ -331,8 +400,6 @@ async def async_stream_agents_response(
Yields:
AG-UI events
"""
import asyncio
# Emit run started
yield create_run_started_event(thread_id, run_id)
@@ -344,7 +411,7 @@ async def async_stream_agents_response(
result = await agents.astart(user_input)
elif hasattr(agents, 'start'):
# Run sync start in executor
loop = asyncio.get_event_loop()
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(None, agents.start, user_input)
else:
raise ValueError("Agents must have 'start' or 'astart' method")
+9 -2
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.6.41"
version = "1.6.43"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -106,6 +106,12 @@ sandbox = [
"e2b-code-interpreter>=1.0.0",
]
# Declarative agent UI (Google A2UI SDK — lazy loaded)
a2ui = [
"a2ui-agent-sdk>=0.2.1",
"a2a-sdk>=0.3.0,<1.0.0",
]
# Docker sandbox dependencies
sandbox-docker = [
"docker>=7.0.0",
@@ -126,7 +132,8 @@ all = [
"praisonaiagents[search]",
"praisonaiagents[crawl]",
"praisonaiagents[autonomy]",
"praisonaiagents[os]"
"praisonaiagents[os]",
"praisonaiagents[a2ui]",
]
[tool.setuptools.packages.find]
@@ -1 +0,0 @@
"""A2UI Tests Package."""
@@ -0,0 +1,36 @@
"""Tests for A2UI adapter (optional a2ui-agent-sdk dependency)."""
import pytest
class TestA2UIAdapterImport:
def test_a2ui_class_export(self):
from praisonaiagents.ui.a2ui import A2UI
assert A2UI is not None
assert hasattr(A2UI, "create_part")
def test_ui_lazy_a2ui_export(self):
from praisonaiagents.ui import A2UI
assert A2UI is not None
def test_missing_optional_dep_raises_clear_error(self):
pytest.importorskip("a2ui", reason="a2ui-agent-sdk not installed")
# If installed, adapter imports should work
from praisonaiagents.ui.a2ui.adapter import create_a2ui_part
part = create_a2ui_part({"createSurface": {"surfaceId": "main", "catalogId": "test"}})
assert part is not None
class TestA2UITool:
def test_send_a2ui_messages_requires_sdk(self):
from praisonaiagents.tools.a2ui_tools import send_a2ui_messages
if pytest.importorskip("a2ui", reason="skip without sdk"):
result = send_a2ui_messages(
messages=[{"createSurface": {"surfaceId": "x", "catalogId": "c"}}]
)
assert result["mime_type"] == "application/json+a2ui"
assert len(result["messages"]) == 1
@@ -1,202 +0,0 @@
"""
Tests for A2UI Agent Integration
TDD: Write tests first, then implement the A2UIAgent class.
"""
import os
import pytest
# Skip tests if no API key
pytestmark = pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY"),
reason="OPENAI_API_KEY not set"
)
class TestA2UIAgent:
"""Test A2UIAgent wrapper class."""
def test_a2ui_agent_creation(self):
"""Test A2UIAgent creation with an agent."""
from praisonaiagents import Agent
from praisonaiagents.ui.a2ui.agent import A2UIAgent
agent = Agent(
name="TestAgent",
role="Test assistant",
goal="Help with testing"
)
a2ui_agent = A2UIAgent(agent=agent)
assert a2ui_agent.agent is agent
assert a2ui_agent.surface_id is not None
def test_a2ui_agent_with_custom_surface_id(self):
"""Test A2UIAgent with custom surface ID."""
from praisonaiagents import Agent
from praisonaiagents.ui.a2ui.agent import A2UIAgent
agent = Agent(name="TestAgent", role="Test", goal="Test")
a2ui_agent = A2UIAgent(agent=agent, surface_id="custom-surface")
assert a2ui_agent.surface_id == "custom-surface"
def test_render_text_response(self):
"""Test rendering a simple text response as A2UI."""
from praisonaiagents import Agent
from praisonaiagents.ui.a2ui.agent import A2UIAgent
agent = Agent(name="TestAgent", role="Test", goal="Test")
a2ui_agent = A2UIAgent(agent=agent)
# Render a simple text response
messages = a2ui_agent.render_text("Hello, World!")
assert isinstance(messages, list)
assert len(messages) >= 2 # createSurface + updateComponents
# Check createSurface
assert "createSurface" in messages[0]
# Check updateComponents has a text component
update_msg = next((m for m in messages if "updateComponents" in m), None)
assert update_msg is not None
components = update_msg["updateComponents"]["components"]
assert any(c.get("component") == "Text" for c in components)
def test_render_list_response(self):
"""Test rendering a list of items as A2UI cards."""
from praisonaiagents import Agent
from praisonaiagents.ui.a2ui.agent import A2UIAgent
agent = Agent(name="TestAgent", role="Test", goal="Test")
a2ui_agent = A2UIAgent(agent=agent)
items = [
{"title": "Item 1", "description": "First item"},
{"title": "Item 2", "description": "Second item"},
]
messages = a2ui_agent.render_list(
title="My Items",
items=items,
item_title_key="title",
item_description_key="description"
)
assert isinstance(messages, list)
assert len(messages) >= 2
def test_render_form(self):
"""Test rendering a form as A2UI."""
from praisonaiagents import Agent
from praisonaiagents.ui.a2ui.agent import A2UIAgent
agent = Agent(name="TestAgent", role="Test", goal="Test")
a2ui_agent = A2UIAgent(agent=agent)
fields = [
{"id": "name", "label": "Name", "type": "text"},
{"id": "email", "label": "Email", "type": "text"},
]
messages = a2ui_agent.render_form(
title="Contact Form",
fields=fields,
submit_action="submit_form"
)
assert isinstance(messages, list)
assert len(messages) >= 2
# Check for TextField components
update_msg = next((m for m in messages if "updateComponents" in m), None)
assert update_msg is not None
components = update_msg["updateComponents"]["components"]
text_fields = [c for c in components if c.get("component") == "TextField"]
assert len(text_fields) >= 2
def test_get_surface(self):
"""Test getting the underlying Surface object."""
from praisonaiagents import Agent
from praisonaiagents.ui.a2ui.agent import A2UIAgent
from praisonaiagents.ui.a2ui.surface import Surface
agent = Agent(name="TestAgent", role="Test", goal="Test")
a2ui_agent = A2UIAgent(agent=agent)
surface = a2ui_agent.get_surface()
assert isinstance(surface, Surface)
assert surface.surface_id == a2ui_agent.surface_id
class TestA2UIAgentWithLLM:
"""Test A2UIAgent with actual LLM calls."""
@pytest.mark.integration
@pytest.mark.asyncio
async def test_chat_with_ui(self):
"""Test chat that returns A2UI response."""
from praisonaiagents import Agent
from praisonaiagents.ui.a2ui.agent import A2UIAgent
agent = Agent(
name="UIAgent",
role="UI Generator",
goal="Generate helpful UI responses",
llm="gpt-4o-mini"
)
a2ui_agent = A2UIAgent(agent=agent)
# This should work with the agent's chat
response = await a2ui_agent.chat_async("Say hello")
assert response is not None
# Response should contain text
assert isinstance(response, str) or isinstance(response, dict)
class TestA2UIAgentIntegration:
"""Integration tests for A2UIAgent with A2A."""
def test_get_a2a_data_part(self):
"""Test getting A2UI as A2A DataPart."""
from praisonaiagents import Agent
from praisonaiagents.ui.a2ui.agent import A2UIAgent
from praisonaiagents.ui.a2ui.extension import is_a2ui_part
agent = Agent(name="TestAgent", role="Test", goal="Test")
a2ui_agent = A2UIAgent(agent=agent)
# Render something
a2ui_agent.render_text("Hello")
# Get as A2A DataPart
part = a2ui_agent.to_a2a_part()
assert part is not None
assert is_a2ui_part(part)
def test_to_json(self):
"""Test getting A2UI as JSON string."""
from praisonaiagents import Agent
from praisonaiagents.ui.a2ui.agent import A2UIAgent
import json
agent = Agent(name="TestAgent", role="Test", goal="Test")
a2ui_agent = A2UIAgent(agent=agent)
# Render something
a2ui_agent.render_text("Hello")
# Get as JSON
json_str = a2ui_agent.to_json()
# Should be valid JSON
parsed = json.loads(json_str)
assert isinstance(parsed, list)
@@ -1,115 +0,0 @@
"""
Tests for A2UI Extension Module
TDD: Write tests first, then implement the extension.
"""
class TestA2UIExtension:
"""Test A2UI extension helpers for A2A integration."""
def test_a2ui_mime_type(self):
"""Test A2UI MIME type constant."""
from praisonaiagents.ui.a2ui.extension import A2UI_MIME_TYPE
assert A2UI_MIME_TYPE == "application/json+a2ui"
def test_a2ui_extension_uri(self):
"""Test A2UI extension URI constant."""
from praisonaiagents.ui.a2ui.extension import A2UI_EXTENSION_URI
assert "a2ui" in A2UI_EXTENSION_URI.lower()
def test_create_a2ui_part(self):
"""Test create_a2ui_part wraps A2UI data in A2A DataPart."""
from praisonaiagents.ui.a2ui.extension import create_a2ui_part, A2UI_MIME_TYPE
a2ui_data = {
"createSurface": {
"surfaceId": "main",
"catalogId": "https://a2ui.dev/standard"
}
}
part = create_a2ui_part(a2ui_data)
# Should return a Part with DataPart root
assert part is not None
assert hasattr(part, 'data')
assert part.data == a2ui_data
assert part.metadata is not None
assert part.metadata.get("mimeType") == A2UI_MIME_TYPE
def test_is_a2ui_part_true(self):
"""Test is_a2ui_part returns True for A2UI parts."""
from praisonaiagents.ui.a2ui.extension import create_a2ui_part, is_a2ui_part
a2ui_data = {"createSurface": {"surfaceId": "main", "catalogId": "test"}}
part = create_a2ui_part(a2ui_data)
assert is_a2ui_part(part) is True
def test_is_a2ui_part_false(self):
"""Test is_a2ui_part returns False for non-A2UI parts."""
from praisonaiagents.ui.a2ui.extension import is_a2ui_part
from praisonaiagents.ui.a2ui.types import A2UIDataPart
# Create a regular data part without A2UI mime type
part = A2UIDataPart(data={"foo": "bar"}, metadata={"mimeType": "application/json"})
assert is_a2ui_part(part) is False
def test_get_a2ui_agent_extension(self):
"""Test get_a2ui_agent_extension returns AgentExtension config."""
from praisonaiagents.ui.a2ui.extension import get_a2ui_agent_extension
ext = get_a2ui_agent_extension()
assert ext is not None
assert hasattr(ext, 'uri')
assert "a2ui" in ext.uri.lower()
assert hasattr(ext, 'description')
def test_get_a2ui_agent_extension_with_inline_catalog(self):
"""Test get_a2ui_agent_extension with inline catalog support."""
from praisonaiagents.ui.a2ui.extension import get_a2ui_agent_extension
ext = get_a2ui_agent_extension(accepts_inline_custom_catalog=True)
assert ext.params is not None
assert ext.params.get("acceptsInlineCustomCatalog") is True
def test_standard_catalog_id(self):
"""Test standard catalog ID constant."""
from praisonaiagents.ui.a2ui.extension import STANDARD_CATALOG_ID
assert "standard_catalog" in STANDARD_CATALOG_ID.lower() or "a2ui" in STANDARD_CATALOG_ID.lower()
class TestA2UIDataPart:
"""Test A2UI DataPart type."""
def test_a2ui_data_part_creation(self):
"""Test A2UIDataPart creation."""
from praisonaiagents.ui.a2ui.types import A2UIDataPart
part = A2UIDataPart(
data={"test": "value"},
metadata={"mimeType": "application/json+a2ui"}
)
assert part.data == {"test": "value"}
assert part.metadata["mimeType"] == "application/json+a2ui"
def test_a2ui_data_part_to_dict(self):
"""Test A2UIDataPart serialization."""
from praisonaiagents.ui.a2ui.types import A2UIDataPart
part = A2UIDataPart(
data={"createSurface": {"surfaceId": "main"}},
metadata={"mimeType": "application/json+a2ui"}
)
data = part.to_dict()
assert "data" in data
assert "metadata" in data
@@ -1,225 +0,0 @@
"""
End-to-End Integration Tests for A2UI
Tests the complete A2UI workflow from agent to JSON output.
"""
import json
import os
import pytest
class TestA2UIEndToEnd:
"""End-to-end integration tests."""
def test_complete_surface_workflow(self):
"""Test complete workflow: create surface, add components, generate JSON."""
from praisonaiagents.ui.a2ui import (
Surface,
PathBinding,
)
# Create surface
surface = Surface(surface_id="test-surface")
# Add components using fluent API
surface.text(id="title", text="Welcome", usage_hint="h1")
surface.text(id="subtitle", text=PathBinding(path="/subtitle"), usage_hint="h2")
surface.button(
id="action-btn",
child="btn-text",
action_name="do_action",
action_context=[{"key": "id", "value": "123"}],
primary=True
)
surface.text(id="btn-text", text="Click Me")
surface.column(id="root", children=["title", "subtitle", "action-btn"])
# Set data
surface.set_data("subtitle", "Hello World")
# Generate messages
messages = surface.to_messages()
# Verify structure
assert len(messages) == 3 # createSurface, updateComponents, updateDataModel
# Verify createSurface
assert "createSurface" in messages[0]
assert messages[0]["createSurface"]["surfaceId"] == "test-surface"
# Verify updateComponents
assert "updateComponents" in messages[1]
components = messages[1]["updateComponents"]["components"]
assert len(components) == 5 # title, subtitle, btn, btn-text, root
# Verify updateDataModel
assert "updateDataModel" in messages[2]
assert messages[2]["updateDataModel"]["value"]["subtitle"] == "Hello World"
# Verify JSON output
json_str = surface.to_json()
parsed = json.loads(json_str)
assert len(parsed) == 3
def test_a2ui_agent_render_workflow(self):
"""Test A2UIAgent rendering workflow."""
from praisonaiagents import Agent
from praisonaiagents.ui.a2ui import A2UIAgent
agent = Agent(
name="TestAgent",
role="Test assistant",
goal="Help with testing"
)
a2ui_agent = A2UIAgent(agent=agent, surface_id="agent-surface")
# Render text
messages = a2ui_agent.render_text(
text="This is a test response",
title="Test Title"
)
# Verify structure
assert len(messages) >= 2
assert "createSurface" in messages[0]
# Verify JSON export
json_str = a2ui_agent.to_json()
parsed = json.loads(json_str)
assert isinstance(parsed, list)
def test_template_workflow(self):
"""Test template-based workflow."""
from praisonaiagents.ui.a2ui import (
ChatTemplate,
ListTemplate,
FormTemplate,
DashboardTemplate,
)
# Chat template
chat = ChatTemplate(surface_id="chat")
chat.add_user_message("Hello!")
chat.add_agent_message("Hi there!")
chat_messages = chat.to_messages()
assert len(chat_messages) >= 2
# List template
list_template = ListTemplate(surface_id="list", title="Items")
list_template.add_item(title="Item 1", description="First")
list_template.add_item(title="Item 2", description="Second")
list_messages = list_template.to_messages()
assert len(list_messages) >= 2
# Form template
form = FormTemplate(surface_id="form", title="Contact")
form.add_text_field(id="name", label="Name")
form.add_email_field(id="email", label="Email")
form.set_submit_action("submit", "Send")
form_messages = form.to_messages()
assert len(form_messages) >= 2
# Dashboard template
dashboard = DashboardTemplate(surface_id="dashboard", title="Dashboard")
dashboard.add_panel(id="stats", title="Stats", content="100 items")
dashboard.add_panel(id="chart", title="Chart", content="[Chart]")
dashboard_messages = dashboard.to_messages()
assert len(dashboard_messages) >= 2
def test_a2a_integration(self):
"""Test A2A protocol integration."""
from praisonaiagents.ui.a2ui import (
Surface,
create_a2ui_part,
is_a2ui_part,
A2UI_MIME_TYPE,
)
# Create surface
surface = Surface(surface_id="a2a-test")
surface.text(id="msg", text="Hello from A2A")
surface.column(id="root", children=["msg"])
# Get messages
messages = surface.to_messages()
# Wrap in A2A DataPart
part = create_a2ui_part({"messages": messages})
# Verify A2UI part
assert is_a2ui_part(part)
assert part.metadata["mimeType"] == A2UI_MIME_TYPE
assert "messages" in part.data
assert len(part.data["messages"]) >= 2
def test_data_binding_workflow(self):
"""Test data binding with path references."""
from praisonaiagents.ui.a2ui import (
Surface,
PathBinding,
ChildrenTemplate,
)
surface = Surface(surface_id="data-binding-test")
# Add components with data binding
surface.text(id="title", text=PathBinding(path="/title"), usage_hint="h1")
surface.text(id="item-template", text=PathBinding(path="/name"))
# Add list with template
surface.list(
id="items",
children=ChildrenTemplate(component_id="item-template", path="/items"),
direction="vertical"
)
surface.column(id="root", children=["title", "items"])
# Set data
surface.set_data("title", "My List")
surface.set_data("items", [
{"name": "Item 1"},
{"name": "Item 2"},
{"name": "Item 3"},
])
messages = surface.to_messages()
# Verify data model
data_msg = next((m for m in messages if "updateDataModel" in m), None)
assert data_msg is not None
assert data_msg["updateDataModel"]["value"]["title"] == "My List"
assert len(data_msg["updateDataModel"]["value"]["items"]) == 3
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY"),
reason="OPENAI_API_KEY not set"
)
class TestA2UIWithLLM:
"""Tests that require LLM API calls."""
def test_agent_chat_and_render(self):
"""Test agent chat followed by UI rendering."""
from praisonaiagents import Agent
from praisonaiagents.ui.a2ui import A2UIAgent
agent = Agent(
name="UIAgent",
role="UI Generator",
goal="Generate helpful responses",
llm="gpt-4o-mini"
)
a2ui_agent = A2UIAgent(agent=agent)
# Chat with agent
response = a2ui_agent.chat("What is 2+2?")
# Render response as UI
messages = a2ui_agent.render_text(response, title="Answer")
assert len(messages) >= 2
assert "createSurface" in messages[0]
@@ -1,228 +0,0 @@
"""
Tests for A2UI Surface Builder
TDD: Write tests first, then implement the Surface class.
"""
class TestSurface:
"""Test Surface builder class."""
def test_surface_creation(self):
"""Test Surface creation with ID and catalog."""
from praisonaiagents.ui.a2ui.surface import Surface
surface = Surface(surface_id="main")
assert surface.surface_id == "main"
assert surface.catalog_id is not None # Should have default
def test_surface_with_custom_catalog(self):
"""Test Surface with custom catalog ID."""
from praisonaiagents.ui.a2ui.surface import Surface
surface = Surface(
surface_id="main",
catalog_id="https://custom.catalog/v1"
)
assert surface.catalog_id == "https://custom.catalog/v1"
def test_add_component(self):
"""Test adding a component to the surface."""
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.types import TextComponent
surface = Surface(surface_id="main")
text = TextComponent(id="title", text="Hello World", usage_hint="h1")
surface.add(text)
assert len(surface.components) == 1
assert surface.components[0].id == "title"
def test_add_multiple_components(self):
"""Test adding multiple components."""
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.types import TextComponent, ButtonComponent
surface = Surface(surface_id="main")
surface.add(TextComponent(id="title", text="Hello"))
surface.add(ButtonComponent(id="btn", child="btn-text"))
assert len(surface.components) == 2
def test_set_data(self):
"""Test setting data model values."""
from praisonaiagents.ui.a2ui.surface import Surface
surface = Surface(surface_id="main")
surface.set_data("title", "Hello World")
surface.set_data("count", 42)
assert surface.data["title"] == "Hello World"
assert surface.data["count"] == 42
def test_set_nested_data(self):
"""Test setting nested data model values."""
from praisonaiagents.ui.a2ui.surface import Surface
surface = Surface(surface_id="main")
surface.set_data("user", {"name": "John", "age": 30})
assert surface.data["user"]["name"] == "John"
def test_to_messages_creates_surface(self):
"""Test to_messages generates CreateSurface message."""
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.types import TextComponent
surface = Surface(surface_id="main")
surface.add(TextComponent(id="title", text="Hello"))
messages = surface.to_messages()
assert len(messages) >= 1
assert "createSurface" in messages[0]
assert messages[0]["createSurface"]["surfaceId"] == "main"
def test_to_messages_includes_components(self):
"""Test to_messages includes UpdateComponents message."""
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.types import TextComponent
surface = Surface(surface_id="main")
surface.add(TextComponent(id="title", text="Hello"))
messages = surface.to_messages()
# Should have createSurface and updateComponents
assert len(messages) >= 2
update_msg = next((m for m in messages if "updateComponents" in m), None)
assert update_msg is not None
assert len(update_msg["updateComponents"]["components"]) == 1
def test_to_messages_includes_data_model(self):
"""Test to_messages includes UpdateDataModel message when data is set."""
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.types import TextComponent
surface = Surface(surface_id="main")
surface.add(TextComponent(id="title", text="Hello"))
surface.set_data("title", "Hello World")
messages = surface.to_messages()
# Should have createSurface, updateComponents, and updateDataModel
assert len(messages) == 3
data_msg = next((m for m in messages if "updateDataModel" in m), None)
assert data_msg is not None
assert data_msg["updateDataModel"]["value"]["title"] == "Hello World"
def test_to_json(self):
"""Test to_json returns JSON string."""
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.types import TextComponent
import json
surface = Surface(surface_id="main")
surface.add(TextComponent(id="title", text="Hello"))
json_str = surface.to_json()
# Should be valid JSON
parsed = json.loads(json_str)
assert isinstance(parsed, list)
assert len(parsed) >= 2
class TestSurfaceChaining:
"""Test Surface method chaining."""
def test_add_returns_self(self):
"""Test add() returns self for chaining."""
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.types import TextComponent
surface = Surface(surface_id="main")
result = surface.add(TextComponent(id="title", text="Hello"))
assert result is surface
def test_set_data_returns_self(self):
"""Test set_data() returns self for chaining."""
from praisonaiagents.ui.a2ui.surface import Surface
surface = Surface(surface_id="main")
result = surface.set_data("key", "value")
assert result is surface
def test_fluent_api(self):
"""Test fluent API for building surfaces."""
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.types import TextComponent, ButtonComponent
surface = (
Surface(surface_id="main")
.add(TextComponent(id="title", text="Hello"))
.add(ButtonComponent(id="btn", child="btn-text"))
.set_data("title", "Hello World")
)
assert len(surface.components) == 2
assert surface.data["title"] == "Hello World"
class TestSurfaceHelpers:
"""Test Surface helper methods."""
def test_text_helper(self):
"""Test text() helper method."""
from praisonaiagents.ui.a2ui.surface import Surface
surface = Surface(surface_id="main")
surface.text(id="title", text="Hello", usage_hint="h1")
assert len(surface.components) == 1
assert surface.components[0].id == "title"
def test_button_helper(self):
"""Test button() helper method."""
from praisonaiagents.ui.a2ui.surface import Surface
surface = Surface(surface_id="main")
surface.button(id="submit", child="submit-text", action_name="submit")
assert len(surface.components) == 1
assert surface.components[0].id == "submit"
def test_card_helper(self):
"""Test card() helper method."""
from praisonaiagents.ui.a2ui.surface import Surface
surface = Surface(surface_id="main")
surface.card(id="card-1", child="card-content")
assert len(surface.components) == 1
assert surface.components[0].id == "card-1"
def test_row_helper(self):
"""Test row() helper method."""
from praisonaiagents.ui.a2ui.surface import Surface
surface = Surface(surface_id="main")
surface.row(id="row-1", children=["col-1", "col-2"])
assert len(surface.components) == 1
assert surface.components[0].id == "row-1"
def test_column_helper(self):
"""Test column() helper method."""
from praisonaiagents.ui.a2ui.surface import Surface
surface = Surface(surface_id="main")
surface.column(id="col-1", children=["item-1", "item-2"])
assert len(surface.components) == 1
assert surface.components[0].id == "col-1"
@@ -1,210 +0,0 @@
"""
Tests for A2UI Templates
TDD: Write tests first, then implement the templates.
"""
class TestChatTemplate:
"""Test ChatTemplate for conversational UIs."""
def test_chat_template_creation(self):
"""Test ChatTemplate creation."""
from praisonaiagents.ui.a2ui.templates import ChatTemplate
template = ChatTemplate(surface_id="chat")
assert template.surface_id == "chat"
def test_add_user_message(self):
"""Test adding a user message."""
from praisonaiagents.ui.a2ui.templates import ChatTemplate
template = ChatTemplate(surface_id="chat")
template.add_user_message("Hello!")
assert len(template.messages) == 1
assert template.messages[0]["role"] == "user"
assert template.messages[0]["content"] == "Hello!"
def test_add_agent_message(self):
"""Test adding an agent message."""
from praisonaiagents.ui.a2ui.templates import ChatTemplate
template = ChatTemplate(surface_id="chat")
template.add_agent_message("Hi there!")
assert len(template.messages) == 1
assert template.messages[0]["role"] == "agent"
def test_to_messages(self):
"""Test generating A2UI messages."""
from praisonaiagents.ui.a2ui.templates import ChatTemplate
template = ChatTemplate(surface_id="chat")
template.add_user_message("Hello!")
template.add_agent_message("Hi there!")
messages = template.to_messages()
assert isinstance(messages, list)
assert len(messages) >= 2 # createSurface + updateComponents
class TestListTemplate:
"""Test ListTemplate for displaying lists of items."""
def test_list_template_creation(self):
"""Test ListTemplate creation."""
from praisonaiagents.ui.a2ui.templates import ListTemplate
template = ListTemplate(
surface_id="list",
title="My Items"
)
assert template.surface_id == "list"
assert template.title == "My Items"
def test_add_item(self):
"""Test adding an item to the list."""
from praisonaiagents.ui.a2ui.templates import ListTemplate
template = ListTemplate(surface_id="list", title="Items")
template.add_item(title="Item 1", description="First item")
assert len(template.items) == 1
assert template.items[0]["title"] == "Item 1"
def test_add_item_with_image(self):
"""Test adding an item with an image."""
from praisonaiagents.ui.a2ui.templates import ListTemplate
template = ListTemplate(surface_id="list", title="Items")
template.add_item(
title="Item 1",
description="First item",
image_url="https://example.com/image.jpg"
)
assert template.items[0]["image_url"] == "https://example.com/image.jpg"
def test_to_messages(self):
"""Test generating A2UI messages."""
from praisonaiagents.ui.a2ui.templates import ListTemplate
template = ListTemplate(surface_id="list", title="Items")
template.add_item(title="Item 1", description="First")
template.add_item(title="Item 2", description="Second")
messages = template.to_messages()
assert isinstance(messages, list)
assert len(messages) >= 2
class TestFormTemplate:
"""Test FormTemplate for data collection forms."""
def test_form_template_creation(self):
"""Test FormTemplate creation."""
from praisonaiagents.ui.a2ui.templates import FormTemplate
template = FormTemplate(
surface_id="form",
title="Contact Form"
)
assert template.surface_id == "form"
assert template.title == "Contact Form"
def test_add_text_field(self):
"""Test adding a text field."""
from praisonaiagents.ui.a2ui.templates import FormTemplate
template = FormTemplate(surface_id="form", title="Form")
template.add_text_field(id="name", label="Name")
assert len(template.fields) == 1
assert template.fields[0]["id"] == "name"
assert template.fields[0]["type"] == "text"
def test_add_number_field(self):
"""Test adding a number field."""
from praisonaiagents.ui.a2ui.templates import FormTemplate
template = FormTemplate(surface_id="form", title="Form")
template.add_number_field(id="age", label="Age")
assert template.fields[0]["type"] == "number"
def test_add_email_field(self):
"""Test adding an email field."""
from praisonaiagents.ui.a2ui.templates import FormTemplate
template = FormTemplate(surface_id="form", title="Form")
template.add_email_field(id="email", label="Email")
assert template.fields[0]["type"] == "email"
def test_set_submit_action(self):
"""Test setting submit action."""
from praisonaiagents.ui.a2ui.templates import FormTemplate
template = FormTemplate(surface_id="form", title="Form")
template.set_submit_action("submit_form", "Send")
assert template.submit_action == "submit_form"
assert template.submit_label == "Send"
def test_to_messages(self):
"""Test generating A2UI messages."""
from praisonaiagents.ui.a2ui.templates import FormTemplate
template = FormTemplate(surface_id="form", title="Form")
template.add_text_field(id="name", label="Name")
template.add_email_field(id="email", label="Email")
messages = template.to_messages()
assert isinstance(messages, list)
assert len(messages) >= 2
class TestDashboardTemplate:
"""Test DashboardTemplate for multi-panel dashboards."""
def test_dashboard_template_creation(self):
"""Test DashboardTemplate creation."""
from praisonaiagents.ui.a2ui.templates import DashboardTemplate
template = DashboardTemplate(
surface_id="dashboard",
title="My Dashboard"
)
assert template.surface_id == "dashboard"
assert template.title == "My Dashboard"
def test_add_panel(self):
"""Test adding a panel."""
from praisonaiagents.ui.a2ui.templates import DashboardTemplate
template = DashboardTemplate(surface_id="dashboard", title="Dashboard")
template.add_panel(id="panel-1", title="Panel 1", content="Some content")
assert len(template.panels) == 1
assert template.panels[0]["id"] == "panel-1"
def test_to_messages(self):
"""Test generating A2UI messages."""
from praisonaiagents.ui.a2ui.templates import DashboardTemplate
template = DashboardTemplate(surface_id="dashboard", title="Dashboard")
template.add_panel(id="panel-1", title="Panel 1", content="Content 1")
template.add_panel(id="panel-2", title="Panel 2", content="Content 2")
messages = template.to_messages()
assert isinstance(messages, list)
assert len(messages) >= 2
@@ -1,318 +0,0 @@
"""
Tests for A2UI Types Module
TDD: Write tests first, then implement the types.
"""
class TestA2UIMessageTypes:
"""Test A2UI message types: CreateSurface, UpdateComponents, UpdateDataModel, DeleteSurface."""
def test_create_surface_message(self):
"""Test CreateSurface message creation."""
from praisonaiagents.ui.a2ui.types import CreateSurfaceMessage
msg = CreateSurfaceMessage(
surface_id="main",
catalog_id="https://a2ui.dev/standard"
)
assert msg.surface_id == "main"
assert msg.catalog_id == "https://a2ui.dev/standard"
# Test serialization
data = msg.to_dict()
assert "createSurface" in data
assert data["createSurface"]["surfaceId"] == "main"
assert data["createSurface"]["catalogId"] == "https://a2ui.dev/standard"
def test_update_components_message(self):
"""Test UpdateComponents message creation."""
from praisonaiagents.ui.a2ui.types import UpdateComponentsMessage, TextComponent
text = TextComponent(id="title", text="Hello World", usage_hint="h1")
msg = UpdateComponentsMessage(
surface_id="main",
components=[text]
)
assert msg.surface_id == "main"
assert len(msg.components) == 1
# Test serialization
data = msg.to_dict()
assert "updateComponents" in data
assert data["updateComponents"]["surfaceId"] == "main"
assert len(data["updateComponents"]["components"]) == 1
def test_update_data_model_message(self):
"""Test UpdateDataModel message creation."""
from praisonaiagents.ui.a2ui.types import UpdateDataModelMessage
msg = UpdateDataModelMessage(
surface_id="main",
path="/",
op="replace",
value={"title": "Hello", "items": []}
)
assert msg.surface_id == "main"
assert msg.path == "/"
assert msg.op == "replace"
assert msg.value == {"title": "Hello", "items": []}
# Test serialization
data = msg.to_dict()
assert "updateDataModel" in data
assert data["updateDataModel"]["surfaceId"] == "main"
def test_delete_surface_message(self):
"""Test DeleteSurface message creation."""
from praisonaiagents.ui.a2ui.types import DeleteSurfaceMessage
msg = DeleteSurfaceMessage(surface_id="main")
assert msg.surface_id == "main"
# Test serialization
data = msg.to_dict()
assert "deleteSurface" in data
assert data["deleteSurface"]["surfaceId"] == "main"
class TestA2UIComponentTypes:
"""Test A2UI component types."""
def test_text_component(self):
"""Test Text component creation."""
from praisonaiagents.ui.a2ui.types import TextComponent
text = TextComponent(id="title", text="Hello World", usage_hint="h1")
assert text.id == "title"
assert text.text == "Hello World"
assert text.usage_hint == "h1"
# Test serialization
data = text.to_dict()
assert data["id"] == "title"
assert data["component"] == "Text"
assert data["text"] == "Hello World"
assert data["usageHint"] == "h1"
def test_text_component_with_path(self):
"""Test Text component with data binding path."""
from praisonaiagents.ui.a2ui.types import TextComponent, PathBinding
text = TextComponent(id="title", text=PathBinding(path="/title"))
data = text.to_dict()
assert data["text"] == {"path": "/title"}
def test_image_component(self):
"""Test Image component creation."""
from praisonaiagents.ui.a2ui.types import ImageComponent
img = ImageComponent(
id="hero",
url="https://example.com/image.jpg",
fit="cover",
usage_hint="header"
)
assert img.id == "hero"
assert img.url == "https://example.com/image.jpg"
assert img.fit == "cover"
data = img.to_dict()
assert data["component"] == "Image"
assert data["url"] == "https://example.com/image.jpg"
def test_button_component(self):
"""Test Button component creation."""
from praisonaiagents.ui.a2ui.types import ButtonComponent, Action, ActionContext
action = Action(
name="submit",
context=[
ActionContext(key="value", value="test")
]
)
btn = ButtonComponent(
id="submit-btn",
child="submit-text",
primary=True,
action=action
)
assert btn.id == "submit-btn"
assert btn.child == "submit-text"
assert btn.primary is True
data = btn.to_dict()
assert data["component"] == "Button"
assert data["action"]["name"] == "submit"
def test_card_component(self):
"""Test Card component creation."""
from praisonaiagents.ui.a2ui.types import CardComponent
card = CardComponent(id="card-1", child="card-content")
assert card.id == "card-1"
assert card.child == "card-content"
data = card.to_dict()
assert data["component"] == "Card"
assert data["child"] == "card-content"
def test_row_component(self):
"""Test Row component creation."""
from praisonaiagents.ui.a2ui.types import RowComponent
row = RowComponent(id="row-1", children=["col-1", "col-2"])
assert row.id == "row-1"
assert row.children == ["col-1", "col-2"]
data = row.to_dict()
assert data["component"] == "Row"
assert data["children"] == ["col-1", "col-2"]
def test_column_component(self):
"""Test Column component creation."""
from praisonaiagents.ui.a2ui.types import ColumnComponent
col = ColumnComponent(id="col-1", children=["item-1", "item-2"])
assert col.id == "col-1"
assert col.children == ["item-1", "item-2"]
data = col.to_dict()
assert data["component"] == "Column"
def test_list_component(self):
"""Test List component creation."""
from praisonaiagents.ui.a2ui.types import ListComponent, ChildrenTemplate
template = ChildrenTemplate(component_id="item-template", path="/items")
lst = ListComponent(id="item-list", direction="vertical", children=template)
assert lst.id == "item-list"
assert lst.direction == "vertical"
data = lst.to_dict()
assert data["component"] == "List"
assert data["children"]["componentId"] == "item-template"
assert data["children"]["path"] == "/items"
def test_text_field_component(self):
"""Test TextField component creation."""
from praisonaiagents.ui.a2ui.types import TextFieldComponent, PathBinding
field = TextFieldComponent(
id="name-field",
label="Name",
text=PathBinding(path="/name"),
field_type="text"
)
assert field.id == "name-field"
assert field.label == "Name"
data = field.to_dict()
assert data["component"] == "TextField"
assert data["label"] == "Name"
assert data["text"] == {"path": "/name"}
def test_checkbox_component(self):
"""Test CheckBox component creation."""
from praisonaiagents.ui.a2ui.types import CheckBoxComponent, PathBinding
cb = CheckBoxComponent(
id="agree-cb",
label="I agree",
checked=PathBinding(path="/agreed")
)
data = cb.to_dict()
assert data["component"] == "CheckBox"
assert data["label"] == "I agree"
def test_slider_component(self):
"""Test Slider component creation."""
from praisonaiagents.ui.a2ui.types import SliderComponent
slider = SliderComponent(
id="volume",
min_value=0,
max_value=100,
value=50
)
data = slider.to_dict()
assert data["component"] == "Slider"
assert data["min"] == 0
assert data["max"] == 100
class TestDataBindingTypes:
"""Test data binding types."""
def test_path_binding(self):
"""Test PathBinding for data model references."""
from praisonaiagents.ui.a2ui.types import PathBinding
path = PathBinding(path="/user/name")
assert path.path == "/user/name"
assert path.to_dict() == {"path": "/user/name"}
def test_string_or_path_literal(self):
"""Test StringOrPath with literal string."""
from praisonaiagents.ui.a2ui.types import resolve_string_or_path
result = resolve_string_or_path("Hello")
assert result == "Hello"
def test_string_or_path_binding(self):
"""Test StringOrPath with path binding."""
from praisonaiagents.ui.a2ui.types import resolve_string_or_path, PathBinding
result = resolve_string_or_path(PathBinding(path="/title"))
assert result == {"path": "/title"}
class TestActionTypes:
"""Test action types for button events."""
def test_action_creation(self):
"""Test Action creation."""
from praisonaiagents.ui.a2ui.types import Action, ActionContext
action = Action(
name="book_restaurant",
context=[
ActionContext(key="restaurantName", value="The Fancy Place"),
ActionContext(key="address", value="123 Main St")
]
)
assert action.name == "book_restaurant"
assert len(action.context) == 2
data = action.to_dict()
assert data["name"] == "book_restaurant"
assert len(data["context"]) == 2
assert data["context"][0]["key"] == "restaurantName"
def test_action_context_with_path(self):
"""Test ActionContext with path binding."""
from praisonaiagents.ui.a2ui.types import ActionContext, PathBinding
ctx = ActionContext(key="name", value=PathBinding(path="/restaurant/name"))
data = ctx.to_dict()
assert data["key"] == "name"
assert data["value"] == {"path": "/restaurant/name"}
@@ -98,6 +98,38 @@ class TestToolCallStreaming:
assert event.content == "Search results..."
class TestStreamEventBridge:
"""Test PraisonAI stream_emitter → AG-UI event conversion."""
def test_delta_text_to_agui(self):
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
from praisonaiagents.ui.agui.streaming import EventBuffer, stream_event_to_agui_events
buffer = EventBuffer()
event = StreamEvent(type=StreamEventType.DELTA_TEXT, content="Hello")
agui_events = stream_event_to_agui_events(event, "msg-1", buffer)
assert len(agui_events) == 2
assert agui_events[0].type == "TEXT_MESSAGE_START"
assert agui_events[1].type == "TEXT_MESSAGE_CONTENT"
assert agui_events[1].delta == "Hello"
def test_tool_call_start_to_agui(self):
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
from praisonaiagents.ui.agui.streaming import EventBuffer, stream_event_to_agui_events
buffer = EventBuffer()
event = StreamEvent(
type=StreamEventType.TOOL_CALL_START,
tool_call={"id": "tc-1", "name": "search", "arguments": {"q": "test"}},
)
agui_events = stream_event_to_agui_events(event, "msg-1", buffer)
assert len(agui_events) == 3
assert agui_events[0].type == "TOOL_CALL_START"
assert agui_events[0].tool_call_name == "search"
class TestRunLifecycleEvents:
"""Test run lifecycle events."""
@@ -0,0 +1,328 @@
"""Integration tests for the ALLOWED_TOOLS filter module."""
import os
import pytest
from unittest.mock import patch, MagicMock
from praisonai.cli.features.agents import MultiAgentHandler
class TestAllowedToolsFilterRegistryIntegration:
"""Integration tests for ALLOWED_TOOLS filter with the tool registry."""
def test_registry_allowed_filter_unset(self):
"""Test registry filter behavior when ALLOWED_TOOLS is unset."""
from praisonaiagents.tools.registry import get_registry
with patch.dict(os.environ, {}, clear=True):
registry = get_registry()
# Mock some tools in the registry
with patch.object(registry, 'list_tools', return_value=['search_web', 'send_email']):
filtered_tools = registry.list_tools_with_allowed_filter()
# Should return all tools when unset
assert set(filtered_tools) == {'search_web', 'send_email'}
def test_registry_allowed_filter_with_whitelist(self):
"""Test registry filter with ALLOWED_TOOLS whitelist."""
from praisonaiagents.tools.registry import get_registry
with patch.dict(os.environ, {
"ALLOWED_TOOLS": "search_web,send_email,unknown_tool",
"CI": "false",
}):
registry = get_registry()
# Mock some tools in the registry
with patch.object(registry, 'list_tools', return_value=['search_web', 'send_email', 'extract_pdf']):
filtered_tools = registry.list_tools_with_allowed_filter()
# Should only include whitelisted tools that are available
assert set(filtered_tools) == {'search_web', 'send_email'}
def test_registry_allowed_filter_empty_result(self):
"""Test registry filter when no tools match whitelist."""
from praisonaiagents.tools.registry import get_registry
with patch.dict(os.environ, {
"ALLOWED_TOOLS": "nonexistent_tool1,nonexistent_tool2",
"CI": "false",
}):
registry = get_registry()
# Mock some tools in the registry
with patch.object(registry, 'list_tools', return_value=['search_web', 'send_email']):
filtered_tools = registry.list_tools_with_allowed_filter()
# Should return empty list when no matches
assert filtered_tools == []
def test_registry_allowed_filter_import_error_fallback(self):
"""Test registry filter fallback when import fails."""
from praisonaiagents.tools.registry import get_registry
registry = get_registry()
with patch.object(registry, 'list_tools', return_value=['search_web', 'send_email']):
# Mock import error for the filter
with patch('praisonaiagents.tools.registry.filter_tools_with_allowed_tools', side_effect=ImportError("Filter not available")):
filtered_tools = registry.list_tools_with_allowed_filter()
# Should fallback to all tools
assert set(filtered_tools) == {'search_web', 'send_email'}
def test_registry_allowed_filter_value_error_propagation(self):
"""Test that ValueError from filter is properly propagated."""
from praisonaiagents.tools.registry import get_registry
with patch.dict(os.environ, {"ALLOWED_TOOLS": ""}): # Empty string should cause ValueError
registry = get_registry()
with patch.object(registry, 'list_tools', return_value=['search_web', 'send_email']):
with pytest.raises(ValueError, match="cannot be empty"):
registry.list_tools_with_allowed_filter()
def test_registry_allowed_filter_general_error_fallback(self):
"""Test registry filter fallback on general errors."""
from praisonaiagents.tools.registry import get_registry
registry = get_registry()
with patch.object(registry, 'list_tools', return_value=['search_web', 'send_email']):
# Mock a generic error during filtering
with patch('praisonaiagents.tools.registry.filter_tools_with_allowed_tools', side_effect=RuntimeError("Some error")):
filtered_tools = registry.list_tools_with_allowed_filter()
# Should fallback to all tools on general errors
assert set(filtered_tools) == {'search_web', 'send_email'}
class TestAllowedToolsFilterGlobalRegistryPath:
"""Test ALLOWED_TOOLS filter through the global registry function."""
def test_global_list_tools_with_allowed_filter(self):
"""Test the global list_tools_with_allowed_filter function."""
from praisonaiagents.tools import list_tools_with_allowed_filter
from praisonaiagents.tools.registry import get_registry
with patch.dict(os.environ, {"ALLOWED_TOOLS": "search_web,send_email"}):
registry = get_registry()
with patch.object(registry, 'list_tools_with_allowed_filter', return_value=['search_web', 'send_email']) as mock_filter:
result = list_tools_with_allowed_filter()
# Should call the registry method
mock_filter.assert_called_once_with(None)
assert result == ['search_web', 'send_email']
def test_global_backward_compatibility_alias(self):
"""Test the backward compatibility alias for hermes filter."""
from praisonaiagents.tools import list_tools_with_hermes_filter, list_tools_with_allowed_filter
from praisonaiagents.tools.registry import get_registry
registry = get_registry()
with patch.object(registry, 'list_tools_with_allowed_filter', return_value=['search_web']) as mock_filter:
# Both should call the same underlying function
result1 = list_tools_with_allowed_filter()
result2 = list_tools_with_hermes_filter()
assert result1 == result2 == ['search_web']
assert mock_filter.call_count == 2
class TestAllowedToolsFilterCLIIntegration:
"""Integration tests for ALLOWED_TOOLS filter with CLI agent handler."""
def test_cli_agents_load_tools_no_filter(self):
"""Test CLI agent tool loading without ALLOWED_TOOLS filter."""
handler = MultiAgentHandler(verbose=False)
with patch.dict(os.environ, {}, clear=True):
# Mock the tools import to avoid actual import dependencies
with patch('praisonai.cli.features.agents.internet_search'), \
patch('praisonai.cli.features.agents.read_file'):
tools = handler._load_tools(["internet_search", "read_file"])
# Should load all requested tools when no filter
assert len(tools) == 2
def test_cli_agents_load_tools_with_filter(self):
"""Test CLI agent tool loading with ALLOWED_TOOLS filter."""
handler = MultiAgentHandler(verbose=False)
with patch.dict(os.environ, {"ALLOWED_TOOLS": "internet_search"}):
# Mock the tools and filter
mock_internet_search = MagicMock()
mock_read_file = MagicMock()
with patch('praisonai.cli.features.agents.internet_search', mock_internet_search), \
patch('praisonai.cli.features.agents.read_file', mock_read_file):
tools = handler._load_tools(["internet_search", "read_file"])
# Should only load tools allowed by filter
assert len(tools) == 1
assert mock_internet_search in tools
assert mock_read_file not in tools
def test_cli_agents_load_tools_filter_error_handling(self):
"""Test CLI tool loading with ALLOWED_TOOLS filter error handling."""
handler = MultiAgentHandler(verbose=False)
requested = ["internet_search", "read_file"]
with patch('os.environ.get', return_value=None):
with patch('praisonai.cli.features.agents.internet_search'), \
patch('praisonai.cli.features.agents.read_file'):
baseline_tools = handler._load_tools(requested)
with patch.dict(os.environ, {"ALLOWED_TOOLS": ""}):
with pytest.raises(ValueError):
handler._load_tools(requested)
def test_cli_agents_load_tools_import_error_fallback(self):
"""Test CLI tool loading fallback when ALLOWED_TOOLS filter import fails."""
handler = MultiAgentHandler(verbose=False)
# Mock ImportError for the filter module
with patch('praisonai.cli.features.agents.filter_tools_with_allowed_tools', side_effect=ImportError("Filter module not available")):
with patch('praisonai.cli.features.agents.internet_search'), \
patch('praisonai.cli.features.agents.read_file'):
tools = handler._load_tools(["internet_search", "read_file"])
# Should load all tools on import error
assert len(tools) == 2
def test_cli_agents_load_tools_filter_value_error_propagation(self):
"""Test that ValueError from filter is properly propagated in CLI."""
handler = MultiAgentHandler(verbose=False)
with patch.dict(os.environ, {"ALLOWED_TOOLS": ""}): # Empty string causes ValueError
with pytest.raises(ValueError, match="cannot be empty"):
handler._load_tools(["internet_search"])
class TestAllowedToolsFilterSpecSemantics:
"""Test ALLOWED_TOOLS filter specification semantics."""
def test_spec_semantics_unset_all_tools_visible(self):
"""Test spec: unset ALLOWED_TOOLS means all tools visible with warning."""
from praisonaiagents.allowed_tools_filter import AllowedToolsFilter
with patch.dict(os.environ, {}, clear=True):
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message", "extract_pdf"}
import logging
with patch.object(logging.getLogger(), 'warning') as mock_warn:
filtered = filter_instance.filter_tools(available_tools)
# Should return all tools
assert filtered == available_tools
# Should warn about unset whitelist
mock_warn.assert_called_once()
assert "Tool whitelist is unset" in mock_warn.call_args[0][0]
def test_spec_semantics_empty_string_error(self):
"""Test spec: empty ALLOWED_TOOLS string is an error."""
from praisonaiagents.allowed_tools_filter import AllowedToolsFilter
with patch.dict(os.environ, {"ALLOWED_TOOLS": ""}):
with pytest.raises(ValueError, match="ALLOWED_TOOLS cannot be empty"):
AllowedToolsFilter()
def test_spec_semantics_whitelist_only_specified_visible(self):
"""Test spec: with ALLOWED_TOOLS values, only whitelisted tools visible."""
from praisonaiagents.allowed_tools_filter import AllowedToolsFilter
with patch.dict(os.environ, {"ALLOWED_TOOLS": "search,send_message"}):
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message", "extract_pdf", "analyze_code"}
filtered = filter_instance.filter_tools(available_tools)
# Should only return whitelisted tools
assert filtered == {"search", "send_message"}
def test_spec_semantics_unknown_tools_dev_warn_strip(self):
"""Test spec: unknown tools in dev mode are warned and stripped."""
from praisonaiagents.allowed_tools_filter import AllowedToolsFilter
with patch.dict(os.environ, {"ALLOWED_TOOLS": "search,unknown_tool", "CI": "false"}):
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message"}
import logging
with patch.object(logging.getLogger(), 'warning') as mock_warn:
filtered = filter_instance.filter_tools(available_tools)
# Should return only known tools
assert filtered == {"search"}
# Should warn about unknown tools
mock_warn.assert_called()
assert "unknown_tool" in str(mock_warn.call_args[0][2])
def test_spec_semantics_unknown_tools_ci_strict_fail(self):
"""Test spec: unknown tools in CI mode cause strict failure."""
from praisonaiagents.allowed_tools_filter import AllowedToolsFilter
with patch.dict(os.environ, {"ALLOWED_TOOLS": "search,unknown_tool", "CI": "true"}):
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message"}
with pytest.raises(ValueError, match="Unknown tools in ALLOWED_TOOLS"):
filter_instance.filter_tools(available_tools)
def test_spec_semantics_order_independence(self):
"""Test spec: whitelist order doesn't matter."""
from praisonaiagents.allowed_tools_filter import AllowedToolsFilter
available_tools = {"search", "send_message", "extract_pdf"}
with patch.dict(os.environ, {"ALLOWED_TOOLS": "search,send_message"}):
filter1 = AllowedToolsFilter()
result1 = filter1.filter_tools(available_tools)
with patch.dict(os.environ, {"ALLOWED_TOOLS": "send_message,search"}):
filter2 = AllowedToolsFilter()
result2 = filter2.filter_tools(available_tools)
# Results should be the same regardless of order
assert result1 == result2 == {"search", "send_message"}
def test_spec_semantics_duplicate_isolation(self):
"""Test spec: duplicate tool names in whitelist are handled properly."""
from praisonaiagents.allowed_tools_filter import AllowedToolsFilter
with patch.dict(os.environ, {"ALLOWED_TOOLS": "search,search,send_message,search"}):
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message", "extract_pdf"}
filtered = filter_instance.filter_tools(available_tools)
# Duplicates should be de-duplicated
assert filtered == {"search", "send_message"}
# Whitelist should be a set (no duplicates)
whitelist = filter_instance.get_whitelist()
assert whitelist == {"search", "send_message"}
def test_spec_backward_compatibility_hermes_precedence(self):
"""Test spec: ALLOWED_TOOLS takes precedence over HERMES_ONLY_TOOLS."""
from praisonaiagents.allowed_tools_filter import AllowedToolsFilter
with patch.dict(os.environ, {
"ALLOWED_TOOLS": "search,send_message",
"HERMES_ONLY_TOOLS": "extract_pdf"
}):
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message", "extract_pdf"}
filtered = filter_instance.filter_tools(available_tools)
# Should use ALLOWED_TOOLS, not HERMES_ONLY_TOOLS
assert filtered == {"search", "send_message"}
assert filter_instance.env_var_name == "ALLOWED_TOOLS"
@@ -6,6 +6,7 @@ Databases tested (all via local Docker):
2. PostgreSQL — ConversationStore + ManagedAgent roundtrip
3. MySQL — ConversationStore + ManagedAgent roundtrip
4. Redis — StateStore + metadata persistence
4a. Valkey — StateStore + metadata persistence
5. MongoDB — StateStore + metadata persistence
6. ClickHouse — Raw connectivity + data write/read
7. JSON file — DefaultSessionStore + ManagedAgent roundtrip
@@ -27,7 +28,6 @@ from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@@ -562,6 +562,206 @@ class TestRedisStateStore:
r.close()
# ===========================================================================
# 4a. Valkey — StateStore operations + metadata persistence
# ===========================================================================
class TestValkeyStateStore:
"""Valkey StateStore: real operations against a live Valkey instance."""
@pytest.fixture(autouse=True)
def check_valkey(self):
"""Skip if Valkey not available."""
try:
from praisonai.persistence._valkey_client import create_valkey_client
client = create_valkey_client(host="localhost", port=6379)
client.ping()
client.close()
except Exception:
pytest.skip("Valkey not available on localhost:6379")
def test_valkey_state_operations(self):
"""StateStore get/set/delete/exists/hash with real Valkey."""
from praisonai.persistence.state.valkey import ValkeyStateStore
prefix = f"test_{uuid.uuid4().hex[:6]}:"
store = ValkeyStateStore(host="localhost", port=6379, prefix=prefix)
try:
# Basic set/get
store.set("agent_id", "agent_valkey_001")
assert store.get("agent_id") == "agent_valkey_001"
# JSON roundtrip
state = {
"agent_id": "agent_valkey_001",
"agent_version": 3,
"total_input_tokens": 500,
"total_output_tokens": 200,
"compute_instance_id": "docker_valkey_test",
"session_history": [{"id": "s1", "status": "idle"}],
}
store.set_json("managed_state", state)
recovered = store.get_json("managed_state")
assert recovered["agent_id"] == "agent_valkey_001"
assert recovered["total_input_tokens"] == 500
assert recovered["compute_instance_id"] == "docker_valkey_test"
assert len(recovered["session_history"]) == 1
# Hash operations
store.hset("agent_meta", "version", "5")
store.hset("agent_meta", "env_id", "env_abc")
assert str(store.hget("agent_meta", "version")) == "5"
all_meta = store.hgetall("agent_meta")
assert str(all_meta["env_id"]) == "env_abc"
# Exists and delete
assert store.exists("agent_id")
store.delete("agent_id")
assert not store.exists("agent_id")
finally:
for k in store.keys():
store.delete(k)
store.close()
def test_valkey_managed_state_persist_restore(self):
"""Simulate managed agent state persist/restore across store instances."""
from praisonai.persistence.state.valkey import ValkeyStateStore
prefix = f"mgd_{uuid.uuid4().hex[:6]}:"
store = ValkeyStateStore(host="localhost", port=6379, prefix=prefix)
try:
session_id = f"valkey_session_{uuid.uuid4().hex[:8]}"
state = {
"agent_id": "agent_valkey_mgd",
"agent_version": 2,
"environment_id": "env_valkey_001",
"total_input_tokens": 1000,
"total_output_tokens": 500,
"compute_instance_id": "modal_valkey_xyz",
"session_history": [
{"id": session_id, "status": "idle", "title": "Valkey test"},
],
}
store.set_json(f"managed:{session_id}", state)
# Simulate restart — new store instance, same prefix
store2 = ValkeyStateStore(host="localhost", port=6379, prefix=prefix)
recovered = store2.get_json(f"managed:{session_id}")
assert recovered is not None
assert recovered["agent_id"] == "agent_valkey_mgd"
assert recovered["total_input_tokens"] == 1000
assert recovered["compute_instance_id"] == "modal_valkey_xyz"
assert len(recovered["session_history"]) == 1
store.close()
store2.close()
finally:
store3 = ValkeyStateStore(host="localhost", port=6379, prefix=prefix)
for k in store3.keys():
store3.delete(k)
store3.close()
# ===========================================================================
# 4b. Valkey Vector Search — ValkeyVectorKnowledgeStore against a live instance
# ===========================================================================
class TestValkeyVectorSearch:
"""Valkey vector search: real operations against a live ValkeySearch instance."""
@pytest.fixture(autouse=True)
def check_valkey_search(self):
"""Skip if Valkey is unreachable or ValkeySearch (FT.*) is not loaded."""
try:
from glide_sync import ft
except ImportError:
pytest.skip("valkey-glide-sync not installed")
try:
from praisonai.persistence._valkey_client import create_valkey_client
except ImportError as e:
pytest.skip(f"praisonai not importable: {e}")
client = None
try:
client = create_valkey_client(host="localhost", port=6379)
client.ping()
except Exception as e:
if client is not None:
client.close()
pytest.skip(f"Valkey not available on localhost:6379: {e}")
try:
# Probe FT.* support; raises if module not loaded
ft.list(client)
except Exception as e:
pytest.skip(f"ValkeySearch (FT.*) module not loaded on localhost:6379: {e}")
finally:
if client is not None:
client.close()
def test_valkey_vector_search_roundtrip(self):
"""Full create → insert → search → delete_collection roundtrip."""
from praisonai.persistence.knowledge.valkey_vector import ValkeyVectorKnowledgeStore
from praisonai.persistence.knowledge.base import KnowledgeDocument
prefix = f"praisontest_{uuid.uuid4().hex[:6]}_"
collection = f"vec_{uuid.uuid4().hex[:6]}"
dimension = 4
store = ValkeyVectorKnowledgeStore(host="localhost", port=6379, prefix=prefix)
try:
# Create index
store.create_collection(collection, dimension=dimension, distance="cosine")
assert store.collection_exists(collection)
# Insert documents with simple, distinguishable embeddings
docs = [
KnowledgeDocument(
id="doc_a",
content="alpha document",
embedding=[1.0, 0.0, 0.0, 0.0],
),
KnowledgeDocument(
id="doc_b",
content="bravo document",
embedding=[0.0, 1.0, 0.0, 0.0],
),
KnowledgeDocument(
id="doc_c",
content="charlie document",
embedding=[0.0, 0.0, 1.0, 0.0],
),
]
inserted = store.insert(collection, docs)
assert set(inserted) == {"doc_a", "doc_b", "doc_c"}
# Vector search: query closest to doc_a
results = store.search(collection, query_embedding=[1.0, 0.0, 0.0, 0.0], limit=2)
assert len(results) >= 1, "Expected at least one search result"
assert results[0].id == "doc_a", f"Expected doc_a as nearest neighbor, got {results[0].id}"
assert results[0].content == "alpha document"
# Verify the bare ID is returned (no prefix leakage)
for r in results:
assert ":" not in r.id or r.id in {"doc_a", "doc_b", "doc_c"}
# get() roundtrip
got = store.get(collection, ["doc_b"])
assert len(got) == 1
assert got[0].id == "doc_b"
assert got[0].content == "bravo document"
# Delete one doc
deleted = store.delete(collection, ids=["doc_c"])
assert deleted == 1
finally:
try:
store.delete_collection(collection)
finally:
store.close()
# ===========================================================================
# 5. MongoDB — StateStore operations + metadata persistence
# ===========================================================================
@@ -673,7 +873,6 @@ class TestClickHouseConnectivity:
def test_clickhouse_write_read(self):
"""Write and read data from ClickHouse."""
import clickhouse_connect
table = f"praison_test_{uuid.uuid4().hex[:8]}"
c = clickhouse_connect.get_client(
host="localhost", port=8123, username="clickhouse", password="clickhouse",
@@ -0,0 +1,84 @@
"""Unit tests for unified achat dispatch (Issue #1703)."""
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from praisonaiagents import Agent
def _fake_completion(content: str = "Mock async reply"):
return SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content=content, reasoning_content=None))]
)
@pytest.mark.asyncio
async def test_unified_achat_returns_after_single_dispatch():
"""achat must return after one unified dispatch, not loop forever."""
agent = Agent(name="test", instructions="You are helpful", llm="gpt-4o-mini")
agent._using_custom_llm = False
agent.self_reflect = False
mock_unified = AsyncMock(return_value=_fake_completion())
with patch.object(agent, "_execute_unified_achat_completion", mock_unified), patch.object(
agent, "_apply_guardrail_with_retry", return_value="Mock async reply"
), patch.object(
agent, "_atrigger_after_agent_hook", new_callable=AsyncMock, return_value="Mock async reply"
), patch.object(
agent, "_execute_callback_and_display"
), patch.object(
agent, "_build_messages", return_value=([{"role": "user", "content": "hi"}], "hi")
), patch.object(
agent, "_persist_message"
), patch.object(
agent, "_format_tools_for_completion", return_value=[]
):
result = await asyncio.wait_for(agent.achat("hi", stream=False), timeout=5)
assert result == "Mock async reply"
mock_unified.assert_called_once()
@pytest.mark.asyncio
async def test_unified_achat_completion_uses_execute_tool_async():
"""Async unified dispatch must wire execute_tool_async, not sync execute_tool."""
agent = Agent(name="test", instructions="You are helpful", llm="gpt-4o-mini")
agent._using_custom_llm = False
mock_dispatcher = MagicMock()
mock_dispatcher.achat_completion = AsyncMock(return_value=_fake_completion())
agent._unified_dispatcher = mock_dispatcher
with patch.object(agent, "_apply_guardrail_with_retry", return_value="ok"), patch.object(
agent, "_atrigger_after_agent_hook", new_callable=AsyncMock, return_value="ok"
), patch.object(agent, "_execute_callback_and_display"), patch.object(
agent, "_build_messages", return_value=([{"role": "user", "content": "hi"}], "hi")
), patch.object(agent, "_persist_message"), patch.object(
agent, "_format_tools_for_completion", return_value=[]
):
await agent._achat_impl(
prompt="hi",
temperature=1.0,
tools=None,
output_json=None,
output_pydantic=None,
reasoning_steps=False,
stream=False,
task_name=None,
task_description=None,
task_id=None,
config=None,
force_retrieval=False,
skip_retrieval=True,
attachments=None,
_trace_emitter=MagicMock(),
)
call_kwargs = mock_dispatcher.achat_completion.call_args[1]
tool_fn = call_kwargs.get("execute_tool_fn")
assert tool_fn is not None
assert getattr(tool_fn, "__name__", "") == "execute_tool_async"
@@ -48,6 +48,12 @@ class TestResolveMemory:
result = resolve_memory("redis")
assert isinstance(result, MemoryConfig)
assert result.backend == MemoryBackend.REDIS
def test_string_valkey_backend(self):
"""'valkey' string returns MemoryConfig with VALKEY backend."""
result = resolve_memory("valkey")
assert isinstance(result, MemoryConfig)
assert result.backend == MemoryBackend.VALKEY
def test_string_custom_backend(self):
"""Custom string backend is preserved."""
@@ -0,0 +1,300 @@
"""Unit tests for the ALLOWED_TOOLS filter module."""
import os
import logging
from unittest.mock import patch, MagicMock
import pytest
from praisonaiagents.allowed_tools_filter import AllowedToolsFilter, filter_tools_with_allowed_tools
class TestAllowedToolsFilter:
"""Test cases for AllowedToolsFilter class."""
def test_filter_initialization_unset(self):
"""Test filter initialization with no environment variable set."""
with patch.dict(os.environ, {}, clear=True):
filter_instance = AllowedToolsFilter()
assert filter_instance.env_value is None
assert not filter_instance.is_enabled()
assert filter_instance.get_whitelist() is None
def test_filter_initialization_primary_var(self):
"""Test filter initialization with ALLOWED_TOOLS set."""
with patch.dict(os.environ, {"ALLOWED_TOOLS": "search,send_message"}):
filter_instance = AllowedToolsFilter()
assert filter_instance.env_value == "search,send_message"
assert filter_instance.env_var_name == "ALLOWED_TOOLS"
assert filter_instance.is_enabled()
assert filter_instance.get_whitelist() == {"search", "send_message"}
def test_filter_initialization_legacy_var(self):
"""Test filter initialization with HERMES_ONLY_TOOLS set (backward compatibility)."""
with patch.dict(os.environ, {"HERMES_ONLY_TOOLS": "search,send_message"}):
filter_instance = AllowedToolsFilter()
assert filter_instance.env_value == "search,send_message"
assert filter_instance.env_var_name == "HERMES_ONLY_TOOLS"
assert filter_instance.is_enabled()
assert filter_instance.get_whitelist() == {"search", "send_message"}
def test_filter_initialization_primary_precedence(self):
"""Test that ALLOWED_TOOLS takes precedence over HERMES_ONLY_TOOLS."""
with patch.dict(os.environ, {
"ALLOWED_TOOLS": "search,send_message",
"HERMES_ONLY_TOOLS": "old_tool"
}):
filter_instance = AllowedToolsFilter()
assert filter_instance.env_value == "search,send_message"
assert filter_instance.env_var_name == "ALLOWED_TOOLS"
assert filter_instance.get_whitelist() == {"search", "send_message"}
def test_filter_initialization_empty_string_error(self):
"""Test that empty string raises ValueError."""
with patch.dict(os.environ, {"ALLOWED_TOOLS": ""}):
with pytest.raises(ValueError, match="ALLOWED_TOOLS cannot be empty"):
AllowedToolsFilter()
def test_filter_initialization_whitespace_only_error(self):
"""Test that whitespace-only string raises ValueError."""
with patch.dict(os.environ, {"ALLOWED_TOOLS": " "}):
with pytest.raises(ValueError, match="ALLOWED_TOOLS cannot be empty"):
AllowedToolsFilter()
def test_filter_initialization_empty_after_split_error(self):
"""Test that comma-only string raises ValueError."""
with patch.dict(os.environ, {"ALLOWED_TOOLS": ",,,"}):
with pytest.raises(ValueError, match="contains no valid tool names"):
AllowedToolsFilter()
def test_parse_whitelist_with_spaces(self):
"""Test parsing comma-separated values with spaces."""
with patch.dict(os.environ, {"ALLOWED_TOOLS": " search , send_message , extract_pdf "}):
filter_instance = AllowedToolsFilter()
assert filter_instance.get_whitelist() == {"search", "send_message", "extract_pdf"}
@patch.dict(os.environ, {"ALLOWED_TOOLS": "search,unknown_tool,send_message", "CI": "false"})
def test_filter_tools_with_unknown_tools_dev_mode(self):
"""Test filtering tools with unknown tools in development mode."""
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message", "extract_pdf"}
with patch.object(logging.getLogger(), 'warning') as mock_warn:
filtered = filter_instance.filter_tools(available_tools)
# Should return intersection of available and whitelisted tools
assert filtered == {"search", "send_message"}
# Should warn about unknown tools
mock_warn.assert_called()
call_args = mock_warn.call_args[0]
assert "Unknown tools" in call_args[0]
assert ["unknown_tool"] == call_args[2]
@patch.dict(os.environ, {"ALLOWED_TOOLS": "search,unknown_tool,send_message", "CI": "true"})
def test_filter_tools_with_unknown_tools_ci_mode(self):
"""Test filtering tools with unknown tools in CI mode."""
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message", "extract_pdf"}
with pytest.raises(ValueError, match="Unknown tools in ALLOWED_TOOLS"):
filter_instance.filter_tools(available_tools)
@patch.dict(os.environ, {"ALLOWED_TOOLS": "search,send_message"})
def test_filter_tools_all_available(self):
"""Test filtering when all whitelisted tools are available."""
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message", "extract_pdf"}
filtered = filter_instance.filter_tools(available_tools)
assert filtered == {"search", "send_message"}
@patch.dict(os.environ, {"ALLOWED_TOOLS": "search,send_message"})
def test_filter_tools_partial_match(self):
"""Test filtering when only some whitelisted tools are available."""
filter_instance = AllowedToolsFilter()
available_tools = {"search", "extract_pdf"}
filtered = filter_instance.filter_tools(available_tools)
assert filtered == {"search"}
@patch.dict(os.environ, {"ALLOWED_TOOLS": "search,send_message"})
def test_filter_tools_dict_input(self):
"""Test filtering with dictionary input."""
filter_instance = AllowedToolsFilter()
available_tools = {"search": lambda: None, "send_message": lambda: None, "extract_pdf": lambda: None}
filtered = filter_instance.filter_tools(available_tools)
assert filtered == {"search", "send_message"}
@patch.dict(os.environ, {"ALLOWED_TOOLS": "search,send_message"})
def test_filter_tools_list_input(self):
"""Test filtering with list input."""
filter_instance = AllowedToolsFilter()
available_tools = ["search", "send_message", "extract_pdf"]
filtered = filter_instance.filter_tools(available_tools)
assert filtered == {"search", "send_message"}
@patch.dict(os.environ, {"ALLOWED_TOOLS": "search,send_message,unknown_in_whitelist"})
def test_diagnostics_data(self):
"""Test diagnostics data collection."""
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message", "extract_pdf"}
filtered = filter_instance.filter_tools(available_tools)
diagnostics = filter_instance.get_diagnostics()
assert diagnostics["env_var_name"] == "ALLOWED_TOOLS"
assert diagnostics["env_value"] == "search,send_message,unknown_in_whitelist"
assert diagnostics["whitelist"] == ["search", "send_message", "unknown_in_whitelist"]
assert set(diagnostics["registered_before_filter"]) == {"search", "send_message", "extract_pdf"}
assert set(diagnostics["registered_after_filter"]) == {"search", "send_message"}
assert set(diagnostics["dropped_tools"]) == {"extract_pdf"}
assert set(diagnostics["unknown_tools"]) == {"unknown_in_whitelist"}
@patch.dict(os.environ, {"ALLOWED_TOOLS": "search,send_message"})
def test_log_diagnostics(self, caplog):
"""Test diagnostics logging."""
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message", "extract_pdf"}
with caplog.at_level(logging.INFO):
filter_instance.filter_tools(available_tools)
filter_instance.log_diagnostics()
log_text = caplog.text
assert "ALLOWED_TOOLS FILTER DIAGNOSTICS" in log_text
assert "ALLOWED_TOOLS=search,send_message" in log_text
assert "RegisteredBeforeFilter" in log_text
assert "RegisteredAfterFilter" in log_text
assert "DroppedTools" in log_text
assert "UnknownTools" in log_text
def test_log_diagnostics_before_filtering(self, caplog):
"""Test logging diagnostics before calling filter_tools."""
filter_instance = AllowedToolsFilter()
with caplog.at_level(logging.WARNING):
filter_instance.log_diagnostics()
assert "No diagnostics available" in caplog.text
def test_filter_tools_unset_env_var(self, caplog):
"""Test filtering with unset environment variable."""
with patch.dict(os.environ, {}, clear=True):
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message", "extract_pdf"}
with caplog.at_level(logging.WARNING):
filtered = filter_instance.filter_tools(available_tools)
# Should return all tools
assert filtered == available_tools
assert "Tool whitelist is unset" in caplog.text
@patch.dict(os.environ, {"ALLOWED_TOOLS": "tool1", "CI": "false"})
def test_no_matching_tools(self):
"""Test filtering when no whitelisted tools match available tools."""
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message", "extract_pdf"}
filtered = filter_instance.filter_tools(available_tools)
assert filtered == set() # Empty set when no matches
def test_custom_env_var_name(self):
"""Test using custom environment variable name."""
with patch.dict(os.environ, {"CUSTOM_TOOLS": "search,send_message"}):
filter_instance = AllowedToolsFilter("CUSTOM_TOOLS")
assert filter_instance.env_var_name == "CUSTOM_TOOLS"
assert filter_instance.get_whitelist() == {"search", "send_message"}
class TestConvenienceFunction:
"""Test cases for the convenience function."""
@patch.dict(os.environ, {"ALLOWED_TOOLS": "search,send_message"})
def test_filter_tools_with_allowed_tools(self):
"""Test the convenience function."""
available_tools = {"search", "send_message", "extract_pdf"}
filtered = filter_tools_with_allowed_tools(available_tools, log_diagnostics=False)
assert filtered == {"search", "send_message"}
@patch.dict(os.environ, {"CUSTOM_TOOLS": "search"})
def test_filter_tools_with_custom_env_var(self):
"""Test the convenience function with custom env var."""
available_tools = {"search", "send_message"}
filtered = filter_tools_with_allowed_tools(available_tools, env_var_name="CUSTOM_TOOLS", log_diagnostics=False)
assert filtered == {"search"}
@patch.dict(os.environ, {"ALLOWED_TOOLS": "search"})
def test_filter_tools_with_diagnostics(self, caplog):
"""Test the convenience function with diagnostics enabled."""
available_tools = {"search", "send_message"}
with caplog.at_level(logging.INFO):
filtered = filter_tools_with_allowed_tools(available_tools, log_diagnostics=True)
assert filtered == {"search"}
assert "ALLOWED_TOOLS FILTER DIAGNOSTICS" in caplog.text
class TestBackwardCompatibility:
"""Test backward compatibility aliases."""
@patch.dict(os.environ, {"HERMES_ONLY_TOOLS": "search,send_message"})
def test_legacy_class_alias(self):
"""Test HermesToolFilter alias works."""
from praisonaiagents.allowed_tools_filter import HermesToolFilter
filter_instance = HermesToolFilter()
assert isinstance(filter_instance, AllowedToolsFilter)
assert filter_instance.env_var_name == "HERMES_ONLY_TOOLS"
assert filter_instance.get_whitelist() == {"search", "send_message"}
@patch.dict(os.environ, {"HERMES_ONLY_TOOLS": "search,send_message"})
def test_legacy_function_aliases(self):
"""Test legacy function aliases work."""
from praisonaiagents.allowed_tools_filter import (
filter_tools_with_hermes, hermes_filter, apply_hermes_filter
)
available_tools = {"search", "send_message", "extract_pdf"}
# All aliases should work and return the same result
result1 = filter_tools_with_hermes(available_tools, log_diagnostics=False)
result2 = hermes_filter(available_tools, log_diagnostics=False)
result3 = apply_hermes_filter(available_tools, log_diagnostics=False)
expected = {"search", "send_message"}
assert result1 == expected
assert result2 == expected
assert result3 == expected
def test_empty_tools_edge_case(self):
"""Test filtering with empty available tools."""
with patch.dict(os.environ, {"ALLOWED_TOOLS": "search,send_message"}):
filter_instance = AllowedToolsFilter()
filtered = filter_instance.filter_tools(set())
assert filtered == set()
# Also test with empty list and dict
assert filter_instance.filter_tools([]) == set()
assert filter_instance.filter_tools({}) == set()
def test_whitelist_immutability(self):
"""Test that get_whitelist returns a copy, not the original."""
with patch.dict(os.environ, {"ALLOWED_TOOLS": "search,send_message"}):
filter_instance = AllowedToolsFilter()
whitelist1 = filter_instance.get_whitelist()
whitelist2 = filter_instance.get_whitelist()
# Should be equal but not the same object
assert whitelist1 == whitelist2
assert whitelist1 is not whitelist2
# Modifying one shouldn't affect the other
whitelist1.add("new_tool")
assert whitelist1 != whitelist2
+562 -299
View File
File diff suppressed because it is too large. Load diff
@@ -1,6 +1,6 @@
{
"version": "1.5.87",
"lastUpdated": "2026-05-19",
"lastUpdated": "2026-05-20",
"generatedBy": "praisonai._dev.parity.generator",
"sourceOfTruth": "Python SDK (praisonaiagents)",
"status": "PARITY_ACHIEVED",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"version": "1.5.87",
"lastUpdated": "2026-05-19",
"lastUpdated": "2026-05-20",
"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-19
> **Version:** 1.5.87 | **Last Updated:** 2026-05-20
> **Source of Truth:** Python SDK (praisonaiagents)
## Summary
+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.41.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.41.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.43.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.43.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
@@ -0,0 +1,53 @@
"""Single source of truth for optional-framework availability."""
import importlib.util
import importlib.metadata
import threading
from typing import Callable
_cache: dict[str, bool] = {}
_lock = threading.Lock()
def _ag2_probe() -> bool:
# AG2 ships under the `autogen` namespace; presence of the dist is
# the only reliable signal.
try:
importlib.metadata.distribution('ag2')
except importlib.metadata.PackageNotFoundError:
return False
return importlib.util.find_spec("autogen") is not None
def _autogen_v4_probe() -> bool:
return (importlib.util.find_spec("autogen_agentchat") is not None
and importlib.util.find_spec("autogen_ext") is not None)
_PROBES: dict[str, Callable[[], bool]] = {
"crewai": lambda: importlib.util.find_spec("crewai") is not None,
"autogen": lambda: importlib.util.find_spec("autogen") is not None,
"autogen_v4": _autogen_v4_probe,
"ag2": _ag2_probe,
"praisonaiagents": lambda: importlib.util.find_spec("praisonaiagents") is not None,
"praisonai_tools": lambda: importlib.util.find_spec("praisonai_tools") is not None,
"agentops": lambda: importlib.util.find_spec("agentops") is not None,
"litellm": lambda: importlib.util.find_spec("litellm") is not None,
"openai": lambda: importlib.util.find_spec("openai") is not None,
}
def is_available(name: str) -> bool:
if name not in _PROBES:
raise ValueError(f"unknown framework name: {name!r}")
cached = _cache.get(name)
if cached is not None:
return cached
with _lock:
cached = _cache.get(name)
if cached is None:
cached = bool(_PROBES[name]())
_cache[name] = cached
return cached
def invalidate(name: str | None = None) -> None:
with _lock:
if name is None:
_cache.clear()
else:
_cache.pop(name, None)
+10 -17
View File
@@ -23,12 +23,8 @@ from .framework_adapters.registry import FrameworkAdapterRegistry, get_default_r
from .tool_registry import ToolRegistry
# Import availability flags
try:
from .inbuilt_tools import PRAISONAI_TOOLS_AVAILABLE, CREWAI_AVAILABLE, AUTOGEN_AVAILABLE
except ImportError:
PRAISONAI_TOOLS_AVAILABLE = False
CREWAI_AVAILABLE = False
AUTOGEN_AVAILABLE = False
# Compatibility imports - now handled by centralized detection
# (inbuilt_tools still defines these but they're read-only compatibility)
# Import BaseTool for tools handling
BaseTool = None
@@ -40,17 +36,14 @@ except ImportError:
except ImportError:
pass
# 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
# Check for additional framework availability using centralized detection
from ._framework_availability import is_available
PRAISONAI_TOOLS_AVAILABLE = is_available("praisonai_tools")
CREWAI_AVAILABLE = is_available("crewai")
AUTOGEN_AVAILABLE = is_available("autogen")
AG2_AVAILABLE = is_available("ag2")
PRAISONAI_AVAILABLE = is_available("praisonaiagents")
AGENTOPS_AVAILABLE = is_available("agentops")
# Framework adapter registry - now uses proper registry pattern
# This replaces the hardcoded FRAMEWORK_ADAPTERS dict
+34 -53
View File
@@ -61,10 +61,40 @@ def _load_optional(key: str, loader):
# --- CrewAI lazy loading ---
# Availability checks now delegated to centralized module
from ._framework_availability import is_available
def _check_crewai_available() -> bool:
"""Check if crewai is available (cached, thread-safe)."""
result = _load_optional("crewai_check", lambda: __import__("crewai"))
return result is not None
return is_available("crewai")
def _check_autogen_available() -> bool:
"""Check if autogen v0.2 is available (cached, thread-safe)."""
return is_available("autogen")
def _check_autogen_v4_available() -> bool:
"""Check if autogen v0.4 is available (cached, thread-safe)."""
return is_available("autogen_v4")
def _check_ag2_available() -> bool:
"""Check if AG2 (community fork of AutoGen) is available (cached, thread-safe)."""
return is_available("ag2")
def _check_praisonai_available() -> bool:
"""Check if praisonaiagents is available (cached, thread-safe)."""
return is_available("praisonaiagents")
def _check_praisonai_tools_available() -> bool:
"""Check if praisonai_tools is available (cached, thread-safe)."""
return is_available("praisonai_tools")
def _check_litellm_available() -> bool:
"""Check if litellm is available (cached, thread-safe)."""
return is_available("litellm")
def _check_openai_available() -> bool:
"""Check if openai is available (cached, thread-safe)."""
return is_available("openai")
def _get_crewai():
@@ -76,31 +106,6 @@ def _get_crewai():
))
# --- AutoGen lazy loading ---
def _check_autogen_available() -> bool:
"""Check if autogen v0.2 is available (cached, thread-safe)."""
result = _load_optional("autogen_check", lambda: __import__("autogen"))
return result is not None
def _check_autogen_v4_available() -> bool:
"""Check if autogen v0.4 is available (cached, thread-safe)."""
result = _load_optional("autogen_v4_check", lambda: __import__("autogen_agentchat.agents", fromlist=["AssistantAgent"]))
return result is not None
def _check_ag2_available() -> bool:
"""Check if AG2 (community fork of AutoGen) is available (cached, thread-safe)."""
def ag2_loader():
import importlib.metadata
importlib.metadata.distribution('ag2')
from autogen import LLMConfig # AG2-exclusive class
return True
result = _load_optional("ag2_check", ag2_loader)
return result is not None
def _get_autogen():
"""Lazy load autogen module (thread-safe)."""
return _load_optional("autogen_module", lambda: __import__("autogen"))
@@ -116,13 +121,6 @@ def _get_autogen_v4():
return _load_optional("autogen_v4_classes", autogen_v4_loader)
# --- PraisonAI Agents lazy loading ---
def _check_praisonai_available() -> bool:
"""Check if praisonaiagents is available (cached, thread-safe)."""
result = _load_optional("praisonai_check", lambda: __import__("praisonaiagents"))
return result is not None
def _get_praisonai():
"""Lazy load praisonaiagents classes (thread-safe)."""
def praisonai_loader():
@@ -133,12 +131,6 @@ def _get_praisonai():
# --- PraisonAI Tools lazy loading ---
def _check_praisonai_tools_available() -> bool:
"""Check if praisonai_tools is available (cached, thread-safe)."""
result = _load_optional("praisonai_tools_check", lambda: __import__("praisonai_tools"))
return result is not None
def _get_praisonai_tools():
"""Lazy load praisonai_tools classes (thread-safe)."""
def tools_loader():
@@ -172,13 +164,6 @@ def _get_praisonai_tools():
return _load_optional("praisonai_tools_dict", tools_loader)
# --- LiteLLM lazy loading ---
def _check_litellm_available() -> bool:
"""Check if litellm is available (cached)."""
result = _load_optional("litellm_check", lambda: __import__("litellm"))
return result is not None
def _get_litellm():
"""Lazy load litellm module."""
result = _load_optional("litellm", lambda: __import__("litellm"))
@@ -188,10 +173,6 @@ def _get_litellm():
# --- OpenAI lazy loading ---
def _check_openai_available() -> bool:
"""Check if openai is available (cached)."""
result = _load_optional("openai_check", lambda: __import__("openai"))
return result is not None
@@ -970,7 +951,7 @@ Use the recommended tools: {', '.join(recommended_tools)}
# Workflow Auto-Generation (Feature Parity)
# =============================================================================
class TaskDetails(BaseModel):
class WorkflowStepDetails(BaseModel):
"""Details for a workflow step."""
agent: str
action: str
@@ -984,7 +965,7 @@ class WorkflowRouteDetails(BaseModel):
class WorkflowParallelDetails(BaseModel):
"""Details for a parallel step."""
name: str
parallel: List[TaskDetails]
parallel: List[WorkflowStepDetails]
class WorkflowAgentDetails(BaseModel):
"""Details for a workflow agent."""
@@ -119,6 +119,20 @@ def doctor_network(
raise typer.Exit(_run_doctor(args))
@app.command("packaging")
def doctor_packaging(
deep: bool = typer.Option(False, "--deep", help="Enable deeper probes"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""Check packaging and entry point configuration (Windows daemon compatibility)."""
args = ["packaging"]
if deep:
args.append("--deep")
if json_output:
args.append("--json")
raise typer.Exit(_run_doctor(args))
@app.command("performance")
def doctor_performance(
deep: bool = typer.Option(False, "--deep", help="Enable deeper probes"),
@@ -123,6 +123,8 @@ class MultiAgentHandler:
def _load_tools(self, tool_names: List[str]) -> List:
"""Load tool functions by name.
Applies ALLOWED_TOOLS filter if enabled to prevent tool name collisions.
Args:
tool_names: List of tool names to load
@@ -148,6 +150,26 @@ class MultiAgentHandler:
'analyze_csv': analyze_csv,
}
# Apply ALLOWED_TOOLS filter if enabled
try:
from praisonaiagents.allowed_tools_filter import filter_tools_with_allowed_tools
available_tools = set(tool_map.keys())
filtered_tools = filter_tools_with_allowed_tools(available_tools, log_diagnostics=False)
# Filter tool_map to only include whitelisted tools
tool_map = {name: func for name, func in tool_map.items() if name in filtered_tools}
except ImportError:
# ALLOWED_TOOLS filter not available, proceed normally
pass
except ValueError:
# Preserve strict filter semantics (invalid config / CI unknown tools)
raise
except Exception as e:
if self.verbose:
print(f"⚠ ALLOWED_TOOLS filter error: {e}")
raise
for name in tool_names:
if name in tool_map:
tools.append(tool_map[name])
@@ -23,3 +23,4 @@ def register_all_checks():
from . import lsp_checks
from . import acp_checks
from . import bot_checks
from . import packaging_checks
@@ -0,0 +1,422 @@
"""
Packaging checks for the Doctor CLI module.
Validates PraisonAI packaging and entry point configurations to diagnose
Windows daemon invocation issues (python -m praisonai vs praisonai console script).
"""
import os
import subprocess
import sys
import platform
import shutil
from ..models import (
CheckCategory,
CheckResult,
CheckStatus,
CheckSeverity,
DoctorConfig,
)
from ..registry import register_check
@register_check(
id="praisonai_package_structure",
title="PraisonAI Package Structure",
description="Check if praisonai package has correct structure for both -m and script execution",
category=CheckCategory.PACKAGING,
severity=CheckSeverity.HIGH,
)
def check_praisonai_package_structure(config: DoctorConfig) -> CheckResult:
"""Check PraisonAI package structure and entry points."""
issues = []
metadata = {}
try:
import praisonai
package_path = praisonai.__file__
if package_path:
package_dir = os.path.dirname(package_path)
metadata["package_path"] = package_path
metadata["package_dir"] = package_dir
# Check for __main__.py
main_py_path = os.path.join(package_dir, "__main__.py")
if os.path.exists(main_py_path):
metadata["has_main_py"] = True
metadata["main_py_path"] = main_py_path
else:
issues.append("Missing __main__.py - python -m praisonai will not work")
metadata["has_main_py"] = False
else:
issues.append("praisonai.__file__ is None - namespace package detected")
except ImportError as e:
issues.append(f"Cannot import praisonai: {e}")
metadata["import_error"] = str(e)
# Check console script availability
praisonai_script = shutil.which("praisonai")
if praisonai_script:
metadata["console_script_path"] = praisonai_script
metadata["has_console_script"] = True
else:
issues.append("praisonai console script not found in PATH")
metadata["has_console_script"] = False
if not issues:
return CheckResult(
id="praisonai_package_structure",
title="PraisonAI Package Structure",
category=CheckCategory.PACKAGING,
status=CheckStatus.PASS,
message="Package structure is correct for both -m and script execution",
metadata=metadata,
)
else:
return CheckResult(
id="praisonai_package_structure",
title="PraisonAI Package Structure",
category=CheckCategory.PACKAGING,
status=CheckStatus.FAIL,
message=f"Package structure issues: {'; '.join(issues)}",
metadata=metadata,
)
@register_check(
id="python_module_execution",
title="Python Module Execution",
description="Test if python -m praisonai works correctly",
category=CheckCategory.PACKAGING,
severity=CheckSeverity.HIGH,
)
def check_python_module_execution(config: DoctorConfig) -> CheckResult:
"""Test python -m praisonai execution."""
metadata = {"test_command": f"{sys.executable} -m praisonai --version"}
try:
result = subprocess.run(
[sys.executable, "-m", "praisonai", "--version"],
capture_output=True,
text=True,
timeout=config.timeout,
)
metadata["return_code"] = result.returncode
metadata["stdout"] = result.stdout.strip()
metadata["stderr"] = result.stderr.strip()
if result.returncode == 0:
return CheckResult(
id="python_module_execution",
title="Python Module Execution",
category=CheckCategory.PACKAGING,
status=CheckStatus.PASS,
message=f"python -m praisonai works: {result.stdout.strip() or result.stderr.strip()}",
metadata=metadata,
)
else:
return CheckResult(
id="python_module_execution",
title="Python Module Execution",
category=CheckCategory.PACKAGING,
status=CheckStatus.FAIL,
message=f"python -m praisonai failed with exit code {result.returncode}",
metadata=metadata,
)
except subprocess.TimeoutExpired:
return CheckResult(
id="python_module_execution",
title="Python Module Execution",
category=CheckCategory.PACKAGING,
status=CheckStatus.FAIL,
message=f"python -m praisonai timed out (>{config.timeout}s)",
metadata=metadata,
)
except Exception as e:
metadata["exception"] = str(e)
return CheckResult(
id="python_module_execution",
title="Python Module Execution",
category=CheckCategory.PACKAGING,
status=CheckStatus.FAIL,
message=f"python -m praisonai execution failed: {e}",
metadata=metadata,
)
@register_check(
id="console_script_execution",
title="Console Script Execution",
description="Test if praisonai console script works correctly",
category=CheckCategory.PACKAGING,
severity=CheckSeverity.HIGH,
)
def check_console_script_execution(config: DoctorConfig) -> CheckResult:
"""Test praisonai console script execution."""
praisonai_script = shutil.which("praisonai")
if not praisonai_script:
return CheckResult(
id="console_script_execution",
title="Console Script Execution",
category=CheckCategory.PACKAGING,
status=CheckStatus.FAIL,
message="praisonai console script not found in PATH",
metadata={"script_path": None},
)
metadata = {
"script_path": praisonai_script,
"test_command": f"{praisonai_script} --version"
}
try:
result = subprocess.run(
[praisonai_script, "--version"],
capture_output=True,
text=True,
timeout=config.timeout,
)
metadata["return_code"] = result.returncode
metadata["stdout"] = result.stdout.strip()
metadata["stderr"] = result.stderr.strip()
if result.returncode == 0:
return CheckResult(
id="console_script_execution",
title="Console Script Execution",
category=CheckCategory.PACKAGING,
status=CheckStatus.PASS,
message=f"praisonai console script works: {result.stdout.strip() or result.stderr.strip()}",
metadata=metadata,
)
else:
return CheckResult(
id="console_script_execution",
title="Console Script Execution",
category=CheckCategory.PACKAGING,
status=CheckStatus.FAIL,
message=f"praisonai console script failed with exit code {result.returncode}",
metadata=metadata,
)
except subprocess.TimeoutExpired:
return CheckResult(
id="console_script_execution",
title="Console Script Execution",
category=CheckCategory.PACKAGING,
status=CheckStatus.FAIL,
message=f"praisonai console script timed out (>{config.timeout}s)",
metadata=metadata,
)
except Exception as e:
metadata["exception"] = str(e)
return CheckResult(
id="console_script_execution",
title="Console Script Execution",
category=CheckCategory.PACKAGING,
status=CheckStatus.FAIL,
message=f"praisonai console script execution failed: {e}",
metadata=metadata,
)
@register_check(
id="windows_daemon_context",
title="Windows Daemon Context",
description="Check Windows-specific daemon execution context",
category=CheckCategory.PACKAGING,
severity=CheckSeverity.MEDIUM,
)
def check_windows_daemon_context(config: DoctorConfig) -> CheckResult:
"""Check Windows daemon execution context and provide recommendations."""
metadata = {
"platform": platform.system(),
"is_windows": platform.system().lower() == "windows"
}
if not metadata["is_windows"]:
return CheckResult(
id="windows_daemon_context",
title="Windows Daemon Context",
category=CheckCategory.PACKAGING,
status=CheckStatus.SKIP,
message="Not Windows - check skipped",
metadata=metadata,
)
recommendations = []
warnings = []
# Check if in virtual environment
in_venv = hasattr(sys, 'real_prefix') or (hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix)
metadata["in_venv"] = in_venv
metadata["sys_prefix"] = sys.prefix
metadata["sys_executable"] = sys.executable
if in_venv:
venv_scripts_dir = os.path.join(sys.prefix, "Scripts")
metadata["venv_scripts_dir"] = venv_scripts_dir
# Check if Scripts dir exists and has praisonai.exe
if os.path.exists(venv_scripts_dir):
praisonai_exe = os.path.join(venv_scripts_dir, "praisonai.exe")
metadata["venv_praisonai_exe_exists"] = os.path.exists(praisonai_exe)
if os.path.exists(praisonai_exe):
recommendations.append(f"For daemons, use absolute path: {praisonai_exe}")
else:
warnings.append("praisonai.exe not found in venv Scripts directory")
else:
warnings.append("Virtual environment Scripts directory not found")
else:
warnings.append("Not in virtual environment - daemon may use global Python")
# Check Python executable type
if "WindowsApps" in sys.executable:
warnings.append("Using Microsoft Store Python - may cause daemon issues")
recommendations.append("Consider using python.org Python or conda for daemons")
# Check encoding environment
pythonioencoding = os.environ.get("PYTHONIOENCODING")
metadata["pythonioencoding"] = pythonioencoding
if not pythonioencoding:
recommendations.append("Set PYTHONIOENCODING=utf-8 for daemon encoding safety")
# Generate canonical daemon command
praisonai_script = shutil.which("praisonai")
if praisonai_script:
canonical_command = f'"{praisonai_script}"'
else:
canonical_command = f'"{sys.executable}" -m praisonai'
metadata["canonical_daemon_command"] = canonical_command
recommendations.append(f"Recommended daemon command: {canonical_command}")
if warnings:
status = CheckStatus.WARN
message = f"Windows daemon context has warnings: {'; '.join(warnings[:2])}"
else:
status = CheckStatus.PASS
message = "Windows daemon context is good"
if recommendations:
metadata["recommendations"] = recommendations
return CheckResult(
id="windows_daemon_context",
title="Windows Daemon Context",
category=CheckCategory.PACKAGING,
status=status,
message=message,
metadata=metadata,
)
@register_check(
id="packaging_metadata",
title="Packaging Metadata",
description="Check PraisonAI package installation metadata",
category=CheckCategory.PACKAGING,
severity=CheckSeverity.LOW,
)
def check_packaging_metadata(config: DoctorConfig) -> CheckResult:
"""Check PraisonAI package installation metadata."""
metadata = {}
issues = []
try:
import praisonai
metadata["praisonai_file"] = getattr(praisonai, "__file__", None)
metadata["praisonai_version"] = getattr(praisonai, "__version__", "unknown")
# Check if installed via pip (using modern importlib.metadata)
try:
try:
from importlib.metadata import distribution, PackageNotFoundError
except ImportError:
# Python < 3.8 fallback
from importlib_metadata import distribution, PackageNotFoundError
try:
dist = distribution("praisonai")
metadata["pip_version"] = dist.version
metadata["pip_location"] = str(dist.locate_file(""))
metadata["pip_installed"] = True
except PackageNotFoundError:
metadata["pip_installed"] = False
issues.append("Package not found in pip registry (editable install?)")
except ImportError:
metadata["importlib_metadata_available"] = False
# Check for editable install markers
if praisonai.__file__:
package_dir = os.path.dirname(praisonai.__file__)
# Look for .egg-link or similar editable install markers
site_packages = []
try:
import site
site_packages.extend(site.getsitepackages())
if hasattr(site, 'getusersitepackages'):
site_packages.append(site.getusersitepackages())
except Exception as e:
metadata["site_packages_probe_error"] = str(e)
is_editable = False
for sp in site_packages:
if sp and os.path.exists(sp):
# Check for both case variations of egg-link files
for egg_link_name in ("praisonai.egg-link", "PraisonAI.egg-link"):
egg_link = os.path.join(sp, egg_link_name)
if os.path.exists(egg_link):
is_editable = True
metadata["editable_install"] = True
metadata["egg_link_path"] = egg_link
break
if is_editable:
break
# Check for modern editable install markers (PEP 660)
try:
for file in os.listdir(sp):
if file.startswith("__editable__") and "praisonai" in file.lower() and file.endswith(".pth"):
is_editable = True
metadata["editable_install"] = True
metadata["editable_pth_path"] = os.path.join(sp, file)
break
except OSError:
pass
if is_editable:
break
if not is_editable:
metadata["editable_install"] = False
except ImportError as e:
issues.append(f"Cannot import praisonai: {e}")
metadata["import_error"] = str(e)
if not issues:
return CheckResult(
id="packaging_metadata",
title="Packaging Metadata",
category=CheckCategory.PACKAGING,
status=CheckStatus.PASS,
message="Package metadata is accessible",
metadata=metadata,
)
else:
return CheckResult(
id="packaging_metadata",
title="Packaging Metadata",
category=CheckCategory.PACKAGING,
status=CheckStatus.WARN,
message=f"Package metadata issues: {'; '.join(issues)}",
metadata=metadata,
)
@@ -40,6 +40,7 @@ class DoctorHandler(CommandHandler):
"permissions",
"network",
"performance",
"packaging",
"ci",
"selftest",
]
@@ -290,6 +291,7 @@ class DoctorHandler(CommandHandler):
"permissions": [CheckCategory.PERMISSIONS],
"network": [CheckCategory.NETWORK],
"performance": [CheckCategory.PERFORMANCE],
"packaging": [CheckCategory.PACKAGING],
"selftest": [CheckCategory.SELFTEST],
"ci": None, # CI runs all checks
}
@@ -32,6 +32,7 @@ class CheckCategory(Enum):
PERMISSIONS = "permissions"
NETWORK = "network"
PERFORMANCE = "performance"
PACKAGING = "packaging"
SELFTEST = "selftest"
BOTS = "bots"
+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.41 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==4.6.43 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
@@ -117,12 +117,8 @@ class AutoGenV4Adapter(BaseFrameworkAdapter):
def is_available(self) -> bool:
"""Check if AutoGen v0.4 is available for import."""
try:
from autogen_agentchat.agents import AssistantAgent # noqa: F401
from autogen_ext.models.openai import OpenAIChatCompletionClient # noqa: F401
return True
except ImportError:
return False
from .._framework_availability import is_available
return is_available("autogen_v4")
def run(
self,
@@ -153,10 +149,148 @@ class AutoGenV4Adapter(BaseFrameworkAdapter):
# Availability already validated at CLI entry
logger.info("Starting AutoGen v0.4 execution...")
# 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")
raise NotImplementedError("AutoGen v0.4 adapter is not yet fully implemented. Please use 'autogen' framework for AutoGen v0.2 support.")
import asyncio
import os
import re
async def run_autogen_v4_async():
from autogen_agentchat.agents import AssistantAgent
from autogen_ext.models.openai import OpenAIChatCompletionClient
from autogen_agentchat.teams import RoundRobinGroupChat
from autogen_agentchat.conditions import TextMentionTermination, MaxMessageTermination
# Create model client for v0.4
model_config = llm_config[0] if llm_config else {}
model_client = OpenAIChatCompletionClient(
model=model_config.get('model', 'gpt-4o-mini'),
api_key=model_config.get('api_key', os.environ.get("OPENAI_API_KEY")),
base_url=model_config.get('base_url', "https://api.openai.com/v1")
)
agents = []
combined_tasks = []
try:
# Create agents from config
for role, details in config['roles'].items():
# For AutoGen v0.4, ensure agent name is a valid Python identifier
agent_name = self._format_template(details['role'], topic=topic)
agent_name = self._sanitize_agent_name_for_autogen_v4(agent_name)
backstory = self._format_template(details['backstory'], topic=topic)
# Convert tools for v0.4 - simplified tool passing
agent_tools = []
for tool_name in details.get('tools', []):
if tools_dict and tool_name in tools_dict:
tool_instance = tools_dict[tool_name]
# For v0.4, we can pass the tool's run method directly if it's callable
if hasattr(tool_instance, 'run') and callable(tool_instance.run):
agent_tools.append(tool_instance.run)
# Create v0.4 AssistantAgent
assistant = AssistantAgent(
name=agent_name,
system_message=backstory + ". Must reply with 'TERMINATE' when the task is complete.",
model_client=model_client,
tools=agent_tools,
reflect_on_tool_use=True
)
agents.append(assistant)
# Collect all task descriptions for sequential execution
for task_name, task_details in details.get('tasks', {}).items():
description_filled = self._format_template(task_details['description'], topic=topic)
combined_tasks.append(description_filled)
if not agents:
return "No agents created from configuration"
# Create termination conditions
text_termination = TextMentionTermination("TERMINATE")
max_messages_termination = MaxMessageTermination(max_messages=20)
termination_condition = text_termination | max_messages_termination
# Create RoundRobinGroupChat for parallel/sequential execution
group_chat = RoundRobinGroupChat(
agents,
termination_condition=termination_condition,
max_turns=len(agents) * 3 # Allow multiple rounds
)
# Combine all tasks into a single task description
task_description = f"Topic: {topic}\n\nTasks to complete:\n" + "\n".join(
f"{i+1}. {task}" for i, task in enumerate(combined_tasks)
)
# Run the group chat
result = await group_chat.run(task=task_description)
# Extract the final message content
if result.messages:
final_message = result.messages[-1]
if hasattr(final_message, 'content'):
return f"### AutoGen v0.4 Output ###\n{final_message.content}"
else:
return f"### AutoGen v0.4 Output ###\n{str(final_message)}"
else:
return "### AutoGen v0.4 Output ###\nNo messages generated"
except Exception as e:
logger.error(f"Error in AutoGen v0.4 execution: {str(e)}")
return f"### AutoGen v0.4 Error ###\n{str(e)}"
finally:
# Close the model client
await model_client.close()
# Run the async function using safe bridge
try:
from .._async_bridge import run_sync
return run_sync(run_autogen_v4_async())
except ImportError:
# Fallback if _async_bridge is not available
return asyncio.run(run_autogen_v4_async())
except Exception as e:
logger.error(f"Error running AutoGen v0.4: {str(e)}")
return f"### AutoGen v0.4 Error ###\n{str(e)}"
def _sanitize_agent_name_for_autogen_v4(self, name):
"""
Sanitize agent name to be a valid Python identifier for AutoGen v0.4.
Args:
name (str): The original agent name
Returns:
str: A valid Python identifier
"""
import re
import keyword
# Convert to string and replace invalid characters with underscores
sanitized = re.sub(r'[^a-zA-Z0-9_]', '_', str(name))
# Collapse only very excessive underscores (5 or more) to reduce extreme cases
sanitized = re.sub(r'_{5,}', '_', sanitized)
# Remove trailing underscores only if not part of a dunder pattern and only if singular
if sanitized.endswith('_') and not sanitized.endswith('__') and sanitized != '_':
sanitized = sanitized.rstrip('_')
# Ensure it doesn't start with a digit
if sanitized and sanitized[0].isdigit():
sanitized = 'agent_' + sanitized
# If it's empty or just underscores, use a default
if not sanitized or sanitized == '_':
sanitized = 'agent'
# Check if it's a Python keyword and append underscore if so
if keyword.iskeyword(sanitized):
sanitized += '_'
return sanitized
class AG2Adapter(BaseFrameworkAdapter):
@@ -168,13 +302,8 @@ class AG2Adapter(BaseFrameworkAdapter):
def is_available(self) -> bool:
"""Check if AG2 is available for import."""
try:
import importlib.metadata as _importlib_metadata
_importlib_metadata.distribution('ag2')
from autogen import LLMConfig # noqa: F401 — AG2-exclusive class
return True
except Exception:
return False
from .._framework_availability import is_available
return is_available("ag2")
def run(
self,
@@ -205,7 +334,135 @@ class AG2Adapter(BaseFrameworkAdapter):
# Availability already validated at CLI entry
logger.info("Starting AG2 execution...")
# 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")
raise NotImplementedError("AG2 adapter is not yet fully implemented. Please use 'autogen' framework for AutoGen/AG2 support.")
import re
import os
from autogen import (
AssistantAgent, UserProxyAgent, GroupChat, GroupChatManager, LLMConfig
)
model_config = llm_config[0] if llm_config else {}
# Allow YAML top-level llm block to override config_list values
yaml_llm = config.get("llm", {}) or {}
# Also check first role's llm block as a fallback
first_role_llm = {}
for role_details in config.get("roles", {}).values():
first_role_llm = role_details.get("llm", {}) or {}
break
# Priority: YAML top-level llm > first role llm > config_list > env vars
def _resolve(key, env_var=None, default=None):
return (yaml_llm.get(key) or first_role_llm.get(key)
or model_config.get(key)
or (os.environ.get(env_var) if env_var else None)
or default)
api_type = _resolve("api_type", default="openai").lower()
model_name = _resolve("model", default="gpt-4o-mini")
api_key = _resolve("api_key", env_var="OPENAI_API_KEY")
# Use resolver for consistent env-var precedence as fallback
try:
from praisonai.llm.env import resolve_llm_endpoint
ep = resolve_llm_endpoint()
base_url = (model_config.get("base_url")
or yaml_llm.get("base_url")
or ep.base_url)
except ImportError:
base_url = model_config.get("base_url") or yaml_llm.get("base_url")
# Build LLMConfig — Bedrock needs no api_key
if api_type == "bedrock":
llm_config_entry = {"api_type": "bedrock", "model": model_name}
else:
llm_config_entry = {"model": model_name}
if api_key:
llm_config_entry["api_key"] = api_key
if base_url and base_url not in ("https://api.openai.com/v1", "https://api.openai.com/v1/"):
llm_config_entry["base_url"] = base_url
llm_config = LLMConfig(llm_config_entry)
user_proxy = UserProxyAgent(
name="User",
human_input_mode="NEVER",
is_termination_msg=lambda x: "TERMINATE" in (x.get("content") or ""),
code_execution_config=False,
)
# Create one AssistantAgent per role
ag2_agent_entries = []
for role, details in config["roles"].items():
agent_name = self._format_template(details.get("role", role), topic=topic)
backstory = self._format_template(details.get("backstory", ""), topic=topic)
agent_name_safe = re.sub(r"[^a-zA-Z0-9_\-]", "_", agent_name)
assistant = AssistantAgent(
name=agent_name_safe,
system_message=backstory + "\nWhen the task is done, reply 'TERMINATE'.",
llm_config=llm_config,
)
ag2_agent_entries.append((role, details, assistant))
# Register tools via AG2 decorator pattern
for role, details, assistant in ag2_agent_entries:
for tool_name in details.get("tools", []):
tool = tools_dict.get(tool_name) if tools_dict else None
if tool is None:
continue
func = tool if callable(tool) else getattr(tool, "run", None)
if func is None:
continue
def make_tool_fn(f, name):
def tool_fn(**kwargs):
return f(**kwargs) if callable(f) else str(f)
tool_fn.__name__ = name
return tool_fn
wrapped = make_tool_fn(func, tool_name)
assistant.register_for_llm(description=f"Tool: {tool_name}")(wrapped)
user_proxy.register_for_execution()(wrapped)
all_assistants = [a for _, _, a in ag2_agent_entries]
if not all_assistants:
return "### AG2 Output ###\nNo agents created from configuration."
# Build initial message from all task descriptions
task_lines = []
for role, details, _ in ag2_agent_entries:
for task_name, task_details in details.get("tasks", {}).items():
desc = self._format_template(task_details.get("description", ""), topic=topic)
if desc:
task_lines.append(desc)
initial_message = "\n".join(task_lines) if task_lines else topic
groupchat = GroupChat(
agents=[user_proxy] + all_assistants,
messages=[],
max_round=12,
)
manager = GroupChatManager(groupchat=groupchat, llm_config=llm_config)
try:
chat_result = user_proxy.initiate_chat(manager, message=initial_message)
except Exception as e:
return f"### AG2 Error ###\n{str(e)}"
# Prefer ChatResult.summary if available, otherwise scan messages
result_content = ""
summary = getattr(chat_result, "summary", None)
if summary and isinstance(summary, str) and summary.strip():
result_content = re.sub(r'[\s\.\,]*TERMINATE[\s\.\,]*$', '', summary, flags=re.IGNORECASE).strip().rstrip('.')
if not result_content:
for msg in reversed(groupchat.messages):
if msg.get("name") == "User":
continue
content = (msg.get("content") or "").strip()
if content:
result_content = re.sub(r'[\s\.\,]*TERMINATE[\s\.\,]*$', '', content, flags=re.IGNORECASE).strip().rstrip('.')
if result_content:
break
if not result_content:
result_content = "Task completed."
return f"### AG2 Output ###\n{result_content}"
@@ -20,11 +20,8 @@ class CrewAIAdapter(BaseFrameworkAdapter):
def is_available(self) -> bool:
"""Check if CrewAI is available for import."""
try:
import crewai # noqa: F401
return True
except ImportError:
return False
from .._framework_availability import is_available
return is_available("crewai")
def run(
self,
@@ -53,60 +50,150 @@ class CrewAIAdapter(BaseFrameworkAdapter):
Execution result as string
"""
# Import CrewAI only when needed (availability already validated at CLI entry)
import os
from crewai import Agent, Task, Crew
from crewai.telemetry import Telemetry
from ..inc import PraisonAIModel
from .._framework_availability import is_available
# Suppress crewai.cli.config logger (scoped to when CrewAI is actually used)
logging.getLogger('crewai.cli.config').setLevel(logging.ERROR)
# Use scoped telemetry disabling instead of global patching
with scoped_telemetry_disable(Telemetry):
# For now, use simplified CrewAI execution
agents = {}
tasks = []
# Create agents
for agent_name, agent_details in config.get('roles', {}).items():
# Resolve tools for this agent from tools_dict
agent_tool_list = []
if tools_dict:
agent_tools = agent_details.get('tools', [])
agent_tool_list = [tools_dict[t] for t in agent_tools if t in tools_dict]
tasks_dict = {}
# Create agents from config
for role, details in config['roles'].items():
role_filled = self._format_template(details['role'], topic=topic)
goal_filled = self._format_template(details['goal'], topic=topic)
backstory_filled = self._format_template(details['backstory'], topic=topic)
# Get agent tools
agent_tools = [tools_dict[tool] for tool in details.get('tools', [])
if tools_dict and tool in tools_dict]
# Configure LLM
llm_model = details.get('llm')
if llm_model:
llm = PraisonAIModel(
model=llm_model.get("model") or os.environ.get("MODEL_NAME") or "openai/gpt-4o-mini",
base_url=llm_config[0].get('base_url') if llm_config else None,
api_key=llm_config[0].get('api_key') if llm_config else None
).get_model()
else:
llm = PraisonAIModel(
base_url=llm_config[0].get('base_url') if llm_config else None,
api_key=llm_config[0].get('api_key') if llm_config else None
).get_model()
# Configure function calling LLM
function_calling_llm_model = details.get('function_calling_llm')
if function_calling_llm_model:
function_calling_llm = PraisonAIModel(
model=function_calling_llm_model.get("model") or os.environ.get("MODEL_NAME") or "openai/gpt-4o-mini",
base_url=llm_config[0].get('base_url') if llm_config else None,
api_key=llm_config[0].get('api_key') if llm_config else None
).get_model()
else:
function_calling_llm = PraisonAIModel(
base_url=llm_config[0].get('base_url') if llm_config else None,
api_key=llm_config[0].get('api_key') if llm_config else None
).get_model()
# Create CrewAI agent with full feature set
agent = Agent(
role=agent_details.get('role', agent_name),
goal=self._format_template(agent_details.get('goal', ''), topic=topic),
backstory=self._format_template(agent_details.get('backstory', ''), topic=topic),
tools=agent_tool_list,
verbose=True,
allow_delegation=agent_details.get('allow_delegation', False)
role=role_filled,
goal=goal_filled,
backstory=backstory_filled,
tools=agent_tools,
allow_delegation=details.get('allow_delegation', False),
llm=llm,
function_calling_llm=function_calling_llm,
max_iter=details.get('max_iter') or 15,
max_rpm=details.get('max_rpm') or None,
max_execution_time=details.get('max_execution_time') or None,
verbose=details.get('verbose', True),
cache=details.get('cache', True),
system_template=details.get('system_template') or None,
prompt_template=details.get('prompt_template') or None,
response_template=details.get('response_template') or None,
)
# Set agent callback if provided
if agent_callback:
agent.step_callback = agent_callback
agents[agent_name] = agent
# Create tasks
for agent_name, agent_details in config.get('roles', {}).items():
for task_name, task_details in agent_details.get('tasks', {}).items():
agents[role] = agent
# Create tasks for the agent
for task_name, task_details in details.get('tasks', {}).items():
description_filled = self._format_template(task_details['description'], topic=topic)
expected_output_filled = self._format_template(task_details['expected_output'], topic=topic)
# Resolve task tools from tools_dict
task_tools = []
for tool_name in task_details.get('tools', []):
if isinstance(tool_name, str) and tools_dict and tool_name in tools_dict:
task_tools.append(tools_dict[tool_name])
elif callable(tool_name):
# Already a callable tool object
task_tools.append(tool_name)
task = Task(
description=self._format_template(task_details['description'], topic=topic),
expected_output=self._format_template(task_details['expected_output'], topic=topic),
agent=agents[agent_name]
description=description_filled,
expected_output=expected_output_filled,
agent=agent,
tools=task_tools,
async_execution=task_details.get('async_execution', False),
context=[],
config=task_details.get('config', {}),
output_json=task_details.get('output_json'),
output_pydantic=task_details.get('output_pydantic'),
output_file=task_details.get('output_file', ""),
callback=task_details.get('callback'),
human_input=task_details.get('human_input', False),
create_directory=task_details.get('create_directory', False)
)
# Set task callback if provided
if task_callback:
task.callback = task_callback
tasks.append(task)
# Create and run crew
tasks_dict[task_name] = task
# Set up task contexts
for details in config['roles'].values():
for task_name, task_details in details.get('tasks', {}).items():
task = tasks_dict[task_name]
context_tasks = [tasks_dict[ctx] for ctx in task_details.get('context', [])
if ctx in tasks_dict]
task.context = context_tasks
# Create and run the crew
crew = Crew(
agents=list(agents.values()),
tasks=tasks,
verbose=True
)
logger.info("Starting CrewAI execution...")
result = crew.kickoff()
logger.info("CrewAI execution completed")
logger.debug("Final Crew Configuration:")
logger.debug(f"Agents: {crew.agents}")
logger.debug(f"Tasks: {crew.tasks}")
response = crew.kickoff()
result = f"### Task Output ###\n{response}"
return str(result)
# AgentOps integration if available
if is_available("agentops"):
import agentops
try:
agentops.end_session("Success")
except Exception as e: # noqa: BLE001 -- agentops errors must not crash the caller
logger.warning(f"agentops.end_session failed: {e}")
return result
@@ -20,11 +20,8 @@ class PraisonAIAdapter(BaseFrameworkAdapter):
def is_available(self) -> bool:
"""Check if PraisonAI agents is available for import."""
try:
from praisonaiagents import Agent, Task, AgentTeam # noqa: F401
return True
except ImportError:
return False
from .._framework_availability import is_available
return is_available("praisonaiagents")
def run(
self,
@@ -56,10 +53,11 @@ class PraisonAIAdapter(BaseFrameworkAdapter):
# Import PraisonAI components only when needed
from praisonaiagents import Agent as PraisonAgent, Task as PraisonTask, AgentTeam
from .._framework_availability import is_available
import os
logger.info("Starting PraisonAI execution...")
# Basic implementation - create agents and tasks from config
agents = {}
tasks = []
@@ -68,89 +66,150 @@ class PraisonAIAdapter(BaseFrameworkAdapter):
if llm_config and llm_config[0].get('model'):
model_name = llm_config[0]['model']
# Create agents from roles
for role, details in config.get('roles', {}).items():
role_filled = self._format_template(details.get('role', role), topic=topic)
goal_filled = self._format_template(details.get('goal', ''), topic=topic)
backstory_filled = self._format_template(details.get('backstory', ''), topic=topic)
# Resolve tools for this agent from tools_dict
agent_tool_list = []
if tools_dict:
agent_tools = details.get('tools', [])
agent_tool_list = [tools_dict[t] for t in agent_tools if t in tools_dict]
# Create basic agent
agent = PraisonAgent(
name=role_filled,
role=role_filled,
goal=goal_filled,
backstory=backstory_filled,
instructions=details.get('instructions'),
llm=model_name,
allow_delegation=details.get('allow_delegation', False),
tools=agent_tool_list,
)
if agent_callback:
agent.step_callback = agent_callback
# Initialize InteractiveRuntime for ACP/LSP if enabled globally
global_config = config.get('config', {})
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:
try:
import asyncio
from praisonai.cli.features.interactive_runtime import InteractiveRuntime, RuntimeConfig
from praisonai.cli.features.agent_tools import create_agent_centric_tools
agents[role] = agent
# Create tasks for the agent
agent_tasks = details.get('tasks', {})
if not agent_tasks:
# Auto-generate a task
task_description = details.get('instructions') or backstory_filled
task = PraisonTask(
description=task_description,
expected_output="Complete the assigned task successfully.",
agent=agent,
# Use scoped event loop instead of process-global mutations
runtime_config = RuntimeConfig(
workspace=os.getcwd(),
acp_enabled=acp_enabled,
lsp_enabled=lsp_enabled,
approval_mode=os.environ.get("PRAISONAI_APPROVAL_MODE", "prompt")
)
if task_callback:
task.callback = task_callback
tasks.append(task)
else:
for task_name, task_details in agent_tasks.items():
description_filled = self._format_template(
task_details['description'], topic=topic
)
expected_output_filled = self._format_template(
task_details['expected_output'], topic=topic
)
interactive_runtime = InteractiveRuntime(runtime_config)
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()
# Start the runtime but keep it alive for agent execution
interactive_loop.run_until_complete(interactive_runtime.start())
centric_tools = create_agent_centric_tools(interactive_runtime)
logger.info(f"Loaded {len(centric_tools)} InteractiveRuntime tools")
# Merge with tools_dict
if tools_dict:
tools_dict.update(centric_tools)
else:
tools_dict = centric_tools
except ImportError as e:
logger.warning(f"InteractiveRuntime not available: {e}")
except Exception as e:
logger.error(f"Error setting up InteractiveRuntime: {e}")
try:
# Create agents from roles
for role, details in config.get('roles', {}).items():
role_filled = self._format_template(details.get('role', role), topic=topic)
goal_filled = self._format_template(details.get('goal', ''), topic=topic)
backstory_filled = self._format_template(details.get('backstory', ''), topic=topic)
# Resolve tools for this agent from tools_dict
agent_tool_list = []
if tools_dict:
agent_tools = details.get('tools', [])
agent_tool_list = [tools_dict[t] for t in agent_tools if t in tools_dict]
# Create basic agent
agent = PraisonAgent(
name=role_filled,
role=role_filled,
goal=goal_filled,
backstory=backstory_filled,
instructions=details.get('instructions'),
llm=model_name,
allow_delegation=details.get('allow_delegation', False),
tools=agent_tool_list,
)
if agent_callback:
agent.step_callback = agent_callback
agents[role] = agent
# Create tasks for the agent
agent_tasks = details.get('tasks', {})
if not agent_tasks:
# Auto-generate a task
task_description = details.get('instructions') or backstory_filled
task = PraisonTask(
description=description_filled,
expected_output=expected_output_filled,
description=task_description,
expected_output="Complete the assigned task successfully.",
agent=agent,
)
if task_callback:
task.callback = task_callback
tasks.append(task)
# Create and run the team
memory = config.get('memory', False)
if config.get('process') == 'hierarchical':
team = AgentTeam(
agents=list(agents.values()),
tasks=tasks,
process="hierarchical",
manager_llm=config.get('manager_llm') or model_name,
memory=memory
)
else:
team = AgentTeam(
agents=list(agents.values()),
tasks=tasks,
memory=memory
)
response = team.start()
result = f"### PraisonAI Output ###\n{response}" if response else "### PraisonAI Output ###\nTask completed."
logger.info("PraisonAI execution completed")
return result
else:
for task_name, task_details in agent_tasks.items():
description_filled = self._format_template(
task_details['description'], topic=topic
)
expected_output_filled = self._format_template(
task_details['expected_output'], topic=topic
)
task = PraisonTask(
description=description_filled,
expected_output=expected_output_filled,
agent=agent,
)
if task_callback:
task.callback = task_callback
tasks.append(task)
# Create and run the team
memory = config.get('memory', False)
if config.get('process') == 'hierarchical':
team = AgentTeam(
agents=list(agents.values()),
tasks=tasks,
process="hierarchical",
manager_llm=config.get('manager_llm') or model_name,
memory=memory
)
else:
team = AgentTeam(
agents=list(agents.values()),
tasks=tasks,
memory=memory
)
response = team.start()
result = f"### PraisonAI Output ###\n{response}" if response else "### PraisonAI Output ###\nTask completed."
# AgentOps integration if available
if is_available("agentops"):
import agentops
try:
agentops.end_session("Success")
except Exception as e: # noqa: BLE001 -- agentops errors must not crash the caller
logger.warning(f"agentops.end_session failed: {e}")
logger.info("PraisonAI execution completed")
return result
finally:
# Cleanup InteractiveRuntime if it was started
if interactive_runtime and interactive_loop:
try:
logger.info("Stopping InteractiveRuntime")
interactive_loop.run_until_complete(interactive_runtime.stop())
except Exception as e:
logger.error(f"Error stopping InteractiveRuntime: {e}")
finally:
interactive_loop.close()
@@ -2,8 +2,9 @@
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
from .._framework_availability import is_available
CREWAI_AVAILABLE = is_available("crewai")
AUTOGEN_AVAILABLE = is_available("autogen")
PRAISONAI_TOOLS_PACKAGE_AVAILABLE = importlib.util.find_spec("praisonai_tools") is not None
@@ -30,4 +31,4 @@ def __getattr__(name):
tools_module = _get_autogen_tools()
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}")
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,44 @@
"""
Shared Valkey client factory.
Centralises GlideClientSync creation so that ValkeyStateStore,
ValkeyVectorKnowledgeStore, and ValkeyStorageAdapter all use the
same connection logic.
"""
from typing import Optional
try:
from glide_sync import (
GlideClient as GlideClientSync,
GlideClientConfiguration,
NodeAddress,
ServerCredentials,
)
except ImportError:
GlideClientSync = None
GlideClientConfiguration = NodeAddress = ServerCredentials = None
_MISSING_MSG = (
"valkey-glide-sync is required for Valkey support. "
"Install with: pip install 'praisonai[valkey]'"
)
def create_valkey_client(
host: str = "localhost",
port: int = 6379,
password: Optional[str] = None,
db: int = 0,
):
"""Create and return a GlideClientSync instance."""
if GlideClientSync is None:
raise ImportError(_MISSING_MSG)
addresses = [NodeAddress(host, port)]
creds = ServerCredentials(password=password) if password else None
config = GlideClientConfiguration(
addresses=addresses,
credentials=creds,
database_id=db,
)
return GlideClientSync.create(config)
@@ -0,0 +1,397 @@
"""
Valkey Vector implementation of KnowledgeStore.
Requires: valkey-glide-sync
Install: pip install 'praisonai[valkey]'
"""
import re
import struct
from typing import Any, Dict, List, Optional
from .base import KnowledgeStore, KnowledgeDocument, validate_identifier
from .._valkey_client import create_valkey_client
try:
from glide_sync import ft
from glide_sync import (
TextField, NumericField, VectorField, VectorFieldAttributesHnsw,
VectorAlgorithm, DistanceMetricType, VectorType,
FtCreateOptions, DataType, FtSearchOptions, ReturnField,
)
from glide_sync import Batch
except ImportError:
ft = None
Batch = None
TextField = NumericField = VectorField = VectorFieldAttributesHnsw = None
VectorAlgorithm = DistanceMetricType = VectorType = None
FtCreateOptions = DataType = FtSearchOptions = ReturnField = None
_SEARCH_SPECIAL_RE = re.compile(r'([,.<>{}\[\]"\'\\:;!@#$%^&*()\-+=~?/| \t])')
_FIELD_NAME_RE = re.compile(r'^[a-zA-Z0-9_]+$')
def _escape_search_value(value: str) -> str:
return _SEARCH_SPECIAL_RE.sub(r'\\\1', str(value))
def _decode(v) -> str:
return v.decode() if isinstance(v, bytes) else v
class ValkeyVectorKnowledgeStore(KnowledgeStore):
"""
Valkey-based knowledge store using ValkeySearch FT commands for vector search.
Requires Valkey with ValkeySearch module.
Example:
store = ValkeyVectorKnowledgeStore(
host="localhost",
port=6379,
)
"""
def __init__(
self,
host: str = "localhost",
port: int = 6379,
password: Optional[str] = None,
prefix: str = "praison:vec:",
):
self.host = host
self.port = port
self.password = password
self.prefix = prefix
self._client = None
def _get_client(self):
"""Lazy initialize Valkey client."""
if self._client is None:
self._client = create_valkey_client(
host=self.host,
port=self.port,
password=self.password,
)
return self._client
def _index_name(self, collection: str) -> str:
return f"{self.prefix}{collection}:idx"
def _doc_key(self, collection: str, doc_id: str) -> str:
return f"{self.prefix}{collection}:{doc_id}"
def create_collection(
self,
name: str,
dimension: int,
distance: str = "cosine",
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Create a new index."""
validate_identifier(name, "collection name")
client = self._get_client()
index_name = self._index_name(name)
try:
dist_enum = {"cosine": DistanceMetricType.COSINE, "euclidean": DistanceMetricType.L2, "dot": DistanceMetricType.IP}.get(distance, DistanceMetricType.COSINE)
schema = [
TextField("content"),
TextField("content_hash"),
NumericField("created_at"),
VectorField("embedding", VectorAlgorithm.HNSW, VectorFieldAttributesHnsw(dimension, dist_enum, VectorType.FLOAT32)),
]
options = FtCreateOptions(DataType.HASH, prefixes=[f"{self.prefix}{name}:"])
ft.create(client, index_name, schema, options)
except Exception as e:
err = str(e)
if "already exists" not in err.lower():
raise RuntimeError(f"Failed to create collection in Valkey: {e}") from e
def delete_collection(self, name: str) -> bool:
"""Delete an index and all documents."""
client = self._get_client()
index_name = self._index_name(name)
try:
ft.dropindex(client, index_name)
except Exception as e:
# Only return False for index not found, re-raise other errors
err_msg = str(e).lower()
if "index not found" in err_msg or "unknown index" in err_msg:
return False
raise RuntimeError(f"Failed to delete collection: {e}") from e
# Manually remove orphaned document hashes left behind (no DD flag in ValkeySearch)
pattern = f"{self.prefix}{name}:*"
cursor: bytes = b"0"
while True:
result = client.scan(cursor, match=pattern, count=100)
cursor = result[0]
keys = result[1] or []
if keys:
client.delete(keys)
if not cursor or cursor in (b"0", "0", 0):
break
return True
def collection_exists(self, name: str) -> bool:
"""Check if an index exists."""
client = self._get_client()
index_name = self._index_name(name)
try:
ft.info(client, index_name)
return True
except Exception as e:
# Only return False for index not found, re-raise other errors
err_msg = str(e).lower()
if "index not found" in err_msg or "unknown index" in err_msg:
return False
raise RuntimeError(f"Failed to check collection existence: {e}") from e
def list_collections(self) -> List[str]:
"""List all collections."""
client = self._get_client()
try:
indexes = ft.list(client)
if not indexes:
return []
result = []
for idx in indexes:
idx_str = _decode(idx)
if idx_str.startswith(self.prefix):
name = idx_str[len(self.prefix):]
if name.endswith(":idx"):
name = name[:-4]
result.append(name)
return result
except Exception as e:
# Only return empty list for connection/auth issues in some contexts
# but generally we should propagate errors
raise RuntimeError(f"Failed to list collections: {e}") from e
def insert(
self,
collection: str,
documents: List[KnowledgeDocument],
) -> List[str]:
"""Insert documents using a non-atomic batch (pipeline)."""
client = self._get_client()
ids = []
batch = Batch(is_atomic=False)
for doc in documents:
if doc.embedding is None:
raise ValueError(f"Document {doc.id} has no embedding")
key = self._doc_key(collection, doc.id)
embedding_bytes = struct.pack(f"{len(doc.embedding)}f", *doc.embedding)
batch.hset(key, {
"content": doc.content,
"content_hash": doc.content_hash or "",
"created_at": str(doc.created_at),
"metadata": json.dumps(doc.metadata or {}),
"embedding": embedding_bytes,
})
ids.append(doc.id)
try:
client.exec(batch, raise_on_error=True)
except Exception as e:
raise RuntimeError(f"Failed to insert documents into Valkey: {e}") from e
return ids
def upsert(
self,
collection: str,
documents: List[KnowledgeDocument],
) -> List[str]:
"""Insert or update documents."""
return self.insert(collection, documents)
def search(
self,
collection: str,
query_embedding: List[float],
limit: int = 5,
filters: Optional[Dict[str, Any]] = None,
score_threshold: Optional[float] = None,
) -> List[KnowledgeDocument]:
"""Search for similar documents.
Args:
collection: Collection name.
query_embedding: Query vector.
limit: Max results.
filters: Optional dict of field-value equality filters applied as
a ValkeySearch pre-filter (e.g. {"content_hash": "abc"}).
score_threshold: Minimum similarity (1 - distance) to include.
NOTE: Currently only accurate for cosine distance. For euclidean/L2
and inner-product distances, the threshold behavior may be incorrect.
"""
client = self._get_client()
index_name = self._index_name(collection)
packed = struct.pack(f"{len(query_embedding)}f", *query_embedding)
# Build pre-filter expression
if filters:
filter_parts = []
for field, value in filters.items():
if not _FIELD_NAME_RE.match(field):
raise ValueError(f"Invalid filter field name: {field!r}")
if not isinstance(value, str):
raise TypeError(
f"Filter value for {field!r} must be a string, got {type(value).__name__}"
)
escaped = _escape_search_value(value)
filter_parts.append(f"@{field}:({escaped})")
pre_filter = " ".join(filter_parts)
query = f"({pre_filter})=>[KNN {limit} @embedding $vec AS score]"
else:
query = f"*=>[KNN {limit} @embedding $vec AS score]"
try:
options = FtSearchOptions(
return_fields=[
ReturnField("content"),
ReturnField("content_hash"),
ReturnField("created_at"),
ReturnField("metadata"),
ReturnField("score"),
],
params={"vec": packed},
)
raw = ft.search(client, index_name, query, options)
except Exception as e:
raise RuntimeError(f"Failed to search Valkey: {e}") from e
# ft.search returns [count, {doc_id: {field: value, ...}, ...}]
if not raw or len(raw) < 2:
return []
documents = []
key_prefix = f"{self.prefix}{collection}:"
for doc_id_raw, fields in raw[1].items():
doc_id_str = _decode(doc_id_raw)
bare_id = doc_id_str.removeprefix(key_prefix)
field_map: Dict[str, str] = {}
for fname, fval in fields.items():
fname = _decode(fname)
fval = _decode(fval)
field_map[fname] = fval
score = float(field_map.get("score", 0))
similarity = 1 - score
if score_threshold is not None and similarity < score_threshold:
continue
documents.append(KnowledgeDocument(
id=bare_id,
content=field_map.get("content", ""),
embedding=None,
metadata=json.loads(field_map.get("metadata", "{}") or "{}"),
content_hash=field_map.get("content_hash", ""),
created_at=float(field_map.get("created_at", 0)),
))
return documents
def get(
self,
collection: str,
ids: List[str],
) -> List[KnowledgeDocument]:
"""Get documents by IDs using a non-atomic batch (pipeline)."""
client = self._get_client()
keys = [self._doc_key(collection, doc_id) for doc_id in ids]
batch = Batch(is_atomic=False)
for key in keys:
batch.hgetall(key)
try:
results = client.exec(batch, raise_on_error=True)
except Exception as e:
raise RuntimeError(f"Failed to get documents from Valkey: {e}") from e
documents = []
for doc_id, data in zip(ids, results):
if data:
embedding = None
emb_key = b"embedding" if b"embedding" in data else "embedding"
if emb_key in data:
raw_bytes = data[emb_key]
count = len(raw_bytes) // 4
embedding = list(struct.unpack(f"{count}f", raw_bytes))
content = _decode(data.get(b"content", data.get("content", b"")))
content_hash = _decode(data.get(b"content_hash", data.get("content_hash", b"")))
created_at_raw = data.get(b"created_at", data.get("created_at", b"0"))
created_at = float(_decode(created_at_raw))
metadata_raw = _decode(data.get(b"metadata", data.get("metadata", b"{}")))
metadata = json.loads(metadata_raw or "{}")
doc = KnowledgeDocument(
id=doc_id,
content=content,
embedding=embedding,
metadata=metadata,
content_hash=content_hash,
created_at=created_at,
)
documents.append(doc)
return documents
def delete(
self,
collection: str,
ids: Optional[List[str]] = None,
filters: Optional[Dict[str, Any]] = None,
) -> int:
"""Delete documents by IDs. Filter-based deletion is not supported."""
if filters and not ids:
raise NotImplementedError("Filter-based deletion is not supported by the Valkey backend")
if not ids:
return 0
client = self._get_client()
keys = [self._doc_key(collection, doc_id) for doc_id in ids]
try:
deleted = client.delete(keys)
except Exception as e:
raise RuntimeError(f"Failed to delete documents from Valkey: {e}") from e
return deleted
def count(self, collection: str) -> int:
"""Count documents in a collection."""
client = self._get_client()
index_name = self._index_name(collection)
try:
info = ft.info(client, index_name)
# ft.info returns a Mapping; num_docs may be bytes key
for k in (b"num_docs", "num_docs"):
if k in info:
return int(info[k])
return 0
except Exception as e:
# Only return 0 for index not found, re-raise other errors
err_msg = str(e).lower()
if "index not found" in err_msg or "unknown index" in err_msg:
return 0
raise RuntimeError(f"Failed to count documents: {e}") from e
def close(self) -> None:
"""Close the store."""
if self._client:
try:
self._client.close()
finally:
self._client = None
@@ -198,7 +198,16 @@ def _register_builtin_knowledge_stores(registry: StoreRegistry):
def _redis_vector(url=None, **kwargs):
from .knowledge.redis_vector import RedisVectorKnowledgeStore
return RedisVectorKnowledgeStore(url=url, **kwargs)
def _valkey_vector(url=None, **kwargs):
from .knowledge.valkey_vector import ValkeyVectorKnowledgeStore
if url is not None:
raise ValueError(
"Valkey vector store does not accept 'url' parameter. "
"Use host/port/password kwargs instead."
)
return ValkeyVectorKnowledgeStore(**kwargs)
def _cassandra(url=None, **kwargs):
kwargs.pop("url", None) # Cassandra doesn't use url parameter
from .knowledge.cassandra import CassandraKnowledgeStore
@@ -258,9 +267,10 @@ def _register_builtin_knowledge_stores(registry: StoreRegistry):
registry.register("milvus", _milvus)
registry.register("pgvector", _pgvector)
registry.register("redis", _redis_vector)
registry.register("valkey", _valkey_vector)
registry.register("cassandra", _cassandra)
registry.register("clickhouse", _clickhouse)
registry.register("mongodb_vector", _mongodb_vector,
registry.register("mongodb_vector", _mongodb_vector,
aliases=("mongodb_atlas", "mongo_vector"))
# Register missing backends that were supported in old factory
@@ -286,7 +296,16 @@ def _register_builtin_state_stores(registry: StoreRegistry):
def _redis(url=None, **kwargs):
from .state.redis import RedisStateStore
return RedisStateStore(url=url, **kwargs)
def _valkey(url=None, **kwargs):
from .state.valkey import ValkeyStateStore
if url is not None:
raise ValueError(
"Valkey state store does not accept 'url' parameter. "
"Use host/port/password kwargs instead."
)
return ValkeyStateStore(**kwargs)
def _dynamodb(url=None, **kwargs):
kwargs.pop("url", None) # DynamoDB doesn't use url parameter
from .state.dynamodb import DynamoDBStateStore
@@ -322,10 +341,11 @@ def _register_builtin_state_stores(registry: StoreRegistry):
return GCSStateStore(bucket_name=bucket, **kwargs)
registry.register("redis", _redis)
registry.register("valkey", _valkey)
registry.register("dynamodb", _dynamodb)
registry.register("firestore", _firestore)
registry.register("mongodb", _mongodb)
registry.register("async_mongodb", _async_mongodb,
registry.register("async_mongodb", _async_mongodb,
aliases=("motor", "mongodb_async"))
registry.register("upstash", _upstash)
registry.register("memory", _memory)
@@ -0,0 +1,233 @@
"""
Valkey implementation of StateStore.
Requires: valkey-glide-sync
Install: pip install 'praisonai[valkey]'
"""
import json
from typing import Any, Dict, List, Optional
from .base import StateStore
from .._valkey_client import create_valkey_client
try:
from glide_sync import ExpirySet, ExpiryType
except ImportError:
ExpirySet = None
ExpiryType = None
class ValkeyStateStore(StateStore):
"""
Valkey-based state store for fast key-value operations.
Example:
store = ValkeyStateStore(
host="localhost",
port=6379,
)
"""
def __init__(
self,
host: str = "localhost",
port: int = 6379,
password: Optional[str] = None,
prefix: str = "praison:",
db: int = 0,
):
self.host = host
self.port = port
self.password = password
self.prefix = prefix
self.db = db
self._client = None
def _get_client(self):
"""Lazy initialize Valkey client."""
if self._client is None:
self._client = create_valkey_client(
host=self.host,
port=self.port,
password=self.password,
db=self.db,
)
return self._client
def _key(self, key: str) -> str:
"""Add prefix to key."""
return f"{self.prefix}{key}"
def get(self, key: str) -> Optional[Any]:
"""Get a value by key."""
client = self._get_client()
try:
value = client.get(self._key(key))
except Exception as e:
raise RuntimeError(f"Failed to get key from Valkey: {e}") from e
if value is None:
return None
if isinstance(value, bytes):
value = value.decode()
try:
return json.loads(value)
except json.JSONDecodeError:
return value
def set(self, key: str, value: Any, ttl: Optional[int] = None) -> None:
"""Set a value with optional TTL."""
client = self._get_client()
value = json.dumps(value)
try:
if ttl is not None:
if ExpirySet is None or ExpiryType is None:
from .._valkey_client import _MISSING_MSG
raise ImportError(_MISSING_MSG)
client.set(self._key(key), value, expiry=ExpirySet(ExpiryType.SEC, ttl))
else:
client.set(self._key(key), value)
except ImportError:
raise
except Exception as e:
raise RuntimeError(f"Failed to set key in Valkey: {e}") from e
def delete(self, key: str) -> bool:
"""Delete a key."""
client = self._get_client()
try:
return client.delete([self._key(key)]) > 0
except Exception as e:
raise RuntimeError(f"Failed to delete key from Valkey: {e}") from e
def exists(self, key: str) -> bool:
"""Check if a key exists."""
client = self._get_client()
try:
return client.exists([self._key(key)]) > 0
except Exception as e:
raise RuntimeError(f"Failed to check key existence in Valkey: {e}") from e
def keys(self, pattern: str = "*") -> List[str]:
"""List keys matching pattern."""
client = self._get_client()
full_pattern = self._key(pattern)
try:
cursor: bytes = b"0"
all_keys = []
while True:
result = client.scan(cursor, match=full_pattern, count=100)
cursor = result[0]
all_keys.extend(result[1] or [])
if not cursor or cursor in (b"0", "0"):
break
prefix_len = len(self.prefix)
result_keys = []
for k in all_keys:
k_str = k.decode() if isinstance(k, bytes) else k
result_keys.append(k_str[prefix_len:] if k_str.startswith(self.prefix) else k_str)
return result_keys
except Exception as e:
raise RuntimeError(f"Failed to list keys from Valkey: {e}") from e
def ttl(self, key: str) -> Optional[int]:
"""Get remaining TTL in seconds."""
client = self._get_client()
try:
result = client.ttl(self._key(key))
except Exception as e:
raise RuntimeError(f"Failed to get TTL from Valkey: {e}") from e
if result is None or result < 0:
return None
return int(result)
def expire(self, key: str, ttl: int) -> bool:
"""Set TTL on existing key."""
client = self._get_client()
try:
return bool(client.expire(self._key(key), ttl))
except Exception as e:
raise RuntimeError(f"Failed to set expire in Valkey: {e}") from e
def hget(self, key: str, field: str) -> Optional[Any]:
"""Get a field from a hash."""
client = self._get_client()
try:
value = client.hget(self._key(key), field)
except Exception as e:
raise RuntimeError(f"Failed to hget from Valkey: {e}") from e
if value is None:
return None
if isinstance(value, bytes):
value = value.decode()
try:
return json.loads(value)
except json.JSONDecodeError:
return value
def hset(self, key: str, field: str, value: Any) -> None:
"""Set a field in a hash."""
client = self._get_client()
value = json.dumps(value)
try:
client.hset(self._key(key), {field: value})
except Exception as e:
raise RuntimeError(f"Failed to hset in Valkey: {e}") from e
def hgetall(self, key: str) -> Dict[str, Any]:
"""Get all fields from a hash."""
client = self._get_client()
try:
data = client.hgetall(self._key(key))
except Exception as e:
raise RuntimeError(f"Failed to hgetall from Valkey: {e}") from e
result = {}
if not data:
return result
for k, v in data.items():
k_str = k.decode() if isinstance(k, bytes) else k
v_str = v.decode() if isinstance(v, bytes) else v
try:
result[k_str] = json.loads(v_str)
except (json.JSONDecodeError, TypeError):
result[k_str] = v_str
return result
def hdel(self, key: str, *fields: str) -> int:
"""Delete fields from a hash."""
if not fields:
return 0
client = self._get_client()
try:
return client.hdel(self._key(key), list(fields))
except Exception as e:
raise RuntimeError(f"Failed to hdel from Valkey: {e}") from e
def incr(self, key: str, amount: int = 1) -> int:
"""Increment a counter."""
client = self._get_client()
try:
return client.incrby(self._key(key), amount)
except Exception as e:
raise RuntimeError(f"Failed to increment key in Valkey: {e}") from e
def decr(self, key: str, amount: int = 1) -> int:
"""Decrement a counter."""
client = self._get_client()
try:
return client.decrby(self._key(key), amount)
except Exception as e:
raise RuntimeError(f"Failed to decrement key in Valkey: {e}") from e
def close(self) -> None:
"""Close the store."""
if self._client:
try:
self._client.close()
finally:
self._client = None
@@ -0,0 +1,492 @@
"""
Tests for ValkeyVectorKnowledgeStore, ValkeyStateStore, and registry integration.
Tests cover:
- ValkeyVectorKnowledgeStore collection and document operations (mocked glide client)
- ValkeyStateStore key-value operations (mocked glide client)
- KNOWLEDGE_STORES and STATE_STORES registry entries for "valkey"
"""
import json
import struct
from unittest.mock import MagicMock, patch
from praisonai.persistence.knowledge.base import KnowledgeDocument
from praisonai.persistence.registry import KNOWLEDGE_STORES, STATE_STORES
def _make_knowledge_store():
"""Return a ValkeyVectorKnowledgeStore with a pre-injected MagicMock client."""
from praisonai.persistence.knowledge.valkey_vector import ValkeyVectorKnowledgeStore
store = ValkeyVectorKnowledgeStore.__new__(ValkeyVectorKnowledgeStore)
store.host = "localhost"
store.port = 6379
store.password = None
store.prefix = "praison:vec:"
mock_client = MagicMock()
store._client = mock_client
store._get_client = lambda: mock_client
return store, mock_client
def _make_state_store():
"""Return a ValkeyStateStore with a pre-injected MagicMock client."""
from praisonai.persistence.state.valkey import ValkeyStateStore
store = ValkeyStateStore.__new__(ValkeyStateStore)
store.host = "localhost"
store.port = 6379
store.password = None
store.prefix = "praison:"
store.db = 0
mock_client = MagicMock()
store._client = mock_client
store._get_client = lambda: mock_client
return store, mock_client
class TestValkeyVectorKnowledgeStore:
"""Tests for ValkeyVectorKnowledgeStore."""
_FT_MODULE = "praisonai.persistence.knowledge.valkey_vector.ft"
def _mock_ft(self, **method_returns):
"""Return a mock ft module with configured method returns."""
mock_ft = MagicMock()
for name, val in method_returns.items():
if isinstance(val, Exception):
getattr(mock_ft, name).side_effect = val
else:
getattr(mock_ft, name).return_value = val
return mock_ft
def test_create_collection(self):
"""Test create_collection calls ft.create with HNSW schema."""
store, _ = _make_knowledge_store()
mock_ft = self._mock_ft(create=b"OK")
with patch(self._FT_MODULE, mock_ft):
store.create_collection("docs", dimension=4)
mock_ft.create.assert_called_once()
assert mock_ft.create.call_args[0][1] == "praison:vec:docs:idx"
def test_create_collection_already_exists(self):
"""Test create_collection silently ignores 'already exists' error."""
store, _ = _make_knowledge_store()
mock_ft = self._mock_ft(create=Exception("already exists"))
with patch(self._FT_MODULE, mock_ft):
store.create_collection("docs", dimension=4) # should not raise
def test_delete_collection(self):
"""Test delete_collection calls ft.dropindex and returns True on success."""
store, mock_client = _make_knowledge_store()
mock_ft = self._mock_ft(dropindex=b"OK")
mock_client.scan.return_value = [b"0", []]
with patch(self._FT_MODULE, mock_ft):
result = store.delete_collection("docs")
assert result is True
mock_ft.dropindex.assert_called_once()
def test_collection_exists_true(self):
"""Test collection_exists returns True when ft.info succeeds."""
store, _ = _make_knowledge_store()
mock_ft = self._mock_ft(info={b"index_name": b"praison:vec:docs:idx"})
with patch(self._FT_MODULE, mock_ft):
result = store.collection_exists("docs")
assert result is True
def test_collection_exists_false(self):
"""Test collection_exists returns False when ft.info raises an exception."""
store, _ = _make_knowledge_store()
mock_ft = self._mock_ft(info=Exception("Unknown index name"))
with patch(self._FT_MODULE, mock_ft):
result = store.collection_exists("docs")
assert result is False
def test_list_collections(self):
"""Test list_collections returns only names matching the prefix, stripped."""
store, _ = _make_knowledge_store()
mock_ft = self._mock_ft(list=[b"praison:vec:docs:idx", b"other:idx"])
with patch(self._FT_MODULE, mock_ft):
result = store.list_collections()
assert "docs" in result
assert all("other" not in name for name in result)
def test_insert(self):
"""Test insert uses Batch pipeline and calls client.exec."""
store, mock_client = _make_knowledge_store()
mock_client.exec.return_value = [4]
embedding = [0.1, 0.2, 0.3, 0.4]
doc = KnowledgeDocument(id="doc1", content="hello", embedding=embedding)
ids = store.insert("docs", [doc])
mock_client.exec.assert_called_once()
assert ids == ["doc1"]
def test_upsert_delegates_to_insert(self):
"""Test upsert calls insert (same path)."""
store, mock_client = _make_knowledge_store()
mock_client.exec.return_value = [4]
doc = KnowledgeDocument(id="doc1", content="hello", embedding=[0.1, 0.2, 0.3, 0.4])
store.upsert("docs", [doc])
mock_client.exec.assert_called_once()
def test_search(self):
"""Test search calls ft.search and returns a list of KnowledgeDocument."""
store, _ = _make_knowledge_store()
ft_result = [1, {
b"praison:vec:docs:doc1": {
b"content": b"hello",
b"content_hash": b"abc",
b"created_at": b"1234.0",
b"score": b"0.1",
}
}]
mock_ft = self._mock_ft(search=ft_result)
with patch(self._FT_MODULE, mock_ft):
results = store.search("docs", query_embedding=[0.1, 0.2, 0.3, 0.4], limit=5)
assert isinstance(results, list)
assert len(results) == 1
assert isinstance(results[0], KnowledgeDocument)
assert results[0].id == "doc1"
mock_ft.search.assert_called_once()
def test_search_with_filters(self):
"""Test search with filters builds a pre-filter KNN query."""
store, _ = _make_knowledge_store()
ft_result = [1, {
b"praison:vec:docs:doc1": {
b"content": b"hello",
b"content_hash": b"abc",
b"created_at": b"1234.0",
b"score": b"0.1",
}
}]
mock_ft = self._mock_ft(search=ft_result)
with patch(self._FT_MODULE, mock_ft):
results = store.search(
"docs",
query_embedding=[0.1, 0.2, 0.3, 0.4],
limit=5,
filters={"content_hash": "abc"},
)
assert len(results) == 1
query_arg = mock_ft.search.call_args[0][2]
assert "KNN" in query_arg
assert query_arg.startswith("(")
def test_search_score_threshold_excludes_low_similarity(self):
"""Test search excludes documents whose similarity is below score_threshold."""
store, _ = _make_knowledge_store()
ft_result = [2, {
b"praison:vec:docs:doc1": {
b"content": b"close",
b"content_hash": b"",
b"created_at": b"1.0",
b"score": b"0.05", # similarity = 0.95 — above threshold
},
b"praison:vec:docs:doc2": {
b"content": b"far",
b"content_hash": b"",
b"created_at": b"2.0",
b"score": b"0.8", # similarity = 0.2 — below threshold
},
}]
mock_ft = self._mock_ft(search=ft_result)
with patch(self._FT_MODULE, mock_ft):
results = store.search(
"docs",
query_embedding=[0.1, 0.2, 0.3, 0.4],
limit=5,
score_threshold=0.5,
)
assert len(results) == 1
assert results[0].id == "doc1"
def test_search_empty_result(self):
"""Test search returns an empty list when ft.search returns no documents."""
store, _ = _make_knowledge_store()
mock_ft = self._mock_ft(search=[])
with patch(self._FT_MODULE, mock_ft):
results = store.search("docs", query_embedding=[0.1, 0.2, 0.3, 0.4])
assert results == []
def test_get(self):
"""Test get uses Batch pipeline and returns a KnowledgeDocument."""
store, mock_client = _make_knowledge_store()
embedding = [0.1, 0.2, 0.3, 0.4]
embedding_bytes = struct.pack(f"{len(embedding)}f", *embedding)
mock_client.exec.return_value = [{
b"content": b"hello",
b"content_hash": b"abc",
b"created_at": b"1234.0",
b"embedding": embedding_bytes,
}]
results = store.get("docs", ["doc1"])
assert len(results) == 1
assert isinstance(results[0], KnowledgeDocument)
assert results[0].id == "doc1"
mock_client.exec.assert_called_once()
def test_delete(self):
"""Test delete calls client.delete and returns count deleted."""
store, mock_client = _make_knowledge_store()
mock_client.delete.return_value = 2
result = store.delete("docs", ids=["doc1", "doc2"])
assert result == 2
def test_count(self):
"""Test count calls ft.info and returns integer document count."""
store, _ = _make_knowledge_store()
mock_ft = self._mock_ft(info={b"num_docs": 7, b"index_name": b"praison:vec:docs:idx"})
with patch(self._FT_MODULE, mock_ft):
result = store.count("docs")
assert isinstance(result, int)
assert result == 7
def test_close_closes_client(self):
"""Test close() calls client.close() and clears the reference."""
store, mock_client = _make_knowledge_store()
store.close()
mock_client.close.assert_called_once()
assert store._client is None
class TestValkeyStateStore:
"""Tests for ValkeyStateStore."""
def test_get_existing(self):
"""Test get returns a dict when client.get returns JSON bytes."""
store, mock_client = _make_state_store()
data = {"result": "ok"}
mock_client.get.return_value = json.dumps(data).encode("utf-8")
result = store.get("mykey")
mock_client.get.assert_called_once_with("praison:mykey")
assert result == data
def test_get_missing(self):
"""Test get returns None when client.get returns None."""
store, mock_client = _make_state_store()
mock_client.get.return_value = None
result = store.get("missing")
assert result is None
def test_set_no_ttl(self):
"""Test set calls client.set with prefixed key when no TTL is given."""
store, mock_client = _make_state_store()
mock_client.set.return_value = b"OK"
store.set("mykey", {"x": 1})
mock_client.set.assert_called_once()
call_args = mock_client.set.call_args[0]
assert call_args[0] == "praison:mykey"
def test_set_with_ttl(self):
"""Test set with TTL passes SetOptions to client.set (ImportError/RuntimeError tolerated)."""
store, mock_client = _make_state_store()
mock_client.set.return_value = b"OK"
# glide's ExpirySet/ExpiryType are lazy-imported inside set(); without a live
# glide install this raises ImportError — both paths are valid in CI.
try:
store.set("ttlkey", {"x": 1}, ttl=300)
except (ImportError, RuntimeError):
pass
def test_delete_existing(self):
"""Test delete returns True when client.delete returns 1."""
store, mock_client = _make_state_store()
mock_client.delete.return_value = 1
result = store.delete("mykey")
mock_client.delete.assert_called_once_with(["praison:mykey"])
assert result is True
def test_delete_missing(self):
"""Test delete returns False when client.delete returns 0."""
store, mock_client = _make_state_store()
mock_client.delete.return_value = 0
result = store.delete("absent")
assert result is False
def test_exists_true(self):
"""Test exists returns True when client.exists returns 1."""
store, mock_client = _make_state_store()
mock_client.exists.return_value = 1
assert store.exists("mykey") is True
mock_client.exists.assert_called_once_with(["praison:mykey"])
def test_exists_false(self):
"""Test exists returns False when client.exists returns 0."""
store, mock_client = _make_state_store()
mock_client.exists.return_value = 0
assert store.exists("absent") is False
def test_keys(self):
"""Test keys returns stripped key names via client.scan."""
store, mock_client = _make_state_store()
# scan() returns [cursor, [keys]]; cursor b"0" signals end of iteration
mock_client.scan.return_value = [b"0", [b"praison:key1", b"praison:key2"]]
result = store.keys()
mock_client.scan.assert_called_once()
assert result == ["key1", "key2"]
def test_ttl_with_value(self):
"""Test ttl returns seconds remaining when client.ttl returns a positive value."""
store, mock_client = _make_state_store()
mock_client.ttl.return_value = 30
result = store.ttl("mykey")
assert result == 30
def test_ttl_no_ttl(self):
"""Test ttl returns None when client.ttl returns -1 (no expiry)."""
store, mock_client = _make_state_store()
mock_client.ttl.return_value = -1
result = store.ttl("mykey")
assert result is None
def test_expire(self):
"""Test expire calls client.expire and returns its result."""
store, mock_client = _make_state_store()
mock_client.expire.return_value = True
result = store.expire("mykey", 600)
mock_client.expire.assert_called_once_with("praison:mykey", 600)
assert result is True
def test_hget(self):
"""Test hget returns a parsed dict when client.hget returns JSON bytes."""
store, mock_client = _make_state_store()
data = {"field_val": 42}
mock_client.hget.return_value = json.dumps(data).encode("utf-8")
result = store.hget("myhash", "field1")
mock_client.hget.assert_called_once_with("praison:myhash", "field1")
assert result == data
def test_hset(self):
"""Test hset calls client.hset with the prefixed key and a mapping dict."""
store, mock_client = _make_state_store()
mock_client.hset.return_value = 1
store.hset("myhash", "field1", "value1")
mock_client.hset.assert_called_once()
call_args = mock_client.hset.call_args[0]
assert call_args[0] == "praison:myhash"
assert isinstance(call_args[1], dict)
assert "field1" in call_args[1]
def test_hgetall(self):
"""Test hgetall returns a decoded dict when client.hgetall returns bytes."""
store, mock_client = _make_state_store()
mock_client.hgetall.return_value = {
b"field1": b'"value1"',
b"field2": b"42",
}
result = store.hgetall("myhash")
mock_client.hgetall.assert_called_once_with("praison:myhash")
assert "field1" in result
assert "field2" in result
def test_hdel(self):
"""Test hdel calls client.hdel and returns the count of deleted fields."""
store, mock_client = _make_state_store()
mock_client.hdel.return_value = 2
result = store.hdel("myhash", "field1", "field2")
assert result == 2
def test_incr(self):
"""Test incr calls client.incrby and returns the new value."""
store, mock_client = _make_state_store()
mock_client.incrby.return_value = 5
result = store.incr("counter")
assert result == 5
mock_client.incrby.assert_called_once_with("praison:counter", 1)
def test_decr(self):
"""Test decr calls client.decrby and returns the new value."""
store, mock_client = _make_state_store()
mock_client.decrby.return_value = 3
result = store.decr("counter")
assert result == 3
mock_client.decrby.assert_called_once_with("praison:counter", 1)
def test_close_closes_client(self):
"""Test close() calls client.close() and clears the reference."""
store, mock_client = _make_state_store()
store.close()
mock_client.close.assert_called_once()
assert store._client is None
class TestRegistryValkey:
"""Tests for Valkey backend registration in store registries."""
def test_knowledge_store_valkey_registered(self):
"""Test KNOWLEDGE_STORES registry contains 'valkey'."""
assert "valkey" in KNOWLEDGE_STORES.list_registered()
def test_state_store_valkey_registered(self):
"""Test STATE_STORES registry contains 'valkey'."""
assert "valkey" in STATE_STORES.list_registered()
+11 -149
View File
@@ -63,6 +63,7 @@ import cProfile
import pstats
import io
import statistics
from collections import OrderedDict
from collections import deque
from contextvars import ContextVar
from dataclasses import dataclass, field, asdict
@@ -266,7 +267,8 @@ class _ProfilerImpl:
self._memory = deque(maxlen=max_records)
self._enabled: bool = False
self._flow_step: int = 0
self._files_accessed: Dict[str, int] = {}
self._files_accessed: "OrderedDict[str, int]" = OrderedDict()
self._files_accessed_max = max_records # reuse the existing bound
self._line_profile_data: Dict[str, Any] = {}
self._cprofile_stats = deque(maxlen=max_records)
self._lock = threading.Lock()
@@ -312,9 +314,15 @@ class _ProfilerImpl:
line=line
))
# Track file access
# Track file access with LRU eviction
if file:
self._files_accessed[file] = self._files_accessed.get(file, 0) + 1
if file in self._files_accessed:
self._files_accessed[file] += 1
self._files_accessed.move_to_end(file)
else:
self._files_accessed[file] = 1
if len(self._files_accessed) > self._files_accessed_max:
self._files_accessed.popitem(last=False)
def record_import(self, module: str, duration_ms: float, parent: str = "") -> None:
"""Record an import timing."""
@@ -951,152 +959,6 @@ def check_module_available(module_name: str) -> bool:
return importlib.util.find_spec(module_name) is not None
# ============================================================================
# Context-Aware Default Profiler
# ============================================================================
# Context variable for current profiler (enables per-agent isolation)
_current_profiler: ContextVar[Optional[_ProfilerImpl]] = ContextVar("current_profiler", default=None)
# Module-level default for CLI and backward compatibility
_default_profiler = None
_default_lock = threading.Lock()
def get_profiler() -> _ProfilerImpl:
"""Get the current profiler (context-aware or default)."""
# Check context variable first (for per-agent use)
profiler = _current_profiler.get()
if profiler is not None:
return profiler
# Fall back to module-level default
global _default_profiler
if _default_profiler is None:
with _default_lock:
if _default_profiler is None:
_default_profiler = _ProfilerImpl()
return _default_profiler
def set_profiler(profiler: _ProfilerImpl) -> None:
"""Set the current profiler in context."""
_current_profiler.set(profiler)
class ProfilerCompat:
"""
Compatibility wrapper for old classmethod-based Profiler usage.
Delegates all calls to the current profiler instance via get_profiler().
This maintains backward compatibility while enabling per-agent isolation.
"""
@staticmethod
def enable() -> None:
"""Enable profiling."""
get_profiler().enable()
@staticmethod
def disable() -> None:
"""Disable profiling."""
get_profiler().disable()
@staticmethod
def is_enabled() -> bool:
"""Check if profiling is enabled."""
return get_profiler().is_enabled()
@staticmethod
def clear() -> None:
"""Clear all profiling data."""
get_profiler().clear()
@staticmethod
def record_timing(name: str, duration_ms: float, category: str = "function",
file: str = "", line: int = 0) -> None:
"""Record a timing measurement."""
get_profiler().record_timing(name, duration_ms, category, file, line)
@staticmethod
def record_import(module: str, duration_ms: float, parent: str = "") -> None:
"""Record an import timing."""
get_profiler().record_import(module, duration_ms, parent)
@staticmethod
def record_flow(name: str, duration_ms: float, file: str = "", line: int = 0) -> None:
"""Record a flow step."""
get_profiler().record_flow(name, duration_ms, file, line)
@staticmethod
def block(name: str, category: str = "block"):
"""Context manager for profiling a block of code."""
return get_profiler().block(name, category)
@staticmethod
def get_timings(category: Optional[str] = None):
"""Get timing records."""
return get_profiler().get_timings(category)
@staticmethod
def get_imports(min_duration_ms: float = 0):
"""Get import records."""
return get_profiler().get_imports(min_duration_ms)
@staticmethod
def get_flow():
"""Get flow records."""
return get_profiler().get_flow()
@staticmethod
def get_files_accessed():
"""Get files accessed."""
return get_profiler().get_files_accessed()
@staticmethod
def get_summary():
"""Get profiling summary."""
return get_profiler().get_summary()
@staticmethod
def report(output: str = "console") -> str:
"""Generate and output profiling report."""
return get_profiler().report(output)
@staticmethod
def record_flow(name: str, duration_ms: float, file: str = "", line: int = 0) -> None:
"""Record a flow step."""
get_profiler().record_flow(name, duration_ms, file, line)
@staticmethod
def record_api_call(endpoint: str, method: str, duration_ms: float,
status_code: int = 0, request_size: int = 0,
response_size: int = 0) -> None:
"""Record an API/HTTP call timing."""
get_profiler().record_api_call(endpoint, method, duration_ms,
status_code, request_size, response_size)
@staticmethod
def block(name: str, category: str = "block"):
"""Context manager for profiling a block of code."""
return get_profiler().block(name, category)
@staticmethod
def streaming(name: str):
"""Context manager for profiling streaming operations."""
return get_profiler().streaming(name)
@staticmethod
def api_call(endpoint: str, method: str = "GET"):
"""Context manager for profiling API calls."""
return get_profiler().api_call(endpoint, method)
# Create compatibility instance that acts like old singleton
# This allows existing code to work: Profiler.enable(), etc.
# But it actually delegates to the context-aware profiler
ProfilerSingleton = ProfilerCompat()
# Replace the class-based Profiler with the compatibility wrapper
# This ensures all existing code continues to work
Profiler = ProfilerCompat
# ============================================================================
# Exports
+41 -2
View File
@@ -6,6 +6,7 @@ Heavy implementations that follow StorageBackendProtocol for:
- MongoDB (praisonai[mongodb])
- PostgreSQL (praisonai[postgresql])
- DynamoDB (praisonai[dynamodb])
- Valkey (praisonai[valkey])
These implementations are kept in the wrapper to avoid bloating the core SDK.
"""
@@ -13,9 +14,12 @@ These implementations are kept in the wrapper to avoid bloating the core SDK.
# Lazy imports - only import when needed
__all__ = [
"RedisStorageAdapter",
"MongoDBStorageAdapter",
"MongoDBStorageAdapter",
"PostgreSQLStorageAdapter",
"DynamoDBStorageAdapter",
"ValkeyStorageAdapter",
"ValkeySearchBackend",
"ValkeyBackend",
]
def __getattr__(name: str):
@@ -32,5 +36,40 @@ def __getattr__(name: str):
elif name == "DynamoDBStorageAdapter":
from .dynamodb_adapter import DynamoDBStorageAdapter
return DynamoDBStorageAdapter
elif name == "ValkeyStorageAdapter":
from .valkey_adapter import ValkeyStorageAdapter
return ValkeyStorageAdapter
elif name == "ValkeySearchBackend":
from .valkey_adapter import ValkeySearchBackend
return ValkeySearchBackend
elif name == "ValkeyBackend":
cls = _make_valkey_backend_class()
globals()["ValkeyBackend"] = cls
return cls
else:
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
def _make_valkey_backend_class():
"""Return ValkeyBackend — ValkeyStorageAdapter pre-configured from env vars."""
import os
from .valkey_adapter import ValkeyStorageAdapter
class ValkeyBackend(ValkeyStorageAdapter):
def __init__(
self,
host: str = None,
port: int = None,
prefix: str = None,
ttl=None,
password: str = None,
):
super().__init__(
host=host if host is not None else os.environ.get("VALKEY_HOST", "localhost"),
port=int(port if port is not None else os.environ.get("VALKEY_PORT", 6379)),
prefix=prefix if prefix is not None else os.environ.get("VALKEY_PREFIX", "praisonai:"),
ttl=ttl,
password=password if password is not None else os.environ.get("VALKEY_PASSWORD"),
)
return ValkeyBackend
@@ -0,0 +1,348 @@
"""
Valkey Storage Adapter for PraisonAI.
Implements StorageBackendProtocol using Valkey for high-speed storage.
This is the wrapper implementation that contains the heavy valkey-glide-sync dependency.
"""
import json
import struct
from typing import Dict, Any, List, Optional
from praisonai.persistence._valkey_client import create_valkey_client
try:
from glide_sync import ExpirySet, ExpiryType
except ImportError:
ExpirySet = None
ExpiryType = None
try:
from glide_sync import ft
from glide_sync import (
VectorField, VectorFieldAttributesHnsw, VectorAlgorithm,
DistanceMetricType, VectorType, FtCreateOptions, DataType,
FtSearchOptions, ReturnField,
)
except ImportError:
ft = None
VectorField = VectorFieldAttributesHnsw = VectorAlgorithm = None
DistanceMetricType = VectorType = FtCreateOptions = DataType = None
FtSearchOptions = ReturnField = None
class ValkeyStorageAdapter:
"""
Valkey-based storage backend adapter.
Uses Valkey for high-speed caching and ephemeral data storage.
Implements StorageBackendProtocol from praisonaiagents.storage.protocols.
Example:
```python
from praisonai.storage import ValkeyStorageAdapter
adapter = ValkeyStorageAdapter(host="localhost", port=6379)
adapter.save("session_123", {"messages": []})
data = adapter.load("session_123")
```
"""
def __init__(
self,
host: str = "localhost",
port: int = 6379,
prefix: str = "praisonai:",
ttl: Optional[int] = None,
password: Optional[str] = None,
):
"""
Initialize the Valkey storage adapter.
Args:
host: Valkey server host
port: Valkey server port
prefix: Key prefix for all stored data
ttl: Optional TTL in seconds for all keys
password: Optional Valkey password
"""
self.host = host
self.port = port
self.prefix = prefix
self.ttl = ttl
self.password = password
self._client = None
def _get_client(self):
"""Lazy initialize Valkey client."""
if self._client is None:
self._client = create_valkey_client(
host=self.host,
port=self.port,
password=self.password,
)
return self._client
def _make_key(self, key: str) -> str:
"""Create prefixed key."""
return f"{self.prefix}{key}"
def _scan_keys(self, pattern: str) -> List[bytes]:
"""Collect all keys matching pattern via SCAN."""
client = self._get_client()
cursor: bytes = b"0"
all_keys: List[bytes] = []
while True:
result = client.scan(cursor, match=pattern, count=100)
cursor = result[0]
all_keys.extend(result[1] or [])
if not cursor or cursor in (b"0", "0", 0):
break
return all_keys
def save(self, key: str, data: Dict[str, Any]) -> None:
"""Save data with the given key."""
client = self._get_client()
full_key = self._make_key(key)
json_data = json.dumps(data, default=str, ensure_ascii=False).encode('utf-8')
try:
if self.ttl is not None:
if ExpirySet is None or ExpiryType is None:
from praisonai.persistence._valkey_client import _MISSING_MSG
raise ImportError(_MISSING_MSG)
client.set(full_key, json_data, expiry=ExpirySet(ExpiryType.SEC, self.ttl))
else:
client.set(full_key, json_data)
except ImportError:
raise
except Exception as e:
raise RuntimeError(f"Failed to save data to Valkey: {e}") from e
def load(self, key: str) -> Optional[Dict[str, Any]]:
"""Load data by key."""
client = self._get_client()
full_key = self._make_key(key)
try:
value = client.get(full_key)
if value is not None:
try:
if isinstance(value, bytes):
value = value.decode('utf-8')
return json.loads(value)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON data for key '{key}': {e}") from e
return None
except ValueError:
raise
except Exception as e:
raise RuntimeError(f"Failed to load data from Valkey: {e}") from e
def delete(self, key: str) -> bool:
"""Delete data by key."""
client = self._get_client()
full_key = self._make_key(key)
try:
return client.delete([full_key]) > 0
except Exception as e:
raise RuntimeError(f"Failed to delete data from Valkey: {e}") from e
def list_keys(self, prefix: str = "") -> List[str]:
"""List all keys, optionally filtered by prefix."""
pattern = self._make_key(f"{prefix}*")
try:
raw_keys = self._scan_keys(pattern)
prefix_len = len(self.prefix)
return sorted(
(k.decode('utf-8') if isinstance(k, bytes) else k)[prefix_len:]
for k in raw_keys
)
except Exception as e:
raise RuntimeError(f"Failed to list keys from Valkey: {e}") from e
def exists(self, key: str) -> bool:
"""Check if a key exists."""
client = self._get_client()
full_key = self._make_key(key)
try:
return client.exists([full_key]) > 0
except Exception as e:
raise RuntimeError(f"Failed to check key existence in Valkey: {e}") from e
def clear(self) -> int:
"""Clear all data with our prefix. Returns number of items deleted."""
client = self._get_client()
pattern = self._make_key("*")
batch_size = 500
try:
all_keys = self._scan_keys(pattern)
if not all_keys:
return 0
total = 0
for i in range(0, len(all_keys), batch_size):
total += client.delete(all_keys[i:i + batch_size])
return total
except Exception as e:
raise RuntimeError(f"Failed to clear data from Valkey: {e}") from e
def set_ttl(self, key: str, ttl: int) -> bool:
"""Set TTL on a specific key."""
client = self._get_client()
full_key = self._make_key(key)
try:
return client.expire(full_key, ttl)
except Exception as e:
raise RuntimeError(f"Failed to set TTL in Valkey: {e}") from e
def ping(self) -> bool:
"""Test connection to Valkey."""
try:
client = self._get_client()
result = client.ping()
return result is not None
except Exception:
return False
def close(self) -> None:
"""Close the Valkey connection."""
if self._client:
try:
self._client.close()
finally:
self._client = None
class ValkeySearchBackend:
"""
Valkey vector search backend using RediSearch / ValkeySearch FT commands.
Example:
```python
from praisonai.storage import ValkeySearchBackend
backend = ValkeySearchBackend(host="localhost", port=6379, vector_dim=1536)
backend.create_index()
backend.add_document("doc1", "hello world", embedding)
results = backend.search(query_embedding, k=5)
```
"""
def __init__(
self,
host: str = "localhost",
port: int = 6379,
index_name: str = "praisonai_vectors",
password: Optional[str] = None,
vector_dim: int = 1536,
):
"""
Initialize the Valkey search backend.
Args:
host: Valkey server host
port: Valkey server port
index_name: Name of the FT index
password: Optional Valkey password
vector_dim: Dimension of embedding vectors
"""
self.host = host
self.port = port
self.index_name = index_name
self.password = password
self.vector_dim = vector_dim
self._client = None
def _get_client(self):
"""Lazy initialize Valkey client."""
if self._client is None:
self._client = create_valkey_client(
host=self.host,
port=self.port,
password=self.password,
)
return self._client
def create_index(self, vector_dim: int = None) -> None:
"""Create the vector search index (no-op if already exists)."""
client = self._get_client()
dim = vector_dim or self.vector_dim
try:
schema = [VectorField(
"embedding",
VectorAlgorithm.HNSW,
VectorFieldAttributesHnsw(dim, DistanceMetricType.COSINE, VectorType.FLOAT32),
)]
options = FtCreateOptions(DataType.HASH, prefixes=[f"{self.index_name}:"])
ft.create(client, self.index_name, schema, options)
except Exception as e:
err = str(e)
if "already exists" not in err.lower() and "index already exists" not in err.lower():
raise RuntimeError(f"Failed to create Valkey search index: {e}") from e
def add_document(self, doc_id: str, text: str, embedding: List[float]) -> None:
"""Add a document with its embedding to the index."""
if len(embedding) != self.vector_dim:
raise ValueError(
f"Embedding dimension mismatch: expected {self.vector_dim}, got {len(embedding)}"
)
client = self._get_client()
full_id = f"{self.index_name}:{doc_id}"
packed = struct.pack(f"{len(embedding)}f", *embedding)
try:
client.hset(full_id, {"text": text, "embedding": packed})
except Exception as e:
raise RuntimeError(f"Failed to add document to Valkey: {e}") from e
def search(self, query_embedding: List[float], k: int = 5) -> List[Dict[str, Any]]:
"""Search for nearest neighbors by embedding."""
if k <= 0:
raise ValueError("k must be > 0")
if len(query_embedding) != self.vector_dim:
raise ValueError(
f"Query embedding dimension mismatch: expected {self.vector_dim}, got {len(query_embedding)}"
)
client = self._get_client()
packed = struct.pack(f"{len(query_embedding)}f", *query_embedding)
try:
options = FtSearchOptions(
return_fields=[ReturnField("text"), ReturnField("score")],
params={"vec": packed},
)
raw = ft.search(client, self.index_name, f"*=>[KNN {k} @embedding $vec AS score]", options)
except Exception as e:
raise RuntimeError(f"Failed to search Valkey: {e}") from e
# ft.search returns [count, {doc_id: {field: value, ...}, ...}]
if not raw or len(raw) < 2:
return []
results = []
key_prefix = f"{self.index_name}:"
for doc_id, fields in raw[1].items():
doc: Dict[str, Any] = {}
raw_id = doc_id.decode('utf-8') if isinstance(doc_id, bytes) else doc_id
doc["id"] = raw_id.removeprefix(key_prefix)
for fname, fval in fields.items():
fname = fname.decode('utf-8') if isinstance(fname, bytes) else fname
fval = fval.decode('utf-8') if isinstance(fval, bytes) else fval
doc[fname] = fval
results.append(doc)
return results
def close(self) -> None:
"""Close the Valkey connection."""
if self._client:
try:
self._client.close()
finally:
self._client = None
+38 -11
View File
@@ -421,18 +421,45 @@ class ToolResolver:
return {}
# Convenience functions that construct resolver explicitly (no global singleton)
# Process-level lazy singleton for performance (matches profiler.py pattern)
_default_resolver: Optional[ToolResolver] = None
_default_resolver_lock = threading.Lock()
def _get_default_resolver() -> ToolResolver:
"""Process-default ToolResolver (double-checked lazy init).
Returns cached ToolResolver that is anchored to the working directory
at first call. Local tools.py resolution is CWD-dependent and cached
for the lifetime of the process.
For test isolation or multi-project CLIs, create explicit resolver
instances instead of using this cached default.
"""
global _default_resolver
if _default_resolver is None:
with _default_resolver_lock:
if _default_resolver is None:
_default_resolver = ToolResolver()
return _default_resolver
# Convenience functions that use cached default resolver for performance
def resolve_tool(name: str, resolver: Optional[ToolResolver] = None) -> Optional[Callable]:
"""Resolve a tool name to a callable.
Args:
name: Tool name to resolve
resolver: Optional resolver instance. If None, creates a new one.
resolver: Optional resolver instance. If None, uses cached default resolver.
Returns:
Callable if found, None otherwise
Note:
When resolver=None, uses a process-level cached resolver that is anchored
to the working directory at first call. For test isolation or multi-project
CLIs, pass an explicit resolver instance.
"""
return (resolver or ToolResolver()).resolve(name)
return (resolver or _get_default_resolver()).resolve(name)
def resolve_tools(names: List[str], resolver: Optional[ToolResolver] = None) -> List[Callable]:
@@ -440,24 +467,24 @@ def resolve_tools(names: List[str], resolver: Optional[ToolResolver] = None) ->
Args:
names: List of tool names
resolver: Optional resolver instance. If None, creates a new one.
resolver: Optional resolver instance. If None, uses cached default resolver.
Returns:
List of resolved callables
"""
return (resolver or ToolResolver()).resolve_many(names)
return (resolver or _get_default_resolver()).resolve_many(names)
def list_available_tools(resolver: Optional[ToolResolver] = None) -> Dict[str, str]:
"""List all available tools with descriptions.
Args:
resolver: Optional resolver instance. If None, creates a new one.
resolver: Optional resolver instance. If None, uses cached default resolver.
Returns:
Dict mapping tool names to descriptions
"""
return (resolver or ToolResolver()).list_available()
return (resolver or _get_default_resolver()).list_available()
def has_tool(name: str, resolver: Optional[ToolResolver] = None) -> bool:
@@ -465,12 +492,12 @@ def has_tool(name: str, resolver: Optional[ToolResolver] = None) -> bool:
Args:
name: Tool name to check
resolver: Optional resolver instance. If None, creates a new one.
resolver: Optional resolver instance. If None, uses cached default resolver.
Returns:
True if tool exists, False otherwise
"""
return (resolver or ToolResolver()).has_tool(name)
return (resolver or _get_default_resolver()).has_tool(name)
def validate_yaml_tools(yaml_config: Dict[str, Any], resolver: Optional[ToolResolver] = None) -> List[str]:
@@ -478,9 +505,9 @@ def validate_yaml_tools(yaml_config: Dict[str, Any], resolver: Optional[ToolReso
Args:
yaml_config: Parsed YAML configuration
resolver: Optional resolver instance. If None, creates a new one.
resolver: Optional resolver instance. If None, uses cached default resolver.
Returns:
List of missing tool names
"""
return (resolver or ToolResolver()).validate_yaml_tools(yaml_config)
return (resolver or _get_default_resolver()).validate_yaml_tools(yaml_config)
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.6.41"
__version__ = "4.6.43"
+4 -1
View File
@@ -12,7 +12,7 @@ dependencies = [
"rich>=13.7",
"markdown>=3.5",
"pyparsing>=3.0.0",
"praisonaiagents>=1.6.41",
"praisonaiagents>=1.6.43",
"python-dotenv>=0.19.0",
"litellm>=1.83.14,<2",
"PyYAML>=6.0",
@@ -180,6 +180,9 @@ postgresql = [
dynamodb = [
"boto3>=1.35.0",
]
valkey = [
"valkey-glide-sync>=2.3.1",
]
# Cloud-native serverless databases
neon = [
"psycopg2-binary>=2.9.0",
Whitespace-only changes.
@@ -0,0 +1,249 @@
"""
Tests for ValkeyStorageAdapter and ValkeySearchBackend.
Tests cover:
- ValkeyStorageAdapter CRUD operations (mocked glide client)
- ValkeySearchBackend index and search operations (mocked)
"""
import json
from unittest.mock import MagicMock, patch
def _make_adapter(prefix="praisonai:", ttl=None):
"""Return a ValkeyStorageAdapter with a pre-injected MagicMock client."""
from praisonai.storage.valkey_adapter import ValkeyStorageAdapter
adapter = ValkeyStorageAdapter.__new__(ValkeyStorageAdapter)
adapter.host = "localhost"
adapter.port = 6379
adapter.prefix = prefix
adapter.ttl = ttl
adapter.password = None
mock_client = MagicMock()
adapter._client = mock_client
adapter._get_client = lambda: mock_client
return adapter, mock_client
def _make_search_backend(index_name="praisonai_vectors", vector_dim=4):
"""Return a ValkeySearchBackend with a pre-injected MagicMock client."""
from praisonai.storage.valkey_adapter import ValkeySearchBackend
backend = ValkeySearchBackend.__new__(ValkeySearchBackend)
backend.host = "localhost"
backend.port = 6379
backend.index_name = index_name
backend.vector_dim = vector_dim
backend.password = None
mock_client = MagicMock()
backend._client = mock_client
backend._get_client = lambda: mock_client
return backend, mock_client
class TestValkeyStorageAdapter:
"""Tests for ValkeyStorageAdapter."""
def test_valkey_adapter_save(self):
"""Test save calls client.set with prefixed key and JSON bytes."""
adapter, mock_client = _make_adapter()
data = {"name": "test", "value": 42}
adapter.save("mykey", data)
mock_client.set.assert_called_once()
call_args = mock_client.set.call_args[0]
assert call_args[0] == "praisonai:mykey"
assert json.loads(call_args[1]) == data
def test_valkey_adapter_save_with_ttl(self):
"""Test save with TTL calls client.set with expiry kwarg (ImportError tolerated without glide)."""
adapter, mock_client = _make_adapter(ttl=300)
# ExpirySet/ExpiryType are lazy-imported inside save(); without a live
# glide install this raises RuntimeError — both paths are valid in CI.
try:
adapter.save("ttlkey", {"key": "val"})
mock_client.set.assert_called_once()
_, kwargs = mock_client.set.call_args
assert "expiry" in kwargs
except (ImportError, RuntimeError):
pass
def test_valkey_adapter_load_existing(self):
"""Test load returns a dict when client.get returns JSON bytes."""
adapter, mock_client = _make_adapter()
data = {"result": "ok"}
mock_client.get.return_value = json.dumps(data).encode("utf-8")
loaded = adapter.load("mykey")
mock_client.get.assert_called_once_with("praisonai:mykey")
assert loaded == data
def test_valkey_adapter_load_missing(self):
"""Test load returns None when client.get returns None."""
adapter, mock_client = _make_adapter()
mock_client.get.return_value = None
loaded = adapter.load("missing")
assert loaded is None
def test_valkey_adapter_delete_existing(self):
"""Test delete returns True when client.delete returns 1."""
adapter, mock_client = _make_adapter()
mock_client.delete.return_value = 1
result = adapter.delete("mykey")
mock_client.delete.assert_called_once_with(["praisonai:mykey"])
assert result is True
def test_valkey_adapter_delete_missing(self):
"""Test delete returns False when client.delete returns 0."""
adapter, mock_client = _make_adapter()
mock_client.delete.return_value = 0
result = adapter.delete("absent")
assert result is False
def test_valkey_adapter_list_keys(self):
"""Test list_keys returns stripped, sorted key names via client.scan."""
adapter, mock_client = _make_adapter()
raw_keys = [b"praisonai:beta", b"praisonai:alpha", b"praisonai:gamma"]
# scan() returns [cursor, [keys]]; cursor b"0" signals end of iteration
mock_client.scan.return_value = [b"0", raw_keys]
keys = adapter.list_keys()
mock_client.scan.assert_called_once()
assert keys == ["alpha", "beta", "gamma"]
def test_valkey_adapter_exists_true(self):
"""Test exists returns True when client.exists returns 1."""
adapter, mock_client = _make_adapter()
mock_client.exists.return_value = 1
assert adapter.exists("mykey") is True
mock_client.exists.assert_called_once_with(["praisonai:mykey"])
def test_valkey_adapter_exists_false(self):
"""Test exists returns False when client.exists returns 0."""
adapter, mock_client = _make_adapter()
mock_client.exists.return_value = 0
assert adapter.exists("absent") is False
def test_valkey_adapter_clear(self):
"""Test clear deletes all prefixed keys and returns count."""
adapter, mock_client = _make_adapter()
raw_keys = [b"praisonai:a", b"praisonai:b", b"praisonai:c"]
# scan() returns [cursor, [keys]]; cursor b"0" signals end of iteration
mock_client.scan.return_value = [b"0", raw_keys]
mock_client.delete.return_value = 3
count = adapter.clear()
assert count == 3
mock_client.delete.assert_called_once()
def test_valkey_adapter_set_ttl(self):
"""Test set_ttl calls client.expire with prefixed key and returns result."""
adapter, mock_client = _make_adapter()
mock_client.expire.return_value = True
result = adapter.set_ttl("mykey", 600)
mock_client.expire.assert_called_once_with("praisonai:mykey", 600)
assert result is True
def test_valkey_adapter_import_error(self):
"""Test ImportError with 'valkey' hint when glide package is not available."""
import praisonai.persistence._valkey_client as client_mod
from praisonai.storage.valkey_adapter import ValkeyStorageAdapter
adapter = ValkeyStorageAdapter.__new__(ValkeyStorageAdapter)
adapter.host = "localhost"
adapter.port = 6379
adapter.prefix = "praisonai:"
adapter.ttl = None
adapter.password = None
adapter._client = None
original = client_mod.GlideClientSync
try:
client_mod.GlideClientSync = None
try:
adapter._get_client()
assert False, "Should have raised ImportError"
except ImportError as e:
assert "valkey" in str(e).lower()
finally:
client_mod.GlideClientSync = original
def test_valkey_adapter_operation_error(self):
"""Test RuntimeError raised when a client operation fails."""
adapter, mock_client = _make_adapter()
mock_client.get.side_effect = Exception("connection refused")
try:
adapter.load("anykey")
assert False, "Should have raised RuntimeError"
except RuntimeError as e:
assert "Valkey" in str(e) or "valkey" in str(e).lower()
class TestValkeySearchBackend:
"""Tests for ValkeySearchBackend."""
def test_create_index(self):
"""Test create_index uses ft.create (no-op if already exists)."""
backend, _ = _make_search_backend()
mock_ft = MagicMock()
mock_ft.create.return_value = b"OK"
with patch("praisonai.storage.valkey_adapter.ft", mock_ft):
backend.create_index()
mock_ft.create.assert_called_once()
assert backend.index_name == mock_ft.create.call_args[0][1]
def test_add_document(self):
"""Test add_document calls hset with correct key and fields."""
backend, mock_client = _make_search_backend()
mock_client.hset.return_value = 2
backend.add_document("doc1", "hello world", [0.1, 0.2, 0.3, 0.4])
mock_client.hset.assert_called_once()
call_args = mock_client.hset.call_args[0]
assert call_args[0] == "praisonai_vectors:doc1"
mapping = call_args[1]
assert "text" in mapping
assert mapping["text"] == "hello world"
assert "embedding" in mapping
def test_search(self):
"""Test search uses ft.search and returns a list of dicts."""
backend, _ = _make_search_backend()
ft_result = [1, {b"praisonai_vectors:doc1": {b"text": b"hello world", b"score": b"0.05"}}]
mock_ft = MagicMock()
mock_ft.search.return_value = ft_result
with patch("praisonai.storage.valkey_adapter.ft", mock_ft):
results = backend.search([0.1, 0.2, 0.3, 0.4], k=5)
assert isinstance(results, list)
assert len(results) == 1
assert results[0]["id"] == "doc1"
assert results[0]["text"] == "hello world"
mock_ft.search.assert_called_once()
+245 -151
View File
@@ -17,6 +17,37 @@ overrides = [
{ name = "neonize", marker = "sys_platform == 'never'" },
]
[[package]]
name = "a2a-sdk"
version = "0.3.26"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-api-core" },
{ name = "httpx" },
{ name = "httpx-sse" },
{ name = "protobuf" },
{ name = "pydantic" },
]
sdist = { url = "https://files.pythonhosted.org/packages/be/97/a6840e01795b182ce751ca165430d46459927cde9bfab838087cbb24aef7/a2a_sdk-0.3.26.tar.gz", hash = "sha256:44068e2d037afbb07ab899267439e9bc7eaa7ac2af94f1e8b239933c993ad52d", size = 274598, upload-time = "2026-04-09T15:21:13.902Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dd/d5/51f4ee1bf3b736add42a542d3c8a3fd3fa85f3d36c17972127defc46c26f/a2a_sdk-0.3.26-py3-none-any.whl", hash = "sha256:754e0573f6d33b225c1d8d51f640efa69cbbed7bdfb06ce9c3540ea9f58d4a91", size = 151016, upload-time = "2026-04-09T15:21:12.35Z" },
]
[[package]]
name = "a2ui-agent-sdk"
version = "0.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "a2a-sdk" },
{ name = "google-adk" },
{ name = "google-genai" },
{ name = "jsonschema" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8f/a1/66106b413acf22a7531e86d9ce6663e33d8bdead5043f15cbed6e22a427f/a2ui_agent_sdk-0.2.1.tar.gz", hash = "sha256:cc42af7a1cdae5ef7b7c90a619a7497b677038558a4273c7f751e843c41fac04", size = 233418, upload-time = "2026-04-10T00:18:43.387Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/21/6a10fa0e20db25825f384a6147a11fb521bb5471e2082dc9f4b471d1e3b4/a2ui_agent_sdk-0.2.1-py3-none-any.whl", hash = "sha256:36157834654c591dbe11c40131a51c9f9b54bdedcb15e789cb33eedbefddbc03", size = 73369, upload-time = "2026-04-10T00:18:41.878Z" },
]
[[package]]
name = "absl-py"
version = "2.4.0"
@@ -262,14 +293,11 @@ wheels = [
[[package]]
name = "aiosqlite"
version = "0.20.0"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0d/3a/22ff5415bf4d296c1e92b07fd746ad42c96781f13295a074d58e77747848/aiosqlite-0.20.0.tar.gz", hash = "sha256:6d35c8c256637f4672f843c31021464090805bf925385ac39473fb16eaaca3d7", size = 21691, upload-time = "2024-02-20T06:12:53.915Z" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/c4/c93eb22025a2de6b83263dfe3d7df2e19138e345bca6f18dba7394120930/aiosqlite-0.20.0-py3-none-any.whl", hash = "sha256:36a1deaca0cac40ebe32aac9977a6e2bbc7f5189f23f4a54d5908986729e5bd6", size = 15564, upload-time = "2024-02-20T06:12:50.657Z" },
{ url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
]
[[package]]
@@ -471,6 +499,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f6/22/91616fe707a5c5510de2cac9b046a30defe7007ba8a0c04f9c08f27df312/audioop_lts-0.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:b492c3b040153e68b9fdaff5913305aaaba5bb433d8a7f73d5cf6a64ed3cc1dd", size = 25206, upload-time = "2025-08-05T16:43:16.444Z" },
]
[[package]]
name = "authlib"
version = "1.6.12"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d3/30/6691fdc63b35f54a5a65e04fa1e59d827f4d4e8f4a39678ba7d3088ce0c8/authlib-1.6.12.tar.gz", hash = "sha256:0656d8482f28fc8221929d5f35b2bde5d13e10555ebc06b4561b0d622e83b1bd", size = 165368, upload-time = "2026-05-04T08:11:31.826Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cd/51/9b0b5cd4cf683a02db937a6f9bbebcdc9c56558a7bb3763ce7d3512103c3/authlib-1.6.12-py2.py3-none-any.whl", hash = "sha256:e9229ad7fde610b139dd12f5edbe97eab9ee78bfb85691247e767727850b99ab", size = 244473, upload-time = "2026-05-04T08:11:30.354Z" },
]
[[package]]
name = "autogen-agentchat"
version = "0.6.4"
@@ -1683,6 +1723,40 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/de/86/5486b0188d08aa643e127774a99bac51ffa6cf343e3deb0583956dca5b22/fsspec-2024.12.0-py3-none-any.whl", hash = "sha256:b520aed47ad9804237ff878b504267a3b0b441e97508bd6d2d8774e3db85cee2", size = 183862, upload-time = "2024-12-19T19:57:28.258Z" },
]
[[package]]
name = "google-adk"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiosqlite" },
{ name = "authlib" },
{ name = "click" },
{ name = "fastapi" },
{ name = "google-auth", extra = ["pyopenssl"] },
{ name = "google-genai" },
{ name = "graphviz" },
{ name = "httpx" },
{ name = "jsonschema" },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-sdk" },
{ name = "packaging" },
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "requests" },
{ name = "starlette" },
{ name = "tenacity" },
{ name = "typing-extensions" },
{ name = "tzlocal" },
{ name = "uvicorn" },
{ name = "watchdog" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a5/08/e9af9ab3b0df422f9c9c07251840f8be876694852d3ac06dbe4e15ce01a7/google_adk-2.0.0.tar.gz", hash = "sha256:2f53c70b5de8409d427f0955bc89f1ba30a8397dec5aefa0ac7c3ecd1b4018d4", size = 3337944, upload-time = "2026-05-19T16:17:10.664Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/f5/596e879aacad5214945ede50a4c8e4a0811979ecff83c91e0df11ef13961/google_adk-2.0.0-py3-none-any.whl", hash = "sha256:6d06d8e3b9119ccd7721505356f8c0a5253dcfdc426dd9a72e0fef1cf9ed4703", size = 3846211, upload-time = "2026-05-19T16:17:08.081Z" },
]
[[package]]
name = "google-api-core"
version = "2.30.3"
@@ -1713,6 +1787,9 @@ wheels = [
]
[package.optional-dependencies]
pyopenssl = [
{ name = "pyopenssl" },
]
requests = [
{ name = "requests" },
]
@@ -1782,52 +1859,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/97/7d75fe37a7a6ed171a2cf17117177e7aab7e6e0d115858741b41e9dd4254/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f639065ea2042d5c034bf258a9f085eaa7af0cd250667c0635a3118e8f92c69c", size = 28800, upload-time = "2025-12-16T00:40:30.322Z" },
]
[[package]]
name = "google-genai"
version = "1.67.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version == '3.12.*'",
"python_full_version == '3.11.*'",
"python_full_version < '3.11'",
]
dependencies = [
{ name = "anyio", marker = "python_full_version < '3.13'" },
{ name = "distro", marker = "python_full_version < '3.13'" },
{ name = "google-auth", extra = ["requests"], marker = "python_full_version < '3.13'" },
{ name = "httpx", marker = "python_full_version < '3.13'" },
{ name = "pydantic", marker = "python_full_version < '3.13'" },
{ name = "requests", marker = "python_full_version < '3.13'" },
{ name = "sniffio", marker = "python_full_version < '3.13'" },
{ name = "tenacity", marker = "python_full_version < '3.13'" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "websockets", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a2/07/59a498f81f2c7b0649eacda2ea470b7fd8bd7149f20caba22962081bdd51/google_genai-1.67.0.tar.gz", hash = "sha256:897195a6a9742deb6de240b99227189ada8b2d901d61bdfba836c3092021eab6", size = 506972, upload-time = "2026-03-12T20:39:16.241Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6e/c2/562aa1f086e53529ffbeb5b43d5d8bc42c1b968102b5e2163fad005ce298/google_genai-1.67.0-py3-none-any.whl", hash = "sha256:58b0484ff2d4335fa53c724b489e9f807fcca8115d9cdbd8fdf341121fbd6d2d", size = 733542, upload-time = "2026-03-12T20:39:14.615Z" },
]
[[package]]
name = "google-genai"
version = "1.73.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.14' and sys_platform != 'win32'",
"python_full_version == '3.13.*'",
]
dependencies = [
{ name = "anyio", marker = "python_full_version >= '3.13'" },
{ name = "distro", marker = "python_full_version >= '3.13'" },
{ name = "google-auth", extra = ["requests"], marker = "python_full_version >= '3.13'" },
{ name = "httpx", marker = "python_full_version >= '3.13'" },
{ name = "pydantic", marker = "python_full_version >= '3.13'" },
{ name = "requests", marker = "python_full_version >= '3.13'" },
{ name = "sniffio", marker = "python_full_version >= '3.13'" },
{ name = "tenacity", marker = "python_full_version >= '3.13'" },
{ name = "typing-extensions", marker = "python_full_version >= '3.13'" },
{ name = "websockets", marker = "python_full_version >= '3.13'" },
{ name = "anyio" },
{ name = "distro" },
{ name = "google-auth", extra = ["requests"] },
{ name = "httpx" },
{ name = "pydantic" },
{ name = "requests" },
{ name = "sniffio" },
{ name = "tenacity" },
{ name = "typing-extensions" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/d8/40f5f107e5a2976bbac52d421f04d14fc221b55a8f05e66be44b2f739fe6/google_genai-1.73.1.tar.gz", hash = "sha256:b637e3a3b9e2eccc46f27136d470165803de84eca52abfed2e7352081a4d5a15", size = 530998, upload-time = "2026-04-14T21:06:19.153Z" }
wheels = [
@@ -1913,6 +1959,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/68/72/a4611ed242a7936d38bd5262c6071fff8dda86cab1c06172a1e372783772/gradio_client-2.4.1-py3-none-any.whl", hash = "sha256:e6cef91ddee06e6a10788c5119977a35aee987968e7a87b0c4716809172c33d4", size = 59563, upload-time = "2026-04-10T20:57:11.774Z" },
]
[[package]]
name = "graphviz"
version = "0.21"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/b3/3ac91e9be6b761a4b30d66ff165e54439dcd48b83f4e20d644867215f6ca/graphviz-0.21.tar.gz", hash = "sha256:20743e7183be82aaaa8ad6c93f8893c923bd6658a04c32ee115edb3c8a835f78", size = 200434, upload-time = "2025-06-15T09:35:05.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-py3-none-any.whl", hash = "sha256:54f33de9f4f911d7e84e4191749cac8cc5653f815b06738c54db9a15ab8b1e42", size = 47300, upload-time = "2025-06-15T09:35:04.433Z" },
]
[[package]]
name = "greenlet"
version = "3.2.2"
@@ -2837,8 +2892,7 @@ dependencies = [
{ name = "async-timeout" },
{ name = "exceptiongroup" },
{ name = "google-cloud-storage" },
{ name = "google-genai", version = "1.67.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
{ name = "google-genai", version = "1.73.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" },
{ name = "google-genai" },
{ name = "ml-collections" },
{ name = "more-itertools" },
{ name = "numpy" },
@@ -4034,32 +4088,32 @@ wheels = [
[[package]]
name = "opentelemetry-api"
version = "1.35.0"
version = "1.41.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "importlib-metadata" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/99/c9/4509bfca6bb43220ce7f863c9f791e0d5001c2ec2b5867d48586008b3d96/opentelemetry_api-1.35.0.tar.gz", hash = "sha256:a111b959bcfa5b4d7dffc2fbd6a241aa72dd78dd8e79b5b1662bda896c5d2ffe", size = 64778, upload-time = "2025-07-11T12:23:28.804Z" }
sdist = { url = "https://files.pythonhosted.org/packages/fa/fc/b7564cbef36601aef0d6c9bc01f7badb64be8e862c2e1c3c5c3b43b53e4f/opentelemetry_api-1.41.1.tar.gz", hash = "sha256:0ad1814d73b875f84494387dae86ce0b12c68556331ce6ce8fe789197c949621", size = 71416, upload-time = "2026-04-24T13:15:38.262Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/5a/3f8d078dbf55d18442f6a2ecedf6786d81d7245844b2b20ce2b8ad6f0307/opentelemetry_api-1.35.0-py3-none-any.whl", hash = "sha256:c4ea7e258a244858daf18474625e9cc0149b8ee354f37843415771a40c25ee06", size = 65566, upload-time = "2025-07-11T12:23:07.944Z" },
{ url = "https://files.pythonhosted.org/packages/29/59/3e7118ed140f76b0982ba4321bdaed1997a0473f9720de2d10788a577033/opentelemetry_api-1.41.1-py3-none-any.whl", hash = "sha256:a22df900e75c76dc08440710e51f52f1aa6b451b429298896023e60db5b3139f", size = 69007, upload-time = "2026-04-24T13:15:15.662Z" },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-common"
version = "1.35.0"
version = "1.41.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-proto" },
]
sdist = { url = "https://files.pythonhosted.org/packages/56/d1/887f860529cba7fc3aba2f6a3597fefec010a17bd1b126810724707d9b51/opentelemetry_exporter_otlp_proto_common-1.35.0.tar.gz", hash = "sha256:6f6d8c39f629b9fa5c79ce19a2829dbd93034f8ac51243cdf40ed2196f00d7eb", size = 20299, upload-time = "2025-07-11T12:23:31.046Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ae/fa/f9e3bd3c4d692b3ce9a2880a167d1f79681a1bea11f00d5bf76adc03e6ea/opentelemetry_exporter_otlp_proto_common-1.41.1.tar.gz", hash = "sha256:0e253156ea9c36b0bd3d2440c5c9ba7dd1f3fb64ba7a08fc85fbac536b56e1fb", size = 20409, upload-time = "2026-04-24T13:15:40.924Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/2c/e31dd3c719bff87fa77391eb7f38b1430d22868c52312cba8aad60f280e5/opentelemetry_exporter_otlp_proto_common-1.35.0-py3-none-any.whl", hash = "sha256:863465de697ae81279ede660f3918680b4480ef5f69dcdac04f30722ed7b74cc", size = 18349, upload-time = "2025-07-11T12:23:11.713Z" },
{ url = "https://files.pythonhosted.org/packages/29/48/bce76d3ea772b609757e9bc844e02ab408a6446609bf74fb562062ba6b71/opentelemetry_exporter_otlp_proto_common-1.41.1-py3-none-any.whl", hash = "sha256:10da74dad6a49344b9b7b21b6182e3060373a235fde1528616d5f01f92e66aa9", size = 18366, upload-time = "2026-04-24T13:15:18.917Z" },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-grpc"
version = "1.35.0"
version = "1.41.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "googleapis-common-protos" },
@@ -4070,14 +4124,14 @@ dependencies = [
{ name = "opentelemetry-sdk" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/20/de/222e4f2f8cd39250991f84d76b661534aef457cafc6a3eb3fcd513627698/opentelemetry_exporter_otlp_proto_grpc-1.35.0.tar.gz", hash = "sha256:ac4c2c3aa5674642db0df0091ab43ec08bbd91a9be469c8d9b18923eb742b9cc", size = 23794, upload-time = "2025-07-11T12:23:31.662Z" }
sdist = { url = "https://files.pythonhosted.org/packages/1e/9b/e4503060b8695579dbaad187dc8cef4554188de68748c88060599b77489e/opentelemetry_exporter_otlp_proto_grpc-1.41.1.tar.gz", hash = "sha256:b05df8fa1333dc9a3fda36b676b96b5095ab6016d3f0c3296d430d629ba1443b", size = 25755, upload-time = "2026-04-24T13:15:41.93Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/a6/3f60a77279e6a3dc21fc076dcb51be159a633b0bba5cba9fb804062a9332/opentelemetry_exporter_otlp_proto_grpc-1.35.0-py3-none-any.whl", hash = "sha256:ee31203eb3e50c7967b8fa71db366cc355099aca4e3726e489b248cdb2fd5a62", size = 18846, upload-time = "2025-07-11T12:23:12.957Z" },
{ url = "https://files.pythonhosted.org/packages/ac/f2/c54f33c92443d087703e57e52e55f22f111373a5c4c4aa349ea60efe512e/opentelemetry_exporter_otlp_proto_grpc-1.41.1-py3-none-any.whl", hash = "sha256:537926dcef951136992479af1d9cd88f25e33d56c530e9f020ed57774dca2f94", size = 20297, upload-time = "2026-04-24T13:15:20.212Z" },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-http"
version = "1.35.0"
version = "1.41.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "googleapis-common-protos" },
@@ -4088,14 +4142,14 @@ dependencies = [
{ name = "requests" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/88/7f/7bdc06e84266a5b4b0fefd9790b3859804bf7682ce2daabcba2e22fdb3b2/opentelemetry_exporter_otlp_proto_http-1.35.0.tar.gz", hash = "sha256:cf940147f91b450ef5f66e9980d40eb187582eed399fa851f4a7a45bb880de79", size = 15908, upload-time = "2025-07-11T12:23:32.335Z" }
sdist = { url = "https://files.pythonhosted.org/packages/33/5b/9d3c7f70cca10136ba82a81e738dee626c8e7fc61c6887ea9a58bf34c606/opentelemetry_exporter_otlp_proto_http-1.41.1.tar.gz", hash = "sha256:4747a9604c8550ab38c6fd6180e2fcb80de3267060bef2c306bad3cb443302bc", size = 24139, upload-time = "2026-04-24T13:15:42.977Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/71/f118cd90dc26797077931dd598bde5e0cc652519db166593f962f8fcd022/opentelemetry_exporter_otlp_proto_http-1.35.0-py3-none-any.whl", hash = "sha256:9a001e3df3c7f160fb31056a28ed7faa2de7df68877ae909516102ae36a54e1d", size = 18589, upload-time = "2025-07-11T12:23:13.906Z" },
{ url = "https://files.pythonhosted.org/packages/ba/4d/ef07ff2fc630849f2080ae0ae73a61f67257905b7ac79066640bfa0c5739/opentelemetry_exporter_otlp_proto_http-1.41.1-py3-none-any.whl", hash = "sha256:1a21e8f49c7a946d935551e90947d6c3eb39236723c6624401da0f33d68edcb4", size = 22673, upload-time = "2026-04-24T13:15:21.313Z" },
]
[[package]]
name = "opentelemetry-instrumentation"
version = "0.56b0"
version = "0.62b1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
@@ -4103,48 +4157,48 @@ dependencies = [
{ name = "packaging" },
{ name = "wrapt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bb/14/964e90f524655aed5c699190dad8dd9a05ed0f5fa334b4b33532237c2b51/opentelemetry_instrumentation-0.56b0.tar.gz", hash = "sha256:d2dbb3021188ca0ec8c5606349ee9a2919239627e8341d4d37f1d21ec3291d11", size = 28551, upload-time = "2025-07-11T12:26:19.305Z" }
sdist = { url = "https://files.pythonhosted.org/packages/52/cb/0523b92c112a6cc70be43724343dc45225d3af134419844d7879a07755d4/opentelemetry_instrumentation-0.62b1.tar.gz", hash = "sha256:90e92a905ba4f84db06ac3aec96701df6c079b2d66e9379f8739f0a1bdcc7f45", size = 34043, upload-time = "2026-04-24T13:22:31.997Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/26/aa/2328f27200b8e51640d4d7ff5343ba6a81ab7d2650a9f574db016aae4adf/opentelemetry_instrumentation-0.56b0-py3-none-any.whl", hash = "sha256:948967f7c8f5bdc6e43512ba74c9ae14acb48eb72a35b61afe8db9909f743be3", size = 31105, upload-time = "2025-07-11T12:25:22.788Z" },
{ url = "https://files.pythonhosted.org/packages/4d/0f/45adbaea1f81b847cffdcee4f4b5f89297e42facf7fac78c7aaac4c38e75/opentelemetry_instrumentation-0.62b1-py3-none-any.whl", hash = "sha256:976fc6e640f2006599e97429c949e622c108d0c17c2059347d1e6c93c707f257", size = 34163, upload-time = "2026-04-24T13:21:31.722Z" },
]
[[package]]
name = "opentelemetry-proto"
version = "1.35.0"
version = "1.41.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dc/a2/7366e32d9a2bccbb8614942dbea2cf93c209610385ea966cb050334f8df7/opentelemetry_proto-1.35.0.tar.gz", hash = "sha256:532497341bd3e1c074def7c5b00172601b28bb83b48afc41a4b779f26eb4ee05", size = 46151, upload-time = "2025-07-11T12:23:38.797Z" }
sdist = { url = "https://files.pythonhosted.org/packages/99/e8/633c6d8a9c8840338b105907e55c32d3da1983abab5e52f899f72a82c3d1/opentelemetry_proto-1.41.1.tar.gz", hash = "sha256:4b9d2eb631237ea43b80e16c073af438554e32bc7e9e3f8ca4a9582f900020e5", size = 45670, upload-time = "2026-04-24T13:15:49.768Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/a7/3f05de580da7e8a8b8dff041d3d07a20bf3bb62d3bcc027f8fd669a73ff4/opentelemetry_proto-1.35.0-py3-none-any.whl", hash = "sha256:98fffa803164499f562718384e703be8d7dfbe680192279a0429cb150a2f8809", size = 72536, upload-time = "2025-07-11T12:23:23.247Z" },
{ url = "https://files.pythonhosted.org/packages/e4/1e/5cd77035e3e82070e2265a63a760f715aacd3cb16dddc7efee913f297fcc/opentelemetry_proto-1.41.1-py3-none-any.whl", hash = "sha256:0496713b804d127a4147e32849fbaf5683fac8ee98550e8e7679cd706c289720", size = 72076, upload-time = "2026-04-24T13:15:32.542Z" },
]
[[package]]
name = "opentelemetry-sdk"
version = "1.35.0"
version = "1.41.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9a/cf/1eb2ed2ce55e0a9aa95b3007f26f55c7943aeef0a783bb006bdd92b3299e/opentelemetry_sdk-1.35.0.tar.gz", hash = "sha256:2a400b415ab68aaa6f04e8a6a9f6552908fb3090ae2ff78d6ae0c597ac581954", size = 160871, upload-time = "2025-07-11T12:23:39.566Z" }
sdist = { url = "https://files.pythonhosted.org/packages/58/d0/54ee30dab82fb0acda23d144502771ff76ef8728459c83c3e89ef9fb1825/opentelemetry_sdk-1.41.1.tar.gz", hash = "sha256:724b615e1215b5aeacda0abb8a6a8922c9a1853068948bd0bd225a56d0c792e6", size = 230180, upload-time = "2026-04-24T13:15:50.991Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/01/4f/8e32b757ef3b660511b638ab52d1ed9259b666bdeeceba51a082ce3aea95/opentelemetry_sdk-1.35.0-py3-none-any.whl", hash = "sha256:223d9e5f5678518f4842311bb73966e0b6db5d1e0b74e35074c052cd2487f800", size = 119379, upload-time = "2025-07-11T12:23:24.521Z" },
{ url = "https://files.pythonhosted.org/packages/b4/e7/a1420b698aad018e1cf60fdbaaccbe49021fb415e2a0d81c242f4c518f54/opentelemetry_sdk-1.41.1-py3-none-any.whl", hash = "sha256:edee379c126c1bce952b0c812b48fe8ff35b30df0eecf17e98afa4d598b7d85d", size = 180213, upload-time = "2026-04-24T13:15:33.767Z" },
]
[[package]]
name = "opentelemetry-semantic-conventions"
version = "0.56b0"
version = "0.62b1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/32/8e/214fa817f63b9f068519463d8ab46afd5d03b98930c39394a37ae3e741d0/opentelemetry_semantic_conventions-0.56b0.tar.gz", hash = "sha256:c114c2eacc8ff6d3908cb328c811eaf64e6d68623840be9224dc829c4fd6c2ea", size = 124221, upload-time = "2025-07-11T12:23:40.71Z" }
sdist = { url = "https://files.pythonhosted.org/packages/9e/de/911ac9e309052aca1b20b2d5549d3db45d1011e1a610e552c6ccdd1b64f8/opentelemetry_semantic_conventions-0.62b1.tar.gz", hash = "sha256:c5cc6e04a7f8c7cdd30be2ed81499fa4e75bfbd52c9cb70d40af1f9cd3619802", size = 145750, upload-time = "2026-04-24T13:15:52.236Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/3f/e80c1b017066a9d999efffe88d1cce66116dcf5cb7f80c41040a83b6e03b/opentelemetry_semantic_conventions-0.56b0-py3-none-any.whl", hash = "sha256:df44492868fd6b482511cc43a942e7194be64e94945f572db24df2e279a001a2", size = 201625, upload-time = "2025-07-11T12:23:25.63Z" },
{ url = "https://files.pythonhosted.org/packages/eb/a6/83dc2ab6fa397ee66fba04fe2e74bdf7be3b3870005359ceb7689103c058/opentelemetry_semantic_conventions-0.62b1-py3-none-any.whl", hash = "sha256:cf506938103d331fbb78eded0d9788095f7fd59016f2bda813c3324e5a74a93c", size = 231620, upload-time = "2026-04-24T13:15:35.454Z" },
]
[[package]]
@@ -4929,7 +4983,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.6.41"
version = "1.6.43"
source = { directory = "../praisonai-agents" }
dependencies = [
{ name = "aiohttp" },
@@ -4942,6 +4996,8 @@ dependencies = [
[package.optional-dependencies]
all = [
{ name = "a2a-sdk" },
{ name = "a2ui-agent-sdk" },
{ name = "ast-grep-cli" },
{ name = "chonkie" },
{ name = "chromadb" },
@@ -4981,6 +5037,8 @@ search = [
[package.metadata]
requires-dist = [
{ name = "a2a-sdk", marker = "extra == 'a2ui'", specifier = ">=0.3.0,<1.0.0" },
{ name = "a2ui-agent-sdk", marker = "extra == 'a2ui'", specifier = ">=0.2.1" },
{ name = "aiohttp", specifier = ">=3.8.0" },
{ name = "ast-grep-cli", marker = "extra == 'autonomy'", specifier = ">=0.30.0" },
{ name = "chonkie", marker = "extra == 'knowledge'", specifier = ">=1.0.2" },
@@ -5006,6 +5064,7 @@ requires-dist = [
{ name = "playwright", marker = "extra == 'crawl'", specifier = ">=1.47.0" },
{ name = "posthog", specifier = ">=3.0.0" },
{ name = "posthog", marker = "extra == 'telemetry'", specifier = ">=3.0.0" },
{ name = "praisonaiagents", extras = ["a2ui"], marker = "extra == 'all'" },
{ name = "praisonaiagents", extras = ["api"], marker = "extra == 'all'" },
{ name = "praisonaiagents", extras = ["auth"], marker = "extra == 'all'" },
{ name = "praisonaiagents", extras = ["autonomy"], marker = "extra == 'all'" },
@@ -5034,7 +5093,7 @@ requires-dist = [
{ name = "uvicorn", marker = "extra == 'os'", specifier = ">=0.34.0" },
{ name = "websockets", marker = "extra == 'mcp'", specifier = ">=12.0" },
]
provides-extras = ["mcp", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "auth", "autonomy", "search", "crawl", "sandbox", "sandbox-docker", "all"]
provides-extras = ["mcp", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "auth", "autonomy", "search", "crawl", "sandbox", "a2ui", "sandbox-docker", "all"]
[[package]]
name = "pre-commit"
@@ -5156,16 +5215,16 @@ wheels = [
[[package]]
name = "protobuf"
version = "5.29.4"
version = "5.29.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/17/7d/b9dca7365f0e2c4fa7c193ff795427cfa6290147e5185ab11ece280a18e7/protobuf-5.29.4.tar.gz", hash = "sha256:4f1dfcd7997b31ef8f53ec82781ff434a28bf71d9102ddde14d076adcfc78c99", size = 424902, upload-time = "2025-03-19T21:23:24.25Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/57/394a763c103e0edf87f0938dafcd918d53b4c011dfc5c8ae80f3b0452dbb/protobuf-5.29.6.tar.gz", hash = "sha256:da9ee6a5424b6b30fd5e45c5ea663aef540ca95f9ad99d1e887e819cdf9b8723", size = 425623, upload-time = "2026-02-04T22:54:40.584Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/b2/043a1a1a20edd134563699b0e91862726a0dc9146c090743b6c44d798e75/protobuf-5.29.4-cp310-abi3-win32.whl", hash = "sha256:13eb236f8eb9ec34e63fc8b1d6efd2777d062fa6aaa68268fb67cf77f6839ad7", size = 422709, upload-time = "2025-03-19T21:23:08.293Z" },
{ url = "https://files.pythonhosted.org/packages/79/fc/2474b59570daa818de6124c0a15741ee3e5d6302e9d6ce0bdfd12e98119f/protobuf-5.29.4-cp310-abi3-win_amd64.whl", hash = "sha256:bcefcdf3976233f8a502d265eb65ea740c989bacc6c30a58290ed0e519eb4b8d", size = 434506, upload-time = "2025-03-19T21:23:11.253Z" },
{ url = "https://files.pythonhosted.org/packages/46/de/7c126bbb06aa0f8a7b38aaf8bd746c514d70e6a2a3f6dd460b3b7aad7aae/protobuf-5.29.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:307ecba1d852ec237e9ba668e087326a67564ef83e45a0189a772ede9e854dd0", size = 417826, upload-time = "2025-03-19T21:23:13.132Z" },
{ url = "https://files.pythonhosted.org/packages/a2/b5/bade14ae31ba871a139aa45e7a8183d869efe87c34a4850c87b936963261/protobuf-5.29.4-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:aec4962f9ea93c431d5714ed1be1c93f13e1a8618e70035ba2b0564d9e633f2e", size = 319574, upload-time = "2025-03-19T21:23:14.531Z" },
{ url = "https://files.pythonhosted.org/packages/46/88/b01ed2291aae68b708f7d334288ad5fb3e7aa769a9c309c91a0d55cb91b0/protobuf-5.29.4-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:d7d3f7d1d5a66ed4942d4fefb12ac4b14a29028b209d4bfb25c68ae172059922", size = 319672, upload-time = "2025-03-19T21:23:15.839Z" },
{ url = "https://files.pythonhosted.org/packages/12/fb/a586e0c973c95502e054ac5f81f88394f24ccc7982dac19c515acd9e2c93/protobuf-5.29.4-py3-none-any.whl", hash = "sha256:3fde11b505e1597f71b875ef2fc52062b6a9740e5f7c8997ce878b6009145862", size = 172551, upload-time = "2025-03-19T21:23:22.682Z" },
{ url = "https://files.pythonhosted.org/packages/d4/88/9ee58ff7863c479d6f8346686d4636dd4c415b0cbeed7a6a7d0617639c2a/protobuf-5.29.6-cp310-abi3-win32.whl", hash = "sha256:62e8a3114992c7c647bce37dcc93647575fc52d50e48de30c6fcb28a6a291eb1", size = 423357, upload-time = "2026-02-04T22:54:25.805Z" },
{ url = "https://files.pythonhosted.org/packages/1c/66/2dc736a4d576847134fb6d80bd995c569b13cdc7b815d669050bf0ce2d2c/protobuf-5.29.6-cp310-abi3-win_amd64.whl", hash = "sha256:7e6ad413275be172f67fdee0f43484b6de5a904cc1c3ea9804cb6fe2ff366eda", size = 435175, upload-time = "2026-02-04T22:54:28.592Z" },
{ url = "https://files.pythonhosted.org/packages/06/db/49b05966fd208ae3f44dcd33837b6243b4915c57561d730a43f881f24dea/protobuf-5.29.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:b5a169e664b4057183a34bdc424540e86eea47560f3c123a0d64de4e137f9269", size = 418619, upload-time = "2026-02-04T22:54:30.266Z" },
{ url = "https://files.pythonhosted.org/packages/b7/d7/48cbf6b0c3c39761e47a99cb483405f0fde2be22cf00d71ef316ce52b458/protobuf-5.29.6-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:a8866b2cff111f0f863c1b3b9e7572dc7eaea23a7fae27f6fc613304046483e6", size = 320284, upload-time = "2026-02-04T22:54:31.782Z" },
{ url = "https://files.pythonhosted.org/packages/e3/dd/cadd6ec43069247d91f6345fa7a0d2858bef6af366dbd7ba8f05d2c77d3b/protobuf-5.29.6-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:e3387f44798ac1106af0233c04fb8abf543772ff241169946f698b3a9a3d3ab9", size = 320478, upload-time = "2026-02-04T22:54:32.909Z" },
{ url = "https://files.pythonhosted.org/packages/5a/cb/e3065b447186cb70aa65acc70c86baf482d82bf75625bf5a2c4f6919c6a3/protobuf-5.29.6-py3-none-any.whl", hash = "sha256:6b9edb641441b2da9fa8f428760fc136a49cf97a52076010cf22a2ff73438a86", size = 173126, upload-time = "2026-02-04T22:54:39.462Z" },
]
[[package]]
@@ -6179,7 +6238,7 @@ wheels = [
[[package]]
name = "requests"
version = "2.32.3"
version = "2.34.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -6187,9 +6246,9 @@ dependencies = [
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" },
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
]
[[package]]
@@ -6811,11 +6870,11 @@ wheels = [
[[package]]
name = "tenacity"
version = "8.5.0"
version = "9.1.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/4d/6a19536c50b849338fcbe9290d562b52cbdcf30d8963d3588a68a4107df1/tenacity-8.5.0.tar.gz", hash = "sha256:8bc6c0c8a09b31e6cad13c47afbed1a567518250a9a171418582ed8d9c20ca78", size = 47309, upload-time = "2024-07-05T07:25:31.836Z" }
sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/3f/8ba87d9e287b9d385a02a7114ddcef61b26f86411e121c9003eb509a1773/tenacity-8.5.0-py3-none-any.whl", hash = "sha256:b594c2a5945830c267ce6b79a166228323ed52718f30302c1359836112346687", size = 28165, upload-time = "2024-07-05T07:25:29.591Z" },
{ url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" },
]
[[package]]
@@ -7253,6 +7312,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a6/ab/7e5f53c3b9d14972843a647d8d7a853969a58aecc7559cb3267302c94774/tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd", size = 346586, upload-time = "2024-09-23T18:56:45.478Z" },
]
[[package]]
name = "tzlocal"
version = "5.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" },
]
[[package]]
name = "uc-micro-py"
version = "1.0.3"
@@ -7421,6 +7492,38 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" },
]
[[package]]
name = "watchdog"
version = "6.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" },
{ url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" },
{ url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" },
{ url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" },
{ url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" },
{ url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" },
{ url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" },
{ url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" },
{ url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" },
{ url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" },
{ url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" },
{ url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" },
{ url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" },
{ url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" },
{ url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" },
{ url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" },
{ url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" },
{ url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" },
{ url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" },
{ url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" },
{ url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" },
{ url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" },
{ url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" },
{ url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
]
[[package]]
name = "watchfiles"
version = "0.24.0"
@@ -7518,70 +7621,61 @@ wheels = [
[[package]]
name = "websockets"
version = "16.0"
version = "15.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" },
{ url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" },
{ url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" },
{ url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" },
{ url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" },
{ url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" },
{ url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" },
{ url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" },
{ url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" },
{ url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" },
{ url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" },
{ url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" },
{ url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" },
{ url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" },
{ url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" },
{ url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" },
{ url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" },
{ url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" },
{ url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
{ url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
{ url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
{ url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
{ url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
{ url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
{ url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
{ url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
{ url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
{ url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
{ url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
{ url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
{ url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
{ url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
{ url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
{ url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" },
{ url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" },
{ url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" },
{ url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" },
{ url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
{ url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" },
{ url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" },
{ url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" },
{ url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" },
{ url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" },
{ url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" },
{ url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" },
{ url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" },
{ url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" },
{ url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" },
{ url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" },
{ url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" },
{ url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" },
{ url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" },
{ url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" },
{ url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" },
{ url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" },
{ url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" },
{ url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" },
{ url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" },
{ url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" },
{ url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" },
{ url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" },
{ url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" },
{ url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" },
{ url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" },
{ url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" },
{ url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" },
{ url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" },
{ url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" },
{ url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" },
{ url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" },
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" },
{ url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" },
{ url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" },
{ url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" },
{ url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" },
{ url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" },
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
]
[[package]]