Compare commits

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

Co-authored-by: Mervin Praison <[email protected]>
2026-05-20 17:45:00 +00:00
296 changed files with 3590 additions and 19217 deletions

No files matched your search

@@ -1,158 +0,0 @@
name: Cross-Repo Integration Tests
on:
push:
branches: [ main ]
paths:
- 'src/praisonai/praisonai/integration/**'
- 'src/praisonai/tests/integration/test_aiui_*'
- 'src/praisonai/pyproject.toml'
pull_request:
branches: [ main ]
paths:
- 'src/praisonai/praisonai/integration/**'
- 'src/praisonai/tests/integration/test_aiui_*'
- 'src/praisonai/pyproject.toml'
workflow_dispatch:
jobs:
cross-repo-integration:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout PraisonAI
uses: actions/checkout@v4
with:
path: PraisonAI
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Checkout PraisonAIUI
uses: actions/checkout@v4
with:
repository: MervinPraison/PraisonAIUI
path: PraisonAIUI
ref: main
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
# Install PraisonAI packages in development mode
cd PraisonAI/src/praisonai-agents
pip install -e .
cd ../praisonai
pip install -e ".[ui,dev]"
# Install PraisonAIUI in development mode
cd ../../../PraisonAIUI
pip install -e .
- name: Verify versions
run: |
python -c "import praisonaiagents; print(f'praisonaiagents: {praisonaiagents.__version__}')"
python -c "import praisonai; print(f'praisonai: {praisonai.__version__}')"
python -c "import praisonaiui; print(f'praisonaiui: {praisonaiui.__version__}')"
- name: Run PraisonAI integration tests
run: |
cd PraisonAI/src/praisonai
python -m pytest tests/integration/test_aiui_* -v --timeout=300
- name: Run PraisonAIUI integration tests
run: |
cd PraisonAIUI
if [ -f tests/integration/test_agentic_roundtrip.py ]; then
python -m pytest tests/integration/test_agentic_roundtrip.py -v --timeout=300
fi
if [ -f tests/test_feature_sdk_backends.py ]; then
python -m pytest tests/test_feature_sdk_backends.py -v --timeout=300
fi
- name: Test Pattern C CLI
run: |
cd PraisonAI/src/praisonai
# Test that ui-gateway command exists
python -m praisonai serve --help | grep ui-gateway
- name: Test public API exports
run: |
cd PraisonAI/src/praisonai
python -c "from praisonai import run_integrated_gateway; print('✓ run_integrated_gateway imported')"
python -c "from praisonai import configure_host; print('✓ configure_host imported')"
- name: Integration smoke test
env:
PRAISONAI_TEST_MODE: "1"
PYTHONPATH: ${{ github.workspace }}/PraisonAI/src/praisonai:${{ github.workspace }}/PraisonAI/src/praisonai-agents:${{ github.workspace }}/PraisonAIUI
run: |
cd PraisonAI/src/praisonai
python -c "
import sys
sys.path.insert(0, '.')
from praisonai.integration.host_app import configure_host
from praisonai import run_integrated_gateway
print('✓ Integration imports successful')
# Test configure_host with new parameters
try:
configure_host(
style='dashboard',
context_paths=['AGENTS.md'],
title='Test App'
)
print('✓ configure_host with new parameters works')
except Exception as e:
print(f'× configure_host failed: {e}')
sys.exit(1)
"
# Optional job with API key for agentic tests (if secret is available)
agentic-integration:
runs-on: ubuntu-latest
timeout-minutes: 30
if: ${{ vars.RUN_AGENTIC_TESTS == 'true' }}
needs: cross-repo-integration
steps:
- name: Checkout PraisonAI
uses: actions/checkout@v4
with:
path: PraisonAI
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Checkout PraisonAIUI
uses: actions/checkout@v4
with:
repository: MervinPraison/PraisonAIUI
path: PraisonAIUI
ref: main
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
cd PraisonAI/src/praisonai-agents
pip install -e .
cd ../praisonai
pip install -e ".[ui,dev]"
cd ../../../PraisonAIUI
pip install -e .
- name: Run agentic integration test
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
cd PraisonAI/src/praisonai
if [ -n "$OPENAI_API_KEY" ]; then
python -m pytest tests/integration/test_aiui_host_agentic.py -v --timeout=600
else
echo "OPENAI_API_KEY not available, skipping agentic tests"
fi
+1 -1
View File
@@ -382,7 +382,7 @@ praisonai flow
Open **http://localhost:7861** — use the **Agent** and **Agent Team** components to create sequential or parallel workflows. Connect Chat Input → Agent Team → Chat Output for instant multi-agent pipelines.
> 📖 [Full Flow docs](https://docs.praison.ai/docs/concepts/agentflow) — visual agent building, component reference, and deployment
> 📖 [Full Flow docs](https://docs.praison.ai/docs/concepts/flow) — visual agent building, component reference, and deployment
### 8. PraisonAI UI 🤖 (Clean Chat)
+1 -28
View File
@@ -40,7 +40,6 @@ Methods:
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">chat_with_context</a>(message: str, context: 'ContextPack', **kwargs) -> str</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">checkpoints</a>() -> Optional[bool]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">checkpoints</a>(value: Optional[bool]) -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">clone_for_channel</a>() -> 'Agent'</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">close</a>() -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">console</a>() -> Optional[Any]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">context_manager</a>() -> Optional[Any]</code>
@@ -172,7 +171,6 @@ Methods:
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">safe_run</a>(**kwargs) -> ToolResult</code>
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">validate</a>() -> bool</code>
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">validate_class</a>() -> bool</code>
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">validate_schema_roundtrip</a>() -> bool</code>
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">__call__</a>(*args, **kwargs) -> Any</code>
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">check_availability</a>() -> tuple[bool, str]</code>
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">injected_params</a>() -> Dict[str, Any]</code>
@@ -479,10 +477,8 @@ Methods:
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">edit_skill</a>(name: str, content: str) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_allowed_tools</a>(name: str) -> List[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_available_skills</a>() -> List[SkillMetadata]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_available_skills_by_state</a>(state: SkillState) -> List[LoadedSkill]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_instructions</a>(name: str) -> Optional[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_skill</a>(name: str) -> Optional[LoadedSkill]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_skills_diagnostics</a>() -> Dict[str, ValidationResult]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_user_invocable_skills</a>() -> List[LoadedSkill]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">invoke</a>(name: str, raw_args: str = '', session_id: Optional[str] = None, shell_exec: bool = False) -> Optional[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">load_resources</a>(name: str) -> bool</code>
@@ -491,7 +487,6 @@ Methods:
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">skill_names</a>() -> List[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">skills</a>() -> List[LoadedSkill]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">to_prompt</a>() -> str</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">validate_skill_capabilities</a>(skill_name: str, force_refresh: bool = False) -> ValidationResult</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">write_skill_file</a>(name: str, file_path: str, file_content: str) -> dict</code>
* <code title="class SkillMetadata">SkillMetadata.<a href="./src/praisonai-agents/praisonaiagents/skills/models.py">from_properties</a>(props: SkillProperties) -> 'SkillMetadata'</code>
* <code title="class SkillProperties">SkillProperties.<a href="./src/praisonai-agents/praisonaiagents/skills/models.py">to_dict</a>() -> dict</code>
@@ -607,13 +602,6 @@ Methods:
* <code title="class ContextManager">ContextManager.<a href="./src/praisonai-agents/praisonaiagents/context/manager.py">set_tool_budget</a>(tool_name: str, max_tokens: int, protected: bool = False) -> None</code>
* <code title="class ContextManager">ContextManager.<a href="./src/praisonai-agents/praisonaiagents/context/manager.py">truncate_tool_output</a>(tool_name: str, output: str) -> str</code>
# UI
Types:
```python
from praisonaiagents import A2UI
```
# Config
Types:
@@ -768,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__, arun, run, run_integrated_gateway
from praisonai import Agent, AgentApp, AgentOS, AnthropicManagedAgent, CloudProvider, DB, Deploy, DeployConfig, DeployType, HostedAgent, HostedAgentConfig, LocalAgent, LocalAgentConfig, LocalManagedAgent, LocalManagedConfig, ManagedAgent, ManagedConfig, PraisonAI, __version__
```
# CLI
@@ -873,7 +861,6 @@ Methods:
* <code title="cli">praisonai doctor packaging <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor performance <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor selftest <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor skills <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor tools <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor troubleshoot <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai endpoints list <a href="./src/praisonai/praisonai/cli/commands/endpoints.py">--help</a></code>
@@ -905,24 +892,11 @@ Methods:
* <code title="cli">praisonai gateway send <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai gateway start <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai gateway status <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai gateway stop <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai gateway uninstall <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai github triage <a href="./src/praisonai/praisonai/cli/commands/github.py">--help</a></code>
* <code title="cli">praisonai hooks add <a href="./src/praisonai/praisonai/cli/commands/hooks.py">--help</a></code>
* <code title="cli">praisonai hooks list <a href="./src/praisonai/praisonai/cli/commands/hooks.py">--help</a></code>
* <code title="cli">praisonai hooks remove <a href="./src/praisonai/praisonai/cli/commands/hooks.py">--help</a></code>
* <code title="cli">praisonai kanban block <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
* <code title="cli">praisonai kanban boards <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
* <code title="cli">praisonai kanban comment <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
* <code title="cli">praisonai kanban complete <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
* <code title="cli">praisonai kanban create <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
* <code title="cli">praisonai kanban dispatch <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
* <code title="cli">praisonai kanban link <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
* <code title="cli">praisonai kanban list <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
* <code title="cli">praisonai kanban move <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
* <code title="cli">praisonai kanban reclaim <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
* <code title="cli">praisonai kanban show <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
* <code title="cli">praisonai kanban unblock <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
* <code title="cli">praisonai knowledge add <a href="./src/praisonai/praisonai/cli/commands/knowledge.py">--help</a></code>
* <code title="cli">praisonai knowledge index <a href="./src/praisonai/praisonai/cli/commands/knowledge.py">--help</a></code>
* <code title="cli">praisonai knowledge list <a href="./src/praisonai/praisonai/cli/commands/knowledge.py">--help</a></code>
@@ -1080,7 +1054,6 @@ Methods:
* <code title="cli">praisonai serve status <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve stop <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve ui <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve ui-gateway <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve unified <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai session delete <a href="./src/praisonai/praisonai/cli/commands/session.py">--help</a></code>
* <code title="cli">praisonai session export <a href="./src/praisonai/praisonai/cli/commands/session.py">--help</a></code>
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.52" \
"praisonai>=4.6.43" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.52" \
"praisonai>=4.6.43" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.52" \
"praisonai>=4.6.43" \
"praisonai[ui]" \
"praisonai[crewai]"
+3 -3
View File
@@ -35,7 +35,7 @@ def run_quick_comparison():
prompt="What is 2+2?",
iterations=2,
paths=["openai_sdk", "praisonai_agent"],
verbose=True
output="verbose"
)
# Print comparison table
@@ -56,7 +56,7 @@ def run_full_benchmark():
report = handler.run_full_benchmark(
prompt="Hi",
iterations=3,
verbose=True
output="verbose"
)
# Print full report
@@ -78,7 +78,7 @@ def run_agent_benchmark():
prompt="Explain Python in one sentence",
iterations=3,
paths=["openai_sdk", "praisonai_agent"],
verbose=True
output="verbose"
)
# Show timeline diagrams
+1 -1
View File
@@ -22,7 +22,7 @@ custom_modes = {
}
# Create handler with verbose output
handler = CompareHandler(verbose=True)
handler = CompareHandler(output="verbose")
# Example: Manually create comparison results for analysis
# (useful for testing without API calls)
+1 -1
View File
@@ -22,7 +22,7 @@ modes = parse_modes("basic,tools,planning")
print(f"\nParsed modes: {modes}")
# Create compare handler
handler = CompareHandler(verbose=True)
handler = CompareHandler(output="verbose")
# Compare modes (this will run actual agent calls)
# Uncomment to run:
@@ -15,7 +15,7 @@ agent = Agent(
# With array [preset, overrides]
agent_override = Agent(
instructions="You are a helpful assistant.",
memory=MemoryConfig(backend="redis", user_id="custom_user"),
memory=["redis", {"user_id": "custom_user"}],
)
if __name__ == "__main__":
+1 -13
View File
@@ -15,20 +15,8 @@ workflow = AgentFlow(
],
output="verbose",
planning=True,
variables={"topic": "AI agents"},
)
if __name__ == "__main__":
import os
import sys
if not os.environ.get("OPENAI_API_KEY"):
print("Set OPENAI_API_KEY to run this example.")
sys.exit(0)
if sys.platform == "win32":
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
result = workflow.run("AI agents")
result = workflow.run(variables={"topic": "AI agents"})
print(result)
+2 -2
View File
@@ -24,7 +24,7 @@ if has_api_key:
input_text="What is 2 + 2?",
expected_output="4",
num_iterations=3, # Run 3 times for statistical significance
verbose=True
output="verbose"
)
# Run evaluation
@@ -42,7 +42,7 @@ else:
input_text="What is 2 + 2?",
expected_output="4",
num_iterations=1,
verbose=True
output="verbose"
)
# Test with mock output
+2 -2
View File
@@ -27,7 +27,7 @@ if has_api_key:
scoring_type="numeric", # Score 1-10
threshold=7.0, # Pass if score >= 7
num_iterations=2,
verbose=True
output="verbose"
)
# Run evaluation
@@ -45,7 +45,7 @@ if has_api_key:
agent=agent,
input_text="Tell me a joke",
scoring_type="binary", # Pass or Fail
verbose=True
output="verbose"
)
binary_result = binary_evaluator.run(print_summary=True)
+1 -1
View File
@@ -26,7 +26,7 @@ if has_api_key:
num_iterations=10, # Run 10 benchmark iterations
warmup_runs=2, # 2 warmup runs before measurement
track_memory=True, # Track memory usage
verbose=True
output="verbose"
)
# Run evaluation
+4 -15
View File
@@ -16,19 +16,8 @@ def search_web(query: str) -> str:
def calculate(expression: str) -> str:
"""Calculate a math expression safely."""
import ast, operator
_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv,
ast.Mod: operator.mod, ast.Pow: operator.pow,
ast.USub: operator.neg, ast.UAdd: operator.pos}
def _ev(n):
if isinstance(n, ast.Expression): return _ev(n.body)
if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)): return n.value
if isinstance(n, ast.UnaryOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.operand))
if isinstance(n, ast.BinOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.left), _ev(n.right))
raise ValueError(f"Unsupported: {ast.dump(n)}")
return str(_ev(ast.parse(expression, mode="eval")))
"""Calculate a math expression."""
return str(eval(expression))
# Check if we have an API key
@@ -48,7 +37,7 @@ if has_api_key:
input_text="Search for the weather in Paris and calculate 25 * 4",
expected_tools=["search_web", "calculate"], # Tools that should be called
forbidden_tools=["delete_file"], # Tools that should NOT be called
verbose=True
output="verbose"
)
# Run evaluation
@@ -70,7 +59,7 @@ evaluator = ReliabilityEvaluator(
agent=mock_agent,
expected_tools=["search_web", "calculate"], # Tools that should be called
forbidden_tools=["delete_file"], # Tools that should NOT be called
verbose=True
output="verbose"
)
result2 = evaluator.evaluate_tool_calls(
+1 -1
View File
@@ -184,7 +184,7 @@ Answer questions based ONLY on the provided knowledge context.
When asked about codes, provide the EXACT code from the documents.
Be concise but accurate.""",
knowledge=[temp_dir],
memory={"user_id": "compression_demo_user"},
user_id="compression_demo_user",
output="verbose",
)
@@ -164,7 +164,7 @@ Answer questions based ONLY on the provided knowledge context.
When asked about department codes, provide the EXACT code from the documents.
Be specific about which department you're referring to.""",
knowledge=[temp_dir],
memory={"user_id": "hierarchy_demo_user"},
user_id="hierarchy_demo_user",
output="verbose",
)
@@ -124,7 +124,7 @@ def main():
# Show strategy selection for different corpus sizes
print(f"\nStrategy selection by corpus size:")
for size in [100, 1000, 10000, 50000, 100000]:
strategy = select_strategy(CorpusStats(file_count=max(1, size // 1000), total_tokens=size))
strategy = select_strategy(corpus_tokens=size)
print(f" {size:>6} tokens -> {strategy.value}")
except ImportError as e:
@@ -142,7 +142,7 @@ Answer questions based ONLY on the provided knowledge context.
When asked about verification codes, provide the EXACT code from the documents.
If information is not in the context, say 'Information not found in documents.'""",
knowledge=[temp_dir],
memory={"user_id": "strategy_demo_user"},
user_id="strategy_demo_user",
output="verbose", # Use new consolidated param
)
+1 -1
View File
@@ -65,7 +65,7 @@ def main():
name="PolicyExpert",
instructions="You are a company policy expert. Answer questions based ONLY on the provided knowledge. Quote exact values when asked about codes or numbers.",
knowledge=[temp_dir], # Add directory with documents
memory={"user_id": "example_user"},
user_id="example_user", # Required for mem0 backend
)
print("\n1. Testing Agent.chat() with context-required question:")
@@ -50,7 +50,7 @@ def main():
name="HR_Assistant_User1",
instructions="You are an HR assistant.",
knowledge=[temp_dir],
memory={"user_id": "user_alice"},
user_id="user_alice", # Knowledge scoped to Alice
)
response = user1_agent.chat("What is the policy on remote work?")
@@ -80,7 +80,7 @@ def main():
name="Personal_HR_Bot",
instructions="You are a personal HR assistant.",
knowledge=[temp_dir],
memory={"user_id": "user_bob"},
user_id="user_bob",
agent_id="hr_bot_v2",
)
@@ -49,7 +49,7 @@ Only authorized personnel with code WRENCH-992 may access this project.
name="AgentA_Ingester",
instructions="You ingest and store knowledge.",
knowledge=[temp_dir],
memory={"user_id": "user_alice"},
user_id="user_alice", # Scoped to Alice
output="silent",
)
@@ -103,7 +103,7 @@ Only authorized personnel with code WRENCH-992 may access this project.
name="AgentB_Querier",
instructions="Answer questions based on the provided knowledge. If you don't have the information, say so.",
knowledge=[temp_dir],
memory={"user_id": "user_alice"},
user_id="user_alice", # Same scope as Agent A
output="silent",
)
+8 -31
View File
@@ -19,43 +19,20 @@ import os
def calculate(expression: str) -> str:
"""
A simple calculator tool that evaluates mathematical expressions safely.
A simple calculator tool that evaluates mathematical expressions.
Args:
expression: A mathematical expression to evaluate (e.g., "2 + 2")
Returns:
The result of the calculation
"""
import ast
import operator
_OPS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.FloorDiv: operator.floordiv,
ast.Mod: operator.mod,
ast.Pow: operator.pow,
ast.USub: operator.neg,
ast.UAdd: operator.pos,
}
def _safe_eval(node):
if isinstance(node, ast.Expression):
return _safe_eval(node.body)
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value
if isinstance(node, ast.UnaryOp) and type(node.op) in _OPS:
return _OPS[type(node.op)](_safe_eval(node.operand))
if isinstance(node, ast.BinOp) and type(node.op) in _OPS:
return _OPS[type(node.op)](_safe_eval(node.left), _safe_eval(node.right))
raise ValueError(f"Unsupported expression: {ast.dump(node)}")
try:
tree = ast.parse(expression, mode="eval")
result = _safe_eval(tree)
# Safe evaluation of mathematical expressions
allowed_chars = set("0123456789+-*/.() ")
if not all(c in allowed_chars for c in expression):
return "Error: Invalid characters in expression"
result = eval(expression)
return f"Result: {result}"
except Exception as e:
return f"Error: {str(e)}"
+6 -12
View File
@@ -24,18 +24,12 @@ def main():
def calculate(expression: str) -> str:
"""Safely evaluate a mathematical expression."""
try:
import ast, operator
_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv,
ast.Mod: operator.mod, ast.Pow: operator.pow,
ast.USub: operator.neg, ast.UAdd: operator.pos}
def _ev(n):
if isinstance(n, ast.Expression): return _ev(n.body)
if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)): return n.value
if isinstance(n, ast.UnaryOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.operand))
if isinstance(n, ast.BinOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.left), _ev(n.right))
raise ValueError(f"Unsupported: {ast.dump(n)}")
return f"Result: {_ev(ast.parse(expression, mode='eval'))}"
# Only allow safe math operations
allowed = set("0123456789+-*/.(). ")
if not all(c in allowed for c in expression):
return "Error: Invalid characters in expression"
result = eval(expression) # Safe due to character filtering
return f"Result: {result}"
except Exception as e:
return f"Error: {e}"
+3 -5
View File
@@ -50,12 +50,10 @@ print("=== Qdrant Knowledge Store Demo (Agent-First) ===")
agent = Agent(
name="KnowledgeAssistant",
instructions="You are a helpful assistant with access to technical documentation.",
knowledge=[doc_path],
knowledge={
"sources": [doc_path],
"vector_store": {
"provider": "qdrant",
"url": "http://localhost:6333",
},
"vector_store": "qdrant",
"url": "http://localhost:6333"
}
)
+2 -29
View File
@@ -21,36 +21,9 @@ def search_web(query: str) -> str:
return f"Search results for: {query}"
def calculate(expression: str) -> str:
"""Calculate a mathematical expression safely."""
import ast
import operator
_OPS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.FloorDiv: operator.floordiv,
ast.Mod: operator.mod,
ast.Pow: operator.pow,
ast.USub: operator.neg,
ast.UAdd: operator.pos,
}
def _eval(node):
if isinstance(node, ast.Expression):
return _eval(node.body)
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value
if isinstance(node, ast.UnaryOp) and type(node.op) in _OPS:
return _OPS[type(node.op)](_eval(node.operand))
if isinstance(node, ast.BinOp) and type(node.op) in _OPS:
return _OPS[type(node.op)](_eval(node.left), _eval(node.right))
raise ValueError(f"Unsupported expression: {ast.dump(node)}")
"""Calculate a mathematical expression."""
try:
tree = ast.parse(expression, mode="eval")
return f"Result: {_eval(tree)}"
return f"Result: {eval(expression)}"
except Exception:
return "Invalid expression"
-27
View File
@@ -1,27 +0,0 @@
#!/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)
@@ -8,7 +8,7 @@ Docs: https://docs.praison.ai/cli/git-integration
from praisonai.cli.features import GitIntegrationHandler
# Initialize
handler = GitIntegrationHandler(verbose=True)
handler = GitIntegrationHandler(output="verbose")
git = handler.initialize(repo_path=".")
# Check if it's a git repo
+1 -1
View File
@@ -8,7 +8,7 @@ Docs: https://docs.praison.ai/cli/repo-map
from praisonai.cli.features import RepoMapHandler
# Initialize with current directory
handler = RepoMapHandler(verbose=True)
handler = RepoMapHandler(output="verbose")
repo_map = handler.initialize(root=".")
# Get the repository map
@@ -9,7 +9,7 @@ from praisonai.cli.features import SandboxExecutorHandler
from praisonai.cli.features.sandbox_executor import SandboxMode
# Initialize with basic sandbox
handler = SandboxExecutorHandler(verbose=True)
handler = SandboxExecutorHandler(output="verbose")
sandbox = handler.initialize(mode="basic")
print(f"Sandbox mode: {handler.get_mode()}")
+2 -1
View File
@@ -25,7 +25,8 @@ pdf_agent = Agent(
goal="Help users understand and extract information from PDF documents",
backstory="You are an expert at reading, analyzing, and answering questions about PDF documents. You provide accurate, detailed answers based on the document content.",
instructions="Read the provided PDF document and answer questions about its content. Be specific and cite relevant sections when possible.",
knowledge={**config, "sources": ["document.pdf"]}, # Replace with your PDF path
knowledge=["document.pdf"], # Replace with your PDF file path
knowledge=config,
reflection=True,
@@ -1,9 +1,6 @@
from pathlib import Path
from praisonaiagents import Agent, Task, AgentTeam
import logging
import os
import sys
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
@@ -20,18 +17,14 @@ config = {
}
}
_sample_path = Path(__file__).with_name("sample_knowledge.txt")
if not _sample_path.exists():
_sample_path.write_text("Mervin Praison is the creator of PraisonAI.\n", encoding="utf-8")
# Create an agent with knowledge capabilities
knowledge_agent = Agent(
name="KnowledgeAgent",
role="Information Specialist",
goal="Store and retrieve knowledge efficiently",
backstory="Expert in managing and utilizing stored knowledge",
knowledge={**config, "sources": [str(_sample_path)]},
)
knowledge=["sample.pdf"],
knowledge=config)
# Define a task for the agent
knowledge_task = Task(
@@ -46,16 +39,8 @@ agents = AgentTeam(
agents=[knowledge_agent],
tasks=[knowledge_task],
process="sequential",
user_id="user1"
)
if __name__ == "__main__":
if not os.environ.get("OPENAI_API_KEY"):
print("Set OPENAI_API_KEY to run this example.")
sys.exit(0)
if sys.platform == "win32":
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
result = agents.start()
print(result)
# Start execution
result = agents.start()
@@ -138,7 +138,8 @@ def main():
agent = Agent(
name="AI Research Assistant",
instructions="You are an AI research assistant. Use your knowledge base to answer questions about artificial intelligence topics.",
knowledge={**rerank_config, "sources": sample_documents},
knowledge=sample_documents, # Will use default knowledge config
knowledge=rerank_config, # Use reranking config
llm="gpt-4o-mini"
)
+3 -7
View File
@@ -32,12 +32,8 @@ rag_task = Task(
agents = AgentTeam(
agents=[rag_agent],
tasks=[rag_task],
user_id="user1"
)
if __name__ == "__main__":
import os
import sys
if not os.environ.get("OPENAI_API_KEY"):
print("Set OPENAI_API_KEY to run this example.")
sys.exit(0)
agents.start()
# Start Agents
agents.start()
@@ -49,18 +49,7 @@ def calculate_with_tracking(expression: str) -> str:
time.sleep(0.05)
try:
import ast, operator
_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv,
ast.Mod: operator.mod, ast.Pow: operator.pow,
ast.USub: operator.neg, ast.UAdd: operator.pos}
def _ev(n):
if isinstance(n, ast.Expression): return _ev(n.body)
if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)): return n.value
if isinstance(n, ast.UnaryOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.operand))
if isinstance(n, ast.BinOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.left), _ev(n.right))
raise ValueError(f"Unsupported: {ast.dump(n)}")
result = _ev(ast.parse(expression, mode="eval"))
result = eval(expression)
return f"Result: {result}"
except Exception as e:
return f"Error: {str(e)}"
+3 -3
View File
@@ -5,7 +5,7 @@ import os
def main():
# Initialize memory config
memory_config = {
"backend": "sqlite",
"provider": "rag",
"use_embedding": True,
"storage": {
"type": "sqlite",
@@ -105,13 +105,13 @@ def main():
agents=[researcher, retriever],
tasks=[store_task, verify_task, query_task, query_both_task], # Use same verbose level as memory
memory={
"provider": "rag",
"embedder": {
"provider": "openai",
"config": {
"model": "text-embedding-3-small"
}
},
"config": {"provider": "sqlite"},
}
}
)
+4 -12
View File
@@ -36,18 +36,10 @@ def calculate(expression: str) -> dict:
expression: Math expression (e.g., "2 + 2 * 3")
"""
try:
import ast, operator
_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv,
ast.Mod: operator.mod, ast.Pow: operator.pow,
ast.USub: operator.neg, ast.UAdd: operator.pos}
def _ev(n):
if isinstance(n, ast.Expression): return _ev(n.body)
if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)): return n.value
if isinstance(n, ast.UnaryOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.operand))
if isinstance(n, ast.BinOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.left), _ev(n.right))
raise ValueError(f"Unsupported: {ast.dump(n)}")
return {"expression": expression, "result": _ev(ast.parse(expression, mode="eval"))}
allowed = set("0123456789+-*/.() ")
if not all(c in allowed for c in expression):
return {"error": "Invalid characters"}
return {"expression": expression, "result": eval(expression)}
except Exception as e:
return {"error": str(e)}
+6 -12
View File
@@ -47,18 +47,12 @@ def calculate(expression: str) -> Dict[str, Any]:
The result of the calculation
"""
try:
import ast, operator
_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv,
ast.Mod: operator.mod, ast.Pow: operator.pow,
ast.USub: operator.neg, ast.UAdd: operator.pos}
def _ev(n):
if isinstance(n, ast.Expression): return _ev(n.body)
if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)): return n.value
if isinstance(n, ast.UnaryOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.operand))
if isinstance(n, ast.BinOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.left), _ev(n.right))
raise ValueError(f"Unsupported: {ast.dump(n)}")
result = _ev(ast.parse(expression, mode="eval"))
# Safe evaluation of mathematical expressions
allowed_chars = set("0123456789+-*/.() ")
if not all(c in allowed_chars for c in expression):
return {"error": "Invalid characters in expression"}
result = eval(expression) # Use safer alternative in production
return {"expression": expression, "result": result}
except Exception as e:
return {"error": str(e)}
+7 -12
View File
@@ -49,18 +49,13 @@ def calculate(expression: str) -> Dict[str, Any]:
The result of the calculation
"""
try:
import ast, operator
_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv,
ast.Mod: operator.mod, ast.Pow: operator.pow,
ast.USub: operator.neg, ast.UAdd: operator.pos}
def _ev(n):
if isinstance(n, ast.Expression): return _ev(n.body)
if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)): return n.value
if isinstance(n, ast.UnaryOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.operand))
if isinstance(n, ast.BinOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.left), _ev(n.right))
raise ValueError(f"Unsupported: {ast.dump(n)}")
result = _ev(ast.parse(expression, mode="eval"))
# Safe evaluation of mathematical expressions
# In production, use a proper math parser
allowed_chars = set("0123456789+-*/.() ")
if not all(c in allowed_chars for c in expression):
return {"error": "Invalid characters in expression"}
result = eval(expression) # Note: Use safer alternative in production
return {"expression": expression, "result": result}
except Exception as e:
return {"error": str(e)}
@@ -12,8 +12,8 @@ print("=== Advanced Graph Memory Integration Example ===\n")
# Configure simple graph memory
memory_config = {
"provider": "mem0",
"config": {
"provider": "mem0",
"vector_store": {
"provider": "chroma",
"config": {
@@ -60,8 +60,8 @@ query_task = Task(
agents_system = AgentTeam(
agents=[knowledge_agent],
tasks=[build_task, query_task],
memory=memory_config,
output="verbose",
memory=True,
memory=memory_config, output="verbose"
)
print("Starting graph memory demonstration...")
@@ -12,8 +12,8 @@ print("=== Advanced Memory Search and Optimization Example ===\n")
# Configure optimized memory settings
memory_config = {
"provider": "mem0",
"config": {
"provider": "mem0",
"vector_store": {
"provider": "chroma",
"config": {
@@ -61,8 +61,8 @@ search_task = Task(
agents_system = AgentTeam(
agents=[research_agent],
tasks=[build_memory_task, search_task],
memory=memory_config,
output="verbose",
memory=True,
memory=memory_config, output="verbose"
)
print("Starting memory search optimization demonstration...")
@@ -86,13 +86,13 @@ def main():
agents=[researcher, retriever],
tasks=[store_task, retrieve_task],
memory={
"provider": "rag",
"embedder": {
"provider": "gemini",
"config": {
"model": "text-embedding-004",
}
},
"config": {"provider": "sqlite"},
}
}
)
+2 -1
View File
@@ -152,7 +152,8 @@ def main():
agents_system = AgentTeam(
agents=[researcher],
tasks=[build_knowledge_task, query_knowledge_task, expand_knowledge_task],
memory=memory_config,
memory=True,
memory=memory_config
)
print("\n🚀 Starting graph memory demonstration...")
@@ -30,7 +30,7 @@ def main():
try:
# Create memory instance
memory = Memory(config={
"backend": "sqlite",
"provider": "rag",
"use_embedding": False,
"agent_name": "demo_agent", # Used in trace events
})
@@ -32,8 +32,9 @@ def init_agent():
return Agent(
name="Knowledge Agent",
instructions="You answer questions based on the provided knowledge.",
knowledge={**config, "sources": ["kag-research-paper.pdf"]},
memory={"user_id": "user1"},
knowledge=["kag-research-paper.pdf"],
knowledge=config,
user_id="user1",
llm="deepseek-r1"
)
@@ -30,12 +30,9 @@ config = {
agent = Agent(
name="Knowledge Agent",
instructions="You answer questions based on the provided knowledge.",
knowledge={
"sources": ["kag-research-paper.pdf"],
"vector_store": config["vector_store"],
"embedder": config["embedder"],
},
memory={"user_id": "user1"},
knowledge=["kag-research-paper.pdf"], # Indexing
knowledge=config,
user_id="user1",
llm="deepseek-r1"
)
@@ -189,8 +189,8 @@ def main():
business_system = AgentTeam(
agents=[data_manager, knowledge_curator, business_analyst, customer_service],
tasks=business_tasks,
memory=mongodb_memory_config,
output="verbose",
memory=True,
memory=mongodb_memory_config, output="verbose"
)
# Execute the comprehensive business pipeline
@@ -55,10 +55,8 @@ def main():
retrieve information from a comprehensive MongoDB knowledge base. You excel
at finding relevant information, synthesizing knowledge from multiple sources,
and providing accurate, context-aware responses.""",
knowledge={
**mongodb_knowledge_config,
"sources": [os.path.join(os.path.dirname(__file__), "llms.md")],
},
knowledge=mongodb_knowledge_config,
knowledge=[os.path.join(os.path.dirname(__file__), "llms.md")],
memory=True,
llm="gpt-4o-mini"
)
@@ -28,8 +28,8 @@ if not os.environ.get("OPENAI_API_KEY"):
def main():
# MongoDB memory configuration
mongodb_memory_config = {
"provider": "mongodb",
"config": {
"provider": "mongodb",
"connection_string": "mongodb://localhost:27017/", # Replace with your MongoDB connection string
"database": "praisonai_memory",
"use_vector_search": True, # Enable Atlas Vector Search (requires MongoDB Atlas)
@@ -93,8 +93,8 @@ def main():
research_system = AgentTeam(
agents=[research_agent],
tasks=research_tasks,
memory=mongodb_memory_config,
output="verbose",
memory=True,
memory=mongodb_memory_config, output="verbose"
)
# Execute the research pipeline
+1 -1
View File
@@ -50,7 +50,7 @@ agent = Agent(
name="TechSupport",
instructions="You are a technical support assistant.",
knowledge=[sample_doc],
memory={"user_id": "comparison_user"},
user_id="comparison_user",
llm="openai/gpt-4o-mini",
output="minimal",
)
+3 -4
View File
@@ -20,9 +20,8 @@ if not os.environ.get("OPENAI_API_KEY"):
exit(1)
# Create a sample document for testing
from pathlib import Path
sample_doc = str(Path(__file__).with_name("sample_doc.txt"))
with open(sample_doc, "w", encoding="utf-8") as f:
sample_doc = "/tmp/sample_doc.txt"
with open(sample_doc, "w") as f:
f.write("""
PraisonAI Framework Documentation
@@ -45,7 +44,7 @@ agent = Agent(
name="DocBot",
instructions="You are a helpful documentation assistant. Answer questions based on the provided context.",
knowledge=[sample_doc],
memory={"user_id": "example_user"},
user_id="example_user", # Required for RAG retrieval
llm="openai/gpt-4o-mini",
output="minimal",
)
+1 -1
View File
@@ -38,7 +38,7 @@ agent = Agent(
name="PolicyBot",
instructions="You are a customer service assistant.",
knowledge=[sample_doc],
memory={"user_id": "policy_user"},
user_id="policy_user",
llm="openai/gpt-4o-mini",
output="minimal",
)
+1 -1
View File
@@ -44,7 +44,7 @@ agent = Agent(
name="MLExpert",
instructions="You are a machine learning expert. Answer questions based on the provided context.",
knowledge=[sample_doc],
memory={"user_id": "rag_user"},
user_id="rag_user",
llm="openai/gpt-4o-mini",
output="minimal",
)
+1 -1
View File
@@ -59,7 +59,7 @@ agent = Agent(
name="APIDocBot",
instructions="You are an API documentation assistant.",
knowledge=[sample_doc],
memory={"user_id": "hybrid_user"},
user_id="hybrid_user",
llm="openai/gpt-4o-mini",
output="minimal",
)
+1 -1
View File
@@ -44,7 +44,7 @@ agent = Agent(
name="ResearchBot",
instructions="You are a research assistant. Always cite your sources.",
knowledge=[doc1, doc2],
memory={"user_id": "citation_user"},
user_id="citation_user",
llm="openai/gpt-4o-mini",
output="minimal",
)
@@ -172,7 +172,7 @@ print("="*70)
# Create sessions for different users working on the same project
reviewer_session = Session(
session_id="peer_review_001",
user_id="user_reviewer_01",
user_id="user_reviewer_01", # Different user
storage_path=session_dir
)
@@ -14,12 +14,13 @@ def main():
# Initialize memory with quality-based configuration
memory_config = {
"backend": "sqlite",
"user_id": "quality_demo_user",
"provider": "rag",
"use_embedding": True,
"rag_db_path": "quality_memory_db"
}
memory = Memory(config=memory_config, verbose=5)
print(f"📦 Initialized memory system with backend: {memory_config['backend']}")
print(f"📦 Initialized memory system with provider: {memory_config['provider']}")
# Create agent with memory
agent = Agent(
@@ -27,6 +28,7 @@ def main():
role="Information Quality Specialist",
instructions="Store and retrieve information with quality assessment",
memory=memory,
user_id="quality_demo_user"
)
print(f"🤖 Created agent: {agent.name}")
+1 -1
View File
@@ -15,7 +15,7 @@ def main():
# Create a session with persistent storage
session = Session(
session_id="demo_chat_001",
user_id="demo_user",
user_id="demo_user"
)
print(f"📝 Created session: {session}")
@@ -88,8 +88,9 @@ def main():
workflow = AgentTeam(
agents=[researcher, analyzer, writer],
tasks=[research_task, analysis_task, report_task],
memory={"user_id": "research_project_001"},
memory=True,
process="workflow",
user_id="research_project_001",
)
print(f"🤖 Created workflow with {len(workflow.agents)} agents and {len(workflow.tasks)} tasks")
@@ -14,7 +14,7 @@ def main():
goal="Find information about a topic",
backstory="You are an expert researcher who finds comprehensive information.",
llm="gpt-4o-mini", # Use a cost-effective model for examples
output={"metrics": True} # Token metrics via consolidated output config
metrics=True # 🎯 SIMPLIFIED: Just add this parameter!
)
# Create a simple task
@@ -14,7 +14,7 @@ def main():
goal="Analyze data and provide insights",
backstory="You are an experienced data analyst with expertise in market research.",
llm="gpt-4o-mini",
output={"metrics": True}
metrics=True # 🎯 Enable metrics tracking
)
writer_agent = Agent(
@@ -23,7 +23,7 @@ def main():
goal="Create well-structured content",
backstory="You are a skilled technical writer who creates clear, engaging content.",
llm="gpt-4o-mini",
output={"metrics": True}
metrics=True # 🎯 Enable metrics tracking
)
# Create multiple tasks
@@ -14,7 +14,7 @@ def main():
goal="Conduct comprehensive market research",
backstory="You are a seasoned market researcher with 10+ years of experience.",
llm="gpt-4o-mini",
output={"metrics": True}
metrics=True # 🎯 Enable automatic metrics tracking
)
strategy_agent = Agent(
@@ -23,7 +23,7 @@ def main():
goal="Develop actionable business strategies",
backstory="You are a strategic consultant who translates research into business plans.",
llm="gpt-4o-mini",
output={"metrics": True}
metrics=True # 🎯 Enable automatic metrics tracking
)
# Create tasks that will generate substantial token usage
@@ -32,8 +32,9 @@ def init_agent():
return Agent(
name="Knowledge Agent",
instructions="You answer questions based on the provided knowledge.",
knowledge={**config, "sources": ["kag-research-paper.pdf"]},
memory={"user_id": "user1"},
knowledge=["kag-research-paper.pdf"],
knowledge=config,
user_id="user1",
llm="deepseek-r1"
)
@@ -264,12 +264,13 @@ agents = AgentTeam(
tasks=[tool_query_task, pentester_analysis_task, ciso_review_task],
process="hierarchical",
manager_llm="gpt-4o-mini",
memory=True, # Enable shared memory for domain context
memory={
"backend": "sqlite",
"provider": "rag",
"config": {
"collection_name": f"{domain.replace('.', '_')}_security_analysis"
},
},
}
}
)
# Main Execution Workflow
@@ -5,7 +5,7 @@ Demonstrates using should_run to conditionally execute agent steps
based on input or previous results.
"""
from praisonaiagents import Agent, AgentFlow, Task
from praisonaiagents import Agent, Workflow, Task
# Condition functions
def is_technical(ctx) -> bool:
+1 -1
View File
@@ -207,7 +207,7 @@ def api_augmented_rag():
instructions="""You are a financial assistant with access to market APIs.
Provide accurate financial information and context.
Always note that prices are subject to change.""",
memory={"user_id": "api_demo"},
user_id="api_demo"
)
queries = [
-222
View File
@@ -1,222 +0,0 @@
# Multi-Channel Bot Deployment Guide
This guide explains how to set up multiple bots on the same platform (e.g., multiple Telegram bots) for different roles using PraisonAI, similar to Hermes-style AI workforces.
## Overview
PraisonAI supports running multiple bots on the same platform, each routing to different agents with specialized knowledge and capabilities. This is useful for:
- **Role-based bots**: `@company_cfo_bot`, `@company_ops_bot`, `@company_content_bot`
- **Department-specific agents**: Each with different knowledge brains and tool access
- **Workflow separation**: Different bots handle different types of requests
## Quick Start with Onboard Wizard
The easiest way to set up multiple channels is using the onboard wizard:
```bash
praisonai onboard
```
The wizard now supports:
1. **Multi-channel setup**: Add multiple bots for the same platform
2. **Role-based naming**: Automatically generates `TELEGRAM_<ROLE>_BOT_TOKEN` environment variables
3. **Agent routing**: Each channel routes to a different agent
### Onboard Wizard Flow
1. **Choose platform**: Start with your first platform (e.g., telegram)
2. **Add more channels**: Wizard asks "Add another bot channel?"
3. **Specify role**: Enter role name (e.g., "cfo", "ops", "content")
4. **Configure tokens**: Each channel gets its own environment variable
5. **Test connections**: Wizard tests each bot separately
## Environment Variable Convention
For multiple bots on the same platform, use this naming convention:
| Channel | Environment Variable | Bot Purpose |
|---------|---------------------|-------------|
| `telegram_cfo` | `TELEGRAM_CFO_BOT_TOKEN` | CFO-related tasks |
| `telegram_ops` | `TELEGRAM_OPS_BOT_TOKEN` | Operations tasks |
| `telegram_content` | `TELEGRAM_CONTENT_BOT_TOKEN` | Content creation |
| `telegram_support` | `TELEGRAM_SUPPORT_BOT_TOKEN` | Customer support |
### Setting Up Tokens
1. **Create bots in @BotFather**:
```text
/newbot
Bot name: Company CFO Bot
Username: @company_cfo_bot
-> Copy token
/newbot
Bot name: Company Ops Bot
Username: @company_ops_bot
-> Copy token
```
2. **Set environment variables**:
```bash
export TELEGRAM_CFO_BOT_TOKEN="123456789:ABC..."
export TELEGRAM_OPS_BOT_TOKEN="987654321:DEF..."
export TELEGRAM_CONTENT_BOT_TOKEN="456789123:GHI..."
```
3. **Or use `~/.praisonai/.env`**:
```env
TELEGRAM_CFO_BOT_TOKEN=123456789:ABC...
TELEGRAM_OPS_BOT_TOKEN=987654321:DEF...
TELEGRAM_CONTENT_BOT_TOKEN=456789123:GHI...
TELEGRAM_ALLOWED_USERS=123456789,987654321
```
## Example Configuration
See `multi-telegram-hermes-workforce.yaml` for a complete example.
### Key Features
1. **Multiple Channels**:
```yaml
channels:
telegram_cfo:
platform: telegram
token: ${TELEGRAM_CFO_BOT_TOKEN}
routes:
default: cfo
telegram_ops:
platform: telegram
token: ${TELEGRAM_OPS_BOT_TOKEN}
routes:
default: ops
```
2. **Specialized Agents**:
```yaml
agents:
cfo:
instructions: "You are a CFO agent. Help with financial analysis..."
model: gpt-4o-mini
ops:
instructions: "You are an ops agent. Help with system monitoring..."
model: gpt-4o-mini
```
## Validation with Doctor
Use the doctor command to validate your multi-channel setup:
```bash
praisonai doctor
```
The doctor checks for:
- ✅ **Unique tokens**: Each channel uses a different bot token
- ✅ **Naming convention**: Follows `PLATFORM_ROLE_BOT_TOKEN` pattern
- ✅ **Missing tokens**: All referenced environment variables are set
- ❌ **Duplicate tokens**: Same token used by multiple channels (fails)
## Deployment
### Start Gateway
```bash
# Start the multi-bot gateway
praisonai gateway start --config multi-telegram-hermes-workforce.yaml
# Or install as daemon
praisonai onboard # Wizard installs daemon automatically
praisonai gateway status
```
### Verify Setup
1. **Check health**: `curl http://127.0.0.1:8765/health`
2. **View dashboard**: `praisonai claw` → http://127.0.0.1:8082
3. **Test bots**: Send messages to each bot to verify routing
## Common Patterns
### Basic Multi-Role Setup
```yaml
# Single platform, multiple roles
channels:
telegram_support:
token: ${TELEGRAM_SUPPORT_BOT_TOKEN}
routes:
default: support_agent
telegram_sales:
token: ${TELEGRAM_SALES_BOT_TOKEN}
routes:
default: sales_agent
```
### Cross-Platform Workforce
```yaml
# Multiple platforms, multiple roles
channels:
telegram_cfo:
platform: telegram
token: ${TELEGRAM_CFO_BOT_TOKEN}
routes:
default: cfo
discord_ops:
platform: discord
token: ${DISCORD_OPS_BOT_TOKEN}
routes:
default: ops
slack_content:
platform: slack
token: ${SLACK_CONTENT_BOT_TOKEN}
routes:
default: content
```
## Troubleshooting
### Common Issues
1. **"Token is used by multiple channels"**
- Each bot needs its own unique token from @BotFather
- Check environment variables for duplicates
2. **"Channel skipped - no token"**
- Set the environment variable: `export TELEGRAM_CFO_BOT_TOKEN="..."`
- Or update `~/.praisonai/.env`
3. **"Bot not responding"**
- Verify token is correct: `praisonai doctor`
- Check bot is not already running elsewhere
- Ensure allowlist includes your user ID
### Debugging Commands
```bash
# Check configuration
praisonai doctor
# View logs
praisonai gateway logs
# Test connection
curl -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN" \
http://127.0.0.1:8765/info
```
## Migration from Single-Bot Setup
If you currently have a single bot setup, you can add more channels:
1. **Run onboard wizard**: `praisonai onboard`
2. **Add new channels**: Wizard will preserve existing setup
3. **Update configuration**: New channels added to existing `bot.yaml`
4. **Restart gateway**: `praisonai gateway restart`
The wizard preserves backward compatibility with single-bot configurations.
-19
View File
@@ -1,19 +0,0 @@
# 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
-40
View File
@@ -1,40 +0,0 @@
# Example: Agent with MCP Tool Integration
#
# This example shows how to configure an agent with MCP (Model Context Protocol)
# servers for additional tool capabilities.
#
# Usage:
# praisonai workflow run --file examples/yaml/agent-with-mcp.yaml
agent:
name: mcp_assistant
instructions: |
You are a helpful assistant with access to filesystem and time tools.
Use the MCP tools when needed to help users with file operations or time queries.
llm: gpt-4o-mini
# MCP server configuration
mcp:
servers:
filesystem:
# Filesystem access for the agent
command: npx
args:
- "-y"
- "@modelcontextprotocol/server-filesystem"
- "."
env: {}
enabled: true
time:
# Time and date utilities
command: npx
args:
- "-y"
- "@modelcontextprotocol/server-time"
env: {}
enabled: true
# NOTE: Per-server tool filtering (tools.include/exclude) is not yet supported
# by the MCP server config schema. Tool filtering should be applied after
# calling mcp.get_tools() in your application code.
@@ -1,66 +0,0 @@
# Multi-Telegram Channel Hermes Workforce Example
# Generated by `praisonai onboard` with multi-channel support
# Start with: praisonai gateway start --config multi-telegram-hermes-workforce.yaml
gateway:
host: "127.0.0.1"
port: 8765
# Legacy single-agent block for backward compatibility
agent:
name: "assistant"
instructions: "You are a helpful AI assistant. You can search the web, manage schedules, and remember past conversations. Use your tools proactively."
model: "gpt-4o-mini"
# Multi-agent workforce with different specialized roles
agents:
cfo:
instructions: "You are a CFO agent. You are a helpful AI assistant. Use your tools proactively to help with CFO-related tasks like financial analysis, budget planning, and cost optimization."
model: gpt-4o-mini
memory: false
ops:
instructions: "You are an ops agent. You are a helpful AI assistant. Use your tools proactively to help with ops-related tasks like system monitoring, deployment, and infrastructure management."
model: gpt-4o-mini
memory: false
content:
instructions: "You are a content agent. You are a helpful AI assistant. Use your tools proactively to help with content-related tasks like writing, editing, and content strategy."
model: gpt-4o-mini
memory: false
# Multiple Telegram channels - each with unique tokens and routing
channels:
telegram_cfo:
platform: telegram
token: ${TELEGRAM_CFO_BOT_TOKEN}
group_policy: mention_only
allowed_users: ${TELEGRAM_ALLOWED_USERS}
# auto_approve_tools: true
# Chat bots cannot show CLI approval prompts; safe tools auto-approved by default.
routes:
default: cfo
telegram_ops:
platform: telegram
token: ${TELEGRAM_OPS_BOT_TOKEN}
group_policy: mention_only
allowed_users: ${TELEGRAM_ALLOWED_USERS}
# auto_approve_tools: true
# Chat bots cannot show CLI approval prompts; safe tools auto-approved by default.
routes:
default: ops
telegram_content:
platform: telegram
token: ${TELEGRAM_CONTENT_BOT_TOKEN}
group_policy: mention_only
allowed_users: ${TELEGRAM_ALLOWED_USERS}
# auto_approve_tools: true
# Chat bots cannot show CLI approval prompts; safe tools auto-approved by default.
routes:
default: content
# Legacy routing block
routing:
default: assistant
+1 -2
View File
@@ -95,5 +95,4 @@ chroma_db/
.windsurf
.ruff_cache
*.sfdx
.sfdx/
.cursor/
.sfdx/
@@ -79,20 +79,10 @@ def get_time(timezone: Literal["est", "pst"]) -> str:
def calculate(expression: str) -> str:
"""Calculate a math expression safely."""
"""Calculate a math expression."""
try:
import ast, operator
_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv,
ast.Mod: operator.mod, ast.Pow: operator.pow,
ast.USub: operator.neg, ast.UAdd: operator.pos}
def _ev(n):
if isinstance(n, ast.Expression): return _ev(n.body)
if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)): return n.value
if isinstance(n, ast.UnaryOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.operand))
if isinstance(n, ast.BinOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.left), _ev(n.right))
raise ValueError(f"Unsupported: {ast.dump(n)}")
return f"Result: {_ev(ast.parse(expression, mode='eval'))}"
result = eval(expression)
return f"Result: {result}"
except Exception:
return "Error: Invalid expression"
@@ -343,7 +343,6 @@ _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'),
@@ -384,7 +383,6 @@ _LAZY_IMPORTS = {
'MultiAgentExecutionConfig': ('praisonaiagents.config.feature_configs', 'MultiAgentExecutionConfig'),
'MultiAgentPlanningConfig': ('praisonaiagents.config.feature_configs', 'MultiAgentPlanningConfig'),
'MultiAgentMemoryConfig': ('praisonaiagents.config.feature_configs', 'MultiAgentMemoryConfig'),
'ToolSearchConfig': ('praisonaiagents.config.feature_configs', 'ToolSearchConfig'),
# Parameter resolver
'resolve': ('praisonaiagents.config.param_resolver', 'resolve'),
@@ -816,6 +814,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, A2UI, etc.
# - RAG, RAGConfig, RAGResult, AGUI, A2A, etc.
# - All telemetry, display, and utility functions
# ============================================================================
@@ -556,7 +556,6 @@ class Agent(SandboxMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandler
backend: Optional[Any] = None, # External managed agent backend (e.g., ManagedAgentIntegration)
cli_backend: Optional[Union[str, Any]] = None, # CLI backend for delegating turns (e.g., "claude-code")
interrupt_controller: Optional['InterruptController'] = None, # G2: Cooperative cancellation
tool_search: Optional[Union[bool, str, Dict[str, Any], 'ToolSearchConfig']] = False, # Progressive tool disclosure
sandbox: Optional[Union[bool, 'SandboxConfig']] = None, # Sandbox for safe code execution
):
"""Initialize an Agent instance.
@@ -661,14 +660,6 @@ class Agent(SandboxMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandler
When provided, agent delegates entire conversation turns to the CLI tool
instead of using the built-in LLM. Enables session continuity and
tool integration through external AI coding assistants.
tool_search: Progressive tool disclosure configuration. Accepts:
- bool: False=disabled (default), True=auto mode
- str: Mode ("auto", "on", "off")
- Dict[str, Any]: Config overrides (e.g. {"threshold_pct": 15})
- ToolSearchConfig: Custom configuration
When enabled, replaces large tool schemas with bridge tools (tool_search,
tool_describe, tool_call) to save context. Core SDK tools never defer.
Auto mode activates based on token threshold. Opt-in feature.
Raises:
ValueError: If all of name, role, goal, backstory, and instructions are None.
@@ -1439,34 +1430,6 @@ class Agent(SandboxMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandler
web_search = False
web_fetch = False
# ─────────────────────────────────────────────────────────────────────
# Resolve TOOL_SEARCH param
# ─────────────────────────────────────────────────────────────────────
# Fast path: None/False -> disabled (zero overhead)
if tool_search is None or tool_search is False:
self._tool_search_config = None
elif tool_search is True:
# True -> auto mode with defaults
from ..tools.tool_search import ToolSearchConfig as _ToolSearchConfig
self._tool_search_config = _ToolSearchConfig(enabled="auto")
elif isinstance(tool_search, str):
# String mode ("auto", "on", "off")
from ..tools.tool_search import ToolSearchConfig as _ToolSearchConfig
self._tool_search_config = _ToolSearchConfig.from_raw(tool_search)
elif isinstance(tool_search, dict):
# Dict -> config overrides
from ..tools.tool_search import ToolSearchConfig as _ToolSearchConfig
self._tool_search_config = _ToolSearchConfig(**tool_search)
else:
from ..tools.tool_search import ToolSearchConfig as _ToolSearchConfig
if isinstance(tool_search, _ToolSearchConfig):
self._tool_search_config = tool_search
else:
raise TypeError(
"tool_search must be False/None, True, a mode string, "
"a dict of ToolSearchConfig fields, or ToolSearchConfig"
)
# ============================================================
# END CONSOLIDATED PARAMS EXTRACTION
# ============================================================
@@ -1989,130 +1952,6 @@ Your Goal: {self.goal}
# Sandbox configuration - initialize SandboxMixin
super().__init__(sandbox=sandbox)
def __deepcopy__(self, memo: dict) -> "Agent":
"""Custom deepcopy that creates fresh threading primitives.
threading.RLock (self.__cache_lock) and threading.Lock (self._cost_lock)
cannot be deep-copied on CPython < 3.13. This hook deep-copies every
other attribute normally and replaces the locks with new instances so
that copy.deepcopy(agent) works in any Python version.
"""
import copy
cls = self.__class__
result = cls.__new__(cls)
memo[id(self)] = result
for k, v in self.__dict__.items():
if k in ("_Agent__cache_lock",):
object.__setattr__(result, k, threading.RLock())
elif k == "_cost_lock":
object.__setattr__(result, k, threading.Lock())
else:
object.__setattr__(result, k, copy.deepcopy(v, memo))
return result
def clone_for_channel(self) -> "Agent":
"""Return a fully independent copy of this agent for a gateway channel.
This method safely clones an agent for use in multi-channel scenarios
by avoiding deepcopy issues with tools/handoffs and creating fresh
instances of mutable objects like interrupt_controller.
Returns:
Agent: A new Agent instance with copied configuration but fresh
locks, interrupt controller, and shallow-copied tools.
"""
import copy
from ..tools.base import BaseTool
# Build kwargs for new Agent instance, copying core attributes
clone_kwargs = {
# Core identity
'name': self.name,
'role': self.role,
'goal': self.goal,
'backstory': self.backstory,
'instructions': self.instructions,
# LLM configuration
'llm': self.llm,
'base_url': getattr(self, 'base_url', None),
'api_key': getattr(self, 'api_key', None),
'auth': getattr(self, 'auth', None),
# Shallow copy tools to avoid deepcopy issues with nested objects
'tools': list(self.tools) if self.tools else None,
# Skip handoffs entirely - they shouldn't be shared across channels
# and can contain nested Agent instances that cause RLock issues
'handoffs': None,
# Feature configurations - check for actual stored config objects
'memory': getattr(self, '_memory_config', None),
'knowledge': getattr(self, '_knowledge_config', None),
'planning': getattr(self, '_planning_config', None),
'reflection': getattr(self, '_reflection_config', None),
'guardrails': getattr(self, '_guardrails_config', None),
'web': getattr(self, '_web_config', None),
'context': getattr(self, '_context_config', None),
'autonomy': getattr(self, '_autonomy_config', None),
'output': getattr(self, '_output_config', None),
'execution': getattr(self, '_execution_config', None),
'templates': getattr(self, '_template_config', None),
'caching': getattr(self, '_caching_config', None),
'hooks': getattr(self, '_hooks_config', None),
'skills': getattr(self, '_skills_config', None),
'approval': getattr(self, '_approval_config', None),
'learn': getattr(self, '_learn_config', None),
'tool_search': getattr(self, '_tool_search_config', None),
# Tool configuration
'tool_timeout': getattr(self, '_tool_timeout', None),
'parallel_tool_calls': getattr(self, 'parallel_tool_calls', False),
# CLI backend
'cli_backend': getattr(self, '_cli_backend', None),
# Create fresh interrupt controller to avoid shared state
'interrupt_controller': None, # Let new instance create its own
# Sandbox config
'sandbox': getattr(self, '_sandbox_config', None),
}
# Handle deprecated parameters for backward compatibility
import warnings
# Check for deprecated standalone attributes and emit warnings
deprecated_attrs = [
('allow_code_execution', 'execution'),
('code_execution_mode', 'execution'),
('auto_save', 'memory'),
('rate_limiter', 'execution'),
('allow_delegation', 'handoffs'),
('verification_hooks', 'autonomy')
]
for old_attr, new_param in deprecated_attrs:
if hasattr(self, old_attr):
value = getattr(self, old_attr)
if value is not None and value != False: # Skip None/False defaults
warnings.warn(
f"Deprecated attribute '{old_attr}' found in agent clone. "
f"Use '{new_param}=' parameter instead.",
DeprecationWarning,
stacklevel=2
)
# For basic compatibility, include in clone_kwargs if not already set
if old_attr == 'allow_code_execution' and clone_kwargs['execution'] is None:
from ..config.execution import ExecutionConfig
clone_kwargs['execution'] = ExecutionConfig(code_execution=value)
elif old_attr == 'auto_save' and clone_kwargs['memory'] is None:
from ..config.memory import MemoryConfig
clone_kwargs['memory'] = MemoryConfig(auto_save=value)
# Create new Agent instance
return self.__class__(**{k: v for k, v in clone_kwargs.items() if v is not None})
@property
def _telemetry(self):
"""Lazy-loaded telemetry instance for performance."""
@@ -4777,57 +4616,60 @@ Answer:"""
error=f"Agent guardrail validation error: {str(e)}"
)
def _validate_with_guardrail(self, response_text):
"""Validate response with guardrail. Returns (success, 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.
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
"""
if not self._guardrail_fn:
return True, response_text, None
return response_text
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:
success, result, error = self._validate_with_guardrail(current_response)
# Create TaskOutput object
task_output = TaskOutput(
description="Agent response output",
raw=current_response,
agent=self.name
)
if success:
# Process guardrail
guardrail_result = self._process_guardrail(task_output)
if guardrail_result.success:
logging.info(f"Agent {self.name}: Guardrail validation passed")
return result
# 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
# 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: {error}"
f"Last error: {guardrail_result.error}"
)
retry_count += 1
logging.warning(f"Agent {self.name}: Guardrail validation failed (retry {retry_count}/{self.max_guardrail_retries}): {error}")
logging.warning(f"Agent {self.name}: Guardrail validation failed (retry {retry_count}/{self.max_guardrail_retries}): {guardrail_result.error}")
# Regenerate response for retry
try:
retry_prompt = f"{prompt}\n\nNote: Previous response failed validation due to: {error}. Please provide an improved response."
retry_prompt = f"{prompt}\n\nNote: Previous response failed validation due to: {guardrail_result.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
@@ -4836,46 +4678,10 @@ Answer:"""
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
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:
# If we can't regenerate, fail the guardrail
raise Exception(
f"Agent {self.name} response failed guardrail validation after {self.max_guardrail_retries} retries. "
f"Last error: {error}"
f"Agent {self.name} guardrail retry failed: {e}"
)
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
@@ -331,7 +331,6 @@ Your Goal: {self.goal}"""
- Callable functions
- String function names
- Objects with to_openai_tool() method
- Tool Search progressive disclosure (if enabled)
Args:
tools: List of tools in various formats or None to use self.tools
@@ -345,14 +344,10 @@ Your Goal: {self.goal}"""
if not tools:
return []
# Check cache first - include tool_search config in cache key for safety
# Check cache first
tools_key = self._get_tools_cache_key(tools)
tool_search_enabled = getattr(self, '_tool_search_config', None) is not None
cache_key = f"{tools_key}:tool_search={tool_search_enabled}"
cached_entry = self._cache_get(self._formatted_tools_cache, cache_key)
if cached_entry is not None:
cached_tools, cached_metadata = cached_entry
self._tool_search_metadata = cached_metadata
cached_tools = self._cache_get(self._formatted_tools_cache, tools_key)
if cached_tools is not None:
return cached_tools
formatted_tools = []
@@ -404,46 +399,9 @@ Your Goal: {self.goal}"""
logging.error(f"Tools are not JSON serializable: {e}")
return []
# Apply tool search assembly if enabled (after formatting, before caching)
if hasattr(self, '_tool_search_config') and self._tool_search_config is not None:
try:
from ..tools.tool_search import assemble_tool_defs
# Get context length from LLM config if available
context_length = getattr(self, '_context_window_size', None)
# Assemble tools with bridge mode check
assembled_tools, metadata = assemble_tool_defs(
tool_defs=formatted_tools,
config=self._tool_search_config,
context_length=context_length
)
# Store metadata for bridge tool dispatch
self._tool_search_metadata = metadata
formatted_tools = assembled_tools
except ImportError:
# Tool search module not available, continue with original tools
logging.warning("Tool search requested but tool_search module not available")
# Strip __praisonai_deferrable__ from provider-facing tool payloads
# Keep the marker only for internal tool classification
cleaned_tools = []
for tool in formatted_tools:
if isinstance(tool, dict) and "__praisonai_deferrable__" in tool:
tool_copy = tool.copy()
tool_copy.pop("__praisonai_deferrable__", None)
cleaned_tools.append(tool_copy)
else:
cleaned_tools.append(tool)
# Cache the formatted tools with LRU eviction, including tool search metadata
self._cache_put(
self._formatted_tools_cache,
cache_key,
(cleaned_tools, getattr(self, "_tool_search_metadata", None)),
)
return cleaned_tools
# Cache the formatted tools with LRU eviction
self._cache_put(self._formatted_tools_cache, tools_key, formatted_tools)
return formatted_tools
def _build_multimodal_prompt(
self,
@@ -549,7 +507,7 @@ Your Goal: {self.goal}"""
emit_events=True
)
def _chat_completion(self, messages, temperature=1.0, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, _retry_depth=0):
def _chat_completion(self, messages, temperature=1.0, tools=None, stream=True, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, _retry_depth=0):
start_time = time.time()
# --- Context compaction (opt-in via ExecutionConfig.context_compaction) ---
@@ -619,39 +577,6 @@ Your Goal: {self.goal}"""
# Use the new _format_tools_for_completion helper method
formatted_tools = self._format_tools_for_completion(tools)
# Smart fallback for streaming: try streaming first, fall back to non-streaming if unsupported
if stream is None:
# Auto-detect: prefer streaming for better UX, fallback if adapter doesn't support it
try:
# First attempt: try with streaming enabled for better user experience
stream_callback = self.stream_emitter.emit if hasattr(self, 'stream_emitter') else None
final_response = self._execute_unified_chat_completion(
messages=messages,
temperature=temperature,
tools=formatted_tools,
stream=True, # Try streaming first
reasoning_steps=reasoning_steps,
task_name=task_name,
task_description=task_description,
task_id=task_id,
response_format=response_format,
stream_callback=stream_callback,
emit_events=True
)
return final_response
except ValueError as e:
if "Streaming is not supported" in str(e):
# Fallback: retry with non-streaming for sync adapters
logging.debug(f"{self.name}: Streaming not supported by adapter, falling back to non-streaming")
stream = False # Set for the main execution below
else:
raise # Re-raise if it's a different ValueError
except Exception:
# For any other exception, fall back to non-streaming
logging.debug(f"{self.name}: Streaming attempt failed, falling back to non-streaming")
stream = False # Set for the main execution below
# If stream was explicitly set or fallback occurred, use the specified/fallback value
try:
# NEW: Unified protocol dispatch path (Issue #1304, #1362)
# UNIFIED: Single protocol-driven dispatch path (fixes DRY violation)
@@ -849,7 +774,7 @@ Your Goal: {self.goal}"""
messages,
temperature=1.0,
tools=None,
stream=None,
stream=True,
reasoning_steps=False,
task_name=None,
task_description=None,
@@ -881,47 +806,6 @@ Your Goal: {self.goal}"""
# Cache the dispatcher
self._unified_dispatcher = dispatcher
# Smart fallback for streaming: try streaming first, fall back to non-streaming if unsupported
if stream is None:
# Auto-detect: prefer streaming for better UX, fallback if adapter doesn't support it
try:
# First attempt: try with streaming enabled for better user experience
if stream_callback is None and hasattr(self, 'stream_emitter'):
stream_callback = getattr(self.stream_emitter, 'emit', None)
final_response = self._unified_dispatcher.chat_completion(
messages=messages,
tools=tools,
tool_choice=getattr(self, 'tool_choice', None),
temperature=temperature,
max_tokens=getattr(self, 'max_tokens', None),
stream=True, # Try streaming first
response_format=response_format,
execute_tool_fn=getattr(self, 'execute_tool', None),
console=self.console if (self.verbose or True) else None, # Enable console for streaming
display_fn=self._display_generating if self.verbose else None,
stream_callback=stream_callback,
emit_events=emit_events,
verbose=self.verbose,
max_iterations=10,
reasoning_steps=reasoning_steps,
task_name=task_name,
task_description=task_description,
task_id=task_id,
agent_name=getattr(self, 'name', 'assistant')
)
return final_response
except ValueError as e:
if "Streaming is not supported" in str(e):
# Fallback: retry with non-streaming for sync adapters
logging.debug(f"Agent: Streaming not supported by adapter, falling back to non-streaming")
stream = False # Set for the main execution below
else:
raise # Re-raise if it's a different ValueError
except Exception:
# For any other exception, fall back to non-streaming
logging.debug(f"Agent: Streaming attempt failed, falling back to non-streaming")
stream = False # Set for the main execution below
# Execute unified dispatch with all necessary parameters
# Includes all parameters from both legacy paths to ensure full compatibility
try:
@@ -973,7 +857,7 @@ Your Goal: {self.goal}"""
messages,
temperature=1.0,
tools=None,
stream=True, # Async methods keep stream=True default (async adapters support streaming vs sync smart fallback)
stream=True,
reasoning_steps=False,
task_name=None,
task_description=None,
@@ -2195,7 +2079,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Apply guardrail validation for custom LLM response
try:
validated_response = await self._aapply_guardrail_with_retry(response_text, prompt, temperature, tools, task_name, task_description, task_id)
validated_response = self._apply_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)
@@ -2303,216 +2187,22 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
task_id=task_id,
response_format=response_format
)
# 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
return await self._finalize_unified_achat_response(
response=response,
original_prompt=original_prompt,
temperature=temperature,
tools=tools,
output_json=output_json,
output_pydantic=output_pydantic,
reasoning_steps=reasoning_steps,
stream=stream,
messages=messages,
chat_history_length=chat_history_length,
start_time=start_time,
task_name=task_name,
task_description=task_description,
task_id=task_id,
)
else:
# LEGACY: Check if OpenAI client is available
if self._openai_client is None:
@@ -2649,7 +2339,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Apply guardrail validation for OpenAI client response
try:
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
validated_response = self._apply_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,33 +403,9 @@ 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,21 +26,6 @@ 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.
@@ -135,12 +120,6 @@ class ToolExecutionMixin:
"""
logging.debug(f"{self.name} executing tool {function_name} with arguments: {arguments}")
# Handle bridge tool unwrapping BEFORE trace/stream/hooks (design invariant #6)
# Only intercept when tool_search is active; otherwise fall through to real tool execution
if (getattr(self, '_tool_search_config', None) is not None and
function_name in ("tool_search", "tool_describe", "tool_call")):
return self._handle_bridge_tool_call(function_name, arguments, tool_call_id)
# NOTE: tool_call callback is triggered by display_tool_call in openai_client.py
# Do NOT call it here to avoid duplicate output
@@ -181,9 +160,8 @@ class ToolExecutionMixin:
# Emit TOOL_CALL_START to stream_emitter (for AIUI/AG-UI consumers)
# Zero overhead when no callbacks registered
_stream_emitter = self._get_existing_stream_emitter()
if _stream_emitter is not None and _stream_emitter.has_callbacks:
_stream_emitter.emit(StreamEvent(
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(
type=StreamEventType.TOOL_CALL_START,
timestamp=_tool_start_perf,
tool_call={
@@ -297,10 +275,10 @@ class ToolExecutionMixin:
# Emit TOOL_CALL_RESULT to stream_emitter (for AIUI/AG-UI consumers)
# Zero overhead when no callbacks registered
if _stream_emitter is not None and _stream_emitter.has_callbacks:
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:
# Truncate result for stream event (keep it reasonable for UI display)
result_summary = str(result)[:500] if result else None
_stream_emitter.emit(StreamEvent(
getattr(self, "_Agent__stream_emitter", None).emit(StreamEvent(
type=StreamEventType.TOOL_CALL_RESULT,
timestamp=_time.perf_counter(),
tool_call={
@@ -312,18 +290,6 @@ 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
@@ -960,97 +926,4 @@ class ToolExecutionMixin:
def pending_approval_count(self) -> int:
"""Number of approval requests still waiting."""
return len(self._pending_approvals)
def _handle_bridge_tool_call(self, function_name: str, arguments: Dict[str, Any], tool_call_id: Optional[str] = None) -> Any:
"""
Handle bridge tool calls (tool_search, tool_describe, tool_call).
This implements the tool search unwrapping logic before trace/stream/hooks
as required by design invariant #6.
Args:
function_name: Bridge tool name
arguments: Arguments passed to bridge tool
tool_call_id: Optional tool call ID
Returns:
Result of bridge tool execution or unwrapped real tool call
"""
# Ensure tool search metadata is available
if not hasattr(self, '_tool_search_metadata') or self._tool_search_metadata is None:
return "Tool search not available or not in bridge mode"
metadata = self._tool_search_metadata
# Check if we're in bridge mode
if not metadata.get("bridge_mode", False):
return "Tool search not in bridge mode"
# Get deferrable tools from metadata
deferrable_tools = metadata.get("deferrable_tools", [])
if function_name == "tool_search":
# Handle tool_search bridge call
try:
from ..tools.tool_search import dispatch_tool_search
query = arguments.get("query", "")
limit = arguments.get("limit", None)
result = dispatch_tool_search(
query=query,
limit=limit,
deferrable_tools=deferrable_tools,
config=self._tool_search_config
)
return json.dumps(result, indent=2)
except ImportError:
return "Tool search module not available"
except Exception as e:
logging.error(f"Error in tool_search: {e}")
return f"Error searching tools: {e}"
elif function_name == "tool_describe":
# Handle tool_describe bridge call
try:
from ..tools.tool_search import dispatch_tool_describe
tool_name = arguments.get("tool_name", "")
result = dispatch_tool_describe(
tool_name=tool_name,
deferrable_tools=deferrable_tools
)
return json.dumps(result, indent=2)
except ImportError:
return "Tool search module not available"
except Exception as e:
logging.error(f"Error in tool_describe: {e}")
return f"Error describing tool: {e}"
elif function_name == "tool_call":
# Handle tool_call bridge - unwrap and recurse with real tool
try:
from ..tools.tool_search import resolve_underlying_call
# Unwrap the real tool call
real_function_name, real_arguments = resolve_underlying_call(function_name, arguments)
# Validate that the real tool is in our deferrable set (security check)
deferrable_names = {
tool_def.get("function", {}).get("name", "")
for tool_def in deferrable_tools
}
if real_function_name not in deferrable_names:
return f"Tool '{real_function_name}' is not available for execution"
# Recursively execute the real tool (this will go through normal execution path)
return self.execute_tool(real_function_name, real_arguments, tool_call_id)
except ImportError:
return "Tool search module not available"
except Exception as e:
logging.error(f"Error in tool_call unwrap: {e}")
return f"Error executing tool: {e}"
else:
return f"Unknown bridge tool: {function_name}"
@@ -15,9 +15,9 @@ from enum import Enum
# Import token tracking
try:
from ..telemetry.token_collector import get_token_collector
from ..telemetry.token_collector import _token_collector
except ImportError:
get_token_collector = None
_token_collector = None
# Import async utility for hot-path usage
try:
@@ -1609,25 +1609,25 @@ class AgentTeam:
original_on_task_start = self.on_task_start
original_on_task_complete = self.on_task_complete
def composed_on_task_start(task, task_id):
def composed_on_task_start(task):
try:
verbose_task_start_callback(task, task_id)
verbose_task_start_callback(task)
except Exception as e:
logging.debug(f"Error in verbose task start callback: {e}")
if original_on_task_start:
try:
original_on_task_start(task, task_id)
original_on_task_start(task)
except Exception as e:
logging.debug(f"Error in original task start callback: {e}")
def composed_on_task_complete(task, task_output):
def composed_on_task_complete(task):
try:
verbose_task_complete_callback(task, task_output)
verbose_task_complete_callback(task)
except Exception as e:
logging.debug(f"Error in verbose task complete callback: {e}")
if original_on_task_complete:
try:
original_on_task_complete(task, task_output)
original_on_task_complete(task)
except Exception as e:
logging.debug(f"Error in original task complete callback: {e}")
@@ -1826,19 +1826,18 @@ class AgentTeam:
def get_token_usage_summary(self) -> Dict[str, Any]:
"""Get a summary of token usage across all agents and tasks."""
if not get_token_collector:
if not _token_collector:
return {"error": "Token tracking not available"}
return get_token_collector().get_session_summary()
return _token_collector.get_session_summary()
def get_detailed_token_report(self) -> Dict[str, Any]:
"""Get a detailed token usage report."""
if not get_token_collector:
if not _token_collector:
return {"error": "Token tracking not available"}
collector = get_token_collector()
summary = collector.get_session_summary()
recent = collector.get_recent_interactions(limit=20)
summary = _token_collector.get_session_summary()
recent = _token_collector.get_recent_interactions(limit=20)
# Calculate cost estimates (example rates)
cost_per_1k_input = 0.0005 # $0.0005 per 1K input tokens
@@ -1862,11 +1861,11 @@ class AgentTeam:
def display_token_usage(self):
"""Display token usage in a formatted table."""
if not get_token_collector:
if not _token_collector:
print("Token tracking not available")
return
summary = get_token_collector().get_session_summary()
summary = _token_collector.get_session_summary()
print("\n" + "="*50)
print("TOKEN USAGE SUMMARY")
@@ -39,7 +39,6 @@ __all__ = [
"CachingConfig",
"HooksConfig",
"SkillsConfig",
"ToolSearchConfig",
# Type aliases
"MemoryParam",
"KnowledgeParam",
@@ -123,7 +122,6 @@ _MODULE_MAP = {
"CachingConfig": "feature_configs",
"HooksConfig": "feature_configs",
"SkillsConfig": "feature_configs",
"ToolSearchConfig": "feature_configs",
"MemoryParam": "feature_configs",
"KnowledgeParam": "feature_configs",
"PlanningParam": "feature_configs",
@@ -8,7 +8,6 @@ Provides dataclasses for consolidated feature configuration:
- ReflectionConfig: Self-reflection settings
- GuardrailConfig: Safety and validation
- WebConfig: Web search and fetch
- ToolSearchConfig: Progressive tool disclosure
All configs follow the agent-centric pattern:
- False: Feature disabled (zero overhead)
@@ -17,22 +16,21 @@ All configs follow the agent-centric pattern:
- Instance: Pre-configured manager/engine
Usage:
from praisonaiagents import Agent, MemoryConfig, KnowledgeConfig, ToolSearchConfig
from praisonaiagents import Agent, MemoryConfig, KnowledgeConfig
# Simple enable
agent = Agent(instructions="...", memory=True, tool_search=True)
agent = Agent(instructions="...", memory=True)
# With config
agent = Agent(
instructions="...",
memory=MemoryConfig(backend="redis", user_id="user123"),
knowledge=KnowledgeConfig(sources=["docs/"], rerank=True),
tool_search=ToolSearchConfig(enabled="auto", threshold_pct=15),
)
"""
from dataclasses import dataclass, field
from typing import Dict, List, Any, Optional, Callable, Tuple, Union, FrozenSet
from typing import Dict, List, Any, Optional, Callable, Tuple, Union
from enum import Enum
# Import AutonomyConfig from canonical location (no circular dep)
@@ -1074,25 +1072,6 @@ class MultiAgentMemoryConfig:
}
# Import ToolSearchConfig from tools module to avoid duplication
def __get_tool_search_config():
try:
from ..tools.tool_search import ToolSearchConfig as _ToolSearchConfig
return _ToolSearchConfig
except ImportError:
# Fallback minimal config if tools module not available
@dataclass
class FallbackToolSearchConfig:
enabled: Union[bool, str] = "auto"
threshold_pct: float = 10.0
search_default_limit: int = 5
max_search_limit: int = 20
core_tools: Optional[FrozenSet[str]] = None
return FallbackToolSearchConfig
ToolSearchConfig = __get_tool_search_config()
class AutonomyLevel(str, Enum):
"""Autonomy levels for agent behavior."""
SUGGEST = "suggest"
@@ -1113,7 +1092,6 @@ CachingParam = Union[bool, CachingConfig]
HooksParam = Union[List[Any], HooksConfig]
SkillsParam = Union[List[str], SkillsConfig]
AutonomyParam = Union[bool, Dict[str, Any], "AutonomyConfig"]
ToolSearchParam = Union[bool, str, Dict[str, Any], ToolSearchConfig]
# =============================================================================
@@ -1365,21 +1343,6 @@ def resolve_autonomy(value: AutonomyParam) -> Optional[AutonomyConfig]:
return value
def resolve_tool_search(value: ToolSearchParam) -> Optional[ToolSearchConfig]:
"""Resolve tool_search= parameter following precedence ladder."""
if value is None or value is False:
return None
if value is True:
return ToolSearchConfig()
if isinstance(value, str):
return ToolSearchConfig(enabled=value)
if isinstance(value, dict):
return ToolSearchConfig(**value)
if isinstance(value, ToolSearchConfig):
return value
return value
__all__ = [
# Enums
"MemoryBackend",
@@ -1405,7 +1368,6 @@ __all__ = [
"HooksConfig",
"SkillsConfig",
"AutonomyConfig",
"ToolSearchConfig",
# Config classes (Multi-Agent)
"MultiAgentHooksConfig",
"MultiAgentOutputConfig",
@@ -1426,7 +1388,6 @@ __all__ = [
"HooksParam",
"SkillsParam",
"AutonomyParam",
"ToolSearchParam",
# Precedence ladder resolvers
"resolve_memory",
"resolve_knowledge",
@@ -1438,5 +1399,4 @@ __all__ = [
"resolve_execution",
"resolve_caching",
"resolve_autonomy",
"resolve_tool_search",
]
@@ -1,13 +0,0 @@
"""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,13 +439,3 @@ 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
@@ -68,14 +68,6 @@ class HookEvent(str, Enum):
SCHEDULE_REMOVE = "schedule_remove"
SCHEDULE_TRIGGER = "schedule_trigger"
# Kanban task lifecycle (wrapper dispatcher + tools emit these)
KANBAN_TASK_CREATED = "kanban_task_created"
KANBAN_TASK_CLAIMED = "kanban_task_claimed"
KANBAN_TASK_MOVED = "kanban_task_moved"
KANBAN_TASK_DONE = "kanban_task_done"
KANBAN_TASK_BLOCKED = "kanban_task_blocked"
KANBAN_TASK_FAILED = "kanban_task_failed"
# Claude Code parity events
USER_PROMPT_SUBMIT = "user_prompt_submit" # When user submits a prompt
NOTIFICATION = "notification" # When notification is sent
@@ -109,34 +101,6 @@ class HookInput:
}
@dataclass
class KanbanHookInput(HookInput):
"""Hook input for kanban task lifecycle events.
Used by wrapper dispatcher and tools when emitting kanban events.
Observability adapters can subscribe to these via the hook registry.
"""
task_id: str = ""
board: str = "default"
status: str = ""
assignee: Optional[str] = field(default=None)
from_status: Optional[str] = field(default=None)
to_status: Optional[str] = field(default=None)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
result = super().to_dict()
result.update({
"task_id": self.task_id,
"board": self.board,
"status": self.status,
"assignee": self.assignee,
"from_status": self.from_status,
"to_status": self.to_status,
})
return result
@dataclass
class HookOutput:
"""Base hook output - common fields for all events."""
@@ -1,23 +0,0 @@
"""
Kanban protocols and types for PraisonAI Agents.
This module provides the protocol contracts for kanban functionality,
allowing the wrapper (praisonai) and PraisonAIUI to share a stable
interface without coupling the core to SQLite or other heavy implementations.
"""
from praisonaiagents.kanban.protocols import (
KanbanStoreProtocol,
KanbanTaskProtocol,
KanbanCommentingProtocol,
KanbanLinkingProtocol,
VALID_KANBAN_STATUSES,
)
__all__ = [
"KanbanStoreProtocol",
"KanbanTaskProtocol",
"KanbanCommentingProtocol",
"KanbanLinkingProtocol",
"VALID_KANBAN_STATUSES",
]
@@ -1,221 +0,0 @@
"""
Kanban protocols for PraisonAI Agents.
Defines the protocol contracts that the wrapper (praisonai) and PraisonAIUI
can use to implement kanban functionality with a stable interface.
This follows AGENTS.md §3.2 Protocol-First Design:
- Protocols define WHAT (interface contract)
- Implementations define HOW (concrete behavior)
- Core SDK has protocols only, wrapper has heavy implementations
"""
from typing import Protocol, TypedDict, runtime_checkable
# Valid kanban statuses matching PraisonAIUI columns + archived
VALID_KANBAN_STATUSES: frozenset[str] = frozenset([
"triage",
"todo",
"ready",
"running",
"blocked",
"review",
"done",
"archived"
])
class KanbanTaskProtocol(TypedDict, total=False):
"""Typed dict shape for kanban task fields.
Defines the expected structure of task objects returned by
KanbanStoreProtocol implementations.
"""
id: str
title: str
body: str
status: str
assignee: str | None
priority: str | None
tenant: str | None
board: str
created_at: float
updated_at: float
@runtime_checkable
class KanbanStoreProtocol(Protocol):
"""Protocol contract for kanban store implementations.
This protocol defines the core interface that PraisonAIUI expects
for injected kanban stores. Wrapper implementations must
implement all methods to be compatible.
Duck typing contract matches InjectedKanbanStore in PraisonAIUI.
"""
def get_board(
self,
*,
board: str = "default",
tenant: str | None = None,
include_archived: bool = False,
) -> dict:
"""Get board data with tasks grouped by status.
Args:
board: Board name (default: "default")
tenant: Optional tenant filter
include_archived: Whether to include archived tasks
Returns:
Board data with tasks organized by status columns
"""
...
def get_task(self, task_id: str) -> dict | None:
"""Get a single task by ID.
Args:
task_id: Task identifier
Returns:
Task data or None if not found
"""
...
def create_task(self, data: dict) -> dict:
"""Create a new task.
Args:
data: Task creation data (title, body, status, etc.)
Returns:
Created task data with assigned ID
"""
...
def update_task(self, task_id: str, data: dict) -> dict | None:
"""Update an existing task.
Args:
task_id: Task identifier
data: Fields to update
Returns:
Updated task data or None if not found
"""
...
def move_task(self, task_id: str, status: str) -> dict | None:
"""Move a task to a different status.
Args:
task_id: Task identifier
status: New status (must be in VALID_KANBAN_STATUSES)
Returns:
Updated task data or None if not found
"""
...
def bulk_update(self, task_ids: list[str], status: str) -> dict:
"""Update multiple tasks to the same status.
Args:
task_ids: List of task identifiers
status: New status for all tasks
Returns:
Results summary with success/failure counts
"""
...
def delete_task(self, task_id: str) -> bool:
"""Delete a task.
Args:
task_id: Task identifier
Returns:
True if deleted, False if not found
"""
...
def list_events(self, since: float = 0.0, board: str = "default") -> list[dict]:
"""List kanban events since a timestamp.
Args:
since: Unix timestamp to filter events from
board: Board name to filter events
Returns:
List of event data
"""
...
def health(self) -> dict:
"""Check store health status.
Returns:
Health status information
"""
...
@runtime_checkable
class KanbanCommentingProtocol(Protocol):
"""Extension protocol for kanban task commenting functionality.
This protocol is implemented separately from KanbanStoreProtocol to allow
stores to optionally support commenting without breaking isinstance checks
on the core protocol.
"""
def add_comment(self, task_id: str, text: str, author: str | None = None) -> dict | None:
"""Add a comment to a task.
Args:
task_id: Task identifier
text: Comment text
author: Optional comment author
Returns:
Comment data or None if task not found
"""
...
@runtime_checkable
class KanbanLinkingProtocol(Protocol):
"""Extension protocol for kanban task linking functionality.
This protocol is implemented separately from KanbanStoreProtocol to allow
stores to optionally support task relationships without breaking isinstance
checks on the core protocol.
"""
def link_tasks(self, parent_id: str, child_id: str) -> bool:
"""Link two tasks in parent-child relationship.
Args:
parent_id: Parent task identifier
child_id: Child task identifier
Returns:
True if linked successfully, False otherwise
"""
...
def unlink_tasks(self, parent_id: str, child_id: str) -> bool:
"""Unlink parent-child task relationship.
Args:
parent_id: Parent task identifier
child_id: Child task identifier
Returns:
True if unlinked successfully, False otherwise
"""
...
@@ -174,14 +174,7 @@ class ChromaKnowledgeAdapter:
# Add where filter if any conditions specified
if where_filter:
# ChromaDB validation expects exactly one condition in the where clause
# When multiple conditions exist, use $and operator to combine them
if len(where_filter) == 1:
query_kwargs["where"] = where_filter
else:
# Convert multiple conditions to ChromaDB's $and format
and_conditions = [{k: v} for k, v in where_filter.items()]
query_kwargs["where"] = {"$and": and_conditions}
query_kwargs["where"] = where_filter
response = self.collection.query(**query_kwargs)
@@ -204,10 +197,6 @@ class ChromaKnowledgeAdapter:
return SearchResult(results=items)
except Exception as e:
# Log ChromaDB query errors for better debugging
import logging
logger = logging.getLogger(__name__)
logger.warning(f"ChromaDB search failed: {e}")
return SearchResult(results=[])
def add(self, content: Any, *, user_id: Optional[str] = None, agent_id: Optional[str] = None,
@@ -73,10 +73,10 @@ def _get_live():
# Import token tracking
try:
from ..telemetry.token_collector import TokenMetrics, get_token_collector
from ..telemetry.token_collector import TokenMetrics, _token_collector
except ImportError:
TokenMetrics = None
get_token_collector = None
_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 get_token_collector:
if not TokenMetrics or not _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'
get_token_collector().track_tokens(
_token_collector.track_tokens(
model=model,
agent=self.current_agent_name,
metrics=metrics,
@@ -83,23 +83,11 @@ def __getattr__(name):
elif name == "OAUTH_CALLBACK_PATH":
from .mcp_oauth_callback import OAUTH_CALLBACK_PATH
return OAUTH_CALLBACK_PATH
# Protocols (lazy loaded)
elif name == "MCPClientProtocol":
from .protocols import MCPClientProtocol
return MCPClientProtocol
# Loader (lazy loaded)
elif name == "load_mcp_tools":
from .loader import load_mcp_tools
return load_mcp_tools
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
# Client
"MCP",
# Protocols (lazy loaded)
"MCPClientProtocol",
# Loader (lazy loaded)
"load_mcp_tools",
# Server (lazy loaded)
"ToolsMCPServer",
"launch_tools_mcp_server",
@@ -1,92 +0,0 @@
"""
MCP Tools Loader for PraisonAI Agents.
Provides a thin helper function to load MCP tools for agents
following the agent-centric design principles in AGENTS.md.
"""
from typing import List, Optional
from ..memory.mcp_config import MCPConfig, MCPConfigManager
from .mcp import MCP
def load_mcp_tools(
names: Optional[List[str]] = None,
*,
configs: Optional[List[MCPConfig]] = None,
prefix_tools: bool = True,
) -> List[MCP]:
"""
Load MCP tools for use with agents.
This is a thin helper that bridges the gap between configuration
and agent tool setup, following the "few lines to run an agent"
principle from AGENTS.md.
Args:
names: Specific config names to load (None = all enabled)
configs: Optional injected configs from wrapper TOML loader
prefix_tools: Whether to prefix tool names when multiple servers
are loaded to avoid collisions (default: True).
NOTE: Currently not implemented - collisions possible with multiple servers.
Returns:
List of MCP client instances ready for agent use
Examples:
# Load all enabled MCP servers from JSON config
>>> from praisonaiagents import Agent
>>> from praisonaiagents.mcp import load_mcp_tools
>>> agent = Agent(name="assistant", tools=load_mcp_tools())
# Load specific servers only
>>> tools = load_mcp_tools(["filesystem", "github"])
>>> agent = Agent(name="coder", tools=tools)
# Inject configs from wrapper TOML
>>> from praisonai.cli.configuration import get_config_loader
>>> loader = get_config_loader()
>>> config = loader.load()
>>> toml_configs = [MCPConfig(...) for server in config.mcp.servers]
>>> tools = load_mcp_tools(configs=toml_configs)
"""
mcp_clients: List[MCP] = []
if configs is not None:
# Use injected configs from wrapper (TOML loading)
target_configs = configs
if names:
# Filter by names if specified
target_configs = [c for c in configs if c.name in names]
else:
# Load from MCPConfigManager (JSON files)
manager = MCPConfigManager()
if names:
target_configs = [manager.get_config(name) for name in names]
target_configs = [c for c in target_configs if c and c.enabled]
else:
target_configs = manager.get_enabled_configs()
for config in target_configs:
if not config.enabled:
continue
# Convert config to MCP instance
mcp = config.to_mcp_instance()
if mcp:
# Apply tool filtering if configured (B3)
# TODO: Wire include/exclude filters when B3 is implemented
# Apply tool prefix if multiple servers (B4)
if prefix_tools and len(target_configs) > 1:
# Sanitize server name for use as prefix
server_name = config.name.replace('-', '_').replace(' ', '_')
# TODO: Tool name prefixing not yet implemented
# Need to modify tool names in MCP instance to avoid collisions
# e.g., "read_file" -> "filesystem_read_file"
pass
mcp_clients.append(mcp)
return mcp_clients
+30 -133
View File
@@ -29,8 +29,8 @@ class MCPToolRunner(threading.Thread):
super().__init__(daemon=True)
self.server_params = server_params
self.queue = queue.Queue()
self.result_queue = queue.Queue()
self.initialized = threading.Event()
self._init_error = None
self.tools = []
self.timeout = timeout
self._tool_timings = {}
@@ -66,12 +66,12 @@ class MCPToolRunner(threading.Thread):
if item is None: # Shutdown signal
break
response_queue, tool_name, arguments = item
tool_name, arguments = item
try:
result = await session.call_tool(tool_name, arguments)
response_queue.put((True, result))
self.result_queue.put((True, result))
except Exception as e:
response_queue.put((False, str(e)))
self.result_queue.put((False, str(e)))
except queue.Empty:
pass
@@ -80,8 +80,8 @@ class MCPToolRunner(threading.Thread):
except asyncio.CancelledError:
break
except Exception as e:
self._init_error = f"MCP initialization error: {str(e)}"
self.initialized.set() # Ensure we don't hang
self.result_queue.put((False, f"MCP initialization error: {str(e)}"))
def call_tool(self, tool_name, arguments):
"""Call an MCP tool and wait for the result."""
@@ -100,25 +100,16 @@ class MCPToolRunner(threading.Thread):
if telemetry:
telemetry.track_tool_usage(tool_name, success=False, execution_time=0)
return f"Error: MCP initialization timed out after {self.timeout} seconds"
if self._init_error:
if telemetry:
telemetry.track_tool_usage(tool_name, success=False, execution_time=0)
return f"Error: {self._init_error}"
# Start timing after initialization check
start_time = time.time()
is_success = False
response_queue = queue.Queue(maxsize=1)
try:
# Put request in queue with caller-specific response channel
self.queue.put((response_queue, tool_name, arguments))
# Put request in queue
self.queue.put((tool_name, arguments))
# Wait for result with timeout
try:
success, result = response_queue.get(timeout=self.timeout)
except queue.Empty:
return f"Error: MCP tool call timed out after {self.timeout} seconds"
# Wait for result
success, result = self.result_queue.get()
if not success:
return f"Error: {result}"
@@ -196,8 +187,7 @@ class MCP:
```
"""
def __init__(self, command_or_string=None, args=None, *, command=None, timeout=60, debug=False,
allowed_tools: Optional[List[str]] = None, disabled_tools: Optional[List[str]] = None, **kwargs):
def __init__(self, command_or_string=None, args=None, *, command=None, timeout=60, debug=False, **kwargs):
"""
Initialize the MCP connection and get tools.
@@ -211,13 +201,7 @@ class MCP:
command: Alternative parameter name for backward compatibility
timeout: Timeout in seconds for MCP server initialization and tool calls (default: 60)
debug: Enable debug logging for MCP operations (default: False)
allowed_tools: Include whitelist - only these tools will be available (default: None = all tools)
disabled_tools: Exclude blacklist - these tools will be filtered out (default: None = no exclusions)
**kwargs: Additional parameters for StdioServerParameters
Note:
If both allowed_tools and disabled_tools are specified, allowed_tools takes precedence
(include wins over exclude).
"""
# Check if MCP is available
if not MCP_AVAILABLE:
@@ -256,8 +240,6 @@ class MCP:
# Store additional parameters
self.timeout = timeout
self.debug = debug
self.allowed_tools = allowed_tools
self.disabled_tools = disabled_tools
# Check if this is a WebSocket URL (ws:// or wss://)
if isinstance(command_or_string, str) and re.match(r'^wss?://', command_or_string):
@@ -272,7 +254,7 @@ class MCP:
auth_token=auth_token,
options=kwargs
)
self._tools = self._apply_tool_filters(list(self.websocket_client.tools))
self._tools = list(self.websocket_client.tools)
self.is_sse = False
self.is_http_stream = False
self.is_websocket = True
@@ -286,7 +268,7 @@ class MCP:
# Legacy SSE URL - use SSE transport for backward compatibility
from .mcp_sse import SSEMCPClient
self.sse_client = SSEMCPClient(command_or_string, debug=debug, timeout=timeout)
self._tools = self._apply_tool_filters(list(self.sse_client.tools))
self._tools = list(self.sse_client.tools)
self.is_sse = True
self.is_http_stream = False
self.is_npx = False
@@ -313,7 +295,7 @@ class MCP:
timeout=timeout,
options=transport_options
)
self._tools = self._apply_tool_filters(list(self.http_stream_client.tools))
self._tools = list(self.http_stream_client.tools)
self.is_sse = False
self.is_http_stream = True
self.is_npx = False
@@ -343,10 +325,20 @@ class MCP:
self.is_sse = False
self.is_http_stream = False
# Build safe environment for stdio MCP servers
# Use safe baseline + explicit env from config (B5 security policy)
custom_env = kwargs.get('env', {})
env = self._build_safe_env(custom_env)
# Ensure UTF-8 encoding in environment for Docker compatibility
env = kwargs.get('env', {})
if not env:
env = os.environ.copy()
# Always set Python encoding
env['PYTHONIOENCODING'] = 'utf-8'
# Only set locale variables on Unix systems
if platform.system() != 'Windows':
env.update({
'LC_ALL': 'C.UTF-8',
'LANG': 'C.UTF-8'
})
kwargs['env'] = env
@@ -377,7 +369,7 @@ class MCP:
self._initialize_npx_mcp_tools(cmd, arguments)
else:
# Generate tool functions immediately and store them
self._tools = self._apply_tool_filters(self._generate_tool_functions())
self._tools = self._generate_tool_functions()
def _generate_tool_functions(self) -> List[Callable]:
"""
@@ -497,7 +489,7 @@ class MCP:
logging.debug(f"Initializing NPX MCP tools with command: {cmd} {' '.join(arguments)}")
# Generate tool functions using the regular MCP approach
self._tools = self._apply_tool_filters(self._generate_tool_functions())
self._tools = self._generate_tool_functions()
if self.debug:
logging.debug(f"Generated {len(self._tools)} NPX MCP tools")
@@ -507,100 +499,6 @@ class MCP:
logging.error(f"Failed to initialize NPX MCP tools: {e}")
raise RuntimeError(f"Failed to initialize NPX MCP tools: {e}")
def _apply_tool_filters(self, raw_tools: List[Any]) -> List[Any]:
"""
Apply tool filtering based on allowed_tools and disabled_tools.
Args:
raw_tools: Unfiltered tool list
Returns:
Filtered tool list
"""
if not self.allowed_tools and not self.disabled_tools:
return raw_tools
# Import filter functions
from .mcp_utils import filter_tools_by_allowlist, filter_disabled_tools
# Convert tools to the format expected by filter functions (list of dicts with 'name')
# Raw tools might be function objects, so we need to extract names
tool_defs = []
for tool in raw_tools:
if hasattr(tool, '__name__'):
tool_defs.append({"name": tool.__name__, "_tool_obj": tool})
elif isinstance(tool, dict) and 'name' in tool:
tool_defs.append(tool)
else:
# Skip tools that don't have a clear name
continue
# Include wins over exclude - apply allowlist exclusively if provided
if self.allowed_tools:
tool_defs = filter_tools_by_allowlist(tool_defs, self.allowed_tools)
elif self.disabled_tools:
tool_defs = filter_disabled_tools(tool_defs, self.disabled_tools)
# Convert back to original format
filtered_tools = []
for tool_def in tool_defs:
if "_tool_obj" in tool_def:
filtered_tools.append(tool_def["_tool_obj"])
else:
filtered_tools.append(tool_def)
return filtered_tools
def _build_safe_env(self, custom_env: Optional[dict] = None) -> dict:
"""
Build a safe environment for stdio MCP servers.
Merges only safe baseline environment variables with explicit custom ones,
following the security policy outlined in AGENTS.md.
Args:
custom_env: Optional custom environment variables from config
Returns:
Safe environment dictionary
"""
# Safe baseline environment variables
safe_baseline = {
'PATH': os.environ.get('PATH', ''),
'HOME': os.environ.get('HOME', os.path.expanduser('~')),
'USER': os.environ.get('USER', ''),
'LANG': os.environ.get('LANG', 'C.UTF-8'),
'LC_ALL': os.environ.get('LC_ALL', 'C.UTF-8'),
'PYTHONIOENCODING': 'utf-8',
}
# Platform-specific safe variables
if platform.system() == 'Windows':
safe_baseline.update({
'SYSTEMROOT': os.environ.get('SYSTEMROOT', ''),
'COMSPEC': os.environ.get('COMSPEC', ''),
'USERNAME': os.environ.get('USERNAME', ''),
'USERPROFILE': os.environ.get('USERPROFILE', ''),
'APPDATA': os.environ.get('APPDATA', ''),
'LOCALAPPDATA': os.environ.get('LOCALAPPDATA', ''),
'TEMP': os.environ.get('TEMP', ''),
'TMP': os.environ.get('TMP', ''),
})
else:
safe_baseline.update({
'TMPDIR': os.environ.get('TMPDIR', '/tmp'),
'SHELL': os.environ.get('SHELL', '/bin/sh'),
})
# Start with safe baseline
env = {k: v for k, v in safe_baseline.items() if v} # Only include non-empty values
# Add explicit custom environment variables from config
if custom_env:
env.update(custom_env)
return env
def __iter__(self) -> Iterable[Callable]:
"""
Allow the MCP instance to be used directly as an iterable of tools.
@@ -711,8 +609,7 @@ class MCP:
"name": tool.name,
"description": tool.description if hasattr(tool, 'description') else f"Call the {tool.name} tool",
"parameters": parameters
},
"__praisonai_deferrable__": True # Mark MCP tools as deferrable for tool search
}
})
return openai_tools
@@ -156,8 +156,7 @@ class HTTPStreamMCPTool:
"name": self.name,
"description": self.description,
"parameters": fixed_schema
},
"__praisonai_deferrable__": True # Mark MCP tools as deferrable for tool search
}
}
class HTTPStreamTransport:
@@ -121,8 +121,7 @@ class SSEMCPTool:
"name": self.name,
"description": self.description,
"parameters": fixed_schema
},
"__praisonai_deferrable__": True # Mark MCP tools as deferrable for tool search
}
}
class SSEMCPClient:
@@ -363,8 +363,7 @@ class WebSocketMCPTool:
"name": self.name,
"description": self.description,
"parameters": fixed_schema
},
"__praisonai_deferrable__": True # Mark MCP tools as deferrable for tool search
}
}
class WebSocketMCPClient:
@@ -1,110 +0,0 @@
"""
Model Context Protocol (MCP) client protocols for PraisonAI Agents.
Defines protocol interfaces for MCP client extensibility following
the protocol-driven design pattern outlined in AGENTS.md.
"""
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
@runtime_checkable
class MCPClientProtocol(Protocol):
"""
Protocol interface for MCP client implementations.
Defines the minimal contract that all MCP clients must follow,
enabling extensibility through different transport mechanisms
(stdio, SSE, HTTP stream, WebSocket) while maintaining a
consistent interface for agents.
"""
def list_tools(self) -> List[Dict[str, Any]]:
"""
List available tools from the MCP server.
Returns:
List of tool definitions from the server
"""
...
def get_tools(self) -> List[Dict[str, Any]]:
"""
Get available tools from the MCP server.
Alias for list_tools() to maintain backward compatibility.
Returns:
List of tool definitions from the server
"""
...
def call_tool(self, name: str, args: Dict[str, Any]) -> Any:
"""
Call a tool on the MCP server.
Args:
name: Tool name to call
args: Tool arguments
Returns:
Tool execution result
Raises:
Exception: If tool call fails
"""
...
def shutdown(self) -> None:
"""
Shutdown the MCP client and clean up resources.
This should gracefully close connections, terminate
subprocesses, and release any held resources.
"""
...
async def async_list_tools(self) -> List[Dict[str, Any]]:
"""
Asynchronously list available tools from the MCP server.
Returns:
List of tool definitions from the server
"""
...
async def async_get_tools(self) -> List[Dict[str, Any]]:
"""
Asynchronously get available tools from the MCP server.
Alias for async_list_tools() to maintain backward compatibility.
Returns:
List of tool definitions from the server
"""
...
async def async_call_tool(self, name: str, args: Dict[str, Any]) -> Any:
"""
Asynchronously call a tool on the MCP server.
Args:
name: Tool name to call
args: Tool arguments
Returns:
Tool execution result
Raises:
Exception: If tool call fails
"""
...
async def async_shutdown(self) -> None:
"""
Asynchronously shutdown the MCP client and clean up resources.
This should gracefully close connections, terminate
subprocesses, and release any held resources.
"""
...
@@ -81,7 +81,7 @@ class MCPConfig:
MCP instance ready for use with agents
"""
try:
from praisonaiagents.mcp import MCP
from praisonaiagents.tools.mcp import MCP
# Resolve environment variables
resolved_env = {}
@@ -6,13 +6,7 @@ No heavy imports - only stdlib.
"""
import hashlib
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple, Union
if TYPE_CHECKING:
from ..knowledge.models import SearchResultItem
ResultItem = Union[Dict[str, Any], "SearchResultItem"]
from typing import Any, Dict, List, Optional, Set, Tuple
def _estimate_tokens(text: str) -> int:
@@ -25,39 +19,6 @@ def _estimate_tokens(text: str) -> int:
return len(text) // 4 + 1
def _extract_value(item: ResultItem, key: str, default: Any = None) -> Any:
"""
Extract value from either dict or object (e.g., SearchResultItem).
Args:
item: Dictionary or object with attributes
key: Key/attribute name to extract
default: Default value if key not found
Returns:
Extracted value or default
"""
if isinstance(item, dict):
return item.get(key, default)
else:
return getattr(item, key, default)
def _extract_metadata_value(
item: ResultItem,
metadata: Dict[str, Any],
key: str,
default: Any = "",
) -> Any:
"""
Extract metadata value with fallback to top-level item attribute.
"""
value = metadata.get(key)
if value is None:
return _extract_value(item, key, default)
return value
def _chunk_hash(text: str, source: Optional[str] = None) -> str:
"""
Generate stable hash for chunk deduplication.
@@ -74,38 +35,31 @@ def _chunk_hash(text: str, source: Optional[str] = None) -> str:
def deduplicate_chunks(
results: List[ResultItem],
results: List[Dict[str, Any]],
similarity_threshold: float = 0.9,
) -> List[ResultItem]:
) -> List[Dict[str, Any]]:
"""
Deduplicate chunks by content hash.
Handles both dict format and SearchResultItem objects.
Args:
results: List of search results (dicts or SearchResultItem objects)
results: List of search results with 'text' or 'memory' key
similarity_threshold: Not used currently (hash-based dedup)
Returns:
Deduplicated list of results
"""
seen_hashes: Set[str] = set()
unique_results: List[ResultItem] = []
unique_results: List[Dict[str, Any]] = []
for result in results:
# Skip None results
if result is None:
continue
# Handle different result formats (dict or SearchResultItem)
text = _extract_value(result, "text") or _extract_value(result, "memory", "")
# Handle different result formats
text = result.get("text") or result.get("memory", "")
# CRITICAL: Handle metadata=None from mem0 - ensure always dict
metadata = _extract_value(result, "metadata") or {}
if not isinstance(metadata, dict):
metadata = {}
source = _extract_metadata_value(result, metadata, "source", "")
metadata = result.get("metadata") or {}
source = metadata.get("source", "")
chunk_id = _chunk_hash(text, source)
@@ -152,19 +106,17 @@ def truncate_context(
def build_context(
results: List[ResultItem],
results: List[Dict[str, Any]],
max_tokens: int = 4000,
deduplicate: bool = True,
separator: str = "\n\n---\n\n",
include_source: bool = True,
) -> Tuple[str, List[ResultItem]]:
) -> Tuple[str, List[Dict[str, Any]]]:
"""
Build context string from retrieval results.
Handles both dict format and SearchResultItem objects.
Args:
results: List of search results (dicts or SearchResultItem objects)
results: List of search results
max_tokens: Maximum tokens for context
deduplicate: Whether to deduplicate chunks
separator: Separator between chunks
@@ -181,7 +133,7 @@ def build_context(
results = deduplicate_chunks(results)
context_parts: List[str] = []
used_results: List[ResultItem] = []
used_results: List[Dict[str, Any]] = []
current_tokens = 0
separator_tokens = _estimate_tokens(separator)
@@ -189,21 +141,17 @@ def build_context(
# Skip None results
if result is None:
continue
# Handle different result formats (dict or SearchResultItem)
text = _extract_value(result, "text") or _extract_value(result, "memory", "")
# Handle different result formats
text = result.get("text") or result.get("memory", "")
if not text:
continue
# Build chunk text with optional source
if include_source:
# CRITICAL: Handle metadata=None from mem0 - ensure always dict
metadata = _extract_value(result, "metadata") or {}
if not isinstance(metadata, dict):
metadata = {}
source = _extract_metadata_value(result, metadata, "source", "")
filename = _extract_metadata_value(result, metadata, "filename", "")
metadata = result.get("metadata") or {}
source = metadata.get("source", "")
filename = metadata.get("filename", "")
source_label = filename or source or f"Source {i + 1}"
chunk_text = f"[{source_label}]\n{text}"
else:
@@ -246,14 +194,11 @@ class DefaultContextBuilder:
def build(
self,
results: List[ResultItem],
results: List[Dict[str, Any]],
max_tokens: int = 4000,
deduplicate: bool = True,
) -> str:
"""Build context string from results.
Handles both dict format and SearchResultItem objects.
"""
"""Build context string from results."""
context, _ = build_context(
results=results,
max_tokens=max_tokens,
@@ -1,29 +0,0 @@
# 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,22 +32,7 @@ When you provide a `session_id` to an Agent:
### Default Storage Location
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.
Sessions are stored in: `~/.praison/sessions/{session_id}.json`
### Session File Format
@@ -60,13 +45,7 @@ Fields are persisted in session JSON `metadata` and mirrored at root on save. Po
],
"created_at": "2026-01-02T04:00:00+00:00",
"updated_at": "2026-01-02T04:01:00+00:00",
"agent_name": "Assistant",
"agent_id": "agent-abc",
"source": "chat",
"model": "gpt-4o-mini",
"total_tokens": 128,
"cost": 0.0004,
"metadata": {}
"agent_name": "Assistant"
}
```
@@ -144,74 +144,6 @@ class HierarchicalSessionStore(DefaultSessionStore):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._extended_cache: Dict[str, ExtendedSessionData] = {}
self._cache_mtimes: Dict[str, float] = {} # Track file modification times
def _load_session_from_disk(self, session_id: str, filepath: str) -> ExtendedSessionData:
"""Load extended session JSON from disk (caller must hold FileLock)."""
if os.path.exists(filepath):
try:
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
return ExtendedSessionData.from_dict(data)
except (json.JSONDecodeError, IOError):
pass
return ExtendedSessionData(session_id=session_id)
def _modify_session_locked(
self,
session_id: str,
mutator,
*,
error_label: str = "modify session",
) -> bool:
"""Locked read-modify-write preserving extended session fields."""
result = super()._modify_session_locked(
session_id, mutator, error_label=error_label
)
if result:
with self._lock:
cached = self._cache.get(session_id)
if isinstance(cached, ExtendedSessionData):
self._extended_cache[session_id] = cached
return result
def _is_cache_valid(self, session_id: str) -> bool:
"""Check if cached session is still valid based on file mtime."""
if session_id not in self._extended_cache:
return False
filepath = self._get_session_path(session_id)
if not os.path.exists(filepath):
return False
try:
current_mtime = os.path.getmtime(filepath)
cached_mtime = self._cache_mtimes.get(session_id, 0)
return current_mtime <= cached_mtime
except (OSError, IOError):
return False
def _read_session_fresh(self, session_id: str) -> ExtendedSessionData:
"""Reload from disk and keep _cache and _extended_cache in sync."""
session = super()._read_session_fresh(session_id)
if not isinstance(session, ExtendedSessionData):
session = ExtendedSessionData.from_session_data(session)
with self._lock:
self._cache[session_id] = session
# Update cache with fresh file mtime
filepath = self._get_session_path(session_id)
try:
mtime = os.path.getmtime(filepath) if os.path.exists(filepath) else time.time()
except (OSError, IOError):
mtime = time.time()
with self._lock:
self._extended_cache[session_id] = session
self._cache_mtimes[session_id] = mtime
return session
def add_message(
self,
@@ -225,32 +157,56 @@ class HierarchicalSessionStore(DefaultSessionStore):
Overrides parent to preserve extended session data.
"""
from .store import SessionMessage
# Load extended session (force reload to get latest)
session = self._load_extended_session(session_id, force_reload=True)
message = SessionMessage(
role=role,
content=content,
timestamp=time.time(),
metadata=metadata or {},
)
def _apply(session: SessionData) -> None:
session.messages.append(message)
if len(session.messages) > self.max_messages:
session.messages = session.messages[-self.max_messages :]
return self._modify_session_locked(
session_id, _apply, error_label="add message to session"
)
session.messages.append(message)
# Trim messages if over limit
if len(session.messages) > self.max_messages:
session.messages = session.messages[-self.max_messages:]
return self._save_extended_session(session)
def _load_extended_session(self, session_id: str, force_reload: bool = False) -> ExtendedSessionData:
"""Load extended session with smart caching based on file modification time."""
# Force reload bypasses cache validation
if force_reload or not self._is_cache_valid(session_id):
return self._read_session_fresh(session_id)
"""Load extended session from disk."""
filepath = self._get_session_path(session_id)
# Check cache first (unless force reload)
if not force_reload:
with self._lock:
if session_id in self._extended_cache:
return self._extended_cache[session_id]
# Load from disk
if not os.path.exists(filepath):
session = ExtendedSessionData(session_id=session_id)
with self._lock:
self._extended_cache[session_id] = session
return session
with FileLock(filepath, self.lock_timeout):
try:
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
session = ExtendedSessionData.from_dict(data)
except (json.JSONDecodeError, IOError) as e:
logger.warning(f"Failed to load session {session_id}: {e}")
session = ExtendedSessionData(session_id=session_id)
# Cache is valid, return cached version
with self._lock:
return self._extended_cache[session_id]
self._extended_cache[session_id] = session
return session
def _save_extended_session(self, session: ExtendedSessionData) -> bool:
"""Save extended session to disk."""
@@ -278,15 +234,8 @@ class HierarchicalSessionStore(DefaultSessionStore):
os.replace(temp_path, filepath)
# Update cache with current file mtime after successful write
try:
mtime = os.path.getmtime(filepath)
except (OSError, IOError):
mtime = time.time()
with self._lock:
self._extended_cache[session.session_id] = session
self._cache_mtimes[session.session_id] = mtime
return True
except (IOError, OSError) as e:
@@ -329,13 +278,12 @@ class HierarchicalSessionStore(DefaultSessionStore):
metadata=metadata or {},
)
# Update parent's children list without clobbering concurrent message writes
# Update parent's children list
if parent_id:
def _apply(parent_session: SessionData) -> None:
assert isinstance(parent_session, ExtendedSessionData)
if sid not in parent_session.children_ids:
parent_session.children_ids.append(sid)
self._modify_session_locked(parent_id, _apply, error_label="update parent children")
parent = self._load_extended_session(parent_id)
if sid not in parent.children_ids:
parent.children_ids.append(sid)
self._save_extended_session(parent)
self._save_extended_session(session)
return sid
@@ -382,15 +330,11 @@ class HierarchicalSessionStore(DefaultSessionStore):
metadata=copy.deepcopy(parent.metadata),
)
# Update parent's children list
parent.children_ids.append(new_id)
self._save_extended_session(forked)
def _register_fork(parent: SessionData) -> None:
if new_id not in parent.children_ids:
parent.children_ids.append(new_id)
self._modify_session_locked(
session_id, _register_fork, error_label="register forked session"
)
self._save_extended_session(parent)
return new_id
@@ -441,22 +385,18 @@ class HierarchicalSessionStore(DefaultSessionStore):
Returns:
The snapshot ID
"""
# Force reload to get latest messages
session = self._load_extended_session(session_id, force_reload=True)
snapshot = SessionSnapshot(
session_id=session_id,
message_index=-1, # Set from fresh session inside locked write
message_index=len(session.messages) - 1 if session.messages else -1,
label=label,
metadata=metadata or {},
)
def _record_snapshot(session: SessionData) -> None:
snapshot.message_index = (
len(session.messages) - 1 if session.messages else -1
)
session.snapshots.append(snapshot)
self._modify_session_locked(
session_id, _record_snapshot, error_label="create snapshot"
)
session.snapshots.append(snapshot)
self._save_extended_session(session)
return snapshot.id
@@ -476,30 +416,26 @@ class HierarchicalSessionStore(DefaultSessionStore):
Returns:
True if successful
"""
def _apply(session: SessionData) -> None:
assert isinstance(session, ExtendedSessionData)
# Find the snapshot
snapshot = None
for s in session.snapshots:
if s.id == snapshot_id:
snapshot = s
break
if snapshot is None:
logger.warning(f"Snapshot {snapshot_id} not found")
raise ValueError(f"Snapshot {snapshot_id} not found")
# Revert messages
if snapshot.message_index >= 0:
session.messages = session.messages[:snapshot.message_index + 1]
else:
session.messages = []
try:
return self._modify_session_locked(session_id, _apply, error_label="revert to snapshot")
except ValueError:
session = self._load_extended_session(session_id)
# Find the snapshot
snapshot = None
for s in session.snapshots:
if s.id == snapshot_id:
snapshot = s
break
if snapshot is None:
logger.warning(f"Snapshot {snapshot_id} not found")
return False
# Revert messages
if snapshot.message_index >= 0:
session.messages = session.messages[:snapshot.message_index + 1]
else:
session.messages = []
return self._save_extended_session(session)
def revert_to_message(self, session_id: str, message_index: int) -> bool:
"""
@@ -512,33 +448,27 @@ class HierarchicalSessionStore(DefaultSessionStore):
Returns:
True if successful
"""
def _apply(session: SessionData) -> None:
assert isinstance(session, ExtendedSessionData)
if message_index < 0 or message_index >= len(session.messages):
logger.warning(f"Invalid message index {message_index}")
raise ValueError(f"Invalid message index {message_index}")
session.messages = session.messages[:message_index + 1]
try:
return self._modify_session_locked(session_id, _apply, error_label="revert to message")
except ValueError:
# Force reload to get latest messages
session = self._load_extended_session(session_id, force_reload=True)
if message_index < 0 or message_index >= len(session.messages):
logger.warning(f"Invalid message index {message_index}")
return False
session.messages = session.messages[:message_index + 1]
return self._save_extended_session(session)
def share_session(self, session_id: str) -> bool:
"""Mark a session as shared."""
def _apply(session: SessionData) -> None:
assert isinstance(session, ExtendedSessionData)
session.is_shared = True
return self._modify_session_locked(session_id, _apply, error_label="share session")
session = self._load_extended_session(session_id)
session.is_shared = True
return self._save_extended_session(session)
def unshare_session(self, session_id: str) -> bool:
"""Mark a session as not shared."""
def _apply(session: SessionData) -> None:
assert isinstance(session, ExtendedSessionData)
session.is_shared = False
return self._modify_session_locked(session_id, _apply, error_label="unshare session")
session = self._load_extended_session(session_id)
session.is_shared = False
return self._save_extended_session(session)
def is_shared(self, session_id: str) -> bool:
"""Check if a session is shared."""
@@ -547,10 +477,9 @@ class HierarchicalSessionStore(DefaultSessionStore):
def set_title(self, session_id: str, title: str) -> bool:
"""Set session title."""
def _apply(session: SessionData) -> None:
assert isinstance(session, ExtendedSessionData)
session.title = title
return self._modify_session_locked(session_id, _apply, error_label="set session title")
session = self._load_extended_session(session_id)
session.title = title
return self._save_extended_session(session)
async def auto_title(self, session_id: str) -> bool:
"""Generate and set title automatically from first exchange.
@@ -605,19 +534,12 @@ class HierarchicalSessionStore(DefaultSessionStore):
title = await generate_title_async(user_msg, assistant_msg)
if title and title.strip():
# Use locked read-modify-write to avoid overwriting concurrent updates
def _apply(fresh_session: SessionData) -> None:
assert isinstance(fresh_session, ExtendedSessionData)
# Only set title if it's still empty
if not fresh_session.title or not fresh_session.title.strip():
fresh_session.title = title.strip()
return await asyncio.to_thread(
self._modify_session_locked,
session_id,
_apply,
error_label="auto-title session"
)
# Reload session to avoid overwriting concurrent updates
fresh_session = await asyncio.to_thread(self._load_extended_session, session_id)
# Only set title if it's still empty
if not fresh_session.title or not fresh_session.title.strip():
fresh_session.title = title.strip()
return await asyncio.to_thread(self._save_extended_session, fresh_session)
except Exception as e:
# Title generation failed - log with context instead of silent failure
@@ -629,17 +551,7 @@ class HierarchicalSessionStore(DefaultSessionStore):
def get_extended_session(self, session_id: str) -> ExtendedSessionData:
"""Get extended session data."""
return self._read_session_fresh(session_id)
def invalidate_cache(self, session_id: Optional[str] = None) -> None:
"""Invalidate base and extended in-memory caches atomically."""
with self._lock:
if session_id:
self._cache.pop(session_id, None)
self._extended_cache.pop(session_id, None)
else:
self._cache.clear()
self._extended_cache.clear()
return self._load_extended_session(session_id)
def export_session(self, session_id: str) -> Dict[str, Any]:
"""
@@ -129,16 +129,3 @@ 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."""
...
@@ -15,14 +15,14 @@ import threading
import time
# fcntl is Unix-only; on Windows, use msvcrt for file locking
try:
if sys.platform != 'win32':
import fcntl
_HAS_FCNTL = True
except ImportError:
else:
_HAS_FCNTL = False
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Callable, Dict, List, Optional
from typing import Any, Dict, List, Optional
from ..paths import get_sessions_dir
@@ -81,7 +81,7 @@ class SessionData:
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
data = {
return {
"session_id": self.session_id,
"messages": [m.to_dict() for m in self.messages],
"created_at": self.created_at,
@@ -92,10 +92,6 @@ 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":
@@ -141,8 +137,7 @@ class FileLock:
self._lock_path = filepath + ".lock"
def __enter__(self):
if not self.acquire():
raise IOError(f"Failed to acquire file lock for {self.filepath} after {self.timeout}s")
self.acquire()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
@@ -167,11 +162,11 @@ class FileLock:
if _HAS_FCNTL:
fcntl.flock(self._lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
else:
# Warn once about degraded locking on non-Windows platforms without fcntl
# Warn once about degraded locking (should never happen with current platform detection)
global _WARNED_NO_FCNTL
if not _WARNED_NO_FCNTL:
logger.warning(
"File locking unavailable on this platform (fcntl not available); "
"File locking unavailable on this platform (no fcntl/msvcrt); "
"concurrent writers may corrupt session files."
)
_WARNED_NO_FCNTL = True
@@ -265,88 +260,32 @@ class DefaultSessionStore:
def _load_session(self, session_id: str) -> SessionData:
"""Load session from disk with file locking."""
filepath = self._get_session_path(session_id)
# When a session file exists, always reload under FileLock so reads
# from another DefaultSessionStore instance (or process) are visible.
if os.path.exists(filepath):
with FileLock(filepath, self.lock_timeout):
session = self._load_session_from_disk(session_id, filepath)
with self._lock:
self._cache[session_id] = session
return session
# Check cache first
with self._lock:
if session_id in self._cache:
return self._cache[session_id]
# Load from disk
if not os.path.exists(filepath):
session = SessionData(session_id=session_id)
self._cache[session_id] = session
with self._lock:
self._cache[session_id] = session
return session
def _load_session_from_disk(self, session_id: str, filepath: str) -> SessionData:
"""Load session JSON from disk (caller must hold FileLock)."""
if os.path.exists(filepath):
with FileLock(filepath, self.lock_timeout):
try:
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
return SessionData.from_dict(data)
except (json.JSONDecodeError, IOError):
pass
return SessionData(session_id=session_id)
def _read_session_fresh(self, session_id: str) -> SessionData:
"""Reload session from disk and refresh the in-process cache."""
filepath = self._get_session_path(session_id)
with FileLock(filepath, self.lock_timeout):
session = self._load_session_from_disk(session_id, filepath)
session = SessionData.from_dict(data)
except (json.JSONDecodeError, IOError) as e:
logger.warning(f"Failed to load session {session_id}: {e}")
session = SessionData(session_id=session_id)
with self._lock:
self._cache[session_id] = session
return session
def _modify_session_locked(
self,
session_id: str,
mutator: Callable[[SessionData], None],
*,
error_label: str = "modify session",
) -> bool:
"""Apply mutator after reloading from disk under FileLock."""
filepath = self._get_session_path(session_id)
with FileLock(filepath, self.lock_timeout):
session = self._load_session_from_disk(session_id, filepath)
mutator(session)
session.updated_at = datetime.now(timezone.utc).isoformat()
if len(session.messages) > self.max_messages:
session.messages = session.messages[-self.max_messages:]
try:
dir_path = os.path.dirname(filepath) or "."
os.makedirs(dir_path, exist_ok=True)
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=dir_path,
delete=False,
suffix=".tmp",
) as f:
json.dump(session.to_dict(), f, indent=2, ensure_ascii=False)
temp_path = f.name
os.replace(temp_path, filepath)
with self._lock:
self._cache[session_id] = session
return True
except (IOError, OSError) as e:
logger.error(f"Failed to {error_label} {session_id}: {e}")
try:
if "temp_path" in locals():
os.remove(temp_path)
except (IOError, OSError):
pass
return False
def _save_session(self, session: SessionData) -> bool:
"""Save session to disk with atomic write."""
@@ -496,13 +435,13 @@ class DefaultSessionStore:
Returns:
List of {"role": "user/assistant", "content": "..."} dicts.
"""
session = self._read_session_fresh(session_id)
session = self._load_session(session_id)
limit = max_messages or self.max_messages
return session.get_chat_history(limit)
def get_session(self, session_id: str) -> SessionData:
"""Get full session data."""
return self._read_session_fresh(session_id)
return self._load_session(session_id)
def set_agent_info(
self,
@@ -511,65 +450,27 @@ class DefaultSessionStore:
user_id: Optional[str] = None,
) -> bool:
"""Set agent info for a session."""
def _apply(session: SessionData) -> None:
session = self._load_session(session_id)
with self._lock:
if agent_name:
session.agent_name = agent_name
if user_id:
session.user_id = user_id
return self._modify_session_locked(
session_id, _apply, error_label="set agent info for session"
)
self._cache[session_id] = session
return self._save_session(session)
def clear_session(self, session_id: str) -> bool:
"""Clear all messages from a session."""
return self._modify_session_locked(
session_id,
lambda session: session.messages.clear(),
error_label="clear session",
)
def set_chat_history(
self,
session_id: str,
messages: List[Dict[str, str]],
) -> bool:
"""Replace session messages atomically (file-locked read-modify-write)."""
def _apply(session: SessionData) -> None:
session = self._load_session(session_id)
with self._lock:
session.messages.clear()
for msg in messages:
session.messages.append(
SessionMessage(
role=msg.get("role", "user"),
content=msg.get("content", ""),
timestamp=msg.get("timestamp", time.time()),
metadata=msg.get("metadata", {}),
)
)
return self._modify_session_locked(
session_id, _apply, error_label="set chat history"
)
def update_session_metadata(self, session_id: str, **fields: Any) -> bool:
"""Merge run stats / metadata fields into a persisted session."""
if not fields:
return True
def _apply(session: SessionData) -> None:
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)
return self._modify_session_locked(
session_id, _apply, error_label="update session metadata"
)
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)
@@ -599,16 +500,10 @@ 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
@@ -668,7 +563,7 @@ class DefaultSessionStore:
List of SessionData objects for the specified agent
"""
session_ids = self.list_sessions_by_agent(agent_name, limit)
return [self._read_session_fresh(sid) for sid in session_ids]
return [self._load_session(sid) for sid in session_ids]
def get_agent_chat_history(
self,
@@ -722,15 +617,16 @@ class DefaultSessionStore:
Returns:
True if saved successfully
"""
def _apply(session: SessionData) -> None:
session = self._load_session(session_id)
with self._lock:
if gateway_session_id:
session.gateway_session_id = gateway_session_id
if agent_id:
session.agent_id = agent_id
return self._modify_session_locked(
session_id, _apply, error_label="set gateway info for session"
)
self._cache[session_id] = session
return self._save_session(session)
def get_by_gateway_session(self, gateway_session_id: str) -> Optional[SessionData]:
"""Get session data linked to a gateway session.
Loaded 100 of 296 files, more files were not shown because too many files have changed in this diff. Show more