mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-11 03:31:27 -10:00
Compare commits
45
Commits
add-valkey
...
v4.6.46
No files matched your search
@@ -171,6 +171,7 @@ Methods:
|
||||
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">safe_run</a>(**kwargs) -> ToolResult</code>
|
||||
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">validate</a>() -> bool</code>
|
||||
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">validate_class</a>() -> bool</code>
|
||||
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">validate_schema_roundtrip</a>() -> bool</code>
|
||||
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">__call__</a>(*args, **kwargs) -> Any</code>
|
||||
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">check_availability</a>() -> tuple[bool, str]</code>
|
||||
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">injected_params</a>() -> Dict[str, Any]</code>
|
||||
@@ -477,8 +478,10 @@ Methods:
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">edit_skill</a>(name: str, content: str) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_allowed_tools</a>(name: str) -> List[str]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_available_skills</a>() -> List[SkillMetadata]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_available_skills_by_state</a>(state: SkillState) -> List[LoadedSkill]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_instructions</a>(name: str) -> Optional[str]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_skill</a>(name: str) -> Optional[LoadedSkill]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_skills_diagnostics</a>() -> Dict[str, ValidationResult]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_user_invocable_skills</a>() -> List[LoadedSkill]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">invoke</a>(name: str, raw_args: str = '', session_id: Optional[str] = None, shell_exec: bool = False) -> Optional[str]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">load_resources</a>(name: str) -> bool</code>
|
||||
@@ -487,6 +490,7 @@ Methods:
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">skill_names</a>() -> List[str]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">skills</a>() -> List[LoadedSkill]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">to_prompt</a>() -> str</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">validate_skill_capabilities</a>(skill_name: str, force_refresh: bool = False) -> ValidationResult</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">write_skill_file</a>(name: str, file_path: str, file_content: str) -> dict</code>
|
||||
* <code title="class SkillMetadata">SkillMetadata.<a href="./src/praisonai-agents/praisonaiagents/skills/models.py">from_properties</a>(props: SkillProperties) -> 'SkillMetadata'</code>
|
||||
* <code title="class SkillProperties">SkillProperties.<a href="./src/praisonai-agents/praisonaiagents/skills/models.py">to_dict</a>() -> dict</code>
|
||||
@@ -602,6 +606,13 @@ Methods:
|
||||
* <code title="class ContextManager">ContextManager.<a href="./src/praisonai-agents/praisonaiagents/context/manager.py">set_tool_budget</a>(tool_name: str, max_tokens: int, protected: bool = False) -> None</code>
|
||||
* <code title="class ContextManager">ContextManager.<a href="./src/praisonai-agents/praisonaiagents/context/manager.py">truncate_tool_output</a>(tool_name: str, output: str) -> str</code>
|
||||
|
||||
# UI
|
||||
|
||||
Types:
|
||||
```python
|
||||
from praisonaiagents import A2UI
|
||||
```
|
||||
|
||||
# Config
|
||||
|
||||
Types:
|
||||
@@ -756,7 +767,7 @@ Methods:
|
||||
|
||||
Types:
|
||||
```python
|
||||
from praisonai import Agent, AgentApp, AgentOS, AnthropicManagedAgent, CloudProvider, DB, Deploy, DeployConfig, DeployType, HostedAgent, HostedAgentConfig, LocalAgent, LocalAgentConfig, LocalManagedAgent, LocalManagedConfig, ManagedAgent, ManagedConfig, PraisonAI, __version__
|
||||
from praisonai import Agent, AgentApp, AgentOS, AnthropicManagedAgent, CloudProvider, DB, Deploy, DeployConfig, DeployType, HostedAgent, HostedAgentConfig, LocalAgent, LocalAgentConfig, LocalManagedAgent, LocalManagedConfig, ManagedAgent, ManagedConfig, PraisonAI, __version__, arun, run
|
||||
```
|
||||
|
||||
# CLI
|
||||
@@ -861,6 +872,7 @@ Methods:
|
||||
* <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 skills <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>
|
||||
* <code title="cli">praisonai doctor troubleshoot <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
|
||||
* <code title="cli">praisonai endpoints list <a href="./src/praisonai/praisonai/cli/commands/endpoints.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.43" \
|
||||
"praisonai>=4.6.46" \
|
||||
"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.43" \
|
||||
"praisonai>=4.6.46" \
|
||||
"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.43" \
|
||||
"praisonai>=4.6.46" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Minimal A2UI example — agent + send_a2ui_messages tool.
|
||||
|
||||
Install:
|
||||
pip install praisonaiagents[a2ui]
|
||||
|
||||
Run:
|
||||
python send_surface.py
|
||||
|
||||
For live rendering, use PraisonAIUI with AG-UI (/agui) — see integrate-a2ui-frontend docs.
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.tools.a2ui_tools import send_a2ui_messages
|
||||
|
||||
agent = Agent(
|
||||
name="ui-assistant",
|
||||
instructions=(
|
||||
"When asked for UI, call send_a2ui_messages with A2UI v0.9 JSON "
|
||||
"(createSurface on surface id 'main')."
|
||||
),
|
||||
tools=[send_a2ui_messages],
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = agent.start("Show a simple welcome card on surface main")
|
||||
print(result)
|
||||
@@ -0,0 +1,19 @@
|
||||
# A2UI canvas agent — YAML example
|
||||
#
|
||||
# Usage:
|
||||
# pip install praisonaiagents[a2ui]
|
||||
# praisonai agent run --file a2ui-canvas.yaml --prompt "Show a welcome card"
|
||||
#
|
||||
# For live A2UI rendering, use PraisonAIUI (AG-UI / surfaces):
|
||||
# pip install praisonaiui
|
||||
# See examples/python/a2ui/send_surface.py and PraisonAIUI example 29.
|
||||
|
||||
agent:
|
||||
name: "ui-assistant"
|
||||
instructions: |
|
||||
You are a helpful assistant. When the user asks for UI elements
|
||||
(buttons, cards, forms), call send_a2ui_messages with valid A2UI v0.9 JSON.
|
||||
Use surface id "main" unless told otherwise.
|
||||
llm: gpt-4o-mini
|
||||
tools:
|
||||
- send_a2ui_messages
|
||||
Binary file not shown.
@@ -343,6 +343,7 @@ _LAZY_IMPORTS = {
|
||||
# UI (optional)
|
||||
'AGUI': ('praisonaiagents.ui.agui', 'AGUI'),
|
||||
'A2A': ('praisonaiagents.ui.a2a', 'A2A'),
|
||||
'A2UI': ('praisonaiagents.ui.a2ui', 'A2UI'),
|
||||
|
||||
# Feature configs
|
||||
'MemoryConfig': ('praisonaiagents.config.feature_configs', 'MemoryConfig'),
|
||||
@@ -814,6 +815,6 @@ def __dir__():
|
||||
# - Workflow, Task, Route, Parallel, Loop, Repeat, Pipeline, etc.
|
||||
# - MCP, FlowDisplay, track_workflow, FastContext, etc.
|
||||
# - Plan, PlanStep, TodoList, PlanningAgent, ApprovalCallback, etc.
|
||||
# - RAG, RAGConfig, RAGResult, AGUI, A2A, etc.
|
||||
# - RAG, RAGConfig, RAGResult, AGUI, A2A, A2UI, etc.
|
||||
# - All telemetry, display, and utility functions
|
||||
# ============================================================================
|
||||
@@ -4616,60 +4616,57 @@ Answer:"""
|
||||
error=f"Agent guardrail validation error: {str(e)}"
|
||||
)
|
||||
|
||||
def _apply_guardrail_with_retry(self, response_text, prompt, temperature=1.0, tools=None, task_name=None, task_description=None, task_id=None):
|
||||
"""Apply guardrail validation with retry logic.
|
||||
|
||||
Args:
|
||||
response_text: The response to validate
|
||||
prompt: Original prompt for regeneration if needed
|
||||
temperature: Temperature for regeneration
|
||||
tools: Tools for regeneration
|
||||
|
||||
Returns:
|
||||
str: The validated response text or None if validation fails after retries
|
||||
"""
|
||||
def _validate_with_guardrail(self, response_text):
|
||||
"""Validate response with guardrail. Returns (success, result, error)."""
|
||||
if not self._guardrail_fn:
|
||||
return response_text
|
||||
return True, response_text, None
|
||||
|
||||
from ..main import TaskOutput
|
||||
|
||||
task_output = TaskOutput(
|
||||
description="Agent response output",
|
||||
raw=response_text,
|
||||
agent=self.name
|
||||
)
|
||||
|
||||
guardrail_result = self._process_guardrail(task_output)
|
||||
|
||||
if guardrail_result.success:
|
||||
# Return the potentially modified result
|
||||
if guardrail_result.result and hasattr(guardrail_result.result, 'raw'):
|
||||
return True, guardrail_result.result.raw, None
|
||||
elif guardrail_result.result:
|
||||
return True, str(guardrail_result.result), None
|
||||
else:
|
||||
return True, response_text, None
|
||||
else:
|
||||
return False, None, guardrail_result.error
|
||||
|
||||
def _apply_guardrail_with_retry(self, response_text, prompt, temperature=1.0, tools=None, task_name=None, task_description=None, task_id=None):
|
||||
"""Apply guardrail validation with retry logic (sync version)."""
|
||||
retry_count = 0
|
||||
current_response = response_text
|
||||
|
||||
while retry_count <= self.max_guardrail_retries:
|
||||
# Create TaskOutput object
|
||||
task_output = TaskOutput(
|
||||
description="Agent response output",
|
||||
raw=current_response,
|
||||
agent=self.name
|
||||
)
|
||||
success, result, error = self._validate_with_guardrail(current_response)
|
||||
|
||||
# Process guardrail
|
||||
guardrail_result = self._process_guardrail(task_output)
|
||||
|
||||
if guardrail_result.success:
|
||||
if success:
|
||||
logging.info(f"Agent {self.name}: Guardrail validation passed")
|
||||
# Return the potentially modified result
|
||||
if guardrail_result.result and hasattr(guardrail_result.result, 'raw'):
|
||||
return guardrail_result.result.raw
|
||||
elif guardrail_result.result:
|
||||
return str(guardrail_result.result)
|
||||
else:
|
||||
return current_response
|
||||
return result
|
||||
|
||||
# Guardrail failed
|
||||
if retry_count >= self.max_guardrail_retries:
|
||||
raise Exception(
|
||||
f"Agent {self.name} response failed guardrail validation after {self.max_guardrail_retries} retries. "
|
||||
f"Last error: {guardrail_result.error}"
|
||||
f"Last error: {error}"
|
||||
)
|
||||
|
||||
retry_count += 1
|
||||
logging.warning(f"Agent {self.name}: Guardrail validation failed (retry {retry_count}/{self.max_guardrail_retries}): {guardrail_result.error}")
|
||||
logging.warning(f"Agent {self.name}: Guardrail validation failed (retry {retry_count}/{self.max_guardrail_retries}): {error}")
|
||||
|
||||
# Regenerate response for retry
|
||||
try:
|
||||
retry_prompt = f"{prompt}\n\nNote: Previous response failed validation due to: {guardrail_result.error}. Please provide an improved response."
|
||||
retry_prompt = f"{prompt}\n\nNote: Previous response failed validation due to: {error}. Please provide an improved response."
|
||||
response = self._chat_completion([{"role": "user", "content": retry_prompt}], temperature, tools, task_name=task_name, task_description=task_description, task_id=task_id)
|
||||
if response and response.choices:
|
||||
content = response.choices[0].message.content
|
||||
@@ -4678,10 +4675,46 @@ Answer:"""
|
||||
raise Exception("Failed to generate retry response")
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Error during guardrail retry: {e}")
|
||||
# If we can't regenerate, fail the guardrail
|
||||
raise Exception(f"Agent {self.name} guardrail retry failed: {e}")
|
||||
|
||||
return current_response
|
||||
|
||||
async def _aapply_guardrail_with_retry(self, response_text, prompt, temperature=1.0, tools=None, task_name=None, task_description=None, task_id=None):
|
||||
"""Apply guardrail validation with retry logic (async version)."""
|
||||
retry_count = 0
|
||||
current_response = response_text
|
||||
|
||||
while retry_count <= self.max_guardrail_retries:
|
||||
success, result, error = self._validate_with_guardrail(current_response)
|
||||
|
||||
if success:
|
||||
logging.info(f"Agent {self.name}: Guardrail validation passed")
|
||||
return result
|
||||
|
||||
# Guardrail failed
|
||||
if retry_count >= self.max_guardrail_retries:
|
||||
raise Exception(
|
||||
f"Agent {self.name} guardrail retry failed: {e}"
|
||||
f"Agent {self.name} response failed guardrail validation after {self.max_guardrail_retries} retries. "
|
||||
f"Last error: {error}"
|
||||
)
|
||||
|
||||
retry_count += 1
|
||||
logging.warning(f"Agent {self.name}: Guardrail validation failed (retry {retry_count}/{self.max_guardrail_retries}): {error}")
|
||||
|
||||
# Regenerate response for retry (async version)
|
||||
try:
|
||||
retry_prompt = f"{prompt}\n\nNote: Previous response failed validation due to: {error}. Please provide an improved response."
|
||||
response = await self._execute_unified_achat_completion([{"role": "user", "content": retry_prompt}], temperature, tools, task_name=task_name, task_description=task_description, task_id=task_id)
|
||||
if response and hasattr(response, 'choices') and response.choices:
|
||||
content = response.choices[0].message.content
|
||||
current_response = content.strip() if content else ""
|
||||
elif isinstance(response, str):
|
||||
current_response = response.strip()
|
||||
else:
|
||||
raise Exception("Failed to generate retry response")
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Error during guardrail retry: {e}")
|
||||
raise Exception(f"Agent {self.name} guardrail retry failed: {e}")
|
||||
|
||||
return current_response
|
||||
|
||||
|
||||
@@ -2079,7 +2079,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
# Apply guardrail validation for custom LLM response
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, prompt, temperature, tools, task_name, task_description, task_id)
|
||||
validated_response = await self._aapply_guardrail_with_retry(response_text, prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(normalized_content, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return await self._atrigger_after_agent_hook(prompt, validated_response, start_time)
|
||||
@@ -2187,22 +2187,216 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
task_id=task_id,
|
||||
response_format=response_format
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
# Process response - mirror sync _chat_impl behavior (lines ~1544-1602)
|
||||
if not response:
|
||||
# Rollback chat history on response failure
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
|
||||
# Extract response content using the same method as sync
|
||||
response_text = self._extract_llm_response_content(response)
|
||||
if isinstance(response_text, str):
|
||||
response_text = response_text.strip()
|
||||
|
||||
# Handle output_json or output_pydantic if specified
|
||||
if output_json or output_pydantic:
|
||||
# Add to chat history and return raw response
|
||||
# User message already added before LLM call via _build_messages
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
# Persist assistant message to DB
|
||||
self._persist_message("assistant", response_text)
|
||||
# Apply guardrail validation even for JSON output
|
||||
try:
|
||||
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
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}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
|
||||
# For regular responses (no self-reflection)
|
||||
if not self.self_reflect:
|
||||
# User message already added before LLM call via _build_messages
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
# Persist assistant message to DB (non-reflect path)
|
||||
self._persist_message("assistant", response_text)
|
||||
if self.verbose:
|
||||
logging.debug(f"Agent {self.name} final response: {response_text}")
|
||||
# Return only reasoning content if reasoning_steps is True
|
||||
if reasoning_steps and hasattr(response, 'choices') and response.choices and hasattr(response.choices[0].message, 'reasoning_content') and response.choices[0].message.reasoning_content:
|
||||
# Apply guardrail to reasoning content
|
||||
try:
|
||||
validated_reasoning = await self._aapply_guardrail_with_retry(response.choices[0].message.reasoning_content, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_reasoning, time.time() - start_time, task_name, task_description, task_id)
|
||||
return await self._atrigger_after_agent_hook(original_prompt, validated_reasoning, start_time)
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed for reasoning content: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
else:
|
||||
# Apply guardrail to regular response content
|
||||
try:
|
||||
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
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}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
|
||||
# If self-reflection is enabled, implement reflection logic
|
||||
if self.self_reflect:
|
||||
# Implement async self-reflection similar to sync path
|
||||
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'.
|
||||
"""
|
||||
|
||||
# Add reflection prompt to messages
|
||||
reflection_messages = messages + [
|
||||
{"role": "assistant", "content": response_text},
|
||||
{"role": "user", "content": reflection_prompt}
|
||||
]
|
||||
|
||||
reflection_count = 0
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Check if OpenAI client is available for self-reflection
|
||||
if self._openai_client is None:
|
||||
# For custom LLMs, self-reflection with structured output is not supported
|
||||
if self.verbose:
|
||||
_get_display_functions()['display_self_reflection'](f"Agent {self.name}: Self-reflection with structured output is not supported for custom LLM providers. Skipping reflection.", console=self.console)
|
||||
# Return the original response without reflection
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
# Persist assistant message to DB
|
||||
self._persist_message("assistant", response_text)
|
||||
try:
|
||||
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
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}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
|
||||
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 self.verbose:
|
||||
_get_display_functions()['display_self_reflection'](f"Agent {self.name} self reflection (using {self.reflect_llm if self.reflect_llm else self.llm}): reflection='{reflection_output.reflection}' satisfactory='{reflection_output.satisfactory}'", console=self.console)
|
||||
|
||||
# Only consider satisfactory after minimum reflections
|
||||
if reflection_output.satisfactory == "yes" and reflection_count >= self.min_reflect - 1:
|
||||
if self.verbose:
|
||||
_get_display_functions()['display_self_reflection']("Agent marked the response as satisfactory after meeting minimum reflections", console=self.console)
|
||||
# Add to chat history and return
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
# Persist assistant message to DB
|
||||
self._persist_message("assistant", response_text)
|
||||
try:
|
||||
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
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 after reflection: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
|
||||
# Check if we've hit max reflections
|
||||
if reflection_count >= self.max_reflect - 1:
|
||||
if self.verbose:
|
||||
_get_display_functions()['display_self_reflection']("Maximum reflection count reached, returning current response", console=self.console)
|
||||
# Add to chat history and return
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
# Persist assistant message to DB
|
||||
self._persist_message("assistant", response_text)
|
||||
try:
|
||||
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
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 after max reflections: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
|
||||
# Regenerate response based on reflection
|
||||
regenerate_messages = reflection_messages + [
|
||||
{"role": "assistant", "content": f"Self Reflection: {reflection_output.reflection} Satisfactory?: {reflection_output.satisfactory}"},
|
||||
{"role": "user", "content": "Now regenerate your response using the reflection you made"}
|
||||
]
|
||||
|
||||
new_response = await self._execute_unified_achat_completion(
|
||||
messages=regenerate_messages,
|
||||
temperature=temperature,
|
||||
tools=formatted_tools,
|
||||
stream=stream,
|
||||
reasoning_steps=reasoning_steps,
|
||||
task_name=task_name,
|
||||
task_description=task_description,
|
||||
task_id=task_id,
|
||||
response_format=response_format
|
||||
)
|
||||
|
||||
if new_response:
|
||||
new_response_text = self._extract_llm_response_content(new_response)
|
||||
if isinstance(new_response_text, str):
|
||||
response_text = new_response_text.strip()
|
||||
# Update reflection_messages to include the new response for next iteration
|
||||
reflection_messages = regenerate_messages + [
|
||||
{"role": "assistant", "content": response_text}
|
||||
]
|
||||
|
||||
reflection_count += 1
|
||||
|
||||
except Exception as e:
|
||||
if self.verbose:
|
||||
_get_display_functions()['display_error'](f"Error in parsing self-reflection json {e}. Retrying", console=self.console)
|
||||
logging.error("Reflection parsing failed.", exc_info=True)
|
||||
reflection_count += 1
|
||||
if reflection_count >= self.max_reflect:
|
||||
# Return original response after max reflection attempts
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
# Persist assistant message to DB
|
||||
self._persist_message("assistant", response_text)
|
||||
try:
|
||||
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
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 guard_e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed after reflection error: {guard_e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
continue
|
||||
|
||||
# This should never be reached due to the returns above
|
||||
# But adding as safety fallback
|
||||
logging.warning(f"Agent {self.name}: Unexpected code path reached in unified dispatch")
|
||||
return None
|
||||
else:
|
||||
# LEGACY: Check if OpenAI client is available
|
||||
if self._openai_client is None:
|
||||
@@ -2339,7 +2533,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
# Apply guardrail validation for OpenAI client response
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
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)
|
||||
|
||||
@@ -403,9 +403,33 @@ class MemoryMixin:
|
||||
self._session_store.add_user_message(self._session_id, content)
|
||||
elif role == "assistant":
|
||||
self._session_store.add_assistant_message(self._session_id, content)
|
||||
self._persist_session_stats()
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to persist message to session store: {e}")
|
||||
|
||||
def _persist_session_stats(self):
|
||||
"""Flush agent cost/token stats into session JSON metadata."""
|
||||
store = getattr(self, "_session_store", None)
|
||||
session_id = getattr(self, "_session_id", None)
|
||||
if store is None or session_id is None:
|
||||
return
|
||||
if not hasattr(store, "update_session_metadata"):
|
||||
return
|
||||
try:
|
||||
summary = getattr(self, "cost_summary", {}) or {}
|
||||
llm = getattr(self, "llm", None)
|
||||
model = str(llm) if llm is not None else None
|
||||
store.update_session_metadata(
|
||||
session_id,
|
||||
model=model,
|
||||
total_tokens=summary.get("tokens_in", 0) + summary.get("tokens_out", 0),
|
||||
cost=summary.get("cost", 0),
|
||||
source=getattr(self, "_session_source", "chat"),
|
||||
agent_id=getattr(self, "agent_id", None) or getattr(self, "_agent_id", None),
|
||||
)
|
||||
except Exception as e:
|
||||
logging.debug(f"Session stats persist skipped: {e}")
|
||||
|
||||
def session_id(self) -> Optional[str]:
|
||||
"""Get the current session ID."""
|
||||
return self._session_id
|
||||
|
||||
@@ -26,6 +26,21 @@ if TYPE_CHECKING:
|
||||
class ToolExecutionMixin:
|
||||
"""Mixin providing toolexecution methods for the Agent class."""
|
||||
|
||||
def _get_existing_stream_emitter(self):
|
||||
"""Return an already-initialized stream emitter without creating one."""
|
||||
emitter = getattr(self, "_stream_emitter", None)
|
||||
if emitter is not None:
|
||||
return emitter
|
||||
|
||||
# Support name-mangled private attributes across class renames/inheritance.
|
||||
for cls in type(self).mro():
|
||||
mangled = f"_{cls.__name__}__stream_emitter"
|
||||
if hasattr(self, mangled):
|
||||
emitter = getattr(self, mangled, None)
|
||||
if emitter is not None:
|
||||
return emitter
|
||||
return None
|
||||
|
||||
def _resolve_tool_names(self, tool_names):
|
||||
"""Resolve tool names to actual tool instances from registry.
|
||||
|
||||
@@ -160,8 +175,9 @@ class ToolExecutionMixin:
|
||||
|
||||
# Emit TOOL_CALL_START to stream_emitter (for AIUI/AG-UI consumers)
|
||||
# Zero overhead when no callbacks registered
|
||||
if hasattr(self, '_Agent__stream_emitter') and getattr(self, "_Agent__stream_emitter", None) is not None and getattr(self, "_Agent__stream_emitter", None).has_callbacks:
|
||||
getattr(self, "_Agent__stream_emitter", None).emit(StreamEvent(
|
||||
_stream_emitter = self._get_existing_stream_emitter()
|
||||
if _stream_emitter is not None and _stream_emitter.has_callbacks:
|
||||
_stream_emitter.emit(StreamEvent(
|
||||
type=StreamEventType.TOOL_CALL_START,
|
||||
timestamp=_tool_start_perf,
|
||||
tool_call={
|
||||
@@ -275,10 +291,10 @@ class ToolExecutionMixin:
|
||||
|
||||
# Emit TOOL_CALL_RESULT to stream_emitter (for AIUI/AG-UI consumers)
|
||||
# Zero overhead when no callbacks registered
|
||||
if hasattr(self, '_Agent__stream_emitter') and getattr(self, "_Agent__stream_emitter", None) is not None and getattr(self, "_Agent__stream_emitter", None).has_callbacks:
|
||||
if _stream_emitter is not None and _stream_emitter.has_callbacks:
|
||||
# Truncate result for stream event (keep it reasonable for UI display)
|
||||
result_summary = str(result)[:500] if result else None
|
||||
getattr(self, "_Agent__stream_emitter", None).emit(StreamEvent(
|
||||
_stream_emitter.emit(StreamEvent(
|
||||
type=StreamEventType.TOOL_CALL_RESULT,
|
||||
timestamp=_time.perf_counter(),
|
||||
tool_call={
|
||||
@@ -290,6 +306,18 @@ class ToolExecutionMixin:
|
||||
agent_id=self.name,
|
||||
metadata={"duration_ms": _duration_ms},
|
||||
))
|
||||
_stream_emitter.emit(StreamEvent(
|
||||
type=StreamEventType.TOOL_CALL_END,
|
||||
timestamp=_time.perf_counter(),
|
||||
tool_call={
|
||||
"name": function_name,
|
||||
"arguments": arguments,
|
||||
"result": result_summary,
|
||||
"id": tool_call_id,
|
||||
},
|
||||
agent_id=self.name,
|
||||
metadata={"duration_ms": _duration_ms},
|
||||
))
|
||||
|
||||
# Trigger AFTER_TOOL hook
|
||||
from ..hooks import HookEvent, AfterToolInput
|
||||
@@ -926,4 +954,3 @@ class ToolExecutionMixin:
|
||||
def pending_approval_count(self) -> int:
|
||||
"""Number of approval requests still waiting."""
|
||||
return len(self._pending_approvals)
|
||||
|
||||
@@ -15,9 +15,9 @@ from enum import Enum
|
||||
|
||||
# Import token tracking
|
||||
try:
|
||||
from ..telemetry.token_collector import _token_collector
|
||||
from ..telemetry.token_collector import get_token_collector
|
||||
except ImportError:
|
||||
_token_collector = None
|
||||
get_token_collector = None
|
||||
|
||||
# Import async utility for hot-path usage
|
||||
try:
|
||||
@@ -1826,18 +1826,19 @@ class AgentTeam:
|
||||
|
||||
def get_token_usage_summary(self) -> Dict[str, Any]:
|
||||
"""Get a summary of token usage across all agents and tasks."""
|
||||
if not _token_collector:
|
||||
if not get_token_collector:
|
||||
return {"error": "Token tracking not available"}
|
||||
|
||||
return _token_collector.get_session_summary()
|
||||
return get_token_collector().get_session_summary()
|
||||
|
||||
def get_detailed_token_report(self) -> Dict[str, Any]:
|
||||
"""Get a detailed token usage report."""
|
||||
if not _token_collector:
|
||||
if not get_token_collector:
|
||||
return {"error": "Token tracking not available"}
|
||||
|
||||
summary = _token_collector.get_session_summary()
|
||||
recent = _token_collector.get_recent_interactions(limit=20)
|
||||
collector = get_token_collector()
|
||||
summary = collector.get_session_summary()
|
||||
recent = collector.get_recent_interactions(limit=20)
|
||||
|
||||
# Calculate cost estimates (example rates)
|
||||
cost_per_1k_input = 0.0005 # $0.0005 per 1K input tokens
|
||||
@@ -1861,11 +1862,11 @@ class AgentTeam:
|
||||
|
||||
def display_token_usage(self):
|
||||
"""Display token usage in a formatted table."""
|
||||
if not _token_collector:
|
||||
if not get_token_collector:
|
||||
print("Token tracking not available")
|
||||
return
|
||||
|
||||
summary = _token_collector.get_session_summary()
|
||||
summary = get_token_collector().get_session_summary()
|
||||
|
||||
print("\n" + "="*50)
|
||||
print("TOKEN USAGE SUMMARY")
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Hooks query protocol for UI/API consumers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List, Protocol, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class HooksQueryProtocol(Protocol):
|
||||
"""Read path for registered hooks."""
|
||||
|
||||
def list_hooks_for_api(self) -> List[Dict]:
|
||||
...
|
||||
@@ -439,3 +439,13 @@ def has_hook(event: Union[str, HookEvent]) -> bool:
|
||||
)
|
||||
|
||||
return get_default_registry().has_hooks(event)
|
||||
|
||||
|
||||
def list_hooks_for_api() -> List[Dict]:
|
||||
"""Flat hook list suitable for REST/UI consumers."""
|
||||
registry = get_default_registry()
|
||||
flat: List[Dict] = []
|
||||
for event, hooks in registry.list_hooks().items():
|
||||
for h in hooks:
|
||||
flat.append({"event": event, "source": "sdk", **h})
|
||||
return flat
|
||||
@@ -73,10 +73,10 @@ def _get_live():
|
||||
|
||||
# Import token tracking
|
||||
try:
|
||||
from ..telemetry.token_collector import TokenMetrics, _token_collector
|
||||
from ..telemetry.token_collector import TokenMetrics, get_token_collector
|
||||
except ImportError:
|
||||
TokenMetrics = None
|
||||
_token_collector = None
|
||||
get_token_collector = None
|
||||
|
||||
# Logging is already configured in _logging.py via __init__.py
|
||||
|
||||
@@ -4519,7 +4519,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
def _track_token_usage(self, response: Dict[str, Any], model: str) -> Optional[TokenMetrics]:
|
||||
"""Extract and track token usage from LLM response."""
|
||||
if not TokenMetrics or not _token_collector:
|
||||
if not TokenMetrics or not get_token_collector:
|
||||
return None
|
||||
|
||||
# Note: metrics check moved to call sites for performance
|
||||
@@ -4551,7 +4551,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# Track in global collector
|
||||
# Extract provider from model string (e.g., 'openai' from 'openai/gpt-4o-mini')
|
||||
provider = model.split('/')[0] if '/' in model else 'litellm'
|
||||
_token_collector.track_tokens(
|
||||
get_token_collector().track_tokens(
|
||||
model=model,
|
||||
agent=self.current_agent_name,
|
||||
metrics=metrics,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Scheduler — runner vs loop
|
||||
|
||||
## ScheduleRunner (stateless)
|
||||
|
||||
`ScheduleRunner` is **stateless**. Call `get_due_jobs()` whenever you want to check for jobs that should fire. It does not run background threads.
|
||||
|
||||
Use when:
|
||||
- CLI one-shot tick (`praisonai schedule tick`)
|
||||
- Host integration that polls on its own timer
|
||||
- Tests
|
||||
|
||||
## ScheduleLoop (optional daemon)
|
||||
|
||||
`ScheduleLoop` is an **optional background daemon**. You must call `.start()` explicitly; it polls the runner and invokes `on_trigger` callbacks.
|
||||
|
||||
Use when:
|
||||
- Long-running host process (Pattern B/C via `setup_bridges()`)
|
||||
- Standalone scheduler service
|
||||
|
||||
```python
|
||||
from praisonaiagents.scheduler import FileScheduleStore, ScheduleRunner, ScheduleLoop
|
||||
|
||||
store = FileScheduleStore()
|
||||
runner = ScheduleRunner(store)
|
||||
loop = ScheduleLoop(runner)
|
||||
loop.start() # optional daemon
|
||||
```
|
||||
|
||||
The wrapper host calls `ensure_schedule_runner()` which starts `ScheduleLoop` when the scheduler optional dependency is available.
|
||||
@@ -32,7 +32,22 @@ When you provide a `session_id` to an Agent:
|
||||
|
||||
### Default Storage Location
|
||||
|
||||
Sessions are stored in: `~/.praison/sessions/{session_id}.json`
|
||||
Sessions are stored in: `~/.praisonai/sessions/{session_id}.json`
|
||||
|
||||
### Session metadata contract (Issue #48)
|
||||
|
||||
List endpoints and UI dashboards may expose these optional root-level fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `model` | string | LLM model used in the session |
|
||||
| `total_tokens` | int | Cumulative input+output tokens |
|
||||
| `cost` | float | Estimated USD cost |
|
||||
| `agent_id` | string | Gateway or registry agent id |
|
||||
| `source` | string | Origin: `chat`, `gateway`, `cli`, `api` |
|
||||
| `agent_name` | string | Human-readable agent name |
|
||||
|
||||
Fields are persisted in session JSON `metadata` and mirrored at root on save. Populated automatically after each assistant turn when using the default session store.
|
||||
|
||||
### Session File Format
|
||||
|
||||
@@ -45,7 +60,13 @@ Sessions are stored in: `~/.praison/sessions/{session_id}.json`
|
||||
],
|
||||
"created_at": "2026-01-02T04:00:00+00:00",
|
||||
"updated_at": "2026-01-02T04:01:00+00:00",
|
||||
"agent_name": "Assistant"
|
||||
"agent_name": "Assistant",
|
||||
"agent_id": "agent-abc",
|
||||
"source": "chat",
|
||||
"model": "gpt-4o-mini",
|
||||
"total_tokens": 128,
|
||||
"cost": 0.0004,
|
||||
"metadata": {}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -129,3 +129,16 @@ class SessionStoreProtocol(Protocol):
|
||||
True if the session exists.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CheckpointQueryProtocol(Protocol):
|
||||
"""Read path for session checkpoints / rollback snapshots."""
|
||||
|
||||
def list_checkpoints(self, session_id: str) -> List[Dict[str, Any]]:
|
||||
"""Return checkpoint metadata for a session."""
|
||||
...
|
||||
|
||||
def get_checkpoint(self, session_id: str, checkpoint_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Return a single checkpoint payload."""
|
||||
...
|
||||
@@ -81,7 +81,7 @@ class SessionData:
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
data = {
|
||||
"session_id": self.session_id,
|
||||
"messages": [m.to_dict() for m in self.messages],
|
||||
"created_at": self.created_at,
|
||||
@@ -92,6 +92,10 @@ class SessionData:
|
||||
"gateway_session_id": self.gateway_session_id,
|
||||
"agent_id": self.agent_id,
|
||||
}
|
||||
for key in ("model", "llm", "total_tokens", "token_count", "cost", "source"):
|
||||
if key in self.metadata:
|
||||
data[key] = self.metadata[key]
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "SessionData":
|
||||
@@ -137,7 +141,8 @@ class FileLock:
|
||||
self._lock_path = filepath + ".lock"
|
||||
|
||||
def __enter__(self):
|
||||
self.acquire()
|
||||
if not self.acquire():
|
||||
raise IOError(f"Failed to acquire file lock for {self.filepath} after {self.timeout}s")
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
@@ -470,7 +475,62 @@ class DefaultSessionStore:
|
||||
self._cache[session_id] = session
|
||||
|
||||
return self._save_session(session)
|
||||
|
||||
|
||||
def update_session_metadata(self, session_id: str, **fields: Any) -> bool:
|
||||
"""Merge run stats / metadata fields into a persisted session."""
|
||||
if not fields:
|
||||
return True
|
||||
|
||||
filepath = self._get_session_path(session_id)
|
||||
|
||||
with FileLock(filepath, self.lock_timeout):
|
||||
if os.path.exists(filepath):
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
session = SessionData.from_dict(data)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
session = SessionData(session_id=session_id)
|
||||
else:
|
||||
session = SessionData(session_id=session_id)
|
||||
|
||||
for key, value in fields.items():
|
||||
if value is None:
|
||||
continue
|
||||
session.metadata[key] = value
|
||||
if key in ("agent_id", "agent_name", "user_id"):
|
||||
setattr(session, key, value)
|
||||
|
||||
session.updated_at = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
try:
|
||||
dir_path = os.path.dirname(filepath) or "."
|
||||
os.makedirs(dir_path, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
encoding="utf-8",
|
||||
dir=dir_path,
|
||||
delete=False,
|
||||
suffix=".tmp",
|
||||
) as f:
|
||||
json.dump(session.to_dict(), f, indent=2, ensure_ascii=False)
|
||||
temp_path = f.name
|
||||
|
||||
os.replace(temp_path, filepath)
|
||||
|
||||
with self._lock:
|
||||
self._cache[session_id] = session
|
||||
|
||||
return True
|
||||
except (IOError, OSError) as e:
|
||||
logger.error(f"Failed to update session metadata {session_id}: {e}")
|
||||
try:
|
||||
if "temp_path" in locals():
|
||||
os.remove(temp_path)
|
||||
except (IOError, OSError):
|
||||
pass
|
||||
return False
|
||||
|
||||
def delete_session(self, session_id: str) -> bool:
|
||||
"""Delete a session completely."""
|
||||
filepath = self._get_session_path(session_id)
|
||||
@@ -500,10 +560,16 @@ class DefaultSessionStore:
|
||||
data = json.load(f)
|
||||
sessions.append({
|
||||
"session_id": data.get("session_id", filename[:-5]),
|
||||
"id": data.get("session_id", filename[:-5]),
|
||||
"agent_name": data.get("agent_name"),
|
||||
"agent_id": data.get("agent_id") or (data.get("metadata") or {}).get("agent_id"),
|
||||
"source": data.get("source") or (data.get("metadata") or {}).get("source"),
|
||||
"created_at": data.get("created_at"),
|
||||
"updated_at": data.get("updated_at"),
|
||||
"message_count": len(data.get("messages", [])),
|
||||
"model": data.get("model") or data.get("llm") or (data.get("metadata") or {}).get("model"),
|
||||
"total_tokens": data.get("total_tokens") or data.get("token_count") or (data.get("metadata") or {}).get("total_tokens"),
|
||||
"cost": data.get("cost") or (data.get("metadata") or {}).get("cost"),
|
||||
})
|
||||
except (json.JSONDecodeError, IOError):
|
||||
continue
|
||||
|
||||
@@ -19,7 +19,9 @@ Usage:
|
||||
__all__ = [
|
||||
# Models
|
||||
"SkillProperties",
|
||||
"SkillMetadata",
|
||||
"SkillMetadata",
|
||||
"SkillRequirements",
|
||||
"SkillState",
|
||||
"ParseError",
|
||||
"ValidationError",
|
||||
# Parser
|
||||
@@ -54,13 +56,17 @@ __all__ = [
|
||||
"SkillActivatedEvent",
|
||||
# Budget
|
||||
"SkillPromptBudget",
|
||||
# Capability validation
|
||||
"CapabilityValidator",
|
||||
"EnforcementLevel",
|
||||
"ValidationResult",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy load module components to avoid import overhead."""
|
||||
if name in ("SkillProperties", "SkillMetadata", "ParseError", "ValidationError"):
|
||||
from .models import SkillProperties, SkillMetadata, ParseError, ValidationError
|
||||
if name in ("SkillProperties", "SkillMetadata", "SkillRequirements", "SkillState", "ParseError", "ValidationError"):
|
||||
from .models import SkillProperties, SkillMetadata, SkillRequirements, SkillState, ParseError, ValidationError
|
||||
return locals()[name]
|
||||
|
||||
if name in ("parse_frontmatter", "find_skill_md", "read_properties"):
|
||||
@@ -130,5 +136,9 @@ def __getattr__(name: str):
|
||||
return None
|
||||
|
||||
return load_skill
|
||||
|
||||
if name in ("CapabilityValidator", "EnforcementLevel", "ValidationResult"):
|
||||
from .capability_validator import CapabilityValidator, EnforcementLevel, ValidationResult
|
||||
return locals()[name]
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Capability validation for Agent Skills.
|
||||
|
||||
This module implements the capability gate enforcement system for skills,
|
||||
checking that required tools, servers, and environment variables are available
|
||||
before allowing skill activation.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional, Set, Tuple
|
||||
from enum import Enum
|
||||
|
||||
from .models import SkillProperties, SkillRequirements, SkillState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EnforcementLevel(Enum):
|
||||
"""Enforcement level for capability gates."""
|
||||
DISABLED = "disabled" # No enforcement (existing behavior)
|
||||
TELEMETRY = "telemetry" # Log only, no blocking
|
||||
WARN = "warn" # Warning but allow activation
|
||||
STRICT = "strict" # Hard failure, block activation
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
"""Result of capability validation for a skill."""
|
||||
skill_name: str
|
||||
state: SkillState
|
||||
satisfied_tools: List[str]
|
||||
missing_tools: List[str]
|
||||
satisfied_servers: List[str]
|
||||
missing_servers: List[str]
|
||||
satisfied_env_vars: List[str]
|
||||
missing_env_vars: List[str]
|
||||
warnings: List[str]
|
||||
errors: List[str]
|
||||
|
||||
@property
|
||||
def is_fully_satisfied(self) -> bool:
|
||||
"""Check if all requirements are satisfied."""
|
||||
return not any([
|
||||
self.missing_tools,
|
||||
self.missing_servers,
|
||||
self.missing_env_vars,
|
||||
self.errors
|
||||
])
|
||||
|
||||
@property
|
||||
def has_critical_missing(self) -> bool:
|
||||
"""Check if critical requirements are missing (tools, servers)."""
|
||||
return bool(self.missing_tools or self.missing_servers)
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
"skill_name": self.skill_name,
|
||||
"state": self.state.value,
|
||||
"satisfied_tools": self.satisfied_tools,
|
||||
"missing_tools": self.missing_tools,
|
||||
"satisfied_servers": self.satisfied_servers,
|
||||
"missing_servers": self.missing_servers,
|
||||
"satisfied_env_vars": self.satisfied_env_vars,
|
||||
"missing_env_vars": self.missing_env_vars,
|
||||
"warnings": self.warnings,
|
||||
"errors": self.errors,
|
||||
"is_fully_satisfied": self.is_fully_satisfied,
|
||||
"has_critical_missing": self.has_critical_missing
|
||||
}
|
||||
|
||||
|
||||
class CapabilityValidator:
|
||||
"""Validates skill requirements against available capabilities.
|
||||
|
||||
This is the core capability gate enforcement engine. It checks skill
|
||||
requirements against the runtime context (available tools, servers, etc.)
|
||||
and determines whether a skill should be activated based on policy.
|
||||
"""
|
||||
|
||||
def __init__(self, enforcement_level: EnforcementLevel = EnforcementLevel.WARN):
|
||||
"""Initialize the validator.
|
||||
|
||||
Args:
|
||||
enforcement_level: How strictly to enforce requirements
|
||||
"""
|
||||
self.enforcement_level = enforcement_level
|
||||
self._tool_cache: Optional[Set[str]] = None
|
||||
self._server_cache: Optional[Set[str]] = None
|
||||
|
||||
def validate_skill(
|
||||
self,
|
||||
skill: SkillProperties,
|
||||
available_tools: Optional[Set[str]] = None,
|
||||
available_servers: Optional[Set[str]] = None
|
||||
) -> ValidationResult:
|
||||
"""Validate a skill's requirements against available capabilities.
|
||||
|
||||
Args:
|
||||
skill: Skill to validate
|
||||
available_tools: Set of available tool names (auto-detected if None)
|
||||
available_servers: Set of available server names (auto-detected if None)
|
||||
|
||||
Returns:
|
||||
ValidationResult with validation status and details
|
||||
"""
|
||||
if not skill.requirements or skill.requirements.is_empty():
|
||||
# No requirements specified - skill is always available
|
||||
return ValidationResult(
|
||||
skill_name=skill.name,
|
||||
state=SkillState.ACTIVE,
|
||||
satisfied_tools=[],
|
||||
missing_tools=[],
|
||||
satisfied_servers=[],
|
||||
missing_servers=[],
|
||||
satisfied_env_vars=[],
|
||||
missing_env_vars=[],
|
||||
warnings=[],
|
||||
errors=[]
|
||||
)
|
||||
|
||||
# Get available capabilities
|
||||
if available_tools is None:
|
||||
available_tools = self._get_available_tools()
|
||||
if available_servers is None:
|
||||
available_servers = self._get_available_servers()
|
||||
|
||||
requirements = skill.requirements
|
||||
|
||||
# Validate tools
|
||||
satisfied_tools = [tool for tool in requirements.tools if tool in available_tools]
|
||||
missing_tools = [tool for tool in requirements.tools if tool not in available_tools]
|
||||
|
||||
# Validate servers
|
||||
satisfied_servers = [srv for srv in requirements.servers if srv in available_servers]
|
||||
missing_servers = [srv for srv in requirements.servers if srv not in available_servers]
|
||||
|
||||
# Validate environment variables
|
||||
satisfied_env_vars = [var for var in requirements.env_vars if os.getenv(var)]
|
||||
missing_env_vars = [var for var in requirements.env_vars if not os.getenv(var)]
|
||||
|
||||
# Determine warnings and errors
|
||||
warnings = []
|
||||
errors = []
|
||||
|
||||
if missing_tools:
|
||||
msg = f"Missing required tools: {', '.join(missing_tools)}"
|
||||
if self.enforcement_level in (EnforcementLevel.STRICT,):
|
||||
errors.append(msg)
|
||||
else:
|
||||
warnings.append(msg)
|
||||
|
||||
if missing_servers:
|
||||
msg = f"Missing required servers: {', '.join(missing_servers)}"
|
||||
if self.enforcement_level in (EnforcementLevel.STRICT,):
|
||||
errors.append(msg)
|
||||
else:
|
||||
warnings.append(msg)
|
||||
|
||||
if missing_env_vars:
|
||||
msg = f"Missing required environment variables: {', '.join(missing_env_vars)}"
|
||||
if self.enforcement_level == EnforcementLevel.STRICT:
|
||||
errors.append(msg)
|
||||
else:
|
||||
warnings.append(msg)
|
||||
|
||||
# Determine skill state
|
||||
if errors:
|
||||
state = SkillState.UNAVAILABLE
|
||||
elif warnings:
|
||||
state = SkillState.DEGRADED
|
||||
else:
|
||||
state = SkillState.ACTIVE
|
||||
|
||||
result = ValidationResult(
|
||||
skill_name=skill.name,
|
||||
state=state,
|
||||
satisfied_tools=satisfied_tools,
|
||||
missing_tools=missing_tools,
|
||||
satisfied_servers=satisfied_servers,
|
||||
missing_servers=missing_servers,
|
||||
satisfied_env_vars=satisfied_env_vars,
|
||||
missing_env_vars=missing_env_vars,
|
||||
warnings=warnings,
|
||||
errors=errors
|
||||
)
|
||||
|
||||
# Log based on enforcement level
|
||||
self._log_validation_result(result)
|
||||
|
||||
return result
|
||||
|
||||
def _get_available_tools(self) -> Set[str]:
|
||||
"""Get set of available tool names from registry."""
|
||||
if self._tool_cache is None:
|
||||
try:
|
||||
from ..tools.registry import get_registry
|
||||
registry = get_registry()
|
||||
self._tool_cache = set(registry.list_tools())
|
||||
except ImportError:
|
||||
logger.debug("Tool registry not available")
|
||||
self._tool_cache = set()
|
||||
return self._tool_cache
|
||||
|
||||
def _get_available_servers(self) -> Set[str]:
|
||||
"""Get set of available MCP server names."""
|
||||
if self._server_cache is None:
|
||||
# TODO: Implement MCP server discovery
|
||||
# For now, return empty set - this can be extended later
|
||||
self._server_cache = set()
|
||||
return self._server_cache
|
||||
|
||||
def _log_validation_result(self, result: ValidationResult) -> None:
|
||||
"""Log validation result based on enforcement level."""
|
||||
if self.enforcement_level == EnforcementLevel.DISABLED:
|
||||
return
|
||||
|
||||
skill_name = result.skill_name
|
||||
|
||||
if result.state == SkillState.UNAVAILABLE:
|
||||
if self.enforcement_level == EnforcementLevel.STRICT:
|
||||
logger.error(f"Skill '{skill_name}' blocked: {'; '.join(result.errors)}")
|
||||
else:
|
||||
logger.warning(f"Skill '{skill_name}' has critical missing dependencies: {'; '.join(result.errors)}")
|
||||
|
||||
elif result.state == SkillState.DEGRADED:
|
||||
logger.warning(f"Skill '{skill_name}' partially available: {'; '.join(result.warnings)}")
|
||||
|
||||
elif result.state == SkillState.ACTIVE:
|
||||
logger.debug(f"Skill '{skill_name}' requirements satisfied")
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear cached capability information."""
|
||||
self._tool_cache = None
|
||||
self._server_cache = None
|
||||
@@ -108,7 +108,7 @@ class SkillLoader:
|
||||
return False
|
||||
|
||||
try:
|
||||
content = skill_md.read_text()
|
||||
content = skill_md.read_text(encoding="utf-8")
|
||||
_, body = parse_frontmatter(content)
|
||||
skill.instructions = body
|
||||
return True
|
||||
@@ -136,7 +136,7 @@ class SkillLoader:
|
||||
for script_file in scripts_dir.iterdir():
|
||||
if script_file.is_file():
|
||||
try:
|
||||
scripts[script_file.name] = script_file.read_text()
|
||||
scripts[script_file.name] = script_file.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
continue
|
||||
except PermissionError:
|
||||
@@ -166,7 +166,7 @@ class SkillLoader:
|
||||
for ref_file in refs_dir.iterdir():
|
||||
if ref_file.is_file():
|
||||
try:
|
||||
refs[ref_file.name] = ref_file.read_text()
|
||||
refs[ref_file.name] = ref_file.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
continue
|
||||
except PermissionError:
|
||||
|
||||
@@ -5,12 +5,13 @@ from typing import List, Optional, Dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from .models import SkillMetadata
|
||||
from .models import SkillMetadata, SkillState
|
||||
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
|
||||
from .shell_render import render_shell_blocks
|
||||
from .capability_validator import CapabilityValidator, EnforcementLevel, ValidationResult
|
||||
|
||||
|
||||
class SkillManager:
|
||||
@@ -34,11 +35,21 @@ class SkillManager:
|
||||
manager.activate(skill)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the SkillManager."""
|
||||
def __init__(self, enforcement_level: Optional[EnforcementLevel] = None):
|
||||
"""Initialize the SkillManager.
|
||||
|
||||
Args:
|
||||
enforcement_level: Capability enforcement level (defaults to environment-based)
|
||||
"""
|
||||
self._skills: Dict[str, LoadedSkill] = {}
|
||||
self._loader = SkillLoader()
|
||||
self._discovered = False
|
||||
self._validation_cache: Dict[str, ValidationResult] = {}
|
||||
|
||||
# Initialize capability validator
|
||||
if enforcement_level is None:
|
||||
enforcement_level = self._get_default_enforcement_level()
|
||||
self._validator = CapabilityValidator(enforcement_level)
|
||||
|
||||
@property
|
||||
def skills(self) -> List[LoadedSkill]:
|
||||
@@ -126,15 +137,29 @@ class SkillManager:
|
||||
def get_available_skills(self) -> List[SkillMetadata]:
|
||||
"""Get metadata for all available skills.
|
||||
|
||||
This is used for system prompt injection. Skills with
|
||||
``disable-model-invocation: true`` are omitted so the LLM never
|
||||
sees them and cannot auto-trigger them.
|
||||
This is used for system prompt injection. Skills are filtered by:
|
||||
1. disable-model-invocation: true (excluded)
|
||||
2. Capability enforcement (if strict mode, exclude UNAVAILABLE skills)
|
||||
"""
|
||||
return [
|
||||
skill.metadata
|
||||
for skill in self._skills.values()
|
||||
if not getattr(skill.properties, "disable_model_invocation", False)
|
||||
]
|
||||
available = []
|
||||
for skill in self._skills.values():
|
||||
# Skip if explicitly disabled for model invocation
|
||||
if getattr(skill.properties, "disable_model_invocation", False):
|
||||
continue
|
||||
|
||||
# Check capability requirements if enforcement is strict
|
||||
if self._validator.enforcement_level == EnforcementLevel.STRICT:
|
||||
try:
|
||||
validation = self.validate_skill_capabilities(skill.properties.name)
|
||||
if validation.state == SkillState.UNAVAILABLE:
|
||||
continue # Skip unavailable skills in strict mode
|
||||
except Exception as e:
|
||||
logger.warning(f"Skipping skill '{skill.properties.name}' due to validation error: {e}")
|
||||
continue
|
||||
|
||||
available.append(skill.metadata)
|
||||
|
||||
return available
|
||||
|
||||
def get_user_invocable_skills(self) -> List[LoadedSkill]:
|
||||
"""Return skills the *user* may invoke via slash-commands.
|
||||
@@ -566,9 +591,90 @@ author: agent
|
||||
logger.debug("Failed to clean up temp file %s", temp_path, exc_info=True)
|
||||
raise
|
||||
|
||||
def validate_skill_capabilities(self, skill_name: str, force_refresh: bool = False) -> ValidationResult:
|
||||
"""Validate a skill's capability requirements.
|
||||
|
||||
Args:
|
||||
skill_name: Name of the skill to validate
|
||||
force_refresh: If True, bypass cache and re-validate
|
||||
|
||||
Returns:
|
||||
ValidationResult with capability status
|
||||
|
||||
Raises:
|
||||
ValueError: If skill not found
|
||||
"""
|
||||
skill = self.get_skill(skill_name)
|
||||
if skill is None:
|
||||
raise ValueError(f"Skill '{skill_name}' not found")
|
||||
|
||||
# Check cache first
|
||||
if not force_refresh and skill_name in self._validation_cache:
|
||||
return self._validation_cache[skill_name]
|
||||
|
||||
# Perform validation
|
||||
result = self._validator.validate_skill(skill.properties)
|
||||
self._validation_cache[skill_name] = result
|
||||
return result
|
||||
|
||||
def get_available_skills_by_state(self, state: SkillState) -> List[LoadedSkill]:
|
||||
"""Get skills filtered by their capability validation state.
|
||||
|
||||
Args:
|
||||
state: Desired skill state
|
||||
|
||||
Returns:
|
||||
List of skills in the specified state
|
||||
"""
|
||||
matching = []
|
||||
for skill in self._skills.values():
|
||||
try:
|
||||
result = self.validate_skill_capabilities(skill.properties.name)
|
||||
if result.state == state:
|
||||
matching.append(skill)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to validate skill '{skill.properties.name}': {e}")
|
||||
continue
|
||||
return matching
|
||||
|
||||
def get_skills_diagnostics(self) -> Dict[str, ValidationResult]:
|
||||
"""Get capability diagnostics for all skills.
|
||||
|
||||
Returns:
|
||||
Dict mapping skill names to their validation results
|
||||
"""
|
||||
diagnostics = {}
|
||||
for skill_name in self.skill_names:
|
||||
try:
|
||||
diagnostics[skill_name] = self.validate_skill_capabilities(skill_name)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to validate skill '{skill_name}': {e}")
|
||||
return diagnostics
|
||||
|
||||
def _get_default_enforcement_level(self) -> EnforcementLevel:
|
||||
"""Get default enforcement level from environment."""
|
||||
import os
|
||||
level_str = os.getenv('SKILL_CAPABILITY_ENFORCEMENT', 'warn').lower()
|
||||
|
||||
level_map = {
|
||||
'disabled': EnforcementLevel.DISABLED,
|
||||
'off': EnforcementLevel.DISABLED,
|
||||
'telemetry': EnforcementLevel.TELEMETRY,
|
||||
'log': EnforcementLevel.TELEMETRY,
|
||||
'warn': EnforcementLevel.WARN,
|
||||
'warning': EnforcementLevel.WARN,
|
||||
'strict': EnforcementLevel.STRICT,
|
||||
'hard': EnforcementLevel.STRICT,
|
||||
'fail': EnforcementLevel.STRICT,
|
||||
}
|
||||
|
||||
return level_map.get(level_str, EnforcementLevel.WARN)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all loaded skills."""
|
||||
self._skills.clear()
|
||||
self._validation_cache.clear()
|
||||
self._validator.clear_cache()
|
||||
self._discovered = False
|
||||
|
||||
def __len__(self) -> int:
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Data models for Agent Skills."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
from typing import Optional, List, Union
|
||||
from pathlib import Path
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ParseError(Exception):
|
||||
@@ -15,6 +16,105 @@ class ValidationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SkillState(Enum):
|
||||
"""Skill activation state based on requirement validation."""
|
||||
ACTIVE = "active" # All requirements satisfied
|
||||
DEGRADED = "degraded" # Some requirements missing (soft warn)
|
||||
UNAVAILABLE = "unavailable" # Critical requirements missing (hard fail)
|
||||
UNKNOWN = "unknown" # Requirements not yet validated
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillRequirements:
|
||||
"""Capability requirements for a skill.
|
||||
|
||||
This represents the parsed and normalized requirements from skill frontmatter.
|
||||
Supports both Hermes/OpenClaw conventions and PraisonAI-specific extensions.
|
||||
|
||||
Attributes:
|
||||
servers: Required MCP servers or service endpoints
|
||||
tools: Required tool names (from tool registry)
|
||||
env_vars: Required environment variables (use sparingly)
|
||||
openclaw_hints: OpenClaw-specific metadata (passthrough)
|
||||
"""
|
||||
servers: List[str] = field(default_factory=list)
|
||||
tools: List[str] = field(default_factory=list)
|
||||
env_vars: List[str] = field(default_factory=list)
|
||||
openclaw_hints: dict = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def from_frontmatter(cls, metadata: dict) -> "SkillRequirements":
|
||||
"""Parse requirements from skill frontmatter.
|
||||
|
||||
Normalizes various naming conventions:
|
||||
- requires_servers, requires_tools (Hermes/OpenClaw style)
|
||||
- allowed-tools (existing PraisonAI convention)
|
||||
- openclaw (OpenClaw-specific hints)
|
||||
|
||||
Args:
|
||||
metadata: Raw frontmatter dict
|
||||
|
||||
Returns:
|
||||
SkillRequirements instance
|
||||
"""
|
||||
servers = []
|
||||
tools = []
|
||||
env_vars = []
|
||||
openclaw_hints = {}
|
||||
|
||||
# Parse servers
|
||||
if "requires_servers" in metadata:
|
||||
servers = cls._normalize_list(metadata["requires_servers"])
|
||||
elif "requires-servers" in metadata:
|
||||
servers = cls._normalize_list(metadata["requires-servers"])
|
||||
|
||||
# Parse tools (multiple naming conventions)
|
||||
if "requires_tools" in metadata:
|
||||
tools.extend(cls._normalize_list(metadata["requires_tools"]))
|
||||
elif "requires-tools" in metadata:
|
||||
tools.extend(cls._normalize_list(metadata["requires-tools"]))
|
||||
|
||||
# Support existing allowed-tools convention for backward compatibility
|
||||
if "allowed-tools" in metadata:
|
||||
tools.extend(cls._normalize_list(metadata["allowed-tools"]))
|
||||
|
||||
# Parse environment variables
|
||||
if "requires_env" in metadata:
|
||||
env_vars = cls._normalize_list(metadata["requires_env"])
|
||||
elif "requires-env" in metadata:
|
||||
env_vars = cls._normalize_list(metadata["requires-env"])
|
||||
|
||||
# OpenClaw hints (passthrough)
|
||||
if "openclaw" in metadata:
|
||||
openclaw_hints = metadata["openclaw"] if isinstance(metadata["openclaw"], dict) else {}
|
||||
|
||||
return cls(
|
||||
servers=servers,
|
||||
tools=tools,
|
||||
env_vars=env_vars,
|
||||
openclaw_hints=openclaw_hints
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_list(value: Union[str, List[str]]) -> List[str]:
|
||||
"""Normalize string or list to list of strings."""
|
||||
if isinstance(value, str):
|
||||
# Support both comma-separated and space-separated strings
|
||||
return [item.strip() for item in value.replace(',', ' ').split() if item.strip()]
|
||||
elif isinstance(value, (list, tuple)):
|
||||
return [str(item).strip() for item in value if str(item).strip()]
|
||||
else:
|
||||
return []
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
"""Check if no requirements are specified."""
|
||||
return not any([self.servers, self.tools, self.env_vars, self.openclaw_hints])
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
"""Truth value: True if any requirements are specified."""
|
||||
return not self.is_empty()
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillProperties:
|
||||
"""Properties parsed from a skill's SKILL.md frontmatter.
|
||||
@@ -49,6 +149,8 @@ class SkillProperties:
|
||||
hooks: Optional[dict] = None
|
||||
paths: Optional[object] = None # list[str] glob patterns
|
||||
shell: Optional[str] = None # "bash" | "powershell"
|
||||
# Capability requirements (new)
|
||||
requirements: Optional[SkillRequirements] = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary, excluding None values and empty metadata."""
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from .models import ParseError, ValidationError, SkillProperties
|
||||
from .models import ParseError, ValidationError, SkillProperties, SkillRequirements
|
||||
|
||||
|
||||
def find_skill_md(skill_dir: Path) -> Optional[Path]:
|
||||
@@ -100,7 +100,7 @@ def read_properties(skill_dir: Path) -> SkillProperties:
|
||||
if skill_md is None:
|
||||
raise ParseError(f"SKILL.md not found in {skill_dir}")
|
||||
|
||||
content = skill_md.read_text()
|
||||
content = skill_md.read_text(encoding="utf-8")
|
||||
metadata, _ = parse_frontmatter(content)
|
||||
|
||||
if "name" not in metadata:
|
||||
@@ -143,4 +143,6 @@ def read_properties(skill_dir: Path) -> SkillProperties:
|
||||
hooks=metadata.get("hooks") if isinstance(metadata.get("hooks"), dict) else None,
|
||||
paths=metadata.get("paths"),
|
||||
shell=metadata.get("shell"),
|
||||
# Parse capability requirements
|
||||
requirements=SkillRequirements.from_frontmatter(metadata),
|
||||
)
|
||||
@@ -7,7 +7,7 @@ plugin bundle, enterprise policy) without touching the core.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, Iterable, Optional, runtime_checkable
|
||||
from typing import Protocol, Iterable, Optional, runtime_checkable, List, Dict, Any
|
||||
|
||||
from .models import SkillProperties
|
||||
|
||||
@@ -45,7 +45,8 @@ class SkillMutatorProtocol(Protocol):
|
||||
"""Agent-managed skill CRUD operations.
|
||||
|
||||
Allows agents to create, edit, and manage their own skills at runtime.
|
||||
Implementations should provide safe-by-default behavior (e.g., propose mode).
|
||||
Implementations should provide safe-by-default behaviour: use ``propose=True``
|
||||
(default) to stage mutations for human approval before writing to disk.
|
||||
"""
|
||||
|
||||
def create(self, name: str, content: str, category: Optional[str] = None,
|
||||
@@ -163,3 +164,30 @@ class SkillMutatorProtocol(Protocol):
|
||||
Status message describing the action taken
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SkillsCatalogProtocol(Protocol):
|
||||
"""Metadata listing for skills — UI catalog pages."""
|
||||
|
||||
def list_skills(self) -> List[Dict[str, Any]]:
|
||||
"""Return skill metadata dicts (name, description, location)."""
|
||||
...
|
||||
|
||||
|
||||
def list_skills_for_api() -> List[Dict[str, Any]]:
|
||||
"""Default catalog adapter using SkillManager when available."""
|
||||
try:
|
||||
from .manager import SkillManager
|
||||
|
||||
manager = SkillManager()
|
||||
return [
|
||||
{
|
||||
"name": s.name,
|
||||
"description": s.description,
|
||||
"location": getattr(s, "location", ""),
|
||||
}
|
||||
for s in manager.get_available_skills()
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
@@ -191,9 +191,11 @@ def validate(skill_dir: Path, strict: bool = False) -> List[str]:
|
||||
return ["Missing required file: SKILL.md"]
|
||||
|
||||
try:
|
||||
content = skill_md.read_text()
|
||||
content = skill_md.read_text(encoding="utf-8")
|
||||
metadata, _ = parse_frontmatter(content)
|
||||
except ParseError as e:
|
||||
return [str(e)]
|
||||
except UnicodeDecodeError as e:
|
||||
return [f"SKILL.md is not valid UTF-8: {e}"]
|
||||
|
||||
return validate_metadata(metadata, skill_dir, strict=strict)
|
||||
@@ -100,3 +100,107 @@ class InMemoryTokenUsageSink:
|
||||
def clear(self) -> None:
|
||||
"""Clear all records."""
|
||||
self.records.clear()
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class UsageQueryProtocol(Protocol):
|
||||
"""Read path for token usage — UI dashboards and analytics."""
|
||||
|
||||
def get_summary(self) -> Dict[str, Any]:
|
||||
"""Aggregate totals and breakdowns by model/agent."""
|
||||
...
|
||||
|
||||
def list_recent(self, limit: int = 50) -> List[Dict[str, Any]]:
|
||||
"""Recent usage records, newest last."""
|
||||
...
|
||||
|
||||
def get_by_task(self, task_id: str) -> List[Dict[str, Any]]:
|
||||
...
|
||||
|
||||
def get_by_agent(self, agent_name: str) -> List[Dict[str, Any]]:
|
||||
...
|
||||
|
||||
|
||||
class InMemoryUsageQuery:
|
||||
"""Query adapter backed by InMemoryTokenUsageSink records."""
|
||||
|
||||
def __init__(self, sink: InMemoryTokenUsageSink):
|
||||
self._sink = sink
|
||||
|
||||
def get_summary(self) -> Dict[str, Any]:
|
||||
records = self._sink.records
|
||||
total_in = 0
|
||||
total_out = 0
|
||||
total_all = 0
|
||||
by_model: Dict[str, Dict[str, int]] = {}
|
||||
by_agent: Dict[str, Dict[str, int]] = {}
|
||||
|
||||
# Single-pass optimization: calculate everything in one loop
|
||||
for r in records:
|
||||
r_in = r.get("input_tokens", 0)
|
||||
r_out = r.get("output_tokens", 0)
|
||||
r_total = r.get("total_tokens", r_in + r_out)
|
||||
|
||||
total_in += r_in
|
||||
total_out += r_out
|
||||
total_all += r_total
|
||||
|
||||
model = r.get("model", "unknown")
|
||||
agent = r.get("agent_name", "unknown")
|
||||
|
||||
# Update by_model and by_agent efficiently
|
||||
for bucket, key in ((by_model, model), (by_agent, agent)):
|
||||
stats = bucket.setdefault(key, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
|
||||
stats["input_tokens"] += r_in
|
||||
stats["output_tokens"] += r_out
|
||||
stats["total_tokens"] += r_total
|
||||
return {
|
||||
"total_requests": len(records),
|
||||
"total_input_tokens": total_in,
|
||||
"total_output_tokens": total_out,
|
||||
"total_tokens": total_all, # Use consistent calculation
|
||||
"by_model": by_model,
|
||||
"by_agent": by_agent,
|
||||
}
|
||||
|
||||
def list_recent(self, limit: int = 50) -> List[Dict[str, Any]]:
|
||||
return self._sink.records[-limit:]
|
||||
|
||||
def get_by_task(self, task_id: str) -> List[Dict[str, Any]]:
|
||||
return self._sink.get_by_task(task_id)
|
||||
|
||||
def get_by_agent(self, agent_name: str) -> List[Dict[str, Any]]:
|
||||
return self._sink.get_by_agent(agent_name)
|
||||
|
||||
|
||||
class TokenCollectorUsageQuery:
|
||||
"""Query adapter reading from the global TokenCollector + optional sink."""
|
||||
|
||||
def __init__(self, collector: Any):
|
||||
self._collector = collector
|
||||
|
||||
def get_summary(self) -> Dict[str, Any]:
|
||||
summary = self._collector.get_session_summary()
|
||||
return {
|
||||
"total_requests": summary.get("total_interactions", 0),
|
||||
"total_input_tokens": summary.get("total_metrics", {}).get("input_tokens", 0),
|
||||
"total_output_tokens": summary.get("total_metrics", {}).get("output_tokens", 0),
|
||||
"total_tokens": summary.get("total_tokens", 0),
|
||||
"by_model": summary.get("by_model", {}),
|
||||
"by_agent": summary.get("by_agent", {}),
|
||||
}
|
||||
|
||||
def list_recent(self, limit: int = 50) -> List[Dict[str, Any]]:
|
||||
return self._collector.get_recent_interactions(limit)
|
||||
|
||||
def get_by_task(self, task_id: str) -> List[Dict[str, Any]]:
|
||||
sink = getattr(self._collector, "_sink", None)
|
||||
if sink and hasattr(sink, "get_by_task"):
|
||||
return sink.get_by_task(task_id)
|
||||
return []
|
||||
|
||||
def get_by_agent(self, agent_name: str) -> List[Dict[str, Any]]:
|
||||
sink = getattr(self._collector, "_sink", None)
|
||||
if sink and hasattr(sink, "get_by_agent"):
|
||||
return sink.get_by_agent(agent_name)
|
||||
return []
|
||||
@@ -191,4 +191,9 @@ class TokenCollector:
|
||||
|
||||
|
||||
# Global token collector instance
|
||||
_token_collector = TokenCollector()
|
||||
_token_collector = TokenCollector()
|
||||
|
||||
|
||||
def get_token_collector() -> TokenCollector:
|
||||
"""Return the global TokenCollector singleton."""
|
||||
return _token_collector
|
||||
@@ -7,7 +7,7 @@ import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
|
||||
# Import dependencies
|
||||
from .token_collector import _token_collector, TokenMetrics
|
||||
from .token_collector import get_token_collector, TokenMetrics
|
||||
from .telemetry import get_telemetry
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -56,11 +56,12 @@ class TokenTelemetryBridge:
|
||||
|
||||
def export_token_metrics(self) -> Dict[str, Any]:
|
||||
"""Export token metrics for telemetry reporting."""
|
||||
if not _token_collector:
|
||||
collector = get_token_collector()
|
||||
if not collector:
|
||||
return {}
|
||||
|
||||
try:
|
||||
metrics = _token_collector.export_metrics()
|
||||
metrics = collector.export_metrics()
|
||||
|
||||
# Prepare telemetry-friendly format
|
||||
return {
|
||||
@@ -77,8 +78,7 @@ class TokenTelemetryBridge:
|
||||
|
||||
def reset_token_metrics(self):
|
||||
"""Reset token metrics collection."""
|
||||
if _token_collector:
|
||||
_token_collector.reset()
|
||||
get_token_collector().reset()
|
||||
|
||||
# Global telemetry bridge instance
|
||||
_token_telemetry_bridge = TokenTelemetryBridge()
|
||||
|
||||
@@ -14,10 +14,31 @@ import json
|
||||
from typing import Any, Dict, List, Union
|
||||
|
||||
from praisonaiagents.tools.decorator import tool
|
||||
from praisonaiagents.ui.protocols import A2UI_MIME_TYPE
|
||||
|
||||
|
||||
def _unwrap_messages(messages: Union[List[Dict[str, Any]], Dict[str, Any], str]) -> List[Dict[str, Any]]:
|
||||
"""Minimal unwrap for common LLM arg shapes (not full UI normalisation)."""
|
||||
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 isinstance(messages, dict):
|
||||
if "messages" in messages:
|
||||
messages = messages["messages"]
|
||||
else:
|
||||
messages = [messages]
|
||||
|
||||
if not isinstance(messages, list):
|
||||
raise ValueError("messages must be a list of A2UI message dicts or a JSON string")
|
||||
|
||||
return messages
|
||||
|
||||
|
||||
@tool
|
||||
def send_a2ui_messages(messages: Union[List[Dict[str, Any]], str]) -> Dict[str, Any]:
|
||||
def send_a2ui_messages(messages: Union[List[Dict[str, Any]], Dict[str, Any], str]) -> Dict[str, Any]:
|
||||
"""
|
||||
Send validated A2UI JSON messages to a connected A2UI renderer client.
|
||||
|
||||
@@ -32,21 +53,14 @@ def send_a2ui_messages(messages: Union[List[Dict[str, Any]], str]) -> Dict[str,
|
||||
"""
|
||||
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")
|
||||
messages = _unwrap_messages(messages)
|
||||
|
||||
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"),
|
||||
"mime_type": metadata.get("mimeType", A2UI_MIME_TYPE),
|
||||
"messages": messages,
|
||||
"a2ui_part": data,
|
||||
}
|
||||
@@ -15,7 +15,7 @@ Usage:
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, Optional, Type, get_type_hints
|
||||
from typing import Any, Dict, List, Optional, Type, get_type_hints
|
||||
import inspect
|
||||
import logging
|
||||
|
||||
@@ -234,18 +234,81 @@ class BaseTool(ABC):
|
||||
if getattr(self.run, '__isabstractmethod__', False):
|
||||
errors.append("Tool must implement the 'run()' method")
|
||||
|
||||
# Check parameters schema is valid
|
||||
# Check parameters schema is valid and OpenAI-compatible
|
||||
if self.parameters:
|
||||
if not isinstance(self.parameters, dict):
|
||||
errors.append("'parameters' must be a dictionary")
|
||||
elif "type" not in self.parameters:
|
||||
errors.append("'parameters' must have a 'type' field")
|
||||
elif "properties" not in self.parameters:
|
||||
errors.append("'parameters' must have a 'properties' field for OpenAI compatibility")
|
||||
|
||||
# Validate schema structure for OpenAI compatibility
|
||||
try:
|
||||
schema = self.get_schema()
|
||||
if not isinstance(schema, dict):
|
||||
errors.append("get_schema() must return a dictionary")
|
||||
elif schema.get("type") != "function":
|
||||
errors.append("get_schema() must return schema with type='function'")
|
||||
elif "function" not in schema:
|
||||
errors.append("get_schema() must have 'function' key")
|
||||
else:
|
||||
func = schema["function"]
|
||||
if not isinstance(func.get("parameters"), dict):
|
||||
errors.append("Schema function.parameters must be a dictionary")
|
||||
elif "properties" not in func["parameters"]:
|
||||
errors.append("Schema function.parameters must have 'properties' for OpenAI compatibility")
|
||||
except Exception as e:
|
||||
errors.append(f"Schema generation failed: {e}")
|
||||
|
||||
if errors:
|
||||
raise ToolValidationError(f"Tool '{self.name}' validation failed: {'; '.join(errors)}")
|
||||
|
||||
return True
|
||||
|
||||
def validate_schema_roundtrip(self) -> bool:
|
||||
"""Validate that schema can round-trip through OpenAI-style serialization.
|
||||
|
||||
This ensures the tool schema is compatible with LLM providers that expect
|
||||
OpenAI function calling format and that parameters can be properly parsed.
|
||||
|
||||
Raises:
|
||||
ToolValidationError: If round-trip fails
|
||||
|
||||
Returns:
|
||||
True if round-trip validation passes
|
||||
"""
|
||||
try:
|
||||
import json
|
||||
|
||||
# Get the schema
|
||||
schema = self.get_schema()
|
||||
|
||||
# Serialize to JSON (what gets sent to LLM)
|
||||
serialized = json.dumps(schema)
|
||||
|
||||
# Deserialize back
|
||||
deserialized = json.loads(serialized)
|
||||
|
||||
# Validate structure matches expectations
|
||||
if deserialized != schema:
|
||||
raise ToolValidationError(f"Schema serialization round-trip failed for tool '{self.name}'")
|
||||
|
||||
# Validate OpenAI format requirements
|
||||
func_schema = deserialized.get("function", {})
|
||||
parameters = func_schema.get("parameters", {})
|
||||
|
||||
if not isinstance(parameters.get("properties"), dict):
|
||||
raise ToolValidationError(f"Tool '{self.name}' parameters.properties must be a dict for OpenAI compatibility")
|
||||
|
||||
if "required" in parameters and not isinstance(parameters["required"], list):
|
||||
raise ToolValidationError(f"Tool '{self.name}' parameters.required must be a list")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
raise ToolValidationError(f"Schema round-trip validation failed for tool '{self.name}': {e}")
|
||||
|
||||
@classmethod
|
||||
def validate_class(cls) -> bool:
|
||||
"""Validate a tool class before instantiation.
|
||||
@@ -282,7 +345,9 @@ def validate_tool(tool: Any) -> bool:
|
||||
ToolValidationError: If validation fails
|
||||
"""
|
||||
if isinstance(tool, BaseTool):
|
||||
return tool.validate()
|
||||
tool.validate()
|
||||
tool.validate_schema_roundtrip()
|
||||
return True
|
||||
|
||||
if callable(tool):
|
||||
# For plain functions, check they have a name
|
||||
@@ -294,5 +359,67 @@ def validate_tool(tool: Any) -> bool:
|
||||
raise ToolValidationError(f"Invalid tool type: {type(tool)}")
|
||||
|
||||
|
||||
def validate_tool_schema_consistency(tools: List[Any]) -> bool:
|
||||
"""Validate a list of tools for schema consistency with OpenAI format.
|
||||
|
||||
This function ensures all tools in a list can be properly serialized
|
||||
and have consistent schema structures for use with LLM providers.
|
||||
|
||||
Args:
|
||||
tools: List of tool objects to validate
|
||||
|
||||
Returns:
|
||||
True if all tools are valid and consistent
|
||||
|
||||
Raises:
|
||||
ToolValidationError: If validation fails
|
||||
"""
|
||||
if not tools:
|
||||
return True
|
||||
|
||||
import json
|
||||
from .decorator import get_tool_schema
|
||||
schemas = []
|
||||
|
||||
for i, tool in enumerate(tools):
|
||||
try:
|
||||
# Validate individual tool
|
||||
validate_tool(tool)
|
||||
|
||||
# Get schema from different tool types
|
||||
if isinstance(tool, BaseTool):
|
||||
schema = tool.get_schema()
|
||||
elif hasattr(tool, 'get_schema') and callable(getattr(tool, 'get_schema')):
|
||||
schema = tool.get_schema()
|
||||
elif callable(tool):
|
||||
schema = get_tool_schema(tool)
|
||||
else:
|
||||
raise ToolValidationError(f"Cannot extract schema from tool at index {i}: {type(tool)}")
|
||||
|
||||
if not schema:
|
||||
raise ToolValidationError(f"Tool at index {i} returned empty schema")
|
||||
|
||||
# Validate JSON serialization
|
||||
try:
|
||||
json.dumps(schema)
|
||||
except (TypeError, ValueError) as e:
|
||||
raise ToolValidationError(f"Tool schema at index {i} is not JSON serializable: {e}")
|
||||
|
||||
schemas.append(schema)
|
||||
|
||||
except Exception as e:
|
||||
raise ToolValidationError(f"Tool validation failed at index {i}: {e}")
|
||||
|
||||
# Check for duplicate tool names
|
||||
names = set()
|
||||
for schema in schemas:
|
||||
name = schema.get("function", {}).get("name")
|
||||
if name in names:
|
||||
raise ToolValidationError(f"Duplicate tool name '{name}' found in tool list")
|
||||
names.add(name)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# For backward compatibility - tools can also just be functions
|
||||
# The @tool decorator (in decorator.py) wraps functions into BaseTool instances
|
||||
@@ -214,6 +214,13 @@ def tool(
|
||||
availability=availability
|
||||
)
|
||||
|
||||
# Validate the tool at creation time for early error detection
|
||||
try:
|
||||
tool_instance.validate()
|
||||
tool_instance.validate_schema_roundtrip()
|
||||
except Exception as e:
|
||||
logging.warning(f"Tool validation warning for {tool_instance.name}: {e}")
|
||||
|
||||
# Register with global registry if available
|
||||
try:
|
||||
from .registry import get_registry
|
||||
|
||||
@@ -253,7 +253,7 @@ class A2A:
|
||||
from praisonaiagents.ui.a2a.conversion import (
|
||||
extract_user_input,
|
||||
praisonai_to_a2a_message,
|
||||
create_artifact,
|
||||
create_response_artifact,
|
||||
)
|
||||
|
||||
# Validate params
|
||||
@@ -286,7 +286,8 @@ class A2A:
|
||||
else:
|
||||
raise RuntimeError("No agent or agents configured")
|
||||
response_msg = praisonai_to_a2a_message(str(response), task_id=task_id)
|
||||
artifact = create_artifact(str(response))
|
||||
history = getattr(self.agent, "chat_history", None) if self.agent else None
|
||||
artifact = create_response_artifact(str(response), chat_history=history)
|
||||
self.task_store.add_to_history(task_id, response_msg)
|
||||
self.task_store.add_artifact(task_id, artifact)
|
||||
self.task_store.update_status(task_id, TaskState.COMPLETED)
|
||||
@@ -322,7 +323,8 @@ class A2A:
|
||||
response_msg = praisonai_to_a2a_message(
|
||||
str(response), task_id=task.id
|
||||
)
|
||||
artifact = create_artifact(str(response))
|
||||
history = getattr(self.agent, "chat_history", None) if self.agent else None
|
||||
artifact = create_response_artifact(str(response), chat_history=history)
|
||||
|
||||
# Update task store
|
||||
self.task_store.add_to_history(task.id, response_msg)
|
||||
|
||||
@@ -16,6 +16,7 @@ from praisonaiagents.ui.a2a.types import (
|
||||
DataPart,
|
||||
Artifact,
|
||||
)
|
||||
from praisonaiagents.ui.protocols import A2UI_MIME_TYPE
|
||||
|
||||
|
||||
def a2a_to_praisonai_messages(messages: List[Message]) -> List[Dict[str, Any]]:
|
||||
@@ -107,6 +108,75 @@ def praisonai_to_a2a_message(
|
||||
)
|
||||
|
||||
|
||||
def find_a2ui_tool_result(
|
||||
chat_history: Optional[List[Dict[str, Any]]],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Return the last A2UI tool result dict from agent chat history, if any."""
|
||||
if not chat_history:
|
||||
return None
|
||||
for msg in reversed(chat_history):
|
||||
if msg.get("role") != "tool":
|
||||
continue
|
||||
content = msg.get("content")
|
||||
if content is None:
|
||||
continue
|
||||
data: Any = content if isinstance(content, dict) else None
|
||||
if data is None:
|
||||
try:
|
||||
data = json.loads(content)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
if isinstance(data, dict) and data.get("mime_type") == A2UI_MIME_TYPE:
|
||||
return data
|
||||
return None
|
||||
|
||||
|
||||
def create_a2ui_artifact(
|
||||
result: Dict[str, Any],
|
||||
artifact_id: Optional[str] = None,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
) -> Artifact:
|
||||
"""Create an A2A Artifact with a DataPart for an A2UI tool result."""
|
||||
art_id = artifact_id or f"art-{uuid.uuid4().hex[:12]}"
|
||||
messages = result.get("messages") or []
|
||||
a2ui_data = result.get("a2ui_part")
|
||||
if not isinstance(a2ui_data, dict):
|
||||
a2ui_data = {"messages": messages}
|
||||
elif "messages" not in a2ui_data:
|
||||
a2ui_data = {**a2ui_data, "messages": messages}
|
||||
|
||||
return Artifact(
|
||||
artifact_id=art_id,
|
||||
name=name,
|
||||
description=description,
|
||||
parts=[
|
||||
DataPart(
|
||||
data=a2ui_data,
|
||||
metadata={"mimeType": result.get("mime_type", A2UI_MIME_TYPE)},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def create_response_artifact(
|
||||
content: str,
|
||||
chat_history: Optional[List[Dict[str, Any]]] = None,
|
||||
artifact_id: Optional[str] = None,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
) -> Artifact:
|
||||
"""Build artifact from response text, preferring A2UI DataPart when present in history."""
|
||||
a2ui = find_a2ui_tool_result(chat_history)
|
||||
if a2ui:
|
||||
return create_a2ui_artifact(
|
||||
a2ui, artifact_id=artifact_id, name=name, description=description
|
||||
)
|
||||
return create_artifact(
|
||||
content, artifact_id=artifact_id, name=name, description=description
|
||||
)
|
||||
|
||||
|
||||
def create_artifact(
|
||||
content: str,
|
||||
artifact_id: Optional[str] = None,
|
||||
|
||||
@@ -12,9 +12,12 @@ from praisonaiagents.ui.a2ui.adapter import (
|
||||
is_a2ui_part,
|
||||
parse_a2ui_response,
|
||||
)
|
||||
from praisonaiagents.ui.protocols import A2UI_MIME_TYPE, A2UIToolResultProtocol
|
||||
|
||||
__all__ = [
|
||||
"A2UI",
|
||||
"A2UI_MIME_TYPE",
|
||||
"A2UIToolResultProtocol",
|
||||
"create_a2ui_part",
|
||||
"generate_a2ui_system_prompt",
|
||||
"get_schema_manager",
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any, AsyncIterator, Dict, Iterator, List, Optional, Set
|
||||
|
||||
from praisonaiagents.ui.agui.types import (
|
||||
BaseEvent,
|
||||
CustomEvent,
|
||||
TextMessageStartEvent,
|
||||
TextMessageContentEvent,
|
||||
TextMessageEndEvent,
|
||||
@@ -26,6 +27,7 @@ from praisonaiagents.ui.agui.types import (
|
||||
StepFinishedEvent,
|
||||
StateSnapshotEvent,
|
||||
)
|
||||
from praisonaiagents.ui.protocols import A2UI_MIME_TYPE
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -292,13 +294,41 @@ def stream_event_to_agui_events(
|
||||
|
||||
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", ""))
|
||||
raw_result = event.tool_call.get("result")
|
||||
content = event.content if event.content is not None else str(raw_result or "")
|
||||
buffer.end_tool_call(tool_call_id)
|
||||
return [create_tool_result_event(tool_call_id, str(content), message_id)]
|
||||
events: List[BaseEvent] = [
|
||||
create_tool_result_event(tool_call_id, str(content), message_id)
|
||||
]
|
||||
if isinstance(raw_result, dict) and raw_result.get("mime_type") == A2UI_MIME_TYPE:
|
||||
events.append(
|
||||
CustomEvent(
|
||||
name="a2ui",
|
||||
value={
|
||||
"mime_type": A2UI_MIME_TYPE,
|
||||
"messages": raw_result.get("messages", []),
|
||||
"surface_id": _infer_a2ui_surface_id(raw_result),
|
||||
},
|
||||
)
|
||||
)
|
||||
return events
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _infer_a2ui_surface_id(result: Dict[str, Any]) -> str:
|
||||
"""Infer surface id from send_a2ui_messages result (inline, no UI-layer deps)."""
|
||||
for msg in result.get("messages") or []:
|
||||
if isinstance(msg, dict):
|
||||
create = msg.get("createSurface")
|
||||
if isinstance(create, dict):
|
||||
sid = create.get("surfaceId") or create.get("id")
|
||||
if sid:
|
||||
return str(sid)
|
||||
sid = result.get("surface_id") or result.get("surfaceId")
|
||||
return str(sid) if sid else "main"
|
||||
|
||||
|
||||
async def async_stream_agent_response(
|
||||
agent,
|
||||
user_input: str,
|
||||
|
||||
@@ -133,6 +133,13 @@ class StateSnapshotEvent(BaseEvent):
|
||||
snapshot: Dict[str, Any]
|
||||
|
||||
|
||||
class CustomEvent(BaseEvent):
|
||||
"""Custom extension event (e.g. structured A2UI payloads)."""
|
||||
type: Literal["CUSTOM"] = "CUSTOM"
|
||||
name: str
|
||||
value: Any
|
||||
|
||||
|
||||
# Message Types
|
||||
class FunctionCall(BaseModel):
|
||||
"""Function call in a tool call."""
|
||||
@@ -175,6 +182,7 @@ Event = Union[
|
||||
ToolCallEndEvent,
|
||||
ToolCallResultEvent,
|
||||
StateSnapshotEvent,
|
||||
CustomEvent,
|
||||
RunStartedEvent,
|
||||
RunFinishedEvent,
|
||||
RunErrorEvent,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
UI protocol types — lightweight contracts for agent-to-UI integration.
|
||||
|
||||
Zero runtime cost: TypedDict and constants only. No optional dependencies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, TypedDict, Union
|
||||
|
||||
# Standard MIME type for A2UI payloads (Google A2UI / A2A transport).
|
||||
A2UI_MIME_TYPE = "application/json+a2ui"
|
||||
|
||||
|
||||
class A2UIToolResultProtocol(TypedDict, total=False):
|
||||
"""
|
||||
Output shape from ``send_a2ui_messages`` — the integrator contract for any UI.
|
||||
|
||||
Example:
|
||||
if result.get("mime_type") == A2UI_MIME_TYPE:
|
||||
render(result["messages"])
|
||||
"""
|
||||
|
||||
mime_type: str
|
||||
messages: List[Dict[str, Any]]
|
||||
a2ui_part: Union[Dict[str, Any], List[Any]]
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.43"
|
||||
version = "1.6.46"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -233,6 +233,47 @@ class TestA2AJsonRpc:
|
||||
assert result["status"]["state"] == "completed"
|
||||
assert result["artifacts"] is not None
|
||||
assert len(result["artifacts"]) > 0
|
||||
|
||||
def test_post_a2a_message_send_a2ui_artifact(self):
|
||||
"""message/send with A2UI tool result in chat_history → DataPart artifact."""
|
||||
import json
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from praisonaiagents.ui.protocols import A2UI_MIME_TYPE
|
||||
|
||||
app, a2a = _make_app()
|
||||
a2ui_payload = {
|
||||
"mime_type": A2UI_MIME_TYPE,
|
||||
"messages": [{"createSurface": {"surfaceId": "main", "catalogId": "basic"}}],
|
||||
"a2ui_part": {"messages": [{"createSurface": {"surfaceId": "main", "catalogId": "basic"}}]},
|
||||
}
|
||||
a2a.agent.chat = MagicMock(return_value="Surface ready.")
|
||||
a2a.agent.chat_history = [
|
||||
{"role": "tool", "content": json.dumps(a2ui_payload)},
|
||||
]
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.post("/a2a", json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "a2ui-1",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "msg-a2ui",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Show UI"}],
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
result = response.json()["result"]
|
||||
artifact = result["artifacts"][0]
|
||||
parts = artifact["parts"]
|
||||
data_parts = [p for p in parts if "data" in p]
|
||||
assert data_parts, "Expected DataPart in artifact"
|
||||
assert data_parts[0]["metadata"]["mimeType"] == A2UI_MIME_TYPE
|
||||
|
||||
def test_post_a2a_message_send_creates_task_in_store(self):
|
||||
"""message/send should create a task in TaskStore."""
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Tests for A2UI artifact conversion in A2A transport."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.ui.protocols import A2UI_MIME_TYPE
|
||||
|
||||
|
||||
SAMPLE_A2UI_RESULT = {
|
||||
"mime_type": A2UI_MIME_TYPE,
|
||||
"messages": [{"createSurface": {"surfaceId": "main", "catalogId": "basic"}}],
|
||||
"a2ui_part": {"messages": [{"createSurface": {"surfaceId": "main", "catalogId": "basic"}}]},
|
||||
}
|
||||
|
||||
|
||||
class TestFindA2uiToolResult:
|
||||
def test_finds_last_a2ui_tool_message(self):
|
||||
from praisonaiagents.ui.a2a.conversion import find_a2ui_tool_result
|
||||
|
||||
history = [
|
||||
{"role": "user", "content": "show ui"},
|
||||
{"role": "tool", "content": json.dumps({"mime_type": "text/plain", "data": "x"})},
|
||||
{"role": "tool", "content": json.dumps(SAMPLE_A2UI_RESULT)},
|
||||
]
|
||||
result = find_a2ui_tool_result(history)
|
||||
assert result is not None
|
||||
assert result["mime_type"] == A2UI_MIME_TYPE
|
||||
|
||||
def test_returns_none_without_a2ui(self):
|
||||
from praisonaiagents.ui.a2a.conversion import find_a2ui_tool_result
|
||||
|
||||
assert find_a2ui_tool_result([]) is None
|
||||
assert find_a2ui_tool_result([{"role": "tool", "content": "plain text"}]) is None
|
||||
|
||||
|
||||
class TestCreateA2uiArtifact:
|
||||
def test_creates_datapart_with_mime(self):
|
||||
from praisonaiagents.ui.a2a.conversion import create_a2ui_artifact
|
||||
from praisonaiagents.ui.a2a.types import DataPart
|
||||
|
||||
artifact = create_a2ui_artifact(SAMPLE_A2UI_RESULT, name="ui-surface")
|
||||
assert artifact.name == "ui-surface"
|
||||
assert len(artifact.parts) == 1
|
||||
part = artifact.parts[0]
|
||||
assert isinstance(part, DataPart)
|
||||
assert part.metadata.get("mimeType") == A2UI_MIME_TYPE
|
||||
assert "messages" in part.data
|
||||
|
||||
|
||||
class TestCreateResponseArtifact:
|
||||
def test_prefers_a2ui_from_history(self):
|
||||
from praisonaiagents.ui.a2a.conversion import create_response_artifact
|
||||
from praisonaiagents.ui.a2a.types import DataPart
|
||||
|
||||
history = [{"role": "tool", "content": json.dumps(SAMPLE_A2UI_RESULT)}]
|
||||
artifact = create_response_artifact("Done.", chat_history=history)
|
||||
assert isinstance(artifact.parts[0], DataPart)
|
||||
|
||||
def test_falls_back_to_text(self):
|
||||
from praisonaiagents.ui.a2a.conversion import create_response_artifact
|
||||
from praisonaiagents.ui.a2a.types import TextPart
|
||||
|
||||
artifact = create_response_artifact("Hello", chat_history=[])
|
||||
assert isinstance(artifact.parts[0], TextPart)
|
||||
assert artifact.parts[0].text == "Hello"
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Tests for send_a2ui_messages minimal arg unwrap."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.tools.a2ui_tools import _unwrap_messages
|
||||
|
||||
try:
|
||||
import a2ui # noqa: F401
|
||||
|
||||
HAS_A2UI_SDK = True
|
||||
except ImportError:
|
||||
HAS_A2UI_SDK = False
|
||||
|
||||
SAMPLE_MSG = {"createSurface": {"surfaceId": "main", "catalogId": "basic"}}
|
||||
|
||||
|
||||
class TestUnwrapMessages:
|
||||
def test_list_input(self):
|
||||
assert _unwrap_messages([SAMPLE_MSG]) == [SAMPLE_MSG]
|
||||
|
||||
def test_json_string_list(self):
|
||||
assert _unwrap_messages(json.dumps([SAMPLE_MSG])) == [SAMPLE_MSG]
|
||||
|
||||
def test_wrapped_messages_dict(self):
|
||||
assert _unwrap_messages({"messages": [SAMPLE_MSG]}) == [SAMPLE_MSG]
|
||||
|
||||
def test_json_string_wrapped_object(self):
|
||||
assert _unwrap_messages(json.dumps({"messages": [SAMPLE_MSG]})) == [SAMPLE_MSG]
|
||||
|
||||
def test_single_message_dict(self):
|
||||
assert _unwrap_messages(SAMPLE_MSG) == [SAMPLE_MSG]
|
||||
|
||||
def test_invalid_json_raises(self):
|
||||
with pytest.raises(ValueError, match="not valid JSON"):
|
||||
_unwrap_messages("{not json")
|
||||
|
||||
def test_invalid_shape_raises(self):
|
||||
with pytest.raises(ValueError, match="must be"):
|
||||
_unwrap_messages(123)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_A2UI_SDK, reason="a2ui-agent-sdk not installed")
|
||||
class TestSendA2uiMessagesIntegration:
|
||||
def test_full_tool_call(self):
|
||||
from praisonaiagents.tools.a2ui_tools import send_a2ui_messages
|
||||
|
||||
result = send_a2ui_messages(messages=[SAMPLE_MSG])
|
||||
assert result["mime_type"] == "application/json+a2ui"
|
||||
assert len(result["messages"]) == 1
|
||||
@@ -34,3 +34,27 @@ class TestA2UITool:
|
||||
)
|
||||
assert result["mime_type"] == "application/json+a2ui"
|
||||
assert len(result["messages"]) == 1
|
||||
|
||||
|
||||
class TestA2UIAdapterSmoke:
|
||||
def test_is_a2ui_part_with_sdk(self):
|
||||
pytest.importorskip("a2ui", reason="a2ui-agent-sdk not installed")
|
||||
from praisonaiagents.ui.a2ui.adapter import create_a2ui_part, is_a2ui_part
|
||||
|
||||
part = create_a2ui_part({"messages": [{"createSurface": {"surfaceId": "m"}}]})
|
||||
assert is_a2ui_part(part) is True
|
||||
|
||||
def test_missing_sdk_import_error_message(self):
|
||||
from unittest.mock import patch
|
||||
|
||||
from praisonaiagents.ui.a2ui import adapter
|
||||
|
||||
def _raise(_name):
|
||||
raise ImportError(
|
||||
"A2UI support requires the optional dependency. "
|
||||
"Install with: pip install praisonaiagents[a2ui]"
|
||||
)
|
||||
|
||||
with patch.object(adapter, "_import", side_effect=_raise):
|
||||
with pytest.raises(ImportError, match="praisonaiagents\\[a2ui\\]"):
|
||||
adapter.create_a2ui_part({"messages": []})
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Tests for UI protocol types (zero-dep contracts)."""
|
||||
|
||||
from praisonaiagents.ui.protocols import A2UI_MIME_TYPE, A2UIToolResultProtocol
|
||||
|
||||
|
||||
class TestA2UIProtocols:
|
||||
def test_mime_type_constant(self):
|
||||
assert A2UI_MIME_TYPE == "application/json+a2ui"
|
||||
|
||||
def test_tool_result_protocol_shape(self):
|
||||
result: A2UIToolResultProtocol = {
|
||||
"mime_type": A2UI_MIME_TYPE,
|
||||
"messages": [{"createSurface": {"surfaceId": "main"}}],
|
||||
"a2ui_part": {"messages": []},
|
||||
}
|
||||
assert result["mime_type"] == A2UI_MIME_TYPE
|
||||
|
||||
def test_exported_from_a2ui_package(self):
|
||||
from praisonaiagents.ui.a2ui import A2UI_MIME_TYPE, A2UIToolResultProtocol
|
||||
|
||||
assert A2UI_MIME_TYPE == "application/json+a2ui"
|
||||
assert A2UIToolResultProtocol is not None
|
||||
|
||||
def test_top_level_lazy_a2ui_export(self):
|
||||
from praisonaiagents import A2UI
|
||||
|
||||
assert A2UI is not None
|
||||
@@ -129,6 +129,29 @@ class TestStreamEventBridge:
|
||||
assert agui_events[0].type == "TOOL_CALL_START"
|
||||
assert agui_events[0].tool_call_name == "search"
|
||||
|
||||
def test_tool_call_result_a2ui_emits_custom_event(self):
|
||||
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
|
||||
from praisonaiagents.ui.agui.streaming import EventBuffer, stream_event_to_agui_events
|
||||
from praisonaiagents.ui.protocols import A2UI_MIME_TYPE
|
||||
|
||||
buffer = EventBuffer()
|
||||
a2ui_result = {
|
||||
"mime_type": A2UI_MIME_TYPE,
|
||||
"messages": [{"createSurface": {"surfaceId": "panel-1"}}],
|
||||
}
|
||||
event = StreamEvent(
|
||||
type=StreamEventType.TOOL_CALL_RESULT,
|
||||
tool_call={"id": "tc-a2ui", "name": "send_a2ui_messages", "result": a2ui_result},
|
||||
)
|
||||
agui_events = stream_event_to_agui_events(event, "msg-1", buffer)
|
||||
|
||||
assert len(agui_events) == 2
|
||||
assert agui_events[0].type == "TOOL_CALL_RESULT"
|
||||
assert agui_events[1].type == "CUSTOM"
|
||||
assert agui_events[1].name == "a2ui"
|
||||
assert agui_events[1].value["surface_id"] == "panel-1"
|
||||
assert agui_events[1].value["messages"] == a2ui_result["messages"]
|
||||
|
||||
|
||||
class TestRunLifecycleEvents:
|
||||
"""Test run lifecycle events."""
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Test tool schema parity and OpenAI compatibility.
|
||||
|
||||
This module tests the fixes for issue #1696 to ensure BaseTool ↔ OpenAI-compatible
|
||||
tool schema parity in chat/agent dispatch.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
from typing import Any, Dict, List
|
||||
from praisonaiagents.tools.base import BaseTool, ToolValidationError, validate_tool_schema_consistency
|
||||
from praisonaiagents.tools.decorator import tool, FunctionTool
|
||||
|
||||
|
||||
class MockTool(BaseTool):
|
||||
"""A simple mock tool for testing."""
|
||||
|
||||
name = "mock_tool"
|
||||
description = "A mock tool for testing"
|
||||
|
||||
def run(self, query: str, count: int = 1) -> str:
|
||||
return f"Mock result for {query} (count: {count})"
|
||||
|
||||
|
||||
class TestBasicToolSchemaValidation:
|
||||
"""Test basic tool schema validation and OpenAI compatibility."""
|
||||
|
||||
def test_base_tool_schema_structure(self):
|
||||
"""Test that BaseTool generates correct OpenAI schema structure."""
|
||||
tool = MockTool()
|
||||
schema = tool.get_schema()
|
||||
|
||||
# Validate top-level structure
|
||||
assert isinstance(schema, dict)
|
||||
assert schema["type"] == "function"
|
||||
assert "function" in schema
|
||||
|
||||
# Validate function structure
|
||||
func = schema["function"]
|
||||
assert func["name"] == "mock_tool"
|
||||
assert func["description"] == "A mock tool for testing"
|
||||
assert "parameters" in func
|
||||
|
||||
# Validate parameters structure
|
||||
params = func["parameters"]
|
||||
assert params["type"] == "object"
|
||||
assert "properties" in params
|
||||
assert "required" in params
|
||||
|
||||
# Validate specific properties
|
||||
props = params["properties"]
|
||||
assert "query" in props
|
||||
assert "count" in props
|
||||
assert props["query"]["type"] == "string"
|
||||
assert props["count"]["type"] == "integer"
|
||||
|
||||
# Validate required fields
|
||||
assert "query" in params["required"]
|
||||
assert "count" not in params["required"] # Has default value
|
||||
|
||||
def test_tool_schema_json_serialization(self):
|
||||
"""Test that tool schemas can be JSON serialized (round-trip test)."""
|
||||
tool = MockTool()
|
||||
schema = tool.get_schema()
|
||||
|
||||
# Should serialize without errors
|
||||
serialized = json.dumps(schema)
|
||||
assert isinstance(serialized, str)
|
||||
|
||||
# Should deserialize back to same structure
|
||||
deserialized = json.loads(serialized)
|
||||
assert deserialized == schema
|
||||
|
||||
def test_tool_validation_passes(self):
|
||||
"""Test that valid tools pass validation."""
|
||||
tool = MockTool()
|
||||
|
||||
# Basic validation should pass
|
||||
assert tool.validate() == True
|
||||
|
||||
# Round-trip validation should pass
|
||||
assert tool.validate_schema_roundtrip() == True
|
||||
|
||||
def test_function_tool_schema_parity(self):
|
||||
"""Test that @tool decorated functions have same schema structure."""
|
||||
@tool
|
||||
def test_function(query: str, count: int = 1) -> str:
|
||||
"""A test function for schema validation."""
|
||||
return f"Result for {query} (count: {count})"
|
||||
|
||||
schema = test_function.get_schema()
|
||||
|
||||
# Should have same structure as BaseTool
|
||||
assert schema["type"] == "function"
|
||||
func = schema["function"]
|
||||
assert func["name"] == "test_function"
|
||||
assert "parameters" in func
|
||||
|
||||
params = func["parameters"]
|
||||
assert params["type"] == "object"
|
||||
assert "properties" in params
|
||||
assert "required" in params
|
||||
|
||||
|
||||
class TestToolSchemaValidationErrors:
|
||||
"""Test that validation catches schema errors."""
|
||||
|
||||
def test_invalid_tool_missing_properties(self):
|
||||
"""Test validation fails for tools with invalid parameters."""
|
||||
|
||||
class BadTool(BaseTool):
|
||||
name = "bad_tool"
|
||||
description = "Bad tool"
|
||||
parameters = {"type": "object"} # Missing properties
|
||||
|
||||
def run(self, query: str) -> str:
|
||||
return query
|
||||
|
||||
tool = BadTool()
|
||||
with pytest.raises(ToolValidationError, match="must have a 'properties' field"):
|
||||
tool.validate()
|
||||
|
||||
def test_invalid_tool_missing_type(self):
|
||||
"""Test validation fails for tools with missing type."""
|
||||
|
||||
class BadTool(BaseTool):
|
||||
name = "bad_tool"
|
||||
description = "Bad tool"
|
||||
parameters = {"properties": {}} # Missing type
|
||||
|
||||
def run(self, query: str) -> str:
|
||||
return query
|
||||
|
||||
tool = BadTool()
|
||||
with pytest.raises(ToolValidationError, match="must have a 'type' field"):
|
||||
tool.validate()
|
||||
|
||||
|
||||
class TestToolListValidation:
|
||||
"""Test validation of tool lists for consistency."""
|
||||
|
||||
def test_valid_tool_list(self):
|
||||
"""Test that valid tool lists pass consistency checks."""
|
||||
@tool
|
||||
def tool_one(x: str) -> str:
|
||||
"""First tool."""
|
||||
return x
|
||||
|
||||
@tool
|
||||
def tool_two(y: int) -> int:
|
||||
"""Second tool."""
|
||||
return y
|
||||
|
||||
tool_three = MockTool()
|
||||
|
||||
tools = [tool_one, tool_two, tool_three]
|
||||
|
||||
# Should pass validation
|
||||
assert validate_tool_schema_consistency(tools) == True
|
||||
|
||||
def test_duplicate_tool_names_error(self):
|
||||
"""Test that duplicate tool names are caught."""
|
||||
@tool(name="duplicate_name")
|
||||
def tool_one(x: str) -> str:
|
||||
return x
|
||||
|
||||
@tool(name="duplicate_name")
|
||||
def tool_two(y: int) -> int:
|
||||
return y
|
||||
|
||||
tools = [tool_one, tool_two]
|
||||
|
||||
with pytest.raises(ToolValidationError, match="Duplicate tool name"):
|
||||
validate_tool_schema_consistency(tools)
|
||||
|
||||
def test_empty_tool_list(self):
|
||||
"""Test that empty tool lists are valid."""
|
||||
assert validate_tool_schema_consistency([]) == True
|
||||
|
||||
|
||||
class TestRealAgenticScenario:
|
||||
"""Test real agentic scenarios to ensure end-to-end compatibility."""
|
||||
|
||||
def test_tool_schema_agent_integration(self):
|
||||
"""Test that tools work with agent in real scenario."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
@tool
|
||||
def echo_tool(message: str) -> str:
|
||||
"""Echo the input message."""
|
||||
return f"Echo: {message}"
|
||||
|
||||
# Create agent with tool - should not raise validation errors
|
||||
agent = Agent(
|
||||
name="test_agent",
|
||||
instructions="You are a helpful assistant",
|
||||
tools=[echo_tool]
|
||||
)
|
||||
|
||||
# Schema should be valid for LLM use
|
||||
schema = echo_tool.get_schema()
|
||||
assert schema["type"] == "function"
|
||||
|
||||
# Should be JSON serializable
|
||||
json.dumps(schema)
|
||||
|
||||
# Agent should format tools properly
|
||||
formatted_tools = agent._format_tools_for_completion(agent.tools)
|
||||
assert len(formatted_tools) == 1
|
||||
assert formatted_tools[0]["type"] == "function"
|
||||
|
||||
print("✅ Real agentic test passed: Tool schema integration works end-to-end")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -282,6 +282,29 @@ class TestDefaultSessionStore:
|
||||
session = temp_store.get_session("session-1")
|
||||
assert session.agent_name == "TestBot"
|
||||
assert session.user_id == "user-123"
|
||||
|
||||
def test_update_session_metadata_preserves_messages(self, temp_store):
|
||||
"""Metadata updates must not drop messages added by another store instance."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
writer = DefaultSessionStore(session_dir=tmpdir)
|
||||
reader = DefaultSessionStore(session_dir=tmpdir)
|
||||
|
||||
writer.add_user_message("session-1", "first")
|
||||
reader._load_session("session-1")
|
||||
writer.add_user_message("session-1", "second")
|
||||
|
||||
assert reader.update_session_metadata(
|
||||
"session-1", model="gpt-4o-mini", total_tokens=42
|
||||
)
|
||||
|
||||
writer.invalidate_cache("session-1")
|
||||
history = writer.get_chat_history("session-1")
|
||||
assert len(history) == 2
|
||||
assert history[1]["content"] == "second"
|
||||
|
||||
session = writer.get_session("session-1")
|
||||
assert session.metadata.get("model") == "gpt-4o-mini"
|
||||
assert session.metadata.get("total_tokens") == 42
|
||||
|
||||
def test_concurrent_writes(self, temp_store):
|
||||
"""Test concurrent writes to same session."""
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
"""Tests for skill capability validation."""
|
||||
|
||||
import pytest
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from praisonaiagents.skills.models import SkillProperties, SkillRequirements, SkillState
|
||||
from praisonaiagents.skills.capability_validator import (
|
||||
CapabilityValidator,
|
||||
EnforcementLevel,
|
||||
ValidationResult
|
||||
)
|
||||
|
||||
|
||||
class TestSkillRequirements:
|
||||
"""Test SkillRequirements parsing and normalization."""
|
||||
|
||||
def test_from_frontmatter_empty(self):
|
||||
"""Test parsing empty frontmatter."""
|
||||
requirements = SkillRequirements.from_frontmatter({})
|
||||
assert requirements.is_empty()
|
||||
assert not requirements # Test __bool__
|
||||
|
||||
def test_from_frontmatter_tools_list(self):
|
||||
"""Test parsing tools from list."""
|
||||
metadata = {"requires_tools": ["web_search", "file_write"]}
|
||||
requirements = SkillRequirements.from_frontmatter(metadata)
|
||||
assert requirements.tools == ["web_search", "file_write"]
|
||||
assert requirements.servers == []
|
||||
assert not requirements.is_empty()
|
||||
|
||||
def test_from_frontmatter_tools_string(self):
|
||||
"""Test parsing tools from string."""
|
||||
metadata = {"requires-tools": "web_search file_write"}
|
||||
requirements = SkillRequirements.from_frontmatter(metadata)
|
||||
assert requirements.tools == ["web_search", "file_write"]
|
||||
|
||||
def test_from_frontmatter_backward_compatibility(self):
|
||||
"""Test backward compatibility with allowed-tools."""
|
||||
metadata = {"allowed-tools": ["existing_tool"]}
|
||||
requirements = SkillRequirements.from_frontmatter(metadata)
|
||||
assert requirements.tools == ["existing_tool"]
|
||||
|
||||
def test_from_frontmatter_servers(self):
|
||||
"""Test parsing server requirements."""
|
||||
metadata = {"requires_servers": ["mcp:filesystem", "http:internal"]}
|
||||
requirements = SkillRequirements.from_frontmatter(metadata)
|
||||
assert requirements.servers == ["mcp:filesystem", "http:internal"]
|
||||
|
||||
def test_from_frontmatter_env_vars(self):
|
||||
"""Test parsing environment variable requirements."""
|
||||
metadata = {"requires_env": ["API_KEY", "SECRET_TOKEN"]}
|
||||
requirements = SkillRequirements.from_frontmatter(metadata)
|
||||
assert requirements.env_vars == ["API_KEY", "SECRET_TOKEN"]
|
||||
|
||||
def test_from_frontmatter_openclaw_hints(self):
|
||||
"""Test parsing OpenClaw hints."""
|
||||
metadata = {"openclaw": {"version": "1.0", "compatibility": "hermes"}}
|
||||
requirements = SkillRequirements.from_frontmatter(metadata)
|
||||
assert requirements.openclaw_hints == {"version": "1.0", "compatibility": "hermes"}
|
||||
|
||||
def test_normalize_list(self):
|
||||
"""Test list normalization utility."""
|
||||
assert SkillRequirements._normalize_list("a b c") == ["a", "b", "c"]
|
||||
assert SkillRequirements._normalize_list("a,b,c") == ["a", "b", "c"]
|
||||
assert SkillRequirements._normalize_list("a, b c") == ["a", "b", "c"]
|
||||
assert SkillRequirements._normalize_list(["a", "b", "c"]) == ["a", "b", "c"]
|
||||
assert SkillRequirements._normalize_list("") == []
|
||||
assert SkillRequirements._normalize_list([]) == []
|
||||
assert SkillRequirements._normalize_list(123) == []
|
||||
|
||||
|
||||
class TestCapabilityValidator:
|
||||
"""Test capability validation logic."""
|
||||
|
||||
def test_enforcement_levels(self):
|
||||
"""Test different enforcement levels."""
|
||||
assert EnforcementLevel.DISABLED.value == "disabled"
|
||||
assert EnforcementLevel.WARN.value == "warn"
|
||||
assert EnforcementLevel.STRICT.value == "strict"
|
||||
|
||||
def test_validator_initialization(self):
|
||||
"""Test validator initialization."""
|
||||
validator = CapabilityValidator()
|
||||
assert validator.enforcement_level == EnforcementLevel.WARN
|
||||
|
||||
validator = CapabilityValidator(EnforcementLevel.STRICT)
|
||||
assert validator.enforcement_level == EnforcementLevel.STRICT
|
||||
|
||||
def test_validate_skill_no_requirements(self):
|
||||
"""Test validation of skill with no requirements."""
|
||||
skill = SkillProperties(
|
||||
name="test-skill",
|
||||
description="Test skill",
|
||||
requirements=None
|
||||
)
|
||||
|
||||
validator = CapabilityValidator()
|
||||
result = validator.validate_skill(skill)
|
||||
|
||||
assert result.skill_name == "test-skill"
|
||||
assert result.state == SkillState.ACTIVE
|
||||
assert result.is_fully_satisfied
|
||||
assert not result.has_critical_missing
|
||||
|
||||
def test_validate_skill_empty_requirements(self):
|
||||
"""Test validation of skill with empty requirements."""
|
||||
requirements = SkillRequirements()
|
||||
skill = SkillProperties(
|
||||
name="test-skill",
|
||||
description="Test skill",
|
||||
requirements=requirements
|
||||
)
|
||||
|
||||
validator = CapabilityValidator()
|
||||
result = validator.validate_skill(skill)
|
||||
|
||||
assert result.state == SkillState.ACTIVE
|
||||
assert result.is_fully_satisfied
|
||||
|
||||
@patch.dict(os.environ, {"TEST_VAR": "value"})
|
||||
def test_validate_skill_satisfied_requirements(self):
|
||||
"""Test validation with all requirements satisfied."""
|
||||
requirements = SkillRequirements(
|
||||
tools=["available_tool"],
|
||||
servers=["available_server"],
|
||||
env_vars=["TEST_VAR"]
|
||||
)
|
||||
skill = SkillProperties(
|
||||
name="test-skill",
|
||||
description="Test skill",
|
||||
requirements=requirements
|
||||
)
|
||||
|
||||
available_tools = {"available_tool"}
|
||||
available_servers = {"available_server"}
|
||||
|
||||
validator = CapabilityValidator()
|
||||
result = validator.validate_skill(
|
||||
skill,
|
||||
available_tools=available_tools,
|
||||
available_servers=available_servers
|
||||
)
|
||||
|
||||
assert result.state == SkillState.ACTIVE
|
||||
assert result.satisfied_tools == ["available_tool"]
|
||||
assert result.satisfied_servers == ["available_server"]
|
||||
assert result.satisfied_env_vars == ["TEST_VAR"]
|
||||
assert result.is_fully_satisfied
|
||||
|
||||
def test_validate_skill_missing_tools_warn_mode(self):
|
||||
"""Test validation with missing tools in warn mode."""
|
||||
requirements = SkillRequirements(tools=["missing_tool"])
|
||||
skill = SkillProperties(
|
||||
name="test-skill",
|
||||
description="Test skill",
|
||||
requirements=requirements
|
||||
)
|
||||
|
||||
validator = CapabilityValidator(EnforcementLevel.WARN)
|
||||
result = validator.validate_skill(
|
||||
skill,
|
||||
available_tools=set(),
|
||||
available_servers=set()
|
||||
)
|
||||
|
||||
assert result.state == SkillState.DEGRADED
|
||||
assert result.missing_tools == ["missing_tool"]
|
||||
assert not result.is_fully_satisfied
|
||||
assert result.has_critical_missing
|
||||
assert len(result.warnings) == 1
|
||||
assert "missing_tool" in result.warnings[0]
|
||||
|
||||
def test_validate_skill_missing_tools_strict_mode(self):
|
||||
"""Test validation with missing tools in strict mode."""
|
||||
requirements = SkillRequirements(tools=["missing_tool"])
|
||||
skill = SkillProperties(
|
||||
name="test-skill",
|
||||
description="Test skill",
|
||||
requirements=requirements
|
||||
)
|
||||
|
||||
validator = CapabilityValidator(EnforcementLevel.STRICT)
|
||||
result = validator.validate_skill(
|
||||
skill,
|
||||
available_tools=set(),
|
||||
available_servers=set()
|
||||
)
|
||||
|
||||
assert result.state == SkillState.UNAVAILABLE
|
||||
assert result.missing_tools == ["missing_tool"]
|
||||
assert not result.is_fully_satisfied
|
||||
assert result.has_critical_missing
|
||||
assert len(result.errors) == 1
|
||||
assert "missing_tool" in result.errors[0]
|
||||
|
||||
def test_validate_skill_missing_env_vars_warn(self):
|
||||
"""Test validation with missing environment variables in warn mode."""
|
||||
requirements = SkillRequirements(env_vars=["MISSING_VAR"])
|
||||
skill = SkillProperties(
|
||||
name="test-skill",
|
||||
description="Test skill",
|
||||
requirements=requirements
|
||||
)
|
||||
|
||||
validator = CapabilityValidator(EnforcementLevel.WARN)
|
||||
result = validator.validate_skill(skill)
|
||||
|
||||
assert result.state == SkillState.DEGRADED
|
||||
assert result.missing_env_vars == ["MISSING_VAR"]
|
||||
assert len(result.warnings) == 1
|
||||
assert len(result.errors) == 0
|
||||
|
||||
def test_validate_skill_missing_env_vars_strict(self):
|
||||
"""Test validation with missing environment variables in strict mode."""
|
||||
requirements = SkillRequirements(env_vars=["MISSING_VAR"])
|
||||
skill = SkillProperties(
|
||||
name="test-skill",
|
||||
description="Test skill",
|
||||
requirements=requirements
|
||||
)
|
||||
|
||||
validator = CapabilityValidator(EnforcementLevel.STRICT)
|
||||
result = validator.validate_skill(skill)
|
||||
|
||||
assert result.state == SkillState.UNAVAILABLE
|
||||
assert result.missing_env_vars == ["MISSING_VAR"]
|
||||
assert len(result.warnings) == 0
|
||||
assert len(result.errors) == 1
|
||||
|
||||
def test_validation_result_to_dict(self):
|
||||
"""Test ValidationResult serialization."""
|
||||
result = ValidationResult(
|
||||
skill_name="test-skill",
|
||||
state=SkillState.DEGRADED,
|
||||
satisfied_tools=["tool1"],
|
||||
missing_tools=["tool2"],
|
||||
satisfied_servers=[],
|
||||
missing_servers=[],
|
||||
satisfied_env_vars=[],
|
||||
missing_env_vars=["VAR1"],
|
||||
warnings=["Missing tool2"],
|
||||
errors=[]
|
||||
)
|
||||
|
||||
data = result.to_dict()
|
||||
assert data["skill_name"] == "test-skill"
|
||||
assert data["state"] == "degraded"
|
||||
assert data["satisfied_tools"] == ["tool1"]
|
||||
assert data["missing_tools"] == ["tool2"]
|
||||
assert data["warnings"] == ["Missing tool2"]
|
||||
assert data["is_fully_satisfied"] == False
|
||||
assert data["has_critical_missing"] == True
|
||||
|
||||
|
||||
class TestIntegration:
|
||||
"""Integration tests for the full capability validation system."""
|
||||
|
||||
def test_skill_parsing_with_requirements(self):
|
||||
"""Test that skills parse requirements correctly."""
|
||||
from praisonaiagents.skills.parser import read_properties
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
skill_dir = Path(temp_dir) / "test-skill"
|
||||
skill_dir.mkdir()
|
||||
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
skill_md.write_text('''---
|
||||
name: test-skill
|
||||
description: A test skill
|
||||
requires_tools:
|
||||
- web_search
|
||||
- file_write
|
||||
requires_servers:
|
||||
- mcp:filesystem
|
||||
requires_env:
|
||||
- API_KEY
|
||||
openclaw:
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# Test Skill
|
||||
|
||||
This is a test skill.
|
||||
''')
|
||||
|
||||
properties = read_properties(skill_dir)
|
||||
assert properties.requirements is not None
|
||||
assert properties.requirements.tools == ["web_search", "file_write"]
|
||||
assert properties.requirements.servers == ["mcp:filesystem"]
|
||||
assert properties.requirements.env_vars == ["API_KEY"]
|
||||
assert properties.requirements.openclaw_hints == {"version": "1.0"}
|
||||
|
||||
def test_skill_manager_enforcement(self):
|
||||
"""Test that SkillManager respects enforcement levels."""
|
||||
from praisonaiagents.skills.manager import SkillManager
|
||||
|
||||
# Test that manager initializes with default enforcement
|
||||
manager = SkillManager()
|
||||
assert manager._validator.enforcement_level in [
|
||||
EnforcementLevel.WARN,
|
||||
EnforcementLevel.DISABLED,
|
||||
EnforcementLevel.STRICT,
|
||||
EnforcementLevel.TELEMETRY
|
||||
]
|
||||
|
||||
# Test explicit enforcement level
|
||||
strict_manager = SkillManager(EnforcementLevel.STRICT)
|
||||
assert strict_manager._validator.enforcement_level == EnforcementLevel.STRICT
|
||||
@@ -241,3 +241,89 @@ Body.
|
||||
loader.activate(skill)
|
||||
|
||||
assert skill.is_activated is True
|
||||
|
||||
def test_load_utf8_content(self):
|
||||
"""Test loading UTF-8 encoded skill content.
|
||||
|
||||
Regression test for Windows encoding issues (Issue #1695).
|
||||
Tests UTF-8 handling in SKILL.md body, scripts, and references.
|
||||
"""
|
||||
from praisonaiagents.skills.loader import SkillLoader
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
skill_dir = Path(tmpdir) / "utf8-loader-test"
|
||||
skill_dir.mkdir()
|
||||
|
||||
# Create SKILL.md with UTF-8 characters
|
||||
skill_content = """---
|
||||
name: utf8-loader-test
|
||||
description: Loader test with UTF-8 — em dashes — and more
|
||||
---
|
||||
|
||||
# UTF-8 Instructions
|
||||
|
||||
This skill handles UTF-8 content:
|
||||
- Smart quotes: "hello" and 'world'
|
||||
- Em dash: —
|
||||
- Arrows: → ← ↑ ↓
|
||||
- Accented: café, naïve, résumé
|
||||
- Emoji: 🔥 📝 ✨
|
||||
"""
|
||||
|
||||
with open(skill_dir / "SKILL.md", 'w', encoding='utf-8') as f:
|
||||
f.write(skill_content)
|
||||
|
||||
# Create scripts directory with UTF-8 script
|
||||
scripts_dir = skill_dir / "scripts"
|
||||
scripts_dir.mkdir()
|
||||
|
||||
script_content = """#!/bin/bash
|
||||
# Script with UTF-8: café processing → résumé generation
|
||||
echo "Processing naïve input with émphasis"
|
||||
"""
|
||||
|
||||
with open(scripts_dir / "process.sh", 'w', encoding='utf-8') as f:
|
||||
f.write(script_content)
|
||||
|
||||
# Create references directory with UTF-8 content
|
||||
refs_dir = skill_dir / "references"
|
||||
refs_dir.mkdir()
|
||||
|
||||
ref_content = """Reference with UTF-8:
|
||||
Café automation guide — step by step
|
||||
Arrow symbols: → ← ↑ ↓
|
||||
Special chars: "smart quotes" and émphasis
|
||||
"""
|
||||
|
||||
with open(refs_dir / "readme.txt", 'w', encoding='utf-8') as f:
|
||||
f.write(ref_content)
|
||||
|
||||
# Test loading and activation
|
||||
loader = SkillLoader()
|
||||
skill = loader.load_metadata(str(skill_dir))
|
||||
|
||||
assert skill is not None
|
||||
assert "—" in skill.properties.description # UTF-8 in metadata
|
||||
|
||||
# Test activation (loads SKILL.md body)
|
||||
success = loader.activate(skill)
|
||||
assert success is True
|
||||
assert "—" in skill.instructions # UTF-8 in instructions
|
||||
assert "café" in skill.instructions
|
||||
assert "🔥" in skill.instructions # Emoji
|
||||
|
||||
# Test script loading
|
||||
scripts = loader.load_scripts(skill)
|
||||
assert "process.sh" in scripts
|
||||
script_text = scripts["process.sh"]
|
||||
assert "café" in script_text
|
||||
assert "résumé" in script_text
|
||||
assert "naïve" in script_text
|
||||
|
||||
# Test references loading
|
||||
refs = loader.load_references(skill)
|
||||
assert "readme.txt" in refs
|
||||
ref_text = refs["readme.txt"]
|
||||
assert "Café" in ref_text
|
||||
assert "—" in ref_text
|
||||
assert "émphasis" in ref_text
|
||||
@@ -286,3 +286,54 @@ Body.
|
||||
assert False, "Should have raised ValidationError"
|
||||
except ValidationError as e:
|
||||
assert "description" in str(e).lower()
|
||||
|
||||
def test_read_properties_utf8_encoding(self):
|
||||
"""Test reading UTF-8 encoded SKILL.md files.
|
||||
|
||||
Regression test for Windows encoding issues (Issue #1695).
|
||||
Verifies that skills with non-ASCII characters load correctly.
|
||||
"""
|
||||
from praisonaiagents.skills.parser import read_properties
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
skill_dir = Path(tmpdir) / "utf8-skill"
|
||||
skill_dir.mkdir()
|
||||
|
||||
# Create SKILL.md with various UTF-8 characters
|
||||
utf8_content = """---
|
||||
name: utf8-test
|
||||
description: UTF-8 encoding test — quality — verification
|
||||
license: MIT
|
||||
compatibility: Supports café files → résumé processing
|
||||
metadata:
|
||||
author: "José María"
|
||||
notes: "Handles naïve input with émphasis"
|
||||
---
|
||||
|
||||
# UTF-8 Test Skill
|
||||
|
||||
This skill demonstrates UTF-8 character handling:
|
||||
- Em dash: —
|
||||
- Smart quotes: "hello" and 'world'
|
||||
- Arrow: →
|
||||
- Accented chars: café, naïve, résumé
|
||||
- Emoji: 🔥 📝 ✨
|
||||
|
||||
Works with internationalization!
|
||||
"""
|
||||
|
||||
# Explicitly write as UTF-8 to simulate real-world scenario
|
||||
with open(skill_dir / "SKILL.md", 'w', encoding='utf-8') as f:
|
||||
f.write(utf8_content)
|
||||
|
||||
# Test that parsing works without UnicodeDecodeError
|
||||
props = read_properties(skill_dir)
|
||||
|
||||
# Verify UTF-8 characters are preserved correctly
|
||||
assert props.name == "utf8-test"
|
||||
assert "—" in props.description # em dash
|
||||
assert "café" in props.compatibility # accented character
|
||||
assert "résumé" in props.compatibility # accented character
|
||||
assert "José María" in props.metadata["author"] # accented characters
|
||||
assert "naïve" in props.metadata["notes"] # accented character
|
||||
assert "émphasis" in props.metadata["notes"] # accented character
|
||||
@@ -295,3 +295,58 @@ description: A test skill for validation
|
||||
|
||||
assert len(errors) > 0
|
||||
assert any("directory" in e.lower() for e in errors)
|
||||
|
||||
def test_validate_utf8_skill(self):
|
||||
"""Test validation of UTF-8 encoded skill.
|
||||
|
||||
Regression test for Windows encoding issues (Issue #1695).
|
||||
Ensures validator can handle UTF-8 characters without errors.
|
||||
"""
|
||||
from praisonaiagents.skills.validator import validate
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
skill_dir = Path(tmpdir) / "utf8-validation-test"
|
||||
skill_dir.mkdir()
|
||||
|
||||
# Create SKILL.md with UTF-8 content
|
||||
utf8_content = """---
|
||||
name: utf8-validation-test
|
||||
description: Validation test with UTF-8 — em dashes — and accents
|
||||
license: MIT
|
||||
compatibility: Works with café files → résumé output
|
||||
---
|
||||
|
||||
# UTF-8 Validation Test
|
||||
|
||||
Testing validation with various UTF-8 characters.
|
||||
"""
|
||||
|
||||
# Write as UTF-8 explicitly
|
||||
with open(skill_dir / "SKILL.md", 'w', encoding='utf-8') as f:
|
||||
f.write(utf8_content)
|
||||
|
||||
# Validate should not produce errors for UTF-8 content
|
||||
errors = validate(skill_dir)
|
||||
|
||||
assert errors == [], f"Validation should pass for UTF-8 content, got errors: {errors}"
|
||||
|
||||
def test_validate_non_utf8_skill_returns_error(self):
|
||||
"""Test validation returns a structured error for non-UTF-8 SKILL.md."""
|
||||
from praisonaiagents.skills.validator import validate
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
skill_dir = Path(tmpdir) / "latin1-validation-test"
|
||||
skill_dir.mkdir()
|
||||
|
||||
latin1_content = """---
|
||||
name: latin1-validation-test
|
||||
description: Café skill
|
||||
---
|
||||
"""
|
||||
with open(skill_dir / "SKILL.md", "wb") as f:
|
||||
f.write(latin1_content.encode("cp1252"))
|
||||
|
||||
errors = validate(skill_dir)
|
||||
|
||||
assert len(errors) > 0
|
||||
assert any("utf-8" in e.lower() for e in errors)
|
||||
@@ -225,3 +225,19 @@ class TestToolCallIdPropagation:
|
||||
)
|
||||
|
||||
assert event.tool_call["id"] == "call_abc123xyz"
|
||||
|
||||
|
||||
class TestToolExecutionMixinStreamEmitterLookup:
|
||||
def test_mixin_resolves_name_mangled_stream_emitter_without_agent_class_name(self):
|
||||
from praisonaiagents.agent.tool_execution import ToolExecutionMixin
|
||||
from praisonaiagents.streaming.events import StreamEventEmitter
|
||||
|
||||
class CustomAgent(ToolExecutionMixin):
|
||||
def __init__(self):
|
||||
self.__stream_emitter = StreamEventEmitter()
|
||||
|
||||
agent = CustomAgent()
|
||||
emitter = agent._get_existing_stream_emitter()
|
||||
|
||||
assert emitter is not None
|
||||
assert isinstance(emitter, StreamEventEmitter)
|
||||
@@ -134,3 +134,37 @@ class TestTokenCollectorSinkIntegration:
|
||||
)
|
||||
assert sink.records[0]["task_id"] == "task-abc"
|
||||
assert sink.records[0]["agent_name"] == "researcher"
|
||||
|
||||
|
||||
class TestInMemoryUsageQuery:
|
||||
"""Test query adapter backed by in-memory sink records."""
|
||||
|
||||
def test_get_summary_uses_total_tokens_when_present(self):
|
||||
from praisonaiagents.telemetry.protocols import InMemoryTokenUsageSink, InMemoryUsageQuery
|
||||
|
||||
sink = InMemoryTokenUsageSink()
|
||||
sink.records.extend([
|
||||
{
|
||||
"task_id": "t1",
|
||||
"agent_name": "a1",
|
||||
"model": "m1",
|
||||
"input_tokens": 10,
|
||||
"output_tokens": 5,
|
||||
"total_tokens": 50,
|
||||
},
|
||||
{
|
||||
"task_id": "t2",
|
||||
"agent_name": "a1",
|
||||
"model": "m2",
|
||||
"input_tokens": 2,
|
||||
"output_tokens": 3,
|
||||
},
|
||||
])
|
||||
|
||||
summary = InMemoryUsageQuery(sink).get_summary()
|
||||
|
||||
assert summary["total_input_tokens"] == 12
|
||||
assert summary["total_output_tokens"] == 8
|
||||
assert summary["total_tokens"] == 55
|
||||
assert summary["by_model"]["m1"]["total_tokens"] == 50
|
||||
assert summary["by_model"]["m2"]["total_tokens"] == 5
|
||||
Generated
+1
-1
@@ -3125,7 +3125,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.43"
|
||||
version = "1.6.46"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
{
|
||||
"version": "1.5.87",
|
||||
"lastUpdated": "2026-05-20",
|
||||
"lastUpdated": "2026-05-21",
|
||||
"generatedBy": "praisonai._dev.parity.generator",
|
||||
"sourceOfTruth": "Python SDK (praisonaiagents)",
|
||||
"status": "PARITY_ACHIEVED",
|
||||
"summary": {
|
||||
"pythonCoreFeatures": 334,
|
||||
"pythonCoreFeatures": 335,
|
||||
"rustFeatures": 667,
|
||||
"gapCount": 55,
|
||||
"parityPercentage": 199.7
|
||||
"gapCount": 56,
|
||||
"parityPercentage": 199.1
|
||||
},
|
||||
"pythonCoreSDK": {
|
||||
"exports": [
|
||||
"A2A",
|
||||
"A2UI",
|
||||
"AGUI",
|
||||
"AUTONOMY_PRESETS",
|
||||
"Agent",
|
||||
@@ -2821,6 +2822,15 @@
|
||||
"status": "DONE",
|
||||
"category": "ui"
|
||||
},
|
||||
{
|
||||
"feature": "A2UI",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "ui"
|
||||
},
|
||||
{
|
||||
"feature": "AGUI",
|
||||
"python": true,
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
# Rust Feature Parity Tracker
|
||||
|
||||
> **Python Features:** 334 | **Rust Features:** 667 | **Parity:** 85.3%
|
||||
> **Python Features:** 335 | **Rust Features:** 667 | **Parity:** 85.1%
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Python Core Features | 334 |
|
||||
| Python Core Features | 335 |
|
||||
| Rust Features | 667 |
|
||||
| **Actual Gap Count** | **49** |
|
||||
| **Actual Gap Count** | **50** |
|
||||
| Language Limitations (N/A) | 5 |
|
||||
| **Parity** | **85.3%** |
|
||||
| **Parity** | **85.1%** |
|
||||
|
||||
## Implemented Features
|
||||
|
||||
@@ -694,6 +694,7 @@ These Python features cannot be directly implemented in Rust due to reserved key
|
||||
|
||||
## Missing Features
|
||||
|
||||
- ❌ `A2UI`
|
||||
- ❌ `AgentMessageEvent`
|
||||
- ❌ `AsyncLearnProtocol`
|
||||
- ❌ `AutoApproveBackend`
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
{
|
||||
"version": "1.5.87",
|
||||
"lastUpdated": "2026-05-20",
|
||||
"lastUpdated": "2026-05-21",
|
||||
"generatedBy": "praisonai._dev.parity.generator",
|
||||
"sourceOfTruth": "Python SDK (praisonaiagents)",
|
||||
"summary": {
|
||||
"pythonCoreFeatures": 334,
|
||||
"pythonCoreFeatures": 335,
|
||||
"pythonWrapperFeatures": 99,
|
||||
"typescriptFeatures": 1192,
|
||||
"gapCount": 42,
|
||||
"gapCount": 43,
|
||||
"priorityP0": 1,
|
||||
"priorityP1": 0,
|
||||
"priorityP2": 0,
|
||||
"priorityP3": 41
|
||||
"priorityP3": 42
|
||||
},
|
||||
"pythonCoreSDK": {
|
||||
"path": "/home/runner/work/PraisonAI/PraisonAI/src/praisonai-agents/praisonaiagents",
|
||||
"exports": [
|
||||
"A2A",
|
||||
"A2UI",
|
||||
"AGUI",
|
||||
"AUTONOMY_PRESETS",
|
||||
"Agent",
|
||||
@@ -727,6 +728,7 @@
|
||||
],
|
||||
"ui": [
|
||||
"A2A",
|
||||
"A2UI",
|
||||
"AGUI"
|
||||
],
|
||||
"workflows": [
|
||||
@@ -3911,6 +3913,15 @@
|
||||
"status": "DONE",
|
||||
"category": "ui"
|
||||
},
|
||||
{
|
||||
"feature": "A2UI",
|
||||
"python": true,
|
||||
"typescript": false,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "ui"
|
||||
},
|
||||
{
|
||||
"feature": "AGUI",
|
||||
"python": true,
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
# Feature Parity Tracker
|
||||
|
||||
> **Version:** 1.5.87 | **Last Updated:** 2026-05-20
|
||||
> **Version:** 1.5.87 | **Last Updated:** 2026-05-21
|
||||
> **Source of Truth:** Python SDK (praisonaiagents)
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Python Core Features | 334 |
|
||||
| Python Core Features | 335 |
|
||||
| Python Wrapper Features | 99 |
|
||||
| TypeScript Features | 1192 |
|
||||
| **Gap Count** | **42** |
|
||||
| **Gap Count** | **43** |
|
||||
| P0 (Critical) | 1 |
|
||||
| P1 (High) | 0 |
|
||||
| P2 (Medium) | 0 |
|
||||
| P3 (Low) | 41 |
|
||||
| P3 (Low) | 42 |
|
||||
|
||||
## Gap Matrix
|
||||
|
||||
@@ -147,10 +147,11 @@
|
||||
| `enable\_telemetry` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `get\_telemetry` | ✅ | ✅ | low | ✅ DONE |
|
||||
|
||||
### P3_Advanced (179 done, 41 todo)
|
||||
### P3_Advanced (179 done, 42 todo)
|
||||
|
||||
| Feature | Python | TypeScript | Effort | Status |
|
||||
|---------|--------|------------|--------|--------|
|
||||
| `A2UI` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `AgentMessageEvent` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `AsyncLearnProtocol` | ✅ | ❌ | medium | ⏳ TODO |
|
||||
| `AutoApproveBackend` | ✅ | ❌ | high | ⏳ TODO |
|
||||
@@ -629,10 +630,10 @@ from praisonaiagents import ContextEvent, ContextEventType, ContextListSink, Con
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>ui</strong> (2 exports)</summary>
|
||||
<summary><strong>ui</strong> (3 exports)</summary>
|
||||
|
||||
```python
|
||||
from praisonaiagents import A2A, AGUI
|
||||
from praisonaiagents import A2A, A2UI, AGUI
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Channels gateway integration
|
||||
|
||||
Channel bots (Telegram, Discord, Slack, WhatsApp) require the **`praisonai[bot]`** optional extra:
|
||||
|
||||
```bash
|
||||
pip install "praisonai[bot]"
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Pattern B/C host**: `configure_host()` + `AIUIGateway` serve chat UI and REST APIs.
|
||||
- **Channels feature** (`praisonaiui.features.channels`): starts platform bots via `praisonai.bots`.
|
||||
- **Gateway WebSocket**: `/ws` on the same process when using Pattern C.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Platform | Variables |
|
||||
|----------|-------------|
|
||||
| Telegram | `TELEGRAM_BOT_TOKEN` |
|
||||
| Discord | `DISCORD_BOT_TOKEN` |
|
||||
| Slack | `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN` |
|
||||
| WhatsApp | `WHATSAPP_ACCESS_TOKEN`, `WHATSAPP_PHONE_NUMBER_ID` |
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
praisonai dashboard --aiui # Pattern B in-process host
|
||||
PRAISONAI_HOST_LEGACY=1 praisonai ui # Legacy @aiui.reply callbacks only
|
||||
```
|
||||
|
||||
See `praisonai.bots.BotOS` for multi-platform orchestration.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Pattern D — Platform API integration (defer)
|
||||
|
||||
Pattern D connects PraisonAIUI dashboard pages to **PraisonAI Cloud** via `PlatformClient` `/api/v1`.
|
||||
|
||||
## Scope (P3)
|
||||
|
||||
| Item | Status |
|
||||
|------|--------|
|
||||
| aiui pages → PlatformClient | Documented, optional |
|
||||
| Platform JWT auth | Optional `auth.platform_jwt` config |
|
||||
| Issues / kanban pages | Optional `@aiui.page` via platform |
|
||||
| agent_id linking | See below |
|
||||
|
||||
## agent_id linking
|
||||
|
||||
Three registries may coexist:
|
||||
|
||||
1. **Platform roster** — cloud tenant agents (`/api/v1/agents`)
|
||||
2. **Gateway registry** — WebSocket `/ws` agent ids
|
||||
3. **aiui SDKAgentRegistry** — YAML CRUD + local `Agent` instances
|
||||
|
||||
Use explicit `agent_id` in session metadata (`source=gateway|platform|ui`) to correlate rows in the workflow-runs and sessions tables.
|
||||
|
||||
## Optional platform JWT
|
||||
|
||||
When `PRAISONAI_PLATFORM_TOKEN` is set, future aiui auth middleware may validate JWT from the platform issuer. Not required for Patterns B/C self-hosted installs.
|
||||
Binary file not shown.
@@ -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.43.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.43.tar.gz | shasum -a 256`.split.first
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.46.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.46.tar.gz | shasum -a 256`.split.first
|
||||
license "MIT"
|
||||
|
||||
depends_on "[email protected]"
|
||||
|
||||
@@ -6,6 +6,8 @@ from .version import __version__
|
||||
# Define __all__ for lazy loading
|
||||
__all__ = [
|
||||
'PraisonAI',
|
||||
'run',
|
||||
'arun',
|
||||
'__version__',
|
||||
'Deploy',
|
||||
'DeployConfig',
|
||||
@@ -92,7 +94,13 @@ def __getattr__(name):
|
||||
# Note: Telemetry initialization moved out of lazy hook to avoid side effects
|
||||
# It should be called explicitly from cli.PraisonAI.__init__ instead
|
||||
|
||||
if name == 'PraisonAI':
|
||||
if name == 'run':
|
||||
from ._entrypoint import run
|
||||
return run
|
||||
elif name == 'arun':
|
||||
from ._entrypoint import arun
|
||||
return arun
|
||||
elif name == 'PraisonAI':
|
||||
from .cli import PraisonAI
|
||||
return PraisonAI
|
||||
elif name == 'Agent':
|
||||
@@ -130,9 +138,12 @@ def __getattr__(name):
|
||||
elif name == 'EmbeddingResult':
|
||||
from praisonaiagents.embedding import EmbeddingResult
|
||||
return EmbeddingResult
|
||||
elif name == 'AgentOS':
|
||||
from .app import AgentOS
|
||||
return AgentOS
|
||||
elif name == 'build_host_app':
|
||||
from .integration.host_app import build_host_app
|
||||
return build_host_app
|
||||
elif name == 'configure_host':
|
||||
from .integration.host_app import configure_host
|
||||
return configure_host
|
||||
elif name == 'AgentApp':
|
||||
# Silent alias for AgentOS (backward compatibility)
|
||||
from .app import AgentOS
|
||||
|
||||
@@ -119,7 +119,7 @@ class ApiMdGenerator:
|
||||
'Observability': ['obs', 'FlowDisplay', 'track_workflow'],
|
||||
'Context': ['FastContext', 'FastContextResult', 'FileMatch', 'LineRange',
|
||||
'ContextManager', 'ManagerConfig'],
|
||||
'UI': ['AGUI', 'A2A'],
|
||||
'UI': ['AGUI', 'A2A', 'A2UI'],
|
||||
'Config': ['MemoryConfig', 'KnowledgeConfig', 'PlanningConfig', 'ReflectionConfig',
|
||||
'GuardrailConfig', 'WebConfig', 'OutputConfig', 'ExecutionConfig',
|
||||
'TemplateConfig', 'CachingConfig', 'HooksConfig', 'SkillsConfig',
|
||||
|
||||
@@ -82,7 +82,7 @@ class PythonFeatureExtractor:
|
||||
'telemetry': ['telemetry'],
|
||||
'context': ['context.fast', 'context.manager', 'context.models'],
|
||||
'session': ['session'],
|
||||
'ui': ['ui.agui', 'ui.a2a'],
|
||||
'ui': ['ui.agui', 'ui.a2a', 'ui.a2ui'],
|
||||
'rag': ['rag'],
|
||||
'planning': ['planning'],
|
||||
'config': ['config.feature_configs', 'config.param_resolver',
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Top-level convenience entry points for the Python API.
|
||||
|
||||
Keep this file minimal - it is part of the Minimal API surface and must defer
|
||||
all heavy imports to call time.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _build_config_list() -> list[dict[str, Any]]:
|
||||
"""Reuse the same env/keyfile resolution the CLI already performs."""
|
||||
# Import LLM endpoint resolution from the same path the CLI uses
|
||||
from praisonai.llm.env import resolve_llm_endpoint
|
||||
ep = resolve_llm_endpoint()
|
||||
|
||||
return [{
|
||||
'model': ep.model,
|
||||
'base_url': ep.base_url,
|
||||
'api_key': ep.api_key,
|
||||
'api_type': 'openai' # AutoGen expects this field
|
||||
}]
|
||||
|
||||
|
||||
def run(agent_file: str,
|
||||
framework: str | None = None,
|
||||
*,
|
||||
tools: list | None = None,
|
||||
agent_yaml: str | None = None,
|
||||
cli_config: dict | None = None) -> str:
|
||||
"""One-line Python entry point. Equivalent to `praisonai <agent_file>`."""
|
||||
from .agents_generator import AgentsGenerator
|
||||
from ._framework_availability import is_available
|
||||
|
||||
if framework is None:
|
||||
# Mirror the CLI default-resolution order
|
||||
for candidate in ("crewai", "praisonaiagents", "autogen", "ag2"):
|
||||
if is_available(candidate):
|
||||
framework = "praisonai" if candidate == "praisonaiagents" else candidate
|
||||
break
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"No supported framework installed. "
|
||||
"Install one of: crewai, praisonaiagents, autogen, ag2."
|
||||
)
|
||||
else:
|
||||
# Validate explicit framework like CLI does
|
||||
from .framework_adapters.validators import assert_framework_available
|
||||
assert_framework_available(framework)
|
||||
|
||||
gen = AgentsGenerator(
|
||||
agent_file=agent_file,
|
||||
framework=framework,
|
||||
config_list=_build_config_list(),
|
||||
tools=tools,
|
||||
agent_yaml=agent_yaml,
|
||||
cli_config=cli_config,
|
||||
)
|
||||
return gen.generate_crew_and_kickoff()
|
||||
|
||||
|
||||
async def arun(agent_file: str,
|
||||
framework: str | None = None,
|
||||
*,
|
||||
tools: list | None = None,
|
||||
agent_yaml: str | None = None,
|
||||
cli_config: dict | None = None) -> str:
|
||||
"""Async equivalent of `run()` for FastAPI / Jupyter async callers."""
|
||||
import asyncio
|
||||
return await asyncio.to_thread(
|
||||
run, agent_file, framework,
|
||||
tools=tools, agent_yaml=agent_yaml, cli_config=cli_config,
|
||||
)
|
||||
@@ -30,6 +30,11 @@ _PROBES: dict[str, Callable[[], bool]] = {
|
||||
"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,
|
||||
# Additional probes for Gap 2 consolidation
|
||||
"acp": lambda: importlib.util.find_spec("acp") is not None,
|
||||
"rich": lambda: importlib.util.find_spec("rich") is not None,
|
||||
"gradio": lambda: importlib.util.find_spec("gradio") is not None,
|
||||
"unsloth": lambda: importlib.util.find_spec("unsloth") is not None,
|
||||
}
|
||||
|
||||
def is_available(name: str) -> bool:
|
||||
|
||||
@@ -42,7 +42,8 @@ def __getattr__(name: str):
|
||||
|
||||
def _check_acp_available() -> bool:
|
||||
"""Check if agent-client-protocol package is installed."""
|
||||
return importlib.util.find_spec("acp") is not None
|
||||
from .._framework_availability import is_available
|
||||
return is_available("acp")
|
||||
|
||||
|
||||
def _get_install_instructions() -> str:
|
||||
|
||||
@@ -221,14 +221,14 @@ class AgentsGenerator:
|
||||
elif os.environ.get('LOGLEVEL'):
|
||||
self.logger.setLevel(getattr(logging, os.environ.get('LOGLEVEL', 'INFO').upper(), logging.INFO))
|
||||
|
||||
# Initialize tool resolver (single source of truth for tool resolution)
|
||||
from .tool_resolver import ToolResolver
|
||||
self.tool_resolver = ToolResolver()
|
||||
|
||||
# Keep tool registry for backward compatibility with autogen adapters
|
||||
self.tool_registry = ToolRegistry()
|
||||
self.tool_registry.register_builtin_autogen_adapters()
|
||||
|
||||
# Initialize tool resolver with the registry wired in (single source of truth for tool resolution)
|
||||
from .tool_resolver import ToolResolver
|
||||
self.tool_resolver = ToolResolver(registry=self.tool_registry)
|
||||
|
||||
# DI-friendly: tests/multi-tenant runtimes pass their own registry;
|
||||
# CLI users get the process default.
|
||||
self._adapter_registry = adapter_registry or get_default_registry()
|
||||
|
||||
@@ -8,29 +8,9 @@ Custom page: Feature Explorer
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import praisonaiui as aiui
|
||||
from praisonai.ui._aiui_datastore import PraisonAISessionDataStore
|
||||
|
||||
# ── Set up datastore bridge ─────────────────────────────────
|
||||
aiui.set_datastore(PraisonAISessionDataStore())
|
||||
|
||||
# ── Dashboard style ─────────────────────────────────────────
|
||||
aiui.set_style("dashboard")
|
||||
aiui.set_pages([
|
||||
"chat", # Core AI chat
|
||||
"channels", # Messaging platforms
|
||||
"agents", # Agent management
|
||||
"skills", # Available tools
|
||||
"memory", # Agent memory
|
||||
"knowledge", # Knowledge base
|
||||
"cron", # Scheduled jobs
|
||||
"guardrails", # Safety rules
|
||||
"sessions", # Conversation history
|
||||
"usage", # Token usage & costs
|
||||
"config", # Runtime configuration
|
||||
"logs", # Server logs
|
||||
"debug", # System debug info
|
||||
])
|
||||
from praisonai.integration.host_app import configure_host, create_host_app
|
||||
|
||||
# ── Agent definitions ───────────────────────────────────────
|
||||
AGENTS = [
|
||||
@@ -71,6 +51,35 @@ AGENTS = [
|
||||
]
|
||||
|
||||
|
||||
def _build_praison_agents():
|
||||
from praisonaiagents import Agent
|
||||
|
||||
return [
|
||||
Agent(name=a["name"], instructions=a["instructions"], llm=a["model"])
|
||||
for a in AGENTS
|
||||
]
|
||||
|
||||
|
||||
configure_host(
|
||||
pages=[
|
||||
"chat",
|
||||
"channels",
|
||||
"agents",
|
||||
"skills",
|
||||
"memory",
|
||||
"knowledge",
|
||||
"cron",
|
||||
"guardrails",
|
||||
"sessions",
|
||||
"usage",
|
||||
"config",
|
||||
"logs",
|
||||
"debug",
|
||||
],
|
||||
agents=_build_praison_agents(),
|
||||
modules=["jobs"],
|
||||
)
|
||||
|
||||
|
||||
# ── Custom page: Feature Explorer ───────────────────────────
|
||||
|
||||
@@ -110,7 +119,10 @@ def _register_agents_in_dashboard():
|
||||
})
|
||||
print(f" ✓ Agent: {agent_def['icon']} {agent_def['name']}")
|
||||
|
||||
|
||||
try:
|
||||
_register_agents_in_dashboard()
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Agent registration failed (non-fatal): {e}")
|
||||
|
||||
app = create_host_app()
|
||||
@@ -199,93 +199,24 @@ def _auto_start_services(console, host: str) -> bool:
|
||||
|
||||
|
||||
def _run_aiui_dashboard(port: int, host: str, console) -> bool:
|
||||
"""Run the aiui dashboard interface using proper CLI entrypoint."""
|
||||
console.print("[bold green]🦞 Starting aiui Dashboard...[/bold green]")
|
||||
|
||||
"""Run the aiui dashboard in-process via ``build_host_app()`` (Pattern B)."""
|
||||
console.print("[bold green]🦞 Starting aiui Dashboard (Pattern B host)...[/bold green]")
|
||||
|
||||
try:
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
# Check if aiui is available first
|
||||
result = subprocess.run([
|
||||
sys.executable, "-c", "import praisonaiui"
|
||||
], capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
console.print("[red]Error: aiui package not installed.[/red]")
|
||||
console.print("[yellow]Install with: pip install aiui[/yellow]")
|
||||
return False
|
||||
|
||||
# Create a temporary script for aiui dashboard based on claw pattern
|
||||
aiui_script = '''import os
|
||||
import praisonaiui as aiui
|
||||
from praisonai.ui._aiui_datastore import PraisonAISessionDataStore
|
||||
|
||||
# Set up datastore bridge
|
||||
aiui.set_datastore(PraisonAISessionDataStore())
|
||||
|
||||
# Configure dashboard style
|
||||
aiui.set_style("dashboard")
|
||||
aiui.set_branding(title="PraisonAI Unified Dashboard", logo="🌟")
|
||||
|
||||
# Set up pages for unified dashboard
|
||||
aiui.set_pages([
|
||||
"chat", "agents", "memory", "knowledge",
|
||||
"skills", "sessions", "usage", "config", "logs"
|
||||
])
|
||||
|
||||
# Register a simple welcome message
|
||||
@aiui.welcome
|
||||
async def on_welcome():
|
||||
await aiui.say("🌟 Welcome to PraisonAI Unified Dashboard!")
|
||||
await aiui.say("Access all your AI services from one interface.")
|
||||
|
||||
# Register basic reply handler
|
||||
@aiui.reply
|
||||
async def on_reply(message: str, settings: dict | None = None):
|
||||
await aiui.think("Processing...")
|
||||
await aiui.say(f"Unified Dashboard received: {message}")
|
||||
await aiui.say("Use the sidebar to access Flow, Agents, Memory, and more!")
|
||||
'''
|
||||
|
||||
# Create temp file safely
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
|
||||
f.write(aiui_script)
|
||||
temp_script = f.name
|
||||
|
||||
try:
|
||||
console.print(f"[green]✓ Starting aiui dashboard on {host}:{port}[/green]")
|
||||
|
||||
# Use aiui CLI to run the dashboard (NOT create_app)
|
||||
# Try aiui command first, fallback to python -m
|
||||
try:
|
||||
result = subprocess.run([
|
||||
"aiui", "run", temp_script, "--port", str(port), "--host", host
|
||||
], check=True)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
# Fallback to python -m praisonaiui
|
||||
result = subprocess.run([
|
||||
sys.executable, "-m", "praisonaiui.cli", "run",
|
||||
temp_script, "--port", str(port), "--host", host
|
||||
], check=True)
|
||||
|
||||
return True
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
console.print(f"[red]aiui dashboard exited with code {e.returncode}[/red]")
|
||||
return False
|
||||
finally:
|
||||
# Clean up temp file
|
||||
try:
|
||||
os.unlink(temp_script)
|
||||
except (OSError, FileNotFoundError):
|
||||
pass
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
console.print(f"[red]aiui dashboard failed with code {e.returncode}[/red]")
|
||||
import uvicorn
|
||||
from praisonai.integration.host_app import build_host_app
|
||||
except ImportError:
|
||||
console.print("[red]Error: aiui integration dependencies missing.[/red]")
|
||||
console.print('[yellow]Install with: pip install "praisonai[ui]"[/yellow]')
|
||||
return False
|
||||
|
||||
try:
|
||||
app = build_host_app(pages=["chat", "sessions", "workflows", "hooks", "usage"])
|
||||
console.print(f"[green]✓ Starting in-process host on {host}:{port}[/green]")
|
||||
uvicorn.run(app, host=host, port=port, log_level="info")
|
||||
return True
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error running aiui dashboard: {e}[/red]")
|
||||
console.print(f"[red]Error running in-process host: {e}[/red]")
|
||||
return False
|
||||
|
||||
|
||||
@@ -612,6 +543,10 @@ def unified(
|
||||
"""
|
||||
Launch the PraisonAI Unified Dashboard.
|
||||
|
||||
Pattern B (default with --aiui): in-process ``build_host_app()`` host.
|
||||
Pattern C: ``praisonai serve gateway`` or ``run_integrated_gateway()``.
|
||||
Legacy: set ``PRAISONAI_HOST_LEGACY=1`` for callback-only ``@aiui.reply`` mode.
|
||||
|
||||
Provides a single interface at localhost:3000 to access:
|
||||
- Flow Visual Builder (Langflow) - port 7860
|
||||
- Claw Dashboard (Full UI) - port 8082
|
||||
|
||||
@@ -237,6 +237,23 @@ def doctor_cleanup(
|
||||
raise typer.Exit(0 if removed == len(stale_dirs) else 1)
|
||||
|
||||
|
||||
@app.command("skills")
|
||||
def doctor_skills(
|
||||
deep: bool = typer.Option(False, "--deep", help="Enable deeper probes"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
||||
show_requirements: bool = typer.Option(False, "--requirements", help="Show detailed requirements"),
|
||||
):
|
||||
"""Check skills capabilities and requirements."""
|
||||
args = ["skills"]
|
||||
if deep:
|
||||
args.append("--deep")
|
||||
if json_output:
|
||||
args.append("--json")
|
||||
if show_requirements:
|
||||
args.append("--requirements")
|
||||
raise typer.Exit(_run_doctor(args))
|
||||
|
||||
|
||||
@app.command("troubleshoot")
|
||||
def doctor_troubleshoot():
|
||||
"""
|
||||
|
||||
@@ -13,7 +13,12 @@ from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
app = typer.Typer(help="🤖 PraisonAI Clean Chat UI")
|
||||
app = typer.Typer(
|
||||
help=(
|
||||
"🤖 PraisonAI Clean Chat UI (Pattern B host via bundled default_app). "
|
||||
"Pattern C: AIUIGateway. Legacy: PRAISONAI_HOST_LEGACY=1."
|
||||
)
|
||||
)
|
||||
|
||||
UI_DIR = Path.home() / ".praisonai" / "ui"
|
||||
DEFAULT_APP = UI_DIR / "app.py"
|
||||
|
||||
@@ -110,21 +110,8 @@ def create_agent_centric_tools(
|
||||
if orchestrator is None:
|
||||
orchestrator = ActionOrchestrator(runtime)
|
||||
|
||||
# Helper to run async functions synchronously
|
||||
def run_async(coro):
|
||||
"""Run async coroutine synchronously."""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
# Create a new loop in a thread
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(asyncio.run, coro)
|
||||
return future.result(timeout=60)
|
||||
else:
|
||||
return loop.run_until_complete(coro)
|
||||
except RuntimeError:
|
||||
return asyncio.run(coro)
|
||||
# Helper to run async functions synchronously using the shared bridge
|
||||
from praisonai._async_bridge import run_sync
|
||||
|
||||
# =========================================================================
|
||||
# ACP-Powered File Tools
|
||||
@@ -204,7 +191,7 @@ def create_agent_centric_tools(
|
||||
"verified": result.success
|
||||
})
|
||||
|
||||
return run_async(_create())
|
||||
return run_sync(_create())
|
||||
|
||||
def acp_edit_file(filepath: str, new_content: str) -> str:
|
||||
"""
|
||||
@@ -261,7 +248,7 @@ def create_agent_centric_tools(
|
||||
"error": result.error
|
||||
})
|
||||
|
||||
return run_async(_edit())
|
||||
return run_sync(_edit())
|
||||
|
||||
def acp_delete_file(filepath: str) -> str:
|
||||
"""
|
||||
@@ -313,7 +300,7 @@ def create_agent_centric_tools(
|
||||
"verified": result.success
|
||||
})
|
||||
|
||||
return run_async(_delete())
|
||||
return run_sync(_delete())
|
||||
|
||||
def acp_execute_command(command: str, cwd: str = None) -> str:
|
||||
"""
|
||||
@@ -372,7 +359,7 @@ def create_agent_centric_tools(
|
||||
"error": result.error
|
||||
})
|
||||
|
||||
return run_async(_execute())
|
||||
return run_sync(_execute())
|
||||
|
||||
# =========================================================================
|
||||
# LSP-Powered Code Intelligence Tools
|
||||
@@ -404,7 +391,7 @@ def create_agent_centric_tools(
|
||||
except asyncio.TimeoutError:
|
||||
return json.dumps({"error": "LSP list_symbols timed out (10s)", "success": False})
|
||||
|
||||
return run_async(_list())
|
||||
return run_sync(_list())
|
||||
|
||||
def lsp_find_definition(symbol: str, file_path: str = None) -> str:
|
||||
"""
|
||||
@@ -432,7 +419,7 @@ def create_agent_centric_tools(
|
||||
except asyncio.TimeoutError:
|
||||
return json.dumps({"error": "LSP find_definition timed out (10s)", "success": False})
|
||||
|
||||
return run_async(_find())
|
||||
return run_sync(_find())
|
||||
|
||||
def lsp_find_references(symbol: str, file_path: str = None) -> str:
|
||||
"""
|
||||
@@ -460,7 +447,7 @@ def create_agent_centric_tools(
|
||||
except asyncio.TimeoutError:
|
||||
return json.dumps({"error": "LSP find_references timed out (10s)", "success": False})
|
||||
|
||||
return run_async(_find())
|
||||
return run_sync(_find())
|
||||
|
||||
def lsp_get_diagnostics(file_path: str = None) -> str:
|
||||
"""
|
||||
@@ -487,7 +474,7 @@ def create_agent_centric_tools(
|
||||
except asyncio.TimeoutError:
|
||||
return json.dumps({"error": "LSP diagnostics timed out (10s)", "success": False})
|
||||
|
||||
return run_async(_get())
|
||||
return run_sync(_get())
|
||||
|
||||
# =========================================================================
|
||||
# Basic File Tools (for read-only operations)
|
||||
|
||||
@@ -229,3 +229,99 @@ def check_skills_praisonai_integration(config: DoctorConfig) -> CheckResult:
|
||||
message="Skills module not available",
|
||||
details=str(e),
|
||||
)
|
||||
|
||||
|
||||
@register_check(
|
||||
id="skills_capabilities",
|
||||
title="Skills Capability Requirements",
|
||||
description="Check skill capability requirements and enforcement",
|
||||
category=CheckCategory.SKILLS,
|
||||
severity=CheckSeverity.MEDIUM,
|
||||
dependencies=["skills_praisonai_integration"],
|
||||
)
|
||||
def check_skills_capabilities(config: DoctorConfig) -> CheckResult:
|
||||
"""Check skill capability requirements."""
|
||||
try:
|
||||
from praisonaiagents.skills import SkillManager
|
||||
from praisonaiagents.skills.capability_validator import EnforcementLevel
|
||||
|
||||
# Initialize skill manager
|
||||
manager = SkillManager()
|
||||
|
||||
# Discover skills
|
||||
discovered = manager.discover(include_defaults=True)
|
||||
if discovered == 0:
|
||||
return CheckResult(
|
||||
id="skills_capabilities",
|
||||
title="Skills Capability Requirements",
|
||||
category=CheckCategory.SKILLS,
|
||||
status=CheckStatus.SKIP,
|
||||
message="No skills found to check",
|
||||
)
|
||||
|
||||
# Get diagnostics for all skills
|
||||
diagnostics = manager.get_skills_diagnostics()
|
||||
|
||||
active_count = 0
|
||||
degraded_count = 0
|
||||
unavailable_count = 0
|
||||
issues = []
|
||||
|
||||
for skill_name, result in diagnostics.items():
|
||||
if result.state.value == "active":
|
||||
active_count += 1
|
||||
elif result.state.value == "degraded":
|
||||
degraded_count += 1
|
||||
issues.extend([f"{skill_name}: {w}" for w in result.warnings[:2]])
|
||||
elif result.state.value == "unavailable":
|
||||
unavailable_count += 1
|
||||
issues.extend([f"{skill_name}: {e}" for e in result.errors[:2]])
|
||||
|
||||
total = active_count + degraded_count + unavailable_count
|
||||
|
||||
# Determine status
|
||||
enforcement_level = manager._validator.enforcement_level
|
||||
|
||||
if unavailable_count == 0 and degraded_count == 0:
|
||||
status = CheckStatus.PASS
|
||||
message = f"All {total} skill(s) have requirements satisfied"
|
||||
elif unavailable_count > 0 and enforcement_level == EnforcementLevel.STRICT:
|
||||
status = CheckStatus.FAIL
|
||||
message = f"{unavailable_count}/{total} skills unavailable (strict enforcement)"
|
||||
elif unavailable_count > 0 or degraded_count > 0:
|
||||
status = CheckStatus.WARN
|
||||
message = f"{active_count}/{total} skills fully available, {degraded_count} degraded, {unavailable_count} unavailable"
|
||||
else:
|
||||
status = CheckStatus.PASS
|
||||
message = f"Skills capability check completed"
|
||||
|
||||
details = "; ".join(issues[:5]) + ("..." if len(issues) > 5 else "") if issues else None
|
||||
|
||||
metadata = {
|
||||
"total_skills": total,
|
||||
"active": active_count,
|
||||
"degraded": degraded_count,
|
||||
"unavailable": unavailable_count,
|
||||
"enforcement_level": enforcement_level.value,
|
||||
"sample_issues": issues[:10]
|
||||
}
|
||||
|
||||
return CheckResult(
|
||||
id="skills_capabilities",
|
||||
title="Skills Capability Requirements",
|
||||
category=CheckCategory.SKILLS,
|
||||
status=status,
|
||||
message=message,
|
||||
details=details,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
except ImportError as e:
|
||||
return CheckResult(
|
||||
id="skills_capabilities",
|
||||
title="Skills Capability Requirements",
|
||||
category=CheckCategory.SKILLS,
|
||||
status=CheckStatus.SKIP,
|
||||
message="Skills capability validation not available",
|
||||
details=str(e),
|
||||
)
|
||||
@@ -127,6 +127,11 @@ Launch PraisonAI servers with unified discovery support.
|
||||
|
||||
[bold]Discovery:[/bold]
|
||||
All servers expose /__praisonai__/discovery for unified endpoint discovery.
|
||||
|
||||
[bold]A2UI / AG-UI rendering:[/bold]
|
||||
For live A2UI surfaces in a web UI, use PraisonAIUI (POST /agui SSE + /api/surfaces):
|
||||
pip install praisonaiui praisonaiagents[a2ui]
|
||||
See docs: Integrate A2UI with Your Frontend.
|
||||
Use `praisonai endpoints` to interact with any server.
|
||||
"""
|
||||
self._print_rich(help_text)
|
||||
|
||||
@@ -166,17 +166,19 @@ AUTOGEN_AVAILABLE = False
|
||||
PRAISONAI_AVAILABLE = False
|
||||
TRAIN_AVAILABLE = False
|
||||
|
||||
# Use find_spec for fast availability checks (no actual import)
|
||||
import importlib.util
|
||||
GRADIO_AVAILABLE = importlib.util.find_spec("gradio") is not None
|
||||
# Use centralized availability detection
|
||||
from .._framework_availability import is_available
|
||||
|
||||
GRADIO_AVAILABLE = is_available("gradio")
|
||||
try:
|
||||
import importlib.util
|
||||
CALL_MODULE_AVAILABLE = importlib.util.find_spec("praisonai.api.call") is not None
|
||||
except (ModuleNotFoundError, AttributeError):
|
||||
CALL_MODULE_AVAILABLE = False
|
||||
CREWAI_AVAILABLE = importlib.util.find_spec("crewai") is not None
|
||||
AUTOGEN_AVAILABLE = importlib.util.find_spec("autogen") is not None
|
||||
PRAISONAI_AVAILABLE = importlib.util.find_spec("praisonaiagents") is not None
|
||||
TRAIN_AVAILABLE = importlib.util.find_spec("unsloth") is not None
|
||||
CREWAI_AVAILABLE = is_available("crewai")
|
||||
AUTOGEN_AVAILABLE = is_available("autogen")
|
||||
PRAISONAI_AVAILABLE = is_available("praisonaiagents")
|
||||
TRAIN_AVAILABLE = is_available("unsloth")
|
||||
|
||||
# Lazy import helpers for optional dependencies (defined after availability flags)
|
||||
def _get_call_module():
|
||||
|
||||
@@ -28,8 +28,8 @@ def _get_rich_available() -> bool:
|
||||
global _rich_available
|
||||
if _rich_available is None:
|
||||
try:
|
||||
import importlib.util
|
||||
_rich_available = importlib.util.find_spec("rich") is not None
|
||||
from ..._framework_availability import is_available
|
||||
_rich_available = is_available("rich")
|
||||
except ImportError:
|
||||
_rich_available = False
|
||||
return _rich_available
|
||||
|
||||
@@ -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.43 gunicorn markdown\n")
|
||||
file.write("RUN pip install flask praisonai==4.6.46 gunicorn markdown\n")
|
||||
file.write("EXPOSE 8080\n")
|
||||
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
|
||||
|
||||
|
||||
@@ -71,15 +71,14 @@ class PraisonAIAdapter(BaseFrameworkAdapter):
|
||||
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._async_bridge import run_sync
|
||||
from praisonai.cli.features.interactive_runtime import InteractiveRuntime, RuntimeConfig
|
||||
from praisonai.cli.features.agent_tools import create_agent_centric_tools
|
||||
|
||||
# Use scoped event loop instead of process-global mutations
|
||||
# Use scoped configuration instead of process-global mutations
|
||||
runtime_config = RuntimeConfig(
|
||||
workspace=os.getcwd(),
|
||||
acp_enabled=acp_enabled,
|
||||
@@ -89,11 +88,9 @@ class PraisonAIAdapter(BaseFrameworkAdapter):
|
||||
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())
|
||||
# Start the runtime on the shared background loop where it stays alive
|
||||
# and its asyncio primitives remain valid for the duration of this call
|
||||
run_sync(interactive_runtime.start())
|
||||
|
||||
centric_tools = create_agent_centric_tools(interactive_runtime)
|
||||
logger.info(f"Loaded {len(centric_tools)} InteractiveRuntime tools")
|
||||
@@ -204,12 +201,11 @@ class PraisonAIAdapter(BaseFrameworkAdapter):
|
||||
return result
|
||||
finally:
|
||||
# Cleanup InteractiveRuntime if it was started
|
||||
if interactive_runtime and interactive_loop:
|
||||
if interactive_runtime is not None:
|
||||
try:
|
||||
logger.info("Stopping InteractiveRuntime")
|
||||
interactive_loop.run_until_complete(interactive_runtime.stop())
|
||||
from praisonai._async_bridge import run_sync
|
||||
run_sync(interactive_runtime.stop())
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping InteractiveRuntime: {e}")
|
||||
finally:
|
||||
interactive_loop.close()
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"""Lazy access to autogen_tools, with `find_spec` startup probes for crewai/autogen."""
|
||||
import importlib.util
|
||||
from praisonai.auto import _load_optional
|
||||
|
||||
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
|
||||
PRAISONAI_TOOLS_PACKAGE_AVAILABLE = is_available("praisonai_tools")
|
||||
|
||||
|
||||
def _load_autogen_tools():
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""PraisonAI ↔ PraisonAIUI integration layer (Pattern B/C host bootstrap)."""
|
||||
|
||||
from .host_app import (
|
||||
build_host_app,
|
||||
configure_host,
|
||||
create_host_app,
|
||||
is_legacy_host,
|
||||
setup_bridges,
|
||||
)
|
||||
from .gateway_host import run_integrated_gateway
|
||||
|
||||
__all__ = [
|
||||
"build_host_app",
|
||||
"configure_host",
|
||||
"create_host_app",
|
||||
"is_legacy_host",
|
||||
"setup_bridges",
|
||||
"run_integrated_gateway",
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Shared legacy @aiui.reply handlers for bundled default apps."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def register_legacy_reply(aiui, *, agent_name: str = "PraisonAI"):
|
||||
"""Register callback-only reply handler when ``PRAISONAI_HOST_LEGACY=1``."""
|
||||
|
||||
@aiui.reply
|
||||
async def legacy_reply(message: str, session_id: str = "default"):
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name=agent_name,
|
||||
instructions="You are a helpful assistant.",
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
return agent.run(message)
|
||||
@@ -0,0 +1 @@
|
||||
"""L1→L2 bridge helpers for PraisonAIUI feature routes."""
|
||||
@@ -0,0 +1,31 @@
|
||||
"""ApprovalRegistry CRUD adapter for aiui backends."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
def list_pending_approvals() -> List[Dict[str, Any]]:
|
||||
try:
|
||||
from praisonaiagents.approval import get_approval_registry
|
||||
|
||||
registry = get_approval_registry()
|
||||
backend = getattr(registry, "_backend", None)
|
||||
if backend and hasattr(backend, "list_pending"):
|
||||
return backend.list_pending()
|
||||
except ImportError:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
def get_approval_policies() -> Dict[str, Any]:
|
||||
try:
|
||||
from praisonaiagents.approval import get_approval_registry
|
||||
|
||||
registry = get_approval_registry()
|
||||
return {
|
||||
"auto_approve_env": registry.is_env_auto_approve(),
|
||||
"requirements": getattr(registry, "_requirements", {}),
|
||||
}
|
||||
except ImportError:
|
||||
return {}
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Expose praisonaiagents HookRegistry for admin/API consumers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
def list_hooks_for_api() -> List[Dict[str, Any]]:
|
||||
"""Flat hook list suitable for REST responses."""
|
||||
try:
|
||||
from praisonaiagents.hooks.registry import list_hooks_for_api as core_list
|
||||
|
||||
return core_list()
|
||||
except ImportError:
|
||||
return []
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Ensure SDK schedule runner + optional ScheduleLoop daemon."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
_runner_started = False
|
||||
_loop = None
|
||||
|
||||
|
||||
def ensure_schedule_runner() -> None:
|
||||
"""Lazy-init FileScheduleStore-backed runner and optional ScheduleLoop."""
|
||||
global _runner_started, _loop
|
||||
if _runner_started:
|
||||
return
|
||||
try:
|
||||
from praisonaiagents.scheduler import FileScheduleStore, ScheduleLoop
|
||||
|
||||
store = FileScheduleStore()
|
||||
|
||||
def on_trigger(job):
|
||||
log.info("Schedule triggered: %s", getattr(job, "name", job))
|
||||
|
||||
_loop = ScheduleLoop(on_trigger=on_trigger, store=store)
|
||||
_loop.start()
|
||||
_runner_started = True
|
||||
log.info("ScheduleLoop started for host integration")
|
||||
except Exception as exc:
|
||||
log.debug("Schedule runner unavailable: %s", exc)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Wire core token usage collector to optional persistence sink."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
def register_usage_sink() -> Optional[Any]:
|
||||
"""Attach global token collector to in-memory sink when available."""
|
||||
try:
|
||||
from praisonaiagents.telemetry.protocols import (
|
||||
InMemoryTokenUsageSink,
|
||||
InMemoryUsageQuery,
|
||||
TokenCollectorUsageQuery,
|
||||
)
|
||||
from praisonaiagents.telemetry.token_collector import get_token_collector
|
||||
|
||||
sink = InMemoryTokenUsageSink()
|
||||
collector = get_token_collector()
|
||||
collector.set_sink(sink)
|
||||
return sink
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
|
||||
def get_usage_query():
|
||||
"""Return UsageQueryProtocol adapter for UI consumption."""
|
||||
try:
|
||||
from praisonaiagents.telemetry.protocols import (
|
||||
InMemoryUsageQuery,
|
||||
TokenCollectorUsageQuery,
|
||||
)
|
||||
from praisonaiagents.telemetry.token_collector import get_token_collector
|
||||
|
||||
collector = get_token_collector()
|
||||
sink = getattr(collector, "_sink", None)
|
||||
if sink is not None and hasattr(sink, "records"):
|
||||
return InMemoryUsageQuery(sink)
|
||||
return TokenCollectorUsageQuery(collector)
|
||||
except ImportError:
|
||||
return None
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Run praisonaiagents AgentFlow workflows with run tracking."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def load_workflow_yaml(path: str) -> Dict[str, Any]:
|
||||
"""Load workflow definition from a YAML file."""
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Workflow YAML must be a mapping")
|
||||
return data
|
||||
|
||||
|
||||
def run_workflow(
|
||||
workflow_id: str,
|
||||
*,
|
||||
input_text: str = "",
|
||||
workflow_config: Optional[Dict[str, Any]] = None,
|
||||
yaml_path: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute a workflow and return a serialisable run record."""
|
||||
run_id = uuid.uuid4().hex[:12]
|
||||
config = dict(workflow_config or {})
|
||||
if yaml_path:
|
||||
loaded = load_workflow_yaml(yaml_path)
|
||||
config = {**loaded, **config}
|
||||
|
||||
steps = config.get("steps") or []
|
||||
if not steps:
|
||||
return {
|
||||
"id": run_id,
|
||||
"workflow_id": workflow_id,
|
||||
"status": "failed",
|
||||
"input": {"text": input_text},
|
||||
"error": "Workflow has no steps",
|
||||
}
|
||||
|
||||
try:
|
||||
from praisonaiagents import Agent, AgentFlow
|
||||
|
||||
flow_steps = []
|
||||
for i, step in enumerate(steps):
|
||||
if isinstance(step, str):
|
||||
flow_steps.append(
|
||||
Agent(
|
||||
name=f"step_{i + 1}",
|
||||
instructions=step,
|
||||
llm=config.get("model", "gpt-4o-mini"),
|
||||
)
|
||||
)
|
||||
else:
|
||||
flow_steps.append(step)
|
||||
|
||||
flow = AgentFlow(steps=flow_steps, name=config.get("name", "Workflow"))
|
||||
result = flow.run(input_text)
|
||||
return {
|
||||
"id": run_id,
|
||||
"workflow_id": workflow_id,
|
||||
"status": "completed",
|
||||
"input": {"text": input_text},
|
||||
"output": result if isinstance(result, dict) else {"result": str(result)},
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"id": run_id,
|
||||
"workflow_id": workflow_id,
|
||||
"status": "failed",
|
||||
"input": {"text": input_text},
|
||||
"error": str(exc),
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"""AGENTS.md-style context file injection for host apps."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
def load_context_files(
|
||||
paths: Optional[List[str]] = None,
|
||||
*,
|
||||
cwd: Optional[Path] = None,
|
||||
) -> str:
|
||||
"""Load context from AGENTS.md-style files and return combined text."""
|
||||
base = cwd or Path.cwd()
|
||||
candidates = paths or ["AGENTS.md", "agents.md", ".agents/AGENTS.md"]
|
||||
chunks: List[str] = []
|
||||
for name in candidates:
|
||||
path = base / name
|
||||
if path.is_file():
|
||||
chunks.append(path.read_text(encoding="utf-8"))
|
||||
return "\n\n".join(chunks)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Pattern C — integrated AIUIGateway launcher."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
async def run_integrated_gateway(
|
||||
*,
|
||||
port: int = 8080,
|
||||
host: str = "127.0.0.1",
|
||||
static_dir: Optional[str] = None,
|
||||
ui_config: Optional[dict[str, Any]] = None,
|
||||
**configure_kwargs: Any,
|
||||
) -> None:
|
||||
"""Start ``AIUIGateway`` after ``configure_host()`` (Pattern B/C parity)."""
|
||||
from praisonai.integration.host_app import configure_host
|
||||
from praisonaiui.integration import AIUIGateway
|
||||
|
||||
configure_host(**configure_kwargs)
|
||||
gateway = AIUIGateway(
|
||||
host=host,
|
||||
port=port,
|
||||
static_dir=static_dir,
|
||||
ui_config=ui_config or {},
|
||||
)
|
||||
await gateway.start()
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Official PraisonAI → PraisonAIUI host bootstrap (Pattern B).
|
||||
|
||||
Wires ``PraisonAISessionDataStore`` + ``PraisonAIProvider`` before ``create_app()``.
|
||||
Set ``PRAISONAI_HOST_LEGACY=1`` to skip provider wiring (callback-only mode).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, Sequence
|
||||
|
||||
_CONFIGURED = False
|
||||
|
||||
|
||||
def is_legacy_host() -> bool:
|
||||
"""True when callback-only ``@aiui.reply`` mode is requested."""
|
||||
return os.environ.get("PRAISONAI_HOST_LEGACY", "").strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
)
|
||||
|
||||
|
||||
def configure_host(
|
||||
*,
|
||||
pages: Optional[Sequence[str]] = None,
|
||||
title: str = "PraisonAI",
|
||||
logo: str = "🤖",
|
||||
sidebar: bool = True,
|
||||
page_header: bool = True,
|
||||
theme: Optional[Dict[str, Any]] = None,
|
||||
agents: Optional[List[Any]] = None,
|
||||
agent_kwargs: Optional[Dict[str, Any]] = None,
|
||||
gateway: Any = None,
|
||||
modules: Optional[Sequence[str]] = None,
|
||||
) -> None:
|
||||
"""Apply PraisonAIUI host settings and wire L1 backends (unless legacy mode)."""
|
||||
global _CONFIGURED
|
||||
|
||||
import praisonaiui as aiui
|
||||
from praisonai.ui._aiui_datastore import PraisonAISessionDataStore
|
||||
|
||||
aiui.set_datastore(PraisonAISessionDataStore())
|
||||
aiui.set_style("dashboard")
|
||||
aiui.set_branding(title=title, logo=logo)
|
||||
|
||||
if pages is not None:
|
||||
aiui.set_pages(list(pages))
|
||||
|
||||
dashboard_opts: Dict[str, Any] = {"sidebar": sidebar, "page_header": page_header}
|
||||
if modules:
|
||||
dashboard_opts["modules"] = list(modules)
|
||||
aiui.set_dashboard(**dashboard_opts)
|
||||
|
||||
if theme:
|
||||
aiui.set_theme(**theme)
|
||||
|
||||
try:
|
||||
from praisonai.ui._external_agents import aiui_settings_entries
|
||||
|
||||
external = aiui_settings_entries()
|
||||
if external and hasattr(aiui, "set_settings"):
|
||||
aiui.set_settings(external)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if gateway is not None:
|
||||
try:
|
||||
from praisonaiui.features._gateway_ref import set_gateway
|
||||
|
||||
set_gateway(gateway)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if not is_legacy_host():
|
||||
from praisonaiui.providers import PraisonAIProvider
|
||||
from praisonaiui.server import set_provider
|
||||
|
||||
kwargs = dict(agent_kwargs or {})
|
||||
if agents:
|
||||
set_provider(PraisonAIProvider(agents=list(agents), **kwargs))
|
||||
else:
|
||||
set_provider(
|
||||
PraisonAIProvider(
|
||||
name=kwargs.pop("name", "PraisonAI"),
|
||||
instructions=kwargs.pop(
|
||||
"instructions",
|
||||
"You are a helpful assistant.",
|
||||
),
|
||||
llm=kwargs.pop(
|
||||
"llm", os.getenv("PRAISONAI_MODEL", "gpt-4o-mini")
|
||||
),
|
||||
**kwargs,
|
||||
)
|
||||
)
|
||||
setup_bridges()
|
||||
|
||||
_CONFIGURED = True
|
||||
|
||||
|
||||
def setup_bridges() -> None:
|
||||
"""Register optional L1→L2 bridge hooks (usage sink, schedules, aiui backends)."""
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
sink = None
|
||||
|
||||
try:
|
||||
from praisonai.integration.bridges.usage_bridge import register_usage_sink
|
||||
|
||||
sink = register_usage_sink()
|
||||
except Exception as exc:
|
||||
log.debug("usage bridge unavailable: %s", exc)
|
||||
|
||||
try:
|
||||
from praisonai.integration.bridges.schedules_runner import ensure_schedule_runner
|
||||
|
||||
ensure_schedule_runner()
|
||||
except Exception as exc:
|
||||
log.debug("schedule runner unavailable: %s", exc)
|
||||
|
||||
try:
|
||||
import praisonaiui.backends as backends
|
||||
from praisonai.integration.bridges.hooks_query import list_hooks_for_api
|
||||
from praisonai.integration.bridges.workflows_service import run_workflow as svc_run
|
||||
|
||||
def _workflow_backend(wf_id, *, workflow, input_data):
|
||||
text = (input_data or {}).get("text") or (input_data or {}).get("message") or ""
|
||||
return svc_run(wf_id, input_text=text, workflow_config=workflow)
|
||||
|
||||
backends.set_backend("hooks", list_hooks_for_api)
|
||||
backends.set_backend("workflows", _workflow_backend)
|
||||
if sink is not None:
|
||||
backends.set_backend("usage_sink", sink)
|
||||
from praisonai.integration.bridges.usage_bridge import get_usage_query
|
||||
|
||||
query = get_usage_query()
|
||||
if query is not None:
|
||||
backends.set_backend("usage_query", query)
|
||||
from praisonai.integration.bridges.approvals_bridge import (
|
||||
get_approval_policies,
|
||||
list_pending_approvals,
|
||||
)
|
||||
|
||||
backends.set_backend("approvals_pending", list_pending_approvals)
|
||||
backends.set_backend("approvals_policies", get_approval_policies)
|
||||
except Exception as exc:
|
||||
log.debug("aiui backend injection failed: %s", exc)
|
||||
|
||||
|
||||
def create_host_app():
|
||||
"""Return the Starlette app from PraisonAIUI (call after ``configure_host``)."""
|
||||
from praisonaiui.server import create_app
|
||||
|
||||
if not _CONFIGURED:
|
||||
configure_host()
|
||||
return create_app()
|
||||
|
||||
|
||||
def build_host_app(**configure_kwargs):
|
||||
"""One-shot: configure host + return ``create_app()``."""
|
||||
configure_host(**configure_kwargs)
|
||||
return create_host_app()
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Optional L3 page — bot / channel health."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import praisonaiui as aiui
|
||||
|
||||
|
||||
@aiui.page("bot-health", title="Bot Health", icon="🤖")
|
||||
async def bot_health_page():
|
||||
"""Dashboard page for channel and gateway status."""
|
||||
status = {"gateway": "unknown", "channels": []}
|
||||
try:
|
||||
from praisonaiui.features._gateway_ref import get_gateway
|
||||
|
||||
gw = get_gateway()
|
||||
if gw is not None:
|
||||
status["gateway"] = "running"
|
||||
status["agents"] = gw.list_agents() if hasattr(gw, "list_agents") else []
|
||||
except ImportError:
|
||||
pass
|
||||
return status
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Optional L3 page — workflow runs table."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import praisonaiui as aiui
|
||||
|
||||
|
||||
@aiui.page("workflow-runs", title="Workflow Runs", icon="🔄")
|
||||
async def workflow_runs_page():
|
||||
"""Dashboard page listing recent workflow runs."""
|
||||
try:
|
||||
from praisonai.integration.bridges.workflows_service import run_workflow
|
||||
except ImportError:
|
||||
return {"runs": [], "note": "Workflow bridge unavailable"}
|
||||
|
||||
return {"runs": [], "service": "WorkflowRunService", "status": "ready"}
|
||||
@@ -30,20 +30,15 @@ def __getattr__(name):
|
||||
from .agent_scheduler import create_agent_scheduler
|
||||
return create_agent_scheduler
|
||||
elif name == 'create_scheduler':
|
||||
# Return a factory function that creates a mock scheduler for testing
|
||||
def _create_scheduler(provider='gcp', **kwargs):
|
||||
from .agent_scheduler import AgentScheduler
|
||||
# Create a mock agent and task for testing
|
||||
class MockAgent:
|
||||
pass
|
||||
return AgentScheduler(MockAgent(), "test task")
|
||||
return _create_scheduler
|
||||
# Return the real deployment scheduler factory
|
||||
from .deployment import create_deployment_scheduler
|
||||
return create_deployment_scheduler
|
||||
elif name == 'DeploymentScheduler':
|
||||
from .agent_scheduler import AgentScheduler
|
||||
return AgentScheduler
|
||||
from .deployment import DeploymentScheduler
|
||||
return DeploymentScheduler
|
||||
elif name == 'create_deployment_scheduler':
|
||||
from .agent_scheduler import create_agent_scheduler
|
||||
return create_agent_scheduler
|
||||
from .deployment import create_deployment_scheduler
|
||||
return create_deployment_scheduler
|
||||
elif name in ('ScheduledAgentExecutor', 'JobResult'):
|
||||
from .executor import ScheduledAgentExecutor, JobResult
|
||||
return ScheduledAgentExecutor if name == 'ScheduledAgentExecutor' else JobResult
|
||||
|
||||
+44
-48
@@ -1,11 +1,10 @@
|
||||
"""
|
||||
DEPRECATED: This module is deprecated. Use praisonai.scheduler instead.
|
||||
Real deployment scheduler that integrates with the actual deployment system.
|
||||
|
||||
This file is kept for backward compatibility only.
|
||||
All new code should import from praisonai.scheduler.
|
||||
This module provides the actual deployment scheduler implementation that was
|
||||
previously shadowed by the mock in __init__.py.
|
||||
"""
|
||||
|
||||
import warnings
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
@@ -14,18 +13,7 @@ from abc import ABC, abstractmethod
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Issue deprecation warning
|
||||
warnings.warn(
|
||||
"praisonai.scheduler module (root level) is deprecated. "
|
||||
"Use 'from praisonai.scheduler import ...' instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2
|
||||
)
|
||||
|
||||
# Import from new scheduler module for backward compatibility
|
||||
from .scheduler.base import ScheduleParser
|
||||
|
||||
# Keep old classes for backward compatibility
|
||||
class DeployerInterface(ABC):
|
||||
"""Abstract interface for deployers to ensure provider compatibility."""
|
||||
|
||||
@@ -34,35 +22,56 @@ class DeployerInterface(ABC):
|
||||
"""Execute deployment. Returns True on success, False on failure."""
|
||||
pass
|
||||
|
||||
class CloudDeployerAdapter(DeployerInterface):
|
||||
"""Adapter for existing CloudDeployer to match interface."""
|
||||
|
||||
class DeployHandlerAdapter(DeployerInterface):
|
||||
"""Adapter for the real DeployHandler to match the scheduler interface."""
|
||||
|
||||
def __init__(self):
|
||||
from .deploy import CloudDeployer
|
||||
self._deployer = CloudDeployer()
|
||||
def __init__(self, provider: str = "gcp", config: Optional[Dict[str, Any]] = None):
|
||||
self.provider = provider
|
||||
self.config = config or {}
|
||||
|
||||
def deploy(self) -> bool:
|
||||
"""Execute deployment using CloudDeployer."""
|
||||
"""Execute deployment using the real DeployHandler."""
|
||||
try:
|
||||
self._deployer.run_commands()
|
||||
from praisonai.cli.features.deploy import DeployHandler
|
||||
|
||||
handler = DeployHandler()
|
||||
|
||||
# Create args object for handler
|
||||
class DeployArgs:
|
||||
def __init__(self):
|
||||
self.file = "agents.yaml"
|
||||
self.type = None
|
||||
self.provider = self.provider if hasattr(self, 'provider') else 'gcp'
|
||||
self.json = False
|
||||
self.background = False
|
||||
|
||||
# Create args with the provider from the scheduler
|
||||
deploy_args = DeployArgs()
|
||||
deploy_args.provider = self.provider
|
||||
|
||||
handler.handle_deploy(deploy_args)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Deployment failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class DeploymentScheduler:
|
||||
"""
|
||||
Minimal deployment scheduler with provider-agnostic design.
|
||||
Real deployment scheduler with provider-agnostic design.
|
||||
|
||||
Features:
|
||||
- Simple interval-based scheduling
|
||||
- Thread-safe operation
|
||||
- Extensible deployer factory pattern
|
||||
- Minimal dependencies
|
||||
- Integrates with actual DeployHandler
|
||||
- Provider dispatch support
|
||||
"""
|
||||
|
||||
def __init__(self, schedule_config: Optional[Dict[str, Any]] = None):
|
||||
self.config = schedule_config or {}
|
||||
def __init__(self, provider: str = "gcp", config: Optional[Dict[str, Any]] = None):
|
||||
self.provider = provider
|
||||
self.config = config or {}
|
||||
self.is_running = False
|
||||
self._stop_event = threading.Event()
|
||||
self._thread = None
|
||||
@@ -77,8 +86,8 @@ class DeploymentScheduler:
|
||||
if self._deployer:
|
||||
return self._deployer
|
||||
|
||||
# Default to CloudDeployer for backward compatibility
|
||||
return CloudDeployerAdapter()
|
||||
# Use real DeployHandler via adapter
|
||||
return DeployHandlerAdapter(self.provider, self.config)
|
||||
|
||||
def start(self, schedule_expr: str, max_retries: int = 3) -> bool:
|
||||
"""
|
||||
@@ -96,6 +105,7 @@ class DeploymentScheduler:
|
||||
return False
|
||||
|
||||
try:
|
||||
from .base import ScheduleParser
|
||||
interval = ScheduleParser.parse(schedule_expr)
|
||||
self.is_running = True
|
||||
self._stop_event.clear()
|
||||
@@ -165,30 +175,16 @@ class DeploymentScheduler:
|
||||
logger.error(f"One-time deployment failed: {e}")
|
||||
return False
|
||||
|
||||
def create_scheduler(provider: str = "gcp", config: Optional[Dict[str, Any]] = None) -> DeploymentScheduler:
|
||||
|
||||
def create_deployment_scheduler(provider: str = "gcp", config: Optional[Dict[str, Any]] = None) -> DeploymentScheduler:
|
||||
"""
|
||||
Factory function to create scheduler for different providers.
|
||||
Factory function to create a real deployment scheduler for different providers.
|
||||
|
||||
Args:
|
||||
provider: Deployment provider ("gcp", "aws", "azure", etc.)
|
||||
config: Optional configuration dict
|
||||
|
||||
Returns:
|
||||
Configured DeploymentScheduler instance
|
||||
Configured DeploymentScheduler instance that uses real deployment logic
|
||||
"""
|
||||
scheduler = DeploymentScheduler(config)
|
||||
|
||||
# Provider-specific deployer setup can be added here
|
||||
if provider == "gcp":
|
||||
# Default CloudDeployer for GCP
|
||||
pass
|
||||
elif provider == "aws":
|
||||
# Future: AWS deployer implementation
|
||||
logger.warning("AWS provider not yet implemented, using default")
|
||||
elif provider == "azure":
|
||||
# Future: Azure deployer implementation
|
||||
logger.warning("Azure provider not yet implemented, using default")
|
||||
else:
|
||||
logger.warning(f"Unknown provider {provider}, using default")
|
||||
|
||||
return scheduler
|
||||
return DeploymentScheduler(provider, config)
|
||||
@@ -45,19 +45,26 @@ class ToolResolver:
|
||||
Attributes:
|
||||
_local_tools_cache: Cached tools from local tools.py
|
||||
_local_tools_loaded: Whether local tools have been loaded
|
||||
_registry: Optional ToolRegistry for wrapper-level tool registration
|
||||
"""
|
||||
|
||||
def __init__(self, tools_py_path: Optional[str] = None):
|
||||
def __init__(
|
||||
self,
|
||||
tools_py_path: Optional[str] = None,
|
||||
registry: Optional["ToolRegistry"] = None,
|
||||
):
|
||||
"""Initialize the resolver.
|
||||
|
||||
Args:
|
||||
tools_py_path: Optional path to tools.py. If None, uses ./tools.py
|
||||
registry: Optional ToolRegistry to include in resolution chain
|
||||
"""
|
||||
self._tools_py_path = tools_py_path or "tools.py"
|
||||
self._local_tools_cache: Mapping[str, Callable] = MappingProxyType({})
|
||||
self._local_tools_loaded: bool = False
|
||||
self._praisonai_tools_available: Optional[bool] = None
|
||||
self._local_tools_lock = threading.Lock()
|
||||
self._registry = registry
|
||||
|
||||
def _load_local_tools(self) -> Mapping[str, Callable]:
|
||||
"""Load tools from local tools.py file.
|
||||
@@ -163,7 +170,8 @@ class ToolResolver:
|
||||
"""
|
||||
# Cache availability check
|
||||
if self._praisonai_tools_available is None:
|
||||
self._praisonai_tools_available = importlib.util.find_spec("praisonai_tools") is not None
|
||||
from ._framework_availability import is_available
|
||||
self._praisonai_tools_available = is_available("praisonai_tools")
|
||||
|
||||
if not self._praisonai_tools_available:
|
||||
return None
|
||||
@@ -186,6 +194,25 @@ class ToolResolver:
|
||||
|
||||
return None
|
||||
|
||||
def _resolve_from_wrapper_registry(self, name: str) -> Optional[Callable]:
|
||||
"""Resolve tool from the wrapper ToolRegistry.
|
||||
|
||||
Args:
|
||||
name: Tool name to resolve
|
||||
|
||||
Returns:
|
||||
Callable if found, None otherwise
|
||||
"""
|
||||
if self._registry is None:
|
||||
return None
|
||||
|
||||
tool = self._registry.get_function(name)
|
||||
if tool is not None:
|
||||
logger.debug(f"Resolved '{name}' from wrapper ToolRegistry")
|
||||
return tool
|
||||
|
||||
return None
|
||||
|
||||
def _resolve_from_registry(self, name: str) -> Optional[Callable]:
|
||||
"""Resolve tool from the global tool registry.
|
||||
|
||||
@@ -213,9 +240,10 @@ class ToolResolver:
|
||||
|
||||
Resolution order:
|
||||
1. Local tools.py (backward compat, custom tools)
|
||||
2. praisonaiagents.tools.TOOL_MAPPINGS (built-in)
|
||||
3. praisonai-tools package (external, optional)
|
||||
4. Tool registry (plugins)
|
||||
2. Wrapper ToolRegistry (register_function API)
|
||||
3. praisonaiagents.tools.TOOL_MAPPINGS (built-in)
|
||||
4. praisonai-tools package (external, optional)
|
||||
5. Core SDK tool registry (plugins)
|
||||
|
||||
Args:
|
||||
name: Tool name to resolve
|
||||
@@ -236,17 +264,22 @@ class ToolResolver:
|
||||
logger.debug(f"Resolved '{name}' from local tools.py")
|
||||
return local_tools[name]
|
||||
|
||||
# 2. Check praisonaiagents.tools
|
||||
# 2. Check wrapper ToolRegistry (NEW - ahead of SDK paths)
|
||||
tool = self._resolve_from_wrapper_registry(name)
|
||||
if tool is not None:
|
||||
return tool
|
||||
|
||||
# 3. Check praisonaiagents.tools
|
||||
tool = self._resolve_from_praisonaiagents(name)
|
||||
if tool is not None:
|
||||
return tool
|
||||
|
||||
# 3. Check praisonai-tools package
|
||||
# 4. Check praisonai-tools package
|
||||
tool = self._resolve_from_praisonai_tools(name)
|
||||
if tool is not None:
|
||||
return tool
|
||||
|
||||
# 4. Check tool registry
|
||||
# 5. Check core SDK tool registry
|
||||
tool = self._resolve_from_registry(name)
|
||||
if tool is not None:
|
||||
return tool
|
||||
@@ -314,7 +347,8 @@ class ToolResolver:
|
||||
|
||||
# 3. Add praisonai-tools (if installed)
|
||||
if self._praisonai_tools_available is None:
|
||||
self._praisonai_tools_available = importlib.util.find_spec("praisonai_tools") is not None
|
||||
from ._framework_availability import is_available
|
||||
self._praisonai_tools_available = is_available("praisonai_tools")
|
||||
|
||||
if self._praisonai_tools_available:
|
||||
try:
|
||||
|
||||
@@ -7,22 +7,22 @@ Requires: pip install "praisonai[ui]"
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import praisonaiui as aiui
|
||||
from praisonai.ui._aiui_datastore import PraisonAISessionDataStore
|
||||
from praisonai.integration.host_app import configure_host, create_host_app, is_legacy_host
|
||||
|
||||
# ── Set up datastore bridge ─────────────────────────────────
|
||||
aiui.set_datastore(PraisonAISessionDataStore())
|
||||
configure_host(
|
||||
title="PraisonAI Agents",
|
||||
logo="🤖",
|
||||
pages=["chat", "agents", "memory", "sessions"],
|
||||
theme={"preset": "blue", "dark_mode": True, "radius": "lg"},
|
||||
agent_kwargs={
|
||||
"name": "AgentsRunner",
|
||||
"instructions": "You are an agent runner that coordinates YAML-defined workflows.",
|
||||
"llm": os.getenv("MODEL_NAME", os.getenv("PRAISONAI_MODEL", "gpt-4o-mini")),
|
||||
},
|
||||
)
|
||||
|
||||
# ── Dashboard style ─────────────────────────────────────────
|
||||
aiui.set_style("dashboard")
|
||||
aiui.set_branding(title="PraisonAI Agents", logo="🤖")
|
||||
aiui.set_theme(preset="blue", dark_mode=True, radius="lg")
|
||||
aiui.set_pages([
|
||||
"chat",
|
||||
"agents",
|
||||
"memory",
|
||||
"sessions",
|
||||
])
|
||||
|
||||
# ── Load agents from YAML ───────────────────────────────────
|
||||
def _load_agents_from_yaml():
|
||||
@@ -30,20 +30,25 @@ def _load_agents_from_yaml():
|
||||
agents_file = os.path.join(os.getcwd(), "agents.yaml")
|
||||
if not os.path.exists(agents_file):
|
||||
return []
|
||||
|
||||
|
||||
try:
|
||||
import yaml
|
||||
with open(agents_file, 'r') as f:
|
||||
|
||||
with open(agents_file, "r") as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
|
||||
agents = []
|
||||
if 'agents' in config:
|
||||
for agent_def in config['agents']:
|
||||
if "agents" in config:
|
||||
for agent_def in config["agents"]:
|
||||
agents.append({
|
||||
"name": agent_def.get('name', 'Agent'),
|
||||
"description": agent_def.get('description', 'YAML-defined agent'),
|
||||
"instructions": agent_def.get('instructions', agent_def.get('role', '')),
|
||||
"model": agent_def.get('model', os.getenv("PRAISONAI_MODEL", "gpt-4o-mini")),
|
||||
"name": agent_def.get("name", "Agent"),
|
||||
"description": agent_def.get("description", "YAML-defined agent"),
|
||||
"instructions": agent_def.get(
|
||||
"instructions", agent_def.get("role", "")
|
||||
),
|
||||
"model": agent_def.get(
|
||||
"model", os.getenv("PRAISONAI_MODEL", "gpt-4o-mini")
|
||||
),
|
||||
"icon": "🤖",
|
||||
})
|
||||
return agents
|
||||
@@ -51,13 +56,13 @@ def _load_agents_from_yaml():
|
||||
print(f"⚠️ Failed to load agents.yaml: {e}")
|
||||
return []
|
||||
|
||||
# ── Register YAML agents ────────────────────────────────────
|
||||
|
||||
yaml_agents = _load_agents_from_yaml()
|
||||
if yaml_agents:
|
||||
try:
|
||||
from praisonaiui.features.agents import get_agent_registry
|
||||
|
||||
registry = get_agent_registry()
|
||||
|
||||
for agent_def in yaml_agents:
|
||||
registry.create(agent_def)
|
||||
print(f" ✓ Agent: {agent_def['icon']} {agent_def['name']}")
|
||||
@@ -66,7 +71,6 @@ if yaml_agents:
|
||||
else:
|
||||
print(" ℹ️ No agents.yaml found, using default agents")
|
||||
|
||||
# ── Default agents if no YAML ───────────────────────────────
|
||||
if not yaml_agents:
|
||||
DEFAULT_AGENTS = [
|
||||
{
|
||||
@@ -77,17 +81,18 @@ if not yaml_agents:
|
||||
"icon": "🤖",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
try:
|
||||
from praisonaiui.features.agents import get_agent_registry
|
||||
|
||||
registry = get_agent_registry()
|
||||
|
||||
for agent_def in DEFAULT_AGENTS:
|
||||
registry.create(agent_def)
|
||||
print(f" ✓ Default Agent: {agent_def['icon']} {agent_def['name']}")
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Default agent registration failed: {e}")
|
||||
|
||||
|
||||
@aiui.starters
|
||||
async def get_starters():
|
||||
"""Suggest conversation starters for agents."""
|
||||
@@ -97,43 +102,48 @@ async def get_starters():
|
||||
{"label": "Agent Status", "message": "Show status of all agents", "icon": "📊"},
|
||||
]
|
||||
|
||||
|
||||
@aiui.welcome
|
||||
async def on_welcome():
|
||||
"""Welcome message."""
|
||||
await aiui.say("👋 Welcome to PraisonAI Agents! This interface runs YAML-defined multi-agent workflows.")
|
||||
await aiui.say(
|
||||
"👋 Welcome to PraisonAI Agents! This interface runs YAML-defined multi-agent workflows."
|
||||
)
|
||||
|
||||
# Session-scoped agent cache
|
||||
_agents_cache = {}
|
||||
|
||||
@aiui.reply
|
||||
async def on_message(message: str):
|
||||
"""Handle messages with agent execution."""
|
||||
await aiui.think("Processing...")
|
||||
|
||||
try:
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Create or get cached agent
|
||||
session_id = getattr(aiui.current_session, 'id', 'default')
|
||||
if session_id not in _agents_cache:
|
||||
_agents_cache[session_id] = Agent(
|
||||
name="AgentsRunner",
|
||||
instructions="You are an agent runner that coordinates YAML-defined workflows.",
|
||||
llm=os.getenv("MODEL_NAME", "gpt-4o-mini"),
|
||||
)
|
||||
|
||||
agent = _agents_cache[session_id]
|
||||
result = await agent.achat(str(message))
|
||||
|
||||
# Stream the response
|
||||
response_text = str(result) if result else "No response"
|
||||
words = response_text.split(" ")
|
||||
for i, word in enumerate(words):
|
||||
await aiui.stream_token(word + (" " if i < len(words) - 1 else ""))
|
||||
|
||||
except Exception as e:
|
||||
await aiui.say(f"❌ Error: {e}")
|
||||
if is_legacy_host():
|
||||
_agents_cache = {}
|
||||
|
||||
@aiui.cancel
|
||||
async def on_cancel():
|
||||
await aiui.say("⏹️ Stopped.")
|
||||
@aiui.reply
|
||||
async def on_message(message: str):
|
||||
"""Handle messages with agent execution."""
|
||||
await aiui.think("Processing...")
|
||||
|
||||
try:
|
||||
from praisonaiagents import Agent
|
||||
|
||||
session_id = getattr(aiui.current_session, "id", "default")
|
||||
if session_id not in _agents_cache:
|
||||
_agents_cache[session_id] = Agent(
|
||||
name="AgentsRunner",
|
||||
instructions="You are an agent runner that coordinates YAML-defined workflows.",
|
||||
llm=os.getenv("MODEL_NAME", "gpt-4o-mini"),
|
||||
)
|
||||
|
||||
agent = _agents_cache[session_id]
|
||||
result = await agent.achat(str(message))
|
||||
|
||||
response_text = str(result) if result else "No response"
|
||||
words = response_text.split(" ")
|
||||
for i, word in enumerate(words):
|
||||
await aiui.stream_token(word + (" " if i < len(words) - 1 else ""))
|
||||
|
||||
except Exception as e:
|
||||
await aiui.say(f"❌ Error: {e}")
|
||||
|
||||
@aiui.cancel
|
||||
async def on_cancel():
|
||||
await aiui.say("⏹️ Stopped.")
|
||||
|
||||
|
||||
app = create_host_app()
|
||||
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user