mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-12 01:41:26 -10:00
Compare commits
43
Commits
v4.6.40
...
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
|
||||
|
||||
@@ -29,9 +29,6 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate GitHub App Token
|
||||
@@ -63,10 +60,12 @@ jobs:
|
||||
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
PRAISONAI_AUTO_APPROVE: "true"
|
||||
ISSUE_NUMBER_INPUT: ${{ inputs.issue_number }}
|
||||
ISSUE_NUMBER_EVENT: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
export ISSUE_NUMBER="${{ inputs.issue_number }}"
|
||||
export ISSUE_NUMBER="$ISSUE_NUMBER_INPUT"
|
||||
else
|
||||
export ISSUE_NUMBER="${{ github.event.issue.number }}"
|
||||
export ISSUE_NUMBER="$ISSUE_NUMBER_EVENT"
|
||||
fi
|
||||
praisonai github triage --issue $ISSUE_NUMBER --agent-file .github/praisonai-issue-triage.yaml
|
||||
@@ -30,22 +30,23 @@ jobs:
|
||||
steps:
|
||||
- name: Determine checkout ref
|
||||
id: dest
|
||||
env:
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
PR_NUMBER_INPUT: ${{ inputs.pr_number }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "issue_comment" ]; then
|
||||
echo "ref=refs/pull/${{ github.event.issue.number }}/head" >> "$GITHUB_OUTPUT"
|
||||
echo "ref=refs/pull/$ISSUE_NUMBER/head" >> "$GITHUB_OUTPUT"
|
||||
elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "ref=refs/pull/${{ inputs.pr_number }}/head" >> "$GITHUB_OUTPUT"
|
||||
echo "ref=refs/pull/$PR_NUMBER_INPUT/head" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "ref=${{ github.event.pull_request.head.sha }}" >> "$GITHUB_OUTPUT"
|
||||
echo "ref=$PR_HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
ref: ${{ steps.dest.outputs.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -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 != ''
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
# Security release checklist (maintainer)
|
||||
|
||||
After merging security PRs [#1684](https://github.com/MervinPraison/PraisonAI/pull/1684) and [#1685](https://github.com/MervinPraison/PraisonAI/pull/1685):
|
||||
|
||||
## 1. Publish PyPI (requires `PYPI_TOKEN`)
|
||||
|
||||
```bash
|
||||
cd src/praisonai-agents
|
||||
export PYPI_TOKEN=...
|
||||
praisonai publish pypi
|
||||
|
||||
cd ../praisonai
|
||||
python scripts/bump_and_release.py <WRAPPER_VERSION> --agents <AGENTS_VERSION> --wait
|
||||
```
|
||||
|
||||
## 2. Publish GitHub security advisories
|
||||
|
||||
Set versions to match PyPI, then:
|
||||
|
||||
```bash
|
||||
AGENTS_VERSION=1.6.40 WRAPPER_VERSION=4.6.40 ./scripts/security_publish_advisories.sh
|
||||
```
|
||||
|
||||
Add reporter `credits` per advisory via `gh api` before publishing if not already set.
|
||||
|
||||
## 3. Verify
|
||||
|
||||
```bash
|
||||
pip index versions praisonaiagents | head -1
|
||||
pip index versions praisonai | head -1
|
||||
gh api repos/MervinPraison/PraisonAI/security-advisories --jq '.[] | "\(.ghsa_id) \(.state) \(.cve_id // "pending")"'
|
||||
```
|
||||
|
||||
Resources: https://github.com/MervinPraison/PraisonAI · https://docs.praison.ai · https://praison.ai
|
||||
@@ -1,26 +0,0 @@
|
||||
# Security advisory triage (maintainer)
|
||||
|
||||
| GHSA | Status after batch 1+2 | Notes |
|
||||
|------|------------------------|-------|
|
||||
| GHSA-4mr5-g6f9-cfrh | fixed batch 1 | Sandbox AST |
|
||||
| GHSA-5c6w-wwfq-7qqm | fixed batch 1 | spider_tools SSRF |
|
||||
| GHSA-9cr9-25q5-8prj | fixed batch 1 | MCP yaml paths |
|
||||
| GHSA-86qc-r5v2-v6x6 | fixed batch 1 | call server auth |
|
||||
| GHSA-hvhp-v2gc-268q | fixed batch 1 | write_file cwd |
|
||||
| GHSA-5cxw-77wg-jrf3 | fixed batch 1 | @url mentions |
|
||||
| GHSA-xp85-6wwf-r67c | fixed batch 1 | GHA branch quote |
|
||||
| GHSA-3qg8-5g3r-79v5 | fixed batch 1+2 | JWT + issue guard |
|
||||
| GHSA-xwq8, 7p8g, w388, g8rr | fixed batch 1 | platform RBAC/IDOR partial |
|
||||
| GHSA-943m, 5jx9, 4x6r, 27p4, cp4f | fixed batch 2 | platform IDOR completion |
|
||||
| GHSA-vg22-4gmj-prxw | fixed batch 2 | example eval hardening |
|
||||
| GHSA-8444-4fhq-fxpq | already-fixed | APIConfig.auth_enabled default True |
|
||||
| GHSA-78r8-wwqv-r299 | already-fixed | load_user_module gate |
|
||||
| GHSA-gv23-xxxx-xxxx, GHSA-h8q5-cp56-rr65, GHSA-6h6v-xxxx-xxxx, GHSA-h37g-4h4p-9x97 | fixed batch 3 | service-layer workspace_id on get/update/delete |
|
||||
| GHSA-c2m8-xxxx-xxxx, GHSA-8g2p-xxxx-xxxx | fixed batch 3 | only owner assigns admin/owner |
|
||||
| GHSA-h8q5-cp56-rr65 | fixed batch 3 | bind default 127.0.0.1 (+ PLATFORM_HOST) |
|
||||
| GHSA-6xj3-927j-6pqw | not-applicable | Open WebUI path; not in this repo |
|
||||
| GHSA-gmjg, 9q28 | published | prior release |
|
||||
|
||||
**Code fixed on main; GHSA state still triage until PyPI publish + advisory publish.**
|
||||
|
||||
Resources: https://github.com/MervinPraison/PraisonAI · https://docs.praison.ai · https://praison.ai
|
||||
@@ -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.40" \
|
||||
"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.40" \
|
||||
"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.40" \
|
||||
"praisonai>=4.6.43" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Publish security advisories after PyPI release.
|
||||
# Usage: AGENTS_VERSION=1.6.40 WRAPPER_VERSION=4.6.40 ./scripts/security_publish_advisories.sh
|
||||
set -euo pipefail
|
||||
|
||||
REPO="MervinPraison/PraisonAI"
|
||||
AGENTS_VERSION="${AGENTS_VERSION:?Set AGENTS_VERSION (e.g. 1.6.40)}"
|
||||
WRAPPER_VERSION="${WRAPPER_VERSION:?Set WRAPPER_VERSION (e.g. 4.6.40)}"
|
||||
LAST_AGENTS="${LAST_AGENTS:-1.6.39}"
|
||||
LAST_WRAPPER="${LAST_WRAPPER:-4.6.39}"
|
||||
|
||||
# Batch 1 + 2 GHSA IDs (update credits login per advisory via gh api first if needed)
|
||||
AGENTS_GHSAS=(
|
||||
GHSA-4mr5-g6f9-cfrh
|
||||
GHSA-5c6w-wwfq-7qqm
|
||||
GHSA-5cxw-77wg-jrf3
|
||||
)
|
||||
|
||||
WRAPPER_GHSAS=(
|
||||
GHSA-9cr9-25q5-8prj
|
||||
GHSA-86qc-r5v2-v6x6
|
||||
GHSA-hvhp-v2gc-268q
|
||||
GHSA-xp85-6wwf-r67c
|
||||
GHSA-8444-4fhq-fxpq
|
||||
GHSA-78r8-wwqv-r299
|
||||
GHSA-vg22-4gmj-prxw
|
||||
GHSA-6xj3-927j-6pqw
|
||||
)
|
||||
|
||||
patch_advisory() {
|
||||
local ghsa="$1" package="$2" last="$3" patched="$4"
|
||||
gh api "repos/${REPO}/security-advisories/${ghsa}" --method PATCH --input - <<EOF
|
||||
{
|
||||
"state": "draft",
|
||||
"vulnerabilities": [
|
||||
{
|
||||
"package": {"ecosystem": "pip", "name": "${package}"},
|
||||
"vulnerable_version_range": "<= ${last}",
|
||||
"patched_versions": ">= ${patched}"
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
gh api "repos/${REPO}/security-advisories/${ghsa}" --method PATCH -f state=published
|
||||
gh api "repos/${REPO}/security-advisories/${ghsa}/cve" --method POST || true
|
||||
echo "Published ${ghsa}"
|
||||
}
|
||||
|
||||
for ghsa in "${AGENTS_GHSAS[@]}"; do
|
||||
patch_advisory "$ghsa" "praisonaiagents" "$LAST_AGENTS" "$AGENTS_VERSION"
|
||||
done
|
||||
|
||||
for ghsa in "${WRAPPER_GHSAS[@]}"; do
|
||||
patch_advisory "$ghsa" "praisonai" "$LAST_WRAPPER" "$WRAPPER_VERSION"
|
||||
done
|
||||
|
||||
echo "Done. Verify: gh api repos/${REPO}/security-advisories --jq '.[] | select(.state==\"published\") | .ghsa_id'"
|
||||
@@ -21,6 +21,7 @@ from .chat_handler import ChatHandlerMixin
|
||||
from .session_manager import SessionManagerMixin
|
||||
from .async_safety import AsyncSafeState
|
||||
from .unified_execution_mixin import UnifiedExecutionMixin
|
||||
from .sandbox_mixin import SandboxMixin
|
||||
|
||||
# Module-level logger for thread safety errors and debugging
|
||||
logger = get_logger(__name__)
|
||||
@@ -255,7 +256,7 @@ if TYPE_CHECKING:
|
||||
# Import structured error from central errors module
|
||||
from ..errors import BudgetExceededError
|
||||
|
||||
class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin, ExecutionMixin, MemoryMixin, AsyncMemoryMixin):
|
||||
class Agent(SandboxMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin, ExecutionMixin, MemoryMixin, AsyncMemoryMixin):
|
||||
# Class-level counter for generating unique display names for nameless agents
|
||||
_agent_counter = 0
|
||||
_agent_counter_lock = threading.Lock()
|
||||
@@ -555,6 +556,7 @@ class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, Session
|
||||
backend: Optional[Any] = None, # External managed agent backend (e.g., ManagedAgentIntegration)
|
||||
cli_backend: Optional[Union[str, Any]] = None, # CLI backend for delegating turns (e.g., "claude-code")
|
||||
interrupt_controller: Optional['InterruptController'] = None, # G2: Cooperative cancellation
|
||||
sandbox: Optional[Union[bool, 'SandboxConfig']] = None, # Sandbox for safe code execution
|
||||
):
|
||||
"""Initialize an Agent instance.
|
||||
|
||||
@@ -1946,6 +1948,9 @@ Your Goal: {self.goal}
|
||||
# Telemetry - lazy initialized via property for performance
|
||||
self.__telemetry = None
|
||||
self.__telemetry_initialized = False
|
||||
|
||||
# Sandbox configuration - initialize SandboxMixin
|
||||
super().__init__(sandbox=sandbox)
|
||||
|
||||
@property
|
||||
def _telemetry(self):
|
||||
|
||||
@@ -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,231 @@
|
||||
"""
|
||||
Sandbox execution mixin for Agent.
|
||||
|
||||
Provides safe code execution capabilities to agents via the sandbox framework.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, Union
|
||||
|
||||
from ..sandbox import (
|
||||
SandboxConfig,
|
||||
SandboxManager,
|
||||
SandboxResult,
|
||||
check_code_safety,
|
||||
format_warnings,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SandboxMixin:
|
||||
"""Mixin that adds sandbox execution capabilities to Agent.
|
||||
|
||||
Provides safe code execution in isolated environments with security pre-checks.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Extract sandbox config from kwargs
|
||||
self.sandbox_config: Optional[SandboxConfig] = kwargs.pop('sandbox', None)
|
||||
|
||||
# Convert boolean True to default config
|
||||
if self.sandbox_config is True:
|
||||
self.sandbox_config = SandboxConfig.subprocess()
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self._sandbox_manager: Optional[SandboxManager] = None
|
||||
|
||||
@property
|
||||
def has_sandbox(self) -> bool:
|
||||
"""Check if sandbox is configured for this agent."""
|
||||
return self.sandbox_config is not None
|
||||
|
||||
def get_sandbox_manager(self) -> Optional[SandboxManager]:
|
||||
"""Get or create sandbox manager."""
|
||||
if not self.has_sandbox:
|
||||
return None
|
||||
|
||||
if self._sandbox_manager is None:
|
||||
self._sandbox_manager = SandboxManager(self.sandbox_config)
|
||||
|
||||
return self._sandbox_manager
|
||||
|
||||
async def execute_code(
|
||||
self,
|
||||
code: str,
|
||||
language: str = "python",
|
||||
check_security: bool = True,
|
||||
**kwargs
|
||||
) -> SandboxResult:
|
||||
"""Execute code safely in configured sandbox.
|
||||
|
||||
Args:
|
||||
code: Code to execute
|
||||
language: Programming language (python, bash, etc.)
|
||||
check_security: Whether to run security pre-checks
|
||||
**kwargs: Additional arguments passed to sandbox.execute()
|
||||
|
||||
Returns:
|
||||
SandboxResult with execution details
|
||||
|
||||
Raises:
|
||||
RuntimeError: If no sandbox is configured
|
||||
"""
|
||||
if not self.has_sandbox:
|
||||
raise RuntimeError(
|
||||
"No sandbox configured. Set sandbox=True or provide SandboxConfig to Agent()"
|
||||
)
|
||||
|
||||
# Security pre-check if enabled
|
||||
if check_security:
|
||||
warnings = check_code_safety(code, language)
|
||||
if warnings:
|
||||
warning_text = format_warnings(warnings)
|
||||
if self.verbose:
|
||||
logger.warning(f"Security warnings for code execution:\n{warning_text}")
|
||||
|
||||
# Store warnings in result metadata
|
||||
kwargs.setdefault('metadata', {})['security_warnings'] = warnings
|
||||
|
||||
manager = self.get_sandbox_manager()
|
||||
return await manager.run_code(code, language=language, **kwargs)
|
||||
|
||||
def execute_code_sync(
|
||||
self,
|
||||
code: str,
|
||||
language: str = "python",
|
||||
check_security: bool = True,
|
||||
**kwargs
|
||||
) -> SandboxResult:
|
||||
"""Synchronous wrapper for execute_code.
|
||||
|
||||
Args:
|
||||
code: Code to execute
|
||||
language: Programming language
|
||||
check_security: Whether to run security pre-checks
|
||||
**kwargs: Additional arguments
|
||||
|
||||
Returns:
|
||||
SandboxResult with execution details
|
||||
"""
|
||||
from ..approval.utils import run_coroutine_safely
|
||||
return run_coroutine_safely(self.execute_code(code, language, check_security, **kwargs))
|
||||
|
||||
async def run_shell_command(
|
||||
self,
|
||||
command: Union[str, list],
|
||||
check_security: bool = True,
|
||||
**kwargs
|
||||
) -> SandboxResult:
|
||||
"""Run a shell command in the sandbox.
|
||||
|
||||
Args:
|
||||
command: Shell command to run
|
||||
check_security: Whether to run security pre-checks
|
||||
**kwargs: Additional arguments
|
||||
|
||||
Returns:
|
||||
SandboxResult with execution details
|
||||
"""
|
||||
if not self.has_sandbox:
|
||||
raise RuntimeError("No sandbox configured")
|
||||
|
||||
# Convert command to string for security checking
|
||||
if isinstance(command, list):
|
||||
command_str = " ".join(command)
|
||||
else:
|
||||
command_str = command
|
||||
|
||||
# Security pre-check
|
||||
if check_security:
|
||||
warnings = check_code_safety(command_str, "bash")
|
||||
if warnings:
|
||||
warning_text = format_warnings(warnings)
|
||||
if self.verbose:
|
||||
logger.warning(f"Security warnings for command:\n{warning_text}")
|
||||
|
||||
async with self.get_sandbox_manager() as sandbox:
|
||||
return await sandbox.run_command(command, **kwargs)
|
||||
|
||||
def get_sandbox_status(self) -> Dict[str, Any]:
|
||||
"""Get sandbox status information.
|
||||
|
||||
Returns:
|
||||
Dictionary with sandbox status details
|
||||
"""
|
||||
if not self.has_sandbox:
|
||||
return {"configured": False}
|
||||
|
||||
status = {
|
||||
"configured": True,
|
||||
"config": self.sandbox_config.to_dict() if self.sandbox_config else None,
|
||||
}
|
||||
|
||||
manager = self.get_sandbox_manager()
|
||||
if manager:
|
||||
# Get available sandbox types
|
||||
available_types = manager.get_available_types()
|
||||
status["available_types"] = available_types
|
||||
status["current_type"] = self.sandbox_config.sandbox_type
|
||||
|
||||
return status
|
||||
|
||||
async def sandbox_cleanup(self):
|
||||
"""Clean up sandbox resources."""
|
||||
if self._sandbox_manager:
|
||||
# The context manager handles cleanup, but we can force it here
|
||||
try:
|
||||
await self._sandbox_manager.__aexit__(None, None, None)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error during sandbox cleanup: {e}")
|
||||
finally:
|
||||
self._sandbox_manager = None
|
||||
|
||||
def _get_code_execution_tools(self):
|
||||
"""Get sandbox-based code execution tools for this agent.
|
||||
|
||||
Returns tools that use the configured sandbox for safe execution.
|
||||
|
||||
Returns:
|
||||
List of tool functions
|
||||
"""
|
||||
if not self.has_sandbox:
|
||||
return []
|
||||
|
||||
from ..tools import tool
|
||||
|
||||
@tool
|
||||
def execute_python_code(code: str) -> str:
|
||||
"""Execute Python code safely in sandbox.
|
||||
|
||||
Args:
|
||||
code: Python code to execute
|
||||
|
||||
Returns:
|
||||
Execution output
|
||||
"""
|
||||
result = self.execute_code_sync(code, language="python")
|
||||
if result.success:
|
||||
return result.stdout or result.output
|
||||
else:
|
||||
return f"Error: {result.error or result.stderr}"
|
||||
|
||||
@tool
|
||||
def execute_shell_command(command: str) -> str:
|
||||
"""Execute shell command safely in sandbox.
|
||||
|
||||
Args:
|
||||
command: Shell command to execute
|
||||
|
||||
Returns:
|
||||
Command output
|
||||
"""
|
||||
from ..approval.utils import run_coroutine_safely
|
||||
result = run_coroutine_safely(self.run_shell_command(command))
|
||||
if result.success:
|
||||
return result.stdout or result.output
|
||||
else:
|
||||
return f"Error: {result.error or result.stderr}"
|
||||
|
||||
return [execute_python_code, execute_shell_command]
|
||||
@@ -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"
|
||||
|
||||
@@ -15,6 +15,8 @@ from .protocols import (
|
||||
ResourceLimits,
|
||||
)
|
||||
from .config import SandboxConfig, SecurityPolicy
|
||||
from .manager import SandboxManager
|
||||
from .security import SecurityWarning, check_code_safety, format_warnings, get_security_summary
|
||||
|
||||
__all__ = [
|
||||
"SandboxProtocol",
|
||||
@@ -22,5 +24,10 @@ __all__ = [
|
||||
"SandboxStatus",
|
||||
"ResourceLimits",
|
||||
"SandboxConfig",
|
||||
"SecurityPolicy",
|
||||
"SecurityPolicy",
|
||||
"SandboxManager",
|
||||
"SecurityWarning",
|
||||
"check_code_safety",
|
||||
"format_warnings",
|
||||
"get_security_summary",
|
||||
]
|
||||
@@ -0,0 +1,302 @@
|
||||
"""
|
||||
SandboxManager for PraisonAI Agents.
|
||||
|
||||
Factory and context manager for sandbox backends.
|
||||
Core SDK component that routes to appropriate sandbox implementations
|
||||
in the praisonai wrapper package.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Dict, Any, Optional, TYPE_CHECKING
|
||||
|
||||
from .config import SandboxConfig
|
||||
from .protocols import SandboxProtocol, SandboxResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import AsyncContextManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SandboxManager:
|
||||
"""Factory and context manager for all sandbox backends.
|
||||
|
||||
Routes to appropriate sandbox implementations based on configuration.
|
||||
Lightweight manager in core SDK - heavy implementations in wrapper.
|
||||
|
||||
Example:
|
||||
from praisonaiagents.sandbox import SandboxManager, SandboxConfig
|
||||
|
||||
# Simple usage
|
||||
config = SandboxConfig.docker("python:3.11-slim")
|
||||
manager = SandboxManager(config)
|
||||
result = await manager.run_code("print('Hello, World!')")
|
||||
|
||||
# Context manager usage
|
||||
async with SandboxManager(config) as sandbox:
|
||||
result = await sandbox.execute("print('Hello, World!')")
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[SandboxConfig] = None):
|
||||
"""Initialize the sandbox manager.
|
||||
|
||||
Args:
|
||||
config: Sandbox configuration. Defaults to subprocess sandbox.
|
||||
"""
|
||||
self.config = config or SandboxConfig.subprocess()
|
||||
self._sandbox: Optional[SandboxProtocol] = None
|
||||
|
||||
async def __aenter__(self) -> SandboxProtocol:
|
||||
"""Async context manager entry."""
|
||||
self._sandbox = await self._create_sandbox()
|
||||
return self._sandbox
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Async context manager exit with cleanup."""
|
||||
if self._sandbox:
|
||||
try:
|
||||
await self._sandbox.stop()
|
||||
await self._sandbox.cleanup()
|
||||
except Exception as e:
|
||||
logger.warning(f"Error during sandbox cleanup: {e}")
|
||||
finally:
|
||||
self._sandbox = None
|
||||
|
||||
async def run_code(
|
||||
self,
|
||||
code: str,
|
||||
language: str = "python",
|
||||
**kwargs
|
||||
) -> SandboxResult:
|
||||
"""Convenience method: create sandbox, run code, cleanup.
|
||||
|
||||
Args:
|
||||
code: Code to execute
|
||||
language: Programming language
|
||||
**kwargs: Additional arguments passed to execute()
|
||||
|
||||
Returns:
|
||||
SandboxResult with execution details
|
||||
"""
|
||||
async with self as sandbox:
|
||||
return await sandbox.execute(code, language=language, **kwargs)
|
||||
|
||||
async def _create_sandbox(self) -> SandboxProtocol:
|
||||
"""Create appropriate sandbox backend based on config.
|
||||
|
||||
Routes to implementations in praisonai wrapper package.
|
||||
|
||||
Returns:
|
||||
Configured sandbox instance
|
||||
|
||||
Raises:
|
||||
ValueError: For unknown sandbox types
|
||||
ImportError: If required dependencies not available
|
||||
"""
|
||||
sandbox_type = self.config.sandbox_type.lower()
|
||||
|
||||
if sandbox_type == "docker":
|
||||
return await self._create_docker_sandbox()
|
||||
elif sandbox_type in ("subprocess", "local"):
|
||||
return await self._create_subprocess_sandbox()
|
||||
elif sandbox_type == "e2b":
|
||||
return await self._create_e2b_sandbox()
|
||||
elif sandbox_type == "sandlock":
|
||||
return await self._create_sandlock_sandbox()
|
||||
elif sandbox_type == "ssh":
|
||||
return await self._create_ssh_sandbox()
|
||||
elif sandbox_type == "modal":
|
||||
return await self._create_modal_sandbox()
|
||||
elif sandbox_type == "daytona":
|
||||
return await self._create_daytona_sandbox()
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unknown sandbox type: {sandbox_type!r}. "
|
||||
f"Supported: 'docker', 'subprocess', 'e2b', 'sandlock', 'ssh', 'modal', 'daytona'"
|
||||
)
|
||||
|
||||
async def _create_docker_sandbox(self) -> SandboxProtocol:
|
||||
"""Create Docker sandbox."""
|
||||
try:
|
||||
# Lazy import from wrapper package
|
||||
from praisonai.sandbox import DockerSandbox
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"Docker sandbox not available. Install with: "
|
||||
"pip install praisonaiagents[sandbox-docker]"
|
||||
) from e
|
||||
|
||||
sandbox = DockerSandbox(
|
||||
image=self.config.image,
|
||||
config=self.config,
|
||||
)
|
||||
|
||||
if not sandbox.is_available:
|
||||
raise RuntimeError(
|
||||
"Docker is not available. Please install Docker and ensure it's running."
|
||||
)
|
||||
|
||||
await sandbox.start()
|
||||
return sandbox
|
||||
|
||||
async def _create_subprocess_sandbox(self) -> SandboxProtocol:
|
||||
"""Create subprocess sandbox."""
|
||||
try:
|
||||
from praisonai.sandbox import SubprocessSandbox
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"Subprocess sandbox not available. This should not happen - "
|
||||
"please check your installation."
|
||||
) from e
|
||||
|
||||
sandbox = SubprocessSandbox(config=self.config)
|
||||
await sandbox.start()
|
||||
return sandbox
|
||||
|
||||
async def _create_e2b_sandbox(self) -> SandboxProtocol:
|
||||
"""Create E2B sandbox."""
|
||||
try:
|
||||
from praisonai.sandbox import E2BSandbox
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"E2B sandbox not available. Install with: "
|
||||
"pip install praisonaiagents[sandbox] e2b-code-interpreter"
|
||||
) from e
|
||||
|
||||
sandbox = E2BSandbox(config=self.config)
|
||||
|
||||
if not sandbox.is_available:
|
||||
raise RuntimeError(
|
||||
"E2B is not available. Please set E2B_API_KEY environment variable."
|
||||
)
|
||||
|
||||
await sandbox.start()
|
||||
return sandbox
|
||||
|
||||
async def _create_sandlock_sandbox(self) -> SandboxProtocol:
|
||||
"""Create Sandlock sandbox."""
|
||||
try:
|
||||
from praisonai.sandbox import SandlockSandbox
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"Sandlock sandbox not available. Install sandlock."
|
||||
) from e
|
||||
|
||||
sandbox = SandlockSandbox(config=self.config)
|
||||
await sandbox.start()
|
||||
return sandbox
|
||||
|
||||
async def _create_ssh_sandbox(self) -> SandboxProtocol:
|
||||
"""Create SSH sandbox."""
|
||||
try:
|
||||
from praisonai.sandbox import SSHSandbox
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"SSH sandbox not available. Install with: "
|
||||
"pip install paramiko"
|
||||
) from e
|
||||
|
||||
sandbox = SSHSandbox(config=self.config)
|
||||
await sandbox.start()
|
||||
return sandbox
|
||||
|
||||
async def _create_modal_sandbox(self) -> SandboxProtocol:
|
||||
"""Create Modal sandbox."""
|
||||
try:
|
||||
from praisonai.sandbox import ModalSandbox
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"Modal sandbox not available. Install with: "
|
||||
"pip install modal"
|
||||
) from e
|
||||
|
||||
sandbox = ModalSandbox(config=self.config)
|
||||
await sandbox.start()
|
||||
return sandbox
|
||||
|
||||
async def _create_daytona_sandbox(self) -> SandboxProtocol:
|
||||
"""Create Daytona sandbox."""
|
||||
try:
|
||||
from praisonai.sandbox import DaytonaSandbox
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"Daytona sandbox not available. Install daytona client."
|
||||
) from e
|
||||
|
||||
sandbox = DaytonaSandbox(config=self.config)
|
||||
await sandbox.start()
|
||||
return sandbox
|
||||
|
||||
def get_available_types(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Get available sandbox types and their status.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping sandbox types to availability info
|
||||
"""
|
||||
types = {}
|
||||
|
||||
# Check Docker
|
||||
try:
|
||||
from praisonai.sandbox import DockerSandbox
|
||||
docker = DockerSandbox()
|
||||
types["docker"] = {
|
||||
"available": docker.is_available,
|
||||
"description": "Isolated Docker containers",
|
||||
"requires": ["docker"],
|
||||
}
|
||||
except ImportError:
|
||||
types["docker"] = {
|
||||
"available": False,
|
||||
"description": "Isolated Docker containers",
|
||||
"requires": ["docker", "praisonaiagents[sandbox-docker]"],
|
||||
}
|
||||
|
||||
# Check subprocess (always available)
|
||||
types["subprocess"] = {
|
||||
"available": True,
|
||||
"description": "Local subprocess (limited isolation)",
|
||||
"requires": [],
|
||||
}
|
||||
|
||||
# Check E2B
|
||||
try:
|
||||
from praisonai.sandbox import E2BSandbox
|
||||
e2b = E2BSandbox()
|
||||
types["e2b"] = {
|
||||
"available": e2b.is_available,
|
||||
"description": "E2B cloud sandboxes",
|
||||
"requires": ["e2b-code-interpreter", "E2B_API_KEY"],
|
||||
}
|
||||
except ImportError:
|
||||
types["e2b"] = {
|
||||
"available": False,
|
||||
"description": "E2B cloud sandboxes",
|
||||
"requires": ["e2b-code-interpreter", "E2B_API_KEY"],
|
||||
}
|
||||
|
||||
# Add other types with try/except blocks
|
||||
for sandbox_type, module_name, description, requirements in [
|
||||
("sandlock", "SandlockSandbox", "OS-native sandboxing", ["sandlock"]),
|
||||
("ssh", "SSHSandbox", "Remote SSH execution", ["paramiko"]),
|
||||
("modal", "ModalSandbox", "Modal cloud compute", ["modal"]),
|
||||
("daytona", "DaytonaSandbox", "Daytona workspaces", ["daytona"]),
|
||||
]:
|
||||
try:
|
||||
from praisonai import sandbox
|
||||
sandbox_class = getattr(sandbox, module_name)
|
||||
# Check if class exists without instantiating (some need required args)
|
||||
types[sandbox_type] = {
|
||||
"available": False, # Can't easily check without required args
|
||||
"description": description,
|
||||
"requires": requirements,
|
||||
}
|
||||
except (ImportError, AttributeError):
|
||||
types[sandbox_type] = {
|
||||
"available": False,
|
||||
"description": description,
|
||||
"requires": requirements,
|
||||
}
|
||||
|
||||
return types
|
||||
@@ -0,0 +1,345 @@
|
||||
"""
|
||||
Security pre-checks for sandbox code execution.
|
||||
|
||||
Provides pre-execution analysis of code for potentially dangerous patterns.
|
||||
Note: These are warnings only - the sandbox provides the real isolation.
|
||||
"""
|
||||
|
||||
import re
|
||||
import ast
|
||||
import logging
|
||||
from typing import List, Optional, Dict, Any
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SecurityWarning:
|
||||
"""A security warning from code analysis."""
|
||||
|
||||
pattern: str
|
||||
message: str
|
||||
severity: str # "low", "medium", "high", "critical"
|
||||
line_number: Optional[int] = None
|
||||
context: Optional[str] = None
|
||||
|
||||
|
||||
# Dangerous patterns to detect in code
|
||||
DANGEROUS_PATTERNS = [
|
||||
# System/OS access
|
||||
(r"import\s+os\s*;?\s*os\.system", "Direct system command execution via os.system", "high"),
|
||||
(r"subprocess\.call.*shell\s*=\s*True", "Shell injection risk with subprocess", "high"),
|
||||
(r"subprocess\.run.*shell\s*=\s*True", "Shell injection risk with subprocess", "high"),
|
||||
(r"subprocess\.Popen.*shell\s*=\s*True", "Shell injection risk with subprocess", "high"),
|
||||
(r"__import__\(['\"]os['\"]\)", "Dynamic import of os module", "medium"),
|
||||
(r"eval\s*\(", "Dynamic code execution with eval()", "critical"),
|
||||
(r"exec\s*\(", "Dynamic code execution with exec()", "critical"),
|
||||
(r"compile\s*\(.*exec", "Code compilation for execution", "high"),
|
||||
|
||||
# File system access
|
||||
(r"open\s*\(.*['\"]\/etc", "Access to system configuration files", "medium"),
|
||||
(r"open\s*\(.*['\"]\/root", "Access to root directory", "high"),
|
||||
(r"open\s*\(.*['\"]\/home", "Access to user directories", "medium"),
|
||||
(r"shutil\.rmtree", "Recursive directory deletion", "high"),
|
||||
(r"os\.remove\s*\(", "File deletion", "medium"),
|
||||
(r"os\.unlink\s*\(", "File deletion", "medium"),
|
||||
(r"pathlib\.Path.*unlink", "File deletion via pathlib", "medium"),
|
||||
|
||||
# Network access
|
||||
(r"socket\.socket", "Direct socket creation", "medium"),
|
||||
(r"urllib\.request", "HTTP requests via urllib", "low"),
|
||||
(r"requests\.", "HTTP requests via requests library", "low"),
|
||||
(r"http\.client", "HTTP client usage", "low"),
|
||||
|
||||
# Process manipulation
|
||||
(r"os\.fork\s*\(", "Process forking", "high"),
|
||||
(r"multiprocessing\.", "Multiprocessing usage", "medium"),
|
||||
(r"threading\.Thread", "Thread creation", "low"),
|
||||
|
||||
# Potentially dangerous builtins
|
||||
(r"getattr\s*\(.*__", "Dynamic attribute access to dunder methods", "medium"),
|
||||
(r"setattr\s*\(.*__", "Dynamic attribute setting of dunder methods", "medium"),
|
||||
(r"hasattr\s*\(.*__", "Dynamic attribute checking of dunder methods", "low"),
|
||||
(r"globals\s*\(\)", "Access to global namespace", "medium"),
|
||||
(r"locals\s*\(\)", "Access to local namespace", "low"),
|
||||
(r"vars\s*\(", "Variable inspection", "low"),
|
||||
(r"dir\s*\(", "Object inspection", "low"),
|
||||
|
||||
# Code modification
|
||||
(r"sys\.modules", "Module manipulation", "high"),
|
||||
(r"importlib\.", "Dynamic import manipulation", "medium"),
|
||||
(r"__builtins__", "Access to builtin functions", "high"),
|
||||
|
||||
# Memory/performance
|
||||
(r"while\s+True:", "Infinite loop (potential DoS)", "medium"),
|
||||
(r"for.*in.*range\s*\(\s*[0-9]{6,}", "Large range iteration", "low"),
|
||||
(r"list\s*\(\s*range\s*\(\s*[0-9]{6,}", "Large list creation", "low"),
|
||||
(r"\*\s*[0-9]{6,}", "Large data multiplication", "low"),
|
||||
]
|
||||
|
||||
|
||||
def check_code_safety(code: str, language: str = "python") -> List[SecurityWarning]:
|
||||
"""Analyze code for potentially dangerous patterns.
|
||||
|
||||
Args:
|
||||
code: Code to analyze
|
||||
language: Programming language (currently only 'python' supported)
|
||||
|
||||
Returns:
|
||||
List of security warnings found
|
||||
|
||||
Note:
|
||||
These are warnings only. The sandbox provides real isolation.
|
||||
This is a best-effort static analysis for user awareness.
|
||||
"""
|
||||
warnings = []
|
||||
|
||||
if language == "python":
|
||||
warnings.extend(_check_python_patterns(code))
|
||||
warnings.extend(_check_python_ast(code))
|
||||
elif language == "bash":
|
||||
warnings.extend(_check_bash_patterns(code))
|
||||
else:
|
||||
# Fall back to basic pattern matching
|
||||
warnings.extend(_check_generic_patterns(code))
|
||||
|
||||
return warnings
|
||||
|
||||
|
||||
def _check_python_patterns(code: str) -> List[SecurityWarning]:
|
||||
"""Check Python code using regex patterns."""
|
||||
warnings = []
|
||||
lines = code.split('\n')
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
for pattern, message, severity in DANGEROUS_PATTERNS:
|
||||
if re.search(pattern, line, re.IGNORECASE):
|
||||
warnings.append(SecurityWarning(
|
||||
pattern=pattern,
|
||||
message=message,
|
||||
severity=severity,
|
||||
line_number=i,
|
||||
context=line.strip(),
|
||||
))
|
||||
|
||||
return warnings
|
||||
|
||||
|
||||
def _check_python_ast(code: str) -> List[SecurityWarning]:
|
||||
"""Check Python code using AST analysis."""
|
||||
warnings = []
|
||||
|
||||
try:
|
||||
tree = ast.parse(code)
|
||||
visitor = DangerousASTVisitor()
|
||||
visitor.visit(tree)
|
||||
warnings.extend(visitor.warnings)
|
||||
except SyntaxError:
|
||||
# Invalid Python syntax - let the sandbox handle it
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug(f"AST analysis failed: {e}")
|
||||
|
||||
return warnings
|
||||
|
||||
|
||||
def _check_bash_patterns(code: str) -> List[SecurityWarning]:
|
||||
"""Check bash/shell code for dangerous patterns."""
|
||||
warnings = []
|
||||
lines = code.split('\n')
|
||||
|
||||
bash_patterns = [
|
||||
(r"rm\s+-rf\s+\/", "Recursive deletion of root directory", "critical"),
|
||||
(r"rm\s+-rf\s+\*", "Recursive deletion with wildcard", "high"),
|
||||
(r">\s*\/dev\/sd[a-z]", "Direct disk device access", "critical"),
|
||||
(r"dd\s+if=.*of=", "Low-level disk operations", "high"),
|
||||
(r"mkfs\.", "Filesystem creation", "critical"),
|
||||
(r"fdisk", "Disk partitioning", "critical"),
|
||||
(r"shutdown", "System shutdown", "high"),
|
||||
(r"reboot", "System reboot", "high"),
|
||||
(r"halt", "System halt", "high"),
|
||||
(r"init\s+[0-6]", "System runlevel change", "high"),
|
||||
(r"chmod\s+777", "Overly permissive file permissions", "medium"),
|
||||
(r"curl.*\|\s*bash", "Piped execution from network", "high"),
|
||||
(r"wget.*\|\s*bash", "Piped execution from network", "high"),
|
||||
(r"\$\(.*\)", "Command substitution", "medium"),
|
||||
(r"`.*`", "Command substitution (backticks)", "medium"),
|
||||
]
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
for pattern, message, severity in bash_patterns:
|
||||
if re.search(pattern, line, re.IGNORECASE):
|
||||
warnings.append(SecurityWarning(
|
||||
pattern=pattern,
|
||||
message=message,
|
||||
severity=severity,
|
||||
line_number=i,
|
||||
context=line.strip(),
|
||||
))
|
||||
|
||||
return warnings
|
||||
|
||||
|
||||
def _check_generic_patterns(code: str) -> List[SecurityWarning]:
|
||||
"""Check code using generic patterns."""
|
||||
warnings = []
|
||||
lines = code.split('\n')
|
||||
|
||||
generic_patterns = [
|
||||
(r"eval\s*\(", "Dynamic code execution", "critical"),
|
||||
(r"exec\s*\(", "Dynamic code execution", "critical"),
|
||||
(r"system\s*\(", "System command execution", "high"),
|
||||
(r"shell\s*\(", "Shell command execution", "high"),
|
||||
]
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
for pattern, message, severity in generic_patterns:
|
||||
if re.search(pattern, line, re.IGNORECASE):
|
||||
warnings.append(SecurityWarning(
|
||||
pattern=pattern,
|
||||
message=message,
|
||||
severity=severity,
|
||||
line_number=i,
|
||||
context=line.strip(),
|
||||
))
|
||||
|
||||
return warnings
|
||||
|
||||
|
||||
class DangerousASTVisitor(ast.NodeVisitor):
|
||||
"""AST visitor to detect dangerous Python constructs."""
|
||||
|
||||
def __init__(self):
|
||||
self.warnings = []
|
||||
|
||||
def visit_Call(self, node):
|
||||
"""Visit function calls."""
|
||||
# Check for dangerous function calls
|
||||
if isinstance(node.func, ast.Name):
|
||||
func_name = node.func.id
|
||||
if func_name in ("eval", "exec", "compile"):
|
||||
self.warnings.append(SecurityWarning(
|
||||
pattern=f"{func_name}()",
|
||||
message=f"Dynamic code execution with {func_name}()",
|
||||
severity="critical",
|
||||
line_number=node.lineno,
|
||||
))
|
||||
|
||||
# Check for attribute calls like os.system
|
||||
elif isinstance(node.func, ast.Attribute):
|
||||
if isinstance(node.func.value, ast.Name):
|
||||
module = node.func.value.id
|
||||
func = node.func.attr
|
||||
|
||||
if module == "os" and func == "system":
|
||||
self.warnings.append(SecurityWarning(
|
||||
pattern="os.system()",
|
||||
message="Direct system command execution",
|
||||
severity="high",
|
||||
line_number=node.lineno,
|
||||
))
|
||||
elif module == "subprocess" and func in ("call", "run", "Popen"):
|
||||
# Check if shell=True is used
|
||||
for keyword in node.keywords:
|
||||
if keyword.arg == "shell" and isinstance(keyword.value, ast.Constant):
|
||||
if keyword.value.value is True:
|
||||
self.warnings.append(SecurityWarning(
|
||||
pattern=f"subprocess.{func}(..., shell=True)",
|
||||
message="Shell injection risk with subprocess",
|
||||
severity="high",
|
||||
line_number=node.lineno,
|
||||
))
|
||||
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_Import(self, node):
|
||||
"""Visit import statements."""
|
||||
for alias in node.names:
|
||||
if alias.name in ("os", "subprocess", "sys"):
|
||||
self.warnings.append(SecurityWarning(
|
||||
pattern=f"import {alias.name}",
|
||||
message=f"Import of potentially dangerous module: {alias.name}",
|
||||
severity="low",
|
||||
line_number=node.lineno,
|
||||
))
|
||||
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_ImportFrom(self, node):
|
||||
"""Visit from ... import statements."""
|
||||
if node.module in ("os", "subprocess", "sys"):
|
||||
for alias in node.names:
|
||||
if alias.name in ("system", "call", "run", "Popen", "modules"):
|
||||
self.warnings.append(SecurityWarning(
|
||||
pattern=f"from {node.module} import {alias.name}",
|
||||
message=f"Import of dangerous function: {alias.name}",
|
||||
severity="medium",
|
||||
line_number=node.lineno,
|
||||
))
|
||||
|
||||
self.generic_visit(node)
|
||||
|
||||
|
||||
def format_warnings(warnings: List[SecurityWarning]) -> str:
|
||||
"""Format security warnings for display.
|
||||
|
||||
Args:
|
||||
warnings: List of security warnings
|
||||
|
||||
Returns:
|
||||
Formatted string of warnings
|
||||
"""
|
||||
if not warnings:
|
||||
return "No security issues detected."
|
||||
|
||||
# Group by severity
|
||||
by_severity = {"critical": [], "high": [], "medium": [], "low": []}
|
||||
for warning in warnings:
|
||||
by_severity[warning.severity].append(warning)
|
||||
|
||||
lines = []
|
||||
lines.append(f"Security analysis found {len(warnings)} potential issue(s):")
|
||||
lines.append("")
|
||||
|
||||
for severity in ["critical", "high", "medium", "low"]:
|
||||
if by_severity[severity]:
|
||||
lines.append(f"{severity.upper()} RISK:")
|
||||
for warning in by_severity[severity]:
|
||||
line_info = f" (line {warning.line_number})" if warning.line_number else ""
|
||||
context_info = f"\n Context: {warning.context}" if warning.context else ""
|
||||
lines.append(f" - {warning.message}{line_info}{context_info}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("Note: These are warnings only. The sandbox provides real isolation.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def get_security_summary(warnings: List[SecurityWarning]) -> Dict[str, Any]:
|
||||
"""Get security analysis summary.
|
||||
|
||||
Args:
|
||||
warnings: List of security warnings
|
||||
|
||||
Returns:
|
||||
Dictionary with summary statistics
|
||||
"""
|
||||
by_severity = {"critical": 0, "high": 0, "medium": 0, "low": 0}
|
||||
for warning in warnings:
|
||||
by_severity[warning.severity] += 1
|
||||
|
||||
max_severity = "low"
|
||||
if by_severity["critical"] > 0:
|
||||
max_severity = "critical"
|
||||
elif by_severity["high"] > 0:
|
||||
max_severity = "high"
|
||||
elif by_severity["medium"] > 0:
|
||||
max_severity = "medium"
|
||||
|
||||
return {
|
||||
"total_warnings": len(warnings),
|
||||
"by_severity": by_severity,
|
||||
"max_severity": max_severity,
|
||||
"is_safe": len(warnings) == 0,
|
||||
"has_critical": by_severity["critical"] > 0,
|
||||
}
|
||||
@@ -6,7 +6,7 @@ from typing import List, Optional, Dict
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from .models import SkillMetadata
|
||||
from .discovery import discover_skills
|
||||
from .discovery import discover_skills, get_default_skill_dirs
|
||||
from .loader import SkillLoader, LoadedSkill
|
||||
from .prompt import generate_skills_xml
|
||||
from .substitution import render_skill_body
|
||||
@@ -271,8 +271,7 @@ class SkillManager:
|
||||
return {"success": False, "error": "Skill content exceeds maximum size (100KB)"}
|
||||
|
||||
# Create skill directory
|
||||
from .discovery import get_default_skill_directories
|
||||
skill_dirs = get_default_skill_directories()
|
||||
skill_dirs = get_default_skill_dirs()
|
||||
base_dir = skill_dirs[0] if skill_dirs else "~/.praisonai/skills"
|
||||
|
||||
import os
|
||||
|
||||
@@ -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.40"
|
||||
version = "1.6.43"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -101,6 +101,22 @@ crawl = [
|
||||
"playwright>=1.47.0",
|
||||
]
|
||||
|
||||
# Sandbox dependencies
|
||||
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",
|
||||
]
|
||||
|
||||
# Combined features
|
||||
all = [
|
||||
"praisonaiagents[memory]",
|
||||
@@ -112,10 +128,12 @@ all = [
|
||||
"praisonaiagents[telemetry]",
|
||||
"praisonaiagents[mongodb]",
|
||||
"praisonaiagents[auth]",
|
||||
"praisonaiagents[sandbox]",
|
||||
"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 @@
|
||||
# Sandbox tests package
|
||||
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
Unit tests for SandboxManager.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, AsyncMock, patch
|
||||
from praisonaiagents.sandbox.manager import SandboxManager
|
||||
from praisonaiagents.sandbox.config import SandboxConfig
|
||||
from praisonaiagents.sandbox.protocols import SandboxResult, SandboxStatus
|
||||
|
||||
|
||||
class TestSandboxManager:
|
||||
"""Test SandboxManager functionality."""
|
||||
|
||||
def test_init_default_config(self):
|
||||
"""Test initialization with default config."""
|
||||
manager = SandboxManager()
|
||||
assert manager.config is not None
|
||||
assert manager.config.sandbox_type == "subprocess"
|
||||
|
||||
def test_init_with_config(self):
|
||||
"""Test initialization with custom config."""
|
||||
config = SandboxConfig.docker("python:3.11")
|
||||
manager = SandboxManager(config)
|
||||
assert manager.config == config
|
||||
|
||||
@patch('praisonaiagents.sandbox.manager.SandboxManager._create_sandbox')
|
||||
async def test_context_manager(self, mock_create_sandbox):
|
||||
"""Test SandboxManager as async context manager."""
|
||||
mock_sandbox = AsyncMock()
|
||||
mock_create_sandbox.return_value = mock_sandbox
|
||||
|
||||
manager = SandboxManager()
|
||||
|
||||
async with manager as sandbox:
|
||||
assert sandbox == mock_sandbox
|
||||
mock_create_sandbox.assert_called_once()
|
||||
|
||||
# Cleanup should be called
|
||||
mock_sandbox.stop.assert_called_once()
|
||||
mock_sandbox.cleanup.assert_called_once()
|
||||
|
||||
@patch('praisonaiagents.sandbox.manager.SandboxManager._create_sandbox')
|
||||
async def test_context_manager_cleanup_error(self, mock_create_sandbox):
|
||||
"""Test context manager cleanup with error."""
|
||||
mock_sandbox = AsyncMock()
|
||||
mock_sandbox.stop.side_effect = Exception("Stop failed")
|
||||
mock_create_sandbox.return_value = mock_sandbox
|
||||
|
||||
manager = SandboxManager()
|
||||
|
||||
# Should not raise exception, just log warning
|
||||
async with manager as sandbox:
|
||||
assert sandbox == mock_sandbox
|
||||
|
||||
mock_sandbox.stop.assert_called_once()
|
||||
mock_sandbox.cleanup.assert_called_once()
|
||||
|
||||
@patch('praisonaiagents.sandbox.manager.SandboxManager._create_sandbox')
|
||||
async def test_run_code_convenience_method(self, mock_create_sandbox):
|
||||
"""Test run_code convenience method."""
|
||||
mock_sandbox = AsyncMock()
|
||||
mock_result = SandboxResult(status=SandboxStatus.COMPLETED, stdout="output")
|
||||
mock_sandbox.execute.return_value = mock_result
|
||||
mock_create_sandbox.return_value = mock_sandbox
|
||||
|
||||
manager = SandboxManager()
|
||||
|
||||
result = await manager.run_code("print('hello')", language="python")
|
||||
|
||||
assert result == mock_result
|
||||
mock_sandbox.execute.assert_called_once_with("print('hello')", language="python")
|
||||
|
||||
async def test_create_sandbox_subprocess(self):
|
||||
"""Test creating subprocess sandbox."""
|
||||
config = SandboxConfig.subprocess()
|
||||
manager = SandboxManager(config)
|
||||
|
||||
with patch('praisonai.sandbox.subprocess.SubprocessSandbox') as mock_class:
|
||||
mock_instance = AsyncMock()
|
||||
mock_class.return_value = mock_instance
|
||||
mock_instance.is_available = True
|
||||
|
||||
sandbox = await manager._create_sandbox()
|
||||
|
||||
mock_class.assert_called_once_with(config)
|
||||
assert sandbox == mock_instance
|
||||
|
||||
async def test_create_sandbox_docker(self):
|
||||
"""Test creating Docker sandbox."""
|
||||
config = SandboxConfig.docker("python:3.11")
|
||||
manager = SandboxManager(config)
|
||||
|
||||
with patch('praisonai.sandbox.docker.DockerSandbox') as mock_class:
|
||||
mock_instance = AsyncMock()
|
||||
mock_class.return_value = mock_instance
|
||||
mock_instance.is_available = True
|
||||
|
||||
sandbox = await manager._create_sandbox()
|
||||
|
||||
mock_class.assert_called_once_with(config)
|
||||
assert sandbox == mock_instance
|
||||
|
||||
async def test_create_sandbox_e2b(self):
|
||||
"""Test creating E2B sandbox."""
|
||||
config = SandboxConfig.e2b()
|
||||
manager = SandboxManager(config)
|
||||
|
||||
with patch('praisonai.sandbox.e2b.E2BSandbox') as mock_class:
|
||||
mock_instance = AsyncMock()
|
||||
mock_class.return_value = mock_instance
|
||||
mock_instance.is_available = True
|
||||
|
||||
sandbox = await manager._create_sandbox()
|
||||
|
||||
mock_class.assert_called_once_with(config)
|
||||
assert sandbox == mock_instance
|
||||
|
||||
async def test_create_sandbox_unknown_type(self):
|
||||
"""Test creating sandbox with unknown type."""
|
||||
config = SandboxConfig(sandbox_type="unknown")
|
||||
manager = SandboxManager(config)
|
||||
|
||||
with pytest.raises(ValueError, match="Unknown sandbox type"):
|
||||
await manager._create_sandbox()
|
||||
|
||||
async def test_create_sandbox_unavailable(self):
|
||||
"""Test creating sandbox when not available."""
|
||||
config = SandboxConfig.docker("python:3.11")
|
||||
manager = SandboxManager(config)
|
||||
|
||||
with patch('praisonai.sandbox.docker.DockerSandbox') as mock_class:
|
||||
mock_instance = Mock()
|
||||
mock_instance.is_available = False
|
||||
mock_class.return_value = mock_instance
|
||||
|
||||
with pytest.raises(RuntimeError, match="not available"):
|
||||
await manager._create_sandbox()
|
||||
|
||||
async def test_create_sandbox_import_error(self):
|
||||
"""Test creating sandbox with import error."""
|
||||
config = SandboxConfig.e2b()
|
||||
manager = SandboxManager(config)
|
||||
|
||||
with patch('importlib.import_module', side_effect=ImportError("Module not found")):
|
||||
with pytest.raises(ImportError, match="Module not found"):
|
||||
await manager._create_sandbox()
|
||||
|
||||
def test_get_available_types(self):
|
||||
"""Test getting available sandbox types."""
|
||||
manager = SandboxManager()
|
||||
|
||||
with patch('praisonaiagents.sandbox.manager.SandboxManager._check_availability') as mock_check:
|
||||
mock_check.side_effect = lambda t: t in ["subprocess", "docker"]
|
||||
|
||||
available = manager.get_available_types()
|
||||
|
||||
assert "subprocess" in available
|
||||
assert available["subprocess"] is True
|
||||
assert "docker" in available
|
||||
assert available["docker"] is True
|
||||
assert "e2b" in available
|
||||
assert available["e2b"] is False
|
||||
|
||||
def test_check_availability_subprocess(self):
|
||||
"""Test checking subprocess availability."""
|
||||
manager = SandboxManager()
|
||||
|
||||
# Subprocess should always be available
|
||||
available = manager._check_availability("subprocess")
|
||||
assert available is True
|
||||
|
||||
def test_check_availability_docker(self):
|
||||
"""Test checking Docker availability."""
|
||||
manager = SandboxManager()
|
||||
|
||||
with patch('importlib.import_module') as mock_import:
|
||||
with patch.object(mock_import.return_value, 'DockerSandbox') as mock_class:
|
||||
mock_instance = Mock()
|
||||
mock_instance.is_available = True
|
||||
mock_class.return_value = mock_instance
|
||||
|
||||
available = manager._check_availability("docker")
|
||||
assert available is True
|
||||
|
||||
def test_check_availability_not_available(self):
|
||||
"""Test checking availability when not available."""
|
||||
manager = SandboxManager()
|
||||
|
||||
with patch('importlib.import_module', side_effect=ImportError()):
|
||||
available = manager._check_availability("docker")
|
||||
assert available is False
|
||||
|
||||
def test_check_availability_unknown(self):
|
||||
"""Test checking availability for unknown type."""
|
||||
manager = SandboxManager()
|
||||
|
||||
available = manager._check_availability("unknown")
|
||||
assert available is False
|
||||
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
Unit tests for SandboxMixin functionality.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, AsyncMock, patch
|
||||
from praisonaiagents.agent.sandbox_mixin import SandboxMixin
|
||||
from praisonaiagents.sandbox import SandboxConfig, SandboxResult, SandboxStatus
|
||||
|
||||
|
||||
class MockAgent(SandboxMixin):
|
||||
"""Mock agent class for testing SandboxMixin."""
|
||||
|
||||
def __init__(self, sandbox=None, verbose=False):
|
||||
super().__init__(sandbox=sandbox)
|
||||
self.verbose = verbose
|
||||
|
||||
|
||||
class TestSandboxMixin:
|
||||
"""Test SandboxMixin functionality."""
|
||||
|
||||
def test_init_no_sandbox(self):
|
||||
"""Test initialization without sandbox."""
|
||||
agent = MockAgent()
|
||||
assert not agent.has_sandbox
|
||||
assert agent.sandbox_config is None
|
||||
|
||||
def test_init_with_boolean_true(self):
|
||||
"""Test initialization with sandbox=True."""
|
||||
agent = MockAgent(sandbox=True)
|
||||
assert agent.has_sandbox
|
||||
assert agent.sandbox_config is not None
|
||||
assert agent.sandbox_config.sandbox_type == "subprocess"
|
||||
|
||||
def test_init_with_config(self):
|
||||
"""Test initialization with explicit config."""
|
||||
config = SandboxConfig.docker("python:3.11")
|
||||
agent = MockAgent(sandbox=config)
|
||||
assert agent.has_sandbox
|
||||
assert agent.sandbox_config == config
|
||||
|
||||
def test_get_sandbox_manager_no_config(self):
|
||||
"""Test get_sandbox_manager without config."""
|
||||
agent = MockAgent()
|
||||
manager = agent.get_sandbox_manager()
|
||||
assert manager is None
|
||||
|
||||
@patch('praisonaiagents.agent.sandbox_mixin.SandboxManager')
|
||||
def test_get_sandbox_manager_with_config(self, mock_manager_class):
|
||||
"""Test get_sandbox_manager with config."""
|
||||
config = SandboxConfig.subprocess()
|
||||
agent = MockAgent(sandbox=config)
|
||||
|
||||
manager = agent.get_sandbox_manager()
|
||||
|
||||
mock_manager_class.assert_called_once_with(config)
|
||||
assert agent._sandbox_manager is not None
|
||||
|
||||
def test_execute_code_without_sandbox_raises(self):
|
||||
"""Test that execute_code raises without sandbox."""
|
||||
agent = MockAgent()
|
||||
|
||||
with pytest.raises(RuntimeError, match="No sandbox configured"):
|
||||
agent.execute_code_sync("print('hello')")
|
||||
|
||||
@patch('praisonaiagents.agent.sandbox_mixin.check_code_safety')
|
||||
@patch('praisonaiagents.agent.sandbox_mixin.SandboxManager')
|
||||
async def test_execute_code_with_warnings(self, mock_manager_class, mock_check_safety):
|
||||
"""Test execute_code with security warnings."""
|
||||
# Setup
|
||||
config = SandboxConfig.subprocess()
|
||||
agent = MockAgent(sandbox=config, verbose=True)
|
||||
|
||||
mock_warnings = ["Potential security issue"]
|
||||
mock_check_safety.return_value = mock_warnings
|
||||
|
||||
mock_manager = AsyncMock()
|
||||
mock_result = SandboxResult(status=SandboxStatus.COMPLETED, stdout="output")
|
||||
mock_manager.run_code.return_value = mock_result
|
||||
mock_manager_class.return_value = mock_manager
|
||||
|
||||
# Execute
|
||||
result = await agent.execute_code("print('test')", check_security=True)
|
||||
|
||||
# Verify
|
||||
mock_check_safety.assert_called_once_with("print('test')", "python")
|
||||
mock_manager.run_code.assert_called_once()
|
||||
assert result == mock_result
|
||||
|
||||
@patch('praisonaiagents.agent.sandbox_mixin.SandboxManager')
|
||||
async def test_run_shell_command(self, mock_manager_class):
|
||||
"""Test run_shell_command functionality."""
|
||||
config = SandboxConfig.subprocess()
|
||||
agent = MockAgent(sandbox=config)
|
||||
|
||||
# Mock manager as async context manager
|
||||
mock_manager = AsyncMock()
|
||||
mock_result = SandboxResult(status=SandboxStatus.COMPLETED, stdout="result")
|
||||
mock_manager.__aenter__.return_value = mock_manager
|
||||
mock_manager.__aexit__.return_value = None
|
||||
mock_manager.run_command.return_value = mock_result
|
||||
|
||||
mock_manager_class.return_value = mock_manager
|
||||
|
||||
result = await agent.run_shell_command("echo hello")
|
||||
|
||||
mock_manager.run_command.assert_called_once_with("echo hello")
|
||||
assert result == mock_result
|
||||
|
||||
def test_get_sandbox_status_no_config(self):
|
||||
"""Test get_sandbox_status without config."""
|
||||
agent = MockAgent()
|
||||
status = agent.get_sandbox_status()
|
||||
assert status == {"configured": False}
|
||||
|
||||
@patch('praisonaiagents.agent.sandbox_mixin.SandboxManager')
|
||||
def test_get_sandbox_status_with_config(self, mock_manager_class):
|
||||
"""Test get_sandbox_status with config."""
|
||||
config = SandboxConfig.subprocess()
|
||||
agent = MockAgent(sandbox=config)
|
||||
|
||||
mock_manager = Mock()
|
||||
mock_manager.get_available_types.return_value = {"subprocess": True}
|
||||
mock_manager_class.return_value = mock_manager
|
||||
|
||||
status = agent.get_sandbox_status()
|
||||
|
||||
assert status["configured"] is True
|
||||
assert status["config"] is not None
|
||||
assert status["available_types"] == {"subprocess": True}
|
||||
assert status["current_type"] == "subprocess"
|
||||
|
||||
def test_get_code_execution_tools_no_sandbox(self):
|
||||
"""Test _get_code_execution_tools without sandbox."""
|
||||
agent = MockAgent()
|
||||
tools = agent._get_code_execution_tools()
|
||||
assert tools == []
|
||||
|
||||
@patch('praisonaiagents.agent.sandbox_mixin.SandboxManager')
|
||||
def test_get_code_execution_tools_with_sandbox(self, mock_manager_class):
|
||||
"""Test _get_code_execution_tools with sandbox."""
|
||||
config = SandboxConfig.subprocess()
|
||||
agent = MockAgent(sandbox=config)
|
||||
|
||||
tools = agent._get_code_execution_tools()
|
||||
|
||||
assert len(tools) == 2
|
||||
# Check that tools are functions
|
||||
assert callable(tools[0])
|
||||
assert callable(tools[1])
|
||||
|
||||
async def test_sandbox_cleanup(self):
|
||||
"""Test sandbox cleanup functionality."""
|
||||
agent = MockAgent()
|
||||
|
||||
# Mock manager
|
||||
mock_manager = AsyncMock()
|
||||
agent._sandbox_manager = mock_manager
|
||||
|
||||
await agent.sandbox_cleanup()
|
||||
|
||||
mock_manager.__aexit__.assert_called_once_with(None, None, None)
|
||||
assert agent._sandbox_manager is None
|
||||
|
||||
async def test_sandbox_cleanup_with_error(self):
|
||||
"""Test sandbox cleanup with error handling."""
|
||||
agent = MockAgent()
|
||||
|
||||
# Mock manager that raises exception
|
||||
mock_manager = AsyncMock()
|
||||
mock_manager.__aexit__.side_effect = Exception("Cleanup failed")
|
||||
agent._sandbox_manager = mock_manager
|
||||
|
||||
# Should not raise, just log warning
|
||||
await agent.sandbox_cleanup()
|
||||
|
||||
assert agent._sandbox_manager is None
|
||||
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
Unit tests for sandbox security module.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from praisonaiagents.sandbox.security import (
|
||||
check_code_safety,
|
||||
format_warnings,
|
||||
SecurityWarning,
|
||||
SecurityLevel,
|
||||
SecurityAnalyzer
|
||||
)
|
||||
|
||||
|
||||
class TestCodeSafety:
|
||||
"""Test code safety checking functionality."""
|
||||
|
||||
def test_empty_code(self):
|
||||
"""Test empty code returns no warnings."""
|
||||
warnings = check_code_safety("", "python")
|
||||
assert warnings == []
|
||||
|
||||
def test_safe_code(self):
|
||||
"""Test safe code returns no warnings."""
|
||||
safe_code = """
|
||||
x = 1 + 2
|
||||
print(f"Result: {x}")
|
||||
"""
|
||||
warnings = check_code_safety(safe_code, "python")
|
||||
assert warnings == []
|
||||
|
||||
def test_dangerous_imports(self):
|
||||
"""Test detection of dangerous imports."""
|
||||
dangerous_code = """
|
||||
import os
|
||||
import subprocess
|
||||
os.system('rm -rf /')
|
||||
"""
|
||||
warnings = check_code_safety(dangerous_code, "python")
|
||||
assert len(warnings) > 0
|
||||
assert any("os.system" in str(w) for w in warnings)
|
||||
|
||||
def test_eval_exec_usage(self):
|
||||
"""Test detection of eval/exec usage."""
|
||||
dangerous_code = """
|
||||
user_input = input("Enter code: ")
|
||||
eval(user_input)
|
||||
exec(user_input)
|
||||
"""
|
||||
warnings = check_code_safety(dangerous_code, "python")
|
||||
assert len(warnings) > 0
|
||||
assert any("eval" in str(w) or "exec" in str(w) for w in warnings)
|
||||
|
||||
def test_network_operations(self):
|
||||
"""Test detection of network operations."""
|
||||
network_code = """
|
||||
import urllib.request
|
||||
import socket
|
||||
urllib.request.urlopen('http://example.com')
|
||||
"""
|
||||
warnings = check_code_safety(network_code, "python")
|
||||
assert len(warnings) > 0
|
||||
|
||||
def test_file_operations(self):
|
||||
"""Test detection of file operations."""
|
||||
file_code = """
|
||||
with open('/etc/passwd', 'r') as f:
|
||||
data = f.read()
|
||||
"""
|
||||
warnings = check_code_safety(file_code, "python")
|
||||
assert len(warnings) > 0
|
||||
|
||||
def test_bash_commands(self):
|
||||
"""Test bash command safety checking."""
|
||||
dangerous_bash = "rm -rf / --no-preserve-root"
|
||||
warnings = check_code_safety(dangerous_bash, "bash")
|
||||
assert len(warnings) > 0
|
||||
|
||||
def test_sql_injection_patterns(self):
|
||||
"""Test SQL injection pattern detection."""
|
||||
sql_code = """
|
||||
query = f"SELECT * FROM users WHERE id = {user_id}"
|
||||
cursor.execute(query)
|
||||
"""
|
||||
warnings = check_code_safety(sql_code, "python")
|
||||
assert len(warnings) > 0
|
||||
|
||||
def test_format_warnings(self):
|
||||
"""Test warning formatting."""
|
||||
warnings = [
|
||||
SecurityWarning("Test warning", SecurityLevel.HIGH, "eval", 1),
|
||||
SecurityWarning("Another warning", SecurityLevel.MEDIUM, "import os", 2)
|
||||
]
|
||||
|
||||
formatted = format_warnings(warnings)
|
||||
assert "Test warning" in formatted
|
||||
assert "Another warning" in formatted
|
||||
assert "HIGH" in formatted
|
||||
assert "MEDIUM" in formatted
|
||||
|
||||
def test_format_warnings_empty(self):
|
||||
"""Test formatting empty warnings list."""
|
||||
formatted = format_warnings([])
|
||||
assert formatted == ""
|
||||
|
||||
|
||||
class TestSecurityAnalyzer:
|
||||
"""Test SecurityAnalyzer class."""
|
||||
|
||||
def test_analyzer_initialization(self):
|
||||
"""Test SecurityAnalyzer initialization."""
|
||||
analyzer = SecurityAnalyzer()
|
||||
assert analyzer is not None
|
||||
|
||||
def test_analyze_safe_code(self):
|
||||
"""Test analyzer with safe code."""
|
||||
analyzer = SecurityAnalyzer()
|
||||
code = "print('Hello, World!')"
|
||||
|
||||
warnings = analyzer.analyze(code, "python")
|
||||
assert warnings == []
|
||||
|
||||
def test_analyze_unsafe_code(self):
|
||||
"""Test analyzer with unsafe code."""
|
||||
analyzer = SecurityAnalyzer()
|
||||
code = "import os; os.system('rm -rf /')"
|
||||
|
||||
warnings = analyzer.analyze(code, "python")
|
||||
assert len(warnings) > 0
|
||||
assert all(isinstance(w, SecurityWarning) for w in warnings)
|
||||
|
||||
def test_different_languages(self):
|
||||
"""Test analyzer with different languages."""
|
||||
analyzer = SecurityAnalyzer()
|
||||
|
||||
# Python
|
||||
python_warnings = analyzer.analyze("import os", "python")
|
||||
assert len(python_warnings) > 0
|
||||
|
||||
# Bash
|
||||
bash_warnings = analyzer.analyze("rm -rf /", "bash")
|
||||
assert len(bash_warnings) > 0
|
||||
|
||||
# JavaScript (should fall back to basic checks)
|
||||
js_warnings = analyzer.analyze("eval(userInput)", "javascript")
|
||||
assert len(js_warnings) > 0
|
||||
|
||||
def test_security_levels(self):
|
||||
"""Test different security levels are detected."""
|
||||
analyzer = SecurityAnalyzer()
|
||||
|
||||
# High risk
|
||||
high_risk_code = "import os; os.system('rm -rf /')"
|
||||
warnings = analyzer.analyze(high_risk_code, "python")
|
||||
high_warnings = [w for w in warnings if w.level == SecurityLevel.HIGH]
|
||||
assert len(high_warnings) > 0
|
||||
|
||||
# Medium risk
|
||||
medium_risk_code = "import subprocess"
|
||||
warnings = analyzer.analyze(medium_risk_code, "python")
|
||||
medium_warnings = [w for w in warnings if w.level == SecurityLevel.MEDIUM]
|
||||
# May or may not have medium warnings depending on implementation
|
||||
|
||||
def test_line_numbers(self):
|
||||
"""Test that line numbers are correctly reported."""
|
||||
analyzer = SecurityAnalyzer()
|
||||
code = """
|
||||
print("Safe line")
|
||||
import os
|
||||
os.system('dangerous')
|
||||
"""
|
||||
|
||||
warnings = analyzer.analyze(code, "python")
|
||||
assert len(warnings) > 0
|
||||
|
||||
# Check that line numbers are reasonable
|
||||
line_numbers = [w.line_number for w in warnings if w.line_number is not None]
|
||||
assert all(ln > 0 for ln in line_numbers)
|
||||
assert all(ln <= 4 for ln in line_numbers) # Within the code block
|
||||
|
||||
def test_pattern_matching(self):
|
||||
"""Test that specific patterns are matched correctly."""
|
||||
analyzer = SecurityAnalyzer()
|
||||
|
||||
test_cases = [
|
||||
("eval(x)", ["eval"]),
|
||||
("exec('code')", ["exec"]),
|
||||
("__import__('os')", ["__import__"]),
|
||||
("open('/etc/passwd')", ["file access"]),
|
||||
("subprocess.call(['rm', 'file'])", ["subprocess"]),
|
||||
]
|
||||
|
||||
for code, expected_patterns in test_cases:
|
||||
warnings = analyzer.analyze(code, "python")
|
||||
assert len(warnings) > 0, f"No warnings for: {code}"
|
||||
|
||||
# Check that at least one expected pattern is found
|
||||
warning_texts = [str(w) for w in warnings]
|
||||
found = any(
|
||||
any(pattern.lower() in text.lower() for text in warning_texts)
|
||||
for pattern in expected_patterns
|
||||
)
|
||||
assert found, f"Expected patterns {expected_patterns} not found in warnings for: {code}"
|
||||
@@ -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
+628
-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.40.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.40.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"),
|
||||
|
||||
@@ -73,48 +73,52 @@ def _get_process_using_port(port: int) -> Optional[Dict]:
|
||||
elif sys.platform == "win32":
|
||||
# Use netstat on Windows
|
||||
result = subprocess.run(
|
||||
["netstat", "-ano", "|", "findstr", f":{port}"],
|
||||
["netstat", "-ano"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
shell=True,
|
||||
timeout=5
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result.returncode != 0 or not result.stdout:
|
||||
return None
|
||||
|
||||
# Parse netstat output
|
||||
# Parse netstat output and filter by exact port
|
||||
lines = result.stdout.strip().split("\n")
|
||||
for line in lines:
|
||||
if f":{port}" in line and "LISTENING" in line:
|
||||
parts = line.split()
|
||||
if len(parts) >= 5:
|
||||
pid = parts[-1]
|
||||
# Get process name from PID
|
||||
try:
|
||||
proc_result = subprocess.run(
|
||||
["tasklist", "/FI", f"PID eq {pid}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
name = "unknown"
|
||||
if proc_result.returncode == 0:
|
||||
proc_lines = proc_result.stdout.strip().split("\n")
|
||||
if len(proc_lines) >= 2:
|
||||
name = proc_lines[1].split()[0]
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"pid": pid,
|
||||
"user": "unknown",
|
||||
"type": "IPv4",
|
||||
"protocol": "TCP",
|
||||
"port": port,
|
||||
"raw": line
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
parts = line.split()
|
||||
if len(parts) >= 5 and "LISTENING" in line:
|
||||
local_addr = parts[1]
|
||||
# Extract port from address, handle IPv6 format [::]:port and IPv4 format host:port
|
||||
port_match = re.search(r":(\d+)$", local_addr)
|
||||
if not port_match or int(port_match.group(1)) != port:
|
||||
continue
|
||||
|
||||
pid = parts[-1]
|
||||
# Get process name from PID
|
||||
try:
|
||||
proc_result = subprocess.run(
|
||||
["tasklist", "/FI", f"PID eq {pid}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
name = "unknown"
|
||||
if proc_result.returncode == 0:
|
||||
proc_lines = proc_result.stdout.strip().split("\n")
|
||||
if len(proc_lines) >= 2:
|
||||
name = proc_lines[1].split()[0]
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"pid": pid,
|
||||
"user": "unknown",
|
||||
"type": "IPv4",
|
||||
"protocol": "TCP",
|
||||
"port": port,
|
||||
"raw": line
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
@@ -166,7 +170,6 @@ def _get_all_ports() -> List[Dict]:
|
||||
["netstat", "-ano"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
shell=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
|
||||
@@ -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.40 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)
|
||||
@@ -8,7 +8,7 @@ Install: pip install clickhouse-connect
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import KnowledgeStore, KnowledgeDocument
|
||||
from .base import KnowledgeStore, KnowledgeDocument, validate_identifier
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -40,6 +40,7 @@ class ClickHouseKnowledgeStore(KnowledgeStore):
|
||||
"clickhouse-connect is required for ClickHouse support. "
|
||||
"Install with: pip install clickhouse-connect"
|
||||
)
|
||||
validate_identifier(database, "database")
|
||||
|
||||
self._client = clickhouse_connect.get_client(
|
||||
host=host,
|
||||
@@ -56,6 +57,8 @@ class ClickHouseKnowledgeStore(KnowledgeStore):
|
||||
logger.info(f"Connected to ClickHouse database: {database}")
|
||||
|
||||
def _table_name(self, collection: str) -> str:
|
||||
# Validate collection name to prevent SQL injection
|
||||
validate_identifier(collection, "collection")
|
||||
return f"{self.database}.praison_vec_{collection}"
|
||||
|
||||
def create_collection(
|
||||
@@ -195,7 +198,8 @@ class ClickHouseKnowledgeStore(KnowledgeStore):
|
||||
"""Get documents by IDs."""
|
||||
table = self._table_name(collection)
|
||||
|
||||
id_list = ", ".join([f"'{i}'" for i in ids])
|
||||
# Escape single quotes to prevent SQL injection
|
||||
id_list = ", ".join([f"'{i.replace(chr(39), chr(39)+chr(39))}'" for i in ids])
|
||||
result = self._client.query(f"""
|
||||
SELECT id, content, content_hash, created_at, embedding
|
||||
FROM {table} WHERE id IN ({id_list})
|
||||
@@ -225,7 +229,8 @@ class ClickHouseKnowledgeStore(KnowledgeStore):
|
||||
table = self._table_name(collection)
|
||||
|
||||
if ids:
|
||||
id_list = ", ".join([f"'{i}'" for i in ids])
|
||||
# Escape single quotes to prevent SQL injection
|
||||
id_list = ", ".join([f"'{i.replace(chr(39), chr(39)+chr(39))}'" for i in ids])
|
||||
self._client.command(f"ALTER TABLE {table} DELETE WHERE id IN ({id_list})")
|
||||
return len(ids)
|
||||
return 0
|
||||
|
||||
@@ -8,7 +8,7 @@ Install: pip install surrealdb
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import KnowledgeStore, KnowledgeDocument
|
||||
from .base import KnowledgeStore, KnowledgeDocument, validate_identifier
|
||||
from ..._async_bridge import run_sync
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -33,8 +33,8 @@ class SurrealDBVectorKnowledgeStore(KnowledgeStore):
|
||||
url: str = "ws://localhost:8000/rpc",
|
||||
namespace: str = "praisonai",
|
||||
database: str = "vectors",
|
||||
username: str = "root",
|
||||
password: str = "root",
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
embedding_dim: int = 1536,
|
||||
):
|
||||
"""
|
||||
@@ -44,10 +44,19 @@ class SurrealDBVectorKnowledgeStore(KnowledgeStore):
|
||||
url: SurrealDB WebSocket URL
|
||||
namespace: SurrealDB namespace
|
||||
database: SurrealDB database
|
||||
username: Authentication username
|
||||
password: Authentication password
|
||||
username: Authentication username (required for security)
|
||||
password: Authentication password (required for security)
|
||||
embedding_dim: Embedding dimension
|
||||
|
||||
Raises:
|
||||
ValueError: If username or password is None (default credentials not allowed)
|
||||
"""
|
||||
if username is None or password is None:
|
||||
raise ValueError(
|
||||
"SurrealDB username and password must be provided explicitly. "
|
||||
"Default credentials are not allowed for security reasons."
|
||||
)
|
||||
|
||||
self.url = url
|
||||
self.namespace = namespace
|
||||
self.database = database
|
||||
@@ -96,12 +105,20 @@ class SurrealDBVectorKnowledgeStore(KnowledgeStore):
|
||||
) -> None:
|
||||
"""Create a collection (SurrealDB creates tables on insert)."""
|
||||
self._init_client()
|
||||
|
||||
# Validate collection name to prevent SQL injection
|
||||
validate_identifier(name, "collection name")
|
||||
|
||||
# SurrealDB creates tables automatically
|
||||
logger.info(f"Collection '{name}' will be created on first insert")
|
||||
|
||||
def delete_collection(self, name: str) -> bool:
|
||||
"""Delete a collection."""
|
||||
self._init_client()
|
||||
|
||||
# Validate collection name to prevent SQL injection
|
||||
validate_identifier(name, "collection name")
|
||||
|
||||
try:
|
||||
self._run_async(self._client.query(f"REMOVE TABLE {name}"))
|
||||
return True
|
||||
@@ -112,6 +129,10 @@ class SurrealDBVectorKnowledgeStore(KnowledgeStore):
|
||||
def collection_exists(self, name: str) -> bool:
|
||||
"""Check if collection exists."""
|
||||
self._init_client()
|
||||
|
||||
# Validate collection name to prevent SQL injection
|
||||
validate_identifier(name, "collection name")
|
||||
|
||||
try:
|
||||
result = self._run_async(self._client.query(f"INFO FOR TABLE {name}"))
|
||||
return bool(result)
|
||||
@@ -139,6 +160,9 @@ class SurrealDBVectorKnowledgeStore(KnowledgeStore):
|
||||
"""Insert documents."""
|
||||
self._init_client()
|
||||
|
||||
# Validate collection name to prevent SQL injection
|
||||
validate_identifier(collection, "collection")
|
||||
|
||||
ids = []
|
||||
for doc in documents:
|
||||
record = {
|
||||
@@ -167,6 +191,9 @@ class SurrealDBVectorKnowledgeStore(KnowledgeStore):
|
||||
"""Upsert documents."""
|
||||
self._init_client()
|
||||
|
||||
# Validate collection name to prevent SQL injection
|
||||
validate_identifier(collection, "collection")
|
||||
|
||||
ids = []
|
||||
for doc in documents:
|
||||
record = {
|
||||
@@ -198,6 +225,9 @@ class SurrealDBVectorKnowledgeStore(KnowledgeStore):
|
||||
"""Search for similar documents using vector search."""
|
||||
self._init_client()
|
||||
|
||||
# Validate collection name to prevent SQL injection
|
||||
validate_identifier(collection, "collection")
|
||||
|
||||
# SurrealDB vector search query
|
||||
embedding_str = str(query_embedding)
|
||||
query = f"""
|
||||
@@ -238,6 +268,9 @@ class SurrealDBVectorKnowledgeStore(KnowledgeStore):
|
||||
ids: List[str]
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""Get documents by IDs."""
|
||||
# Validate collection name to prevent SQL injection
|
||||
validate_identifier(collection, "collection")
|
||||
|
||||
self._init_client()
|
||||
|
||||
documents = []
|
||||
@@ -268,6 +301,9 @@ class SurrealDBVectorKnowledgeStore(KnowledgeStore):
|
||||
"""Delete documents."""
|
||||
self._init_client()
|
||||
|
||||
# Validate collection name to prevent SQL injection
|
||||
validate_identifier(collection, "collection")
|
||||
|
||||
count = 0
|
||||
if ids:
|
||||
for doc_id in ids:
|
||||
@@ -289,6 +325,9 @@ class SurrealDBVectorKnowledgeStore(KnowledgeStore):
|
||||
"""Count documents."""
|
||||
self._init_client()
|
||||
|
||||
# Validate collection name to prevent SQL injection
|
||||
validate_identifier(collection, "collection")
|
||||
|
||||
try:
|
||||
result = self._run_async(
|
||||
self._client.query(f"SELECT count() FROM {collection} GROUP ALL")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,6 +13,7 @@ if TYPE_CHECKING:
|
||||
from .ssh import SSHSandbox
|
||||
from .modal import ModalSandbox
|
||||
from .daytona import DaytonaSandbox
|
||||
from .e2b import E2BSandbox
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy loading of sandbox components."""
|
||||
@@ -34,6 +35,9 @@ def __getattr__(name: str):
|
||||
if name == "DaytonaSandbox":
|
||||
from .daytona import DaytonaSandbox
|
||||
return DaytonaSandbox
|
||||
if name == "E2BSandbox":
|
||||
from .e2b import E2BSandbox
|
||||
return E2BSandbox
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
__all__ = [
|
||||
@@ -42,5 +46,6 @@ __all__ = [
|
||||
"SandlockSandbox",
|
||||
"SSHSandbox",
|
||||
"ModalSandbox",
|
||||
"DaytonaSandbox"
|
||||
"DaytonaSandbox",
|
||||
"E2BSandbox",
|
||||
]
|
||||
@@ -0,0 +1,388 @@
|
||||
"""
|
||||
E2B Sandbox implementation for PraisonAI.
|
||||
|
||||
Provides isolated code execution using E2B cloud VMs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from praisonaiagents.sandbox import (
|
||||
SandboxConfig,
|
||||
SandboxResult,
|
||||
SandboxStatus,
|
||||
ResourceLimits,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class E2BSandbox:
|
||||
"""E2B cloud sandbox for safe code execution.
|
||||
|
||||
Executes code in isolated E2B cloud VMs with full filesystem access.
|
||||
|
||||
Example:
|
||||
import os
|
||||
from praisonai.sandbox import E2BSandbox
|
||||
|
||||
os.environ["E2B_API_KEY"] = "your-api-key"
|
||||
sandbox = E2BSandbox()
|
||||
result = await sandbox.execute("print('Hello, World!')")
|
||||
print(result.stdout) # Hello, World!
|
||||
|
||||
Requires:
|
||||
- pip install e2b-code-interpreter
|
||||
- E2B_API_KEY environment variable
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[SandboxConfig] = None):
|
||||
"""Initialize the E2B sandbox.
|
||||
|
||||
Args:
|
||||
config: Optional sandbox configuration
|
||||
"""
|
||||
self.config = config or SandboxConfig.e2b()
|
||||
self._sandbox = None
|
||||
self._is_running = False
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
"""Check if E2B is available."""
|
||||
try:
|
||||
import e2b_code_interpreter
|
||||
api_key = os.getenv("E2B_API_KEY")
|
||||
return api_key is not None and api_key.strip() != ""
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
@property
|
||||
def sandbox_type(self) -> str:
|
||||
return "e2b"
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start/initialize the sandbox environment."""
|
||||
if self._is_running:
|
||||
return
|
||||
|
||||
if not self.is_available:
|
||||
raise RuntimeError(
|
||||
"E2B is not available. Please install e2b-code-interpreter and set E2B_API_KEY"
|
||||
)
|
||||
|
||||
try:
|
||||
from e2b_code_interpreter import Sandbox
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"e2b-code-interpreter not installed. "
|
||||
"Install with: pip install e2b-code-interpreter"
|
||||
)
|
||||
|
||||
api_key = os.getenv("E2B_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError("E2B_API_KEY environment variable not set")
|
||||
|
||||
# Create E2B sandbox (this starts a cloud VM)
|
||||
self._sandbox = Sandbox(api_key=api_key)
|
||||
self._is_running = True
|
||||
logger.info("E2B sandbox initialized")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop/cleanup the sandbox environment."""
|
||||
if not self._is_running:
|
||||
return
|
||||
|
||||
if self._sandbox:
|
||||
try:
|
||||
await asyncio.get_event_loop().run_in_executor(
|
||||
None, self._sandbox.kill
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to kill E2B sandbox: {e}")
|
||||
self._sandbox = None
|
||||
|
||||
self._is_running = False
|
||||
logger.info("E2B sandbox stopped")
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
code: str,
|
||||
language: str = "python",
|
||||
limits: Optional[ResourceLimits] = None,
|
||||
env: Optional[Dict[str, str]] = None,
|
||||
working_dir: Optional[str] = None,
|
||||
) -> SandboxResult:
|
||||
"""Execute code in the sandbox."""
|
||||
if not self._is_running:
|
||||
await self.start()
|
||||
|
||||
limits = limits or self.config.resource_limits
|
||||
execution_id = str(uuid.uuid4())
|
||||
started_at = time.time()
|
||||
|
||||
try:
|
||||
if language == "python":
|
||||
result = await self._execute_python_code(code, limits, env)
|
||||
elif language == "bash":
|
||||
result = await self._execute_bash_command(code, limits, env, working_dir)
|
||||
else:
|
||||
# Default to Python for other languages
|
||||
result = await self._execute_python_code(code, limits, env)
|
||||
|
||||
result.execution_id = execution_id
|
||||
result.started_at = started_at
|
||||
result.completed_at = time.time()
|
||||
result.duration_seconds = result.completed_at - started_at
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
return SandboxResult(
|
||||
execution_id=execution_id,
|
||||
status=SandboxStatus.FAILED,
|
||||
error=str(e),
|
||||
duration_seconds=time.time() - started_at,
|
||||
started_at=started_at,
|
||||
completed_at=time.time(),
|
||||
)
|
||||
|
||||
async def _execute_python_code(
|
||||
self,
|
||||
code: str,
|
||||
limits: ResourceLimits,
|
||||
env: Optional[Dict[str, str]],
|
||||
) -> SandboxResult:
|
||||
"""Execute Python code using E2B code interpreter."""
|
||||
try:
|
||||
# Run in executor to avoid blocking the event loop
|
||||
execution = await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
lambda: self._sandbox.run_code(code, timeout=limits.timeout_seconds)
|
||||
)
|
||||
|
||||
# Extract results from E2B execution
|
||||
stdout_parts = []
|
||||
stderr_parts = []
|
||||
|
||||
for result in execution.results:
|
||||
if hasattr(result, 'text') and result.text:
|
||||
stdout_parts.append(str(result.text))
|
||||
elif hasattr(result, 'logs'):
|
||||
# Handle different result types from E2B
|
||||
for log in result.logs:
|
||||
if hasattr(log, 'line'):
|
||||
stdout_parts.append(str(log.line))
|
||||
|
||||
if execution.error:
|
||||
stderr_parts.append(execution.error.traceback if execution.error.traceback else str(execution.error))
|
||||
exit_code = 1
|
||||
status = SandboxStatus.FAILED
|
||||
else:
|
||||
exit_code = 0
|
||||
status = SandboxStatus.COMPLETED
|
||||
|
||||
return SandboxResult(
|
||||
status=status,
|
||||
exit_code=exit_code,
|
||||
stdout="\n".join(stdout_parts) if stdout_parts else "",
|
||||
stderr="\n".join(stderr_parts) if stderr_parts else "",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
if "timeout" in str(e).lower():
|
||||
return SandboxResult(
|
||||
status=SandboxStatus.TIMEOUT,
|
||||
error=f"Execution timed out after {limits.timeout_seconds}s",
|
||||
)
|
||||
else:
|
||||
return SandboxResult(
|
||||
status=SandboxStatus.FAILED,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
async def _execute_bash_command(
|
||||
self,
|
||||
command: str,
|
||||
limits: ResourceLimits,
|
||||
env: Optional[Dict[str, str]],
|
||||
working_dir: Optional[str],
|
||||
) -> SandboxResult:
|
||||
"""Execute bash command using E2B terminal."""
|
||||
try:
|
||||
# Set environment variables
|
||||
if env:
|
||||
import shlex
|
||||
for key, value in env.items():
|
||||
await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
lambda k=key, v=value: self._sandbox.commands.run(f"export {shlex.quote(k)}={shlex.quote(v)}", timeout=5)
|
||||
)
|
||||
|
||||
# Change directory if needed
|
||||
if working_dir:
|
||||
import shlex
|
||||
await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
lambda: self._sandbox.commands.run(f"cd {shlex.quote(working_dir)}", timeout=5)
|
||||
)
|
||||
|
||||
# Execute the command
|
||||
result = await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
lambda: self._sandbox.commands.run(command, timeout=limits.timeout_seconds)
|
||||
)
|
||||
|
||||
return SandboxResult(
|
||||
status=SandboxStatus.COMPLETED if result.exit_code == 0 else SandboxStatus.FAILED,
|
||||
exit_code=result.exit_code,
|
||||
stdout=result.stdout or "",
|
||||
stderr=result.stderr or "",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
if "timeout" in str(e).lower():
|
||||
return SandboxResult(
|
||||
status=SandboxStatus.TIMEOUT,
|
||||
error=f"Command timed out after {limits.timeout_seconds}s",
|
||||
)
|
||||
else:
|
||||
return SandboxResult(
|
||||
status=SandboxStatus.FAILED,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
async def execute_file(
|
||||
self,
|
||||
file_path: str,
|
||||
args: Optional[List[str]] = None,
|
||||
limits: Optional[ResourceLimits] = None,
|
||||
env: Optional[Dict[str, str]] = None,
|
||||
) -> SandboxResult:
|
||||
"""Execute a file in the sandbox."""
|
||||
try:
|
||||
# Read file content and execute
|
||||
with open(file_path, "r") as f:
|
||||
code = f.read()
|
||||
|
||||
if file_path.endswith(".py"):
|
||||
return await self.execute(code, language="python", limits=limits, env=env)
|
||||
elif file_path.endswith((".sh", ".bash")):
|
||||
return await self.execute(code, language="bash", limits=limits, env=env)
|
||||
else:
|
||||
# Try to execute as Python
|
||||
return await self.execute(code, language="python", limits=limits, env=env)
|
||||
|
||||
except FileNotFoundError:
|
||||
return SandboxResult(
|
||||
status=SandboxStatus.FAILED,
|
||||
error=f"File not found: {file_path}",
|
||||
)
|
||||
except Exception as e:
|
||||
return SandboxResult(
|
||||
status=SandboxStatus.FAILED,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
async def run_command(
|
||||
self,
|
||||
command: Union[str, List[str]],
|
||||
limits: Optional[ResourceLimits] = None,
|
||||
env: Optional[Dict[str, str]] = None,
|
||||
working_dir: Optional[str] = None,
|
||||
) -> SandboxResult:
|
||||
"""Run a shell command in the sandbox."""
|
||||
if not self._is_running:
|
||||
await self.start()
|
||||
|
||||
if isinstance(command, list):
|
||||
command = " ".join(command)
|
||||
|
||||
return await self._execute_bash_command(command, limits or self.config.resource_limits, env, working_dir)
|
||||
|
||||
async def write_file(
|
||||
self,
|
||||
path: str,
|
||||
content: Union[str, bytes],
|
||||
) -> bool:
|
||||
"""Write a file to the sandbox."""
|
||||
if not self._is_running:
|
||||
await self.start()
|
||||
|
||||
try:
|
||||
if isinstance(content, bytes):
|
||||
content = content.decode("utf-8")
|
||||
|
||||
await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
lambda: self._sandbox.files.write(path, content)
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to write file {path}: {e}")
|
||||
return False
|
||||
|
||||
async def read_file(
|
||||
self,
|
||||
path: str,
|
||||
) -> Optional[Union[str, bytes]]:
|
||||
"""Read a file from the sandbox."""
|
||||
if not self._is_running:
|
||||
await self.start()
|
||||
|
||||
try:
|
||||
content = await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
lambda: self._sandbox.files.read(path)
|
||||
)
|
||||
return content
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read file {path}: {e}")
|
||||
return None
|
||||
|
||||
async def list_files(
|
||||
self,
|
||||
path: str = "/",
|
||||
) -> List[str]:
|
||||
"""List files in a sandbox directory."""
|
||||
if not self._is_running:
|
||||
await self.start()
|
||||
|
||||
try:
|
||||
# Use ls command to list files
|
||||
result = await self.run_command(f"find {path} -type f")
|
||||
if result.success:
|
||||
files = [f.strip() for f in result.stdout.split("\n") if f.strip()]
|
||||
return files
|
||||
return []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""Get sandbox status information."""
|
||||
return {
|
||||
"available": self.is_available,
|
||||
"type": self.sandbox_type,
|
||||
"running": self._is_running,
|
||||
"api_key_set": bool(os.getenv("E2B_API_KEY")),
|
||||
}
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
"""Clean up sandbox resources."""
|
||||
if self._sandbox:
|
||||
try:
|
||||
# E2B sandbox cleanup is handled by the platform
|
||||
# We can optionally clear files or reset state here
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"Cleanup error: {e}")
|
||||
|
||||
async def reset(self) -> None:
|
||||
"""Reset sandbox to initial state."""
|
||||
await self.stop()
|
||||
await self.start()
|
||||
@@ -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.40"
|
||||
__version__ = "4.6.43"
|
||||
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"rich>=13.7",
|
||||
"markdown>=3.5",
|
||||
"pyparsing>=3.0.0",
|
||||
"praisonaiagents>=1.6.40",
|
||||
"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",
|
||||
|
||||
@@ -20,13 +20,23 @@ if [[ -z "$EXPECTED_VERSION" ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "==> Running official installer one-liner"
|
||||
if [[ -n "$EXTRAS" ]]; then
|
||||
PRAISONAI_EXTRAS="$EXTRAS" curl -fsSL "$INSTALL_URL" | bash
|
||||
else
|
||||
curl -fsSL "$INSTALL_URL" | bash
|
||||
echo "==> Running official installer"
|
||||
INSTALLER_SCRIPT="/tmp/praisonai-install.sh"
|
||||
curl -fsSL "$INSTALL_URL" -o "$INSTALLER_SCRIPT"
|
||||
|
||||
if [[ ! -f "$INSTALLER_SCRIPT" ]]; then
|
||||
echo "ERROR: Failed to download installer from $INSTALL_URL"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "$EXTRAS" ]]; then
|
||||
PRAISONAI_EXTRAS="$EXTRAS" bash "$INSTALLER_SCRIPT"
|
||||
else
|
||||
bash "$INSTALLER_SCRIPT"
|
||||
fi
|
||||
|
||||
rm -f "$INSTALLER_SCRIPT"
|
||||
|
||||
echo "==> Verify installation"
|
||||
|
||||
# Source the venv if it exists
|
||||
|
||||
@@ -173,7 +173,16 @@ install_python_macos() {
|
||||
if [[ "$DRY_RUN" == "1" ]]; then
|
||||
log_info "[DRY RUN] Would install Homebrew"
|
||||
else
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
# Download and verify Homebrew installer
|
||||
HOMEBREW_INSTALLER="/tmp/homebrew-install.sh"
|
||||
curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh -o "$HOMEBREW_INSTALLER"
|
||||
if [[ -f "$HOMEBREW_INSTALLER" ]]; then
|
||||
/bin/bash "$HOMEBREW_INSTALLER"
|
||||
rm -f "$HOMEBREW_INSTALLER"
|
||||
else
|
||||
log_error "Failed to download Homebrew installer"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Add Homebrew to PATH for this session
|
||||
if [[ -f "/opt/homebrew/bin/brew" ]]; then
|
||||
|
||||
@@ -151,3 +151,24 @@ def test_cassandra_rejects_malicious_collection(monkeypatch):
|
||||
store.delete_collection("x; DROP TABLE users; --")
|
||||
with pytest.raises(ValueError):
|
||||
store.count("x; DROP TABLE users; --")
|
||||
|
||||
|
||||
def test_clickhouse_rejects_malicious_database(monkeypatch):
|
||||
clickhouse_connect = types.ModuleType("clickhouse_connect")
|
||||
clickhouse_connect.get_client = lambda **kwargs: object()
|
||||
monkeypatch.setitem(sys.modules, "clickhouse_connect", clickhouse_connect)
|
||||
|
||||
from praisonai.persistence.knowledge.clickhouse import ClickHouseKnowledgeStore
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
ClickHouseKnowledgeStore(database="db; DROP DATABASE prod; --")
|
||||
|
||||
|
||||
def test_surrealdb_rejects_malicious_collection_on_get():
|
||||
from praisonai.persistence.knowledge.surrealdb_vector import (
|
||||
SurrealDBVectorKnowledgeStore,
|
||||
)
|
||||
|
||||
store = SurrealDBVectorKnowledgeStore(username="user", password="pass")
|
||||
with pytest.raises(ValueError):
|
||||
store.get("x; DROP TABLE users; --", ["doc-1"])
|
||||
@@ -0,0 +1 @@
|
||||
# Sandbox tests package
|
||||
@@ -0,0 +1,327 @@
|
||||
"""
|
||||
Unit tests for E2B sandbox implementation.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import os
|
||||
from unittest.mock import Mock, AsyncMock, patch
|
||||
from praisonai.sandbox.e2b import E2BSandbox
|
||||
from praisonaiagents.sandbox import SandboxConfig, SandboxResult, SandboxStatus
|
||||
|
||||
|
||||
class TestE2BSandbox:
|
||||
"""Test E2B sandbox implementation."""
|
||||
|
||||
def test_init_default(self):
|
||||
"""Test initialization with defaults."""
|
||||
sandbox = E2BSandbox()
|
||||
assert sandbox.config is not None
|
||||
assert sandbox.sandbox_type == "e2b"
|
||||
assert not sandbox._is_running
|
||||
|
||||
def test_init_with_config(self):
|
||||
"""Test initialization with custom config."""
|
||||
config = SandboxConfig.e2b()
|
||||
sandbox = E2BSandbox(config)
|
||||
assert sandbox.config == config
|
||||
|
||||
@patch.dict(os.environ, {"E2B_API_KEY": "test-key"})
|
||||
@patch('importlib.import_module')
|
||||
def test_is_available_true(self, mock_import):
|
||||
"""Test is_available returns True when E2B is properly set up."""
|
||||
sandbox = E2BSandbox()
|
||||
assert sandbox.is_available is True
|
||||
|
||||
def test_is_available_no_api_key(self):
|
||||
"""Test is_available returns False without API key."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
sandbox = E2BSandbox()
|
||||
assert sandbox.is_available is False
|
||||
|
||||
@patch('importlib.import_module', side_effect=ImportError())
|
||||
def test_is_available_no_module(self, mock_import):
|
||||
"""Test is_available returns False without module."""
|
||||
sandbox = E2BSandbox()
|
||||
assert sandbox.is_available is False
|
||||
|
||||
@patch.dict(os.environ, {"E2B_API_KEY": "test-key"})
|
||||
@patch('e2b_code_interpreter.Sandbox')
|
||||
async def test_start_success(self, mock_sandbox_class):
|
||||
"""Test successful sandbox start."""
|
||||
mock_instance = Mock()
|
||||
mock_sandbox_class.return_value = mock_instance
|
||||
|
||||
sandbox = E2BSandbox()
|
||||
|
||||
with patch.object(sandbox, 'is_available', True):
|
||||
await sandbox.start()
|
||||
|
||||
assert sandbox._is_running
|
||||
assert sandbox._sandbox == mock_instance
|
||||
mock_sandbox_class.assert_called_once_with(api_key="test-key")
|
||||
|
||||
async def test_start_not_available(self):
|
||||
"""Test start when not available."""
|
||||
sandbox = E2BSandbox()
|
||||
|
||||
with patch.object(sandbox, 'is_available', False):
|
||||
with pytest.raises(RuntimeError, match="E2B is not available"):
|
||||
await sandbox.start()
|
||||
|
||||
async def test_start_no_api_key(self):
|
||||
"""Test start without API key."""
|
||||
sandbox = E2BSandbox()
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
with patch.object(sandbox, 'is_available', True):
|
||||
with pytest.raises(ValueError, match="E2B_API_KEY"):
|
||||
await sandbox.start()
|
||||
|
||||
async def test_start_already_running(self):
|
||||
"""Test start when already running."""
|
||||
sandbox = E2BSandbox()
|
||||
sandbox._is_running = True
|
||||
|
||||
await sandbox.start() # Should not raise or do anything
|
||||
|
||||
async def test_stop_success(self):
|
||||
"""Test successful sandbox stop."""
|
||||
mock_sandbox = Mock()
|
||||
|
||||
sandbox = E2BSandbox()
|
||||
sandbox._sandbox = mock_sandbox
|
||||
sandbox._is_running = True
|
||||
|
||||
with patch('asyncio.get_event_loop') as mock_loop:
|
||||
mock_executor = AsyncMock()
|
||||
mock_loop.return_value.run_in_executor.return_value = mock_executor
|
||||
|
||||
await sandbox.stop()
|
||||
|
||||
assert not sandbox._is_running
|
||||
assert sandbox._sandbox is None
|
||||
|
||||
async def test_stop_with_error(self):
|
||||
"""Test stop with cleanup error."""
|
||||
mock_sandbox = Mock()
|
||||
mock_sandbox.kill.side_effect = Exception("Kill failed")
|
||||
|
||||
sandbox = E2BSandbox()
|
||||
sandbox._sandbox = mock_sandbox
|
||||
sandbox._is_running = True
|
||||
|
||||
with patch('asyncio.get_event_loop') as mock_loop:
|
||||
mock_loop.return_value.run_in_executor.side_effect = Exception("Kill failed")
|
||||
|
||||
await sandbox.stop() # Should not raise
|
||||
|
||||
assert not sandbox._is_running
|
||||
|
||||
async def test_execute_python_code(self):
|
||||
"""Test Python code execution."""
|
||||
sandbox = E2BSandbox()
|
||||
sandbox._is_running = False
|
||||
|
||||
mock_execution = Mock()
|
||||
mock_result = Mock()
|
||||
mock_result.text = "Hello, World!"
|
||||
mock_execution.results = [mock_result]
|
||||
mock_execution.error = None
|
||||
|
||||
with patch.object(sandbox, 'start') as mock_start:
|
||||
with patch('asyncio.get_event_loop') as mock_loop:
|
||||
mock_loop.return_value.run_in_executor.return_value = mock_execution
|
||||
|
||||
result = await sandbox.execute("print('Hello, World!')", language="python")
|
||||
|
||||
mock_start.assert_called_once()
|
||||
assert result.status == SandboxStatus.COMPLETED
|
||||
assert result.exit_code == 0
|
||||
assert "Hello, World!" in result.stdout
|
||||
|
||||
async def test_execute_python_code_with_error(self):
|
||||
"""Test Python code execution with error."""
|
||||
sandbox = E2BSandbox()
|
||||
sandbox._is_running = True
|
||||
|
||||
mock_execution = Mock()
|
||||
mock_execution.results = []
|
||||
mock_error = Mock()
|
||||
mock_error.traceback = "Traceback: error"
|
||||
mock_execution.error = mock_error
|
||||
|
||||
with patch('asyncio.get_event_loop') as mock_loop:
|
||||
mock_loop.return_value.run_in_executor.return_value = mock_execution
|
||||
|
||||
result = await sandbox.execute("invalid code", language="python")
|
||||
|
||||
assert result.status == SandboxStatus.FAILED
|
||||
assert result.exit_code == 1
|
||||
assert "Traceback: error" in result.stderr
|
||||
|
||||
async def test_execute_bash_command(self):
|
||||
"""Test bash command execution."""
|
||||
sandbox = E2BSandbox()
|
||||
sandbox._is_running = True
|
||||
sandbox._sandbox = Mock()
|
||||
|
||||
mock_command_result = Mock()
|
||||
mock_command_result.exit_code = 0
|
||||
mock_command_result.stdout = "command output"
|
||||
mock_command_result.stderr = ""
|
||||
|
||||
with patch('asyncio.get_event_loop') as mock_loop:
|
||||
mock_loop.return_value.run_in_executor.return_value = mock_command_result
|
||||
|
||||
result = await sandbox.execute("echo hello", language="bash")
|
||||
|
||||
assert result.status == SandboxStatus.COMPLETED
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout == "command output"
|
||||
|
||||
async def test_execute_bash_command_with_env_and_workdir(self):
|
||||
"""Test bash command with environment and working directory."""
|
||||
sandbox = E2BSandbox()
|
||||
sandbox._is_running = True
|
||||
sandbox._sandbox = Mock()
|
||||
|
||||
mock_command_result = Mock()
|
||||
mock_command_result.exit_code = 0
|
||||
mock_command_result.stdout = "output"
|
||||
mock_command_result.stderr = ""
|
||||
|
||||
with patch('asyncio.get_event_loop') as mock_loop:
|
||||
mock_loop.return_value.run_in_executor.return_value = mock_command_result
|
||||
|
||||
# Test that shlex.quote is used for security
|
||||
env = {"TEST_VAR": "test value with spaces"}
|
||||
working_dir = "/path with spaces"
|
||||
|
||||
result = await sandbox.execute(
|
||||
"echo $TEST_VAR",
|
||||
language="bash",
|
||||
env=env,
|
||||
working_dir=working_dir
|
||||
)
|
||||
|
||||
# Verify the command was executed
|
||||
assert result.status == SandboxStatus.COMPLETED
|
||||
|
||||
async def test_execute_timeout(self):
|
||||
"""Test execution timeout handling."""
|
||||
sandbox = E2BSandbox()
|
||||
sandbox._is_running = True
|
||||
|
||||
with patch('asyncio.get_event_loop') as mock_loop:
|
||||
mock_loop.return_value.run_in_executor.side_effect = Exception("timeout occurred")
|
||||
|
||||
result = await sandbox.execute("sleep 100", language="bash")
|
||||
|
||||
assert result.status == SandboxStatus.FAILED
|
||||
|
||||
async def test_write_file(self):
|
||||
"""Test writing file to sandbox."""
|
||||
sandbox = E2BSandbox()
|
||||
sandbox._is_running = True
|
||||
sandbox._sandbox = Mock()
|
||||
|
||||
with patch('asyncio.get_event_loop') as mock_loop:
|
||||
mock_loop.return_value.run_in_executor.return_value = None
|
||||
|
||||
success = await sandbox.write_file("/test.txt", "content")
|
||||
|
||||
assert success is True
|
||||
|
||||
async def test_write_file_error(self):
|
||||
"""Test writing file with error."""
|
||||
sandbox = E2BSandbox()
|
||||
sandbox._is_running = True
|
||||
sandbox._sandbox = Mock()
|
||||
|
||||
with patch('asyncio.get_event_loop') as mock_loop:
|
||||
mock_loop.return_value.run_in_executor.side_effect = Exception("Write failed")
|
||||
|
||||
success = await sandbox.write_file("/test.txt", "content")
|
||||
|
||||
assert success is False
|
||||
|
||||
async def test_read_file(self):
|
||||
"""Test reading file from sandbox."""
|
||||
sandbox = E2BSandbox()
|
||||
sandbox._is_running = True
|
||||
sandbox._sandbox = Mock()
|
||||
|
||||
with patch('asyncio.get_event_loop') as mock_loop:
|
||||
mock_loop.return_value.run_in_executor.return_value = "file content"
|
||||
|
||||
content = await sandbox.read_file("/test.txt")
|
||||
|
||||
assert content == "file content"
|
||||
|
||||
async def test_read_file_error(self):
|
||||
"""Test reading file with error."""
|
||||
sandbox = E2BSandbox()
|
||||
sandbox._is_running = True
|
||||
sandbox._sandbox = Mock()
|
||||
|
||||
with patch('asyncio.get_event_loop') as mock_loop:
|
||||
mock_loop.return_value.run_in_executor.side_effect = Exception("Read failed")
|
||||
|
||||
content = await sandbox.read_file("/test.txt")
|
||||
|
||||
assert content is None
|
||||
|
||||
async def test_list_files(self):
|
||||
"""Test listing files in sandbox."""
|
||||
sandbox = E2BSandbox()
|
||||
|
||||
mock_result = SandboxResult(
|
||||
status=SandboxStatus.COMPLETED,
|
||||
stdout="/file1.txt\n/file2.py\n",
|
||||
success=True
|
||||
)
|
||||
|
||||
with patch.object(sandbox, 'run_command', return_value=mock_result):
|
||||
files = await sandbox.list_files("/")
|
||||
|
||||
assert files == ["/file1.txt", "/file2.py"]
|
||||
|
||||
async def test_list_files_error(self):
|
||||
"""Test listing files with error."""
|
||||
sandbox = E2BSandbox()
|
||||
|
||||
with patch.object(sandbox, 'run_command', side_effect=Exception("List failed")):
|
||||
files = await sandbox.list_files("/")
|
||||
|
||||
assert files == []
|
||||
|
||||
def test_get_status(self):
|
||||
"""Test getting sandbox status."""
|
||||
sandbox = E2BSandbox()
|
||||
|
||||
with patch.dict(os.environ, {"E2B_API_KEY": "test-key"}):
|
||||
with patch.object(sandbox, 'is_available', True):
|
||||
status = sandbox.get_status()
|
||||
|
||||
assert status["available"] is True
|
||||
assert status["type"] == "e2b"
|
||||
assert status["running"] is False
|
||||
assert status["api_key_set"] is True
|
||||
|
||||
async def test_cleanup(self):
|
||||
"""Test sandbox cleanup."""
|
||||
sandbox = E2BSandbox()
|
||||
|
||||
# Should not raise
|
||||
await sandbox.cleanup()
|
||||
|
||||
async def test_reset(self):
|
||||
"""Test sandbox reset."""
|
||||
sandbox = E2BSandbox()
|
||||
|
||||
with patch.object(sandbox, 'stop') as mock_stop:
|
||||
with patch.object(sandbox, 'start') as mock_start:
|
||||
await sandbox.reset()
|
||||
|
||||
mock_stop.assert_called_once()
|
||||
mock_start.assert_called_once()
|
||||
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
+355
-158
@@ -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"
|
||||
@@ -61,18 +92,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "agentops"
|
||||
version = "0.3.16"
|
||||
version = "0.4.21"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "httpx" },
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-exporter-otlp-proto-http" },
|
||||
{ name = "opentelemetry-instrumentation" },
|
||||
{ name = "opentelemetry-sdk" },
|
||||
{ name = "opentelemetry-semantic-conventions" },
|
||||
{ name = "ordered-set" },
|
||||
{ name = "packaging" },
|
||||
{ name = "psutil" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "requests" },
|
||||
{ name = "termcolor" },
|
||||
{ name = "wrapt" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/74/8e77e654b37a5e0c977eca4f7e92740c1e24be39c827815e7bd8da429003/agentops-0.3.16.tar.gz", hash = "sha256:564163eb048939d64e848c7e6caf25d6c0aee31200623ef97efe492f090f8939", size = 51308, upload-time = "2024-11-09T18:44:23.037Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0a/c4/023fe976169c57b1edd71f4c08d6dedaf66814f5b25ecf59b3a8540311ab/agentops-0.4.21.tar.gz", hash = "sha256:47759c6dfd6ea58bad2f7764257e4778cb2e34ae180cef642f60f56adced6510", size = 430861, upload-time = "2025-08-29T06:36:55.323Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/76/e1c933480ec9ad093a841321e5c9f7f16a0af59f339ba2c840851b1af01d/agentops-0.3.16-py3-none-any.whl", hash = "sha256:7763e65efe053fa81cea2a2e16f015c7603365280972e0c0709eec32c3c8569e", size = 55351, upload-time = "2024-11-09T18:44:21.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/63/3e48da56d5121ddcefef8645ad5a3446b0974154111a14bf75ea2b5b3cc3/agentops-0.4.21-py3-none-any.whl", hash = "sha256:93b098ea77bc5f64dcae5031a8292531cb446d9d66e6c7ef2f21a66d4e4fb2f0", size = 309579, upload-time = "2025-08-29T06:36:53.855Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -253,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]]
|
||||
@@ -462,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"
|
||||
@@ -653,6 +702,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/13/6f/7b45ed2ca300c1ad38ecfc82c1368546d4a90512d9dff589ebbd182a7317/botocore-1.42.66-py3-none-any.whl", hash = "sha256:ac48af1ab527dfa08c4617c387413ca56a7f87780d7bfc1da34ef847a59219a5", size = 14653886, upload-time = "2026-03-11T19:58:04.922Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bracex"
|
||||
version = "2.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/63/9a/fec38644694abfaaeca2798b58e276a8e61de49e2e37494ace423395febc/bracex-2.6.tar.gz", hash = "sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7", size = 26642, upload-time = "2025-06-22T19:12:31.254Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/2a/9186535ce58db529927f6cf5990a849aa9e052eea3e2cfefe20b9e1802da/bracex-2.6-py3-none-any.whl", hash = "sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952", size = 11508, upload-time = "2025-06-22T19:12:29.781Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "1.1.0"
|
||||
@@ -1306,6 +1364,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dockerfile-parse"
|
||||
version = "2.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/92/df/929ee0b5d2c8bd8d713c45e71b94ab57c7e11e322130724d54f469b2cd48/dockerfile-parse-2.0.1.tar.gz", hash = "sha256:3184ccdc513221983e503ac00e1aa504a2aa8f84e5de673c46b0b6eee99ec7bc", size = 24556, upload-time = "2023-07-18T13:36:07.897Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/6c/79cd5bc1b880d8c1a9a5550aa8dacd57353fa3bb2457227e1fb47383eb49/dockerfile_parse-2.0.1-py2.py3-none-any.whl", hash = "sha256:bdffd126d2eb26acf1066acb54cb2e336682e1d72b974a40894fac76a4df17f6", size = 14845, upload-time = "2023-07-18T13:36:06.052Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "docstring-parser"
|
||||
version = "0.16"
|
||||
@@ -1324,6 +1391,43 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/a3/ac312faeceffd2d8f86bc6dcb5c401188ba5a01bc88e69bed97578a0dfcd/durationpy-0.9-py3-none-any.whl", hash = "sha256:e65359a7af5cedad07fb77a2dd3f390f8eb0b74cb845589fa6c057086834dd38", size = 3461, upload-time = "2024-10-02T17:58:59.349Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "e2b"
|
||||
version = "2.21.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "dockerfile-parse" },
|
||||
{ name = "h2" },
|
||||
{ name = "httpcore", version = "1.0.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" },
|
||||
{ name = "httpcore", version = "1.0.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" },
|
||||
{ name = "httpx" },
|
||||
{ name = "packaging" },
|
||||
{ name = "protobuf" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "rich" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "wcmatch" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f7/97/0e86ccb9e05c18e6e795e0808f14e2dc9f5c9ffb7be2a5cb77afd6d9f59e/e2b-2.21.1.tar.gz", hash = "sha256:2eff473ca03173cee1ccd9f9ec9e90c2b4705cca418e080e3104753d7ec33490", size = 157458, upload-time = "2026-05-14T17:36:02.318Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/d4/8b6a9a120e724dd8f91aededa89348a667a01fffbba28ae1a42cb397b0f0/e2b-2.21.1-py3-none-any.whl", hash = "sha256:9ec4646f3dba4a6da855baa8adeab239aa988e15904611e38bf12aeec2562ac9", size = 297476, upload-time = "2026-05-14T17:36:00.351Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "e2b-code-interpreter"
|
||||
version = "2.6.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "e2b" },
|
||||
{ name = "httpx" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/dc/0a/59c95fb95ad3c7caba4828b230188abf3b53acaf09b99d45a10a861972fd/e2b_code_interpreter-2.6.2.tar.gz", hash = "sha256:ffd3b52be945b50b57b0ed110555b3a0d1d49267d2d490fd56566775e897b3d3", size = 10659, upload-time = "2026-04-30T18:28:54.192Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/8d/6f7bc9547db0299eb544c4ae736748176945ada55dbe6279ba20739ae76e/e2b_code_interpreter-2.6.2-py3-none-any.whl", hash = "sha256:4cbcecf805895571a49397506e23e6bdcf3ec18f437989fb963a841b26d60619", size = 13716, upload-time = "2026-04-30T18:28:52.698Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.19.1"
|
||||
@@ -1619,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"
|
||||
@@ -1649,6 +1787,9 @@ wheels = [
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
pyopenssl = [
|
||||
{ name = "pyopenssl" },
|
||||
]
|
||||
requests = [
|
||||
{ name = "requests" },
|
||||
]
|
||||
@@ -1718,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 = [
|
||||
@@ -1849,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"
|
||||
@@ -2773,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" },
|
||||
@@ -3970,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" },
|
||||
@@ -4006,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" },
|
||||
@@ -4024,48 +4142,72 @@ 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.62b1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-semantic-conventions" },
|
||||
{ name = "packaging" },
|
||||
{ name = "wrapt" },
|
||||
]
|
||||
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/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]]
|
||||
name = "ordered-set"
|
||||
version = "4.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4c/ca/bfac8bc689799bcca4157e0e0ced07e70ce125193fc2e166d2e685b7e2fe/ordered-set-4.1.0.tar.gz", hash = "sha256:694a8e44c87657c59292ede72891eb91d34131f6531463aab3009191c77364a8", size = 12826, upload-time = "2022-01-26T14:38:56.6Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/33/55/af02708f230eb77084a299d7b08175cff006dea4f2721074b92cdb0296c0/ordered_set-4.1.0-py3-none-any.whl", hash = "sha256:046e1132c71fcf3330438a539928932caf51ddbc582496833e23de611de14562", size = 7634, upload-time = "2022-01-26T14:38:48.677Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4191,11 +4333,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "23.2"
|
||||
version = "24.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fb/2b/9b9c33ffed44ee921d0967086d653047286054117d584f1b1a7c22ceaf7b/packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", size = 146714, upload-time = "2023-10-01T13:50:05.279Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950, upload-time = "2024-11-08T09:47:47.202Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/1a/610693ac4ee14fcdf2d9bf3c493370e4f2ef7ae2e19217d7a237ff42367d/packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7", size = 53011, upload-time = "2023-10-01T13:50:03.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451, upload-time = "2024-11-08T09:47:44.722Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4841,7 +4983,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.40"
|
||||
version = "1.6.43"
|
||||
source = { directory = "../praisonai-agents" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -4854,11 +4996,14 @@ dependencies = [
|
||||
|
||||
[package.optional-dependencies]
|
||||
all = [
|
||||
{ name = "a2a-sdk" },
|
||||
{ name = "a2ui-agent-sdk" },
|
||||
{ name = "ast-grep-cli" },
|
||||
{ name = "chonkie" },
|
||||
{ name = "chromadb" },
|
||||
{ name = "crawl4ai" },
|
||||
{ name = "ddgs" },
|
||||
{ name = "e2b-code-interpreter" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "litellm" },
|
||||
{ name = "markitdown", extra = ["all"] },
|
||||
@@ -4892,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" },
|
||||
@@ -4900,6 +5047,8 @@ requires-dist = [
|
||||
{ name = "chromadb", marker = "extra == 'memory'", specifier = ">=1.0.0" },
|
||||
{ name = "crawl4ai", marker = "extra == 'crawl'", specifier = ">=0.4.0" },
|
||||
{ name = "ddgs", marker = "extra == 'search'", specifier = ">=9.0.0" },
|
||||
{ name = "docker", marker = "extra == 'sandbox-docker'", specifier = ">=7.0.0" },
|
||||
{ name = "e2b-code-interpreter", marker = "extra == 'sandbox'", specifier = ">=1.0.0" },
|
||||
{ name = "fastapi", marker = "extra == 'api'", specifier = ">=0.115.0" },
|
||||
{ name = "fastapi", marker = "extra == 'mcp'", specifier = ">=0.115.0" },
|
||||
{ name = "fastapi", marker = "extra == 'os'", specifier = ">=0.115.0" },
|
||||
@@ -4915,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'" },
|
||||
@@ -4926,6 +5076,7 @@ requires-dist = [
|
||||
{ name = "praisonaiagents", extras = ["memory"], marker = "extra == 'all'" },
|
||||
{ name = "praisonaiagents", extras = ["mongodb"], marker = "extra == 'all'" },
|
||||
{ name = "praisonaiagents", extras = ["os"], marker = "extra == 'all'" },
|
||||
{ name = "praisonaiagents", extras = ["sandbox"], marker = "extra == 'all'" },
|
||||
{ name = "praisonaiagents", extras = ["search"], marker = "extra == 'all'" },
|
||||
{ name = "praisonaiagents", extras = ["telemetry"], marker = "extra == 'all'" },
|
||||
{ name = "pydantic", specifier = ">=2.10.0" },
|
||||
@@ -4942,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", "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"
|
||||
@@ -5064,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]]
|
||||
@@ -6087,7 +6238,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.3"
|
||||
version = "2.34.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
@@ -6095,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]]
|
||||
@@ -6127,16 +6278,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "13.9.4"
|
||||
version = "14.3.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "pygments" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149, upload-time = "2024-11-01T16:43:57.873Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e9/67/cae617f1351490c25a4b8ac3b8b63a4dda609295d8222bad12242dfdc629/rich-14.3.4.tar.gz", hash = "sha256:817e02727f2b25b40ef56f5aa2217f400c8489f79ca8f46ea2b70dd5e14558a9", size = 230524, upload-time = "2026-04-11T02:57:45.419Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424, upload-time = "2024-11-01T16:43:55.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/76/6d163cfac87b632216f71879e6b2cf17163f773ff59c00b5ff4900a80fa3/rich-14.3.4-py3-none-any.whl", hash = "sha256:07e7adb4690f68864777b1450859253bed81a99a31ac321ac1817b2313558952", size = 310480, upload-time = "2026-04-11T02:57:47.484Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6720,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]]
|
||||
@@ -7162,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"
|
||||
@@ -7330,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"
|
||||
@@ -7395,6 +7589,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/c4/08b3c2cda45db5169148a981c2100c744a4a222fa7ae7644937c0c002069/watchfiles-0.24.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01550ccf1d0aed6ea375ef259706af76ad009ef5b0203a3a4cce0f6024f9b68a", size = 426804, upload-time = "2024-08-28T16:21:30.687Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wcmatch"
|
||||
version = "10.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "bracex" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/79/3e/c0bdc27cf06f4e47680bd5803a07cb3dfd17de84cde92dd217dcb9e05253/wcmatch-10.1.tar.gz", hash = "sha256:f11f94208c8c8484a16f4f48638a85d771d9513f4ab3f37595978801cb9465af", size = 117421, upload-time = "2025-06-22T19:14:02.49Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/d8/0d1d2e9d3fabcf5d6840362adcf05f8cf3cd06a73358140c3a97189238ae/wcmatch-10.1-py3-none-any.whl", hash = "sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a", size = 39854, upload-time = "2025-06-22T19:14:00.978Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webencodings"
|
||||
version = "0.5.1"
|
||||
@@ -7415,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