mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-10 11:41:32 -10:00
Compare commits
31
Commits
v4.6.41
...
add-valkey
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 != ''
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]"
|
||||
|
||||
|
||||
@@ -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]" \
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
Generated
+562
-299
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,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,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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 @@
|
||||
__version__ = "4.6.41"
|
||||
__version__ = "4.6.43"
|
||||
@@ -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()
|
||||
|
||||
Generated
+245
-151
@@ -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]]
|
||||
|
||||
Reference in new issue
Block a user