Compare commits

...
18 Commits
Author SHA1 Message Date
praison 445003042e Release v4.6.45 2026-05-21 11:42:37 +01:00
Mervin Praison 1dc61d3946 Merge pull request #1706 from MervinPraison/feat/aiui-integration-host
feat: PraisonAIUI host integration (Patterns B/C) + core protocol bridges
2026-05-21 10:49:26 +01:00
MervinPraison 40bfd66797 docs: auto-update api.md [skip ci] 2026-05-21 09:28:35 +00:00
Mervin Praison 0da4a90c80 Merge pull request #1708 from MervinPraison/claude/issue-1707-20260521-0816
fix: consolidate framework availability detection and add simple Python API
2026-05-21 10:28:04 +01:00
praisonai-triage-agent[bot]andMervin Praison b95a7f6ded fix: address reviewer feedback - explicit arun signature, remove unused imports, add framework validation
- Make arun() signature explicit instead of opaque **kwargs
- Remove unused importlib.util import from inbuilt_tools/__init__.py
- Add trailing newline to _entrypoint.py
- Add framework validation for explicit framework param like CLI does

Co-authored-by: Mervin Praison <[email protected]>
2026-05-21 08:31:41 +00:00
praisonai-triage-agent[bot]andMervinPraison 92953fdbe6 fix: consolidate framework availability detection and add simple Python API (fixes #1707)
- Consolidate framework availability detection in _framework_availability.py
  - Add missing probes for acp, rich, gradio, unsloth
  - Replace ~30 direct find_spec calls with centralized is_available()
  - Update cli/main.py, tool_resolver.py, inbuilt_tools, acp, console.py

- Add simple Python API entry points
  - Create _entrypoint.py with run() and arun() functions
  - Export in __init__.py with lazy loading
  - Use same LLM endpoint resolution as CLI for consistency
  - Enables one-liner: praisonai.run('agents.yaml')

Addresses Gaps 2 and 3 from architectural review.
Gap 1 (dead _run_* methods) requires adapter functionality porting first.

Co-authored-by: MervinPraison <[email protected]>
2026-05-21 08:22:12 +00:00
MervinPraison b3cd7ecfd0 docs: auto-update feature parity trackers [skip ci] 2026-05-21 06:59:29 +00:00
copilot-swe-agent[bot]andMervinPraison 01ea0f5a05 fix: address gemini review findings in tool emitter + usage summary
Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/c2c4c0e0-0cb7-4608-ab5e-3a259f9bd635

Co-authored-by: MervinPraison <[email protected]>
2026-05-21 06:58:47 +00:00
praisonai-triage-agent[bot]andMervin Praison eddcae8d31 fix: replace fragile name mangling with proper stream_emitter property access
- Replace getattr(self, '_Agent__stream_emitter', None) with self.stream_emitter
- Optimize token summary calculation with single-pass algorithm
- Fix inconsistent total_tokens calculation in telemetry protocols

Co-authored-by: Mervin Praison <[email protected]>
2026-05-21 06:55:55 +00:00
praison 71f891003e Add PraisonAIUI host integration layer (Patterns B/C) with core protocol bridges.
Introduces configure_host/build_host_app, optional L1 bridges (hooks, workflows, usage, approvals, schedules), refactors bundled default apps, and adds session/telemetry protocols plus integration tests so aiui can run standalone or wired via set_backend injection.
2026-05-21 07:49:50 +01:00
praison 13750a4ccd Release v4.6.44 2026-05-20 20:45:21 +01:00
Mervin Praison c9e1d1a202 Merge pull request #1704 from MervinPraison/claude/issue-1703-20260520-1507
fix: Agent.achat hanging - unified dispatch missing response handling
2026-05-20 20:43:31 +01:00
praisonai-triage-agent[bot]andMervin Praison 5515290329 refactor: extract shared guardrail validation logic to reduce code duplication
- Extract _validate_with_guardrail() method containing common validation logic
- Simplified _apply_guardrail_with_retry() and _aapply_guardrail_with_retry() to use shared validation
- Reduced code duplication from ~126 lines to ~25 lines of shared core logic
- Maintains identical functionality while improving maintainability

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

Co-authored-by: Mervin Praison <[email protected]>
2026-05-20 19:41:18 +00:00
praisonai-triage-agent[bot]andMervin Praison cb1cefd94b fix: resolve async guardrail retry bug and improve response handling
- Fixed critical bug in _aapply_guardrail_with_retry line 4732
- Changed await self._achat_completion to await self._execute_unified_achat_completion
- _achat_completion expects response object, not message list
- Added response type safety guards (hasattr checks, isinstance fallbacks)
- Prevents AttributeError crashes during async guardrail retries
- Maintains both sync and async guardrail methods for architectural clarity

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

Co-authored-by: Mervin Praison <[email protected]>
2026-05-20 19:04:14 +00:00
praisonai-triage-agent[bot]andMervin Praison a90666a19f fix: resolve async guardrails and response.choices access issues in unified dispatch
- Add proper hasattr guards for response.choices access to prevent crashes when dispatcher returns plain strings
- Fix stale reflection context by properly updating reflection_messages with new responses
- Create async _aapply_guardrail_with_retry method to prevent event loop blocking
- Update unified async dispatch path to use async guardrails while preserving sync guardrails in sync paths
- Resolves critical issues identified in Greptile review while maintaining backward compatibility

Co-authored-by: Mervin Praison <[email protected]>
2026-05-20 16:21:45 +00:00
Mervin Praison 89df701c2a Merge branch 'main' into claude/issue-1703-20260520-1507 2026-05-20 16:51:34 +01:00
praisonai-triage-agent[bot]andMervin Praison 480848c26d fix: implement async self-reflection in unified dispatch to prevent infinite loop
- Add complete self-reflection logic to unified achat dispatch path
- Prevent infinite loop when self.self_reflect=True with unified dispatch
- Mirror sync implementation with proper async/await patterns
- Handle OpenAI client unavailability gracefully for custom LLMs
- Add proper return statements to break while loop
- Maintain reflection limits (min_reflect, max_reflect)
- Include comprehensive error handling and retry logic

Fixes infinite hanging issue for achat with self-reflection enabled.

Co-authored-by: Mervin Praison <[email protected]>
2026-05-20 15:33:16 +00:00
praisonai-triage-agent[bot]andMervinPraison a064a286f3 fix: Agent.achat hanging - unified dispatch missing response handling (fixes #1703)
- Fix execute_tool_async issue in _execute_unified_achat_completion
- Add proper response processing after unified dispatch call
- Mirror sync _chat_impl behavior for content extraction, history update
- Add guardrails, callbacks, and proper return to exit while loop
- Prevents infinite loop by handling response and returning result

Co-authored-by: MervinPraison <[email protected]>
2026-05-20 15:11:24 +00:00
89 changed files with 2445 additions and 510 deletions

No files matched your search

+1 -1
View File
@@ -756,7 +756,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
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.43" \
"praisonai>=4.6.45" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.43" \
"praisonai>=4.6.45" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.43" \
"praisonai>=4.6.45" \
"praisonai[ui]" \
"praisonai[crewai]"
+27
View File
@@ -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)
+19
View File
@@ -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
@@ -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":
@@ -470,7 +474,20 @@ 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."""
session = self._load_session(session_id)
with self._lock:
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)
self._cache[session_id] = session
return self._save_session(session)
def delete_session(self, session_id: str) -> bool:
"""Delete a session completely."""
filepath = self._get_session_path(session_id)
@@ -500,10 +517,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
@@ -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 []
@@ -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,
}
@@ -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]]
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.6.43"
version = "1.6.45"
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."""
@@ -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
+1 -1
View File
@@ -3125,7 +3125,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.6.43"
version = "1.6.45"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -1,6 +1,6 @@
{
"version": "1.5.87",
"lastUpdated": "2026-05-20",
"lastUpdated": "2026-05-21",
"generatedBy": "praisonai._dev.parity.generator",
"sourceOfTruth": "Python SDK (praisonaiagents)",
"status": "PARITY_ACHIEVED",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"version": "1.5.87",
"lastUpdated": "2026-05-20",
"lastUpdated": "2026-05-21",
"generatedBy": "praisonai._dev.parity.generator",
"sourceOfTruth": "Python SDK (praisonaiagents)",
"summary": {
+1 -1
View File
@@ -1,6 +1,6 @@
# Feature Parity Tracker
> **Version:** 1.5.87 | **Last Updated:** 2026-05-20
> **Version:** 1.5.87 | **Last Updated:** 2026-05-21
> **Source of Truth:** Python SDK (praisonaiagents)
## Summary
+31
View File
@@ -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.
+26
View File
@@ -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.
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.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.45.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.45.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
+15 -4
View File
@@ -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
+1 -1
View File
@@ -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',
+73
View File
@@ -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:
+2 -1
View File
@@ -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:
+33 -22
View File
@@ -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,34 @@ 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(),
)
# ── Custom page: Feature Explorer ───────────────────────────
@@ -110,7 +118,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
+6 -1
View File
@@ -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"
@@ -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)
+9 -7
View File
@@ -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
+1 -1
View File
@@ -57,7 +57,7 @@ class CloudDeployer:
file.write("FROM python:3.11-slim\n")
file.write("WORKDIR /app\n")
file.write("COPY . .\n")
file.write("RUN pip install flask praisonai==4.6.43 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==4.6.45 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
@@ -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"}
+4 -2
View File
@@ -163,7 +163,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
@@ -314,7 +315,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()
+65 -60
View File
@@ -7,20 +7,24 @@ 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 Bot",
logo="🤖",
pages=["chat", "sessions"],
theme={"preset": "green", "dark_mode": True, "radius": "lg"},
agent_kwargs={
"name": "PraisonBot",
"instructions": (
"You are a helpful bot assistant. Break down your reasoning into clear steps."
),
"llm": os.getenv("MODEL_NAME", os.getenv("PRAISONAI_MODEL", "gpt-4o-mini")),
},
)
# ── Dashboard style ─────────────────────────────────────────
aiui.set_style("dashboard")
aiui.set_branding(title="PraisonAI Bot", logo="🤖")
aiui.set_theme(preset="green", dark_mode=True, radius="lg")
aiui.set_pages([
"chat",
"sessions",
])
@aiui.starters
async def get_starters():
@@ -32,58 +36,59 @@ async def get_starters():
{"label": "About", "message": "Tell me about yourself", "icon": "ℹ️"},
]
@aiui.welcome
async def on_welcome():
"""Welcome message for bot interface."""
await aiui.say("🤖 Hi! I'm your PraisonAI Bot. I can help with various tasks and show you step-by-step reasoning.")
await aiui.say(
"🤖 Hi! I'm your PraisonAI Bot. I can help with various tasks "
"and show you step-by-step reasoning."
)
# Session-scoped bot cache
_bots_cache = {}
@aiui.reply
async def on_message(message: str):
"""Handle bot interactions with step visualization."""
session_id = getattr(aiui.current_session, 'id', 'default')
# Show initial thinking step
await aiui.think("🤔 Analyzing request...")
try:
from praisonaiagents import Agent
# Create or get cached bot agent
if session_id not in _bots_cache:
_bots_cache[session_id] = Agent(
name="PraisonBot",
instructions="You are a helpful bot assistant. Break down your reasoning into clear steps.",
llm=os.getenv("MODEL_NAME", "gpt-4o-mini"),
)
bot = _bots_cache[session_id]
# Execute with step visualization
await aiui.think("⚙️ Processing...")
result = await bot.achat(str(message))
# Stream the response with step markers
response_text = str(result) if result else "No response from bot"
# Add step visualization
await aiui.say("**🔄 Step 1: Analysis Complete**")
await aiui.think("📤 Generating response...")
# Stream the main response
words = response_text.split(" ")
for i, word in enumerate(words):
await aiui.stream_token(word + (" " if i < len(words) - 1 else ""))
await aiui.say("\n\n**✅ Step 2: Response Generated**")
except Exception as e:
await aiui.say(f"❌ Bot Error: {e}")
await aiui.say("Please check your configuration and try again.")
if is_legacy_host():
_bots_cache = {}
@aiui.cancel
async def on_cancel():
await aiui.say("⏹️ Bot interaction cancelled.")
@aiui.reply
async def on_message(message: str):
"""Handle bot interactions with step visualization."""
session_id = getattr(aiui.current_session, "id", "default")
await aiui.think("🤔 Analyzing request...")
try:
from praisonaiagents import Agent
if session_id not in _bots_cache:
_bots_cache[session_id] = Agent(
name="PraisonBot",
instructions=(
"You are a helpful bot assistant. "
"Break down your reasoning into clear steps."
),
llm=os.getenv("MODEL_NAME", "gpt-4o-mini"),
)
bot = _bots_cache[session_id]
await aiui.think("⚙️ Processing...")
result = await bot.achat(str(message))
response_text = str(result) if result else "No response from bot"
await aiui.say("**🔄 Step 1: Analysis Complete**")
await aiui.think("📤 Generating response...")
words = response_text.split(" ")
for i, word in enumerate(words):
await aiui.stream_token(word + (" " if i < len(words) - 1 else ""))
await aiui.say("\n\n**✅ Step 2: Response Generated**")
except Exception as e:
await aiui.say(f"❌ Bot Error: {e}")
await aiui.say("Please check your configuration and try again.")
@aiui.cancel
async def on_cancel():
await aiui.say("⏹️ Bot interaction cancelled.")
app = create_host_app()
+97 -97
View File
@@ -13,27 +13,24 @@ Run:
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())
# ── Dashboard style, but no sidebar navigation ─────────────
aiui.set_style("dashboard")
aiui.set_dashboard(sidebar=False, page_header=False)
aiui.set_branding(title="PraisonAI Chat", logo="🤖")
aiui.set_theme(preset="blue", dark_mode=True, radius="lg")
aiui.set_pages(["chat"])
# Add external agent settings
try:
from praisonai.ui._external_agents import aiui_settings_entries
external_settings = aiui_settings_entries()
if external_settings:
aiui.set_settings(external_settings)
except ImportError:
# External agents not available
pass
configure_host(
title="PraisonAI Chat",
logo="🤖",
pages=["chat"],
sidebar=False,
page_header=False,
theme={"preset": "blue", "dark_mode": True, "radius": "lg"},
agent_kwargs={
"name": "PraisonAI",
"instructions": (
"You are a helpful assistant. Delegate coding/analysis tasks "
"to external subagents when available."
),
"llm": os.getenv("MODEL_NAME", os.getenv("PRAISONAI_MODEL", "gpt-4o-mini")),
},
)
@aiui.starters
@@ -53,90 +50,93 @@ async def on_welcome():
await aiui.say("👋 Hi! I'm your PraisonAI assistant. Ask me anything!")
# Session-scoped agent cache to avoid cross-user state leaks
_agents_cache = {}
if is_legacy_host():
_agents_cache = {}
@aiui.settings
async def on_settings(new_settings):
# Clear cache for current session when settings change
session_id = getattr(aiui.current_session, 'id', 'default')
if session_id in _agents_cache:
del _agents_cache[session_id]
@aiui.settings
async def on_settings(new_settings):
session_id = getattr(aiui.current_session, "id", "default")
_agents_cache.pop(session_id, None)
def _get_agent(settings: dict | None = None):
session_id = getattr(aiui.current_session, "id", "default")
settings_key = str(sorted((settings or {}).items()))
cache_key = f"{session_id}:{settings_key}"
if cache_key not in _agents_cache:
try:
from praisonaiagents import Agent
from praisonai.ui._external_agents import external_agent_tools
tools = external_agent_tools(
settings or {},
workspace=os.environ.get("PRAISONAI_WORKSPACE", "."),
)
_agents_cache[cache_key] = Agent(
name="PraisonAI",
instructions=(
"You are a helpful assistant. Delegate coding/analysis tasks "
"to external subagents when available."
),
llm=os.getenv("MODEL_NAME", "gpt-4o-mini"),
tools=tools if tools else None,
)
except ImportError:
_agents_cache[cache_key] = None
return _agents_cache[cache_key]
@aiui.reply
async def on_message(message: str, settings: dict | None = None):
"""Stream a response using PraisonAI Agent or fallback to OpenAI."""
await aiui.think("Thinking...")
agent = _get_agent(settings)
if agent is not None:
try:
result = await agent.achat(str(message))
response_text = str(result) if result else ""
words = response_text.split(" ")
for i, word in enumerate(words):
await aiui.stream_token(word + (" " if i < len(words) - 1 else ""))
return
except Exception as e:
import logging
logging.getLogger(__name__).exception("Agent execution failed")
await aiui.say(f"⚠️ Agent error: {e}. Falling back to OpenAI...")
def _get_agent(settings: dict | None = None):
session_id = getattr(aiui.current_session, 'id', 'default')
settings_key = str(sorted((settings or {}).items()))
cache_key = f"{session_id}:{settings_key}"
if cache_key not in _agents_cache:
try:
from praisonaiagents import Agent
from praisonai.ui._external_agents import external_agent_tools
# Get external agent tools based on settings
tools = external_agent_tools(settings or {}, workspace=os.environ.get("PRAISONAI_WORKSPACE", "."))
agent = Agent(
name="PraisonAI",
instructions="You are a helpful assistant. Delegate coding/analysis tasks to external subagents when available.",
llm=os.getenv("MODEL_NAME", "gpt-4o-mini"),
tools=tools if tools else None,
)
_agents_cache[cache_key] = agent
from openai import AsyncOpenAI
except ImportError:
# Fallback to OpenAI if PraisonAI agents not available
_agents_cache[cache_key] = None
return _agents_cache[cache_key]
@aiui.reply
async def on_message(message: str, settings: dict | None = None):
"""Stream a response using PraisonAI Agent or fallback to OpenAI."""
await aiui.think("Thinking...")
# Try PraisonAI Agent first
agent = _get_agent(settings)
if agent is not None:
try:
# Use async call to avoid blocking the event loop
result = await agent.achat(str(message))
response_text = str(result) if result else ""
words = response_text.split(" ")
for i, word in enumerate(words):
await aiui.stream_token(word + (" " if i < len(words) - 1 else ""))
await aiui.say("❌ Please install openai: `pip install openai`")
return
except Exception as e:
import logging
logging.getLogger(__name__).exception("Agent execution failed")
await aiui.say(f"⚠️ Agent error: {e}. Falling back to OpenAI...")
# Fallback to direct OpenAI
try:
from openai import AsyncOpenAI
except ImportError:
await aiui.say("❌ Please install openai: `pip install openai`")
return
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
await aiui.say("❌ Please set OPENAI_API_KEY environment variable.")
return
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
await aiui.say("❌ Please set OPENAI_API_KEY environment variable.")
return
client = AsyncOpenAI(api_key=api_key)
stream = await client.chat.completions.create(
model=os.getenv("PRAISONAI_MODEL", "gpt-4o-mini"),
messages=[
{"role": "system", "content": "You are a helpful, concise assistant. Use markdown for formatting."},
{"role": "user", "content": str(message)},
],
stream=True,
)
client = AsyncOpenAI(api_key=api_key)
stream = await client.chat.completions.create(
model=os.getenv("PRAISONAI_MODEL", "gpt-4o-mini"),
messages=[
{
"role": "system",
"content": "You are a helpful, concise assistant. Use markdown for formatting.",
},
{"role": "user", "content": str(message)},
],
stream=True,
)
async for chunk in stream:
delta = chunk.choices[0].delta
if delta.content:
await aiui.stream_token(delta.content)
async for chunk in stream:
delta = chunk.choices[0].delta
if delta.content:
await aiui.stream_token(delta.content)
@aiui.cancel
async def on_cancel():
await aiui.say("⏹️ Stopped.")
@aiui.cancel
async def on_cancel():
await aiui.say("⏹️ Stopped.")
app = create_host_app()
@@ -0,0 +1,47 @@
"""PraisonAI Unified Dashboard — single host for all sidebar features.
Loaded by ``praisonai unified`` (aiui mode) via ``build_host_app()``.
"""
import os
import praisonaiui as aiui
from praisonai.integration.host_app import configure_host, create_host_app, is_legacy_host
configure_host(
title="PraisonAI Unified Dashboard",
logo="🌟",
pages=[
"chat",
"agents",
"memory",
"knowledge",
"skills",
"sessions",
"usage",
"config",
"logs",
],
agent_kwargs={
"name": "PraisonAI",
"instructions": "You are a helpful assistant for the PraisonAI unified dashboard.",
"llm": os.getenv("PRAISONAI_MODEL", "gpt-4o-mini"),
},
)
@aiui.welcome
async def on_welcome():
await aiui.say("🌟 Welcome to PraisonAI Unified Dashboard!")
await aiui.say("Use the sidebar to access chat, agents, memory, and more.")
if is_legacy_host():
@aiui.reply
async def on_reply(message: str, settings: dict | None = None):
await aiui.think("Processing...")
await aiui.say(f"Unified Dashboard received: {message}")
app = create_host_app()
@@ -9,22 +9,28 @@ Requires: pip install "praisonai[ui]"
"""
import os
import praisonaiui as aiui
from praisonaiui.features.realtime import OpenAIRealtimeManager
from praisonai.ui._aiui_datastore import PraisonAISessionDataStore
# ── Set up datastore bridge and realtime manager ───────────
aiui.set_datastore(PraisonAISessionDataStore())
import praisonaiui as aiui
from praisonai.integration.host_app import configure_host, create_host_app, is_legacy_host
from praisonaiui.features.realtime import OpenAIRealtimeManager
configure_host(
title="PraisonAI Realtime Voice",
logo="🎤",
pages=["chat", "sessions"],
theme={"preset": "red", "dark_mode": True, "radius": "lg"},
agent_kwargs={
"name": "RealtimeAssistant",
"instructions": (
"You are a voice-optimized assistant. "
"Keep responses conversational and concise for voice interaction."
),
"llm": os.getenv("MODEL_NAME", os.getenv("PRAISONAI_MODEL", "gpt-4o-mini")),
},
)
aiui.set_realtime_manager(OpenAIRealtimeManager())
# ── Dashboard style ─────────────────────────────────────────
aiui.set_style("dashboard")
aiui.set_branding(title="PraisonAI Realtime Voice", logo="🎤")
aiui.set_theme(preset="red", dark_mode=True, radius="lg")
aiui.set_pages([
"chat",
"sessions",
])
@aiui.starters
async def get_starters():
@@ -35,6 +41,7 @@ async def get_starters():
{"label": "Features", "message": "What realtime features are available?", "icon": "⚡"},
]
@aiui.welcome
async def on_welcome():
"""Welcome with realtime status."""
@@ -48,43 +55,43 @@ Welcome to PraisonAI's voice-powered realtime chat! Use the microphone button to
- Dashboard with chat history and usage logs
""")
# Session-scoped realtime agent cache
_realtime_cache = {}
@aiui.reply
async def on_message(message: str):
"""Handle realtime interactions via voice or text."""
session_id = getattr(aiui.current_session, 'id', 'default')
await aiui.think("🎤 Processing realtime request...")
try:
from praisonaiagents import Agent
# Create or get cached realtime agent
if session_id not in _realtime_cache:
_realtime_cache[session_id] = Agent(
name="RealtimeAssistant",
instructions="You are a voice-optimized assistant. Keep responses conversational and concise for voice interaction.",
llm=os.getenv("MODEL_NAME", "gpt-4o-mini"),
)
agent = _realtime_cache[session_id]
# Process the message with the agent
result = await agent.achat(str(message))
# Stream response naturally (aiui handles voice output via WebRTC)
response_text = str(result) if result else "I'm sorry, I couldn't process that."
# Stream tokens for smooth output
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"❌ Realtime Error: {e}")
if is_legacy_host():
_realtime_cache = {}
@aiui.cancel
async def on_cancel():
await aiui.say("🔇 Realtime interaction stopped.")
@aiui.reply
async def on_message(message: str):
"""Handle realtime interactions via voice or text."""
session_id = getattr(aiui.current_session, "id", "default")
await aiui.think("🎤 Processing realtime request...")
try:
from praisonaiagents import Agent
if session_id not in _realtime_cache:
_realtime_cache[session_id] = Agent(
name="RealtimeAssistant",
instructions=(
"You are a voice-optimized assistant. "
"Keep responses conversational and concise for voice interaction."
),
llm=os.getenv("MODEL_NAME", "gpt-4o-mini"),
)
agent = _realtime_cache[session_id]
result = await agent.achat(str(message))
response_text = str(result) if result else "I'm sorry, I couldn't process that."
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"❌ Realtime Error: {e}")
@aiui.cancel
async def on_cancel():
await aiui.say("🔇 Realtime interaction stopped.")
app = create_host_app()
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.6.43"
__version__ = "4.6.45"
+1 -1
View File
@@ -12,7 +12,7 @@ dependencies = [
"rich>=13.7",
"markdown>=3.5",
"pyparsing>=3.0.0",
"praisonaiagents>=1.6.43",
"praisonaiagents>=1.6.45",
"python-dotenv>=0.19.0",
"litellm>=1.83.14,<2",
"PyYAML>=6.0",
@@ -0,0 +1,19 @@
"""Cross-repo test — wrapper build_host_app + aiui backend injection."""
from __future__ import annotations
import pytest
pytest.importorskip("praisonaiui")
def test_build_host_injects_backends():
import praisonaiui.backends as backends
from praisonai.integration.host_app import build_host_app
backends.clear_backends()
app = build_host_app(pages=["chat"])
assert app is not None
registered = backends.list_backends()
assert "hooks" in registered
assert "workflows" in registered
@@ -0,0 +1,22 @@
"""Pattern C gateway parity — datastore + backends after bootstrap."""
from __future__ import annotations
import pytest
pytest.importorskip("praisonaiui")
def test_gateway_start_wires_datastore(monkeypatch):
monkeypatch.delenv("PRAISONAI_HOST_LEGACY", raising=False)
import praisonaiui.backends as backends
import praisonaiui.server as srv
from praisonai.integration import host_app
host_app._CONFIGURED = False
backends.clear_backends()
srv._provider = None
host_app.configure_host(pages=["chat"])
assert srv.get_datastore() is not None
assert "hooks" in backends.list_backends() or host_app._CONFIGURED
@@ -0,0 +1,110 @@
"""Integration tests for PraisonAI ↔ PraisonAIUI host bootstrap (Pattern B)."""
from __future__ import annotations
import importlib
import os
import time
import pytest
pytest.importorskip("praisonaiui")
class TestHostAppBootstrap:
def test_is_legacy_host_env(self, monkeypatch):
from praisonai.integration.host_app import is_legacy_host
monkeypatch.delenv("PRAISONAI_HOST_LEGACY", raising=False)
assert is_legacy_host() is False
monkeypatch.setenv("PRAISONAI_HOST_LEGACY", "1")
assert is_legacy_host() is True
def test_configure_host_sets_provider(self, monkeypatch):
monkeypatch.delenv("PRAISONAI_HOST_LEGACY", raising=False)
import praisonaiui.server as srv
from praisonai.integration import host_app
from praisonaiui.providers import PraisonAIProvider
importlib.reload(host_app)
host_app._CONFIGURED = False
srv._provider = None
seen = []
monkeypatch.setattr(srv, "set_provider", lambda p: seen.append(p))
host_app.configure_host(
title="Test Host",
pages=["chat"],
agent_kwargs={"name": "test", "instructions": "test", "llm": "gpt-4o-mini"},
)
assert len(seen) == 1
assert isinstance(seen[0], PraisonAIProvider)
assert srv.get_datastore() is not None
def test_legacy_skips_provider(self, monkeypatch):
monkeypatch.setenv("PRAISONAI_HOST_LEGACY", "true")
import praisonaiui.server as srv
from praisonai.integration import host_app
importlib.reload(host_app)
host_app._CONFIGURED = False
seen = []
def _track(provider):
seen.append(provider)
monkeypatch.setattr(srv, "set_provider", _track)
host_app.configure_host(pages=["chat"])
assert seen == []
def test_build_host_app_returns_starlette(self, monkeypatch):
monkeypatch.delenv("PRAISONAI_HOST_LEGACY", raising=False)
from praisonai.integration import host_app
importlib.reload(host_app)
host_app._CONFIGURED = False
app = host_app.build_host_app(pages=["chat"])
assert app is not None
assert hasattr(app, "routes")
def test_host_import_performance(self):
start = time.perf_counter()
import praisonai.integration.host_app # noqa: F401
elapsed_ms = (time.perf_counter() - start) * 1000
assert elapsed_ms < 2000, f"host_app import took {elapsed_ms:.0f}ms"
def test_hooks_query_bridge(self):
from praisonai.integration.bridges.hooks_query import list_hooks_for_api
hooks = list_hooks_for_api()
assert isinstance(hooks, list)
class TestDefaultAppsLoad:
@pytest.mark.parametrize(
"module_path",
[
"praisonai.ui_chat.default_app",
"praisonai.ui_dashboard.default_app",
"praisonai.claw.default_app",
"praisonai.ui_bot.default_app",
"praisonai.ui_agents.default_app",
"praisonai.ui_realtime.default_app",
],
)
def test_default_app_exports_app(self, module_path, monkeypatch):
monkeypatch.delenv("PRAISONAI_HOST_LEGACY", raising=False)
mod = importlib.import_module(module_path)
assert hasattr(mod, "app")
assert mod.app is not None
@@ -0,0 +1,31 @@
"""Real agentic integration test — LLM via /run (AC-3). Skips without API key."""
from __future__ import annotations
import os
import pytest
from starlette.testclient import TestClient
pytest.importorskip("praisonaiui")
pytestmark = pytest.mark.skipif(
not os.getenv("OPENAI_API_KEY"),
reason="OPENAI_API_KEY required for real agentic test",
)
def test_run_creates_session():
from praisonai.integration.host_app import build_host_app
client = TestClient(build_host_app(pages=["chat"]))
session_id = "agentic-ac3-test"
response = client.post(
"/run",
json={"message": "Reply with exactly: pong", "session_id": session_id},
)
assert response.status_code == 200
sessions = client.get("/sessions").json()
ids = [s.get("id") or s.get("session_id") for s in sessions.get("sessions", sessions)]
assert session_id in ids or any(session_id in str(i) for i in ids)
@@ -0,0 +1,45 @@
"""Multi-agent session isolation test (AC-9)."""
from __future__ import annotations
import pytest
from starlette.testclient import TestClient
pytest.importorskip("praisonaiui")
class _SessionProvider:
name = "mock"
def __init__(self):
self._sessions = {}
async def run(self, message, *, session_id=None, agent_name=None, **kwargs):
sid = session_id or "default"
self._sessions.setdefault(sid, []).append(message)
reply = f"echo:{message}"
yield {"type": "text", "content": reply}
yield {"type": "done", "content": ""}
@pytest.fixture
def isolated_client(monkeypatch):
monkeypatch.delenv("PRAISONAI_HOST_LEGACY", raising=False)
import praisonaiui.server as srv
from praisonai.integration import host_app
host_app._CONFIGURED = False
provider = _SessionProvider()
monkeypatch.setattr(srv, "set_provider", lambda p: setattr(srv, "_provider", p))
app = host_app.build_host_app(pages=["chat"])
srv.set_provider(provider)
return TestClient(app), provider
def test_two_sessions_no_cross_leak(isolated_client):
client, provider = isolated_client
client.post("/run", json={"message": "secret-a", "session_id": "sess-a"})
client.post("/run", json={"message": "secret-b", "session_id": "sess-b"})
assert provider._sessions.get("sess-a") == ["secret-a"]
assert provider._sessions.get("sess-b") == ["secret-b"]
assert "secret-b" not in provider._sessions.get("sess-a", [])
@@ -0,0 +1,45 @@
"""Integration tests — POST /run SSE RunEvent stream (AC-2)."""
from __future__ import annotations
import json
import pytest
from starlette.testclient import TestClient
pytest.importorskip("praisonaiui")
class _MockProvider:
name = "mock"
async def run(self, message, *, session_id=None, agent_name=None, **kwargs):
yield {"type": "text", "content": "Hello SSE"}
yield {"type": "done", "content": ""}
@pytest.fixture
def host_client(monkeypatch):
monkeypatch.delenv("PRAISONAI_HOST_LEGACY", raising=False)
import praisonaiui.server as srv
from praisonai.integration import host_app
host_app._CONFIGURED = False
srv._provider = None
monkeypatch.setattr(srv, "set_provider", lambda p: setattr(srv, "_provider", p))
app = host_app.build_host_app(pages=["chat"])
srv.set_provider(_MockProvider())
return TestClient(app)
def test_run_sse_stream(host_client):
with host_client.stream(
"POST",
"/run",
json={"message": "hi", "session_id": "sse-test"},
headers={"Accept": "text/event-stream"},
) as response:
assert response.status_code == 200
chunks = list(response.iter_text())
body = "".join(chunks)
assert "data:" in body or len(chunks) > 0
+1 -1
View File
@@ -4983,7 +4983,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.6.43"
version = "1.6.45"
source = { directory = "../praisonai-agents" }
dependencies = [
{ name = "aiohttp" },