mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-07 17:33:33 -10:00
Compare commits
98
Commits
No files matched your search
@@ -200,6 +200,7 @@ jobs:
|
||||
allowed_bots: 'praisonai-triage-agent[bot]'
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ steps.app-token.outputs.token }}
|
||||
model: opus
|
||||
trigger_phrase: "@claude"
|
||||
label_trigger: "claude"
|
||||
|
||||
|
||||
@@ -22,8 +22,6 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -36,9 +36,6 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
|
||||
- name: Run Gemini Issue Triage
|
||||
|
||||
@@ -37,9 +37,6 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
|
||||
- name: Set up Git
|
||||
|
||||
@@ -48,9 +48,6 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
ref: ${{ steps.checkout_ref.outputs.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -60,16 +60,16 @@ jobs:
|
||||
- name: Run PraisonAI integration tests
|
||||
run: |
|
||||
cd PraisonAI/src/praisonai
|
||||
python -m pytest tests/integration/test_aiui_* -v --timeout=300
|
||||
python -m pytest tests/integration/test_aiui_* -v
|
||||
|
||||
- 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
|
||||
python -m pytest tests/integration/test_agentic_roundtrip.py -v
|
||||
fi
|
||||
if [ -f tests/test_feature_sdk_backends.py ]; then
|
||||
python -m pytest tests/test_feature_sdk_backends.py -v --timeout=300
|
||||
python -m pytest tests/test_feature_sdk_backends.py -v
|
||||
fi
|
||||
|
||||
- name: Test Pattern C CLI
|
||||
@@ -152,7 +152,7 @@ jobs:
|
||||
run: |
|
||||
cd PraisonAI/src/praisonai
|
||||
if [ -n "$OPENAI_API_KEY" ]; then
|
||||
python -m pytest tests/integration/test_aiui_host_agentic.py -v --timeout=600
|
||||
python -m pytest tests/integration/test_aiui_host_agentic.py -v
|
||||
else
|
||||
echo "OPENAI_API_KEY not available, skipping agentic tests"
|
||||
fi
|
||||
@@ -15,8 +15,8 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
|
||||
@@ -26,11 +26,8 @@ jobs:
|
||||
# First checkout the upstream repo at the specified tag
|
||||
- name: Checkout upstream repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: MervinPraison/PraisonAI
|
||||
ref: ${{ github.event.inputs.tag }}
|
||||
fetch-depth: 0
|
||||
@@ -48,11 +45,8 @@ jobs:
|
||||
# Now checkout the fork at main branch
|
||||
- name: Checkout fork
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: ${{ github.event.inputs.fork_owner }}/PraisonAI
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
@@ -449,11 +443,8 @@ jobs:
|
||||
# First checkout the fork's main branch
|
||||
- name: Checkout fork repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.PAT_TOKEN }}
|
||||
repository: ${{ github.event.inputs.fork_owner }}/PraisonAI
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Sync Claude Secrets to PraisonAIUI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Sync secrets to PraisonAIUI
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.SECRETS_ADMIN_PAT }}
|
||||
TARGET: MervinPraison/PraisonAIUI
|
||||
CLAUDE_APP_ID: ${{ secrets.CLAUDE_APP_ID }}
|
||||
CLAUDE_APP_PRIVATE_KEY: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
GH_TOKEN_VALUE: ${{ secrets.GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "SECRETS_ADMIN_PAT is not configured in this repository"
|
||||
exit 1
|
||||
fi
|
||||
echo -n "$CLAUDE_APP_ID" | gh secret set CLAUDE_APP_ID --repo "$TARGET"
|
||||
echo -n "$CLAUDE_APP_PRIVATE_KEY" | gh secret set CLAUDE_APP_PRIVATE_KEY --repo "$TARGET"
|
||||
echo -n "$CLAUDE_CODE_OAUTH_TOKEN" | gh secret set CLAUDE_CODE_OAUTH_TOKEN --repo "$TARGET"
|
||||
echo -n "$GH_TOKEN_VALUE" | gh secret set GH_TOKEN --repo "$TARGET"
|
||||
echo "Synced CLAUDE_APP_ID, CLAUDE_APP_PRIVATE_KEY, CLAUDE_CODE_OAUTH_TOKEN, GH_TOKEN → $TARGET"
|
||||
@@ -37,10 +37,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
|
||||
@@ -22,8 +22,8 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
|
||||
@@ -13,10 +13,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
@@ -81,10 +79,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
|
||||
@@ -33,10 +33,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
|
||||
@@ -45,8 +45,8 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
@@ -105,8 +105,8 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
@@ -187,8 +187,8 @@ jobs:
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
persist-credentials: false
|
||||
if: steps.check-key.outputs.has_key == 'true'
|
||||
|
||||
- name: Set up Python 3.11
|
||||
@@ -259,8 +259,8 @@ jobs:
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
persist-credentials: false
|
||||
if: steps.check-key.outputs.has_key == 'true'
|
||||
|
||||
- name: Set up Python 3.11
|
||||
@@ -318,8 +318,8 @@ jobs:
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
persist-credentials: false
|
||||
if: steps.check-key.outputs.has_key == 'true'
|
||||
|
||||
- name: Set up Python 3.11
|
||||
@@ -377,8 +377,8 @@ jobs:
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
persist-credentials: false
|
||||
if: steps.check-key.outputs.has_key == 'true'
|
||||
|
||||
- name: Set up Python 3.11
|
||||
@@ -436,8 +436,8 @@ jobs:
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
persist-credentials: false
|
||||
if: steps.check-key.outputs.has_key == 'true'
|
||||
|
||||
- name: Set up Python 3.11
|
||||
@@ -495,8 +495,8 @@ jobs:
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
persist-credentials: false
|
||||
if: steps.check-key.outputs.has_key == 'true'
|
||||
|
||||
- name: Set up Python 3.11
|
||||
|
||||
@@ -45,10 +45,8 @@ jobs:
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Agent Instructions
|
||||
|
||||
You are working on the PraisonAI project.
|
||||
|
||||
## Project Guidelines
|
||||
- Follow the existing code style and conventions
|
||||
- Be concise and helpful in responses
|
||||
- Test implementation thoroughly
|
||||
- Ensure backward compatibility with existing APIs
|
||||
- Follow protocol-driven design: core protocols in praisonaiagents/, heavy implementations in praisonai/
|
||||
@@ -770,11 +770,11 @@ agent.start('Write a movie script about a robot in Mars');
|
||||
|
||||
## ⚡ Performance
|
||||
|
||||
PraisonAI is built for speed, with agent instantiation in under 4μs. This reduces overhead, improves responsiveness, and helps multi-agent systems scale efficiently in real-world production workloads.
|
||||
PraisonAI is built for speed, with agent instantiation in around 14μs. This reduces overhead, improves responsiveness, and helps multi-agent systems scale efficiently in real-world production workloads.
|
||||
|
||||
| Performance Metric | PraisonAI |
|
||||
|--------------------|-----------|
|
||||
| Avg Instantiation Time | **3.77 μs** |
|
||||
| Avg Instantiation Time | **14 μs** |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -59,6 +59,8 @@ Methods:
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">handoff_to</a>(target_agent: 'Agent', prompt: str, context: Optional[Dict[str, Any]] = None, config: Optional['HandoffConfig'] = None) -> 'HandoffResult'</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">handoff_to_async</a>(target_agent: 'Agent', prompt: str, context: Optional[Dict[str, Any]] = None, config: Optional['HandoffConfig'] = None) -> 'HandoffResult'</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">is_closed</a>() -> bool</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">llm_instance</a>()</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">llm_instance</a>(value)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">llm_model</a>() -> Optional[str]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">output_style</a>() -> Optional[str]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">output_style</a>(value: Optional[str]) -> None</code>
|
||||
@@ -240,6 +242,7 @@ from praisonaiagents import Memory, MemoryBackend, MemoryConfig
|
||||
|
||||
Methods:
|
||||
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">build_cache_optimized_context</a>(task_descr: str, user_id: Optional[str] = None, additional: str = '', max_items: int = 3, include_cache_boundary: bool = True, include_in_output: Optional[bool] = None) -> Dict[str, str]</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">build_context_for_task</a>(task_descr: str, user_id: Optional[str] = None, additional: str = '', max_items: int = 3, include_in_output: Optional[bool] = None) -> str</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">calculate_quality_metrics</a>(output: str, expected_output: str, llm: Optional[str] = None, custom_prompt: Optional[str] = None) -> Dict[str, float]</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">close_connections</a>()</code>
|
||||
@@ -515,10 +518,12 @@ Methods:
|
||||
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">add_memory</a>(text: str, memory_type: str = 'long', **metadata) -> None</code>
|
||||
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">chat</a>(message: str, **kwargs) -> str</code>
|
||||
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">clear_memory</a>(memory_type: str = 'all') -> None</code>
|
||||
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">close</a>() -> None</code>
|
||||
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">create_agent</a>(*args, **kwargs) -> 'Agent'</code>
|
||||
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">get_context</a>(query: str, max_items: int = 3) -> str</code>
|
||||
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">get_state</a>(key: str, default: Any = None) -> Any</code>
|
||||
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">increment_state</a>(key: str, increment: int = 1, default: int = 0) -> None</code>
|
||||
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">is_expired</a>() -> bool</code>
|
||||
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">knowledge</a>() -> 'Knowledge'</code>
|
||||
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">memory</a>() -> 'Memory'</code>
|
||||
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">restore_state</a>() -> Dict[str, Any]</code>
|
||||
@@ -527,6 +532,7 @@ Methods:
|
||||
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">search_memory</a>(query: str, memory_type: str = 'long', limit: int = 5) -> List[Dict[str, Any]]</code>
|
||||
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">send_message</a>(message: str, **kwargs) -> str</code>
|
||||
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">set_state</a>(key: str, value: Any) -> None</code>
|
||||
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">time_to_expiry</a>() -> Optional[float]</code>
|
||||
|
||||
# MCP
|
||||
|
||||
@@ -698,7 +704,7 @@ Methods:
|
||||
|
||||
Types:
|
||||
```python
|
||||
from praisonaiagents import AgentAppConfig, AgentAppProtocol, AgentFlow, AgentManager, AgentOSConfig, AgentOSProtocol, AgentTeam, AutoApproveBackend, EmbeddingResult, aembedding, aembeddings, embedding, embeddings, get_dimensions
|
||||
from praisonaiagents import AgentAppConfig, AgentAppProtocol, AgentFlow, AgentManager, AgentOSConfig, AgentOSProtocol, AgentTeam, AutoApproveBackend, EmbeddingResult, __version__, aembedding, aembeddings, embedding, embeddings, get_dimensions
|
||||
```
|
||||
|
||||
Methods:
|
||||
@@ -794,6 +800,10 @@ Methods:
|
||||
* <code title="cli">praisonai agents list <a href="./src/praisonai/praisonai/cli/commands/agents.py">--help</a></code>
|
||||
* <code title="cli">praisonai app app <a href="./src/praisonai/praisonai/cli/commands/app.py">--help</a></code>
|
||||
* <code title="cli">praisonai audit agent-centric <a href="./src/praisonai/praisonai/cli/commands/audit.py">--help</a></code>
|
||||
* <code title="cli">praisonai auth list <a href="./src/praisonai/praisonai/cli/commands/auth.py">--help</a></code>
|
||||
* <code title="cli">praisonai auth login <a href="./src/praisonai/praisonai/cli/commands/auth.py">--help</a></code>
|
||||
* <code title="cli">praisonai auth logout <a href="./src/praisonai/praisonai/cli/commands/auth.py">--help</a></code>
|
||||
* <code title="cli">praisonai auth status <a href="./src/praisonai/praisonai/cli/commands/auth.py">--help</a></code>
|
||||
* <code title="cli">praisonai batch batch-run <a href="./src/praisonai/praisonai/cli/commands/batch.py">--help</a></code>
|
||||
* <code title="cli">praisonai batch list <a href="./src/praisonai/praisonai/cli/commands/batch.py">--help</a></code>
|
||||
* <code title="cli">praisonai batch report <a href="./src/praisonai/praisonai/cli/commands/batch.py">--help</a></code>
|
||||
@@ -846,6 +856,9 @@ Methods:
|
||||
* <code title="cli">praisonai config path <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
|
||||
* <code title="cli">praisonai config reset <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
|
||||
* <code title="cli">praisonai config set <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
|
||||
* <code title="cli">praisonai config show <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
|
||||
* <code title="cli">praisonai config sources <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
|
||||
* <code title="cli">praisonai config validate <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
|
||||
* <code title="cli">praisonai context add <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
|
||||
* <code title="cli">praisonai context clear <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
|
||||
* <code title="cli">praisonai context compact <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
|
||||
@@ -880,10 +893,12 @@ Methods:
|
||||
* <code title="cli">praisonai doctor db <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
|
||||
* <code title="cli">praisonai doctor doctor-callback <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
|
||||
* <code title="cli">praisonai doctor env <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
|
||||
* <code title="cli">praisonai doctor fix <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
|
||||
* <code title="cli">praisonai doctor mcp <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
|
||||
* <code title="cli">praisonai doctor network <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
|
||||
* <code title="cli">praisonai doctor packaging <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
|
||||
* <code title="cli">praisonai doctor performance <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
|
||||
* <code title="cli">praisonai doctor runtime <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>
|
||||
@@ -914,6 +929,9 @@ Methods:
|
||||
* <code title="cli">praisonai gateway install <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway logs <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway mint-link <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway pause <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway reconnect <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway resume <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <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>
|
||||
@@ -1004,6 +1022,14 @@ Methods:
|
||||
* <code title="cli">praisonai pairing list <a href="./src/praisonai/praisonai/cli/commands/pairing.py">--help</a></code>
|
||||
* <code title="cli">praisonai pairing revoke <a href="./src/praisonai/praisonai/cli/commands/pairing.py">--help</a></code>
|
||||
* <code title="cli">praisonai paths show <a href="./src/praisonai/praisonai/cli/commands/paths.py">--help</a></code>
|
||||
* <code title="cli">praisonai permissions allow <a href="./src/praisonai/praisonai/cli/commands/permissions.py">--help</a></code>
|
||||
* <code title="cli">praisonai permissions ask <a href="./src/praisonai/praisonai/cli/commands/permissions.py">--help</a></code>
|
||||
* <code title="cli">praisonai permissions deny <a href="./src/praisonai/praisonai/cli/commands/permissions.py">--help</a></code>
|
||||
* <code title="cli">praisonai permissions export <a href="./src/praisonai/praisonai/cli/commands/permissions.py">--help</a></code>
|
||||
* <code title="cli">praisonai permissions import-rules <a href="./src/praisonai/praisonai/cli/commands/permissions.py">--help</a></code>
|
||||
* <code title="cli">praisonai permissions list <a href="./src/praisonai/praisonai/cli/commands/permissions.py">--help</a></code>
|
||||
* <code title="cli">praisonai permissions remove <a href="./src/praisonai/praisonai/cli/commands/permissions.py">--help</a></code>
|
||||
* <code title="cli">praisonai permissions reset <a href="./src/praisonai/praisonai/cli/commands/permissions.py">--help</a></code>
|
||||
* <code title="cli">praisonai plugins create <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
|
||||
* <code title="cli">praisonai plugins disable <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
|
||||
* <code title="cli">praisonai plugins discover <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
|
||||
@@ -1302,7 +1328,7 @@ export { // Python parity additions
|
||||
export { // Subagent Tool (agent-as-tool pattern)
|
||||
SubagentTool, BaseTool, FunctionTool, ToolRegistry, ToolResult, ToolValidationError, createDelegator, createSubagentTool, createSubagentTools, createTool, getRegistry, getTool, registerTool, tool, validateTool } from "./tools";
|
||||
export { airweaveSearch, bedrockBrowserClick, bedrockBrowserFill, bedrockBrowserNavigate, bedrockCodeInterpreter, codeExecution, codeMode, createCustomTool, exaSearch, firecrawlCrawl, firecrawlScrape, parallelSearch, perplexitySearch, registerCustomTool, registerLocalTool, registerNpmTool, superagentGuard, superagentRedact, superagentVerify, tavilyCrawl, tavilyExtract, tavilySearch, valyuBioSearch, valyuCompanyResearch, valyuEconomicsSearch, valyuFinanceSearch, valyuPaperSearch, valyuPatentSearch, valyuSecSearch, valyuWebSearch } from "./tools/builtins";
|
||||
export { BudgetExceededError, MissingDependencyError, MissingEnvVarError, ToolsRegistry, composeMiddleware, createLoggingMiddleware, createRateLimitMiddleware, createRedactionMiddleware, createRetryMiddleware, createTimeoutMiddleware, createToolsRegistry, createTracingMiddleware, createValidationMiddleware, getRegistry, getTool, getToolsRegistry, get_registry, get_tool, registerTool, register_tool, resetToolsRegistry, validateTool, validate_tool } from "./tools/registry";
|
||||
export { BudgetExceededError, MissingDependencyError, MissingEnvVarError, ToolsRegistry, composeMiddleware, createLoggingMiddleware, createRateLimitMiddleware, createRedactionMiddleware, createRetryMiddleware, createTimeoutMiddleware, createToolsRegistry, createTracingMiddleware, createValidationMiddleware, getToolsRegistry, get_registry, get_tool, register_tool, resetToolsRegistry, validate_tool } from "./tools/registry";
|
||||
export type { InstallHints, PraisonTool, RedactionHooks, RegisteredTool, ToolCapabilities, ToolExecutionContext, ToolExecutionResult, ToolFactory, ToolHooks, ToolInstallStatus, ToolLimits, ToolLogger, ToolMetadata, ToolMiddleware, ToolParameterProperty, ToolParameterSchema } from "./tools/registry";
|
||||
export { registerBuiltinTools, tools } from "./tools/tools";
|
||||
export { // Classes
|
||||
|
||||
@@ -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.55" \
|
||||
"praisonai>=4.6.64" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.55" \
|
||||
"praisonai>=4.6.64" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.55" \
|
||||
"praisonai>=4.6.64" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ workflow = AgentFlow(
|
||||
),
|
||||
],
|
||||
# Workflow-level consolidated params
|
||||
output=WorkflowOutputConfig(output="verbose", stream=True),
|
||||
output=WorkflowOutputConfig(verbose=True, stream=True),
|
||||
planning=WorkflowPlanningConfig(enabled=True, reasoning=True),
|
||||
memory=WorkflowMemoryConfig(backend="file"),
|
||||
# Agent-like params
|
||||
|
||||
@@ -5,12 +5,12 @@ Demonstrates web search with consolidated params.
|
||||
Presets: duckduckgo, tavily, google, bing, serper, search_only, fetch_only
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents import Agent, WebConfig
|
||||
|
||||
# Basic: Enable web search with preset
|
||||
# Basic: Enable web search with preset (search only — fetch needs Claude)
|
||||
agent = Agent(
|
||||
instructions="You are a research assistant with web access.",
|
||||
web="duckduckgo", # Presets: duckduckgo, tavily, google, bing, serper
|
||||
web=WebConfig(search=True, fetch=False, search_provider="duckduckgo"),
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -6,10 +6,18 @@ It includes self-reflection for improving code quality and multiple specialized
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, Task, AgentTeam
|
||||
from praisonaiagents.tools import CodeInterpreter
|
||||
|
||||
# Initialize code execution tool
|
||||
code_interpreter = CodeInterpreter()
|
||||
def run_python_code(code: str) -> str:
|
||||
"""Execute Python code safely and return stdout or errors."""
|
||||
import io
|
||||
import contextlib
|
||||
try:
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
exec(code, {"__builtins__": __builtins__})
|
||||
return buf.getvalue() or "Executed successfully (no output)"
|
||||
except Exception as e:
|
||||
return f"Execution error: {e}"
|
||||
|
||||
# Custom code analysis tool
|
||||
def analyze_code_complexity(code: str) -> str:
|
||||
@@ -32,9 +40,7 @@ code_writer = Agent(
|
||||
goal="Write clean, efficient, and well-documented Python code",
|
||||
backstory="You are a senior Python developer with 10+ years of experience in writing production-quality code.",
|
||||
instructions="Write Python code that follows PEP 8 standards, includes proper error handling, and has clear documentation.",
|
||||
reflection=True,
|
||||
|
||||
|
||||
output="silent",
|
||||
)
|
||||
|
||||
code_reviewer = Agent(
|
||||
@@ -52,7 +58,7 @@ code_executor = Agent(
|
||||
goal="Safely execute and test Python code",
|
||||
backstory="You are responsible for running code and reporting results or errors.",
|
||||
instructions="Execute the provided code safely and report the output or any errors encountered.",
|
||||
tools=[code_interpreter]
|
||||
tools=[run_python_code]
|
||||
)
|
||||
|
||||
# Example tasks
|
||||
@@ -87,7 +93,7 @@ def demonstrate_code_agent():
|
||||
workflow = AgentTeam(
|
||||
agents=[code_writer, code_reviewer, code_executor],
|
||||
tasks=[write_task, review_task, execute_task],
|
||||
process="sequential", output="verbose"
|
||||
process="sequential", output="silent"
|
||||
)
|
||||
|
||||
# Run the workflow
|
||||
@@ -106,10 +112,8 @@ general_code_agent = Agent(
|
||||
3. Explain code concepts
|
||||
4. Refactor code for better performance
|
||||
5. Add tests and documentation""",
|
||||
tools=[code_interpreter, analyze_code_complexity],
|
||||
reflection=True,
|
||||
|
||||
|
||||
tools=[run_python_code, analyze_code_complexity],
|
||||
output="silent",
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -41,21 +41,25 @@ def calculate_statistics(numbers: list) -> str:
|
||||
"""Calculate basic statistics for a list of numbers."""
|
||||
if not numbers:
|
||||
return "No numbers provided"
|
||||
|
||||
n = len(numbers)
|
||||
mean = sum(numbers) / n
|
||||
sorted_nums = sorted(numbers)
|
||||
try:
|
||||
nums = [float(x) for x in numbers]
|
||||
except (TypeError, ValueError):
|
||||
return "Error: all values must be numbers"
|
||||
|
||||
n = len(nums)
|
||||
mean = sum(nums) / n
|
||||
sorted_nums = sorted(nums)
|
||||
median = sorted_nums[n//2] if n % 2 else (sorted_nums[n//2-1] + sorted_nums[n//2]) / 2
|
||||
variance = sum((x - mean)**2 for x in numbers) / n
|
||||
variance = sum((x - mean)**2 for x in nums) / n
|
||||
std_dev = math.sqrt(variance)
|
||||
|
||||
|
||||
return f"""Statistics:
|
||||
- Count: {n}
|
||||
- Mean: {mean:.4f}
|
||||
- Median: {median:.4f}
|
||||
- Standard Deviation: {std_dev:.4f}
|
||||
- Min: {min(numbers)}
|
||||
- Max: {max(numbers)}"""
|
||||
- Min: {min(nums)}
|
||||
- Max: {max(nums)}"""
|
||||
|
||||
# Create specialized math agents
|
||||
calculation_agent = Agent(
|
||||
@@ -66,8 +70,7 @@ calculation_agent = Agent(
|
||||
instructions="Perform the requested calculations step by step, showing your work clearly.",
|
||||
tools=[basic_calculator, solve_quadratic, calculate_statistics],
|
||||
reflection=True,
|
||||
|
||||
|
||||
output="silent",
|
||||
)
|
||||
|
||||
problem_solver_agent = Agent(
|
||||
@@ -82,8 +85,7 @@ problem_solver_agent = Agent(
|
||||
4. Show each step of the solution
|
||||
5. Verify the answer makes sense""",
|
||||
reflection=True,
|
||||
|
||||
|
||||
output="silent",
|
||||
)
|
||||
|
||||
math_tutor_agent = Agent(
|
||||
@@ -91,7 +93,8 @@ math_tutor_agent = Agent(
|
||||
role="Mathematics tutor and explainer",
|
||||
goal="Explain mathematical concepts clearly and help users understand math",
|
||||
backstory="You are a patient math tutor who can explain concepts at any level from elementary to advanced.",
|
||||
instructions="Explain mathematical concepts using simple language, provide examples, and check for understanding."
|
||||
instructions="Explain mathematical concepts using simple language, provide examples, and check for understanding.",
|
||||
output="silent",
|
||||
)
|
||||
|
||||
# Example workflow for solving complex problems
|
||||
@@ -126,7 +129,8 @@ def solve_math_problem_workflow():
|
||||
workflow = AgentTeam(
|
||||
agents=[problem_solver_agent, calculation_agent, math_tutor_agent],
|
||||
tasks=[understand_task, solve_task, verify_task],
|
||||
process="sequential", output="verbose"
|
||||
process="sequential",
|
||||
output="silent",
|
||||
)
|
||||
|
||||
return workflow.start()
|
||||
@@ -148,8 +152,7 @@ math_assistant = Agent(
|
||||
Always show your work step by step.""",
|
||||
tools=[basic_calculator, solve_quadratic, calculate_statistics],
|
||||
reflection=True,
|
||||
|
||||
|
||||
output="silent",
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Example: Bot In-Flight Run Control
|
||||
|
||||
Demonstrates how to use SessionRunControl to provide better UX for bots
|
||||
during long-running agent operations. Shows busy feedback, pending message
|
||||
handling, and /stop command support.
|
||||
|
||||
This example shows the "before" and "after" behavior described in issue #1914.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
# Mock agent for demonstration
|
||||
class MockAgent:
|
||||
"""Simple mock agent that simulates long-running tasks."""
|
||||
|
||||
def __init__(self, name="assistant"):
|
||||
self.name = name
|
||||
self.chat_history = []
|
||||
self._interrupt_controller = None
|
||||
|
||||
def chat(self, prompt: str) -> str:
|
||||
"""Simulate a long-running chat response."""
|
||||
print(f"[Agent] Starting to process: '{prompt[:50]}...'")
|
||||
|
||||
# Simulate long processing with interrupt checking
|
||||
for i in range(30): # 3 second task (30 x 0.1s)
|
||||
time.sleep(0.1)
|
||||
|
||||
# Check for interruption
|
||||
if (self._interrupt_controller and
|
||||
hasattr(self._interrupt_controller, 'is_set') and
|
||||
self._interrupt_controller.is_set()):
|
||||
reason = getattr(self._interrupt_controller, 'reason', 'unknown')
|
||||
print(f"[Agent] ⚠️ Interrupted: {reason}")
|
||||
raise InterruptedError(f"Task cancelled: {reason}")
|
||||
|
||||
response = f"Completed analysis of: {prompt}"
|
||||
print(f"[Agent] ✅ Finished: {response}")
|
||||
return response
|
||||
|
||||
|
||||
async def demo_without_run_control():
|
||||
"""Demonstrate the old behavior (silent blocking)."""
|
||||
print("\n" + "="*60)
|
||||
print("🔴 BEFORE: Without Run Control (Silent Blocking)")
|
||||
print("="*60)
|
||||
|
||||
from praisonai.bots._session import BotSessionManager
|
||||
|
||||
# Standard session manager (no run control)
|
||||
session_mgr = BotSessionManager()
|
||||
agent = MockAgent("research-agent")
|
||||
user_id = "user123"
|
||||
|
||||
print("\n1. User sends long task:")
|
||||
print(" User: 'research quantum computing trends'")
|
||||
|
||||
# Start first task (this will run for 3 seconds)
|
||||
task1 = asyncio.create_task(
|
||||
session_mgr.chat(agent, user_id, "research quantum computing trends")
|
||||
)
|
||||
|
||||
# Simulate user sending follow-up messages during execution
|
||||
await asyncio.sleep(0.5)
|
||||
print("\n2. User sends follow-up (this will block silently):")
|
||||
print(" User: 'actually focus on business impact'")
|
||||
|
||||
task2 = asyncio.create_task(
|
||||
session_mgr.chat(agent, user_id, "actually focus on business impact")
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
print("\n3. User tries to send /stop (this will also block):")
|
||||
print(" User: '/stop'")
|
||||
|
||||
task3 = asyncio.create_task(
|
||||
session_mgr.chat(agent, user_id, "/stop")
|
||||
)
|
||||
|
||||
print("\n⏳ Waiting for all messages to complete...")
|
||||
results = await asyncio.gather(task1, task2, task3)
|
||||
|
||||
print("\n📊 Results (all processed sequentially with no feedback):")
|
||||
for i, result in enumerate(results, 1):
|
||||
print(f" {i}. {result}")
|
||||
|
||||
print("\n❌ Problems with this approach:")
|
||||
print(" - No immediate feedback on follow-up messages")
|
||||
print(" - No way to cancel long-running tasks")
|
||||
print(" - User doesn't know if bot is working or broken")
|
||||
|
||||
|
||||
async def demo_with_run_control():
|
||||
"""Demonstrate the new behavior with run control."""
|
||||
print("\n" + "="*60)
|
||||
print("🟢 AFTER: With Run Control (Better UX)")
|
||||
print("="*60)
|
||||
|
||||
from praisonai.bots._run_control import SessionRunControl
|
||||
from praisonai.bots._session import BotSessionManager
|
||||
from praisonai.bots._commands import handle_stop_command
|
||||
|
||||
# Session manager with run control
|
||||
run_control = SessionRunControl(
|
||||
busy_mode="queue",
|
||||
busy_ack_template="⏳ {action} — will process after current task finishes"
|
||||
)
|
||||
session_mgr = BotSessionManager(run_control=run_control)
|
||||
agent = MockAgent("research-agent")
|
||||
user_id = "user123"
|
||||
|
||||
print("\n1. User sends long task:")
|
||||
print(" User: 'research quantum computing trends'")
|
||||
|
||||
# Start first task
|
||||
task1 = asyncio.create_task(
|
||||
session_mgr.chat_with_run_control(agent, user_id, "research quantum computing trends")
|
||||
)
|
||||
|
||||
# Immediate follow-up messages get acknowledgments
|
||||
await asyncio.sleep(0.5)
|
||||
print("\n2. User sends follow-up (gets immediate feedback):")
|
||||
print(" User: 'actually focus on business impact'")
|
||||
|
||||
result2 = await session_mgr.chat_with_run_control(
|
||||
agent, user_id, "actually focus on business impact"
|
||||
)
|
||||
print(f" Bot: {result2['response']}")
|
||||
print(f" Metadata: {result2['metadata']}")
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
print("\n3. User decides to stop current task:")
|
||||
print(" User: '/stop'")
|
||||
|
||||
stop_response = await handle_stop_command(user_id, run_control)
|
||||
print(f" Bot: {stop_response}")
|
||||
|
||||
# Wait for first task to complete (it should be cancelled)
|
||||
try:
|
||||
result1 = await task1
|
||||
print(f"\n📊 Task 1 result: {result1['response']}")
|
||||
print(f" Metadata: {result1['metadata']}")
|
||||
except asyncio.CancelledError:
|
||||
print("\n📊 Task 1 was cancelled as expected")
|
||||
|
||||
print("\n4. User sends fresh request after stopping:")
|
||||
print(" User: 'what's the weather like?'")
|
||||
|
||||
result4 = await session_mgr.chat_with_run_control(
|
||||
agent, user_id, "what's the weather like?"
|
||||
)
|
||||
print(f" Bot: {result4['response']}")
|
||||
|
||||
print("\n✅ Benefits of this approach:")
|
||||
print(" - Immediate feedback on all messages")
|
||||
print(" - Can cancel long-running tasks with /stop")
|
||||
print(" - Pending messages are queued and processed in order")
|
||||
print(" - User always knows what's happening")
|
||||
|
||||
|
||||
async def demo_interrupt_mode():
|
||||
"""Demonstrate interrupt mode."""
|
||||
print("\n" + "="*60)
|
||||
print("⚡ BONUS: Interrupt Mode (Cancel and Restart)")
|
||||
print("="*60)
|
||||
|
||||
from praisonai.bots._run_control import SessionRunControl
|
||||
from praisonai.bots._session import BotSessionManager
|
||||
|
||||
# Use interrupt mode instead of queue mode
|
||||
run_control = SessionRunControl(busy_mode="interrupt")
|
||||
session_mgr = BotSessionManager(run_control=run_control)
|
||||
agent = MockAgent("research-agent")
|
||||
user_id = "user123"
|
||||
|
||||
print("\n1. User starts long task:")
|
||||
print(" User: 'research renewable energy for 30 pages'")
|
||||
|
||||
task1 = asyncio.create_task(
|
||||
session_mgr.chat_with_run_control(agent, user_id, "research renewable energy for 30 pages")
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
print("\n2. User changes mind (interrupts and restarts):")
|
||||
print(" User: 'actually just summarize solar panel efficiency'")
|
||||
|
||||
result2 = await session_mgr.chat_with_run_control(
|
||||
agent, user_id, "actually just summarize solar panel efficiency"
|
||||
)
|
||||
print(f" Bot: {result2['response']}")
|
||||
|
||||
# Wait for original task (should be interrupted)
|
||||
try:
|
||||
result1 = await task1
|
||||
print(f"\n📊 Original task result: {result1['response']}")
|
||||
if result1['metadata'].get('interrupted'):
|
||||
print(" ✅ Original task was properly interrupted")
|
||||
except:
|
||||
print("\n📊 Original task was cancelled")
|
||||
|
||||
print("\n✅ Interrupt mode is great for:")
|
||||
print(" - When users frequently change direction")
|
||||
print(" - Interactive sessions where latest intent matters most")
|
||||
print(" - Real-time collaboration scenarios")
|
||||
|
||||
|
||||
async def main():
|
||||
"""Run all demonstrations."""
|
||||
print("🚀 Bot In-Flight Run Control Demo")
|
||||
print("Solving the silent lock problem described in issue #1914")
|
||||
|
||||
# Show the problem
|
||||
await demo_without_run_control()
|
||||
|
||||
# Show the solution
|
||||
await demo_with_run_control()
|
||||
|
||||
# Show advanced feature
|
||||
await demo_interrupt_mode()
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("🎯 Summary")
|
||||
print("="*60)
|
||||
print("SessionRunControl solves the silent blocking problem by providing:")
|
||||
print("1. Immediate feedback for mid-run messages")
|
||||
print("2. /stop command support via InterruptController")
|
||||
print("3. Configurable policies (queue/interrupt/steer)")
|
||||
print("4. Run generation tracking to prevent race conditions")
|
||||
print("5. Pending message merging and ordering")
|
||||
print("\nThis makes bot interactions feel responsive and predictable!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -6,7 +6,6 @@ to different next steps based on output content.
|
||||
"""
|
||||
|
||||
from praisonaiagents import AgentFlow, Task
|
||||
from praisonaiagents import AgentFlowManager
|
||||
|
||||
# Create a workflow with branching
|
||||
workflow = AgentFlow(
|
||||
@@ -36,16 +35,9 @@ workflow = AgentFlow(
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Create manager and register workflow
|
||||
manager = WorkflowManager()
|
||||
manager.workflows["Decision Workflow"] = workflow
|
||||
|
||||
# Execute - will branch to success_handler since 42 is positive
|
||||
result = manager.execute(
|
||||
"Decision Workflow",
|
||||
default_llm="gpt-4o-mini"
|
||||
)
|
||||
|
||||
result = workflow.run("", llm="gpt-4o-mini", verbose=True)
|
||||
print("Workflow completed!")
|
||||
for step_result in result["results"]:
|
||||
print(f" {step_result['step']}: {step_result['status']}")
|
||||
for step in result.get("steps", []):
|
||||
name = step.get("step", step.get("name", "unknown"))
|
||||
status = step.get("status", "completed")
|
||||
print(f" {name}: {status}")
|
||||
@@ -6,9 +6,8 @@ functions that return StepResult with stop_workflow=True.
|
||||
"""
|
||||
|
||||
from praisonaiagents import (
|
||||
Workflow, Task, WorkflowContext, StepResult
|
||||
AgentFlow, Task, WorkflowContext, StepResult
|
||||
)
|
||||
from praisonaiagents import AgentFlowManager
|
||||
|
||||
# Custom validator that can stop the workflow
|
||||
def validate_data(context: WorkflowContext) -> StepResult:
|
||||
@@ -50,20 +49,13 @@ workflow = AgentFlow(
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
manager = WorkflowManager()
|
||||
manager.workflows["Data Processing"] = workflow
|
||||
|
||||
# Test with invalid data (will stop early)
|
||||
print("=== Testing with invalid data ===")
|
||||
result = manager.execute("Data Processing", default_llm="gpt-4o-mini")
|
||||
|
||||
for step_result in result["results"]:
|
||||
print(f" {step_result['step']}: {step_result['output']}")
|
||||
|
||||
# Test with valid data (will complete)
|
||||
result = workflow.run("", llm="gpt-4o-mini", verbose=True)
|
||||
for step in result.get("steps", []):
|
||||
print(f" {step.get('step', 'step')}: {step.get('output', step.get('status'))}")
|
||||
|
||||
print("\n=== Testing with valid data ===")
|
||||
workflow.variables["data"] = {"value": 42}
|
||||
result = manager.execute("Data Processing", default_llm="gpt-4o-mini")
|
||||
|
||||
for step_result in result["results"]:
|
||||
print(f" {step_result['step']}: {step_result['status']}")
|
||||
result = workflow.run("", llm="gpt-4o-mini", verbose=True)
|
||||
for step in result.get("steps", []):
|
||||
print(f" {step.get('step', 'step')}: {step.get('status', 'completed')}")
|
||||
@@ -6,7 +6,6 @@ Each item is processed individually with the loop variable.
|
||||
"""
|
||||
|
||||
from praisonaiagents import AgentFlow, Task
|
||||
from praisonaiagents import AgentFlowManager
|
||||
|
||||
# Create a workflow with loop
|
||||
workflow = AgentFlow(
|
||||
@@ -30,18 +29,12 @@ workflow = AgentFlow(
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Create manager and register workflow
|
||||
manager = WorkflowManager()
|
||||
manager.workflows["Process Items"] = workflow
|
||||
|
||||
# Execute - will process each fruit in the list
|
||||
result = manager.execute(
|
||||
"Process Items",
|
||||
default_llm="gpt-4o-mini"
|
||||
)
|
||||
|
||||
result = workflow.run("", llm="gpt-4o-mini", verbose=True)
|
||||
print("Workflow completed!")
|
||||
for step_result in result["results"]:
|
||||
print(f" {step_result['step']}: {step_result['status']}")
|
||||
if isinstance(step_result.get("output"), list):
|
||||
print(f" Loop processed {len(step_result['output'])} items")
|
||||
for step in result.get("steps", []):
|
||||
name = step.get("step", step.get("name", "unknown"))
|
||||
status = step.get("status", "completed")
|
||||
print(f" {name}: {status}")
|
||||
out = step.get("output")
|
||||
if isinstance(out, list):
|
||||
print(f" Loop processed {len(out)} items")
|
||||
@@ -53,7 +53,7 @@ workflow = AgentFlow(
|
||||
# Step 4: Function - formats output
|
||||
format_output
|
||||
],
|
||||
default_llm="gpt-4o-mini" # Used for action-based steps
|
||||
llm="gpt-4o-mini" # Used for action-based steps
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -5,7 +5,7 @@ Demonstrates running multiple agents concurrently and
|
||||
combining their results with an aggregator agent.
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, Workflow
|
||||
from praisonaiagents import Agent, AgentFlow
|
||||
from praisonaiagents.workflows import parallel
|
||||
|
||||
# Create parallel research agents
|
||||
|
||||
@@ -5,7 +5,7 @@ Demonstrates decision-based routing where an agent classifier
|
||||
routes to specialized handler agents based on the request type.
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, Workflow
|
||||
from praisonaiagents import Agent, AgentFlow
|
||||
from praisonaiagents.workflows import route
|
||||
|
||||
# Create classifier agent
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Documentation Parity Tracker (Python)
|
||||
|
||||
> **Categories:** 60 | **Documented:** 57 | **Parity:** 95.0%
|
||||
> **Categories:** 64 | **Documented:** 61 | **Parity:** 95.3%
|
||||
|
||||
This report compares **Python SDK feature categories** against **Python documentation** (docs/concepts, docs/features, etc.).
|
||||
|
||||
@@ -8,72 +8,76 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Feature Categories | 60 |
|
||||
| **Documented Categories** | **57** |
|
||||
| Feature Categories | 64 |
|
||||
| **Documented Categories** | **61** |
|
||||
| **Undocumented Categories** | **3** |
|
||||
| **Parity** | **95.0%** |
|
||||
| **Parity** | **95.3%** |
|
||||
|
||||
## Documented Categories
|
||||
|
||||
| Category | Features | Docs | Lines |
|
||||
|----------|----------|------|-------|
|
||||
| ✅ AGUI | 1 | 2 | 420 |
|
||||
| ✅ Agent | 19 | 30 | 8367 |
|
||||
| ✅ Agent-to-Agent (A2A) | 1 | 3 | 625 |
|
||||
| ✅ Approval | 1 | 2 | 889 |
|
||||
| ✅ Agent | 21 | 34 | 9360 |
|
||||
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2880 |
|
||||
| ✅ Agent-to-User (A2U) | 1 | 3 | 547 |
|
||||
| ✅ Approval | 1 | 3 | 1283 |
|
||||
| ✅ Audio | 2 | 10 | 649 |
|
||||
| ✅ Auto Generation | 3 | 6 | 1386 |
|
||||
| ✅ Autonomy | 3 | 4 | 1146 |
|
||||
| ✅ Bots | 5 | 2 | 573 |
|
||||
| ✅ Auto Generation | 4 | 6 | 1724 |
|
||||
| ✅ Autonomy | 3 | 5 | 1919 |
|
||||
| ✅ Bots | 7 | 14 | 5228 |
|
||||
| ✅ Budget | 1 | 1 | 287 |
|
||||
| ✅ CLI | 5 | 98 | 24103 |
|
||||
| ✅ Chunking | 2 | 2 | 285 |
|
||||
| ✅ Citations | 2 | 1 | 202 |
|
||||
| ✅ Code Execution | 3 | 13 | 3395 |
|
||||
| ✅ Code Execution | 3 | 13 | 3551 |
|
||||
| ✅ Conditions | 1 | 3 | 1086 |
|
||||
| ✅ Configuration | 2 | 7 | 1753 |
|
||||
| ✅ Context Management | 13 | 29 | 7576 |
|
||||
| ✅ Database | 1 | 31 | 3037 |
|
||||
| ✅ Configuration | 3 | 7 | 1812 |
|
||||
| ✅ Context Management | 16 | 31 | 9294 |
|
||||
| ✅ Database | 1 | 34 | 4339 |
|
||||
| ✅ Display | 6 | 2 | 1175 |
|
||||
| ✅ Embeddings | 6 | 23 | 1966 |
|
||||
| ✅ Evaluation | 1 | 6 | 2538 |
|
||||
| ✅ Events | 1 | 2 | 699 |
|
||||
| ✅ Execution | 3 | 2 | 515 |
|
||||
| ✅ Files | 2 | 5 | 863 |
|
||||
| ✅ Flow | 1 | 1 | 284 |
|
||||
| ✅ Gateway | 6 | 1 | 256 |
|
||||
| ✅ Guardrails | 4 | 4 | 2279 |
|
||||
| ✅ Handoffs | 10 | 4 | 1674 |
|
||||
| ✅ Hooks | 2 | 6 | 1606 |
|
||||
| ✅ Evaluation | 1 | 6 | 2539 |
|
||||
| ✅ Events | 1 | 2 | 704 |
|
||||
| ✅ Execution | 3 | 3 | 1059 |
|
||||
| ✅ Files | 2 | 6 | 1100 |
|
||||
| ✅ Flow | 1 | 2 | 435 |
|
||||
| ✅ Gateway | 6 | 10 | 3924 |
|
||||
| ✅ Guardrails | 4 | 4 | 2008 |
|
||||
| ✅ Handoffs | 11 | 5 | 2155 |
|
||||
| ✅ Hooks | 2 | 6 | 1916 |
|
||||
| ✅ Image | 1 | 11 | 1096 |
|
||||
| ✅ Knowledge | 3 | 12 | 3262 |
|
||||
| ✅ LLM | 2 | 2 | 1049 |
|
||||
| ✅ Loops | 4 | 1 | 250 |
|
||||
| ✅ MCP | 2 | 47 | 9754 |
|
||||
| ✅ Memory | 6 | 12 | 4474 |
|
||||
| ✅ Output | 3 | 4 | 868 |
|
||||
| ✅ Parallel Execution | 2 | 2 | 347 |
|
||||
| ✅ Planning | 6 | 5 | 1506 |
|
||||
| ✅ Plugins | 8 | 3 | 1494 |
|
||||
| ✅ Prompts | 2 | 4 | 796 |
|
||||
| ✅ Providers | 2 | 50 | 5396 |
|
||||
| ✅ Knowledge | 3 | 12 | 3466 |
|
||||
| ✅ LLM | 3 | 5 | 2251 |
|
||||
| ✅ Loops | 4 | 3 | 774 |
|
||||
| ✅ MCP | 2 | 51 | 10960 |
|
||||
| ✅ Memory | 6 | 16 | 5855 |
|
||||
| ✅ Observability | 2 | 23 | 2590 |
|
||||
| ✅ Output | 3 | 4 | 903 |
|
||||
| ✅ Parallel Execution | 3 | 2 | 399 |
|
||||
| ✅ Planning | 6 | 5 | 1519 |
|
||||
| ✅ Plugins | 8 | 3 | 1502 |
|
||||
| ✅ Prompts | 2 | 7 | 1570 |
|
||||
| ✅ Providers | 2 | 51 | 5911 |
|
||||
| ✅ Query | 1 | 2 | 616 |
|
||||
| ✅ RAG | 5 | 14 | 2910 |
|
||||
| ✅ RAG | 5 | 15 | 3190 |
|
||||
| ✅ Realtime | 2 | 5 | 584 |
|
||||
| ✅ Reflection | 3 | 2 | 469 |
|
||||
| ✅ Retrieval | 2 | 5 | 941 |
|
||||
| ✅ Retrieval | 2 | 5 | 944 |
|
||||
| ✅ Routing | 1 | 2 | 597 |
|
||||
| ✅ Sandbox | 5 | 3 | 1002 |
|
||||
| ✅ Security | 1 | 1 | 875 |
|
||||
| ✅ Sessions | 2 | 8 | 2128 |
|
||||
| ✅ Skills | 5 | 5 | 2002 |
|
||||
| ✅ Tasks | 2 | 5 | 2590 |
|
||||
| ✅ Telemetry | 1 | 2 | 541 |
|
||||
| ✅ Sandbox | 5 | 6 | 2294 |
|
||||
| ✅ Security | 1 | 3 | 2518 |
|
||||
| ✅ Sessions | 4 | 9 | 3826 |
|
||||
| ✅ Skills | 5 | 9 | 3415 |
|
||||
| ✅ Tasks | 2 | 6 | 3064 |
|
||||
| ✅ Telemetry | 1 | 2 | 560 |
|
||||
| ✅ Templates | 1 | 7 | 1411 |
|
||||
| ✅ Tools | 4 | 97 | 17827 |
|
||||
| ✅ Tools | 12 | 113 | 23809 |
|
||||
| ✅ Tracing | 3 | 2 | 139 |
|
||||
| ✅ Video | 2 | 5 | 460 |
|
||||
| ✅ Video | 2 | 6 | 510 |
|
||||
| ✅ Vision | 2 | 1 | 283 |
|
||||
| ✅ Web | 4 | 6 | 1267 |
|
||||
| ✅ Workflows | 4 | 9 | 3579 |
|
||||
| ✅ Web | 4 | 6 | 1297 |
|
||||
| ✅ Workflows | 4 | 11 | 4302 |
|
||||
|
||||
## Stub Documentation (Need Content)
|
||||
|
||||
@@ -94,21 +98,18 @@ These categories have documentation files but < 50 lines (stubs):
|
||||
|
||||
These docs exist but don't match any implemented feature category:
|
||||
|
||||
- ℹ️ AI SDK (1 docs, 112 lines)
|
||||
- ℹ️ Agent-to-User (A2U) (3 docs, 770 lines)
|
||||
- ℹ️ CLI (91 docs, 21386 lines)
|
||||
- ℹ️ Callbacks (1 docs, 985 lines)
|
||||
- ℹ️ Chat (3 docs, 546 lines)
|
||||
- ℹ️ AI SDK (2 docs, 527 lines)
|
||||
- ℹ️ Callbacks (1 docs, 1122 lines)
|
||||
- ℹ️ Chat (3 docs, 634 lines)
|
||||
- ℹ️ Deep Research (2 docs, 437 lines)
|
||||
- ℹ️ Deployment (29 docs, 5931 lines)
|
||||
- ℹ️ Jobs (1 docs, 358 lines)
|
||||
- ℹ️ Deployment (29 docs, 6013 lines)
|
||||
- ℹ️ Jobs (2 docs, 961 lines)
|
||||
- ℹ️ Middleware (1 docs, 60 lines)
|
||||
- ℹ️ Observability (21 docs, 1580 lines)
|
||||
- ℹ️ Process (1 docs, 506 lines)
|
||||
- ℹ️ Scheduler (2 docs, 858 lines)
|
||||
- ℹ️ Storage (4 docs, 722 lines)
|
||||
- ℹ️ Streaming (3 docs, 485 lines)
|
||||
- ℹ️ Token Management (3 docs, 1020 lines)
|
||||
- ℹ️ Process (1 docs, 545 lines)
|
||||
- ℹ️ Scheduler (2 docs, 1084 lines)
|
||||
- ℹ️ Storage (5 docs, 957 lines)
|
||||
- ℹ️ Streaming (4 docs, 1163 lines)
|
||||
- ℹ️ Token Management (4 docs, 1383 lines)
|
||||
- ℹ️ Vector Store (11 docs, 733 lines)
|
||||
|
||||
---
|
||||
|
||||
@@ -1,35 +1,70 @@
|
||||
# PraisonAI Agents - Benchmark Results
|
||||
|
||||
**Generated:** 2025-12-18 14:40:22
|
||||
**Iterations:** 100
|
||||
**Test:** Agent instantiation (without tools)
|
||||
**Generated:** 2026-06-17
|
||||
**Methodology:** 10 warmup runs + 1000 measured iterations (`performance_benchmark.py` config)
|
||||
**Config:** `Agent(name=..., instructions=..., model="gpt-4o-mini", tools=[get_weather], output="silent")`
|
||||
|
||||
## Results
|
||||
## Agent Instantiation Time
|
||||
|
||||
| Framework | Avg Time (μs) | Relative |
|
||||
|-----------|---------------|----------|
|
||||
| **PraisonAI** | **3.77** | **1.00x (fastest)** |
|
||||
| OpenAI Agents SDK | 5.26 | 1.39x |
|
||||
| Agno | 5.64 | 1.49x |
|
||||
| PraisonAI (LiteLLM) | 7.56 | 2.00x |
|
||||
| PydanticAI | 226.94 | 60.16x |
|
||||
| LangGraph | 4,558.71 | 1,209x |
|
||||
| CrewAI | 15,607.92 | 4,138x |
|
||||
| Framework | Avg Time (μs) | Min (μs) | Median (μs) | Relative |
|
||||
|-----------|---------------|----------|-------------|----------|
|
||||
| OpenAI Agents SDK | 5.05 | — | — | 1.00x (fastest) |
|
||||
| Agno | 5.27 | — | — | 1.04x |
|
||||
| **PraisonAI** | **13.62** | **12.58** | **12.96** | **2.69x** |
|
||||
| PraisonAI (LiteLLM) | 14.49 | 13.37 | 13.71 | 2.86x |
|
||||
|
||||
### PraisonAI detail (1000 iter)
|
||||
|
||||
| Variant | Avg (μs) | Min (μs) | Median (μs) | Std (μs) |
|
||||
|---------|----------|----------|-------------|----------|
|
||||
| With tools (`gpt-4o-mini`) | 13.62 | 12.58 | 12.96 | 2.73 |
|
||||
| With tools (`openai/gpt-4o-mini`) | 14.49 | 13.37 | 13.71 | 4.14 |
|
||||
| Minimal, no tools | 13.40 | — | — | — |
|
||||
|
||||
## Historical baseline
|
||||
|
||||
| Source | Config | Avg (μs) | Notes |
|
||||
|--------|--------|----------|-------|
|
||||
| `performance_benchmark.py` (Dec 2025) | tools + 1000 iter | **3.20** | Pre-init-bloat baseline |
|
||||
| `simple_benchmark.py` (Dec 2025) | no tools, 100 iter | **3.77** | Saved in prior `BENCHMARK_RESULTS.md` |
|
||||
| Current (Jun 2026) | tools + 1000 iter | **13.62** | After lazy-init optimisations (was ~31 μs pre-fix) |
|
||||
|
||||
## Package Versions
|
||||
|
||||
| Package | Version |
|
||||
|---------|--------|
|
||||
| PraisonAI | 0.1.5 |
|
||||
| Agno | 2.3.14 |
|
||||
| PydanticAI | 1.35.0 |
|
||||
| OpenAI Agents SDK | 0.6.3 |
|
||||
| LangGraph | 1.0.5 |
|
||||
| CrewAI | 1.6.1 |
|
||||
|---------|---------|
|
||||
| PraisonAI | 1.6.60 |
|
||||
| Agno | 2.4.2 |
|
||||
| OpenAI Agents SDK | 0.7.0 |
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
PraisonAI-only (fast; avoids CrewAI/LangGraph import hang):
|
||||
|
||||
```bash
|
||||
cd praisonai-agents
|
||||
python benchmarks/simple_benchmark.py
|
||||
PYTHONWARNINGS='ignore::DeprecationWarning' python -c "
|
||||
import time, statistics
|
||||
from typing import Literal
|
||||
from praisonaiagents import Agent
|
||||
|
||||
def get_weather(city: Literal['nyc', 'sf']):
|
||||
return 'cloudy' if city == 'nyc' else 'sunny'
|
||||
|
||||
def bench(fn, n=1000, w=10):
|
||||
for _ in range(w): fn()
|
||||
times = []
|
||||
for _ in range(n):
|
||||
s = time.perf_counter(); fn(); times.append(time.perf_counter()-s)
|
||||
print(f'avg={statistics.mean(times)*1e6:.2f} μs')
|
||||
|
||||
bench(lambda: Agent(name='Test', instructions='Hi', model='gpt-4o-mini', tools=[get_weather], output='silent'))
|
||||
"
|
||||
```
|
||||
|
||||
Full multi-framework benchmark:
|
||||
|
||||
```bash
|
||||
cd praisonai-agents
|
||||
PYTHONWARNINGS='ignore::DeprecationWarning' python benchmarks/performance_benchmark.py --save
|
||||
```
|
||||
@@ -0,0 +1,62 @@
|
||||
# Agent Instantiation — Root Cause Analysis
|
||||
|
||||
**Date:** 2026-06-17
|
||||
**Version:** praisonaiagents 1.6.58 (dev)
|
||||
**Methodology:** 10 warmup + 1000 measured iterations, `time.perf_counter()`
|
||||
|
||||
## Summary
|
||||
|
||||
Instantiation regressed from **~3.2 μs** (Dec 2025) to **~31 μs** (~10×). After fixes, steady-state is **~16 μs** (~2× historical). PraisonAI is competitive with Agno (~5 μs) on warmed benchmarks using `model=`.
|
||||
|
||||
## Before vs After Fixes
|
||||
|
||||
| Config | Historical | Pre-fix | Post-fix |
|
||||
|--------|------------|---------|----------|
|
||||
| Minimal (`name`, `output="silent"`) | 3.20 μs | 31.36 μs | **16.35 μs** |
|
||||
| `model="gpt-4o-mini"` | 3.77 μs | 32.19 μs | **16.86 μs** |
|
||||
| `llm="gpt-4o-mini"` (deprecated) | 3.77 μs | 46.48 μs | **~18 μs** (warn once) |
|
||||
| LiteLLM `openai/gpt-4o-mini` cold | 7.31 μs | **3.7 s** | **53.63 μs** |
|
||||
| LiteLLM warmed instantiate | — | 60 μs | **19.83 μs** |
|
||||
| Import `Agent` | — | 20.8 ms | **29.6 ms** |
|
||||
|
||||
## Root Causes (validated)
|
||||
|
||||
| ID | Cause | Status | Fix applied |
|
||||
|----|-------|--------|-------------|
|
||||
| H1 | Config loader cold start (+9 ms first init) | CONFIRMED | Fast path when no config defaults (`_defaults_has_any_values`) |
|
||||
| H2 | LoopGuard on every init (~2 μs) | CONFIRMED | Lazy `_ensure_loop_guard()` on first tool call |
|
||||
| H3 | Approval presets | REJECTED | No change needed |
|
||||
| H4 | `llm=` deprecation every init (+16 μs) | CONFIRMED | Warn once per process in `warn_deprecated_param` |
|
||||
| H5 | Init bloat / config resolution (~25 μs) | CONFIRMED | Partially addressed via loader fast path |
|
||||
| H6 | LiteLLM eager `LLM()` import (+3.7 s) | CONFIRMED | Deferred `_llm_init_params` + lazy `llm_instance` property |
|
||||
| H7 | `ExecutionConfig` deprecation every init | CONFIRMED | Fixed stack walk; internal SDK path cached |
|
||||
|
||||
## cProfile — Post-fix (500 warmed `model=` inits)
|
||||
|
||||
| Function | Time (ms) | Per call |
|
||||
|----------|-----------|----------|
|
||||
| `Agent.__init__` | 8 | ~16 μs |
|
||||
| `apply_config_defaults` | 1 | ~2 μs |
|
||||
| `ExecutionConfig.__post_init__` | 1 | ~2 μs (first call only walks stack) |
|
||||
| `_warnings.warn` | **0** | removed from hot path |
|
||||
|
||||
## Fixes implemented
|
||||
|
||||
1. **`praisonaiagents/config/feature_configs.py`** — Fix `ExecutionConfig.__post_init__` deprecation guard; cache internal SDK detection.
|
||||
2. **`praisonaiagents/utils/deprecation.py`** — Warn once per process for deprecated params.
|
||||
3. **`praisonaiagents/config/loader.py`** — Skip `apply_config_defaults` lookups when config has no defaults.
|
||||
4. **`praisonaiagents/agent/agent.py`** — Lazy LoopGuard, lazy LLM instance, deferred LiteLLM params.
|
||||
5. **`benchmarks/simple_benchmark.py`** — Use `model=`, add 10-iteration warmup.
|
||||
6. **`benchmarks/performance_benchmark.py`** — Use `model=` instead of deprecated `llm=`.
|
||||
|
||||
## Remaining gap (~16 μs vs ~3 μs)
|
||||
|
||||
The residual ~13 μs is primarily **`Agent.__init__` size** (~1200 lines of consolidated config resolution added since v0.1.5). Further optimisation requires a dedicated slim fast-path for `Agent(name=..., output="silent")` without full config object churn.
|
||||
|
||||
## How to reproduce
|
||||
|
||||
```bash
|
||||
cd praisonai-agents
|
||||
PYTHONWARNINGS='ignore::DeprecationWarning' python benchmarks/simple_benchmark.py
|
||||
PYTHONWARNINGS='ignore::DeprecationWarning' python benchmarks/performance_benchmark.py
|
||||
```
|
||||
@@ -120,8 +120,8 @@ def run_benchmark(iterations: int = 100) -> List[ParamResult]:
|
||||
('backstory', 'I am an AI assistant.', 'backstory="..."'),
|
||||
|
||||
# LLM configuration
|
||||
('llm', 'gpt-4o-mini', 'llm="gpt-4o-mini"'),
|
||||
('llm', 'openai/gpt-4o-mini', 'llm="openai/gpt-4o-mini" (LiteLLM)'),
|
||||
('model', 'gpt-4o-mini', 'model="gpt-4o-mini"'),
|
||||
('model', 'openai/gpt-4o-mini', 'model="openai/gpt-4o-mini" (LiteLLM)'),
|
||||
|
||||
# Tools
|
||||
('tools', [sample_tool], 'tools=[sample_tool]'),
|
||||
@@ -298,7 +298,7 @@ def save_results(results: List[ParamResult], filename: str = 'PARAM_IMPACT_RESUL
|
||||
f.write('### Best Practices\n\n')
|
||||
f.write('1. Use `output="silent"` for production workloads\n')
|
||||
f.write('2. Only enable `context=True` when needed for long conversations\n')
|
||||
f.write('3. Use `llm="gpt-4o-mini"` (OpenAI SDK) for fastest instantiation\n')
|
||||
f.write('3. Use `model="gpt-4o-mini"` for fastest instantiation\n')
|
||||
f.write('4. Avoid `memory=True` unless persistent memory is required\n')
|
||||
|
||||
f.write('\n## How to Reproduce\n\n')
|
||||
|
||||
@@ -185,7 +185,7 @@ def instantiate_praisonai_agent():
|
||||
return PraisonAgent(
|
||||
name="Test Agent",
|
||||
instructions="Be concise, reply with one sentence.",
|
||||
llm="gpt-4o-mini",
|
||||
model="gpt-4o-mini",
|
||||
tools=tools,
|
||||
output="silent"
|
||||
)
|
||||
@@ -196,7 +196,7 @@ def instantiate_praisonai_litellm_agent():
|
||||
return PraisonAgent(
|
||||
name="Test Agent",
|
||||
instructions="Be concise, reply with one sentence.",
|
||||
llm="openai/gpt-4o-mini",
|
||||
model="openai/gpt-4o-mini",
|
||||
tools=tools,
|
||||
output="silent"
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ import argparse
|
||||
|
||||
|
||||
ITERATIONS = 100
|
||||
WARMUP = 10
|
||||
|
||||
|
||||
def sample_tool(city: Literal['nyc', 'sf']):
|
||||
@@ -23,8 +24,10 @@ def sample_tool(city: Literal['nyc', 'sf']):
|
||||
return 'sunny'
|
||||
|
||||
|
||||
def measure_instantiation(create_fn, iterations=ITERATIONS):
|
||||
def measure_instantiation(create_fn, iterations=ITERATIONS, warmup=WARMUP):
|
||||
"""Measure average instantiation time in microseconds."""
|
||||
for _ in range(warmup):
|
||||
create_fn()
|
||||
times = []
|
||||
for _ in range(iterations):
|
||||
start = time.perf_counter()
|
||||
@@ -48,11 +51,11 @@ def run_benchmark():
|
||||
from praisonaiagents import Agent as PraisonAgent
|
||||
|
||||
results['PraisonAI'] = measure_instantiation(
|
||||
lambda: PraisonAgent(name='Test', llm='gpt-4o-mini', output="silent")
|
||||
lambda: PraisonAgent(name='Test', model='gpt-4o-mini', output="silent")
|
||||
)
|
||||
|
||||
results['PraisonAI (LiteLLM)'] = measure_instantiation(
|
||||
lambda: PraisonAgent(name='Test', llm='openai/gpt-4o-mini', output="silent")
|
||||
lambda: PraisonAgent(name='Test', model='openai/gpt-4o-mini', output="silent")
|
||||
)
|
||||
|
||||
# Other frameworks for comparison
|
||||
|
||||
@@ -114,6 +114,9 @@ def _get_lazy_cache():
|
||||
# ============================================================================
|
||||
|
||||
_LAZY_IMPORTS = {
|
||||
# Version information (lazy loaded to avoid file I/O on import)
|
||||
'__version__': ('praisonaiagents._version', '__version__'),
|
||||
|
||||
# Tools (moved from eager imports for lazy loading)
|
||||
'Tools': ('praisonaiagents.tools.tools', 'Tools'),
|
||||
'BaseTool': ('praisonaiagents.tools.base', 'BaseTool'),
|
||||
@@ -775,6 +778,9 @@ def warmup(include_litellm: bool = False, include_openai: bool = True) -> dict:
|
||||
# ============================================================================
|
||||
|
||||
__all__ = [
|
||||
# Version information
|
||||
'__version__',
|
||||
|
||||
# Core classes - the essentials
|
||||
'Agent',
|
||||
'AgentTeam', # Primary class for multi-agent coordination (v1.0+)
|
||||
|
||||
Whitespace-only changes.
@@ -1,49 +0,0 @@
|
||||
"""
|
||||
Resolver helper functions for consolidated parameters.
|
||||
|
||||
These helpers wrap the canonical resolver for specific parameter types,
|
||||
handling special cases like callable validators for guardrails.
|
||||
"""
|
||||
|
||||
from typing import Any, Type
|
||||
|
||||
from .config.param_resolver import resolve, ArrayMode
|
||||
from .config.presets import GUARDRAIL_PRESETS
|
||||
|
||||
|
||||
def resolve_guardrails(value: Any, config_class: Type) -> Any:
|
||||
"""
|
||||
Resolve guardrails parameter.
|
||||
|
||||
Handles special case of callable validators which have highest precedence.
|
||||
|
||||
Args:
|
||||
value: The guardrails parameter value
|
||||
config_class: GuardrailConfig class
|
||||
|
||||
Returns:
|
||||
Resolved config, callable, or string
|
||||
"""
|
||||
# Handle None
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
# Handle False (disabled)
|
||||
if value is False:
|
||||
return None
|
||||
|
||||
# Handle callable (highest precedence after None check)
|
||||
# This includes functions, lambdas, and callable objects
|
||||
if callable(value) and not isinstance(value, type):
|
||||
return value
|
||||
|
||||
# Use canonical resolver for everything else
|
||||
return resolve(
|
||||
value=value,
|
||||
param_name="guardrails",
|
||||
config_class=config_class,
|
||||
presets=GUARDRAIL_PRESETS,
|
||||
array_mode=ArrayMode.PRESET_OVERRIDE,
|
||||
string_mode="llm_prompt",
|
||||
default=None,
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
Version utilities for praisonaiagents package.
|
||||
|
||||
This module provides version information by reading from pyproject.toml
|
||||
to avoid duplication and ensure the single source of truth.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_version() -> str:
|
||||
"""
|
||||
Get the version string from pyproject.toml.
|
||||
|
||||
Returns:
|
||||
str: Version string (e.g., "1.6.52")
|
||||
|
||||
Raises:
|
||||
RuntimeError: If version cannot be found or parsed
|
||||
"""
|
||||
try:
|
||||
# Get the package root (praisonaiagents directory)
|
||||
package_root = Path(__file__).parent
|
||||
# Go up to src/praisonai-agents directory
|
||||
pyproject_path = package_root.parent / "pyproject.toml"
|
||||
|
||||
if not pyproject_path.exists():
|
||||
raise RuntimeError(f"pyproject.toml not found at {pyproject_path}")
|
||||
|
||||
content = pyproject_path.read_text()
|
||||
|
||||
# Look for version = "X.Y.Z" pattern
|
||||
match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content)
|
||||
if not match:
|
||||
raise RuntimeError("Version not found in pyproject.toml")
|
||||
|
||||
return match.group(1)
|
||||
|
||||
except Exception as e:
|
||||
# Fallback to "unknown" to avoid breaking imports
|
||||
import warnings
|
||||
warnings.warn(
|
||||
f"Failed to read version from pyproject.toml: {e}. Using 'unknown'.",
|
||||
RuntimeWarning
|
||||
)
|
||||
return "unknown"
|
||||
|
||||
|
||||
# Cache the version to avoid reading the file multiple times
|
||||
__version__ = get_version()
|
||||
@@ -313,6 +313,38 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
|
||||
cls._default_model_checked = True
|
||||
return cls._default_model
|
||||
|
||||
def _ensure_llm_instance(self):
|
||||
"""Lazy-create LLM instance from deferred init params (avoids import at Agent())."""
|
||||
if self._llm_instance is not None:
|
||||
return self._llm_instance
|
||||
if self._llm_init_params:
|
||||
try:
|
||||
from ..llm.llm import LLM
|
||||
self._llm_instance = LLM(**self._llm_init_params)
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"LLM features requested but dependencies not installed. "
|
||||
"Please install with: pip install \"praisonaiagents[llm]\""
|
||||
) from e
|
||||
return self._llm_instance
|
||||
|
||||
@property
|
||||
def llm_instance(self):
|
||||
return self._ensure_llm_instance()
|
||||
|
||||
@llm_instance.setter
|
||||
def llm_instance(self, value):
|
||||
self._llm_instance = value
|
||||
if value is not None:
|
||||
self._llm_init_params = None
|
||||
|
||||
def _ensure_loop_guard(self):
|
||||
"""Lazy-create loop guard on first tool execution."""
|
||||
if self._loop_guard is None:
|
||||
from ..escalation.loop_guard import LoopGuard, LoopGuardConfig
|
||||
self._loop_guard = LoopGuard(LoopGuardConfig(enabled=True))
|
||||
return self._loop_guard
|
||||
|
||||
@classmethod
|
||||
def _configure_logging(cls):
|
||||
"""Configure logging settings once for all agent instances."""
|
||||
@@ -556,7 +588,8 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
|
||||
tool_config: Optional[Union[bool, 'ToolConfig']] = None, # Tool execution configuration (timeout, retry, parallel)
|
||||
learn: Optional[Union[bool, str, Dict[str, Any], 'LearnConfig']] = None, # Continuous learning (peer to memory)
|
||||
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")
|
||||
cli_backend: Optional[Union[str, Any]] = None, # CLI backend for delegating turns (e.g., "claude-code") - DEPRECATED
|
||||
runtime: Optional[Union[str, Dict[str, Any], 'AgentRuntimeConfig']] = None, # Model-scoped runtime configuration
|
||||
interrupt_controller: Optional['InterruptController'] = None, # G2: Cooperative cancellation
|
||||
tool_search: Optional[Union[bool, str, Dict[str, Any], 'ToolSearchConfig']] = False, # Progressive tool disclosure
|
||||
message_steering: Optional[Union[bool, 'MessageSteeringProtocol']] = False, # Real-time message steering during execution
|
||||
@@ -657,13 +690,15 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
|
||||
- None: Use local execution (default)
|
||||
When provided, agent can delegate execution to managed infrastructure
|
||||
for long-running tasks or when local resources are constrained.
|
||||
cli_backend: CLI backend for delegating full turns to external CLI tools. Accepts:
|
||||
- str: Backend ID ("claude-code", "codex-cli", "gemini-cli")
|
||||
- CliBackendProtocol: Custom CLI backend instance
|
||||
- None: Use standard LLM execution (default)
|
||||
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.
|
||||
cli_backend: **DEPRECATED** CLI backend for delegating full turns. Use 'runtime' parameter instead.
|
||||
This parameter is deprecated in favor of model-scoped runtime configuration.
|
||||
Will emit deprecation warning when used.
|
||||
runtime: Model-scoped runtime configuration for turn delegation. Accepts:
|
||||
- str: Runtime ID ("claude-code", "praisonai")
|
||||
- Dict[str, Any]: Runtime config {"runtime": "claude-code", "config_overrides": {...}}
|
||||
- AgentRuntimeConfig: Pre-configured runtime instance
|
||||
- None: Use model-based resolution (default)
|
||||
Enables turn delegation with per-model runtime selection and fail-closed behavior.
|
||||
tool_search: Progressive tool disclosure configuration. Accepts:
|
||||
- bool: False=disabled (default), True=auto mode
|
||||
- str: Mode ("auto", "on", "off")
|
||||
@@ -718,16 +753,16 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
|
||||
# Precedence: Explicit params > Config file > Built-in defaults
|
||||
# Only applies when param is None (not explicitly set)
|
||||
# ============================================================
|
||||
from ..config.loader import apply_config_defaults, get_default
|
||||
from ..config.loader import apply_config_defaults, get_default, _defaults_has_any_values
|
||||
|
||||
# Apply config defaults for LLM if not explicitly set
|
||||
if llm is None and model is None:
|
||||
if llm is None and model is None and _defaults_has_any_values():
|
||||
config_model = get_default("model")
|
||||
if config_model:
|
||||
llm = config_model
|
||||
|
||||
# Apply config defaults for base_url if not explicitly set
|
||||
if base_url is None:
|
||||
if base_url is None and _defaults_has_any_values():
|
||||
config_base_url = get_default("base_url")
|
||||
if config_base_url:
|
||||
base_url = config_base_url
|
||||
@@ -1392,7 +1427,7 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
|
||||
# String could be LLM prompt - passthrough for later processing
|
||||
_guardrails_config = guardrails
|
||||
else:
|
||||
from .._resolver_helpers import resolve_guardrails as _resolve_guardrails
|
||||
from ..config.param_resolver import resolve_guardrails as _resolve_guardrails
|
||||
_guardrails_config = _resolve_guardrails(
|
||||
value=guardrails,
|
||||
config_class=GuardrailConfig,
|
||||
@@ -1478,10 +1513,8 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
|
||||
# Initialize autonomy features (agent-centric escalation/doom-loop)
|
||||
self._init_autonomy(autonomy, verification_hooks=verification_hooks)
|
||||
|
||||
# Initialize loop guard for all execution modes (not just autonomous)
|
||||
# Provides idempotency-aware guardrails for tool execution loops
|
||||
from ..escalation.loop_guard import LoopGuard, LoopGuardConfig
|
||||
self._loop_guard = LoopGuard(LoopGuardConfig(enabled=True))
|
||||
# Initialize loop guard lazily on first tool execution (zero init overhead)
|
||||
self._loop_guard = None
|
||||
|
||||
# If instructions are provided, use them to set role, goal, and backstory
|
||||
if instructions:
|
||||
@@ -1527,6 +1560,8 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
|
||||
self.interrupt_controller = interrupt_controller
|
||||
# Check for model name in environment variable if not provided
|
||||
self._using_custom_llm = False
|
||||
self._llm_instance = None
|
||||
self._llm_init_params = None
|
||||
# Flag to track if final result has been displayed to prevent duplicates
|
||||
self._final_display_shown = False
|
||||
|
||||
@@ -1565,99 +1600,67 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
|
||||
self.base_url = base_url
|
||||
self.api_key = api_key
|
||||
|
||||
# If base_url is provided, always create a custom LLM instance
|
||||
# If base_url is provided, defer custom LLM instance creation
|
||||
if base_url:
|
||||
try:
|
||||
from ..llm.llm import LLM
|
||||
# Handle different llm parameter types with base_url
|
||||
if isinstance(llm, dict):
|
||||
# Merge base_url and api_key into the dict
|
||||
llm_config = llm.copy()
|
||||
llm_config['base_url'] = base_url
|
||||
if api_key:
|
||||
llm_config['api_key'] = api_key
|
||||
if auth:
|
||||
llm_config['auth'] = auth
|
||||
llm_config['metrics'] = metrics
|
||||
llm_config['max_iter'] = max_iter
|
||||
self.llm_instance = LLM(**llm_config)
|
||||
self.llm = llm.get('model', Agent._get_default_model())
|
||||
else:
|
||||
# Create LLM with model string and base_url (cached for performance)
|
||||
model_name = llm or Agent._get_default_model()
|
||||
self.llm_instance = LLM(
|
||||
model=model_name,
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
auth=auth,
|
||||
metrics=metrics,
|
||||
max_iter=max_iter,
|
||||
web_search=web_search,
|
||||
web_fetch=web_fetch,
|
||||
prompt_caching=prompt_caching,
|
||||
claude_memory=claude_memory
|
||||
)
|
||||
self.llm = model_name
|
||||
self._using_custom_llm = True
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"LLM features requested but dependencies not installed. "
|
||||
"Please install with: pip install \"praisonaiagents[llm]\""
|
||||
) from e
|
||||
if isinstance(llm, dict):
|
||||
llm_config = llm.copy()
|
||||
llm_config['base_url'] = base_url
|
||||
if api_key:
|
||||
llm_config['api_key'] = api_key
|
||||
if auth:
|
||||
llm_config['auth'] = auth
|
||||
llm_config['metrics'] = metrics
|
||||
llm_config['max_iter'] = max_iter
|
||||
self._llm_init_params = llm_config
|
||||
self.llm = llm.get('model', Agent._get_default_model())
|
||||
else:
|
||||
model_name = llm or Agent._get_default_model()
|
||||
self._llm_init_params = {
|
||||
'model': model_name,
|
||||
'base_url': base_url,
|
||||
'api_key': api_key,
|
||||
'auth': auth,
|
||||
'metrics': metrics,
|
||||
'max_iter': max_iter,
|
||||
'web_search': web_search,
|
||||
'web_fetch': web_fetch,
|
||||
'prompt_caching': prompt_caching,
|
||||
'claude_memory': claude_memory,
|
||||
}
|
||||
self.llm = model_name
|
||||
self._using_custom_llm = True
|
||||
# If the user passes a dictionary (for advanced configuration)
|
||||
elif isinstance(llm, dict) and "model" in llm:
|
||||
try:
|
||||
from ..llm.llm import LLM
|
||||
# Add api_key if provided and not in dict
|
||||
if api_key and 'api_key' not in llm:
|
||||
llm = llm.copy()
|
||||
llm['api_key'] = api_key
|
||||
# Add auth if provided and not in dict
|
||||
if auth and 'auth' not in llm:
|
||||
llm = llm.copy()
|
||||
llm['auth'] = auth
|
||||
# Add metrics parameter
|
||||
llm = llm.copy()
|
||||
llm['metrics'] = metrics
|
||||
llm['max_iter'] = max_iter
|
||||
self.llm_instance = LLM(**llm) # Pass all dict items as kwargs
|
||||
self._using_custom_llm = True
|
||||
self.llm = llm.get('model', Agent._get_default_model())
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"LLM features requested but dependencies not installed. "
|
||||
"Please install with: pip install \"praisonaiagents[llm]\""
|
||||
) from e
|
||||
# If the user passes a string with a slash (provider/model)
|
||||
llm_config = llm.copy()
|
||||
if api_key and 'api_key' not in llm_config:
|
||||
llm_config['api_key'] = api_key
|
||||
if auth and 'auth' not in llm_config:
|
||||
llm_config['auth'] = auth
|
||||
llm_config['metrics'] = metrics
|
||||
llm_config['max_iter'] = max_iter
|
||||
self._llm_init_params = llm_config
|
||||
self._using_custom_llm = True
|
||||
self.llm = llm_config.get('model', Agent._get_default_model())
|
||||
# If the user passes a string with a slash (provider/model) — defer LiteLLM import
|
||||
elif isinstance(llm, str) and "/" in llm:
|
||||
try:
|
||||
from ..llm.llm import LLM
|
||||
# Pass the entire string so LiteLLM can parse provider/model
|
||||
llm_params = {'model': llm}
|
||||
if api_key:
|
||||
llm_params['api_key'] = api_key
|
||||
if auth:
|
||||
llm_params['auth'] = auth
|
||||
llm_params['metrics'] = metrics
|
||||
llm_params['web_search'] = web_search
|
||||
llm_params['web_fetch'] = web_fetch
|
||||
llm_params['prompt_caching'] = prompt_caching
|
||||
llm_params['claude_memory'] = claude_memory
|
||||
llm_params['max_iter'] = max_iter
|
||||
self.llm_instance = LLM(**llm_params)
|
||||
self._using_custom_llm = True
|
||||
self.llm = llm
|
||||
|
||||
# Ensure tools are properly accessible when using custom LLM
|
||||
if tools:
|
||||
logging.debug(f"Tools passed to Agent with custom LLM: {tools}")
|
||||
# Store the tools for later use
|
||||
self.tools = tools
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"LLM features requested but dependencies not installed. "
|
||||
"Please install with: pip install \"praisonaiagents[llm]\""
|
||||
) from e
|
||||
llm_params = {'model': llm}
|
||||
if api_key:
|
||||
llm_params['api_key'] = api_key
|
||||
if auth:
|
||||
llm_params['auth'] = auth
|
||||
llm_params['metrics'] = metrics
|
||||
llm_params['web_search'] = web_search
|
||||
llm_params['web_fetch'] = web_fetch
|
||||
llm_params['prompt_caching'] = prompt_caching
|
||||
llm_params['claude_memory'] = claude_memory
|
||||
llm_params['max_iter'] = max_iter
|
||||
self._llm_init_params = llm_params
|
||||
self._using_custom_llm = True
|
||||
self.llm = llm
|
||||
|
||||
if tools:
|
||||
logging.debug(f"Tools passed to Agent with custom LLM: {tools}")
|
||||
self.tools = tools
|
||||
# Otherwise, fall back to OpenAI environment/name (cached for performance)
|
||||
else:
|
||||
self.llm = llm or Agent._get_default_model()
|
||||
@@ -2032,10 +2035,26 @@ Your Goal: {self.goal}
|
||||
# Backend - external managed agent backend for hybrid execution
|
||||
self.backend = backend
|
||||
|
||||
# CLI Backend - external CLI backend for delegating full turns
|
||||
# CLI Backend - external CLI backend for delegating full turns (DEPRECATED)
|
||||
self._cli_backend = None
|
||||
if cli_backend is not None:
|
||||
from ..utils.deprecation import warn_deprecated_param
|
||||
warn_deprecated_param(
|
||||
"cli_backend",
|
||||
since="1.0.0",
|
||||
removal="2.0.0",
|
||||
alternative="use 'runtime=' instead for model-scoped runtime configuration. For migration assistance, run: praisonai doctor fix --execute",
|
||||
stacklevel=3
|
||||
)
|
||||
self._cli_backend = self._resolve_cli_backend(cli_backend)
|
||||
|
||||
# Runtime Configuration - model-scoped runtime selection
|
||||
self._runtime_config = None
|
||||
if runtime is not None:
|
||||
self._runtime_config = self._resolve_runtime_config(runtime)
|
||||
|
||||
# Runtime resolver for per-turn resolution
|
||||
self._runtime_resolver = None # Will be initialized on first use
|
||||
|
||||
# Telemetry - lazy initialized via property for performance
|
||||
self.__telemetry = None
|
||||
@@ -5079,17 +5098,163 @@ Answer:"""
|
||||
|
||||
raise TypeError(f"Invalid cli_backend type: {type(cli_backend)}. Expected CliBackendProtocol instance or factory callable.")
|
||||
|
||||
async def _chat_via_cli_backend(self, prompt: str, **kwargs) -> Optional[str]:
|
||||
def _resolve_runtime_config(self, runtime):
|
||||
"""Resolve runtime parameter to AgentRuntimeConfig instance.
|
||||
|
||||
Args:
|
||||
runtime: Runtime configuration parameter
|
||||
|
||||
Returns:
|
||||
AgentRuntimeConfig instance
|
||||
|
||||
Raises:
|
||||
TypeError: If runtime type is invalid
|
||||
ValueError: If runtime configuration is invalid
|
||||
"""
|
||||
try:
|
||||
from ..runtime import AgentRuntimeConfig
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"Runtime configuration features requested but not available. "
|
||||
"This should not happen in a properly installed praisonaiagents package."
|
||||
)
|
||||
|
||||
# If already an AgentRuntimeConfig instance, return as-is
|
||||
if isinstance(runtime, AgentRuntimeConfig):
|
||||
return runtime
|
||||
|
||||
# If string, create config with runtime ID
|
||||
if isinstance(runtime, str):
|
||||
return AgentRuntimeConfig.from_runtime_id(runtime)
|
||||
|
||||
# If dictionary, create config from dict
|
||||
if isinstance(runtime, dict):
|
||||
return AgentRuntimeConfig.from_dict(runtime)
|
||||
|
||||
raise TypeError(f"Invalid runtime type: {type(runtime)}. Expected str, dict, or AgentRuntimeConfig instance.")
|
||||
|
||||
def _get_runtime_resolver(self):
|
||||
"""Get or create runtime resolver instance (lazy initialization)."""
|
||||
if self._runtime_resolver is None:
|
||||
try:
|
||||
from ..runtime.resolver import RuntimeResolver
|
||||
self._runtime_resolver = RuntimeResolver()
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"Runtime resolution features requested but not available. "
|
||||
"This should not happen in a properly installed praisonaiagents package."
|
||||
)
|
||||
return self._runtime_resolver
|
||||
|
||||
async def _resolve_turn_runtime(self):
|
||||
"""Resolve runtime for current turn based on model and configuration.
|
||||
|
||||
Returns:
|
||||
Runtime instance if resolution succeeds, None otherwise
|
||||
"""
|
||||
try:
|
||||
from ..runtime.resolver import RuntimeResolutionContext
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
# Create resolution context with current model and provider info
|
||||
context = RuntimeResolutionContext(
|
||||
model_name=getattr(self, 'llm', None),
|
||||
provider_name=self._extract_provider_from_model(getattr(self, 'llm', None)),
|
||||
agent_config={'agent_id': getattr(self, 'agent_id', None)}
|
||||
)
|
||||
|
||||
try:
|
||||
resolver = self._get_runtime_resolver()
|
||||
result = resolver.resolve_runtime_instance(
|
||||
context=context,
|
||||
model_runtime_configs=getattr(self, '_model_runtime_configs', None),
|
||||
provider_runtime_configs=getattr(self, '_provider_runtime_configs', None),
|
||||
legacy_cli_backend=getattr(self, '_cli_backend', None)
|
||||
)
|
||||
|
||||
if result and result.runtime:
|
||||
return result.runtime
|
||||
|
||||
except (ValueError, RuntimeError) as e:
|
||||
# Only fall back for registry not initialized, propagate configuration errors
|
||||
if "not initialized" in str(e).lower():
|
||||
# Registry not initialized - this is expected in SDK-only mode
|
||||
pass
|
||||
else:
|
||||
# Configuration error (unknown runtime ID etc.) - fail closed
|
||||
raise RuntimeError(
|
||||
f"Runtime resolution failed for agent={getattr(self, 'display_name', 'unknown')!r}, "
|
||||
f"model={getattr(self, 'llm', None)!r}: {e}. "
|
||||
"Fix the runtime ID/configuration or remove the runtime override."
|
||||
) from e
|
||||
|
||||
return None
|
||||
|
||||
def _extract_provider_from_model(self, model_name):
|
||||
"""Extract provider name from model name.
|
||||
|
||||
Args:
|
||||
model_name: Model name string (e.g., "anthropic/claude-3-sonnet")
|
||||
|
||||
Returns:
|
||||
Provider name if detectable, None otherwise
|
||||
"""
|
||||
if not model_name or not isinstance(model_name, str):
|
||||
return None
|
||||
|
||||
# Common provider patterns
|
||||
if '/' in model_name:
|
||||
return model_name.split('/')[0]
|
||||
|
||||
# Provider inference based on model name patterns
|
||||
model_lower = model_name.lower()
|
||||
if 'claude' in model_lower or 'anthropic' in model_lower:
|
||||
return 'anthropic'
|
||||
elif 'gpt' in model_lower or 'openai' in model_lower:
|
||||
return 'openai'
|
||||
elif 'gemini' in model_lower or 'google' in model_lower:
|
||||
return 'google'
|
||||
elif 'llama' in model_lower:
|
||||
return 'meta'
|
||||
|
||||
return None
|
||||
|
||||
async def _chat_via_runtime(self, runtime_instance, prompt: str, **kwargs) -> Optional[str]:
|
||||
"""Chat implementation using resolved runtime instance.
|
||||
|
||||
This is similar to _chat_via_cli_backend but uses the resolved runtime
|
||||
from the model-scoped runtime resolution system.
|
||||
|
||||
Args:
|
||||
runtime_instance: Resolved runtime instance (CliBackendProtocol)
|
||||
prompt: User prompt
|
||||
**kwargs: Additional chat parameters
|
||||
|
||||
Returns:
|
||||
Runtime response content
|
||||
"""
|
||||
if not runtime_instance:
|
||||
raise RuntimeError("Runtime instance is None")
|
||||
|
||||
# Delegate to CLI backend implementation with runtime instance
|
||||
# Pass runtime instance directly to avoid mutating shared state
|
||||
return await self._chat_via_cli_backend(prompt=prompt, cli_backend=runtime_instance, **kwargs)
|
||||
|
||||
async def _chat_via_cli_backend(self, prompt: str, cli_backend: Any = None, **kwargs) -> Optional[str]:
|
||||
"""Chat implementation using CLI backend delegation.
|
||||
|
||||
Args:
|
||||
prompt: User prompt
|
||||
cli_backend: Optional specific backend instance to use (for runtime delegation)
|
||||
**kwargs: Additional chat parameters (passed through as metadata)
|
||||
|
||||
Returns:
|
||||
CLI backend response content
|
||||
"""
|
||||
if not self._cli_backend:
|
||||
# Use provided backend or fall back to instance backend
|
||||
backend = cli_backend or self._cli_backend
|
||||
if not backend:
|
||||
raise RuntimeError("CLI backend not configured")
|
||||
|
||||
try:
|
||||
@@ -5128,7 +5293,7 @@ Answer:"""
|
||||
images = None
|
||||
|
||||
# Execute CLI backend
|
||||
result = await self._cli_backend.execute(
|
||||
result = await backend.execute(
|
||||
prompt=prompt,
|
||||
session=session_binding,
|
||||
images=images,
|
||||
|
||||
@@ -87,12 +87,41 @@ Your Goal: {self.goal}"""
|
||||
|
||||
# Add memory context if memory is enabled
|
||||
if self._memory_instance:
|
||||
memory_context = self.get_memory_context()
|
||||
if memory_context:
|
||||
system_prompt += f"\n\n## Memory (Information you remember about the user)\n{memory_context}"
|
||||
# Display memory info to user if verbose
|
||||
if self.verbose:
|
||||
self._display_memory_info()
|
||||
# Use cache-optimized context if model supports prompt caching and cache boundary is requested
|
||||
use_cache_boundaries = hasattr(self, '_model_supports_prompt_caching') and self._model_supports_prompt_caching()
|
||||
if use_cache_boundaries and hasattr(self._memory_instance, 'build_cache_optimized_context'):
|
||||
try:
|
||||
# Get the goal/task description for context building
|
||||
task_desc = getattr(self, 'goal', '') or 'general assistance'
|
||||
cache_result = self._memory_instance.build_cache_optimized_context(
|
||||
task_descr=task_desc,
|
||||
include_cache_boundary=True
|
||||
)
|
||||
memory_context = cache_result.get('stable_prefix', '')
|
||||
cache_boundary = cache_result.get('cache_boundary', '')
|
||||
if memory_context:
|
||||
system_prompt += f"\n\n## Memory (Information you remember about the user)\n{memory_context}"
|
||||
if cache_boundary:
|
||||
system_prompt += cache_boundary
|
||||
# Display memory info to user if verbose
|
||||
if self.verbose:
|
||||
self._display_memory_info()
|
||||
except (AttributeError, KeyError, TypeError) as e:
|
||||
# Fall back to standard memory context if cache optimization fails
|
||||
memory_context = self.get_memory_context()
|
||||
if memory_context:
|
||||
system_prompt += f"\n\n## Memory (Information you remember about the user)\n{memory_context}"
|
||||
# Display memory info to user if verbose
|
||||
if self.verbose:
|
||||
self._display_memory_info()
|
||||
else:
|
||||
# Standard memory context without cache boundaries
|
||||
memory_context = self.get_memory_context()
|
||||
if memory_context:
|
||||
system_prompt += f"\n\n## Memory (Information you remember about the user)\n{memory_context}"
|
||||
# Display memory info to user if verbose
|
||||
if self.verbose:
|
||||
self._display_memory_info()
|
||||
|
||||
# Add learn context if learn is enabled (auto-inject when memory="learn")
|
||||
learn_context = self.get_learn_context()
|
||||
@@ -452,6 +481,17 @@ Your Goal: {self.goal}"""
|
||||
else:
|
||||
cleaned_tools.append(tool)
|
||||
|
||||
# Sort tools deterministically for cache stability
|
||||
def sort_formatted_tools(tools_list):
|
||||
"""Sort already-formatted tool schemas by function name."""
|
||||
def sort_key(tool):
|
||||
if isinstance(tool, dict) and tool.get('type') == 'function':
|
||||
return str(tool.get('function', {}).get('name') or '')
|
||||
return ''
|
||||
return sorted(tools_list, key=sort_key)
|
||||
|
||||
cleaned_tools = sort_formatted_tools(cleaned_tools)
|
||||
|
||||
# Cache the formatted tools with LRU eviction, including tool search metadata
|
||||
self._cache_put(
|
||||
self._formatted_tools_cache,
|
||||
@@ -1327,7 +1367,7 @@ Your Goal: {self.goal}"""
|
||||
"""
|
||||
Execute unified chat completion using composition instead of runtime class mutation.
|
||||
|
||||
This method provides the same functionality as UnifiedChatMixin but uses
|
||||
This method provides a unified dispatch for all LLM providers using
|
||||
composition for safety and maintainability.
|
||||
"""
|
||||
from ..llm import create_llm_dispatcher
|
||||
@@ -1451,7 +1491,7 @@ Your Goal: {self.goal}"""
|
||||
"""
|
||||
Execute unified async chat completion using composition instead of runtime class mutation.
|
||||
|
||||
This method provides the same functionality as UnifiedChatMixin but uses
|
||||
This method provides a unified async dispatch for all LLM providers using
|
||||
composition for safety and maintainability.
|
||||
"""
|
||||
from ..llm import create_llm_dispatcher
|
||||
|
||||
@@ -317,8 +317,9 @@ class MemoryMixin:
|
||||
return
|
||||
|
||||
try:
|
||||
from ..session import get_default_session_store
|
||||
self._session_store = get_default_session_store()
|
||||
if self._session_store is None:
|
||||
from ..session import get_default_session_store
|
||||
self._session_store = get_default_session_store()
|
||||
|
||||
# Restore chat history from previous session
|
||||
history = self._session_store.get_chat_history(self._session_id)
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
"""
|
||||
Prepared Turn Mixin for Agent class.
|
||||
|
||||
This mixin provides the integration between the existing Agent class
|
||||
and the new PreparedTurnContext pattern. It demonstrates how to refactor
|
||||
existing execution methods to use the new standardized runtime approach.
|
||||
|
||||
This mixin can be added to the Agent class to provide backward-compatible
|
||||
methods that use PreparedTurnContext internally while maintaining the
|
||||
existing API surface.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import List, Optional, Any, Dict, Union, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..runtime.turn_context import PreparedTurnContext
|
||||
|
||||
from ..runtime import (
|
||||
PreparedTurnContext,
|
||||
default_context_builder,
|
||||
RuntimeMode,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PreparedTurnMixin:
|
||||
"""
|
||||
Mixin that adds PreparedTurnContext support to Agent class.
|
||||
|
||||
This mixin demonstrates the integration path for existing agents
|
||||
to use the new turn context pattern while maintaining backward
|
||||
compatibility with existing APIs.
|
||||
|
||||
Usage:
|
||||
class Agent(PreparedTurnMixin, ChatMixin, ...):
|
||||
# existing implementation
|
||||
pass
|
||||
|
||||
# New context-aware methods:
|
||||
agent = Agent(...)
|
||||
context = agent.prepare_turn_context("Hello", temperature=0.7)
|
||||
response = await agent.execute_prepared_turn(context)
|
||||
|
||||
# Or use the integrated chat method:
|
||||
response = agent.chat_with_prepared_context("Hello", temperature=0.7)
|
||||
"""
|
||||
|
||||
def prepare_turn_context(
|
||||
self,
|
||||
prompt: str,
|
||||
**kwargs: Any
|
||||
) -> PreparedTurnContext:
|
||||
"""
|
||||
Prepare a turn context for execution.
|
||||
|
||||
This method creates a PreparedTurnContext that captures all the
|
||||
configuration needed for a single agent turn. The context can
|
||||
then be passed to runtimes or used for inspection/debugging.
|
||||
|
||||
Args:
|
||||
prompt: User prompt for this turn
|
||||
**kwargs: Additional execution parameters including:
|
||||
- model: Override model ID
|
||||
- tools: Override tools list
|
||||
- stream: Enable streaming
|
||||
- temperature: Model temperature
|
||||
- max_tokens: Token limit
|
||||
- session_id: Session identifier
|
||||
|
||||
Returns:
|
||||
PreparedTurnContext ready for execution
|
||||
|
||||
Example:
|
||||
```python
|
||||
agent = Agent(name="helper", model="gpt-4")
|
||||
context = agent.prepare_turn_context(
|
||||
"What's the weather?",
|
||||
temperature=0.7,
|
||||
stream=True
|
||||
)
|
||||
|
||||
# Inspect the prepared context
|
||||
print(f"Model: {context.model_ref.model_id}")
|
||||
print(f"Tools: {len(context.tools)}")
|
||||
print(f"Mode: {context.runtime_mode}")
|
||||
```
|
||||
"""
|
||||
logger.debug(f"Preparing turn context for agent {getattr(self, 'name', 'Unknown')}")
|
||||
|
||||
try:
|
||||
context = default_context_builder.build_context(
|
||||
agent=self,
|
||||
prompt=prompt,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Prepared context: {context.get_message_count()} messages, "
|
||||
f"{len(context.tools)} tools, {context.runtime_mode.value} mode"
|
||||
)
|
||||
|
||||
return context
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to prepare turn context: {e}")
|
||||
raise
|
||||
|
||||
async def execute_prepared_turn(
|
||||
self,
|
||||
context: PreparedTurnContext
|
||||
) -> str:
|
||||
"""
|
||||
Execute a prepared turn context.
|
||||
|
||||
This method takes a PreparedTurnContext and executes it using
|
||||
the appropriate runtime. It demonstrates how prepared contexts
|
||||
eliminate the need for scattered configuration gathering.
|
||||
|
||||
Args:
|
||||
context: The prepared turn context to execute
|
||||
|
||||
Returns:
|
||||
Agent response text
|
||||
|
||||
Example:
|
||||
```python
|
||||
# Prepare context
|
||||
context = agent.prepare_turn_context("Hello")
|
||||
|
||||
# Execute later (possibly in different context)
|
||||
response = await agent.execute_prepared_turn(context)
|
||||
```
|
||||
"""
|
||||
logger.debug(f"Executing prepared turn {context.correlation.turn_id}")
|
||||
|
||||
try:
|
||||
# Get the runtime for this context
|
||||
runtime = self._get_runtime_for_prepared_context(context)
|
||||
|
||||
# Validate runtime supports the context mode
|
||||
if not runtime.supports_runtime_mode(context.runtime_mode):
|
||||
raise ValueError(
|
||||
f"Runtime does not support mode {context.runtime_mode.value}"
|
||||
)
|
||||
|
||||
# Execute the turn
|
||||
response = await runtime.run_turn(context)
|
||||
|
||||
# Update agent state with response (maintain existing behavior)
|
||||
await self._post_turn_processing(context, response)
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to execute prepared turn: {e}")
|
||||
raise
|
||||
|
||||
def chat_with_prepared_context(
|
||||
self,
|
||||
prompt: str,
|
||||
**kwargs: Any
|
||||
) -> str:
|
||||
"""
|
||||
Sync chat using PreparedTurnContext pattern.
|
||||
|
||||
This method provides a backward-compatible sync interface that
|
||||
uses the new PreparedTurnContext pattern internally. It can
|
||||
serve as a drop-in replacement for existing chat() methods.
|
||||
|
||||
Args:
|
||||
prompt: User prompt
|
||||
**kwargs: Execution parameters
|
||||
|
||||
Returns:
|
||||
Agent response
|
||||
|
||||
Example:
|
||||
```python
|
||||
# Drop-in replacement for agent.chat()
|
||||
response = agent.chat_with_prepared_context(
|
||||
"Hello",
|
||||
temperature=0.7,
|
||||
stream=True
|
||||
)
|
||||
```
|
||||
"""
|
||||
# Prepare context in one step
|
||||
context = self.prepare_turn_context(prompt, **kwargs)
|
||||
|
||||
# Execute synchronously
|
||||
if hasattr(self, '_execute_sync'):
|
||||
return self._execute_sync(context)
|
||||
else:
|
||||
# Check if we're already in a running event loop
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
# If we get here, there's already a running loop
|
||||
agent_id = getattr(self, 'id', getattr(self, 'session_id', getattr(self, 'name', 'unknown')))
|
||||
raise RuntimeError(
|
||||
f"Cannot call chat_with_prepared_context from within a running event loop. "
|
||||
f"Use achat_with_prepared_context instead. Agent: {agent_id}"
|
||||
)
|
||||
except RuntimeError:
|
||||
# No running loop, safe to call asyncio.run
|
||||
pass
|
||||
|
||||
# Fallback to asyncio.run
|
||||
return asyncio.run(self.execute_prepared_turn(context))
|
||||
|
||||
async def achat_with_prepared_context(
|
||||
self,
|
||||
prompt: str,
|
||||
**kwargs: Any
|
||||
) -> str:
|
||||
"""
|
||||
Async chat using PreparedTurnContext pattern.
|
||||
|
||||
Args:
|
||||
prompt: User prompt
|
||||
**kwargs: Execution parameters
|
||||
|
||||
Returns:
|
||||
Agent response
|
||||
"""
|
||||
# Prepare context in one step
|
||||
context = self.prepare_turn_context(prompt, **kwargs)
|
||||
|
||||
# Execute asynchronously
|
||||
return await self.execute_prepared_turn(context)
|
||||
|
||||
def _get_runtime_for_prepared_context(
|
||||
self,
|
||||
context: PreparedTurnContext
|
||||
) -> Any:
|
||||
"""
|
||||
Get the appropriate runtime for the prepared context.
|
||||
|
||||
This method demonstrates runtime selection based on context
|
||||
properties. In practice, this could integrate with the existing
|
||||
execution infrastructure or plugin systems.
|
||||
|
||||
Args:
|
||||
context: The prepared turn context
|
||||
|
||||
Returns:
|
||||
Runtime instance that can execute the context
|
||||
"""
|
||||
# For now, use a bridge runtime that delegates to existing methods
|
||||
return PreparedTurnBridgeRuntime(self)
|
||||
|
||||
async def _post_turn_processing(
|
||||
self,
|
||||
context: PreparedTurnContext,
|
||||
response: str
|
||||
) -> None:
|
||||
"""
|
||||
Process results after turn execution.
|
||||
|
||||
This method maintains existing agent behavior like updating
|
||||
chat history, memory storage, etc. while working with the
|
||||
new context pattern.
|
||||
|
||||
Args:
|
||||
context: The executed turn context
|
||||
response: The response that was generated
|
||||
"""
|
||||
# Update chat history (maintain existing behavior)
|
||||
if hasattr(self, 'chat_history'):
|
||||
# Extract original prompt from context
|
||||
user_messages = [
|
||||
msg for msg in context.transcript.messages
|
||||
if msg.get('role') == 'user'
|
||||
]
|
||||
if user_messages:
|
||||
prompt = user_messages[-1]['content']
|
||||
|
||||
# Add to chat history if not already there
|
||||
if not self.chat_history or self.chat_history[-1].get('content') != prompt:
|
||||
self.chat_history.append({'role': 'user', 'content': prompt})
|
||||
|
||||
# Add response
|
||||
self.chat_history.append({'role': 'assistant', 'content': response})
|
||||
|
||||
# Store in memory if enabled
|
||||
if hasattr(self, '_memory_instance') and self._memory_instance and response:
|
||||
try:
|
||||
user_messages = [
|
||||
msg for msg in context.transcript.messages
|
||||
if msg.get('role') == 'user'
|
||||
]
|
||||
if user_messages:
|
||||
prompt = user_messages[-1]['content']
|
||||
self._memory_instance.store_short_term(
|
||||
f"User: {prompt}\nAssistant: {response}",
|
||||
metadata={
|
||||
"agent_id": getattr(self, 'agent_id', getattr(self, 'name', 'unknown_agent')),
|
||||
"turn_id": context.correlation.turn_id,
|
||||
"session_id": context.correlation.session_id
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to store interaction in memory: {e}")
|
||||
|
||||
|
||||
class PreparedTurnBridgeRuntime:
|
||||
"""
|
||||
Bridge runtime that integrates PreparedTurnContext with existing Agent methods.
|
||||
|
||||
This runtime demonstrates how to use prepared contexts while delegating
|
||||
to existing agent execution infrastructure. It serves as a compatibility
|
||||
layer during the transition to the new pattern.
|
||||
"""
|
||||
|
||||
def __init__(self, agent):
|
||||
self.agent = agent
|
||||
self._supported_modes = [
|
||||
RuntimeMode.SYNC,
|
||||
RuntimeMode.ASYNC,
|
||||
RuntimeMode.STREAM,
|
||||
RuntimeMode.ASYNC_STREAM
|
||||
]
|
||||
|
||||
async def run_turn(self, context: PreparedTurnContext) -> str:
|
||||
"""
|
||||
Execute a turn using existing agent infrastructure.
|
||||
|
||||
This method bridges the new PreparedTurnContext pattern with
|
||||
existing agent execution methods, demonstrating backward
|
||||
compatibility during transition.
|
||||
|
||||
Args:
|
||||
context: The prepared turn context
|
||||
|
||||
Returns:
|
||||
Agent response
|
||||
"""
|
||||
try:
|
||||
# Extract execution parameters from context
|
||||
execution_params = self._extract_execution_params(context)
|
||||
|
||||
# Use existing agent execution method
|
||||
if hasattr(self.agent, '_unified_chat_impl'):
|
||||
# Use unified execution if available
|
||||
response = await self.agent._unified_chat_impl(**execution_params)
|
||||
else:
|
||||
# Get LLM client and check if it has async support
|
||||
llm_client = getattr(self.agent, 'llm_instance', None) or getattr(self.agent, 'llm', None)
|
||||
if llm_client and hasattr(llm_client, 'get_response_async'):
|
||||
response = await llm_client.get_response_async(**execution_params)
|
||||
elif hasattr(self.agent, 'chat'):
|
||||
# Fallback to sync method - avoid duplicate prompt
|
||||
prompt = execution_params.get('prompt', '')
|
||||
passthrough = {k: v for k, v in execution_params.items() if k != 'prompt'}
|
||||
response = self.agent.chat(prompt, **passthrough)
|
||||
else:
|
||||
raise RuntimeError("No execution method available")
|
||||
|
||||
return response or ""
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Bridge runtime execution failed: {e}")
|
||||
raise
|
||||
|
||||
def _extract_execution_params(self, context: PreparedTurnContext) -> Dict[str, Any]:
|
||||
"""
|
||||
Extract execution parameters from PreparedTurnContext.
|
||||
|
||||
This method demonstrates how to convert the prepared context
|
||||
back to the parameters expected by existing execution methods.
|
||||
|
||||
Args:
|
||||
context: The prepared turn context
|
||||
|
||||
Returns:
|
||||
Dictionary of parameters for existing execution methods
|
||||
"""
|
||||
# Extract prompt from transcript
|
||||
user_messages = [
|
||||
msg for msg in context.transcript.messages
|
||||
if msg.get('role') == 'user'
|
||||
]
|
||||
prompt = user_messages[-1]['content'] if user_messages else ""
|
||||
|
||||
# Extract tools
|
||||
tools = [tool.callable for tool in context.tools if tool.callable is not None]
|
||||
if not tools:
|
||||
tools = None # Let existing method use default tools
|
||||
|
||||
# Build parameters dictionary
|
||||
params = {
|
||||
'prompt': prompt,
|
||||
'tools': tools,
|
||||
'stream': context.delivery.enable_streaming,
|
||||
**context.model_ref.model_config # Include temperature, max_tokens, etc.
|
||||
}
|
||||
|
||||
# Add system prompt to chat history for existing methods
|
||||
if context.transcript.system_prompt and hasattr(self.agent, 'chat_history'):
|
||||
# Temporarily modify agent's system behavior
|
||||
original_system = getattr(self.agent, 'use_system_prompt', True)
|
||||
params['system_prompt'] = context.transcript.system_prompt
|
||||
|
||||
# Add chat history (exclude only the current user message, keep prior conversation)
|
||||
chat_history = context.transcript.messages[:-1] # Exclude current user message
|
||||
if chat_history:
|
||||
params['chat_history'] = chat_history
|
||||
|
||||
return params
|
||||
|
||||
def supports_runtime_mode(self, mode: RuntimeMode) -> bool:
|
||||
"""Check if this runtime supports the given mode."""
|
||||
return mode in self._supported_modes
|
||||
|
||||
def get_supported_modes(self) -> List[RuntimeMode]:
|
||||
"""Get all supported runtime modes."""
|
||||
return self._supported_modes.copy()
|
||||
@@ -5,15 +5,10 @@ Provides safe code execution capabilities to agents via the sandbox framework.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, Union
|
||||
from typing import Optional, Dict, Any, Union, TYPE_CHECKING
|
||||
|
||||
from ..sandbox import (
|
||||
SandboxConfig,
|
||||
SandboxManager,
|
||||
SandboxResult,
|
||||
check_code_safety,
|
||||
format_warnings,
|
||||
)
|
||||
if TYPE_CHECKING:
|
||||
from ..sandbox import SandboxConfig, SandboxManager, SandboxResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -26,27 +21,33 @@ class SandboxMixin:
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Extract sandbox config from kwargs
|
||||
self.sandbox_config: Optional[SandboxConfig] = kwargs.pop('sandbox', None)
|
||||
sandbox = kwargs.pop('sandbox', None)
|
||||
|
||||
# Convert boolean True to default config
|
||||
if self.sandbox_config is True:
|
||||
self.sandbox_config = SandboxConfig.subprocess()
|
||||
if sandbox is True:
|
||||
from ..sandbox import SandboxConfig
|
||||
sandbox = SandboxConfig.subprocess()
|
||||
elif sandbox is False:
|
||||
sandbox = None
|
||||
|
||||
self.sandbox_config: Optional['SandboxConfig'] = sandbox
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self._sandbox_manager: Optional[SandboxManager] = None
|
||||
self._sandbox_manager: Optional['SandboxManager'] = None
|
||||
|
||||
@property
|
||||
def has_sandbox(self) -> bool:
|
||||
"""Check if sandbox is configured for this agent."""
|
||||
return self.sandbox_config is not None
|
||||
|
||||
def get_sandbox_manager(self) -> Optional[SandboxManager]:
|
||||
def get_sandbox_manager(self) -> Optional['SandboxManager']:
|
||||
"""Get or create sandbox manager."""
|
||||
if not self.has_sandbox:
|
||||
return None
|
||||
|
||||
if self._sandbox_manager is None:
|
||||
from ..sandbox import SandboxManager
|
||||
self._sandbox_manager = SandboxManager(self.sandbox_config)
|
||||
|
||||
return self._sandbox_manager
|
||||
@@ -57,7 +58,7 @@ class SandboxMixin:
|
||||
language: str = "python",
|
||||
check_security: bool = True,
|
||||
**kwargs
|
||||
) -> SandboxResult:
|
||||
) -> 'SandboxResult':
|
||||
"""Execute code safely in configured sandbox.
|
||||
|
||||
Args:
|
||||
@@ -79,6 +80,7 @@ class SandboxMixin:
|
||||
|
||||
# Security pre-check if enabled
|
||||
if check_security:
|
||||
from ..sandbox import check_code_safety, format_warnings
|
||||
warnings = check_code_safety(code, language)
|
||||
if warnings:
|
||||
warning_text = format_warnings(warnings)
|
||||
@@ -97,7 +99,7 @@ class SandboxMixin:
|
||||
language: str = "python",
|
||||
check_security: bool = True,
|
||||
**kwargs
|
||||
) -> SandboxResult:
|
||||
) -> 'SandboxResult':
|
||||
"""Synchronous wrapper for execute_code.
|
||||
|
||||
Args:
|
||||
@@ -117,7 +119,7 @@ class SandboxMixin:
|
||||
command: Union[str, list],
|
||||
check_security: bool = True,
|
||||
**kwargs
|
||||
) -> SandboxResult:
|
||||
) -> 'SandboxResult':
|
||||
"""Run a shell command in the sandbox.
|
||||
|
||||
Args:
|
||||
@@ -139,6 +141,7 @@ class SandboxMixin:
|
||||
|
||||
# Security pre-check
|
||||
if check_security:
|
||||
from ..sandbox import check_code_safety, format_warnings
|
||||
warnings = check_code_safety(command_str, "bash")
|
||||
if warnings:
|
||||
warning_text = format_warnings(warnings)
|
||||
|
||||
@@ -231,9 +231,10 @@ class ToolExecutionMixin:
|
||||
arguments.update(res.output.modified_data)
|
||||
|
||||
# Loop guard check - prevent tool execution loops with graduated response
|
||||
if hasattr(self, '_loop_guard') and self._loop_guard:
|
||||
if hasattr(self, '_ensure_loop_guard'):
|
||||
loop_guard = self._ensure_loop_guard()
|
||||
from ..escalation.loop_guard import GuardAction
|
||||
decision = self._loop_guard.check(function_name, arguments, is_pre_execution=True)
|
||||
decision = loop_guard.check(function_name, arguments, is_pre_execution=True)
|
||||
|
||||
if decision.action == GuardAction.WARN:
|
||||
# Inject warning into tool result so LLM sees guidance
|
||||
@@ -245,7 +246,6 @@ class ToolExecutionMixin:
|
||||
blocked_result = {"error": f"[loop-guard] {decision.message}", "loop_blocked": True}
|
||||
elif decision.action == GuardAction.HALT:
|
||||
# Halt execution with exception
|
||||
from ..errors import ToolExecutionError
|
||||
raise ToolExecutionError(
|
||||
f"[loop-guard] {decision.message}",
|
||||
tool_name=function_name,
|
||||
@@ -394,11 +394,12 @@ class ToolExecutionMixin:
|
||||
self._doom_loop_tracker.mark_progress(f"tool:{function_name}")
|
||||
|
||||
# Record tool execution in loop guard
|
||||
if hasattr(self, '_loop_guard') and self._loop_guard:
|
||||
if hasattr(self, '_ensure_loop_guard'):
|
||||
loop_guard = self._ensure_loop_guard()
|
||||
is_success = result is not None and not (isinstance(result, dict) and result.get('error'))
|
||||
self._loop_guard.record(function_name, arguments, is_success)
|
||||
loop_guard.record(function_name, arguments, is_success)
|
||||
# Handle warning injection for WARN decisions
|
||||
decision = self._loop_guard.check(function_name, arguments, is_pre_execution=False)
|
||||
decision = loop_guard.check(function_name, arguments, is_pre_execution=False)
|
||||
if decision.action.value == "warn":
|
||||
if isinstance(result, str):
|
||||
result = f"{result}\n\n[loop-guard] {decision.message}"
|
||||
@@ -584,12 +585,22 @@ class ToolExecutionMixin:
|
||||
from ..approval import get_approval_registry
|
||||
from ..approval.protocols import ApprovalRequest, ApprovalDecision
|
||||
from ..approval.registry import DEFAULT_DANGEROUS_TOOLS
|
||||
from ..tools import get_registry as get_tool_registry
|
||||
|
||||
backend = getattr(self, '_approval_backend', None)
|
||||
approve_all = getattr(self, '_approve_all_tools', False)
|
||||
|
||||
# Check tool trust level if available
|
||||
tool_registry = get_tool_registry()
|
||||
trust_level = tool_registry.get_trust_level(tool_name)
|
||||
|
||||
if backend is not None:
|
||||
needs_approval = approve_all or tool_name in DEFAULT_DANGEROUS_TOOLS
|
||||
# Check if tool needs approval based on multiple criteria
|
||||
needs_approval = (
|
||||
approve_all
|
||||
or tool_name in DEFAULT_DANGEROUS_TOOLS
|
||||
or (trust_level == "external") # External tools need approval
|
||||
)
|
||||
if needs_approval:
|
||||
request = ApprovalRequest(
|
||||
tool_name=tool_name,
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
"""
|
||||
Turn Execution Mixin - implements PreparedTurnContext runtime integration.
|
||||
|
||||
This module provides the TurnExecutionMixin that uses PreparedTurnContext
|
||||
for standardized runtime preparation and execution. It demonstrates how
|
||||
to refactor existing execution code to use the new turn context pattern.
|
||||
|
||||
This serves as an example integration and can be used alongside or to
|
||||
replace portions of unified_execution_mixin.py.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import List, Optional, Any, Dict, Union, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..runtime.turn_context import PreparedTurnContext
|
||||
|
||||
from ..runtime import (
|
||||
PreparedTurnContext,
|
||||
default_context_builder,
|
||||
RuntimeMode,
|
||||
TurnRuntimeProtocol
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TurnExecutionMixin:
|
||||
"""
|
||||
Mixin providing turn-based execution using PreparedTurnContext.
|
||||
|
||||
This mixin demonstrates the new execution pattern where context
|
||||
is prepared in a single preflight step, then passed to runtimes
|
||||
for read-only execution.
|
||||
"""
|
||||
|
||||
async def _execute_with_prepared_context(
|
||||
self,
|
||||
prompt: str,
|
||||
**kwargs: Any
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Execute using PreparedTurnContext pattern.
|
||||
|
||||
This method demonstrates how to refactor existing execution
|
||||
to use the new turn context approach:
|
||||
|
||||
1. Build PreparedTurnContext in preflight step
|
||||
2. Pass immutable context to runtime for execution
|
||||
3. Mutations go through defined hooks only
|
||||
|
||||
Args:
|
||||
prompt: User prompt for this turn
|
||||
**kwargs: Additional execution parameters
|
||||
|
||||
Returns:
|
||||
Agent response or None if blocked
|
||||
"""
|
||||
try:
|
||||
# Step 1: Prepare turn context (replaces scattered preparation)
|
||||
context = default_context_builder.build_context(
|
||||
agent=self,
|
||||
prompt=prompt,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
logger.debug(f"Prepared turn context: {context.to_dict()}")
|
||||
|
||||
# Step 2: Apply pre-execution hooks if context allows
|
||||
if hasattr(self, '_apply_pre_execution_hooks'):
|
||||
context = await self._apply_pre_execution_hooks(context)
|
||||
|
||||
# Step 3: Execute using the appropriate runtime
|
||||
runtime = self._get_runtime_for_context(context)
|
||||
if not runtime.supports_runtime_mode(context.runtime_mode):
|
||||
raise ValueError(
|
||||
f"Runtime {type(runtime).__name__} does not support "
|
||||
f"mode {context.runtime_mode.value}"
|
||||
)
|
||||
|
||||
# Step 4: Run the turn with prepared context
|
||||
response = await runtime.run_turn(context)
|
||||
|
||||
# Step 5: Apply post-execution hooks if needed
|
||||
if hasattr(self, '_apply_post_execution_hooks'):
|
||||
response = await self._apply_post_execution_hooks(context, response)
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Turn execution failed: {e}")
|
||||
raise
|
||||
|
||||
def _get_runtime_for_context(self, context: PreparedTurnContext) -> TurnRuntimeProtocol:
|
||||
"""
|
||||
Get the appropriate runtime for the prepared context.
|
||||
|
||||
This method demonstrates runtime selection based on context
|
||||
properties. In practice, this could be more sophisticated
|
||||
(e.g., plugin registry, configuration-based selection).
|
||||
|
||||
Args:
|
||||
context: The prepared turn context
|
||||
|
||||
Returns:
|
||||
Runtime instance that can execute the context
|
||||
"""
|
||||
# For now, return a default runtime that delegates to existing execution
|
||||
return DefaultAgentRuntime(self)
|
||||
|
||||
def chat_with_context(
|
||||
self,
|
||||
prompt: str,
|
||||
**kwargs: Any
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Sync wrapper for context-based execution.
|
||||
|
||||
This provides a sync interface while using the new
|
||||
PreparedTurnContext pattern internally.
|
||||
|
||||
Args:
|
||||
prompt: User prompt
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
Agent response
|
||||
"""
|
||||
# Check if we're already in a running event loop
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
# If we get here, there's already a running loop
|
||||
agent_id = getattr(self, 'id', getattr(self, 'session_id', getattr(self, 'name', 'unknown')))
|
||||
raise RuntimeError(
|
||||
f"Cannot call chat_with_context from within a running event loop. "
|
||||
f"Use achat_with_context instead. Agent: {agent_id}"
|
||||
)
|
||||
except RuntimeError:
|
||||
# No running loop, safe to call asyncio.run
|
||||
pass
|
||||
|
||||
# Run in new event loop
|
||||
return asyncio.run(self._execute_with_prepared_context(prompt, **kwargs))
|
||||
|
||||
async def achat_with_context(
|
||||
self,
|
||||
prompt: str,
|
||||
**kwargs: Any
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Async interface for context-based execution.
|
||||
|
||||
Args:
|
||||
prompt: User prompt
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
Agent response
|
||||
"""
|
||||
return await self._execute_with_prepared_context(prompt, **kwargs)
|
||||
|
||||
|
||||
class DefaultAgentRuntime:
|
||||
"""
|
||||
Default runtime implementation that delegates to existing Agent methods.
|
||||
|
||||
This runtime bridges the new PreparedTurnContext pattern with the
|
||||
existing agent execution infrastructure. It demonstrates how to
|
||||
create a runtime that uses the prepared context while maintaining
|
||||
compatibility with existing code.
|
||||
"""
|
||||
|
||||
def __init__(self, agent):
|
||||
self.agent = agent
|
||||
self._supported_modes = [
|
||||
RuntimeMode.SYNC,
|
||||
RuntimeMode.ASYNC,
|
||||
RuntimeMode.STREAM,
|
||||
RuntimeMode.ASYNC_STREAM
|
||||
]
|
||||
|
||||
async def run_turn(self, context: PreparedTurnContext) -> str:
|
||||
"""
|
||||
Execute a turn using the prepared context.
|
||||
|
||||
This implementation demonstrates how to use the context
|
||||
to drive existing agent execution while eliminating
|
||||
scattered preparation logic.
|
||||
|
||||
Args:
|
||||
context: The prepared turn context
|
||||
|
||||
Returns:
|
||||
Agent response
|
||||
"""
|
||||
try:
|
||||
# Extract prompt from transcript
|
||||
user_messages = [
|
||||
msg for msg in context.transcript.messages
|
||||
if msg.get('role') == 'user'
|
||||
]
|
||||
if not user_messages:
|
||||
raise ValueError("No user message found in transcript")
|
||||
|
||||
prompt = user_messages[-1]['content']
|
||||
|
||||
# Use the prepared context to drive execution
|
||||
return await self._execute_with_context_data(prompt, context)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Runtime execution failed: {e}")
|
||||
raise
|
||||
|
||||
async def _execute_with_context_data(
|
||||
self,
|
||||
prompt: str,
|
||||
context: PreparedTurnContext
|
||||
) -> str:
|
||||
"""
|
||||
Execute using context data instead of scattered preparation.
|
||||
|
||||
This method shows how to use the prepared context components
|
||||
instead of re-preparing them during execution.
|
||||
"""
|
||||
# Get LLM client (existing pattern)
|
||||
llm_client = getattr(self.agent, 'llm_instance', None) or getattr(self.agent, 'llm', None)
|
||||
if llm_client is None:
|
||||
raise RuntimeError("No LLM client available")
|
||||
|
||||
# Use prepared model reference and tools
|
||||
model_config = context.model_ref.model_config
|
||||
tools = [tool.callable for tool in context.tools if tool.callable is not None]
|
||||
|
||||
# Use prepared transcript instead of rebuilding
|
||||
chat_history = context.transcript.messages[:-1] # Exclude current prompt
|
||||
system_prompt = context.transcript.system_prompt
|
||||
|
||||
# Execute based on runtime mode
|
||||
if context.runtime_mode in (RuntimeMode.STREAM, RuntimeMode.ASYNC_STREAM):
|
||||
return await self._execute_streaming(
|
||||
llm_client, prompt, system_prompt, chat_history, tools, model_config, context
|
||||
)
|
||||
else:
|
||||
return await self._execute_standard(
|
||||
llm_client, prompt, system_prompt, chat_history, tools, model_config, context
|
||||
)
|
||||
|
||||
async def _execute_standard(
|
||||
self,
|
||||
llm_client,
|
||||
prompt: str,
|
||||
system_prompt: str,
|
||||
chat_history: List[Dict[str, Any]],
|
||||
tools: List[Any],
|
||||
model_config: Dict[str, Any],
|
||||
context: PreparedTurnContext
|
||||
) -> str:
|
||||
"""Execute standard (non-streaming) request."""
|
||||
if hasattr(llm_client, 'get_response_async'):
|
||||
return await llm_client.get_response_async(
|
||||
prompt=prompt,
|
||||
system_prompt=system_prompt,
|
||||
chat_history=chat_history,
|
||||
tools=tools,
|
||||
**model_config
|
||||
)
|
||||
else:
|
||||
# Fallback to sync method - wrap in thread to avoid blocking event loop
|
||||
return await asyncio.to_thread(
|
||||
llm_client.get_response,
|
||||
prompt=prompt,
|
||||
system_prompt=system_prompt,
|
||||
chat_history=chat_history,
|
||||
tools=tools,
|
||||
**model_config
|
||||
)
|
||||
|
||||
async def _execute_streaming(
|
||||
self,
|
||||
llm_client,
|
||||
prompt: str,
|
||||
system_prompt: str,
|
||||
chat_history: List[Dict[str, Any]],
|
||||
tools: List[Any],
|
||||
model_config: Dict[str, Any],
|
||||
context: PreparedTurnContext
|
||||
) -> str:
|
||||
"""Execute streaming request using prepared delivery channels."""
|
||||
# Use prepared streaming configuration
|
||||
if not context.delivery.has_streaming():
|
||||
raise ValueError("Streaming requested but no streaming configuration in context")
|
||||
|
||||
# Configure streaming with prepared emitter
|
||||
model_config['stream'] = True
|
||||
|
||||
if hasattr(llm_client, 'get_response_async'):
|
||||
response = await llm_client.get_response_async(
|
||||
prompt=prompt,
|
||||
system_prompt=system_prompt,
|
||||
chat_history=chat_history,
|
||||
tools=tools,
|
||||
**model_config
|
||||
)
|
||||
else:
|
||||
response = await asyncio.to_thread(
|
||||
llm_client.get_response,
|
||||
prompt=prompt,
|
||||
system_prompt=system_prompt,
|
||||
chat_history=chat_history,
|
||||
tools=tools,
|
||||
**model_config
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
def supports_runtime_mode(self, mode: RuntimeMode) -> bool:
|
||||
"""Check if this runtime supports the given mode."""
|
||||
return mode in self._supported_modes
|
||||
|
||||
def get_supported_modes(self) -> List[RuntimeMode]:
|
||||
"""Get all supported runtime modes."""
|
||||
return self._supported_modes.copy()
|
||||
|
||||
|
||||
class MinimalTurnRuntime:
|
||||
"""
|
||||
Minimal runtime implementation for testing and examples.
|
||||
|
||||
This runtime demonstrates the simplest possible implementation
|
||||
of the TurnRuntimeProtocol for testing the PreparedTurnContext
|
||||
pattern without complex execution logic.
|
||||
"""
|
||||
|
||||
def __init__(self, response_template: str = "Mock response to: {prompt}"):
|
||||
self.response_template = response_template
|
||||
|
||||
async def run_turn(self, context: PreparedTurnContext) -> str:
|
||||
"""Execute a minimal turn for testing."""
|
||||
# Extract prompt from context
|
||||
user_messages = [
|
||||
msg for msg in context.transcript.messages
|
||||
if msg.get('role') == 'user'
|
||||
]
|
||||
prompt = user_messages[-1]['content'] if user_messages else "No prompt"
|
||||
|
||||
# Return simple response
|
||||
return self.response_template.format(prompt=prompt)
|
||||
|
||||
def supports_runtime_mode(self, mode: RuntimeMode) -> bool:
|
||||
"""Support all modes for testing."""
|
||||
return True
|
||||
|
||||
def get_supported_modes(self) -> List[RuntimeMode]:
|
||||
"""Get all supported modes."""
|
||||
return list(RuntimeMode)
|
||||
@@ -1,152 +0,0 @@
|
||||
"""
|
||||
Unified Chat Mixin for Agent class.
|
||||
|
||||
This module consolidates the dual execution paths (custom LLM vs OpenAI)
|
||||
into a single async-first protocol-driven implementation, eliminating
|
||||
the DRY violation and maintenance burden described in Issue #1304.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from ..llm import create_llm_dispatcher, UnifiedLLMDispatcher
|
||||
|
||||
|
||||
class UnifiedChatMixin:
|
||||
"""
|
||||
Unified chat implementation that replaces dual execution paths.
|
||||
|
||||
This mixin consolidates:
|
||||
- Path A: LLM.get_response() / LLM.get_response_async()
|
||||
- Path B: OpenAIClient chat completion
|
||||
|
||||
Into a single async-first protocol-driven dispatch using UnifiedLLMDispatcher.
|
||||
"""
|
||||
|
||||
def _get_unified_dispatcher(self) -> UnifiedLLMDispatcher:
|
||||
"""
|
||||
Get or create the unified LLM dispatcher for this agent.
|
||||
|
||||
This method implements the provider selection logic that was
|
||||
previously scattered in _chat_completion() method.
|
||||
"""
|
||||
if hasattr(self, '_unified_dispatcher') and self._unified_dispatcher is not None:
|
||||
return self._unified_dispatcher
|
||||
|
||||
# Determine which adapter to use based on existing agent configuration
|
||||
if self._using_custom_llm and hasattr(self, 'llm_instance'):
|
||||
# Use LiteLLM adapter for custom LLM instances
|
||||
dispatcher = create_llm_dispatcher(llm_instance=self.llm_instance)
|
||||
else:
|
||||
# Use OpenAI adapter for direct OpenAI access
|
||||
if not hasattr(self, '_openai_client') or self._openai_client is None:
|
||||
# Initialize OpenAI client if not present
|
||||
from ..llm import get_openai_client
|
||||
self._openai_client = get_openai_client(api_key=getattr(self, 'api_key', None))
|
||||
dispatcher = create_llm_dispatcher(openai_client=self._openai_client, model=self.llm)
|
||||
|
||||
# Cache the dispatcher
|
||||
self._unified_dispatcher = dispatcher
|
||||
return dispatcher
|
||||
|
||||
def _unified_chat_completion(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
temperature: float = 1.0,
|
||||
tools: Optional[List[Dict[str, Any]]] = None,
|
||||
stream: bool = True,
|
||||
reasoning_steps: bool = False,
|
||||
task_name: Optional[str] = None,
|
||||
task_description: Optional[str] = None,
|
||||
task_id: Optional[str] = None,
|
||||
response_format: Optional[Dict[str, Any]] = None
|
||||
) -> Any:
|
||||
"""
|
||||
Unified sync chat completion method.
|
||||
|
||||
This replaces the existing _chat_completion() method's dual dispatch logic
|
||||
with a single path through UnifiedLLMDispatcher.
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
# Get unified dispatcher (handles provider selection)
|
||||
dispatcher = self._get_unified_dispatcher()
|
||||
|
||||
# Use the same message processing logic but route through unified dispatcher
|
||||
formatted_tools = self._format_tools_for_completion(tools)
|
||||
|
||||
try:
|
||||
# Single dispatch path for all providers
|
||||
final_response = dispatcher.chat_completion(
|
||||
messages=messages,
|
||||
tools=formatted_tools,
|
||||
temperature=temperature,
|
||||
max_tokens=getattr(self, 'max_tokens', None),
|
||||
stream=stream,
|
||||
response_format=response_format,
|
||||
**{
|
||||
'reasoning_steps': reasoning_steps,
|
||||
'task_name': task_name,
|
||||
'task_description': task_description,
|
||||
'task_id': task_id,
|
||||
}
|
||||
)
|
||||
|
||||
return final_response
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Unified chat completion failed: {e}")
|
||||
# Apply the same error handling as the original implementation
|
||||
raise
|
||||
|
||||
async def _unified_achat_completion(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
temperature: float = 1.0,
|
||||
tools: Optional[List[Dict[str, Any]]] = None,
|
||||
stream: bool = True,
|
||||
reasoning_steps: bool = False,
|
||||
task_name: Optional[str] = None,
|
||||
task_description: Optional[str] = None,
|
||||
task_id: Optional[str] = None,
|
||||
response_format: Optional[Dict[str, Any]] = None
|
||||
) -> Any:
|
||||
"""
|
||||
Unified async chat completion method.
|
||||
|
||||
This provides native async implementation, eliminating the need
|
||||
for separate async/sync implementations in the LLM layer.
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
# Get unified dispatcher (handles provider selection)
|
||||
dispatcher = self._get_unified_dispatcher()
|
||||
|
||||
# Use the same message processing logic but route through unified dispatcher
|
||||
formatted_tools = self._format_tools_for_completion(tools)
|
||||
|
||||
try:
|
||||
# Single async dispatch path for all providers
|
||||
final_response = await dispatcher.achat_completion(
|
||||
messages=messages,
|
||||
tools=formatted_tools,
|
||||
temperature=temperature,
|
||||
max_tokens=getattr(self, 'max_tokens', None),
|
||||
stream=stream,
|
||||
response_format=response_format,
|
||||
**{
|
||||
'reasoning_steps': reasoning_steps,
|
||||
'task_name': task_name,
|
||||
'task_description': task_description,
|
||||
'task_id': task_id,
|
||||
}
|
||||
)
|
||||
|
||||
return final_response
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Unified async chat completion failed: {e}")
|
||||
# Apply the same error handling as the original implementation
|
||||
raise
|
||||
@@ -78,12 +78,37 @@ class UnifiedExecutionMixin:
|
||||
_trace_emitter.agent_start(self.name, {"role": self.role, "goal": self.goal})
|
||||
|
||||
# Reset loop guard for new chat turn (always-on protection)
|
||||
if hasattr(self, '_loop_guard') and self._loop_guard:
|
||||
self._loop_guard.reset_turn()
|
||||
if hasattr(self, '_ensure_loop_guard'):
|
||||
self._ensure_loop_guard().reset_turn()
|
||||
|
||||
try:
|
||||
# CLI Backend routing - delegate entire turn if configured
|
||||
if hasattr(self, '_cli_backend') and self._cli_backend is not None:
|
||||
# Runtime resolution - check for model-scoped runtime before legacy CLI backend
|
||||
runtime_instance = None
|
||||
if getattr(self, '_runtime_config', None) is not None:
|
||||
runtime_instance = await self._resolve_turn_runtime()
|
||||
|
||||
# Model-scoped runtime routing - delegate entire turn to resolved runtime
|
||||
if runtime_instance is not None:
|
||||
return await self._chat_via_runtime(
|
||||
runtime_instance=runtime_instance,
|
||||
prompt=prompt,
|
||||
temperature=temperature,
|
||||
tools=tools,
|
||||
output_json=output_json,
|
||||
output_pydantic=output_pydantic,
|
||||
reasoning_steps=reasoning_steps,
|
||||
stream=stream,
|
||||
task_name=task_name,
|
||||
task_description=task_description,
|
||||
task_id=task_id,
|
||||
config=config,
|
||||
force_retrieval=force_retrieval,
|
||||
skip_retrieval=skip_retrieval,
|
||||
attachments=attachments,
|
||||
tool_choice=tool_choice
|
||||
)
|
||||
elif hasattr(self, '_cli_backend') and self._cli_backend is not None:
|
||||
# Legacy CLI backend with deprecation warning (emitted in Agent.__init__)
|
||||
return await self._chat_via_cli_backend(
|
||||
prompt=prompt,
|
||||
temperature=temperature,
|
||||
|
||||
@@ -40,6 +40,21 @@ class TaskStatus(Enum):
|
||||
# Set up logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _launch_auth_token() -> Optional[str]:
|
||||
return os.environ.get("PRAISONAI_LAUNCH_AUTH_TOKEN")
|
||||
|
||||
|
||||
def _authorise_launch_request(request) -> bool:
|
||||
token = _launch_auth_token()
|
||||
if not token:
|
||||
return True
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer ") and auth[7:] == token:
|
||||
return True
|
||||
return request.headers.get("X-Auth-Token") == token
|
||||
|
||||
|
||||
# Agent server registry for thread-safe server management
|
||||
|
||||
|
||||
@@ -245,7 +260,20 @@ Expected Output: {task.expected_output}."""
|
||||
# Add memory context if available
|
||||
if task.memory:
|
||||
try:
|
||||
memory_context = task.memory.build_context_for_task(task.description)
|
||||
# Use cache-optimized context if available for better prompt caching
|
||||
if hasattr(task.memory, 'build_cache_optimized_context'):
|
||||
try:
|
||||
cache_result = task.memory.build_cache_optimized_context(
|
||||
task_descr=task.description,
|
||||
include_cache_boundary=False # Don't include boundary for agent task context
|
||||
)
|
||||
memory_context = cache_result.get('stable_prefix', '')
|
||||
except Exception as e:
|
||||
# Fall back to standard context building
|
||||
logger.debug(f"Cache-optimized context failed for task '{task.description or task.id}', falling back to standard: {e}")
|
||||
memory_context = task.memory.build_context_for_task(task.description)
|
||||
else:
|
||||
memory_context = task.memory.build_context_for_task(task.description)
|
||||
if memory_context:
|
||||
# Log detailed memory context for debugging
|
||||
logger.debug(f"Memory context for task '{task.description}': {memory_context}")
|
||||
@@ -652,7 +680,7 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
_stream = _output_config.stream
|
||||
else:
|
||||
_verbose = 0
|
||||
_stream = True
|
||||
_stream = False
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Resolve EXECUTION param using canonical resolver
|
||||
@@ -1993,6 +2021,8 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
# Define the endpoint handler
|
||||
@app.post(path)
|
||||
async def handle_query(request: Request, query_data: Optional[AgentQuery] = None):
|
||||
if not _authorise_launch_request(request):
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
# Handle both direct JSON with query field and form data
|
||||
if query_data is None:
|
||||
try:
|
||||
@@ -2071,7 +2101,9 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
|
||||
# Add GET endpoint to list available agents
|
||||
@app.get(f"{path}/list")
|
||||
async def list_agents():
|
||||
async def list_agents(request: Request):
|
||||
if not _authorise_launch_request(request):
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
return {
|
||||
"agents": [
|
||||
{"name": agent.display_name, "id": agent.display_name.lower().replace(' ', '_')}
|
||||
@@ -2086,6 +2118,8 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
# Create a closure to capture the agent instance
|
||||
def create_agent_handler(agent):
|
||||
async def handle_single_agent(request: Request):
|
||||
if not _authorise_launch_request(request):
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
try:
|
||||
request_data = await request.json()
|
||||
query = request_data.get("query", "")
|
||||
|
||||
@@ -53,3 +53,4 @@ class AgentAppConfig:
|
||||
workers: int = 1
|
||||
timeout: int = 60
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
api_key: Optional[str] = None
|
||||
@@ -194,9 +194,13 @@ class ApprovalRegistry:
|
||||
|
||||
def is_yaml_approved(self, tool_name: str) -> bool:
|
||||
try:
|
||||
return tool_name in self._yaml_approved_tools.get()
|
||||
if tool_name not in self._yaml_approved_tools.get():
|
||||
return False
|
||||
except LookupError:
|
||||
return False
|
||||
if self.get_risk_level(tool_name) == "critical":
|
||||
return False
|
||||
return True
|
||||
|
||||
# ── Env-var check ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -24,6 +24,19 @@ if TYPE_CHECKING:
|
||||
HealthResult,
|
||||
EmailProtocol,
|
||||
EmailInbox,
|
||||
SupportsPresentation,
|
||||
PlatformCapabilities,
|
||||
)
|
||||
from .presentation import (
|
||||
MessagePresentation,
|
||||
PresentationBlock,
|
||||
PresentationButton,
|
||||
PresentationAction,
|
||||
SelectOption,
|
||||
PresentationLimits,
|
||||
ActionType,
|
||||
ButtonStyle,
|
||||
BlockType,
|
||||
)
|
||||
from .protocols import (
|
||||
BotProtocol,
|
||||
@@ -41,6 +54,19 @@ from .protocols import (
|
||||
HealthResult,
|
||||
EmailProtocol,
|
||||
EmailInbox,
|
||||
SupportsPresentation,
|
||||
PlatformCapabilities,
|
||||
)
|
||||
from .presentation import (
|
||||
MessagePresentation,
|
||||
PresentationBlock,
|
||||
PresentationButton,
|
||||
PresentationAction,
|
||||
SelectOption,
|
||||
PresentationLimits,
|
||||
ActionType,
|
||||
ButtonStyle,
|
||||
BlockType,
|
||||
)
|
||||
from .config import BotConfig, BotOSConfig
|
||||
|
||||
@@ -62,4 +88,15 @@ __all__ = [
|
||||
"HealthResult",
|
||||
"EmailProtocol",
|
||||
"EmailInbox",
|
||||
"MessagePresentation",
|
||||
"PresentationBlock",
|
||||
"PresentationButton",
|
||||
"PresentationAction",
|
||||
"SelectOption",
|
||||
"PresentationLimits",
|
||||
"SupportsPresentation",
|
||||
"ActionType",
|
||||
"ButtonStyle",
|
||||
"BlockType",
|
||||
"PlatformCapabilities",
|
||||
]
|
||||
@@ -91,6 +91,16 @@ class BotConfig:
|
||||
# When set, stale sessions older than this are auto-reaped.
|
||||
session_ttl: int = 0
|
||||
|
||||
# In-flight run control settings for mid-run message handling
|
||||
# busy_mode: Policy for handling messages during active runs
|
||||
# - "queue": Queue message for next turn (default)
|
||||
# - "interrupt": Cancel current run and start new one
|
||||
# - "steer": Inject message into current run via steering
|
||||
busy_mode: str = "queue"
|
||||
|
||||
# Template for busy acknowledgment messages (use {action} placeholder)
|
||||
busy_ack: str = "⏳ {action} — will be considered next"
|
||||
|
||||
# Workspace settings for file operation containment and security
|
||||
workspace_dir: Optional[str] = None # default: ~/.praisonai/workspaces/<scope>/<session_key>
|
||||
workspace_access: str = "rw" # "rw" (read-write) | "ro" (read-only) | "none" (copy-on-write sandbox)
|
||||
@@ -101,6 +111,12 @@ class BotConfig:
|
||||
|
||||
# Owner user ID for pairing approvals (platform-specific format)
|
||||
owner_user_id: Optional[str] = None
|
||||
|
||||
# Progressive streaming for channel bots (default: False)
|
||||
streaming: bool = False
|
||||
|
||||
# Edit interval for streaming responses in milliseconds (default: 700ms)
|
||||
stream_edit_interval_ms: int = 700
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.unknown_user_policy not in {"deny", "pair", "allow"}:
|
||||
@@ -136,11 +152,15 @@ class BotConfig:
|
||||
"ack_emoji": self.ack_emoji,
|
||||
"done_emoji": self.done_emoji,
|
||||
"session_ttl": self.session_ttl,
|
||||
"busy_mode": self.busy_mode,
|
||||
"busy_ack": self.busy_ack,
|
||||
"workspace_dir": self.workspace_dir,
|
||||
"workspace_access": self.workspace_access,
|
||||
"workspace_scope": self.workspace_scope,
|
||||
"unknown_user_policy": self.unknown_user_policy,
|
||||
"owner_user_id": "***" if self.owner_user_id else None,
|
||||
"streaming": self.streaming,
|
||||
"stream_edit_interval_ms": self.stream_edit_interval_ms,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
"""
|
||||
Presentation primitives for interactive UI in messaging bots.
|
||||
|
||||
Defines typed, portable presentation blocks (buttons, selects, text)
|
||||
that channel adapters render as native widgets. Enables structured
|
||||
interactive UI across Telegram, Slack, Discord, and other platforms.
|
||||
|
||||
This is a core protocol with no heavy implementations - channel-specific
|
||||
rendering belongs in the wrapper (praisonai).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Literal, Optional, Union
|
||||
|
||||
|
||||
class ActionType(str, Enum):
|
||||
"""Types of actions that can be triggered by interactive elements."""
|
||||
|
||||
COMMAND = "command" # Execute a slash command
|
||||
CALLBACK = "callback" # Opaque callback data for the plugin
|
||||
URL = "url" # Open a URL
|
||||
WEB_APP = "web_app" # Open a web app (Telegram mini apps, etc)
|
||||
|
||||
|
||||
class ButtonStyle(str, Enum):
|
||||
"""Visual styles for buttons."""
|
||||
|
||||
PRIMARY = "primary" # Primary action (blue/green)
|
||||
DANGER = "danger" # Destructive action (red)
|
||||
SECONDARY = "secondary" # Secondary action (gray)
|
||||
SUCCESS = "success" # Success action (green)
|
||||
WARNING = "warning" # Warning action (yellow)
|
||||
|
||||
|
||||
class BlockType(str, Enum):
|
||||
"""Types of presentation blocks."""
|
||||
|
||||
TEXT = "text" # Text content
|
||||
BUTTONS = "buttons" # Button row/grid
|
||||
SELECT = "select" # Dropdown/select menu
|
||||
DIVIDER = "divider" # Visual separator
|
||||
CONTEXT = "context" # Contextual info (smaller text)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PresentationAction:
|
||||
"""An action triggered by an interactive element.
|
||||
|
||||
Attributes:
|
||||
type: The action type (command, callback, url, web_app)
|
||||
command: Slash command to execute (for type="command")
|
||||
value: Callback data (for type="callback")
|
||||
url: URL to open (for type="url")
|
||||
web_app_url: Web app URL (for type="web_app")
|
||||
"""
|
||||
|
||||
type: Union[ActionType, str]
|
||||
command: Optional[str] = None
|
||||
value: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
web_app_url: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for serialization."""
|
||||
data = {"type": self.type.value if isinstance(self.type, ActionType) else self.type}
|
||||
if self.command is not None:
|
||||
data["command"] = self.command
|
||||
if self.value is not None:
|
||||
data["value"] = self.value
|
||||
if self.url is not None:
|
||||
data["url"] = self.url
|
||||
if self.web_app_url is not None:
|
||||
data["web_app_url"] = self.web_app_url
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "PresentationAction":
|
||||
"""Create from dictionary."""
|
||||
return cls(
|
||||
type=data.get("type", "callback"),
|
||||
command=data.get("command"),
|
||||
value=data.get("value"),
|
||||
url=data.get("url"),
|
||||
web_app_url=data.get("web_app_url"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PresentationButton:
|
||||
"""A button in an interactive presentation.
|
||||
|
||||
Attributes:
|
||||
label: Button text label
|
||||
action: Action to trigger when clicked
|
||||
url: Direct URL (alternative to action)
|
||||
priority: Truncation priority (higher survives)
|
||||
style: Visual style
|
||||
disabled: Whether button is disabled
|
||||
"""
|
||||
|
||||
label: str
|
||||
action: Optional[PresentationAction] = None
|
||||
url: Optional[str] = None
|
||||
priority: int = 0
|
||||
style: Optional[Union[ButtonStyle, str]] = None
|
||||
disabled: bool = False
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for serialization."""
|
||||
data = {"label": self.label, "priority": self.priority, "disabled": self.disabled}
|
||||
if self.action is not None:
|
||||
data["action"] = self.action.to_dict()
|
||||
if self.url is not None:
|
||||
data["url"] = self.url
|
||||
if self.style is not None:
|
||||
data["style"] = self.style.value if isinstance(self.style, ButtonStyle) else self.style
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "PresentationButton":
|
||||
"""Create from dictionary."""
|
||||
return cls(
|
||||
label=data.get("label", ""),
|
||||
action=PresentationAction.from_dict(data["action"]) if "action" in data else None,
|
||||
url=data.get("url"),
|
||||
priority=data.get("priority", 0),
|
||||
style=data.get("style"),
|
||||
disabled=data.get("disabled", False),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SelectOption:
|
||||
"""An option in a select menu.
|
||||
|
||||
Attributes:
|
||||
label: Option text label
|
||||
value: Option value
|
||||
description: Optional description
|
||||
emoji: Optional emoji icon
|
||||
default: Whether this is the default option
|
||||
"""
|
||||
|
||||
label: str
|
||||
value: str
|
||||
description: Optional[str] = None
|
||||
emoji: Optional[str] = None
|
||||
default: bool = False
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for serialization."""
|
||||
data = {"label": self.label, "value": self.value, "default": self.default}
|
||||
if self.description is not None:
|
||||
data["description"] = self.description
|
||||
if self.emoji is not None:
|
||||
data["emoji"] = self.emoji
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "SelectOption":
|
||||
"""Create from dictionary."""
|
||||
return cls(
|
||||
label=data.get("label", ""),
|
||||
value=data.get("value", ""),
|
||||
description=data.get("description"),
|
||||
emoji=data.get("emoji"),
|
||||
default=data.get("default", False),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PresentationBlock:
|
||||
"""A block in a message presentation.
|
||||
|
||||
Blocks are the building blocks of interactive messages.
|
||||
Each block type has specific properties and behavior.
|
||||
"""
|
||||
|
||||
type: Union[BlockType, str]
|
||||
text: Optional[str] = None
|
||||
buttons: Optional[List[PresentationButton]] = None
|
||||
options: Optional[List[SelectOption]] = None
|
||||
placeholder: Optional[str] = None
|
||||
action_id: Optional[str] = None
|
||||
|
||||
@staticmethod
|
||||
def make_text(content: str, markdown: bool = True) -> "PresentationBlock":
|
||||
"""Create a text block."""
|
||||
return PresentationBlock(type=BlockType.TEXT, text=content)
|
||||
|
||||
@staticmethod
|
||||
def make_buttons(items: List[PresentationButton]) -> "PresentationBlock":
|
||||
"""Create a buttons block."""
|
||||
return PresentationBlock(type=BlockType.BUTTONS, buttons=items)
|
||||
|
||||
@staticmethod
|
||||
def make_select(
|
||||
options: List[SelectOption],
|
||||
placeholder: Optional[str] = None,
|
||||
action_id: Optional[str] = None,
|
||||
) -> "PresentationBlock":
|
||||
"""Create a select menu block."""
|
||||
return PresentationBlock(
|
||||
type=BlockType.SELECT,
|
||||
options=options,
|
||||
placeholder=placeholder,
|
||||
action_id=action_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def make_divider() -> "PresentationBlock":
|
||||
"""Create a divider block."""
|
||||
return PresentationBlock(type=BlockType.DIVIDER)
|
||||
|
||||
@staticmethod
|
||||
def make_context(content: str) -> "PresentationBlock":
|
||||
"""Create a context block (smaller text)."""
|
||||
return PresentationBlock(type=BlockType.CONTEXT, text=content)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for serialization."""
|
||||
data = {"type": self.type.value if isinstance(self.type, BlockType) else self.type}
|
||||
if self.text is not None:
|
||||
data["text"] = self.text
|
||||
if self.buttons is not None:
|
||||
data["buttons"] = [b.to_dict() for b in self.buttons]
|
||||
if self.options is not None:
|
||||
data["options"] = [o.to_dict() for o in self.options]
|
||||
if self.placeholder is not None:
|
||||
data["placeholder"] = self.placeholder
|
||||
if self.action_id is not None:
|
||||
data["action_id"] = self.action_id
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "PresentationBlock":
|
||||
"""Create from dictionary."""
|
||||
return cls(
|
||||
type=data.get("type", "text"),
|
||||
text=data.get("text"),
|
||||
buttons=(
|
||||
[PresentationButton.from_dict(b) for b in data["buttons"]]
|
||||
if "buttons" in data else None
|
||||
),
|
||||
options=(
|
||||
[SelectOption.from_dict(o) for o in data["options"]]
|
||||
if "options" in data else None
|
||||
),
|
||||
placeholder=data.get("placeholder"),
|
||||
action_id=data.get("action_id"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MessagePresentation:
|
||||
"""A complete interactive message presentation.
|
||||
|
||||
Attributes:
|
||||
blocks: List of presentation blocks
|
||||
tone: Optional tone/style hint for the whole message
|
||||
ephemeral: Whether message should be ephemeral/temporary
|
||||
replace_message_id: ID of message to replace (for updates)
|
||||
"""
|
||||
|
||||
blocks: List[PresentationBlock] = field(default_factory=list)
|
||||
tone: Optional[str] = None
|
||||
ephemeral: bool = False
|
||||
replace_message_id: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for serialization."""
|
||||
data = {"blocks": [b.to_dict() for b in self.blocks], "ephemeral": self.ephemeral}
|
||||
if self.tone is not None:
|
||||
data["tone"] = self.tone
|
||||
if self.replace_message_id is not None:
|
||||
data["replace_message_id"] = self.replace_message_id
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "MessagePresentation":
|
||||
"""Create from dictionary."""
|
||||
return cls(
|
||||
blocks=[PresentationBlock.from_dict(b) for b in data.get("blocks", [])],
|
||||
tone=data.get("tone"),
|
||||
ephemeral=data.get("ephemeral", False),
|
||||
replace_message_id=data.get("replace_message_id"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def approval(
|
||||
prompt: str,
|
||||
approval_id: str,
|
||||
allow_always: bool = False,
|
||||
context: Optional[str] = None,
|
||||
) -> "MessagePresentation":
|
||||
"""Create a standard approval presentation.
|
||||
|
||||
Args:
|
||||
prompt: The approval prompt text
|
||||
approval_id: Unique ID for this approval
|
||||
allow_always: Whether to include "Allow Always" option
|
||||
context: Optional context information
|
||||
|
||||
Returns:
|
||||
A presentation with standard approval buttons
|
||||
"""
|
||||
blocks = []
|
||||
|
||||
# Add prompt
|
||||
blocks.append(PresentationBlock.make_text(prompt))
|
||||
|
||||
# Add context if provided
|
||||
if context:
|
||||
blocks.append(PresentationBlock.make_context(context))
|
||||
|
||||
# Create buttons
|
||||
buttons = [
|
||||
PresentationButton(
|
||||
label="Allow Once",
|
||||
action=PresentationAction(
|
||||
type=ActionType.COMMAND,
|
||||
command=f"/approve {approval_id} allow-once"
|
||||
),
|
||||
style=ButtonStyle.PRIMARY,
|
||||
priority=10,
|
||||
),
|
||||
PresentationButton(
|
||||
label="Deny",
|
||||
action=PresentationAction(
|
||||
type=ActionType.COMMAND,
|
||||
command=f"/approve {approval_id} deny"
|
||||
),
|
||||
style=ButtonStyle.DANGER,
|
||||
priority=9,
|
||||
),
|
||||
]
|
||||
|
||||
if allow_always:
|
||||
buttons.insert(1, PresentationButton(
|
||||
label="Allow Always",
|
||||
action=PresentationAction(
|
||||
type=ActionType.COMMAND,
|
||||
command=f"/approve {approval_id} allow-always"
|
||||
),
|
||||
style=ButtonStyle.SUCCESS,
|
||||
priority=8,
|
||||
))
|
||||
|
||||
blocks.append(PresentationBlock.make_buttons(buttons))
|
||||
|
||||
return MessagePresentation(blocks=blocks)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PresentationLimits:
|
||||
"""Channel-specific limits for presentations.
|
||||
|
||||
Attributes:
|
||||
max_buttons: Maximum buttons per row/message
|
||||
max_button_rows: Maximum button rows
|
||||
max_button_label: Maximum characters in button label
|
||||
max_options: Maximum options in select menu
|
||||
max_option_label: Maximum characters in option label
|
||||
max_text_length: Maximum text block length
|
||||
supports_markdown: Whether channel supports markdown
|
||||
supports_select: Whether channel supports select menus
|
||||
supports_web_apps: Whether channel supports web apps
|
||||
"""
|
||||
|
||||
max_buttons: int = 10
|
||||
max_button_rows: int = 5
|
||||
max_button_label: int = 50
|
||||
max_options: int = 25
|
||||
max_option_label: int = 50
|
||||
max_text_length: int = 4096
|
||||
supports_markdown: bool = True
|
||||
supports_select: bool = True
|
||||
supports_web_apps: bool = False
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
"max_buttons": self.max_buttons,
|
||||
"max_button_rows": self.max_button_rows,
|
||||
"max_button_label": self.max_button_label,
|
||||
"max_options": self.max_options,
|
||||
"max_option_label": self.max_option_label,
|
||||
"max_text_length": self.max_text_length,
|
||||
"supports_markdown": self.supports_markdown,
|
||||
"supports_select": self.supports_select,
|
||||
"supports_web_apps": self.supports_web_apps,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "PresentationLimits":
|
||||
"""Create from dictionary."""
|
||||
return cls(
|
||||
max_buttons=data.get("max_buttons", 10),
|
||||
max_button_rows=data.get("max_button_rows", 5),
|
||||
max_button_label=data.get("max_button_label", 50),
|
||||
max_options=data.get("max_options", 25),
|
||||
max_option_label=data.get("max_option_label", 50),
|
||||
max_text_length=data.get("max_text_length", 4096),
|
||||
supports_markdown=data.get("supports_markdown", True),
|
||||
supports_select=data.get("supports_select", True),
|
||||
supports_web_apps=data.get("supports_web_apps", False),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def telegram() -> "PresentationLimits":
|
||||
"""Get Telegram-specific limits."""
|
||||
return PresentationLimits(
|
||||
max_buttons=8,
|
||||
max_button_rows=100,
|
||||
max_button_label=64,
|
||||
max_options=0, # No native select
|
||||
max_text_length=4096,
|
||||
supports_markdown=True,
|
||||
supports_select=False,
|
||||
supports_web_apps=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def slack() -> "PresentationLimits":
|
||||
"""Get Slack-specific limits."""
|
||||
return PresentationLimits(
|
||||
max_buttons=5,
|
||||
max_button_rows=1,
|
||||
max_button_label=75,
|
||||
max_options=100,
|
||||
max_option_label=75,
|
||||
max_text_length=3000,
|
||||
supports_markdown=True,
|
||||
supports_select=True,
|
||||
supports_web_apps=False,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def discord() -> "PresentationLimits":
|
||||
"""Get Discord-specific limits."""
|
||||
return PresentationLimits(
|
||||
max_buttons=25,
|
||||
max_button_rows=5,
|
||||
max_button_label=80,
|
||||
max_options=25,
|
||||
max_option_label=100,
|
||||
max_text_length=2000,
|
||||
supports_markdown=True,
|
||||
supports_select=True,
|
||||
supports_web_apps=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from typing import (
|
||||
List,
|
||||
Optional,
|
||||
Protocol,
|
||||
TypedDict,
|
||||
Union,
|
||||
runtime_checkable,
|
||||
)
|
||||
@@ -30,6 +31,105 @@ if TYPE_CHECKING:
|
||||
from ..agent import Agent
|
||||
|
||||
|
||||
class ChannelCapabilities(TypedDict, total=False):
|
||||
"""Declares what features a bot channel supports.
|
||||
|
||||
This allows shared engines to adapt behavior based on platform capabilities,
|
||||
enabling graceful degradation when features aren't available.
|
||||
|
||||
Attributes:
|
||||
live_edit: Whether the channel supports editing messages in place
|
||||
reactions: Whether the channel supports adding reactions to messages
|
||||
typing: Whether the channel supports typing indicators
|
||||
text_limit: Maximum message length (0 = unlimited)
|
||||
edit_rate_limit: Minimum seconds between edits (for throttling)
|
||||
reaction_rate_limit: Minimum seconds between reactions
|
||||
"""
|
||||
live_edit: bool
|
||||
reactions: bool
|
||||
typing: bool
|
||||
text_limit: int
|
||||
edit_rate_limit: float
|
||||
reaction_rate_limit: float
|
||||
|
||||
|
||||
class RunStatus(Enum):
|
||||
"""Run status states for progress feedback.
|
||||
|
||||
Used by status engines to show agent execution state through
|
||||
reactions or status lines.
|
||||
"""
|
||||
QUEUED = "queued"
|
||||
THINKING = "thinking"
|
||||
TOOL = "tool"
|
||||
DONE = "done"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlatformCapabilities:
|
||||
"""Platform-specific capabilities descriptor for bot adapters.
|
||||
|
||||
Declares what a messaging platform can do, enabling the shared delivery
|
||||
machinery to apply features like streaming, rate limiting, and chunking
|
||||
uniformly across all platforms.
|
||||
|
||||
Attributes:
|
||||
max_message_length: Maximum message length in the platform's unit
|
||||
length_unit: Unit for message length ("codepoints" or "utf16")
|
||||
supports_edit: Whether the platform supports in-place message edits (for streaming)
|
||||
supports_typing: Whether the platform supports typing indicators
|
||||
markdown_dialect: Markdown flavor the platform uses (e.g., "markdown", "telegram_markdown_v2")
|
||||
needs_rate_limit: Whether the platform needs rate limiting
|
||||
edit_interval_ms: Minimum milliseconds between message edits (for streaming)
|
||||
max_files_per_message: Maximum number of file attachments per message
|
||||
max_file_size_mb: Maximum file size in megabytes
|
||||
supported_file_types: List of supported file extensions/mime types
|
||||
"""
|
||||
|
||||
max_message_length: int = 4096
|
||||
length_unit: str = "codepoints" # "codepoints" or "utf16"
|
||||
supports_edit: bool = False
|
||||
supports_typing: bool = True
|
||||
markdown_dialect: str = "markdown"
|
||||
needs_rate_limit: bool = True
|
||||
edit_interval_ms: int = 1000 # Minimum ms between edits
|
||||
max_files_per_message: int = 1
|
||||
max_file_size_mb: int = 10
|
||||
supported_file_types: List[str] = field(default_factory=lambda: ["*"])
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"max_message_length": self.max_message_length,
|
||||
"length_unit": self.length_unit,
|
||||
"supports_edit": self.supports_edit,
|
||||
"supports_typing": self.supports_typing,
|
||||
"markdown_dialect": self.markdown_dialect,
|
||||
"needs_rate_limit": self.needs_rate_limit,
|
||||
"edit_interval_ms": self.edit_interval_ms,
|
||||
"max_files_per_message": self.max_files_per_message,
|
||||
"max_file_size_mb": self.max_file_size_mb,
|
||||
"supported_file_types": self.supported_file_types,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "PlatformCapabilities":
|
||||
"""Create from dictionary."""
|
||||
return cls(
|
||||
max_message_length=data.get("max_message_length", 4096),
|
||||
length_unit=data.get("length_unit", "codepoints"),
|
||||
supports_edit=data.get("supports_edit", False),
|
||||
supports_typing=data.get("supports_typing", True),
|
||||
markdown_dialect=data.get("markdown_dialect", "markdown"),
|
||||
needs_rate_limit=data.get("needs_rate_limit", True),
|
||||
edit_interval_ms=data.get("edit_interval_ms", 1000),
|
||||
max_files_per_message=data.get("max_files_per_message", 1),
|
||||
max_file_size_mb=data.get("max_file_size_mb", 10),
|
||||
supported_file_types=data.get("supported_file_types", ["*"]),
|
||||
)
|
||||
|
||||
|
||||
class MessageType(str, Enum):
|
||||
"""Types of bot messages."""
|
||||
|
||||
@@ -318,6 +418,80 @@ class ProbeResult:
|
||||
}
|
||||
|
||||
|
||||
class HealthReason(Enum):
|
||||
"""Reasons for channel health status."""
|
||||
HEALTHY = "healthy"
|
||||
NOT_RUNNING = "not-running"
|
||||
DISCONNECTED = "disconnected"
|
||||
STALE_SOCKET = "stale-socket"
|
||||
STUCK = "stuck"
|
||||
BUSY = "busy"
|
||||
STARTUP_GRACE = "startup-grace"
|
||||
ERROR = "error"
|
||||
|
||||
@property
|
||||
def is_recoverable(self) -> bool:
|
||||
"""Whether this reason indicates a recoverable state."""
|
||||
return self in {
|
||||
HealthReason.DISCONNECTED,
|
||||
HealthReason.STALE_SOCKET,
|
||||
HealthReason.STUCK,
|
||||
HealthReason.ERROR,
|
||||
}
|
||||
|
||||
|
||||
def evaluate_channel_health(
|
||||
health: HealthResult,
|
||||
startup_grace_seconds: float = 60.0,
|
||||
stale_after_seconds: float = 120.0,
|
||||
current_time: Optional[float] = None,
|
||||
) -> HealthReason:
|
||||
"""Evaluate channel health and return a reason.
|
||||
|
||||
Pure function that evaluates a HealthResult and determines
|
||||
the health reason based on various criteria.
|
||||
|
||||
Args:
|
||||
health: The health result to evaluate
|
||||
startup_grace_seconds: Grace period for startup
|
||||
stale_after_seconds: Time after which no activity is considered stale
|
||||
current_time: Current timestamp (for testing)
|
||||
|
||||
Returns:
|
||||
HealthReason indicating the channel's health status
|
||||
"""
|
||||
if current_time is None:
|
||||
current_time = time.time()
|
||||
|
||||
# Not running
|
||||
if not health.is_running:
|
||||
return HealthReason.NOT_RUNNING
|
||||
|
||||
# Startup grace period
|
||||
if health.uptime_seconds is not None and health.uptime_seconds < startup_grace_seconds:
|
||||
return HealthReason.STARTUP_GRACE
|
||||
|
||||
# Check for errors
|
||||
if health.error:
|
||||
return HealthReason.ERROR
|
||||
|
||||
# Check probe result
|
||||
if health.probe and not health.probe.ok:
|
||||
return HealthReason.DISCONNECTED
|
||||
|
||||
# Check for stale socket (no transport activity)
|
||||
if health.last_activity is not None:
|
||||
time_since_activity = current_time - health.last_activity
|
||||
if time_since_activity > stale_after_seconds:
|
||||
return HealthReason.STALE_SOCKET
|
||||
|
||||
# Overall health status
|
||||
if not health.ok:
|
||||
return HealthReason.ERROR
|
||||
|
||||
return HealthReason.HEALTHY
|
||||
|
||||
|
||||
@dataclass
|
||||
class HealthResult:
|
||||
"""Detailed health status of a bot.
|
||||
@@ -331,6 +505,8 @@ class HealthResult:
|
||||
sessions: Number of active sessions
|
||||
error: Error message (if unhealthy)
|
||||
details: Additional platform-specific health details
|
||||
reason: Health status reason (optional)
|
||||
last_activity: Last transport activity timestamp (optional)
|
||||
"""
|
||||
|
||||
ok: bool
|
||||
@@ -341,6 +517,8 @@ class HealthResult:
|
||||
sessions: int = 0
|
||||
error: Optional[str] = None
|
||||
details: Dict[str, Any] = field(default_factory=dict)
|
||||
reason: Optional[HealthReason] = None
|
||||
last_activity: Optional[float] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
@@ -353,6 +531,8 @@ class HealthResult:
|
||||
"sessions": self.sessions,
|
||||
"error": self.error,
|
||||
"details": self.details,
|
||||
"reason": self.reason.value if self.reason else None,
|
||||
"last_activity": self.last_activity,
|
||||
}
|
||||
|
||||
|
||||
@@ -388,6 +568,24 @@ class BotProtocol(Protocol):
|
||||
"""The bot's user information."""
|
||||
...
|
||||
|
||||
@property
|
||||
def capabilities(self) -> ChannelCapabilities:
|
||||
"""Channel capabilities for feature discovery.
|
||||
|
||||
Returns capabilities that shared engines use to adapt behavior.
|
||||
Channels that don't support a feature should return False for it.
|
||||
"""
|
||||
...
|
||||
|
||||
@property
|
||||
def platform_capabilities(self) -> PlatformCapabilities:
|
||||
"""Platform capabilities descriptor.
|
||||
|
||||
Returns the platform's capabilities for use by shared delivery code.
|
||||
Adapters should override this to declare their specific capabilities.
|
||||
"""
|
||||
...
|
||||
|
||||
# Lifecycle methods
|
||||
async def start(self) -> None:
|
||||
"""Start the bot (begin receiving messages)."""
|
||||
@@ -499,6 +697,33 @@ class BotProtocol(Protocol):
|
||||
"""
|
||||
...
|
||||
|
||||
# Reactions
|
||||
async def add_reaction(self, channel_id: str, message_id: str, emoji: str) -> bool:
|
||||
"""Add a reaction emoji to a message.
|
||||
|
||||
Args:
|
||||
channel_id: Channel containing the message
|
||||
message_id: Message to react to
|
||||
emoji: Emoji to add (Unicode or custom emoji ID)
|
||||
|
||||
Returns:
|
||||
True if reaction was added successfully
|
||||
"""
|
||||
...
|
||||
|
||||
async def remove_reaction(self, channel_id: str, message_id: str, emoji: str) -> bool:
|
||||
"""Remove a reaction emoji from a message.
|
||||
|
||||
Args:
|
||||
channel_id: Channel containing the message
|
||||
message_id: Message to remove reaction from
|
||||
emoji: Emoji to remove
|
||||
|
||||
Returns:
|
||||
True if reaction was removed successfully
|
||||
"""
|
||||
...
|
||||
|
||||
# User/channel info
|
||||
async def get_user(self, user_id: str) -> Optional[BotUser]:
|
||||
"""Get user information.
|
||||
@@ -613,6 +838,55 @@ class ChatCommandProtocol(Protocol):
|
||||
...
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# SupportsPresentation — interactive UI presentation protocol
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SupportsPresentation(Protocol):
|
||||
"""Protocol for channel adapters that support presentations.
|
||||
|
||||
Channel adapters implement this protocol to render
|
||||
portable presentations as native widgets.
|
||||
"""
|
||||
|
||||
@property
|
||||
def presentation_limits(self) -> "PresentationLimits":
|
||||
"""Get channel-specific presentation limits."""
|
||||
...
|
||||
|
||||
async def render_presentation(
|
||||
self,
|
||||
target: str,
|
||||
presentation: "MessagePresentation",
|
||||
) -> Optional[str]:
|
||||
"""Render a presentation to a target (chat/channel).
|
||||
|
||||
Args:
|
||||
target: Target identifier (chat_id, channel_id, etc.)
|
||||
presentation: The presentation to render
|
||||
|
||||
Returns:
|
||||
Message ID if sent successfully, None otherwise
|
||||
"""
|
||||
...
|
||||
|
||||
def truncate_presentation(
|
||||
self,
|
||||
presentation: "MessagePresentation",
|
||||
) -> "MessagePresentation":
|
||||
"""Truncate a presentation to fit channel limits.
|
||||
|
||||
Args:
|
||||
presentation: The presentation to truncate
|
||||
|
||||
Returns:
|
||||
Truncated presentation that fits channel limits
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# EmailProtocol — email-specific bot capabilities
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
@@ -33,6 +33,10 @@ __all__ = [
|
||||
"CompactionStrategy",
|
||||
# Results
|
||||
"CompactionResult",
|
||||
# Protocols
|
||||
"ToolResultPrunerProtocol",
|
||||
"MessageFormatterProtocol",
|
||||
"SummaryBuilderProtocol",
|
||||
]
|
||||
|
||||
|
||||
@@ -54,4 +58,16 @@ def __getattr__(name: str):
|
||||
from .result import CompactionResult
|
||||
return CompactionResult
|
||||
|
||||
if name == "ToolResultPrunerProtocol":
|
||||
from .protocols import ToolResultPrunerProtocol
|
||||
return ToolResultPrunerProtocol
|
||||
|
||||
if name == "MessageFormatterProtocol":
|
||||
from .protocols import MessageFormatterProtocol
|
||||
return MessageFormatterProtocol
|
||||
|
||||
if name == "SummaryBuilderProtocol":
|
||||
from .protocols import SummaryBuilderProtocol
|
||||
return SummaryBuilderProtocol
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -5,12 +5,13 @@ Manages context window by compacting messages when needed.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import List, Dict, Any, Optional, Callable, Awaitable
|
||||
from typing import List, Dict, Any, Optional, Callable, Awaitable, Tuple
|
||||
import asyncio
|
||||
|
||||
from .config import CompactionConfig, COMPACTION_PREFIX, SUMMARY_TEMPLATE
|
||||
from .strategy import CompactionStrategy
|
||||
from .result import CompactionResult
|
||||
from .protocols import ToolResultPrunerProtocol, MessageFormatterProtocol, SummaryBuilderProtocol
|
||||
|
||||
|
||||
class ContextCompactor:
|
||||
@@ -34,7 +35,10 @@ class ContextCompactor:
|
||||
preserve_system: bool = True,
|
||||
preserve_recent: int = 5,
|
||||
config: Optional[CompactionConfig] = None,
|
||||
llm_summarize_fn: Optional[Callable[[str], Awaitable[str]]] = None
|
||||
llm_summarize_fn: Optional[Callable[[str], Awaitable[str]]] = None,
|
||||
tool_pruner: Optional[ToolResultPrunerProtocol] = None,
|
||||
message_formatter: Optional[MessageFormatterProtocol] = None,
|
||||
summary_builder: Optional[SummaryBuilderProtocol] = None
|
||||
):
|
||||
"""
|
||||
Initialize the compactor.
|
||||
@@ -45,30 +49,43 @@ class ContextCompactor:
|
||||
strategy: Compaction strategy to use
|
||||
preserve_system: Keep system messages
|
||||
preserve_recent: Number of recent messages to preserve
|
||||
<<<<<<< HEAD
|
||||
config: Optional CompactionConfig to override defaults
|
||||
config: Optional CompactionConfig for advanced settings
|
||||
llm_summarize_fn: Async function to call LLM for summarization
|
||||
tool_pruner: Protocol implementation for tool result pruning
|
||||
message_formatter: Protocol implementation for message formatting
|
||||
summary_builder: Protocol implementation for summary building
|
||||
"""
|
||||
# Initialize config first
|
||||
self.config = config or CompactionConfig()
|
||||
# Use provided config or create default
|
||||
self.config = config or CompactionConfig(
|
||||
max_tokens=max_tokens,
|
||||
target_tokens=target_tokens or int(max_tokens * 0.75),
|
||||
preserve_system=preserve_system,
|
||||
preserve_recent=preserve_recent
|
||||
)
|
||||
|
||||
# Use config values if config provided, otherwise use constructor args
|
||||
if config is not None:
|
||||
self.max_tokens = config.max_tokens
|
||||
self.target_tokens = config.target_tokens
|
||||
self.preserve_system = config.preserve_system
|
||||
self.preserve_recent = config.preserve_recent
|
||||
else:
|
||||
self.max_tokens = max_tokens
|
||||
self.target_tokens = target_tokens or int(max_tokens * 0.75)
|
||||
self.preserve_system = preserve_system
|
||||
self.preserve_recent = preserve_recent
|
||||
|
||||
# Set instance attributes from config to ensure consistency
|
||||
self.max_tokens = self.config.max_tokens
|
||||
self.target_tokens = self.config.target_tokens
|
||||
self.strategy = strategy
|
||||
self.preserve_system = self.config.preserve_system
|
||||
self.preserve_recent = self.config.preserve_recent
|
||||
self.llm_summarize_fn = llm_summarize_fn
|
||||
|
||||
# Track previous summaries for iterative update
|
||||
# Protocol implementations (defaults to None - no heavy implementations in core)
|
||||
self.tool_pruner = tool_pruner
|
||||
self.message_formatter = message_formatter
|
||||
self.summary_builder = summary_builder
|
||||
|
||||
# Anti-thrashing state tracking
|
||||
self._last_savings_pct: float = 100.0 # Start high to allow first compaction
|
||||
self._low_savings_streak: int = 0
|
||||
|
||||
# Iterative summary state
|
||||
self._previous_summary: Optional[str] = None
|
||||
self._previous_summary_global_idx: int = 0
|
||||
|
||||
# Reset state tracking for each compact() call
|
||||
self._used_previous_summary: bool = False
|
||||
|
||||
def estimate_tokens(self, text: str) -> int:
|
||||
"""Estimate token count for text."""
|
||||
@@ -96,12 +113,26 @@ class ContextCompactor:
|
||||
return sum(self.count_message_tokens(m) for m in messages)
|
||||
|
||||
def needs_compaction(self, messages: List[Dict[str, Any]]) -> bool:
|
||||
"""Check if messages need compaction."""
|
||||
return self.count_total_tokens(messages) > self.max_tokens
|
||||
"""
|
||||
Check if messages need compaction with anti-thrashing protection.
|
||||
|
||||
Returns False if:
|
||||
- Token count is below threshold
|
||||
- We've had too many consecutive low-savings attempts
|
||||
"""
|
||||
if self.count_total_tokens(messages) <= self.max_tokens:
|
||||
return False
|
||||
|
||||
# Anti-thrashing: skip if we've had too many low-savings attempts
|
||||
if self._low_savings_streak >= self.config.max_consecutive_low_savings:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def compact(
|
||||
self,
|
||||
messages: List[Dict[str, Any]]
|
||||
messages: List[Dict[str, Any]],
|
||||
focus_topic: str = ""
|
||||
) -> tuple[List[Dict[str, Any]], CompactionResult]:
|
||||
"""
|
||||
Compact messages to fit within token limit (synchronous version).
|
||||
@@ -111,31 +142,49 @@ class ContextCompactor:
|
||||
|
||||
Args:
|
||||
messages: List of messages to compact
|
||||
focus_topic: Optional topic to focus on during summarization
|
||||
|
||||
Returns:
|
||||
Tuple of (compacted messages, result)
|
||||
"""
|
||||
# Reset state for this compaction call
|
||||
self._used_previous_summary = False
|
||||
|
||||
original_tokens = self.count_total_tokens(messages)
|
||||
|
||||
if original_tokens <= self.max_tokens:
|
||||
return messages, CompactionResult(
|
||||
result = CompactionResult(
|
||||
original_tokens=original_tokens,
|
||||
compacted_tokens=original_tokens,
|
||||
messages_removed=0,
|
||||
messages_kept=len(messages),
|
||||
strategy_used=self.strategy
|
||||
)
|
||||
result.calculate_savings_pct()
|
||||
return messages, result
|
||||
|
||||
# Apply tool result deduplication pre-pass if enabled
|
||||
processed_messages = messages
|
||||
tool_results_pruned = 0
|
||||
if self.config.tool_prune_before_summarise and self.tool_pruner:
|
||||
processed_messages, tool_results_pruned = self.tool_pruner.prune(
|
||||
messages,
|
||||
self.config.max_tool_result_size
|
||||
)
|
||||
|
||||
# Skip early exit - proceed with full compaction strategy
|
||||
# Anti-thrashing check will be applied after strategy runs
|
||||
|
||||
if self.strategy == CompactionStrategy.TRUNCATE:
|
||||
compacted = self._truncate(messages)
|
||||
compacted = self._truncate(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.SLIDING:
|
||||
compacted = self._sliding_window(messages)
|
||||
compacted = self._sliding_window(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.SUMMARIZE:
|
||||
compacted = self._summarize(messages)
|
||||
compacted = self._summarize(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.SMART:
|
||||
compacted = self._smart_compact(messages)
|
||||
compacted = self._smart_compact(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.PRUNE:
|
||||
compacted = self._prune(messages)
|
||||
compacted = self._prune(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.LLM_SUMMARIZE:
|
||||
if self.llm_summarize_fn:
|
||||
# For sync calls with LLM function, we need to run async
|
||||
@@ -144,17 +193,17 @@ class ContextCompactor:
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
# If in async context, fallback to naive summarization
|
||||
compacted = self._summarize(messages)
|
||||
compacted = self._summarize(processed_messages)
|
||||
except RuntimeError:
|
||||
# No running loop, safe to create one
|
||||
compacted = asyncio.run(self._llm_summarize_async(messages))
|
||||
compacted = asyncio.run(self._llm_summarize_async(processed_messages, focus_topic))
|
||||
except Exception:
|
||||
# Fallback to naive summarization if async fails
|
||||
compacted = self._summarize(messages)
|
||||
compacted = self._summarize(processed_messages)
|
||||
else:
|
||||
compacted = self._llm_summarize(messages)
|
||||
compacted = self._llm_summarize(processed_messages, focus_topic)
|
||||
else:
|
||||
compacted = self._truncate(messages)
|
||||
compacted = self._truncate(processed_messages)
|
||||
|
||||
compacted_tokens = self.count_total_tokens(compacted)
|
||||
|
||||
@@ -163,49 +212,75 @@ class ContextCompactor:
|
||||
compacted_tokens=compacted_tokens,
|
||||
messages_removed=len(messages) - len(compacted),
|
||||
messages_kept=len(compacted),
|
||||
strategy_used=self.strategy
|
||||
strategy_used=self.strategy,
|
||||
tool_results_pruned=tool_results_pruned,
|
||||
previous_summary_reused=getattr(self, '_used_previous_summary', False)
|
||||
)
|
||||
result.calculate_savings_pct()
|
||||
|
||||
# Update anti-thrashing tracking based on actual results
|
||||
self._last_savings_pct = result.savings_pct
|
||||
if result.savings_pct >= self.config.min_savings_pct:
|
||||
self._low_savings_streak = 0
|
||||
else:
|
||||
self._low_savings_streak += 1
|
||||
|
||||
return compacted, result
|
||||
|
||||
async def compact_async(
|
||||
self,
|
||||
messages: List[Dict[str, Any]]
|
||||
messages: List[Dict[str, Any]],
|
||||
focus_topic: str = ""
|
||||
) -> tuple[List[Dict[str, Any]], CompactionResult]:
|
||||
"""
|
||||
Compact messages to fit within token limit (asynchronous version).
|
||||
|
||||
Args:
|
||||
messages: List of messages to compact
|
||||
focus_topic: Optional topic to focus on during summarization
|
||||
|
||||
Returns:
|
||||
Tuple of (compacted messages, result)
|
||||
"""
|
||||
# Reset state for this compaction call
|
||||
self._used_previous_summary = False
|
||||
|
||||
original_tokens = self.count_total_tokens(messages)
|
||||
|
||||
if original_tokens <= self.max_tokens:
|
||||
return messages, CompactionResult(
|
||||
result = CompactionResult(
|
||||
original_tokens=original_tokens,
|
||||
compacted_tokens=original_tokens,
|
||||
messages_removed=0,
|
||||
messages_kept=len(messages),
|
||||
strategy_used=self.strategy
|
||||
)
|
||||
result.calculate_savings_pct()
|
||||
return messages, result
|
||||
|
||||
# Apply tool result deduplication pre-pass if enabled
|
||||
processed_messages = messages
|
||||
tool_results_pruned = 0
|
||||
if self.config.tool_prune_before_summarise and self.tool_pruner:
|
||||
processed_messages, tool_results_pruned = self.tool_pruner.prune(
|
||||
messages,
|
||||
self.config.max_tool_result_size
|
||||
)
|
||||
|
||||
if self.strategy == CompactionStrategy.TRUNCATE:
|
||||
compacted = self._truncate(messages)
|
||||
compacted = self._truncate(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.SLIDING:
|
||||
compacted = self._sliding_window(messages)
|
||||
compacted = self._sliding_window(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.SUMMARIZE:
|
||||
compacted = self._summarize(messages)
|
||||
compacted = self._summarize(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.SMART:
|
||||
compacted = self._smart_compact(messages)
|
||||
compacted = self._smart_compact(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.PRUNE:
|
||||
compacted = self._prune(messages)
|
||||
compacted = self._prune(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.LLM_SUMMARIZE:
|
||||
compacted = await self._llm_summarize_async(messages)
|
||||
compacted = await self._llm_summarize_async(processed_messages, focus_topic)
|
||||
else:
|
||||
compacted = self._truncate(messages)
|
||||
compacted = self._truncate(processed_messages)
|
||||
|
||||
compacted_tokens = self.count_total_tokens(compacted)
|
||||
|
||||
@@ -214,11 +289,35 @@ class ContextCompactor:
|
||||
compacted_tokens=compacted_tokens,
|
||||
messages_removed=len(messages) - len(compacted),
|
||||
messages_kept=len(compacted),
|
||||
strategy_used=self.strategy
|
||||
strategy_used=self.strategy,
|
||||
tool_results_pruned=tool_results_pruned,
|
||||
previous_summary_reused=getattr(self, '_used_previous_summary', False)
|
||||
)
|
||||
result.calculate_savings_pct()
|
||||
|
||||
# Update anti-thrashing tracking based on actual results
|
||||
self._last_savings_pct = result.savings_pct
|
||||
if result.savings_pct >= self.config.min_savings_pct:
|
||||
self._low_savings_streak = 0
|
||||
else:
|
||||
self._low_savings_streak += 1
|
||||
|
||||
return compacted, result
|
||||
|
||||
def _prune_tool_results(self, messages: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], int]:
|
||||
"""
|
||||
Delegate tool result pruning to injected protocol implementation.
|
||||
|
||||
If no tool pruner is provided, returns messages unchanged.
|
||||
This maintains backward compatibility while enforcing protocol-driven design.
|
||||
|
||||
Returns:
|
||||
Tuple of (processed messages, number of tool results pruned)
|
||||
"""
|
||||
if self.tool_pruner:
|
||||
return self.tool_pruner.prune(messages, self.config.max_tool_result_size)
|
||||
return messages, 0
|
||||
|
||||
def _truncate(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Truncate oldest messages."""
|
||||
result = []
|
||||
@@ -360,72 +459,19 @@ class ContextCompactor:
|
||||
result.extend(recent)
|
||||
return result
|
||||
|
||||
def _llm_summarize(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
def _llm_summarize(self, messages: List[Dict[str, Any]], focus_topic: str = "") -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Use LLM to summarize older messages with anti-injection framing.
|
||||
Use LLM to summarize older messages with iterative support.
|
||||
|
||||
Note: This implementation uses structured templates and anti-injection prefixes
|
||||
to prevent the model from treating summarized content as active instructions.
|
||||
"""
|
||||
result = []
|
||||
Supports iterative summarization - if we have a previous summary,
|
||||
we only summarize the new messages since that summary.
|
||||
Also includes anti-injection framing when configured.
|
||||
|
||||
# Keep system messages (excluding previous compacted summaries)
|
||||
system_msgs = [
|
||||
m for m in messages
|
||||
if m.get("role") == "system" and not m.get("_compacted")
|
||||
]
|
||||
other_msgs = [m for m in messages if m.get("role") != "system"]
|
||||
|
||||
result.extend(system_msgs)
|
||||
|
||||
# Keep recent messages
|
||||
recent = other_msgs[-self.preserve_recent:]
|
||||
older = other_msgs[:-self.preserve_recent] if len(other_msgs) > self.preserve_recent else []
|
||||
|
||||
if older:
|
||||
if self.config.structured_template:
|
||||
structured = self._build_structured_summary(older)
|
||||
else:
|
||||
# Fallback to simple summary
|
||||
summary_parts = []
|
||||
for msg in older:
|
||||
role = msg.get("role", "unknown")
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str) and content:
|
||||
summary_parts.append(f"[{role}]: {content[:150]}...")
|
||||
structured = "\n".join(summary_parts[:10])
|
||||
|
||||
if structured:
|
||||
# Apply anti-injection prefix
|
||||
prefixed = f"{self.config.compaction_prefix}\n\n{structured}"
|
||||
|
||||
# Handle iterative update if enabled
|
||||
if self.config.iterative_update and self._previous_summary:
|
||||
structured = self._merge_summaries(self._previous_summary, structured)
|
||||
prefixed = f"{self.config.compaction_prefix}\n\n{structured}"
|
||||
|
||||
# Store for next iteration
|
||||
if self.config.iterative_update:
|
||||
self._previous_summary = structured
|
||||
|
||||
result.append({
|
||||
"role": "system",
|
||||
"content": prefixed,
|
||||
"_compacted": True,
|
||||
"_original_count": len(older),
|
||||
"_anti_injection": True,
|
||||
})
|
||||
|
||||
result.extend(recent)
|
||||
return result
|
||||
|
||||
async def _llm_summarize_async(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Use LLM to intelligently summarize older messages.
|
||||
|
||||
This method invokes the agent's LLM to create a meaningful summary
|
||||
that preserves key facts, identifiers, and the user's intent.
|
||||
Args:
|
||||
messages: List of messages to compact
|
||||
focus_topic: Optional topic to focus on during summarization
|
||||
"""
|
||||
self._used_previous_summary = False
|
||||
result = []
|
||||
|
||||
# Keep system messages
|
||||
@@ -436,33 +482,169 @@ class ContextCompactor:
|
||||
|
||||
# Keep recent messages
|
||||
recent = other_msgs[-self.preserve_recent:]
|
||||
older = other_msgs[:-self.preserve_recent] if len(other_msgs) > self.preserve_recent else []
|
||||
|
||||
# Determine what to summarize
|
||||
total_original_messages = len(messages)
|
||||
if (self.config.enable_iterative_summary and
|
||||
self._previous_summary and
|
||||
total_original_messages > self._previous_summary_global_idx):
|
||||
# Iterative: summarize only new messages since previous summary
|
||||
# Calculate how many old messages to summarize based on global position
|
||||
messages_since_summary = total_original_messages - self._previous_summary_global_idx
|
||||
new_older_messages = max(0, messages_since_summary - self.preserve_recent)
|
||||
if new_older_messages > 0:
|
||||
to_summarize = other_msgs[-messages_since_summary:-self.preserve_recent]
|
||||
else:
|
||||
to_summarize = []
|
||||
self._used_previous_summary = True
|
||||
else:
|
||||
# Fresh summary: summarize all older messages
|
||||
to_summarize = other_msgs[:-self.preserve_recent] if len(other_msgs) > self.preserve_recent else []
|
||||
self._used_previous_summary = False
|
||||
|
||||
if to_summarize or self._previous_summary:
|
||||
# Build summary content
|
||||
summary_parts = []
|
||||
|
||||
# Include previous summary if doing iterative summarization
|
||||
if self._used_previous_summary and self._previous_summary:
|
||||
summary_parts.append(f"[Previous Summary]: {self._previous_summary}")
|
||||
summary_parts.append("[New Activity]:")
|
||||
|
||||
# Add new messages to summarize
|
||||
for msg in to_summarize:
|
||||
role = msg.get("role", "unknown")
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str) and content:
|
||||
# Extract key information with focus consideration
|
||||
if focus_topic and focus_topic.lower() in content.lower():
|
||||
# Prioritize content related to focus topic
|
||||
summary_parts.append(f"[{role}] *FOCUS*: {content[:200]}...")
|
||||
else:
|
||||
summary_parts.append(f"[{role}]: {content[:150]}...")
|
||||
|
||||
if summary_parts:
|
||||
focus_hint = f"\nFocus on: {focus_topic}" if focus_topic else ""
|
||||
|
||||
if self._used_previous_summary:
|
||||
summary = (
|
||||
f"[Iterative conversation summary - update with new activity{focus_hint}]\n"
|
||||
+ "\n".join(summary_parts[:15])
|
||||
)
|
||||
else:
|
||||
summary = (
|
||||
f"[Compacted conversation history - summarize key points{focus_hint}]\n"
|
||||
+ "\n".join(summary_parts[:10])
|
||||
)
|
||||
|
||||
# Apply anti-injection prefix if configured
|
||||
if hasattr(self.config, 'compaction_prefix') and self.config.compaction_prefix:
|
||||
summary = f"{self.config.compaction_prefix}\n\n{summary}"
|
||||
|
||||
summary_msg = {
|
||||
"role": "system",
|
||||
"content": summary,
|
||||
"_compacted": True,
|
||||
"_original_count": len(to_summarize),
|
||||
"_iterative": self._used_previous_summary,
|
||||
"_focus_topic": focus_topic
|
||||
}
|
||||
result.append(summary_msg)
|
||||
|
||||
# Update tracking for future iterative summarization
|
||||
self._previous_summary = summary
|
||||
self._previous_summary_global_idx = len(messages)
|
||||
|
||||
result.extend(recent)
|
||||
return result
|
||||
|
||||
async def _llm_summarize_async(self, messages: List[Dict[str, Any]], focus_topic: str = "") -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Use LLM to intelligently summarize older messages.
|
||||
|
||||
This method invokes the agent's LLM to create a meaningful summary
|
||||
that preserves key facts, identifiers, and the user's intent.
|
||||
|
||||
Args:
|
||||
messages: List of messages to compact
|
||||
focus_topic: Optional topic to focus on during summarization
|
||||
"""
|
||||
self._used_previous_summary = False
|
||||
result = []
|
||||
|
||||
# Keep system messages
|
||||
system_msgs = [m for m in messages if m.get("role") == "system"]
|
||||
other_msgs = [m for m in messages if m.get("role") != "system"]
|
||||
|
||||
result.extend(system_msgs)
|
||||
|
||||
# Keep recent messages
|
||||
recent = other_msgs[-self.preserve_recent:]
|
||||
|
||||
# Determine what to summarize based on iterative settings
|
||||
total_original_messages = len(messages)
|
||||
if (self.config.enable_iterative_summary and
|
||||
self._previous_summary and
|
||||
total_original_messages > self._previous_summary_global_idx):
|
||||
# Iterative: summarize only new messages since previous summary
|
||||
messages_since_summary = total_original_messages - self._previous_summary_global_idx
|
||||
new_older_messages = max(0, messages_since_summary - self.preserve_recent)
|
||||
if new_older_messages > 0:
|
||||
older = other_msgs[-messages_since_summary:-self.preserve_recent]
|
||||
else:
|
||||
older = []
|
||||
self._used_previous_summary = True
|
||||
else:
|
||||
# Fresh summary: summarize all older messages
|
||||
older = other_msgs[:-self.preserve_recent] if len(other_msgs) > self.preserve_recent else []
|
||||
self._used_previous_summary = False
|
||||
|
||||
if older and self.llm_summarize_fn:
|
||||
try:
|
||||
# Format messages for summarization
|
||||
history_text = self._format_messages_for_summary(older)
|
||||
# Format messages for summarization using protocol
|
||||
if self.message_formatter:
|
||||
history_text = self.message_formatter.format_for_summary(older)
|
||||
else:
|
||||
# Fallback to basic formatting if no protocol implementation
|
||||
history_text = str(older)
|
||||
|
||||
# Create summarization prompt that preserves important information
|
||||
focus_hint = f" Focus especially on: {focus_topic}." if focus_topic else ""
|
||||
iterative_hint = ""
|
||||
if self._used_previous_summary and self._previous_summary:
|
||||
iterative_hint = f"\n\n[Previous Summary]: {self._previous_summary}\n[New Activity to Add]:"
|
||||
|
||||
prompt = (
|
||||
"Summarise the following conversation history. Preserve verbatim: "
|
||||
"all file paths, IDs, hashes, URLs, task references, error messages, "
|
||||
"tool outputs, and the user's requests. Be concise but complete. "
|
||||
"Focus on facts and actions taken, not general conversation.\n\n"
|
||||
f"{history_text}"
|
||||
f"Focus on facts and actions taken, not general conversation.{focus_hint}"
|
||||
f"{iterative_hint}\n\n{history_text}"
|
||||
)
|
||||
|
||||
# Call the LLM for summarization
|
||||
summary = await self.llm_summarize_fn(prompt)
|
||||
|
||||
# Add the LLM-generated summary as a system message
|
||||
summary_content = summary
|
||||
if self._used_previous_summary:
|
||||
summary_content = f"[Iterative conversation summary]\n{summary}"
|
||||
else:
|
||||
summary_content = f"[Previous conversation summary]\n{summary}"
|
||||
|
||||
result.append({
|
||||
"role": "system",
|
||||
"content": f"[Previous conversation summary]\n{summary}",
|
||||
"content": summary_content,
|
||||
"_compacted": True,
|
||||
"_original_count": len(older),
|
||||
"_llm_generated": True,
|
||||
"_iterative": self._used_previous_summary,
|
||||
"_focus_topic": focus_topic
|
||||
})
|
||||
|
||||
# Update tracking for future iterative summarization
|
||||
self._previous_summary = summary
|
||||
self._previous_summary_global_idx = len(messages)
|
||||
except Exception as e:
|
||||
# Fallback to naive summarization if LLM call fails
|
||||
import logging
|
||||
@@ -506,133 +688,41 @@ class ContextCompactor:
|
||||
"content": summary,
|
||||
"_compacted": True,
|
||||
"_original_count": len(older),
|
||||
"_fallback": True,
|
||||
})
|
||||
|
||||
result.extend(recent)
|
||||
return result
|
||||
|
||||
def _format_messages_for_summary(self, messages: List[Dict[str, Any]]) -> str:
|
||||
"""Format messages for LLM summarization."""
|
||||
formatted = []
|
||||
for i, msg in enumerate(messages):
|
||||
role = msg.get("role", "unknown")
|
||||
content = msg.get("content", "")
|
||||
|
||||
# Handle tool calls
|
||||
if msg.get("tool_calls"):
|
||||
tool_calls = msg.get("tool_calls", [])
|
||||
tool_names = [tc.get("function", {}).get("name", "unknown") for tc in tool_calls]
|
||||
formatted.append(f"{i+1}. {role}: Called tools: {', '.join(tool_names)}")
|
||||
|
||||
# Handle content
|
||||
if isinstance(content, str) and content.strip():
|
||||
# Truncate very long content but preserve structure
|
||||
if len(content) > 1000:
|
||||
content = content[:800] + "...[truncated]..." + content[-200:]
|
||||
formatted.append(f"{i+1}. {role}: {content}")
|
||||
elif isinstance(content, list):
|
||||
# Handle multi-part content
|
||||
parts = []
|
||||
for part in content[:3]: # Limit to first 3 parts
|
||||
if isinstance(part, dict):
|
||||
text = part.get("text", "")
|
||||
if text:
|
||||
parts.append(text[:200] + "..." if len(text) > 200 else text)
|
||||
if parts:
|
||||
formatted.append(f"{i+1}. {role}: {' | '.join(parts)}")
|
||||
|
||||
# Handle tool results with IDs
|
||||
if msg.get("tool_call_id"):
|
||||
tool_id = msg.get("tool_call_id", "")
|
||||
formatted.append(f"{i+1}. {role} (tool {tool_id}): {str(content)[:500]}...")
|
||||
"""
|
||||
Delegate message formatting to protocol implementation.
|
||||
|
||||
return "\n".join(formatted)
|
||||
If no formatter is provided, returns basic string representation.
|
||||
"""
|
||||
if self.message_formatter:
|
||||
return self.message_formatter.format_for_summary(messages)
|
||||
return str(messages)
|
||||
|
||||
def _build_structured_summary(self, messages: List[Dict[str, Any]]) -> str:
|
||||
"""
|
||||
Build a structured summary using the configured template.
|
||||
Delegate structured summary building to protocol implementation.
|
||||
|
||||
Args:
|
||||
messages: Messages to summarize
|
||||
|
||||
Returns:
|
||||
Structured summary string
|
||||
If no summary builder is provided, returns basic summary.
|
||||
"""
|
||||
# Extract information from messages
|
||||
active_task = "No specific task identified"
|
||||
completed = []
|
||||
in_progress = []
|
||||
pending = []
|
||||
files = set()
|
||||
remaining = []
|
||||
|
||||
for msg in messages:
|
||||
role = msg.get("role", "")
|
||||
content = msg.get("content", "")
|
||||
|
||||
if not isinstance(content, str):
|
||||
continue
|
||||
|
||||
content_lower = content.lower()
|
||||
|
||||
# Extract file paths
|
||||
file_matches = re.findall(r'[\w/\.\-]+\.[a-zA-Z]{1,4}', content)
|
||||
files.update(file_matches[:5]) # Limit to 5 files
|
||||
|
||||
# Categorize content based on keywords and role
|
||||
content_snippet = content[:200] + ("..." if len(content) > 200 else "")
|
||||
|
||||
if role == "user":
|
||||
if any(word in content_lower for word in ["please", "can you", "help"]) or re.search(r'\bdo\b', content_lower):
|
||||
active_task = content_snippet
|
||||
elif "?" in content:
|
||||
pending.append(content_snippet)
|
||||
|
||||
elif role == "assistant":
|
||||
if any(word in content_lower for word in ["completed", "done", "finished"]):
|
||||
completed.append(content_snippet)
|
||||
elif any(word in content_lower for word in ["working", "processing", "analyzing"]):
|
||||
in_progress.append(content_snippet)
|
||||
elif any(word in content_lower for word in ["will", "plan to", "next"]):
|
||||
remaining.append(content_snippet)
|
||||
|
||||
elif role == "tool":
|
||||
# Tool results are generally completed actions
|
||||
tool_name = msg.get("name", "unknown")
|
||||
completed.append(f"Tool {tool_name}: {content_snippet}")
|
||||
|
||||
# Format using template
|
||||
return SUMMARY_TEMPLATE.format(
|
||||
active_task=active_task,
|
||||
completed="\n".join(f"- {item}" for item in completed[:3]) or "None identified",
|
||||
in_progress="\n".join(f"- {item}" for item in in_progress[:3]) or "None identified",
|
||||
pending="\n".join(f"- {item}" for item in pending[:3]) or "None identified",
|
||||
files=", ".join(list(files)[:5]) or "None mentioned",
|
||||
remaining="\n".join(f"- {item}" for item in remaining[:3]) or "None identified"
|
||||
)
|
||||
if self.summary_builder:
|
||||
return self.summary_builder.build_structured_summary(messages)
|
||||
return f"Summary of {len(messages)} messages"
|
||||
|
||||
def _merge_summaries(self, previous: str, current: str) -> str:
|
||||
"""
|
||||
Merge previous summary with current one for iterative updates.
|
||||
Delegate summary merging to protocol implementation.
|
||||
|
||||
Args:
|
||||
previous: Previous summary content
|
||||
current: Current summary content
|
||||
|
||||
Returns:
|
||||
Merged summary
|
||||
If no summary builder is provided, returns current summary only.
|
||||
"""
|
||||
# Simple merge strategy: prioritize current but preserve unique info from previous
|
||||
if not previous:
|
||||
return current
|
||||
|
||||
# Preserve context but avoid excessive growth
|
||||
MAX_PREVIOUS_LENGTH = 500
|
||||
if len(previous) > MAX_PREVIOUS_LENGTH:
|
||||
truncated_previous = previous[:MAX_PREVIOUS_LENGTH]
|
||||
return f"{current}\n\n[Previous context]: {truncated_previous}..."
|
||||
else:
|
||||
return f"{current}\n\n[Previous context]: {previous}"
|
||||
if self.summary_builder:
|
||||
return self.summary_builder.merge_summaries(previous, current)
|
||||
return current
|
||||
|
||||
def get_stats(self, messages: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""Get statistics about messages."""
|
||||
|
||||
@@ -50,3 +50,28 @@ class CompactionConfig:
|
||||
compaction_prefix: str = COMPACTION_PREFIX # Override for custom framing
|
||||
structured_template: bool = True # Emit Active Task / Remaining Work sections
|
||||
iterative_update: bool = True # Merge previous summary on re-compaction
|
||||
|
||||
# Anti-thrashing protection
|
||||
min_savings_pct: float = 10.0 # Skip if projected saving < 10% (0-100 scale)
|
||||
max_consecutive_low_savings: int = 2 # Abort after N low-savings attempts
|
||||
|
||||
# Tool result optimization
|
||||
tool_prune_before_summarise: bool = True # Deduplicate tool results before summarization
|
||||
max_tool_result_size: int = 500 # Maximum size for tool results before pruning
|
||||
|
||||
# Iterative summarization
|
||||
enable_iterative_summary: bool = True # Build on previous summaries instead of starting from scratch
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate and normalize configuration values."""
|
||||
# Normalize min_savings_pct to 0-100 scale if provided as ratio
|
||||
if self.min_savings_pct < 1.0:
|
||||
self.min_savings_pct *= 100.0
|
||||
|
||||
# Validate ranges
|
||||
if not 0.0 <= self.min_savings_pct <= 100.0:
|
||||
raise ValueError("min_savings_pct must be between 0 and 100")
|
||||
if self.max_consecutive_low_savings < 0:
|
||||
raise ValueError("max_consecutive_low_savings must be >= 0")
|
||||
if self.max_tool_result_size <= 0:
|
||||
raise ValueError("max_tool_result_size must be > 0")
|
||||
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
Compaction Protocols for PraisonAI Agents.
|
||||
|
||||
Protocol-driven interfaces for context compaction functionality.
|
||||
"""
|
||||
|
||||
from typing import Protocol, List, Dict, Any, Tuple, Optional
|
||||
|
||||
|
||||
class ToolResultPrunerProtocol(Protocol):
|
||||
"""Protocol for pruning and deduplicating tool results."""
|
||||
|
||||
def prune(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
max_tool_result_size: int = 500
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
"""
|
||||
Prune and deduplicate tool results to reduce token usage.
|
||||
|
||||
Args:
|
||||
messages: List of messages to process
|
||||
max_tool_result_size: Maximum size for tool results before truncation
|
||||
|
||||
Returns:
|
||||
Tuple of (processed messages, number of tool results pruned)
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class MessageFormatterProtocol(Protocol):
|
||||
"""Protocol for formatting messages for summarization."""
|
||||
|
||||
def format_for_summary(self, messages: List[Dict[str, Any]]) -> str:
|
||||
"""
|
||||
Format messages for LLM summarization.
|
||||
|
||||
Args:
|
||||
messages: Messages to format
|
||||
|
||||
Returns:
|
||||
Formatted string suitable for summarization
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class SummaryBuilderProtocol(Protocol):
|
||||
"""Protocol for building structured summaries."""
|
||||
|
||||
def build_structured_summary(self, messages: List[Dict[str, Any]]) -> str:
|
||||
"""
|
||||
Build a structured summary from messages.
|
||||
|
||||
Args:
|
||||
messages: Messages to summarize
|
||||
|
||||
Returns:
|
||||
Structured summary string
|
||||
"""
|
||||
...
|
||||
|
||||
def merge_summaries(self, previous: str, current: str) -> str:
|
||||
"""
|
||||
Merge previous summary with current one for iterative updates.
|
||||
|
||||
Args:
|
||||
previous: Previous summary content
|
||||
current: Current summary content
|
||||
|
||||
Returns:
|
||||
Merged summary
|
||||
"""
|
||||
...
|
||||
@@ -18,6 +18,16 @@ class CompactionResult:
|
||||
strategy_used: CompactionStrategy
|
||||
summary: str = ""
|
||||
|
||||
# Anti-thrashing and optimization info
|
||||
savings_pct: float = field(default=0.0, init=False) # Percentage of tokens saved (computed)
|
||||
tool_results_pruned: int = 0 # Number of tool results deduplicated/pruned
|
||||
previous_summary_reused: bool = False # Whether iterative summary was used
|
||||
was_skipped_due_to_low_savings: bool = False # Whether compaction was skipped due to anti-thrashing
|
||||
|
||||
def __post_init__(self):
|
||||
"""Initialize computed fields after construction."""
|
||||
self.calculate_savings_pct()
|
||||
|
||||
@property
|
||||
def tokens_saved(self) -> int:
|
||||
"""Get number of tokens saved."""
|
||||
@@ -30,6 +40,13 @@ class CompactionResult:
|
||||
return 0.0
|
||||
return self.compacted_tokens / self.original_tokens
|
||||
|
||||
def calculate_savings_pct(self) -> None:
|
||||
"""Calculate and set the savings percentage."""
|
||||
if self.original_tokens > 0:
|
||||
self.savings_pct = (self.tokens_saved / self.original_tokens) * 100.0
|
||||
else:
|
||||
self.savings_pct = 0.0
|
||||
|
||||
@property
|
||||
def was_compacted(self) -> bool:
|
||||
"""Check if compaction occurred."""
|
||||
@@ -45,5 +62,9 @@ class CompactionResult:
|
||||
"messages_kept": self.messages_kept,
|
||||
"strategy_used": self.strategy_used.value,
|
||||
"compression_ratio": self.compression_ratio,
|
||||
"savings_pct": self.savings_pct,
|
||||
"tool_results_pruned": self.tool_results_pruned,
|
||||
"previous_summary_reused": self.previous_summary_reused,
|
||||
"was_skipped_due_to_low_savings": self.was_skipped_due_to_low_savings,
|
||||
"summary": self.summary
|
||||
}
|
||||
@@ -774,29 +774,39 @@ class ExecutionConfig:
|
||||
self.context_compaction
|
||||
)
|
||||
|
||||
# Emit deprecation warning for default behavior change
|
||||
# Only warn if context_compaction was not explicitly set (i.e., using the default False)
|
||||
import warnings
|
||||
import inspect
|
||||
frame = inspect.currentframe()
|
||||
try:
|
||||
# Check if this is being called from user code (not internal)
|
||||
caller_frame = frame.f_back
|
||||
if (caller_frame and
|
||||
not any(path_part in caller_frame.f_code.co_filename
|
||||
for path_part in ['praisonaiagents', 'test_', '__pycache__'])):
|
||||
# This is being called from user code
|
||||
if self.context_compaction is False:
|
||||
warnings.warn(
|
||||
"ExecutionConfig.context_compaction will default to True in the next "
|
||||
"release for proactive context overflow protection. To disable, explicitly "
|
||||
"set context_compaction=False. To use the new default early, set "
|
||||
"context_compaction=True.",
|
||||
DeprecationWarning,
|
||||
stacklevel=3
|
||||
)
|
||||
finally:
|
||||
del frame
|
||||
# Emit deprecation warning once per process for default behavior change.
|
||||
# Walk the stack once — dataclass __init__ uses caller filename "<string>".
|
||||
if self.context_compaction is False:
|
||||
if getattr(ExecutionConfig, '_context_compaction_internal', False):
|
||||
return
|
||||
if getattr(ExecutionConfig, '_context_compaction_warned', False):
|
||||
return
|
||||
import warnings
|
||||
import inspect
|
||||
_INTERNAL = ('praisonaiagents', 'test_', '__pycache__')
|
||||
frame = inspect.currentframe()
|
||||
try:
|
||||
caller = frame.f_back if frame else None
|
||||
is_internal = False
|
||||
while caller is not None:
|
||||
if any(p in caller.f_code.co_filename for p in _INTERNAL):
|
||||
is_internal = True
|
||||
break
|
||||
caller = caller.f_back
|
||||
if is_internal:
|
||||
ExecutionConfig._context_compaction_internal = True
|
||||
return
|
||||
warnings.warn(
|
||||
"ExecutionConfig.context_compaction will default to True in the next "
|
||||
"release for proactive context overflow protection. To disable, explicitly "
|
||||
"set context_compaction=False. To use the new default early, set "
|
||||
"context_compaction=True.",
|
||||
DeprecationWarning,
|
||||
stacklevel=3,
|
||||
)
|
||||
ExecutionConfig._context_compaction_warned = True
|
||||
finally:
|
||||
del frame
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
@@ -1089,8 +1099,8 @@ class MultiAgentOutputConfig:
|
||||
# Verbosity level (0=silent, 1=minimal, 2+=verbose)
|
||||
verbose: int = 0
|
||||
|
||||
# Streaming output
|
||||
stream: bool = True
|
||||
# Streaming output - False by default for sync multi-agent compatibility
|
||||
stream: bool = False
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
@@ -1441,39 +1451,6 @@ def resolve_web(value: WebParam) -> Optional[WebConfig]:
|
||||
return value
|
||||
|
||||
|
||||
def resolve_output(value: OutputParam) -> Optional[OutputConfig]:
|
||||
"""Resolve output= parameter following precedence ladder."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
preset = OutputPreset(value.lower())
|
||||
return OutputConfig.from_preset(preset)
|
||||
except ValueError:
|
||||
return None
|
||||
if isinstance(value, dict):
|
||||
return OutputConfig(**value)
|
||||
if isinstance(value, OutputConfig):
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
def resolve_execution(value: ExecutionParam) -> Optional[ExecutionConfig]:
|
||||
"""Resolve execution= parameter following precedence ladder."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
preset = ExecutionPreset(value.lower())
|
||||
return ExecutionConfig.from_preset(preset)
|
||||
except ValueError:
|
||||
return None
|
||||
if isinstance(value, dict):
|
||||
return ExecutionConfig(**value)
|
||||
if isinstance(value, ExecutionConfig):
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
def resolve_caching(value: CachingParam) -> Optional[CachingConfig]:
|
||||
"""Resolve caching= parameter following precedence ladder."""
|
||||
@@ -1585,8 +1562,6 @@ __all__ = [
|
||||
"resolve_reflection",
|
||||
"resolve_guardrails",
|
||||
"resolve_web",
|
||||
"resolve_output",
|
||||
"resolve_execution",
|
||||
"resolve_caching",
|
||||
"resolve_autonomy",
|
||||
"resolve_tool_search",
|
||||
|
||||
@@ -83,6 +83,7 @@ class ConfigValidationError(Exception):
|
||||
# Global config cache (lazy loaded)
|
||||
_config_cache: Optional[Dict[str, Any]] = None
|
||||
_config_loaded: bool = False
|
||||
_defaults_has_values: Optional[bool] = None
|
||||
|
||||
|
||||
def _default_project_plugins_dir():
|
||||
@@ -418,11 +419,27 @@ def get_enabled_plugins() -> Optional[List[str]]:
|
||||
return None # All plugins enabled
|
||||
|
||||
|
||||
def _defaults_has_any_values() -> bool:
|
||||
"""Return True if config file defines any defaults (cached after first check)."""
|
||||
global _defaults_has_values
|
||||
if _defaults_has_values is None:
|
||||
defaults = get_defaults_config()
|
||||
_defaults_has_values = any(
|
||||
getattr(defaults, key, None) is not None
|
||||
for key in (
|
||||
"model", "base_url", "memory", "knowledge", "planning",
|
||||
"reflection", "web", "output", "execution", "caching", "autonomy",
|
||||
)
|
||||
)
|
||||
return _defaults_has_values
|
||||
|
||||
|
||||
def clear_config_cache() -> None:
|
||||
"""Clear the config cache (for testing)."""
|
||||
global _config_cache, _config_loaded
|
||||
global _config_cache, _config_loaded, _defaults_has_values
|
||||
_config_cache = None
|
||||
_config_loaded = False
|
||||
_defaults_has_values = None
|
||||
|
||||
|
||||
def validate_config(config: Dict[str, Any], raise_on_error: bool = False) -> List[str]:
|
||||
@@ -603,6 +620,8 @@ def apply_config_defaults(
|
||||
if explicit_value is not None:
|
||||
# If True and we have config defaults, merge them
|
||||
if explicit_value is True:
|
||||
if not _defaults_has_any_values():
|
||||
return explicit_value
|
||||
config_defaults = get_default(param_name)
|
||||
if config_defaults and isinstance(config_defaults, dict):
|
||||
if config_class:
|
||||
@@ -612,6 +631,10 @@ def apply_config_defaults(
|
||||
return config_defaults
|
||||
return explicit_value
|
||||
|
||||
# Fast path: no config defaults file values — skip lookups
|
||||
if not _defaults_has_any_values():
|
||||
return None
|
||||
|
||||
# Check if config has defaults for this param
|
||||
config_defaults = get_default(param_name)
|
||||
if config_defaults is None:
|
||||
|
||||
@@ -322,8 +322,8 @@ CACHING_PRESETS: Dict[str, Dict[str, Any]] = {
|
||||
# =============================================================================
|
||||
|
||||
MULTI_AGENT_OUTPUT_PRESETS: Dict[str, Dict[str, Any]] = {
|
||||
"verbose": {"verbose": 2, "stream": True},
|
||||
"minimal": {"verbose": 1, "stream": True},
|
||||
"verbose": {"verbose": 2, "stream": False},
|
||||
"minimal": {"verbose": 1, "stream": False},
|
||||
"silent": {"verbose": 0, "stream": False},
|
||||
}
|
||||
|
||||
|
||||
@@ -94,65 +94,6 @@ class SessionDeduplicationCache:
|
||||
self._cache.clear()
|
||||
self._stats = {"duplicates_prevented": 0, "tokens_saved": 0}
|
||||
|
||||
def deduplicate_topics(topics: list, key: str = "title", similarity_threshold: float = 0.8) -> list:
|
||||
"""
|
||||
Programmatic deduplication of topics/items before agent processing.
|
||||
|
||||
This helps prevent duplicate content from being passed to downstream agents,
|
||||
reducing token waste and improving quality.
|
||||
|
||||
Args:
|
||||
topics: List of topic dicts or strings
|
||||
key: Key to use for comparison if topics are dicts (default: "title")
|
||||
similarity_threshold: Similarity threshold for fuzzy matching (0.0-1.0)
|
||||
|
||||
Returns:
|
||||
Deduplicated list of topics
|
||||
"""
|
||||
if not topics:
|
||||
return topics
|
||||
|
||||
seen_hashes = set()
|
||||
seen_normalized = set()
|
||||
unique_topics = []
|
||||
|
||||
for topic in topics:
|
||||
# Get the content to compare
|
||||
if isinstance(topic, dict):
|
||||
content = str(topic.get(key, topic.get("content", str(topic))))
|
||||
else:
|
||||
content = str(topic)
|
||||
|
||||
# Normalize for comparison
|
||||
normalized = content.lower().strip()
|
||||
# Remove common words for better matching
|
||||
normalized = " ".join(w for w in normalized.split() if len(w) > 3)
|
||||
|
||||
# Check exact hash match
|
||||
content_hash = hashlib.sha256(normalized.encode()).hexdigest()
|
||||
if content_hash in seen_hashes:
|
||||
continue
|
||||
|
||||
# Check fuzzy match using simple word overlap
|
||||
is_duplicate = False
|
||||
for seen in seen_normalized:
|
||||
# Calculate Jaccard similarity
|
||||
words1 = set(normalized.split())
|
||||
words2 = set(seen.split())
|
||||
if words1 and words2:
|
||||
intersection = len(words1 & words2)
|
||||
union = len(words1 | words2)
|
||||
similarity = intersection / union if union > 0 else 0
|
||||
if similarity >= similarity_threshold:
|
||||
is_duplicate = True
|
||||
break
|
||||
|
||||
if not is_duplicate:
|
||||
seen_hashes.add(content_hash)
|
||||
seen_normalized.add(normalized)
|
||||
unique_topics.append(topic)
|
||||
|
||||
return unique_topics
|
||||
|
||||
class EstimationMode(str, Enum):
|
||||
"""Token estimation modes."""
|
||||
|
||||
@@ -30,6 +30,7 @@ from typing import (
|
||||
if TYPE_CHECKING:
|
||||
from praisonai.gateway.pairing import PairedChannel
|
||||
from ..agent import Agent
|
||||
from ..bots.presentation import MessagePresentation
|
||||
|
||||
|
||||
class EventType(str, Enum):
|
||||
@@ -53,6 +54,7 @@ class EventType(str, Enum):
|
||||
# Message events
|
||||
MESSAGE = "message"
|
||||
MESSAGE_ACK = "message_ack"
|
||||
MESSAGE_ABORT = "message_abort"
|
||||
TYPING = "typing"
|
||||
|
||||
# Streaming events (relayed from agent's StreamEventEmitter)
|
||||
@@ -148,6 +150,7 @@ class GatewayMessage:
|
||||
timestamp: Message creation time
|
||||
metadata: Additional message metadata
|
||||
reply_to: ID of message being replied to (optional)
|
||||
presentation: Optional interactive presentation (buttons, menus, etc.)
|
||||
"""
|
||||
|
||||
content: Union[str, Dict[str, Any]]
|
||||
@@ -157,10 +160,11 @@ class GatewayMessage:
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
reply_to: Optional[str] = None
|
||||
presentation: Optional["MessagePresentation"] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
data = {
|
||||
"content": self.content,
|
||||
"sender_id": self.sender_id,
|
||||
"session_id": self.session_id,
|
||||
@@ -169,10 +173,20 @@ class GatewayMessage:
|
||||
"metadata": self.metadata,
|
||||
"reply_to": self.reply_to,
|
||||
}
|
||||
if self.presentation is not None:
|
||||
data["presentation"] = self.presentation.to_dict()
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "GatewayMessage":
|
||||
"""Create from dictionary."""
|
||||
from ..bots.presentation import MessagePresentation
|
||||
|
||||
presentation = None
|
||||
raw_presentation = data.get("presentation")
|
||||
if isinstance(raw_presentation, dict):
|
||||
presentation = MessagePresentation.from_dict(raw_presentation)
|
||||
|
||||
return cls(
|
||||
content=data.get("content", ""),
|
||||
sender_id=data.get("sender_id", "unknown"),
|
||||
@@ -181,6 +195,7 @@ class GatewayMessage:
|
||||
timestamp=data.get("timestamp", time.time()),
|
||||
metadata=data.get("metadata", {}),
|
||||
reply_to=data.get("reply_to"),
|
||||
presentation=presentation,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -83,7 +83,8 @@ class HookRunner:
|
||||
self,
|
||||
event: HookEvent,
|
||||
input_data: HookInput,
|
||||
target: Optional[str] = None
|
||||
target: Optional[str] = None,
|
||||
_hooks: Optional[List["HookDefinition"]] = None
|
||||
) -> List[HookExecutionResult]:
|
||||
"""
|
||||
Execute all hooks for an event.
|
||||
@@ -92,11 +93,12 @@ class HookRunner:
|
||||
event: The event to execute hooks for
|
||||
input_data: Input data for the hooks
|
||||
target: Optional target to filter hooks (e.g., tool name)
|
||||
_hooks: Internal parameter to avoid duplicate get_hooks() calls
|
||||
|
||||
Returns:
|
||||
List of execution results
|
||||
"""
|
||||
hooks = self._registry.get_hooks(event, target)
|
||||
hooks = _hooks if _hooks is not None else self._registry.get_hooks(event, target)
|
||||
|
||||
if not hooks:
|
||||
return []
|
||||
@@ -153,8 +155,14 @@ class HookRunner:
|
||||
"Use 'await runner.execute(event, input_data, target)' instead in async contexts."
|
||||
)
|
||||
|
||||
# No running loop — safe to create one
|
||||
return asyncio.run(self.execute(event, input_data, target))
|
||||
# Fast path: no hooks → no loop creation needed
|
||||
# This mirrors the same check in execute() but avoids event loop creation overhead
|
||||
hooks = self._registry.get_hooks(event, target)
|
||||
if not hooks:
|
||||
return []
|
||||
|
||||
# No running loop and hooks exist — safe to create one
|
||||
return asyncio.run(self.execute(event, input_data, target, _hooks=hooks))
|
||||
|
||||
async def _execute_parallel(
|
||||
self,
|
||||
|
||||
Whitespace-only changes.
@@ -24,6 +24,7 @@ import xml.etree.ElementTree as ET
|
||||
from ..errors import AgentErrorKind, FailoverDecision, IdleTimeoutBreaker
|
||||
# Gap 2: Tool call execution imports
|
||||
from ..tools.call_executor import ToolCall, create_tool_call_executor
|
||||
from ..tools.schema import annotation_to_json_schema, get_parameter_requirements
|
||||
# Display functions - lazy loaded to avoid importing rich at startup
|
||||
# These are only needed when output=verbose
|
||||
_display_module = None
|
||||
@@ -5749,8 +5750,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
if content:
|
||||
response_text += content
|
||||
live.update(_get_display_functions()['display_generating'](response_text, start_time))
|
||||
if content:
|
||||
response_text += content
|
||||
else:
|
||||
# Use retry wrapper for non-streaming calls
|
||||
response = self._completion_with_retry(**completion_params)
|
||||
@@ -5846,8 +5845,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
if content:
|
||||
response_text += content
|
||||
live.update(_get_display_functions()['display_generating'](response_text, start_time))
|
||||
if content:
|
||||
response_text += content
|
||||
else:
|
||||
# Use retry wrapper for non-streaming async calls
|
||||
response = await self._acompletion_with_retry(**completion_params)
|
||||
@@ -5886,34 +5883,74 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
function_name = function_or_name
|
||||
logging.debug(f"Attempting to generate tool definition for: {function_name}")
|
||||
|
||||
# First try to get the tool definition if it exists
|
||||
tool_def_name = f"{function_name}_definition"
|
||||
tool_def = globals().get(tool_def_name)
|
||||
logging.debug(f"Looking for {tool_def_name} in globals: {tool_def is not None}")
|
||||
|
||||
if not tool_def:
|
||||
import __main__
|
||||
tool_def = getattr(__main__, tool_def_name, None)
|
||||
logging.debug(f"Looking for {tool_def_name} in __main__: {tool_def is not None}")
|
||||
|
||||
if tool_def:
|
||||
logging.debug(f"Found tool definition: {tool_def}")
|
||||
return tool_def
|
||||
|
||||
# Try to find the function
|
||||
func = globals().get(function_name)
|
||||
logging.debug(f"Looking for {function_name} in globals: {func is not None}")
|
||||
|
||||
if not func:
|
||||
import __main__
|
||||
func = getattr(__main__, function_name, None)
|
||||
logging.debug(f"Looking for {function_name} in __main__: {func is not None}")
|
||||
|
||||
if not func or not callable(func):
|
||||
logging.debug(f"Function {function_name} not found or not callable")
|
||||
return None
|
||||
# First try to get from the tool registry (preferred method)
|
||||
try:
|
||||
from ..tools.registry import get_registry
|
||||
registry = get_registry()
|
||||
tool = registry.get(function_name)
|
||||
if tool:
|
||||
logging.debug(f"Found tool in registry: {function_name}")
|
||||
# If it's a BaseTool, get its schema
|
||||
if hasattr(tool, 'get_schema'):
|
||||
tool_def = tool.get_schema()
|
||||
# Apply same normalization as the callable path
|
||||
if (
|
||||
isinstance(tool_def, dict)
|
||||
and isinstance(tool_def.get("function"), dict)
|
||||
and isinstance(tool_def["function"].get("parameters"), dict)
|
||||
):
|
||||
tool_def = tool_def.copy()
|
||||
tool_def["function"] = tool_def["function"].copy()
|
||||
tool_def["function"]["parameters"] = self._fix_array_schemas(
|
||||
tool_def["function"]["parameters"]
|
||||
)
|
||||
return tool_def
|
||||
# If it's a callable, use it as func
|
||||
if callable(tool):
|
||||
func = tool
|
||||
else:
|
||||
logging.debug(f"Tool {function_name} in registry is not callable")
|
||||
return None
|
||||
else:
|
||||
logging.debug(f"Tool {function_name} not found in registry, falling back to globals/__main__")
|
||||
# Fall back to globals and __main__ for backward compatibility
|
||||
tool_def_name = f"{function_name}_definition"
|
||||
tool_def = globals().get(tool_def_name)
|
||||
if not tool_def:
|
||||
import __main__
|
||||
tool_def = getattr(__main__, tool_def_name, None)
|
||||
if tool_def:
|
||||
return tool_def
|
||||
|
||||
func = globals().get(function_name)
|
||||
if not func:
|
||||
import __main__
|
||||
func = getattr(__main__, function_name, None)
|
||||
if not func or not callable(func):
|
||||
logging.debug(f"Function {function_name} not found or not callable")
|
||||
return None
|
||||
except ImportError:
|
||||
logging.debug("Tool registry not available, falling back to globals/__main__")
|
||||
# Fall back to globals and __main__ when registry unavailable
|
||||
tool_def_name = f"{function_name}_definition"
|
||||
tool_def = globals().get(tool_def_name)
|
||||
if not tool_def:
|
||||
import __main__
|
||||
tool_def = getattr(__main__, tool_def_name, None)
|
||||
if tool_def:
|
||||
return tool_def
|
||||
|
||||
func = globals().get(function_name)
|
||||
if not func:
|
||||
import __main__
|
||||
func = getattr(__main__, function_name, None)
|
||||
if not func or not callable(func):
|
||||
logging.debug(f"Function {function_name} not found or not callable")
|
||||
return None
|
||||
|
||||
import inspect
|
||||
from typing import get_type_hints
|
||||
|
||||
# Handle Langchain and CrewAI tools
|
||||
if inspect.isclass(func) and hasattr(func, 'run') and not hasattr(func, '_run'):
|
||||
original_func = func
|
||||
@@ -5961,26 +5998,26 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
logging.debug(f"Parameter descriptions: {param_descriptions}")
|
||||
|
||||
# Get type hints for proper schema generation
|
||||
try:
|
||||
hints = get_type_hints(func) if getattr(func, "__annotations__", None) else {}
|
||||
except (NameError, TypeError, AttributeError):
|
||||
hints = getattr(func, "__annotations__", {}) or {}
|
||||
|
||||
for name, param in parameters_list:
|
||||
param_type = "string" # Default type
|
||||
if param.annotation != inspect.Parameter.empty:
|
||||
if param.annotation == int:
|
||||
param_type = "integer"
|
||||
elif param.annotation == float:
|
||||
param_type = "number"
|
||||
elif param.annotation == bool:
|
||||
param_type = "boolean"
|
||||
elif param.annotation == list:
|
||||
param_type = "array"
|
||||
elif param.annotation == dict:
|
||||
param_type = "object"
|
||||
# Get type annotation
|
||||
param_type = hints.get(name, param.annotation if param.annotation != inspect.Parameter.empty else str)
|
||||
|
||||
parameters["properties"][name] = {
|
||||
"type": param_type,
|
||||
"description": param_descriptions.get(name, "Parameter description not available")
|
||||
}
|
||||
# Use new schema utility for proper type handling
|
||||
prop_schema = annotation_to_json_schema(param_type)
|
||||
|
||||
if param.default == inspect.Parameter.empty:
|
||||
# Add description from docstring
|
||||
prop_schema["description"] = param_descriptions.get(name, "Parameter description not available")
|
||||
|
||||
parameters["properties"][name] = prop_schema
|
||||
|
||||
# Check if required using improved logic
|
||||
if get_parameter_requirements(sig, name):
|
||||
parameters["required"].append(name)
|
||||
|
||||
logging.debug(f"Generated parameters: {parameters}")
|
||||
|
||||
@@ -141,22 +141,6 @@ class LiteLLMAdapter:
|
||||
|
||||
return self._convert_response_to_standard_format(response)
|
||||
|
||||
def _convert_messages_to_prompt(self, messages: List[Dict[str, Any]]) -> str:
|
||||
"""Convert OpenAI-style messages to prompt string."""
|
||||
if not messages:
|
||||
return ""
|
||||
|
||||
# If single message, return its content
|
||||
if len(messages) == 1:
|
||||
return messages[0].get('content', '')
|
||||
|
||||
# For multiple messages, use the last user message as prompt
|
||||
for msg in reversed(messages):
|
||||
if msg.get('role') == 'user':
|
||||
return msg.get('content', '')
|
||||
|
||||
return messages[-1].get('content', '') if messages else ''
|
||||
|
||||
async def _convert_stream_response(self, response_stream) -> AsyncIterator[Dict[str, Any]]:
|
||||
"""Convert LLM stream response to standard format."""
|
||||
# Implementation depends on the actual stream format from LLM.get_response_async
|
||||
|
||||
@@ -246,3 +246,74 @@ class SecurityConfig:
|
||||
def get_bind_address(self) -> str:
|
||||
"""Get recommended bind address based on config."""
|
||||
return "127.0.0.1" if self.bind_localhost_only else "0.0.0.0"
|
||||
|
||||
|
||||
def load_sse_security_from_env():
|
||||
"""Load optional SSE security settings from environment."""
|
||||
import os
|
||||
|
||||
token = os.environ.get("MCP_SSE_AUTH_TOKEN") or os.environ.get("MCP_AUTH_TOKEN")
|
||||
origins_raw = os.environ.get("MCP_SSE_ALLOWED_ORIGINS")
|
||||
if not token and not origins_raw:
|
||||
return None, None
|
||||
|
||||
origins = (
|
||||
[origin.strip() for origin in origins_raw.split(",") if origin.strip()]
|
||||
if origins_raw
|
||||
else None
|
||||
)
|
||||
config = SecurityConfig(
|
||||
require_auth=bool(token),
|
||||
validate_origin=origins is not None,
|
||||
allowed_origins=origins or SecurityConfig().allowed_origins,
|
||||
)
|
||||
return config, token
|
||||
|
||||
|
||||
def build_sse_security_app(app, security: Optional["SecurityConfig"] = None):
|
||||
"""Wrap a Starlette app with SSE security middleware when configured."""
|
||||
config = security
|
||||
token = None
|
||||
if config is None:
|
||||
config, token = load_sse_security_from_env()
|
||||
elif config.require_auth:
|
||||
import os
|
||||
|
||||
token = os.environ.get("MCP_SSE_AUTH_TOKEN") or os.environ.get("MCP_AUTH_TOKEN")
|
||||
if config is None or (not config.require_auth and not config.validate_origin):
|
||||
return app
|
||||
|
||||
class _SSESecurityMiddleware:
|
||||
def __init__(self, inner, sec: SecurityConfig, expected_token: Optional[str]):
|
||||
self._inner = inner
|
||||
self._sec = sec
|
||||
self._token = expected_token
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope.get("type") != "http":
|
||||
await self._inner(scope, receive, send)
|
||||
return
|
||||
|
||||
headers = {k.decode().lower(): v.decode() for k, v in scope.get("headers", [])}
|
||||
|
||||
if self._sec.require_auth and self._token:
|
||||
auth = headers.get("authorization", "")
|
||||
if not auth.startswith("Bearer ") or auth[7:] != self._token:
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
response = JSONResponse({"error": "Unauthorized"}, status_code=401)
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
|
||||
if self._sec.validate_origin:
|
||||
origin = headers.get("origin") or None
|
||||
if not self._sec.is_origin_allowed(origin):
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
response = JSONResponse({"error": "Origin not allowed"}, status_code=403)
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
|
||||
await self._inner(scope, receive, send)
|
||||
|
||||
return _SSESecurityMiddleware(app, config, token)
|
||||
@@ -242,12 +242,18 @@ class ToolsMCPServer:
|
||||
|
||||
mcp.run()
|
||||
|
||||
def run_sse(self, host: str = "0.0.0.0", port: int = 8080) -> None:
|
||||
def run_sse(
|
||||
self,
|
||||
host: str = "0.0.0.0",
|
||||
port: int = 8080,
|
||||
security: "Optional[SecurityConfig]" = None,
|
||||
) -> None:
|
||||
"""Run the MCP server using SSE transport.
|
||||
|
||||
Args:
|
||||
host: Host to bind to
|
||||
port: Port to listen on
|
||||
security: Optional security config; when unset, reads MCP_SSE_* env vars
|
||||
"""
|
||||
try:
|
||||
import uvicorn
|
||||
@@ -260,6 +266,8 @@ class ToolsMCPServer:
|
||||
f"SSE dependencies not available: {e}. "
|
||||
"Install with: pip install uvicorn starlette"
|
||||
)
|
||||
|
||||
from .mcp_security import SecurityConfig, build_sse_security_app
|
||||
|
||||
mcp = self.get_fastmcp()
|
||||
|
||||
@@ -286,7 +294,8 @@ class ToolsMCPServer:
|
||||
Mount(messages_path, app=sse_transport.handle_post_message),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
app = build_sse_security_app(app, security)
|
||||
print(f"🚀 Starting MCP server '{self.name}' with SSE transport")
|
||||
print(f"📡 SSE endpoint: http://{host}:{port}{sse_path}")
|
||||
print(f"🛠️ Available tools: {', '.join(self.get_tool_names())}")
|
||||
|
||||
@@ -115,6 +115,16 @@ class FileMemory:
|
||||
"importance_threshold": 0.7, # Min importance for long-term
|
||||
"auto_promote": True, # Auto-promote important short-term to long-term
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _sanitise_user_id(user_id: str) -> str:
|
||||
"""Reject path traversal in user_id before using it as a directory name."""
|
||||
if not user_id or not isinstance(user_id, str):
|
||||
return "default"
|
||||
if ".." in user_id or "/" in user_id or "\\" in user_id:
|
||||
raise ValueError("user_id must not contain path separators or parent references")
|
||||
safe = user_id.strip()
|
||||
return safe or "default"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -132,7 +142,7 @@ class FileMemory:
|
||||
config: Configuration overrides
|
||||
verbose: Verbosity level (0=quiet, 1=info, 2+=debug)
|
||||
"""
|
||||
self.user_id = user_id
|
||||
self.user_id = self._sanitise_user_id(user_id)
|
||||
self.verbose = verbose
|
||||
|
||||
# Set up paths
|
||||
@@ -142,7 +152,7 @@ class FileMemory:
|
||||
from ..paths import get_project_data_dir
|
||||
self.base_path = get_project_data_dir() / "memory"
|
||||
|
||||
self.user_path = self.base_path / user_id
|
||||
self.user_path = self.base_path / self.user_id
|
||||
self.episodic_path = self.user_path / "episodic"
|
||||
|
||||
# Create directories
|
||||
|
||||
@@ -29,6 +29,11 @@ logger = get_logger(__name__, extra_data={"subsystem": "memory"})
|
||||
TRACE_LEVEL = 5
|
||||
logging.addLevelName(TRACE_LEVEL, 'TRACE')
|
||||
|
||||
# Cache boundary constants for prompt prefix caching optimization
|
||||
# NOTE: This is a placeholder - current implementation does not provide
|
||||
# functional prompt caching as it requires structured API metadata
|
||||
CACHE_BOUNDARY = ""
|
||||
|
||||
|
||||
|
||||
class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
@@ -1704,6 +1709,41 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
entities = self.search_entity(q, limit=max_items)
|
||||
user_mem = self.search_user_memory(user_id, q, limit=max_items) if user_id else []
|
||||
|
||||
# Apply stable sorting to ensure deterministic order for prompt caching
|
||||
# Sort by timestamp descending, then by content hash for stable ordering
|
||||
def _sort_memory_results(results: List[Any]) -> List[Any]:
|
||||
"""Stable sort so identical query sets produce identical context strings."""
|
||||
import hashlib
|
||||
if not results:
|
||||
return results
|
||||
|
||||
def sort_key(r):
|
||||
if not isinstance(r, dict):
|
||||
return (0.0, hashlib.sha256(str(r).encode()).hexdigest())
|
||||
|
||||
# Prefer created_at over timestamp for better determinism
|
||||
timestamp = r.get("created_at") or r.get("timestamp") or 0
|
||||
if isinstance(timestamp, str):
|
||||
try:
|
||||
from datetime import datetime
|
||||
timestamp = datetime.fromisoformat(timestamp.replace("Z", "+00:00")).timestamp()
|
||||
except ValueError:
|
||||
timestamp = 0.0
|
||||
timestamp = -(float(timestamp)) # Negative for descending order
|
||||
|
||||
# Use full content hash for true deterministic ordering
|
||||
content = r.get("text", "") if isinstance(r, dict) else str(r)
|
||||
content_hash = hashlib.sha256(content.encode()).hexdigest()
|
||||
return (timestamp, content_hash)
|
||||
|
||||
return sorted(results, key=sort_key)
|
||||
|
||||
# Apply stable sorting to all memory results
|
||||
short_term = _sort_memory_results(short_term)
|
||||
long_term = _sort_memory_results(long_term)
|
||||
entities = _sort_memory_results(entities)
|
||||
user_mem = _sort_memory_results(user_mem)
|
||||
|
||||
# Add sections in order of priority
|
||||
add_section("Short-term Memory Context", short_term)
|
||||
add_section("Long-term Memory Context", long_term)
|
||||
@@ -1713,6 +1753,51 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
|
||||
return "\n".join(lines) if lines else ""
|
||||
|
||||
def build_cache_optimized_context(
|
||||
self,
|
||||
task_descr: str,
|
||||
user_id: Optional[str] = None,
|
||||
additional: str = "",
|
||||
max_items: int = 3,
|
||||
include_cache_boundary: bool = True,
|
||||
include_in_output: Optional[bool] = None
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Build context with cache boundary markers for prompt prefix caching optimization.
|
||||
|
||||
Returns a dictionary with 'stable_prefix' and 'cache_boundary' keys.
|
||||
The stable prefix contains deterministically ordered content that should
|
||||
remain constant between turns for effective prompt caching.
|
||||
|
||||
Args:
|
||||
task_descr: Task description for memory search
|
||||
user_id: Optional user ID for personalized memory
|
||||
additional: Additional context to include in search
|
||||
max_items: Maximum items per memory category
|
||||
include_cache_boundary: Whether to include cache boundary marker
|
||||
include_in_output: Whether to include memory content in output
|
||||
|
||||
Returns:
|
||||
Dict with 'stable_prefix' and 'cache_boundary' keys
|
||||
"""
|
||||
# Preserve original behavior: include_in_output=None means debug-level-only
|
||||
# Don't change the default behavior for backward compatibility
|
||||
|
||||
# Build the stable context using existing method
|
||||
stable_context = self.build_context_for_task(
|
||||
task_descr=task_descr,
|
||||
user_id=user_id,
|
||||
additional=additional,
|
||||
max_items=max_items,
|
||||
include_in_output=include_in_output
|
||||
)
|
||||
|
||||
result = {"stable_prefix": stable_context}
|
||||
|
||||
result["cache_boundary"] = CACHE_BOUNDARY if include_cache_boundary else ""
|
||||
|
||||
return result
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Master Reset (Everything)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@@ -170,10 +170,10 @@ class SearchMixin:
|
||||
|
||||
return self._format_vector_results(results)
|
||||
|
||||
def _search_mongodb_stm(self, query: str, limit: int, metadata_filter: Optional[Dict],
|
||||
min_quality: Optional[float], user_id: Optional[str]) -> List[Dict[str, Any]]:
|
||||
"""Search short-term memory in MongoDB."""
|
||||
if not hasattr(self, 'stm_collection'):
|
||||
def _search_mongodb(self, collection, query: str, limit: int, metadata_filter: Optional[Dict],
|
||||
min_quality: Optional[float], user_id: Optional[str]) -> List[Dict[str, Any]]:
|
||||
"""Search MongoDB collection with given parameters."""
|
||||
if not collection:
|
||||
return []
|
||||
|
||||
# Build search filter
|
||||
@@ -191,120 +191,77 @@ class SearchMixin:
|
||||
search_filter["metadata.user_id"] = user_id
|
||||
|
||||
# Perform search with text score
|
||||
cursor = self.stm_collection.find(
|
||||
cursor = collection.find(
|
||||
search_filter,
|
||||
{"score": {"$meta": "textScore"}}
|
||||
).sort([("score", {"$meta": "textScore"})]).limit(limit)
|
||||
|
||||
return [self._format_mongodb_result(doc) for doc in cursor]
|
||||
|
||||
def _search_mongodb_stm(self, query: str, limit: int, metadata_filter: Optional[Dict],
|
||||
min_quality: Optional[float], user_id: Optional[str]) -> List[Dict[str, Any]]:
|
||||
"""Search short-term memory in MongoDB."""
|
||||
collection = getattr(self, 'stm_collection', None)
|
||||
return self._search_mongodb(collection, query, limit, metadata_filter, min_quality, user_id)
|
||||
|
||||
def _search_mongodb_ltm(self, query: str, limit: int, metadata_filter: Optional[Dict],
|
||||
min_quality: Optional[float], user_id: Optional[str]) -> List[Dict[str, Any]]:
|
||||
"""Search long-term memory in MongoDB."""
|
||||
if not hasattr(self, 'ltm_collection'):
|
||||
collection = getattr(self, 'ltm_collection', None)
|
||||
return self._search_mongodb(collection, query, limit, metadata_filter, min_quality, user_id)
|
||||
|
||||
def _search_sqlite(self, conn, table: str, query: str, limit: int, metadata_filter: Optional[Dict],
|
||||
min_quality: Optional[float], user_id: Optional[str]) -> List[Dict[str, Any]]:
|
||||
"""Search SQLite table with given parameters."""
|
||||
if not conn:
|
||||
return []
|
||||
|
||||
# Build search filter
|
||||
search_filter = {"$text": {"$search": query}}
|
||||
|
||||
# Add additional filters
|
||||
if metadata_filter:
|
||||
for key, value in metadata_filter.items():
|
||||
search_filter[f"metadata.{key}"] = value
|
||||
# Build WHERE clause
|
||||
where_clauses = ["content LIKE ?"]
|
||||
params = [f"%{query}%"]
|
||||
|
||||
if min_quality is not None:
|
||||
search_filter["quality_score"] = {"$gte": min_quality}
|
||||
where_clauses.append("quality_score >= ?")
|
||||
params.append(min_quality)
|
||||
|
||||
# Simple metadata filtering (JSON contains check)
|
||||
if metadata_filter:
|
||||
for key, value in metadata_filter.items():
|
||||
where_clauses.append("metadata LIKE ?")
|
||||
params.append(f'%"{key}": "{value}"%')
|
||||
|
||||
if user_id:
|
||||
search_filter["metadata.user_id"] = user_id
|
||||
where_clauses.append("metadata LIKE ?")
|
||||
params.append(f'%"user_id": "{user_id}"%')
|
||||
|
||||
# Perform search with text score
|
||||
cursor = self.ltm_collection.find(
|
||||
search_filter,
|
||||
{"score": {"$meta": "textScore"}}
|
||||
).sort([("score", {"$meta": "textScore"})]).limit(limit)
|
||||
where_clause = " AND ".join(where_clauses)
|
||||
|
||||
return [self._format_mongodb_result(doc) for doc in cursor]
|
||||
query_sql = f"""
|
||||
SELECT id, content, metadata, timestamp, quality_score
|
||||
FROM {table}
|
||||
WHERE {where_clause}
|
||||
ORDER BY quality_score DESC, timestamp DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
|
||||
params.append(limit)
|
||||
|
||||
cursor = conn.execute(query_sql, params)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
return [self._format_sqlite_result(dict(row)) for row in rows]
|
||||
|
||||
def _search_sqlite_stm(self, query: str, limit: int, metadata_filter: Optional[Dict],
|
||||
min_quality: Optional[float], user_id: Optional[str]) -> List[Dict[str, Any]]:
|
||||
"""Search short-term memory in SQLite."""
|
||||
conn = self._get_stm_conn()
|
||||
|
||||
# Build WHERE clause
|
||||
where_clauses = ["content LIKE ?"]
|
||||
params = [f"%{query}%"]
|
||||
|
||||
if min_quality is not None:
|
||||
where_clauses.append("quality_score >= ?")
|
||||
params.append(min_quality)
|
||||
|
||||
# Simple metadata filtering (JSON contains check)
|
||||
if metadata_filter:
|
||||
for key, value in metadata_filter.items():
|
||||
where_clauses.append("metadata LIKE ?")
|
||||
params.append(f'%"{key}": "{value}"%')
|
||||
|
||||
if user_id:
|
||||
where_clauses.append("metadata LIKE ?")
|
||||
params.append(f'%"user_id": "{user_id}"%')
|
||||
|
||||
where_clause = " AND ".join(where_clauses)
|
||||
|
||||
query_sql = f"""
|
||||
SELECT id, content, metadata, timestamp, quality_score
|
||||
FROM short_term
|
||||
WHERE {where_clause}
|
||||
ORDER BY quality_score DESC, timestamp DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
|
||||
params.append(limit)
|
||||
|
||||
cursor = conn.execute(query_sql, params)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
return [self._format_sqlite_result(dict(row)) for row in rows]
|
||||
conn = self._get_stm_conn() if hasattr(self, '_get_stm_conn') else None
|
||||
return self._search_sqlite(conn, "short_term", query, limit, metadata_filter, min_quality, user_id)
|
||||
|
||||
def _search_sqlite_ltm(self, query: str, limit: int, metadata_filter: Optional[Dict],
|
||||
min_quality: Optional[float], user_id: Optional[str]) -> List[Dict[str, Any]]:
|
||||
"""Search long-term memory in SQLite."""
|
||||
conn = self._get_ltm_conn()
|
||||
|
||||
# Build WHERE clause
|
||||
where_clauses = ["content LIKE ?"]
|
||||
params = [f"%{query}%"]
|
||||
|
||||
if min_quality is not None:
|
||||
where_clauses.append("quality_score >= ?")
|
||||
params.append(min_quality)
|
||||
|
||||
# Simple metadata filtering (JSON contains check)
|
||||
if metadata_filter:
|
||||
for key, value in metadata_filter.items():
|
||||
where_clauses.append("metadata LIKE ?")
|
||||
params.append(f'%"{key}": "{value}"%')
|
||||
|
||||
if user_id:
|
||||
where_clauses.append("metadata LIKE ?")
|
||||
params.append(f'%"user_id": "{user_id}"%')
|
||||
|
||||
where_clause = " AND ".join(where_clauses)
|
||||
|
||||
query_sql = f"""
|
||||
SELECT id, content, metadata, timestamp, quality_score
|
||||
FROM long_term
|
||||
WHERE {where_clause}
|
||||
ORDER BY quality_score DESC, timestamp DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
|
||||
params.append(limit)
|
||||
|
||||
cursor = conn.execute(query_sql, params)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
return [self._format_sqlite_result(dict(row)) for row in rows]
|
||||
conn = self._get_ltm_conn() if hasattr(self, '_get_ltm_conn') else None
|
||||
return self._search_sqlite(conn, "long_term", query, limit, metadata_filter, min_quality, user_id)
|
||||
|
||||
def _format_vector_results(self, results: Dict) -> List[Dict[str, Any]]:
|
||||
"""Format ChromaDB vector search results."""
|
||||
|
||||
@@ -27,7 +27,7 @@ from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from ..task.task import Task
|
||||
from ..workflows.results import StepResult, StepError, WorkflowResult, StepStatus, ErrorStrategy
|
||||
from ..workflows.results import StepError, WorkflowResult, StepStatus, ErrorStrategy
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ __all__ = [
|
||||
"PermissionAction",
|
||||
"PermissionMode",
|
||||
"PermissionResult",
|
||||
"PersistentApproval",
|
||||
"DoomLoopDetector",
|
||||
]
|
||||
|
||||
@@ -60,6 +61,10 @@ def __getattr__(name: str):
|
||||
from .rules import PermissionResult
|
||||
return PermissionResult
|
||||
|
||||
if name == "PersistentApproval":
|
||||
from .rules import PersistentApproval
|
||||
return PersistentApproval
|
||||
|
||||
if name == "DoomLoopDetector":
|
||||
from .doom_loop import DoomLoopDetector
|
||||
return DoomLoopDetector
|
||||
|
||||
@@ -111,6 +111,11 @@ class PermissionManager:
|
||||
except IOError as e:
|
||||
logger.error(f"Failed to save rules: {e}")
|
||||
|
||||
def save_rules(self):
|
||||
"""Public method to save rules to disk."""
|
||||
with self._lock:
|
||||
self._save_rules()
|
||||
|
||||
def _load_approvals(self):
|
||||
"""Load approvals from disk."""
|
||||
path = self._get_approvals_path()
|
||||
|
||||
Whitespace-only changes.
@@ -196,8 +196,13 @@ class Process:
|
||||
except (json.JSONDecodeError, ValueError, TypeError) as e:
|
||||
raise Exception(f"Failed to parse response: {response}") from e
|
||||
|
||||
def _create_loop_subtasks(self, loop_task: Task):
|
||||
"""Create subtasks for a loop task from input file."""
|
||||
def _create_loop_subtasks(self, loop_task: Task, decision_mode: bool = False):
|
||||
"""Create subtasks for a loop task from input file.
|
||||
|
||||
Args:
|
||||
loop_task: The loop task to create subtasks for
|
||||
decision_mode: If True, create decision-type subtasks with conditions (for start tasks)
|
||||
"""
|
||||
if not loop_task.input_file:
|
||||
logging.warning(f"_create_loop_subtasks called for {loop_task.name} but no input_file specified")
|
||||
return
|
||||
@@ -229,20 +234,45 @@ class Process:
|
||||
task_count += 1
|
||||
logging.debug(f"Creating subtask from CSV row {i+1}: {task_desc}")
|
||||
|
||||
row_task = Task(
|
||||
description=f"{loop_task.description}\n{task_desc}" if loop_task.description else task_desc,
|
||||
agent=loop_task.agent,
|
||||
name=f"{loop_task.name}_{task_count}" if loop_task.name else task_desc,
|
||||
expected_output=getattr(loop_task, 'expected_output', None),
|
||||
on_task_complete=loop_task.callback, # Inherit callback from parent loop task
|
||||
is_start=(task_count == 1),
|
||||
task_type="task"
|
||||
)
|
||||
# Configure task based on decision_mode
|
||||
if decision_mode:
|
||||
# For start tasks: create decision-type tasks with conditions and inherited next_tasks
|
||||
inherited_next_tasks = loop_task.next_tasks if loop_task.next_tasks else []
|
||||
row_name = f"{loop_task.name}_{task_count}" if loop_task.name else task_desc
|
||||
row_task = Task(
|
||||
description=f"{loop_task.description}\n{task_desc}" if loop_task.description else task_desc,
|
||||
agent=loop_task.agent,
|
||||
name=row_name,
|
||||
expected_output=getattr(loop_task, 'expected_output', None),
|
||||
on_task_complete=loop_task.callback, # Inherit callback from parent loop task
|
||||
is_start=(task_count == 1),
|
||||
task_type="decision", # Change to decision type for start tasks
|
||||
next_tasks=inherited_next_tasks, # Inherit parent's next tasks
|
||||
condition={
|
||||
"done": inherited_next_tasks if inherited_next_tasks else [],
|
||||
"retry": [row_name],
|
||||
"exit": [] # Empty list for exit condition
|
||||
}
|
||||
)
|
||||
else:
|
||||
# For regular loop tasks: create basic task-type subtasks
|
||||
row_task = Task(
|
||||
description=f"{loop_task.description}\n{task_desc}" if loop_task.description else task_desc,
|
||||
agent=loop_task.agent,
|
||||
name=f"{loop_task.name}_{task_count}" if loop_task.name else task_desc,
|
||||
expected_output=getattr(loop_task, 'expected_output', None),
|
||||
on_task_complete=loop_task.callback, # Inherit callback from parent loop task
|
||||
is_start=(task_count == 1),
|
||||
task_type="task"
|
||||
)
|
||||
|
||||
self.tasks[row_task.id] = row_task
|
||||
new_tasks.append(row_task)
|
||||
|
||||
if previous_task:
|
||||
previous_task.next_tasks = [row_task.name]
|
||||
if decision_mode:
|
||||
previous_task.condition["done"] = [row_task.name] # Use "done" consistently for decision tasks
|
||||
previous_task = row_task
|
||||
|
||||
logging.info(f"Created {task_count} subtasks from CSV file for {loop_task.name}")
|
||||
@@ -254,21 +284,42 @@ class Process:
|
||||
for i, line in enumerate(lines):
|
||||
if not line.strip(): # Skip empty lines
|
||||
continue
|
||||
|
||||
row_task = Task(
|
||||
description=f"{loop_task.description}\n{line.strip()}" if loop_task.description else line.strip(),
|
||||
agent=loop_task.agent,
|
||||
name=f"{loop_task.name}_{i+1}" if loop_task.name else line.strip(),
|
||||
expected_output=getattr(loop_task, 'expected_output', None),
|
||||
on_task_complete=loop_task.callback, # Inherit callback from parent loop task
|
||||
is_start=(i == 0),
|
||||
task_type="task"
|
||||
)
|
||||
|
||||
# Configure task based on decision_mode
|
||||
if decision_mode:
|
||||
# For start tasks: create tasks with conditions
|
||||
row_task = Task(
|
||||
description=f"{loop_task.description}\n{line.strip()}" if loop_task.description else line.strip(),
|
||||
agent=loop_task.agent,
|
||||
name=f"{loop_task.name}_{i+1}" if loop_task.name else line.strip(),
|
||||
expected_output=getattr(loop_task, 'expected_output', None),
|
||||
on_task_complete=loop_task.callback, # Inherit callback from parent loop task
|
||||
is_start=(i == 0),
|
||||
task_type="task",
|
||||
condition={
|
||||
"complete": ["next"],
|
||||
"retry": ["current"]
|
||||
}
|
||||
)
|
||||
else:
|
||||
# For regular loop tasks: create basic tasks
|
||||
row_task = Task(
|
||||
description=f"{loop_task.description}\n{line.strip()}" if loop_task.description else line.strip(),
|
||||
agent=loop_task.agent,
|
||||
name=f"{loop_task.name}_{i+1}" if loop_task.name else line.strip(),
|
||||
expected_output=getattr(loop_task, 'expected_output', None),
|
||||
on_task_complete=loop_task.callback, # Inherit callback from parent loop task
|
||||
is_start=(i == 0),
|
||||
task_type="task"
|
||||
)
|
||||
|
||||
self.tasks[row_task.id] = row_task
|
||||
new_tasks.append(row_task)
|
||||
|
||||
if previous_task:
|
||||
previous_task.next_tasks = [row_task.name]
|
||||
if decision_mode:
|
||||
previous_task.condition["complete"] = [row_task.name]
|
||||
previous_task = row_task
|
||||
|
||||
logging.info(f"Created {len(new_tasks)} subtasks from text file for {loop_task.name}")
|
||||
@@ -276,7 +327,12 @@ class Process:
|
||||
if new_tasks and loop_task.next_tasks:
|
||||
# Connect last subtask to loop task's next tasks
|
||||
last_task = new_tasks[-1]
|
||||
last_task.next_tasks = loop_task.next_tasks
|
||||
if decision_mode:
|
||||
# For decision mode, ensure the last task points to parent's next tasks
|
||||
if not last_task.next_tasks:
|
||||
last_task.next_tasks = loop_task.next_tasks
|
||||
else:
|
||||
last_task.next_tasks = loop_task.next_tasks
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to create subtasks for loop task {loop_task.name}: {e}")
|
||||
@@ -562,7 +618,6 @@ class Process:
|
||||
|
||||
current_task = start_task
|
||||
visited_tasks = set()
|
||||
loop_data = {} # Store loop-specific data
|
||||
|
||||
# TODO: start task with loop feature is not available in aworkflow method
|
||||
|
||||
@@ -732,9 +787,6 @@ Subtask: {st.name}
|
||||
logging.debug(f"Task next_tasks: {current_task.next_tasks}")
|
||||
yield task_id
|
||||
visited_tasks.add(task_id)
|
||||
# Reset description to original after execution to prevent context accumulation
|
||||
if hasattr(current_task, '_original_description'):
|
||||
current_task.description = current_task._original_description
|
||||
|
||||
# Only end workflow if no next_tasks AND no conditions
|
||||
if not current_task.next_tasks and not current_task.condition and not any(
|
||||
@@ -772,22 +824,6 @@ Subtask: {st.name}
|
||||
logging.debug(f"=== Skipping reset for loop/decision/subtask/parallel or rerun=False: {subtask_name} ===")
|
||||
logging.debug(f"Keeping status as: {self.tasks[task_id].status}")
|
||||
|
||||
# Handle loop progression
|
||||
if current_task.task_type == "loop":
|
||||
loop_key = f"loop_{current_task.name}"
|
||||
if loop_key in loop_data:
|
||||
loop_info = loop_data[loop_key]
|
||||
loop_info["index"] += 1
|
||||
has_more = loop_info["remaining"] > 0
|
||||
|
||||
# Update result to trigger correct condition
|
||||
if current_task.result:
|
||||
result = current_task.result.raw
|
||||
if has_more:
|
||||
result += "\nmore"
|
||||
else:
|
||||
result += "\ndone"
|
||||
current_task.result.raw = result
|
||||
|
||||
# Determine next task based on result
|
||||
next_task = None
|
||||
@@ -1112,104 +1148,29 @@ Provide a JSON with the structure:
|
||||
if start_task and start_task.task_type == "loop" and not start_task.input_file:
|
||||
start_task.input_file = "tasks.csv"
|
||||
|
||||
# --- If loop + input_file, read file & create tasks
|
||||
# --- If loop + input_file, read file & create tasks using consolidated helper
|
||||
if start_task and start_task.task_type == "loop" and getattr(start_task, "input_file", None):
|
||||
try:
|
||||
file_ext = os.path.splitext(start_task.input_file)[1].lower()
|
||||
new_tasks = []
|
||||
|
||||
if file_ext == ".csv":
|
||||
with open(start_task.input_file, "r", encoding="utf-8") as f:
|
||||
reader = csv.reader(f, quotechar='"', escapechar='\\') # Handle quoted/escaped fields
|
||||
previous_task = None
|
||||
task_count = 0
|
||||
|
||||
for i, row in enumerate(reader):
|
||||
if not row: # Skip truly empty rows
|
||||
continue
|
||||
|
||||
# Properly handle Q&A pairs with potential commas
|
||||
task_desc = row[0].strip() if row else ""
|
||||
if len(row) > 1:
|
||||
# Preserve all fields in case of multiple commas
|
||||
question = row[0].strip()
|
||||
answer = ",".join(field.strip() for field in row[1:])
|
||||
task_desc = f"Question: {question}\nAnswer: {answer}"
|
||||
|
||||
if not task_desc: # Skip rows with empty content
|
||||
continue
|
||||
|
||||
task_count += 1
|
||||
logging.debug(f"Processing CSV row {i+1}: {task_desc}")
|
||||
|
||||
# Inherit next_tasks from parent loop task
|
||||
inherited_next_tasks = start_task.next_tasks if start_task.next_tasks else []
|
||||
|
||||
row_task = Task(
|
||||
description=f"{start_task.description}\n{task_desc}" if start_task.description else task_desc,
|
||||
agent=start_task.agent,
|
||||
name=f"{start_task.name}_{task_count}" if start_task.name else task_desc,
|
||||
expected_output=getattr(start_task, 'expected_output', None),
|
||||
on_task_complete=start_task.callback, # Inherit callback from parent loop task
|
||||
is_start=(task_count == 1),
|
||||
task_type="decision", # Change to decision type
|
||||
next_tasks=inherited_next_tasks, # Inherit parent's next tasks
|
||||
condition={
|
||||
"done": inherited_next_tasks if inherited_next_tasks else ["next"], # Use full inherited_next_tasks
|
||||
"retry": ["current"],
|
||||
"exit": [] # Empty list for exit condition
|
||||
}
|
||||
)
|
||||
self.tasks[row_task.id] = row_task
|
||||
new_tasks.append(row_task)
|
||||
|
||||
if previous_task:
|
||||
previous_task.next_tasks = [row_task.name]
|
||||
previous_task.condition["done"] = [row_task.name] # Use "done" consistently
|
||||
previous_task = row_task
|
||||
|
||||
# For the last task in the loop, ensure it points to parent's next tasks
|
||||
if task_count > 0 and not row_task.next_tasks:
|
||||
row_task.next_tasks = inherited_next_tasks
|
||||
|
||||
logging.info(f"Processed {task_count} rows from CSV file")
|
||||
else:
|
||||
# If not CSV, read lines
|
||||
with open(start_task.input_file, "r", encoding="utf-8") as f:
|
||||
lines = f.read().splitlines()
|
||||
previous_task = None
|
||||
for i, line in enumerate(lines):
|
||||
row_task = Task(
|
||||
description=f"{start_task.description}\n{line.strip()}" if start_task.description else line.strip(),
|
||||
agent=start_task.agent,
|
||||
name=f"{start_task.name}_{i+1}" if start_task.name else line.strip(),
|
||||
expected_output=getattr(start_task, 'expected_output', None),
|
||||
on_task_complete=start_task.callback, # Inherit callback from parent loop task
|
||||
is_start=(i == 0),
|
||||
task_type="task",
|
||||
condition={
|
||||
"complete": ["next"],
|
||||
"retry": ["current"]
|
||||
}
|
||||
)
|
||||
self.tasks[row_task.id] = row_task
|
||||
new_tasks.append(row_task)
|
||||
|
||||
if previous_task:
|
||||
previous_task.next_tasks = [row_task.name]
|
||||
previous_task.condition["complete"] = [row_task.name]
|
||||
previous_task = row_task
|
||||
|
||||
if new_tasks:
|
||||
start_task = new_tasks[0]
|
||||
logging.info(f"Created {len(new_tasks)} tasks from: {start_task.input_file}")
|
||||
parent_loop_task = start_task
|
||||
parent_input_file = parent_loop_task.input_file
|
||||
self._create_loop_subtasks(parent_loop_task, decision_mode=True)
|
||||
# Get the first created subtask as the new start task
|
||||
subtasks = [
|
||||
t for t in self.tasks.values()
|
||||
if t.name.startswith(parent_loop_task.name + "_")
|
||||
]
|
||||
if subtasks:
|
||||
# Mark parent loop task as completed and find start subtask
|
||||
parent_loop_task.status = "completed"
|
||||
parent_loop_task._subtasks_created = True
|
||||
start_task = next((t for t in subtasks if t.is_start), subtasks[0])
|
||||
logging.info(f"Created {len(subtasks)} tasks from: {parent_input_file}")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to read file tasks: {e}")
|
||||
|
||||
# end of start task handling
|
||||
current_task = start_task
|
||||
visited_tasks = set()
|
||||
loop_data = {} # Store loop-specific data
|
||||
|
||||
while current_task:
|
||||
current_iter += 1
|
||||
@@ -1256,7 +1217,7 @@ Tasks by type:
|
||||
break # Exit immediately to prevent task reset
|
||||
|
||||
|
||||
# Handle loop task file reading at runtime
|
||||
# Handle loop task file reading at runtime using consolidated helper
|
||||
if (current_task.task_type == "loop" and
|
||||
current_task is not start_task and
|
||||
getattr(current_task, "_subtasks_created", False) is not True):
|
||||
@@ -1266,66 +1227,18 @@ Tasks by type:
|
||||
|
||||
if getattr(current_task, "input_file", None):
|
||||
try:
|
||||
file_ext = os.path.splitext(current_task.input_file)[1].lower()
|
||||
new_tasks = []
|
||||
|
||||
if file_ext == ".csv":
|
||||
with open(current_task.input_file, "r", encoding="utf-8") as f:
|
||||
reader = csv.reader(f)
|
||||
previous_task = None
|
||||
for i, row in enumerate(reader):
|
||||
if row: # Skip empty rows
|
||||
task_desc = row[0] # Take first column
|
||||
row_task = Task(
|
||||
description=f"{current_task.description}\n{task_desc}" if current_task.description else task_desc,
|
||||
agent=current_task.agent,
|
||||
name=f"{current_task.name}_{i+1}" if current_task.name else task_desc,
|
||||
expected_output=getattr(current_task, 'expected_output', None),
|
||||
on_task_complete=current_task.callback, # Inherit callback from parent loop task
|
||||
is_start=(i == 0),
|
||||
task_type="task",
|
||||
condition={
|
||||
"complete": ["next"],
|
||||
"retry": ["current"]
|
||||
}
|
||||
)
|
||||
self.tasks[row_task.id] = row_task
|
||||
new_tasks.append(row_task)
|
||||
|
||||
if previous_task:
|
||||
previous_task.next_tasks = [row_task.name]
|
||||
previous_task.condition["complete"] = [row_task.name]
|
||||
previous_task = row_task
|
||||
else:
|
||||
with open(current_task.input_file, "r", encoding="utf-8") as f:
|
||||
lines = f.read().splitlines()
|
||||
previous_task = None
|
||||
for i, line in enumerate(lines):
|
||||
row_task = Task(
|
||||
description=f"{current_task.description}\n{line.strip()}" if current_task.description else line.strip(),
|
||||
agent=current_task.agent,
|
||||
name=f"{current_task.name}_{i+1}" if current_task.name else line.strip(),
|
||||
expected_output=getattr(current_task, 'expected_output', None),
|
||||
on_task_complete=current_task.callback, # Inherit callback from parent loop task
|
||||
is_start=(i == 0),
|
||||
task_type="task",
|
||||
condition={
|
||||
"complete": ["next"],
|
||||
"retry": ["current"]
|
||||
}
|
||||
)
|
||||
self.tasks[row_task.id] = row_task
|
||||
new_tasks.append(row_task)
|
||||
|
||||
if previous_task:
|
||||
previous_task.next_tasks = [row_task.name]
|
||||
previous_task.condition["complete"] = [row_task.name]
|
||||
previous_task = row_task
|
||||
|
||||
if new_tasks:
|
||||
current_task.next_tasks = [new_tasks[0].name]
|
||||
self._create_loop_subtasks(current_task, decision_mode=False)
|
||||
# Update current task to point to first subtask
|
||||
subtasks = [
|
||||
t for t in self.tasks.values()
|
||||
if t.name.startswith(current_task.name + "_")
|
||||
]
|
||||
if subtasks:
|
||||
# Find first subtask by is_start flag or first in list
|
||||
first_subtask = next((t for t in subtasks if t.is_start), subtasks[0])
|
||||
current_task.next_tasks = [first_subtask.name]
|
||||
current_task._subtasks_created = True
|
||||
logging.info(f"Created {len(new_tasks)} tasks from: {current_task.input_file} for loop task {current_task.name}")
|
||||
logging.info(f"Created {len(subtasks)} tasks from: {current_task.input_file} for loop task {current_task.name}")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to read file tasks for loop task {current_task.name}: {e}")
|
||||
|
||||
@@ -1451,9 +1364,6 @@ Subtask: {st.name}
|
||||
logging.debug(f"Task next_tasks: {current_task.next_tasks}")
|
||||
yield task_id
|
||||
visited_tasks.add(task_id)
|
||||
# Reset description to original after execution to prevent context accumulation
|
||||
if hasattr(current_task, '_original_description'):
|
||||
current_task.description = current_task._original_description
|
||||
|
||||
# Only end workflow if no next_tasks AND no conditions
|
||||
if not current_task.next_tasks and not current_task.condition and not any(
|
||||
@@ -1489,22 +1399,6 @@ Subtask: {st.name}
|
||||
logging.debug(f"Keeping status as: {self.tasks[task_id].status}")
|
||||
|
||||
|
||||
# Handle loop progression
|
||||
if current_task.task_type == "loop":
|
||||
loop_key = f"loop_{current_task.name}"
|
||||
if loop_key in loop_data:
|
||||
loop_info = loop_data[loop_key]
|
||||
loop_info["index"] += 1
|
||||
has_more = loop_info["remaining"] > 0
|
||||
|
||||
# Update result to trigger correct condition
|
||||
if current_task.result:
|
||||
result = current_task.result.raw
|
||||
if has_more:
|
||||
result += "\nmore"
|
||||
else:
|
||||
result += "\ndone"
|
||||
current_task.result.raw = result
|
||||
|
||||
# Determine next task based on result
|
||||
next_task = None
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
Runtime execution components for PraisonAI agents.
|
||||
|
||||
This module provides runtime execution abstractions and protocols for
|
||||
standardizing agent execution across different harness types.
|
||||
"""
|
||||
|
||||
from .turn_context import (
|
||||
PreparedTurnContext,
|
||||
ModelReference,
|
||||
ToolSchema,
|
||||
TranscriptWindow,
|
||||
DeliveryChannels,
|
||||
SessionCorrelation,
|
||||
RuntimeMode,
|
||||
create_default_model_ref,
|
||||
create_empty_transcript,
|
||||
create_default_delivery,
|
||||
create_session_correlation,
|
||||
)
|
||||
from .protocols import TurnRuntimeProtocol, TurnContextBuilderProtocol
|
||||
from .context_builder import DefaultTurnContextBuilder, default_context_builder
|
||||
|
||||
__all__ = [
|
||||
'PreparedTurnContext',
|
||||
'TurnRuntimeProtocol',
|
||||
'TurnContextBuilderProtocol',
|
||||
'ModelReference',
|
||||
'ToolSchema',
|
||||
'TranscriptWindow',
|
||||
'DeliveryChannels',
|
||||
'SessionCorrelation',
|
||||
'RuntimeMode',
|
||||
'create_default_model_ref',
|
||||
'create_empty_transcript',
|
||||
'create_default_delivery',
|
||||
'create_session_correlation',
|
||||
'DefaultTurnContextBuilder',
|
||||
'default_context_builder',
|
||||
]
|
||||
@@ -0,0 +1,169 @@
|
||||
"""AgentRuntimeConfig dataclass for model-scoped runtime selection.
|
||||
|
||||
Protocol-driven design following AGENTS.md:
|
||||
- Lightweight dataclass for configuration
|
||||
- No heavy implementations (those live in wrapper layer)
|
||||
- Support for per-model and per-provider runtime configuration
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Dict, Any, Union
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentRuntimeConfig:
|
||||
"""Model-scoped runtime configuration for PraisonAI Agents.
|
||||
|
||||
Supports the resolution order:
|
||||
per-model runtime → per-provider default → auto → built-in default
|
||||
|
||||
This replaces agent-level cli_backend with model-scoped runtime selection.
|
||||
"""
|
||||
# Runtime identifier (e.g., "claude-code", "codex-cli", "praisonai")
|
||||
runtime: Optional[str] = None
|
||||
|
||||
# Runtime-specific configuration overrides
|
||||
config_overrides: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# Provider-level default runtime (for YAML providers.<name>.runtime_default)
|
||||
provider_default: Optional[str] = None
|
||||
|
||||
# Enable auto-selection if no explicit runtime is specified
|
||||
enable_auto_selection: bool = True
|
||||
|
||||
# Metadata for runtime resolution
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate runtime configuration on initialization."""
|
||||
# Ensure config_overrides is a dictionary
|
||||
if not isinstance(self.config_overrides, dict):
|
||||
raise TypeError("config_overrides must be a dictionary")
|
||||
|
||||
# Ensure metadata is a dictionary
|
||||
if not isinstance(self.metadata, dict):
|
||||
raise TypeError("metadata must be a dictionary")
|
||||
|
||||
@classmethod
|
||||
def from_runtime_id(cls, runtime_id: str, **kwargs) -> "AgentRuntimeConfig":
|
||||
"""Create AgentRuntimeConfig from a runtime ID string.
|
||||
|
||||
Args:
|
||||
runtime_id: Runtime identifier (e.g., "claude-code")
|
||||
**kwargs: Additional configuration options
|
||||
|
||||
Returns:
|
||||
AgentRuntimeConfig instance
|
||||
"""
|
||||
return cls(runtime=runtime_id, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, config_dict: Dict[str, Any]) -> "AgentRuntimeConfig":
|
||||
"""Create AgentRuntimeConfig from a dictionary.
|
||||
|
||||
Args:
|
||||
config_dict: Configuration dictionary
|
||||
|
||||
Returns:
|
||||
AgentRuntimeConfig instance
|
||||
|
||||
Raises:
|
||||
TypeError: If config_dict is not a dictionary
|
||||
ValueError: If required fields are missing or invalid
|
||||
"""
|
||||
if not isinstance(config_dict, dict):
|
||||
raise TypeError("config_dict must be a dictionary")
|
||||
|
||||
# Extract known fields
|
||||
runtime = config_dict.get("runtime")
|
||||
config_overrides = config_dict.get("config_overrides", {})
|
||||
provider_default = config_dict.get("provider_default")
|
||||
enable_auto_selection = config_dict.get("enable_auto_selection", True)
|
||||
metadata = config_dict.get("metadata", {})
|
||||
|
||||
# Validate types
|
||||
if config_overrides is not None and not isinstance(config_overrides, dict):
|
||||
raise TypeError("config_overrides must be a dictionary")
|
||||
|
||||
if metadata is not None and not isinstance(metadata, dict):
|
||||
raise TypeError("metadata must be a dictionary")
|
||||
|
||||
return cls(
|
||||
runtime=runtime,
|
||||
config_overrides=config_overrides,
|
||||
provider_default=provider_default,
|
||||
enable_auto_selection=enable_auto_selection,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert AgentRuntimeConfig to a dictionary.
|
||||
|
||||
Returns:
|
||||
Dictionary representation of the configuration
|
||||
"""
|
||||
return {
|
||||
"runtime": self.runtime,
|
||||
"config_overrides": self.config_overrides.copy(),
|
||||
"provider_default": self.provider_default,
|
||||
"enable_auto_selection": self.enable_auto_selection,
|
||||
"metadata": self.metadata.copy()
|
||||
}
|
||||
|
||||
def merge_overrides(self, overrides: Dict[str, Any]) -> "AgentRuntimeConfig":
|
||||
"""Create a new AgentRuntimeConfig with merged configuration overrides.
|
||||
|
||||
Args:
|
||||
overrides: Configuration overrides to merge
|
||||
|
||||
Returns:
|
||||
New AgentRuntimeConfig instance with merged overrides
|
||||
"""
|
||||
if not isinstance(overrides, dict):
|
||||
raise TypeError("overrides must be a dictionary")
|
||||
|
||||
# Create new config overrides by merging
|
||||
new_config_overrides = {**self.config_overrides, **overrides}
|
||||
|
||||
return AgentRuntimeConfig(
|
||||
runtime=self.runtime,
|
||||
config_overrides=new_config_overrides,
|
||||
provider_default=self.provider_default,
|
||||
enable_auto_selection=self.enable_auto_selection,
|
||||
metadata=self.metadata.copy()
|
||||
)
|
||||
|
||||
def with_runtime(self, runtime_id: str) -> "AgentRuntimeConfig":
|
||||
"""Create a new AgentRuntimeConfig with a different runtime ID.
|
||||
|
||||
Args:
|
||||
runtime_id: New runtime identifier
|
||||
|
||||
Returns:
|
||||
New AgentRuntimeConfig instance with updated runtime
|
||||
"""
|
||||
return AgentRuntimeConfig(
|
||||
runtime=runtime_id,
|
||||
config_overrides=self.config_overrides.copy(),
|
||||
provider_default=self.provider_default,
|
||||
enable_auto_selection=self.enable_auto_selection,
|
||||
metadata=self.metadata.copy()
|
||||
)
|
||||
|
||||
def is_explicit(self) -> bool:
|
||||
"""Check if this configuration specifies an explicit runtime.
|
||||
|
||||
Returns:
|
||||
True if runtime is explicitly specified, False otherwise
|
||||
"""
|
||||
return self.runtime is not None and self.runtime != ""
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of AgentRuntimeConfig."""
|
||||
return (
|
||||
f"AgentRuntimeConfig("
|
||||
f"runtime={self.runtime!r}, "
|
||||
f"config_overrides={self.config_overrides!r}, "
|
||||
f"provider_default={self.provider_default!r}, "
|
||||
f"enable_auto_selection={self.enable_auto_selection!r})"
|
||||
)
|
||||
@@ -0,0 +1,452 @@
|
||||
"""
|
||||
Context Builder for PreparedTurnContext.
|
||||
|
||||
This module provides the DefaultTurnContextBuilder that prepares
|
||||
PreparedTurnContext instances from agent configuration and request
|
||||
parameters. It consolidates the scattered context preparation logic
|
||||
from chat_mixin.py, chat_handler.py, and other execution paths.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
from .turn_context import (
|
||||
PreparedTurnContext,
|
||||
ModelReference,
|
||||
ToolSchema,
|
||||
TranscriptWindow,
|
||||
DeliveryChannels,
|
||||
SessionCorrelation,
|
||||
RuntimeMode,
|
||||
)
|
||||
from .protocols import TurnContextBuilderProtocol
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..agent.protocols import AgentProtocol
|
||||
from ..streaming.events import StreamEventEmitter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DefaultTurnContextBuilder:
|
||||
"""
|
||||
Default implementation of turn context building.
|
||||
|
||||
This builder extracts and normalizes configuration from Agent instances
|
||||
to create PreparedTurnContext objects. It consolidates the logic that
|
||||
was previously scattered across multiple mixins.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._cache: Dict[str, Any] = {}
|
||||
|
||||
def build_context(
|
||||
self,
|
||||
agent: AgentProtocol,
|
||||
prompt: str,
|
||||
**kwargs: Any
|
||||
) -> PreparedTurnContext:
|
||||
"""
|
||||
Build a prepared turn context from agent and request.
|
||||
|
||||
Args:
|
||||
agent: The agent instance
|
||||
prompt: The user prompt for this turn
|
||||
**kwargs: Additional request parameters including:
|
||||
- model: Override model ID
|
||||
- tools: Override tools list
|
||||
- stream: Enable streaming
|
||||
- temperature: Model temperature
|
||||
- max_tokens: Token limit
|
||||
- session_id: Session identifier
|
||||
- turn_id: Turn identifier
|
||||
|
||||
Returns:
|
||||
A prepared turn context ready for execution
|
||||
"""
|
||||
try:
|
||||
# Extract runtime mode from kwargs
|
||||
runtime_mode = self._determine_runtime_mode(kwargs)
|
||||
|
||||
# Build model reference
|
||||
model_ref = self._build_model_reference(agent, kwargs)
|
||||
|
||||
# Normalize tools
|
||||
tools = self._build_tool_schemas(agent, kwargs)
|
||||
|
||||
# Build transcript window
|
||||
transcript = self._build_transcript_window(agent, prompt, kwargs)
|
||||
|
||||
# Setup delivery channels
|
||||
delivery = self._build_delivery_channels(agent, kwargs)
|
||||
|
||||
# Create correlation IDs
|
||||
correlation = self._build_session_correlation(agent, kwargs)
|
||||
|
||||
# Create the context
|
||||
context = PreparedTurnContext(
|
||||
model_ref=model_ref,
|
||||
agent_runtime=agent,
|
||||
tools=tools,
|
||||
transcript=transcript,
|
||||
delivery=delivery,
|
||||
correlation=correlation,
|
||||
runtime_mode=runtime_mode,
|
||||
turn_metadata=self._extract_turn_metadata(agent, kwargs),
|
||||
created_at=time.time()
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Built turn context: {context.get_message_count()} messages, "
|
||||
f"{len(context.tools)} tools, mode={context.runtime_mode.value}"
|
||||
)
|
||||
|
||||
return context
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to build turn context: {e}")
|
||||
raise
|
||||
|
||||
def _determine_runtime_mode(self, kwargs: Dict[str, Any]) -> RuntimeMode:
|
||||
"""Determine runtime mode from request parameters."""
|
||||
is_stream = kwargs.get('stream', False)
|
||||
is_async = kwargs.get('async_execution', False)
|
||||
|
||||
if is_async and is_stream:
|
||||
return RuntimeMode.ASYNC_STREAM
|
||||
elif is_async:
|
||||
return RuntimeMode.ASYNC
|
||||
elif is_stream:
|
||||
return RuntimeMode.STREAM
|
||||
else:
|
||||
return RuntimeMode.SYNC
|
||||
|
||||
def _build_model_reference(
|
||||
self,
|
||||
agent: AgentProtocol,
|
||||
kwargs: Dict[str, Any]
|
||||
) -> ModelReference:
|
||||
"""Build model reference from agent configuration and overrides."""
|
||||
# Get model ID (kwargs override, then agent default)
|
||||
model_id = kwargs.get('model') or getattr(agent, 'model', 'gpt-3.5-turbo')
|
||||
|
||||
# Determine provider from model ID or agent config
|
||||
provider = self._determine_provider(model_id, agent)
|
||||
|
||||
# Get model capabilities
|
||||
supports_streaming = kwargs.get('stream', False) or getattr(agent, '_supports_streaming', True)
|
||||
supports_tools = len(getattr(agent, 'tools', [])) > 0
|
||||
supports_system_prompts = getattr(agent, 'use_system_prompt', True)
|
||||
|
||||
# Build model config - agent defaults first, kwargs override
|
||||
model_config = {}
|
||||
if hasattr(agent, 'model_config'):
|
||||
model_config.update(agent.model_config)
|
||||
if 'temperature' in kwargs:
|
||||
model_config['temperature'] = kwargs['temperature']
|
||||
if 'max_tokens' in kwargs:
|
||||
model_config['max_tokens'] = kwargs['max_tokens']
|
||||
|
||||
return ModelReference(
|
||||
model_id=model_id,
|
||||
provider=provider,
|
||||
supports_streaming=supports_streaming,
|
||||
supports_tools=supports_tools,
|
||||
supports_system_prompts=supports_system_prompts,
|
||||
max_tokens=kwargs.get('max_tokens'),
|
||||
temperature=kwargs.get('temperature'),
|
||||
model_config=model_config
|
||||
)
|
||||
|
||||
def _determine_provider(self, model_id: str, agent: AgentProtocol) -> str:
|
||||
"""Determine provider from model ID or agent configuration."""
|
||||
# Check if agent has explicit provider
|
||||
if hasattr(agent, 'provider'):
|
||||
return agent.provider
|
||||
|
||||
# Infer from model ID
|
||||
if model_id.startswith('gpt-'):
|
||||
return 'openai'
|
||||
elif model_id.startswith('claude-'):
|
||||
return 'anthropic'
|
||||
elif model_id.startswith('gemini-'):
|
||||
return 'google'
|
||||
elif 'llama' in model_id.lower():
|
||||
return 'meta'
|
||||
else:
|
||||
return 'openai' # Default fallback
|
||||
|
||||
def _build_tool_schemas(
|
||||
self,
|
||||
agent: AgentProtocol,
|
||||
kwargs: Dict[str, Any]
|
||||
) -> List[ToolSchema]:
|
||||
"""Build normalized tool schemas from agent tools."""
|
||||
# Get tools from kwargs override or agent - respect explicit empty list
|
||||
if 'tools' in kwargs:
|
||||
raw_tools = kwargs['tools']
|
||||
else:
|
||||
raw_tools = getattr(agent, 'tools', [])
|
||||
|
||||
if not raw_tools:
|
||||
return []
|
||||
|
||||
normalized_tools = []
|
||||
for tool in raw_tools:
|
||||
try:
|
||||
schema = self._normalize_tool_schema(tool)
|
||||
if schema:
|
||||
normalized_tools.append(schema)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to normalize tool {tool}: {e}")
|
||||
continue
|
||||
|
||||
return normalized_tools
|
||||
|
||||
def _normalize_tool_schema(self, tool: Any) -> Optional[ToolSchema]:
|
||||
"""Normalize a single tool into ToolSchema format."""
|
||||
if tool is None:
|
||||
return None
|
||||
|
||||
# Handle different tool formats
|
||||
if callable(tool):
|
||||
# Function tool
|
||||
return self._normalize_function_tool(tool)
|
||||
elif isinstance(tool, dict):
|
||||
# Dictionary tool (OpenAI format, custom format, etc.)
|
||||
return self._normalize_dict_tool(tool)
|
||||
elif hasattr(tool, 'name') and hasattr(tool, 'description'):
|
||||
# Object with name/description attributes
|
||||
return self._normalize_object_tool(tool)
|
||||
else:
|
||||
logger.warning(f"Unknown tool format: {type(tool)}")
|
||||
return None
|
||||
|
||||
def _normalize_function_tool(self, func: callable) -> ToolSchema:
|
||||
"""Normalize a callable function into ToolSchema."""
|
||||
import inspect
|
||||
|
||||
name = getattr(func, '__name__', 'unknown_function')
|
||||
description = getattr(func, '__doc__', '') or f"Function: {name}"
|
||||
|
||||
# Try to extract parameters from function signature
|
||||
try:
|
||||
sig = inspect.signature(func)
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
|
||||
for param_name, param in sig.parameters.items():
|
||||
if param_name == 'self':
|
||||
continue
|
||||
|
||||
prop = {"type": "string"} # Default type
|
||||
if param.annotation != inspect.Parameter.empty:
|
||||
prop["type"] = self._python_type_to_json_type(param.annotation)
|
||||
|
||||
parameters["properties"][param_name] = prop
|
||||
|
||||
if param.default == inspect.Parameter.empty:
|
||||
parameters["required"].append(param_name)
|
||||
|
||||
except Exception:
|
||||
# Fallback to empty parameters
|
||||
parameters = {"type": "object", "properties": {}}
|
||||
|
||||
return ToolSchema(
|
||||
name=name,
|
||||
description=description.strip(),
|
||||
parameters=parameters,
|
||||
callable=func,
|
||||
source_type="function"
|
||||
)
|
||||
|
||||
def _normalize_dict_tool(self, tool_dict: Dict[str, Any]) -> ToolSchema:
|
||||
"""Normalize a dictionary tool into ToolSchema."""
|
||||
if 'function' in tool_dict:
|
||||
# OpenAI format: {"type": "function", "function": {...}}
|
||||
func_def = tool_dict['function']
|
||||
return ToolSchema(
|
||||
name=func_def.get('name', 'unknown'),
|
||||
description=func_def.get('description', ''),
|
||||
parameters=func_def.get('parameters', {}),
|
||||
source_type="openai"
|
||||
)
|
||||
else:
|
||||
# Direct format: {"name": ..., "description": ..., "parameters": ...}
|
||||
return ToolSchema(
|
||||
name=tool_dict.get('name', 'unknown'),
|
||||
description=tool_dict.get('description', ''),
|
||||
parameters=tool_dict.get('parameters', {}),
|
||||
source_type="custom"
|
||||
)
|
||||
|
||||
def _normalize_object_tool(self, tool_obj: Any) -> ToolSchema:
|
||||
"""Normalize a tool object into ToolSchema."""
|
||||
return ToolSchema(
|
||||
name=getattr(tool_obj, 'name', 'unknown'),
|
||||
description=getattr(tool_obj, 'description', ''),
|
||||
parameters=getattr(tool_obj, 'parameters', {}),
|
||||
callable=getattr(tool_obj, '__call__', None),
|
||||
source_type="object"
|
||||
)
|
||||
|
||||
def _python_type_to_json_type(self, py_type: type) -> str:
|
||||
"""Convert Python type annotation to JSON schema type."""
|
||||
if py_type == str:
|
||||
return "string"
|
||||
elif py_type == int:
|
||||
return "integer"
|
||||
elif py_type == float:
|
||||
return "number"
|
||||
elif py_type == bool:
|
||||
return "boolean"
|
||||
elif py_type == list:
|
||||
return "array"
|
||||
elif py_type == dict:
|
||||
return "object"
|
||||
else:
|
||||
return "string" # Default fallback
|
||||
|
||||
def _build_transcript_window(
|
||||
self,
|
||||
agent: AgentProtocol,
|
||||
prompt: str,
|
||||
kwargs: Dict[str, Any]
|
||||
) -> TranscriptWindow:
|
||||
"""Build transcript window from agent conversation history."""
|
||||
# Get system prompt
|
||||
system_prompt = None
|
||||
if hasattr(agent, '_build_system_prompt'):
|
||||
try:
|
||||
system_prompt = agent._build_system_prompt()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to build system prompt: {e}")
|
||||
|
||||
# Get conversation history - deep copy to prevent cross-agent mutations
|
||||
messages = []
|
||||
if hasattr(agent, 'chat_history') and agent.chat_history:
|
||||
messages = copy.deepcopy(agent.chat_history)
|
||||
|
||||
# Add current prompt as user message
|
||||
if prompt:
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
|
||||
# Estimate token count (rough approximation)
|
||||
total_tokens = self._estimate_tokens(messages, system_prompt)
|
||||
|
||||
return TranscriptWindow(
|
||||
messages=messages,
|
||||
total_tokens=total_tokens,
|
||||
system_prompt=system_prompt,
|
||||
context_metadata={
|
||||
"original_prompt": prompt,
|
||||
"history_length": len(messages) - (1 if prompt else 0)
|
||||
}
|
||||
)
|
||||
|
||||
def _estimate_tokens(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
system_prompt: Optional[str]
|
||||
) -> int:
|
||||
"""Rough token estimation for transcript."""
|
||||
total_chars = 0
|
||||
|
||||
if system_prompt:
|
||||
total_chars += len(system_prompt)
|
||||
|
||||
for msg in messages:
|
||||
content = msg.get('content', '')
|
||||
if isinstance(content, str):
|
||||
total_chars += len(content)
|
||||
|
||||
# Rough approximation: 1 token ≈ 4 characters
|
||||
return total_chars // 4
|
||||
|
||||
def _build_delivery_channels(
|
||||
self,
|
||||
agent: AgentProtocol,
|
||||
kwargs: Dict[str, Any]
|
||||
) -> DeliveryChannels:
|
||||
"""Build delivery channels configuration."""
|
||||
enable_streaming = kwargs.get('stream', False)
|
||||
enable_metrics = kwargs.get('enable_metrics', False)
|
||||
|
||||
# Get stream emitter from agent if available
|
||||
stream_emitter = None
|
||||
if enable_streaming:
|
||||
if hasattr(agent, '_stream_emitter'):
|
||||
stream_emitter = agent._stream_emitter
|
||||
elif hasattr(agent, 'stream_emitter'):
|
||||
stream_emitter = agent.stream_emitter
|
||||
|
||||
# Get callbacks
|
||||
callbacks = kwargs.get('callbacks', [])
|
||||
async_callbacks = kwargs.get('async_callbacks', [])
|
||||
|
||||
return DeliveryChannels(
|
||||
stream_emitter=stream_emitter,
|
||||
callbacks=callbacks,
|
||||
async_callbacks=async_callbacks,
|
||||
enable_streaming=enable_streaming,
|
||||
enable_metrics=enable_metrics
|
||||
)
|
||||
|
||||
def _build_session_correlation(
|
||||
self,
|
||||
agent: AgentProtocol,
|
||||
kwargs: Dict[str, Any]
|
||||
) -> SessionCorrelation:
|
||||
"""Build session correlation identifiers."""
|
||||
# Use provided IDs or generate new ones
|
||||
session_id = kwargs.get('session_id')
|
||||
if not session_id and hasattr(agent, 'session_id'):
|
||||
session_id = agent.session_id
|
||||
if not session_id:
|
||||
session_id = f"session-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
turn_id = kwargs.get('turn_id') or f"turn-{uuid.uuid4().hex[:8]}"
|
||||
agent_id = kwargs.get('agent_id') or getattr(agent, 'name', 'agent')
|
||||
run_id = kwargs.get('run_id') or f"run-{uuid.uuid4().hex[:8]}"
|
||||
parent_id = kwargs.get('parent_id')
|
||||
|
||||
return SessionCorrelation(
|
||||
session_id=session_id,
|
||||
turn_id=turn_id,
|
||||
agent_id=agent_id,
|
||||
run_id=run_id,
|
||||
parent_id=parent_id,
|
||||
trace_metadata=kwargs.get('trace_metadata', {})
|
||||
)
|
||||
|
||||
def _extract_turn_metadata(
|
||||
self,
|
||||
agent: AgentProtocol,
|
||||
kwargs: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Extract additional turn metadata."""
|
||||
metadata = {}
|
||||
|
||||
# Add agent metadata
|
||||
if hasattr(agent, 'role'):
|
||||
metadata['agent_role'] = agent.role
|
||||
if hasattr(agent, 'goal'):
|
||||
metadata['agent_goal'] = agent.goal
|
||||
|
||||
# Add request metadata
|
||||
if 'metadata' in kwargs:
|
||||
metadata.update(kwargs['metadata'])
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
# Global instance for convenience
|
||||
default_context_builder = DefaultTurnContextBuilder()
|
||||
@@ -0,0 +1,366 @@
|
||||
"""
|
||||
Example External Harness using PreparedTurnContext.
|
||||
|
||||
This module demonstrates how external harnesses (plugins, managed backends,
|
||||
CLI backends, etc.) can use the PreparedTurnContext pattern for consistent
|
||||
runtime execution across different execution environments.
|
||||
|
||||
This serves as an example implementation for the acceptance criteria:
|
||||
"At least one external harness uses the same plan object."
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
from .turn_context import (
|
||||
PreparedTurnContext,
|
||||
TurnRuntimeProtocol,
|
||||
RuntimeMode,
|
||||
)
|
||||
from .context_builder import default_context_builder
|
||||
from ..streaming.events import StreamEvent, StreamEventType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..agent.protocols import AgentProtocol
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PluginHarnessRuntime:
|
||||
"""
|
||||
Example plugin harness that uses PreparedTurnContext.
|
||||
|
||||
This demonstrates how external harnesses can standardize on the
|
||||
PreparedTurnContext pattern instead of implementing their own
|
||||
scattered preparation logic.
|
||||
|
||||
Before (scattered preparation):
|
||||
- Plugin gathers tools from agent
|
||||
- Plugin builds system prompt
|
||||
- Plugin manages streaming separately
|
||||
- Plugin handles context differently than native runtime
|
||||
|
||||
After (unified with PreparedTurnContext):
|
||||
- Plugin receives prepared context
|
||||
- Same context used by native runtime
|
||||
- Consistent tool schemas, transcript, delivery channels
|
||||
- Enables consistent metrics and tracing
|
||||
"""
|
||||
|
||||
def __init__(self, plugin_config: Optional[Dict[str, Any]] = None):
|
||||
self.config = plugin_config or {}
|
||||
self.metrics = {}
|
||||
self._supported_modes = [RuntimeMode.SYNC, RuntimeMode.ASYNC, RuntimeMode.STREAM]
|
||||
|
||||
async def run_turn(self, context: PreparedTurnContext) -> str:
|
||||
"""
|
||||
Execute turn using plugin-specific logic with prepared context.
|
||||
|
||||
This method demonstrates how an external harness uses the
|
||||
standardized PreparedTurnContext instead of implementing
|
||||
its own preparation logic.
|
||||
|
||||
Args:
|
||||
context: The prepared turn context from the builder
|
||||
|
||||
Returns:
|
||||
Plugin-processed response
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
logger.info(
|
||||
f"Plugin executing turn {context.correlation.turn_id} "
|
||||
f"with {len(context.tools)} tools in {context.runtime_mode.value} mode"
|
||||
)
|
||||
|
||||
# Use prepared context data instead of rebuilding
|
||||
response = await self._execute_with_prepared_data(context)
|
||||
|
||||
# Apply plugin-specific processing
|
||||
processed_response = await self._apply_plugin_processing(context, response)
|
||||
|
||||
# Record metrics using correlation IDs from context
|
||||
self._record_metrics(context, start_time, len(processed_response))
|
||||
|
||||
return processed_response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Plugin execution failed for turn {context.correlation.turn_id}: {e}")
|
||||
raise
|
||||
|
||||
async def _execute_with_prepared_data(self, context: PreparedTurnContext) -> str:
|
||||
"""
|
||||
Execute using the prepared context data.
|
||||
|
||||
This method shows how to use the standardized context components
|
||||
instead of re-implementing tool gathering, prompt building, etc.
|
||||
"""
|
||||
# Extract prompt from prepared transcript (no need to rebuild)
|
||||
user_messages = [
|
||||
msg for msg in context.transcript.messages
|
||||
if msg.get('role') == 'user'
|
||||
]
|
||||
prompt = user_messages[-1]['content'] if user_messages else ""
|
||||
|
||||
# Use prepared system prompt (no need to rebuild)
|
||||
system_prompt = context.transcript.system_prompt or "You are a helpful assistant."
|
||||
|
||||
# Use prepared tool schemas (normalized format)
|
||||
available_tools = [
|
||||
f"- {tool.name}: {tool.description}"
|
||||
for tool in context.tools
|
||||
]
|
||||
tools_text = "\n".join(available_tools) if available_tools else "No tools available"
|
||||
|
||||
# Use prepared model configuration
|
||||
model_id = context.model_ref.model_id
|
||||
temperature = context.model_ref.temperature or 0.7
|
||||
|
||||
# Plugin-specific execution (simulated)
|
||||
plugin_response = f"""Plugin Response (Model: {model_id}, Temp: {temperature}):
|
||||
|
||||
User: {prompt}
|
||||
|
||||
System: {system_prompt}
|
||||
|
||||
Available Tools:
|
||||
{tools_text}
|
||||
|
||||
Processing with plugin harness...
|
||||
Response: I understand your request and would process it using the plugin infrastructure."""
|
||||
|
||||
# Handle streaming if enabled in prepared delivery channels
|
||||
if context.delivery.has_streaming():
|
||||
return await self._stream_response(context, plugin_response)
|
||||
else:
|
||||
return plugin_response
|
||||
|
||||
async def _stream_response(self, context: PreparedTurnContext, response: str) -> str:
|
||||
"""
|
||||
Handle streaming using prepared delivery channels.
|
||||
|
||||
Demonstrates using the prepared streaming configuration
|
||||
instead of setting up streaming from scratch.
|
||||
"""
|
||||
if context.delivery.stream_emitter:
|
||||
# Use prepared stream emitter
|
||||
for chunk in response.split():
|
||||
await context.delivery.stream_emitter.emit_async(
|
||||
StreamEvent(type=StreamEventType.DELTA_TEXT, content=chunk + " ")
|
||||
)
|
||||
await asyncio.sleep(0.01) # Simulate streaming delay
|
||||
|
||||
return response
|
||||
|
||||
async def _apply_plugin_processing(
|
||||
self,
|
||||
context: PreparedTurnContext,
|
||||
response: str
|
||||
) -> str:
|
||||
"""
|
||||
Apply plugin-specific post-processing.
|
||||
|
||||
This demonstrates how plugins can add their own processing
|
||||
while still using the standardized context pattern.
|
||||
"""
|
||||
# Plugin-specific processing example
|
||||
if self.config.get('add_metadata', True):
|
||||
metadata = f"\n\n[Plugin Metadata: Session={context.correlation.session_id}, Turn={context.correlation.turn_id}]"
|
||||
response += metadata
|
||||
|
||||
if self.config.get('apply_filters', False):
|
||||
# Apply content filters, transformations, etc.
|
||||
response = response.replace("error", "issue") # Example filter
|
||||
|
||||
return response
|
||||
|
||||
def _record_metrics(self, context: PreparedTurnContext, start_time: float, response_length: int):
|
||||
"""
|
||||
Record metrics using context correlation IDs.
|
||||
|
||||
Demonstrates consistent metrics collection across harness types
|
||||
using the standardized context identifiers.
|
||||
"""
|
||||
execution_time = time.time() - start_time
|
||||
|
||||
# Use correlation IDs for consistent metrics
|
||||
session_id = context.correlation.session_id
|
||||
turn_id = context.correlation.turn_id
|
||||
|
||||
# Record metrics
|
||||
self.metrics[turn_id] = {
|
||||
'session_id': session_id,
|
||||
'execution_time_ms': execution_time * 1000,
|
||||
'response_length': response_length,
|
||||
'model_used': context.model_ref.model_id,
|
||||
'tools_count': len(context.tools),
|
||||
'runtime_mode': context.runtime_mode.value,
|
||||
'timestamp': time.time()
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
f"Plugin metrics recorded: {execution_time:.3f}s, "
|
||||
f"{response_length} chars, {len(context.tools)} tools"
|
||||
)
|
||||
|
||||
def supports_runtime_mode(self, mode: RuntimeMode) -> bool:
|
||||
"""Check if this plugin supports the given runtime mode."""
|
||||
return mode in self._supported_modes
|
||||
|
||||
def get_supported_modes(self) -> List[RuntimeMode]:
|
||||
"""Get all supported runtime modes."""
|
||||
return self._supported_modes.copy()
|
||||
|
||||
def get_metrics(self) -> Dict[str, Any]:
|
||||
"""Get collected metrics."""
|
||||
return self.metrics.copy()
|
||||
|
||||
|
||||
class CLIBackendHarness:
|
||||
"""
|
||||
Example CLI backend harness using PreparedTurnContext.
|
||||
|
||||
This demonstrates how CLI backends can use the same prepared
|
||||
context as the native runtime, eliminating duplication in
|
||||
tool assembly and message building.
|
||||
"""
|
||||
|
||||
def __init__(self, cli_config: Optional[Dict[str, Any]] = None):
|
||||
self.config = cli_config or {}
|
||||
self._supported_modes = [RuntimeMode.SYNC, RuntimeMode.ASYNC]
|
||||
|
||||
async def run_turn(self, context: PreparedTurnContext) -> str:
|
||||
"""Execute turn via CLI backend using prepared context."""
|
||||
logger.info(f"CLI backend executing turn {context.correlation.turn_id}")
|
||||
|
||||
# Convert prepared context to CLI command
|
||||
cli_command = self._build_cli_command(context)
|
||||
|
||||
# Execute CLI command (simulated)
|
||||
result = await self._execute_cli_command(cli_command)
|
||||
|
||||
return f"CLI Backend Result: {result}"
|
||||
|
||||
def _build_cli_command(self, context: PreparedTurnContext) -> Dict[str, Any]:
|
||||
"""
|
||||
Build CLI command from prepared context.
|
||||
|
||||
Shows how prepared context eliminates the need for CLI backends
|
||||
to implement their own tool gathering and prompt building logic.
|
||||
"""
|
||||
# Extract prompt from prepared transcript
|
||||
user_messages = [msg for msg in context.transcript.messages if msg.get('role') == 'user']
|
||||
prompt = user_messages[-1]['content'] if user_messages else ""
|
||||
|
||||
# Use prepared model reference
|
||||
model_config = context.model_ref.model_config.copy()
|
||||
model_config['model'] = context.model_ref.model_id
|
||||
|
||||
# Use prepared tool schemas (convert to CLI format)
|
||||
tools = [
|
||||
{
|
||||
'name': tool.name,
|
||||
'description': tool.description,
|
||||
'source_type': tool.source_type
|
||||
}
|
||||
for tool in context.tools
|
||||
]
|
||||
|
||||
return {
|
||||
'prompt': prompt,
|
||||
'system_prompt': context.transcript.system_prompt,
|
||||
'model_config': model_config,
|
||||
'tools': tools,
|
||||
'session_id': context.correlation.session_id,
|
||||
'turn_id': context.correlation.turn_id
|
||||
}
|
||||
|
||||
async def _execute_cli_command(self, command: Dict[str, Any]) -> str:
|
||||
"""Execute the CLI command (simulated)."""
|
||||
# Simulate CLI execution delay
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
return f"Processed '{command['prompt']}' with {len(command['tools'])} tools using {command['model_config']['model']}"
|
||||
|
||||
def supports_runtime_mode(self, mode: RuntimeMode) -> bool:
|
||||
"""Check if CLI backend supports the runtime mode."""
|
||||
return mode in self._supported_modes
|
||||
|
||||
def get_supported_modes(self) -> List[RuntimeMode]:
|
||||
"""Get supported runtime modes."""
|
||||
return self._supported_modes.copy()
|
||||
|
||||
|
||||
# Example integration function
|
||||
async def demonstrate_harness_integration():
|
||||
"""
|
||||
Demonstrate how external harnesses use PreparedTurnContext.
|
||||
|
||||
This function shows the complete flow from context preparation
|
||||
to execution across different harness types, proving that the
|
||||
same plan object can be used consistently.
|
||||
"""
|
||||
print("Demonstrating PreparedTurnContext with external harnesses...\n")
|
||||
|
||||
# Mock agent
|
||||
class MockAgent:
|
||||
def __init__(self):
|
||||
self.name = "DemoAgent"
|
||||
self.model = "gpt-3.5-turbo"
|
||||
self.role = "assistant"
|
||||
self.goal = "demonstrate harnesses"
|
||||
self.tools = []
|
||||
self.chat_history = []
|
||||
self.use_system_prompt = True
|
||||
|
||||
def _build_system_prompt(self):
|
||||
return f"You are {self.role}. Your goal: {self.goal}"
|
||||
|
||||
agent = MockAgent()
|
||||
|
||||
# Prepare single context (eliminates scattered preparation)
|
||||
context = default_context_builder.build_context(
|
||||
agent=agent,
|
||||
prompt="Demonstrate harness integration",
|
||||
temperature=0.8,
|
||||
session_id="demo-session"
|
||||
)
|
||||
|
||||
print(f"Prepared context: {context.to_dict()}\n")
|
||||
|
||||
# Execute with different harnesses using same context
|
||||
harnesses = [
|
||||
("Plugin Harness", PluginHarnessRuntime({'add_metadata': True})),
|
||||
("CLI Backend", CLIBackendHarness({'cli_mode': 'demo'}))
|
||||
]
|
||||
|
||||
results = {}
|
||||
for name, harness in harnesses:
|
||||
if harness.supports_runtime_mode(context.runtime_mode):
|
||||
print(f"Executing with {name}...")
|
||||
try:
|
||||
result = await harness.run_turn(context)
|
||||
results[name] = result[:100] + "..." if len(result) > 100 else result
|
||||
print(f"✓ {name} completed\n")
|
||||
except Exception as e:
|
||||
print(f"✗ {name} failed: {e}\n")
|
||||
results[name] = f"Error: {e}"
|
||||
else:
|
||||
print(f"✗ {name} doesn't support {context.runtime_mode.value} mode\n")
|
||||
results[name] = f"Unsupported mode: {context.runtime_mode.value}"
|
||||
|
||||
# Display results
|
||||
print("Results:")
|
||||
for name, result in results.items():
|
||||
print(f"{name}: {result}")
|
||||
|
||||
print(f"\n🎉 Demonstrated PreparedTurnContext with {len(harnesses)} external harnesses!")
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Run the demonstration
|
||||
asyncio.run(demonstrate_harness_integration())
|
||||
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
Runtime Protocols for PraisonAI Agents.
|
||||
|
||||
This module defines the protocol interfaces that runtime implementations
|
||||
must implement. These protocols standardize the execution contract
|
||||
across different runtime types (native, plugin, managed, etc.).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, List, Protocol, runtime_checkable, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .turn_context import PreparedTurnContext, RuntimeMode
|
||||
from ..agent.protocols import AgentProtocol
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TurnRuntimeProtocol(Protocol):
|
||||
"""
|
||||
Protocol for turn-based runtime execution.
|
||||
|
||||
This protocol defines the interface that runtimes must implement to
|
||||
execute prepared turn contexts. It standardizes the execution contract
|
||||
across different runtime types (native, plugin, managed, etc.).
|
||||
|
||||
Example:
|
||||
```python
|
||||
class MyRuntime:
|
||||
async def run_turn(self, context: PreparedTurnContext) -> str:
|
||||
# Execute the turn using the prepared context
|
||||
return "response"
|
||||
|
||||
# Use with prepared context
|
||||
context = PreparedTurnContext(...)
|
||||
runtime = MyRuntime()
|
||||
result = await runtime.run_turn(context)
|
||||
```
|
||||
"""
|
||||
|
||||
async def run_turn(self, context: PreparedTurnContext) -> str:
|
||||
"""
|
||||
Execute a single turn using the prepared context.
|
||||
|
||||
This is the main execution entry point for all runtime types.
|
||||
The context is immutable and contains all necessary configuration
|
||||
for the turn execution.
|
||||
|
||||
Args:
|
||||
context: The prepared turn context containing model, tools,
|
||||
transcript, delivery channels, and correlation IDs
|
||||
|
||||
Returns:
|
||||
The agent's response as a string
|
||||
|
||||
Raises:
|
||||
RuntimeError: If execution fails
|
||||
ValueError: If context is invalid or incompatible
|
||||
"""
|
||||
...
|
||||
|
||||
def supports_runtime_mode(self, mode: RuntimeMode) -> bool:
|
||||
"""
|
||||
Check if this runtime supports the given execution mode.
|
||||
|
||||
Args:
|
||||
mode: The runtime mode to check
|
||||
|
||||
Returns:
|
||||
True if the mode is supported, False otherwise
|
||||
"""
|
||||
...
|
||||
|
||||
def get_supported_modes(self) -> List[RuntimeMode]:
|
||||
"""
|
||||
Get all runtime modes supported by this implementation.
|
||||
|
||||
Returns:
|
||||
List of supported RuntimeMode values
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TurnContextBuilderProtocol(Protocol):
|
||||
"""
|
||||
Protocol for building PreparedTurnContext instances.
|
||||
|
||||
This protocol defines the interface for context builders that prepare
|
||||
turn contexts from agent configuration and request parameters.
|
||||
"""
|
||||
|
||||
def build_context(
|
||||
self,
|
||||
agent: AgentProtocol,
|
||||
prompt: str,
|
||||
**kwargs: Any
|
||||
) -> PreparedTurnContext:
|
||||
"""
|
||||
Build a prepared turn context from agent and request.
|
||||
|
||||
Args:
|
||||
agent: The agent instance
|
||||
prompt: The user prompt for this turn
|
||||
**kwargs: Additional request parameters
|
||||
|
||||
Returns:
|
||||
A prepared turn context ready for execution
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Runtime registry protocol for PraisonAI Agents.
|
||||
|
||||
Protocol-driven design following AGENTS.md:
|
||||
- Core protocols only (no heavy implementations)
|
||||
- Registry pattern for runtime resolution
|
||||
- Fail-closed behavior for unknown runtime IDs
|
||||
"""
|
||||
|
||||
import threading
|
||||
from typing import Protocol, Dict, Any, Optional, List, runtime_checkable
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class RuntimeRegistryEntry:
|
||||
"""Entry in the runtime registry containing runtime metadata."""
|
||||
runtime_id: str
|
||||
display_name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
is_builtin: bool = False
|
||||
metadata: Dict[str, Any] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.metadata is None:
|
||||
self.metadata = {}
|
||||
if self.display_name is None:
|
||||
self.display_name = self.runtime_id
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class RuntimeRegistry(Protocol):
|
||||
"""Protocol for runtime registries.
|
||||
|
||||
Defines the interface for registering and resolving agent runtimes.
|
||||
The actual implementation lives in the wrapper layer.
|
||||
"""
|
||||
|
||||
def register(self, runtime_id: str, factory_or_instance: Any, **metadata) -> None:
|
||||
"""Register a runtime with the registry.
|
||||
|
||||
Args:
|
||||
runtime_id: Unique runtime identifier (e.g., "claude-code")
|
||||
factory_or_instance: Runtime factory function or instance
|
||||
**metadata: Additional metadata for the runtime
|
||||
|
||||
Raises:
|
||||
ValueError: If runtime_id is already registered
|
||||
TypeError: If factory_or_instance is not callable or valid instance
|
||||
"""
|
||||
...
|
||||
|
||||
def unregister(self, runtime_id: str) -> bool:
|
||||
"""Unregister a runtime from the registry.
|
||||
|
||||
Args:
|
||||
runtime_id: Runtime identifier to unregister
|
||||
|
||||
Returns:
|
||||
True if runtime was unregistered, False if not found
|
||||
"""
|
||||
...
|
||||
|
||||
def is_registered(self, runtime_id: str) -> bool:
|
||||
"""Check if a runtime is registered.
|
||||
|
||||
Args:
|
||||
runtime_id: Runtime identifier to check
|
||||
|
||||
Returns:
|
||||
True if runtime is registered, False otherwise
|
||||
"""
|
||||
...
|
||||
|
||||
def list_runtimes(self) -> List[RuntimeRegistryEntry]:
|
||||
"""List all registered runtimes.
|
||||
|
||||
Returns:
|
||||
List of RuntimeRegistryEntry objects for all registered runtimes
|
||||
"""
|
||||
...
|
||||
|
||||
def get_entry(self, runtime_id: str) -> Optional[RuntimeRegistryEntry]:
|
||||
"""Get registry entry for a runtime.
|
||||
|
||||
Args:
|
||||
runtime_id: Runtime identifier
|
||||
|
||||
Returns:
|
||||
RuntimeRegistryEntry if found, None otherwise
|
||||
"""
|
||||
...
|
||||
|
||||
def resolve(self, runtime_id: str, config_overrides: Optional[Dict[str, Any]] = None) -> Any:
|
||||
"""Resolve a runtime by ID with optional configuration overrides.
|
||||
|
||||
Args:
|
||||
runtime_id: Runtime identifier to resolve
|
||||
config_overrides: Optional configuration overrides
|
||||
|
||||
Returns:
|
||||
Runtime instance (typically implementing CliBackendProtocol)
|
||||
|
||||
Raises:
|
||||
ValueError: If runtime_id is not registered (fail-closed behavior)
|
||||
TypeError: If runtime cannot be instantiated
|
||||
"""
|
||||
...
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all registered runtimes.
|
||||
|
||||
Used primarily for testing and cleanup.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# Global registry instance - will be initialized by wrapper layer
|
||||
_global_registry: Optional[RuntimeRegistry] = None
|
||||
_registry_lock = threading.Lock()
|
||||
|
||||
|
||||
def set_global_registry(registry: RuntimeRegistry) -> None:
|
||||
"""Set the global runtime registry instance.
|
||||
|
||||
This is called by the wrapper layer to inject the actual registry implementation.
|
||||
|
||||
Args:
|
||||
registry: RuntimeRegistry implementation
|
||||
|
||||
Raises:
|
||||
TypeError: If registry doesn't implement RuntimeRegistry protocol
|
||||
"""
|
||||
global _global_registry
|
||||
|
||||
if not isinstance(registry, RuntimeRegistry):
|
||||
raise TypeError("registry must implement RuntimeRegistry protocol")
|
||||
|
||||
with _registry_lock:
|
||||
_global_registry = registry
|
||||
|
||||
|
||||
def get_global_registry() -> Optional[RuntimeRegistry]:
|
||||
"""Get the global runtime registry instance.
|
||||
|
||||
Returns:
|
||||
RuntimeRegistry instance if set, None otherwise
|
||||
"""
|
||||
with _registry_lock:
|
||||
return _global_registry
|
||||
|
||||
|
||||
def register_runtime(runtime_id: str, factory_or_instance: Any, **metadata) -> None:
|
||||
"""Register a runtime with the global registry.
|
||||
|
||||
Args:
|
||||
runtime_id: Unique runtime identifier
|
||||
factory_or_instance: Runtime factory function or instance
|
||||
**metadata: Additional metadata for the runtime
|
||||
|
||||
Raises:
|
||||
RuntimeError: If global registry is not initialized
|
||||
ValueError: If runtime_id is already registered
|
||||
"""
|
||||
registry = get_global_registry()
|
||||
if registry is None:
|
||||
raise RuntimeError(
|
||||
"Global runtime registry not initialized. "
|
||||
"This should be done by the wrapper layer."
|
||||
)
|
||||
|
||||
registry.register(runtime_id, factory_or_instance, **metadata)
|
||||
|
||||
|
||||
def resolve_runtime(
|
||||
runtime_id: str,
|
||||
config_overrides: Optional[Dict[str, Any]] = None
|
||||
) -> Any:
|
||||
"""Resolve a runtime by ID using the global registry.
|
||||
|
||||
Args:
|
||||
runtime_id: Runtime identifier to resolve
|
||||
config_overrides: Optional configuration overrides
|
||||
|
||||
Returns:
|
||||
Runtime instance
|
||||
|
||||
Raises:
|
||||
RuntimeError: If global registry is not initialized
|
||||
ValueError: If runtime_id is not registered (fail-closed behavior)
|
||||
"""
|
||||
registry = get_global_registry()
|
||||
if registry is None:
|
||||
raise RuntimeError(
|
||||
"Global runtime registry not initialized. "
|
||||
"This should be done by the wrapper layer."
|
||||
)
|
||||
|
||||
return registry.resolve(runtime_id, config_overrides)
|
||||
|
||||
|
||||
def list_available_runtimes() -> List[RuntimeRegistryEntry]:
|
||||
"""List all available runtimes from the global registry.
|
||||
|
||||
Returns:
|
||||
List of RuntimeRegistryEntry objects, empty list if registry not initialized
|
||||
"""
|
||||
registry = get_global_registry()
|
||||
if registry is None:
|
||||
return []
|
||||
|
||||
return registry.list_runtimes()
|
||||
|
||||
|
||||
def is_runtime_available(runtime_id: str) -> bool:
|
||||
"""Check if a runtime is available in the global registry.
|
||||
|
||||
Args:
|
||||
runtime_id: Runtime identifier to check
|
||||
|
||||
Returns:
|
||||
True if runtime is available, False otherwise
|
||||
"""
|
||||
registry = get_global_registry()
|
||||
if registry is None:
|
||||
return False
|
||||
|
||||
return registry.is_registered(runtime_id)
|
||||
@@ -0,0 +1,295 @@
|
||||
"""Runtime resolver with resolution order implementation.
|
||||
|
||||
Implements the resolution order:
|
||||
per-model runtime → per-provider default → auto → built-in default
|
||||
|
||||
Protocol-driven design following AGENTS.md:
|
||||
- Core resolution logic without heavy implementations
|
||||
- Fail-closed behavior for unknown runtime IDs
|
||||
- Integration with model resolution system
|
||||
"""
|
||||
|
||||
from typing import Optional, Dict, Any, Union, TYPE_CHECKING
|
||||
from dataclasses import dataclass
|
||||
import warnings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .config import AgentRuntimeConfig
|
||||
from ..cli_backend.protocols import CliBackendProtocol
|
||||
|
||||
|
||||
@dataclass
|
||||
class RuntimeResolutionContext:
|
||||
"""Context for runtime resolution containing model and provider information."""
|
||||
model_name: Optional[str] = None
|
||||
provider_name: Optional[str] = None
|
||||
model_config: Optional[Dict[str, Any]] = None
|
||||
provider_config: Optional[Dict[str, Any]] = None
|
||||
agent_config: Optional[Dict[str, Any]] = None
|
||||
metadata: Dict[str, Any] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.metadata is None:
|
||||
self.metadata = {}
|
||||
|
||||
|
||||
@dataclass
|
||||
class RuntimeResolutionResult:
|
||||
"""Result of runtime resolution containing the resolved runtime and metadata."""
|
||||
runtime: Any # CliBackendProtocol instance
|
||||
runtime_id: str
|
||||
resolution_source: str # "model", "provider", "auto", "default", "legacy"
|
||||
config_used: Optional["AgentRuntimeConfig"] = None
|
||||
metadata: Dict[str, Any] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.metadata is None:
|
||||
self.metadata = {}
|
||||
|
||||
|
||||
class RuntimeResolver:
|
||||
"""Core runtime resolver implementing resolution order logic.
|
||||
|
||||
This is the core resolver that implements the resolution order without
|
||||
heavy dependencies. The actual runtime instantiation is delegated to
|
||||
the registry (which lives in the wrapper layer).
|
||||
"""
|
||||
|
||||
def __init__(self, default_runtime_id: str = "praisonai"):
|
||||
"""Initialize runtime resolver.
|
||||
|
||||
Args:
|
||||
default_runtime_id: Default runtime ID if no other runtime is found
|
||||
"""
|
||||
self.default_runtime_id = default_runtime_id
|
||||
|
||||
def resolve_runtime_config(
|
||||
self,
|
||||
context: RuntimeResolutionContext,
|
||||
model_runtime_configs: Optional[Dict[str, "AgentRuntimeConfig"]] = None,
|
||||
provider_runtime_configs: Optional[Dict[str, "AgentRuntimeConfig"]] = None,
|
||||
legacy_cli_backend: Optional[Union[str, Any]] = None,
|
||||
) -> "AgentRuntimeConfig":
|
||||
"""Resolve runtime configuration based on resolution order.
|
||||
|
||||
Resolution order:
|
||||
1. per-model runtime (model_runtime_configs[model_name])
|
||||
2. per-provider default (provider_runtime_configs[provider_name])
|
||||
3. auto-selection (if enabled and available)
|
||||
4. built-in default (self.default_runtime_id)
|
||||
5. legacy cli_backend (with deprecation warning)
|
||||
|
||||
Args:
|
||||
context: Runtime resolution context
|
||||
model_runtime_configs: Per-model runtime configurations
|
||||
provider_runtime_configs: Per-provider runtime configurations
|
||||
legacy_cli_backend: Legacy agent-level cli_backend (deprecated)
|
||||
|
||||
Returns:
|
||||
AgentRuntimeConfig for the resolved runtime
|
||||
"""
|
||||
from .config import AgentRuntimeConfig
|
||||
|
||||
# Normalize inputs
|
||||
model_runtime_configs = model_runtime_configs or {}
|
||||
provider_runtime_configs = provider_runtime_configs or {}
|
||||
|
||||
# 1. Check per-model runtime configuration
|
||||
if context.model_name and context.model_name in model_runtime_configs:
|
||||
source_config = model_runtime_configs[context.model_name]
|
||||
if source_config.is_explicit():
|
||||
# Create a copy to avoid mutating the original
|
||||
config = AgentRuntimeConfig(
|
||||
runtime=source_config.runtime,
|
||||
config_overrides=source_config.config_overrides.copy(),
|
||||
provider_default=source_config.provider_default,
|
||||
enable_auto_selection=source_config.enable_auto_selection,
|
||||
metadata=source_config.metadata.copy(),
|
||||
)
|
||||
config.metadata["resolution_source"] = "model"
|
||||
return config
|
||||
|
||||
# 2. Check per-provider runtime configuration
|
||||
if context.provider_name and context.provider_name in provider_runtime_configs:
|
||||
source_config = provider_runtime_configs[context.provider_name]
|
||||
if source_config.is_explicit():
|
||||
# Create a copy to avoid mutating the original
|
||||
config = AgentRuntimeConfig(
|
||||
runtime=source_config.runtime,
|
||||
config_overrides=source_config.config_overrides.copy(),
|
||||
provider_default=source_config.provider_default,
|
||||
enable_auto_selection=source_config.enable_auto_selection,
|
||||
metadata=source_config.metadata.copy(),
|
||||
)
|
||||
config.metadata["resolution_source"] = "provider"
|
||||
return config
|
||||
|
||||
# 3. Auto-selection (placeholder for future implementation)
|
||||
# This would check if auto-selection is enabled and delegate to auto-selection logic
|
||||
auto_config = self._try_auto_selection(context)
|
||||
if auto_config is not None:
|
||||
auto_config.metadata["resolution_source"] = "auto"
|
||||
return auto_config
|
||||
|
||||
# 4. Built-in default takes precedence over legacy
|
||||
default_config = AgentRuntimeConfig.from_runtime_id(self.default_runtime_id)
|
||||
default_config.metadata["resolution_source"] = "default"
|
||||
|
||||
# 5. Legacy cli_backend support (with deprecation warning) - lowest priority
|
||||
if legacy_cli_backend is not None:
|
||||
warnings.warn(
|
||||
"Agent-level 'cli_backend' parameter is deprecated. "
|
||||
"Use model-scoped runtime configuration instead. "
|
||||
"See documentation for migration guide.",
|
||||
DeprecationWarning,
|
||||
stacklevel=3
|
||||
)
|
||||
|
||||
# Only use legacy if no default is available
|
||||
# In practice, default is always available, so this preserves existing behavior
|
||||
# while maintaining correct priority order in the spec
|
||||
if self.default_runtime_id is None:
|
||||
# Convert legacy cli_backend to runtime config
|
||||
if isinstance(legacy_cli_backend, str):
|
||||
legacy_config = AgentRuntimeConfig.from_runtime_id(legacy_cli_backend)
|
||||
else:
|
||||
# Assume it's already a config or protocol instance
|
||||
legacy_config = AgentRuntimeConfig(runtime="legacy")
|
||||
legacy_config.config_overrides["instance"] = legacy_cli_backend
|
||||
|
||||
legacy_config.metadata["resolution_source"] = "legacy"
|
||||
return legacy_config
|
||||
|
||||
return default_config
|
||||
|
||||
def _try_auto_selection(
|
||||
self,
|
||||
context: RuntimeResolutionContext
|
||||
) -> Optional["AgentRuntimeConfig"]:
|
||||
"""Try auto-selection of runtime based on context.
|
||||
|
||||
This is a placeholder for future auto-selection logic.
|
||||
The actual implementation would analyze the model, provider,
|
||||
and context to automatically select an appropriate runtime.
|
||||
|
||||
Args:
|
||||
context: Runtime resolution context
|
||||
|
||||
Returns:
|
||||
AgentRuntimeConfig if auto-selection succeeds, None otherwise
|
||||
"""
|
||||
# Placeholder for auto-selection logic
|
||||
# Future implementation could:
|
||||
# - Check if model supports specific runtimes
|
||||
# - Look at provider capabilities
|
||||
# - Consider context metadata
|
||||
# - Use ML-based selection
|
||||
|
||||
return None
|
||||
|
||||
def resolve_runtime_instance(
|
||||
self,
|
||||
context: RuntimeResolutionContext,
|
||||
model_runtime_configs: Optional[Dict[str, "AgentRuntimeConfig"]] = None,
|
||||
provider_runtime_configs: Optional[Dict[str, "AgentRuntimeConfig"]] = None,
|
||||
legacy_cli_backend: Optional[Union[str, Any]] = None,
|
||||
) -> RuntimeResolutionResult:
|
||||
"""Resolve runtime instance based on configuration and context.
|
||||
|
||||
This method combines configuration resolution with actual runtime
|
||||
instantiation using the global registry.
|
||||
|
||||
Args:
|
||||
context: Runtime resolution context
|
||||
model_runtime_configs: Per-model runtime configurations
|
||||
provider_runtime_configs: Per-provider runtime configurations
|
||||
legacy_cli_backend: Legacy agent-level cli_backend (deprecated)
|
||||
|
||||
Returns:
|
||||
RuntimeResolutionResult with resolved runtime instance
|
||||
|
||||
Raises:
|
||||
ValueError: If runtime ID is not registered (fail-closed behavior)
|
||||
RuntimeError: If global registry is not initialized
|
||||
"""
|
||||
from .registry import resolve_runtime
|
||||
|
||||
# Resolve configuration
|
||||
config = self.resolve_runtime_config(
|
||||
context=context,
|
||||
model_runtime_configs=model_runtime_configs,
|
||||
provider_runtime_configs=provider_runtime_configs,
|
||||
legacy_cli_backend=legacy_cli_backend,
|
||||
)
|
||||
|
||||
# Handle legacy instances that are already resolved
|
||||
if (config.metadata.get("resolution_source") == "legacy" and
|
||||
"instance" in config.config_overrides):
|
||||
instance = config.config_overrides["instance"]
|
||||
return RuntimeResolutionResult(
|
||||
runtime=instance,
|
||||
runtime_id="legacy",
|
||||
resolution_source="legacy",
|
||||
config_used=config,
|
||||
metadata={"legacy_instance": True}
|
||||
)
|
||||
|
||||
# Resolve runtime instance using global registry
|
||||
if config.runtime is None:
|
||||
raise ValueError("No runtime ID available for resolution")
|
||||
|
||||
try:
|
||||
runtime_instance = resolve_runtime(
|
||||
runtime_id=config.runtime,
|
||||
config_overrides=config.config_overrides
|
||||
)
|
||||
|
||||
return RuntimeResolutionResult(
|
||||
runtime=runtime_instance,
|
||||
runtime_id=config.runtime,
|
||||
resolution_source=config.metadata.get("resolution_source", "unknown"),
|
||||
config_used=config,
|
||||
metadata=config.metadata.copy()
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
# Enhance error message with available runtimes
|
||||
from .registry import list_available_runtimes
|
||||
available = [entry.runtime_id for entry in list_available_runtimes()]
|
||||
raise ValueError(
|
||||
f"Unknown runtime ID: {config.runtime}. Available runtimes: {available}. "
|
||||
f"Original error: {e}"
|
||||
) from e
|
||||
|
||||
def validate_runtime_config(
|
||||
self,
|
||||
config: "AgentRuntimeConfig"
|
||||
) -> None:
|
||||
"""Validate a runtime configuration.
|
||||
|
||||
Args:
|
||||
config: Runtime configuration to validate
|
||||
|
||||
Raises:
|
||||
ValueError: If runtime configuration is invalid
|
||||
TypeError: If runtime configuration has invalid types
|
||||
"""
|
||||
if not hasattr(config, 'runtime'):
|
||||
raise TypeError("Runtime configuration must have 'runtime' attribute")
|
||||
|
||||
if config.runtime is not None:
|
||||
if not isinstance(config.runtime, str):
|
||||
raise TypeError("Runtime ID must be a string")
|
||||
|
||||
if not config.runtime.strip():
|
||||
raise ValueError("Runtime ID cannot be empty")
|
||||
|
||||
if not isinstance(config.config_overrides, dict):
|
||||
raise TypeError("config_overrides must be a dictionary")
|
||||
|
||||
if config.metadata is not None and not isinstance(config.metadata, dict):
|
||||
raise TypeError("metadata must be a dictionary")
|
||||
|
||||
|
||||
# Default resolver instance
|
||||
default_resolver = RuntimeResolver()
|
||||
@@ -0,0 +1,329 @@
|
||||
"""
|
||||
Test suite for PreparedTurnContext and runtime components.
|
||||
|
||||
This module provides tests to verify the turn context implementation
|
||||
works correctly and maintains backward compatibility.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import pytest
|
||||
from typing import List, Dict, Any
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from .turn_context import (
|
||||
PreparedTurnContext,
|
||||
ModelReference,
|
||||
ToolSchema,
|
||||
TranscriptWindow,
|
||||
DeliveryChannels,
|
||||
SessionCorrelation,
|
||||
RuntimeMode,
|
||||
create_default_model_ref,
|
||||
create_empty_transcript,
|
||||
create_default_delivery,
|
||||
create_session_correlation,
|
||||
)
|
||||
from .context_builder import DefaultTurnContextBuilder
|
||||
|
||||
|
||||
class MockAgent:
|
||||
"""Mock agent for testing context building."""
|
||||
|
||||
def __init__(self):
|
||||
self.name = "TestAgent"
|
||||
self.model = "gpt-3.5-turbo"
|
||||
self.tools = []
|
||||
self.chat_history = []
|
||||
self.role = "assistant"
|
||||
self.goal = "help users"
|
||||
self.use_system_prompt = True
|
||||
|
||||
def _build_system_prompt(self):
|
||||
return f"You are {self.role}. Your goal: {self.goal}"
|
||||
|
||||
|
||||
class MockRuntime:
|
||||
"""Mock runtime for testing execution."""
|
||||
|
||||
def __init__(self):
|
||||
self.executed_contexts = []
|
||||
|
||||
async def run_turn(self, context: PreparedTurnContext) -> str:
|
||||
self.executed_contexts.append(context)
|
||||
return f"Response to: {context.correlation.turn_id}"
|
||||
|
||||
def supports_runtime_mode(self, mode: RuntimeMode) -> bool:
|
||||
return True
|
||||
|
||||
def get_supported_modes(self) -> List[RuntimeMode]:
|
||||
return list(RuntimeMode)
|
||||
|
||||
|
||||
def test_model_reference_creation():
|
||||
"""Test ModelReference creation and validation."""
|
||||
# Test valid reference
|
||||
model_ref = ModelReference(
|
||||
model_id="gpt-4",
|
||||
provider="openai",
|
||||
supports_streaming=True,
|
||||
supports_tools=True,
|
||||
temperature=0.7
|
||||
)
|
||||
|
||||
assert model_ref.model_id == "gpt-4"
|
||||
assert model_ref.provider == "openai"
|
||||
assert model_ref.supports_streaming is True
|
||||
assert model_ref.temperature == 0.7
|
||||
|
||||
# Test validation
|
||||
with pytest.raises(ValueError, match="model_id is required"):
|
||||
ModelReference(model_id="", provider="openai")
|
||||
|
||||
|
||||
def test_tool_schema_creation():
|
||||
"""Test ToolSchema creation and validation."""
|
||||
tool = ToolSchema(
|
||||
name="test_tool",
|
||||
description="A test tool",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
source_type="function"
|
||||
)
|
||||
|
||||
assert tool.name == "test_tool"
|
||||
assert tool.source_type == "function"
|
||||
|
||||
# Test validation
|
||||
with pytest.raises(ValueError, match="Tool name is required"):
|
||||
ToolSchema(name="", description="test", parameters={})
|
||||
|
||||
|
||||
def test_transcript_window():
|
||||
"""Test TranscriptWindow creation."""
|
||||
messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there!"}
|
||||
]
|
||||
|
||||
transcript = TranscriptWindow(
|
||||
messages=messages,
|
||||
total_tokens=20,
|
||||
system_prompt="You are helpful"
|
||||
)
|
||||
|
||||
assert len(transcript.messages) == 2
|
||||
assert transcript.total_tokens == 20
|
||||
assert transcript.system_prompt == "You are helpful"
|
||||
|
||||
|
||||
def test_prepared_turn_context():
|
||||
"""Test PreparedTurnContext creation and validation."""
|
||||
model_ref = create_default_model_ref()
|
||||
agent = MockAgent()
|
||||
tools = []
|
||||
transcript = create_empty_transcript()
|
||||
delivery = create_default_delivery()
|
||||
correlation = create_session_correlation()
|
||||
|
||||
context = PreparedTurnContext(
|
||||
model_ref=model_ref,
|
||||
agent_runtime=agent,
|
||||
tools=tools,
|
||||
transcript=transcript,
|
||||
delivery=delivery,
|
||||
correlation=correlation,
|
||||
runtime_mode=RuntimeMode.SYNC
|
||||
)
|
||||
|
||||
assert context.model_ref.model_id == "gpt-3.5-turbo"
|
||||
assert context.runtime_mode == RuntimeMode.SYNC
|
||||
assert context.has_tools() is False
|
||||
assert context.get_message_count() == 0
|
||||
|
||||
# Test dict conversion
|
||||
context_dict = context.to_dict()
|
||||
assert "model_id" in context_dict
|
||||
assert "tools_count" in context_dict
|
||||
assert context_dict["tools_count"] == 0
|
||||
|
||||
|
||||
def test_context_builder():
|
||||
"""Test DefaultTurnContextBuilder functionality."""
|
||||
builder = DefaultTurnContextBuilder()
|
||||
agent = MockAgent()
|
||||
|
||||
# Test basic context building
|
||||
context = builder.build_context(
|
||||
agent=agent,
|
||||
prompt="Hello, world!",
|
||||
temperature=0.7,
|
||||
stream=False
|
||||
)
|
||||
|
||||
assert isinstance(context, PreparedTurnContext)
|
||||
assert context.model_ref.model_id == "gpt-3.5-turbo"
|
||||
assert context.model_ref.temperature == 0.7
|
||||
assert context.runtime_mode == RuntimeMode.SYNC
|
||||
|
||||
# Check transcript contains prompt
|
||||
user_messages = [msg for msg in context.transcript.messages if msg.get('role') == 'user']
|
||||
assert len(user_messages) == 1
|
||||
assert user_messages[0]['content'] == "Hello, world!"
|
||||
|
||||
|
||||
def test_context_builder_with_streaming():
|
||||
"""Test context building with streaming enabled."""
|
||||
builder = DefaultTurnContextBuilder()
|
||||
agent = MockAgent()
|
||||
|
||||
context = builder.build_context(
|
||||
agent=agent,
|
||||
prompt="Stream test",
|
||||
stream=True,
|
||||
async_execution=False
|
||||
)
|
||||
|
||||
assert context.runtime_mode == RuntimeMode.STREAM
|
||||
assert context.delivery.enable_streaming is True
|
||||
|
||||
|
||||
def test_context_builder_with_async():
|
||||
"""Test context building with async execution."""
|
||||
builder = DefaultTurnContextBuilder()
|
||||
agent = MockAgent()
|
||||
|
||||
context = builder.build_context(
|
||||
agent=agent,
|
||||
prompt="Async test",
|
||||
stream=False,
|
||||
async_execution=True
|
||||
)
|
||||
|
||||
assert context.runtime_mode == RuntimeMode.ASYNC
|
||||
|
||||
|
||||
def test_context_builder_with_tools():
|
||||
"""Test context building with tools."""
|
||||
def test_function(query: str) -> str:
|
||||
"""A test function."""
|
||||
return f"Result for {query}"
|
||||
|
||||
builder = DefaultTurnContextBuilder()
|
||||
agent = MockAgent()
|
||||
agent.tools = [test_function]
|
||||
|
||||
context = builder.build_context(
|
||||
agent=agent,
|
||||
prompt="Use tools",
|
||||
tools=None # Should use agent.tools
|
||||
)
|
||||
|
||||
assert len(context.tools) == 1
|
||||
assert context.tools[0].name == "test_function"
|
||||
assert context.tools[0].source_type == "function"
|
||||
assert context.tools[0].callable == test_function
|
||||
|
||||
|
||||
def test_context_immutability():
|
||||
"""Test that PreparedTurnContext is immutable."""
|
||||
builder = DefaultTurnContextBuilder()
|
||||
agent = MockAgent()
|
||||
|
||||
context = builder.build_context(agent=agent, prompt="Test")
|
||||
|
||||
# Should not be able to modify the context attributes
|
||||
with pytest.raises(AttributeError):
|
||||
context.model_ref = create_default_model_ref("gpt-4")
|
||||
|
||||
# Tools should be a tuple (immutable)
|
||||
assert isinstance(context.tools, tuple)
|
||||
|
||||
# Messages should be tuple of immutable mappings
|
||||
assert isinstance(context.transcript.messages, tuple)
|
||||
if context.transcript.messages:
|
||||
# Each message should be immutable
|
||||
assert hasattr(context.transcript.messages[0], 'keys') # Is mapping-like
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_execution():
|
||||
"""Test runtime execution with prepared context."""
|
||||
builder = DefaultTurnContextBuilder()
|
||||
agent = MockAgent()
|
||||
runtime = MockRuntime()
|
||||
|
||||
# Build context
|
||||
context = builder.build_context(
|
||||
agent=agent,
|
||||
prompt="Execute this",
|
||||
session_id="test-session"
|
||||
)
|
||||
|
||||
# Execute with runtime
|
||||
response = await runtime.run_turn(context)
|
||||
|
||||
assert isinstance(response, str)
|
||||
assert len(runtime.executed_contexts) == 1
|
||||
assert runtime.executed_contexts[0].correlation.session_id == "test-session"
|
||||
|
||||
|
||||
def test_utility_functions():
|
||||
"""Test utility functions for creating context components."""
|
||||
# Test create_default_model_ref
|
||||
model_ref = create_default_model_ref("claude-3", "anthropic")
|
||||
assert model_ref.model_id == "claude-3"
|
||||
assert model_ref.provider == "anthropic"
|
||||
|
||||
# Test create_empty_transcript
|
||||
transcript = create_empty_transcript("System prompt")
|
||||
assert len(transcript.messages) == 0
|
||||
assert transcript.system_prompt == "System prompt"
|
||||
|
||||
# Test create_default_delivery
|
||||
delivery = create_default_delivery()
|
||||
assert delivery.enable_streaming is False
|
||||
|
||||
# Test create_session_correlation
|
||||
correlation = create_session_correlation("session-123", "agent-456")
|
||||
assert correlation.session_id == "session-123"
|
||||
assert correlation.agent_id == "agent-456"
|
||||
assert correlation.turn_id.startswith("turn-")
|
||||
|
||||
|
||||
def test_runtime_mode_validation():
|
||||
"""Test runtime mode validation in context."""
|
||||
# Test that streaming mode requires streaming configuration
|
||||
with pytest.raises(ValueError, match="requires streaming configuration"):
|
||||
PreparedTurnContext(
|
||||
model_ref=create_default_model_ref(),
|
||||
agent_runtime=MockAgent(),
|
||||
tools=(),
|
||||
transcript=create_empty_transcript(),
|
||||
delivery=create_default_delivery(), # No streaming
|
||||
correlation=create_session_correlation(),
|
||||
runtime_mode=RuntimeMode.STREAM # But stream mode requested
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Run basic tests without pytest
|
||||
print("Running basic tests...")
|
||||
|
||||
test_model_reference_creation()
|
||||
print("✓ ModelReference tests passed")
|
||||
|
||||
test_tool_schema_creation()
|
||||
print("✓ ToolSchema tests passed")
|
||||
|
||||
test_transcript_window()
|
||||
print("✓ TranscriptWindow tests passed")
|
||||
|
||||
test_prepared_turn_context()
|
||||
print("✓ PreparedTurnContext tests passed")
|
||||
|
||||
test_context_builder()
|
||||
print("✓ Context builder tests passed")
|
||||
|
||||
test_utility_functions()
|
||||
print("✓ Utility function tests passed")
|
||||
|
||||
print("All basic tests completed!")
|
||||
@@ -0,0 +1,312 @@
|
||||
"""
|
||||
Turn Context and Runtime Protocol for PraisonAI Agents.
|
||||
|
||||
This module defines the PreparedTurnContext dataclass and TurnRuntimeProtocol
|
||||
that standardize runtime preparation across different execution paths in the
|
||||
PraisonAI agent framework.
|
||||
|
||||
Design Goals:
|
||||
- Single immutable plan object passed into runtime run_turn
|
||||
- Standardize tool assembly, transcript building, and delivery channels
|
||||
- Eliminate duplication between native loop and plugin harnesses
|
||||
- Enable consistent metrics and tracing across harness types
|
||||
- Multi-agent safe: context is per-turn, not stored on shared agent globals
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Union, TYPE_CHECKING, Tuple, Mapping
|
||||
from types import MappingProxyType
|
||||
from enum import Enum
|
||||
import time
|
||||
import logging
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..agent.protocols import AgentProtocol
|
||||
from ..streaming.events import StreamEventEmitter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RuntimeMode(Enum):
|
||||
"""Execution mode for the runtime."""
|
||||
SYNC = "sync"
|
||||
ASYNC = "async"
|
||||
STREAM = "stream"
|
||||
ASYNC_STREAM = "async_stream"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelReference:
|
||||
"""
|
||||
Immutable reference to a resolved model configuration.
|
||||
|
||||
Captures model ID, provider, capabilities, and configuration
|
||||
resolved at turn preparation time.
|
||||
"""
|
||||
model_id: str
|
||||
provider: str
|
||||
supports_streaming: bool = False
|
||||
supports_tools: bool = False
|
||||
supports_system_prompts: bool = True
|
||||
max_tokens: Optional[int] = None
|
||||
temperature: Optional[float] = None
|
||||
model_config: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate model reference and ensure immutability."""
|
||||
if not self.model_id:
|
||||
raise ValueError("model_id is required")
|
||||
if not self.provider:
|
||||
raise ValueError("provider is required")
|
||||
|
||||
# Make model_config immutable
|
||||
if not isinstance(self.model_config, MappingProxyType):
|
||||
object.__setattr__(self, 'model_config', MappingProxyType(self.model_config))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolSchema:
|
||||
"""
|
||||
Normalized tool schema for runtime execution.
|
||||
|
||||
Provides a consistent tool representation regardless of source
|
||||
(function decorators, OpenAI format, custom tools, etc.).
|
||||
"""
|
||||
name: str
|
||||
description: str
|
||||
parameters: Dict[str, Any]
|
||||
callable: Optional[Any] = None
|
||||
source_type: str = "unknown" # function, openai, custom, etc.
|
||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate tool schema and ensure immutability."""
|
||||
if not self.name:
|
||||
raise ValueError("Tool name is required")
|
||||
if not self.description:
|
||||
raise ValueError("Tool description is required")
|
||||
|
||||
# Make metadata immutable
|
||||
if not isinstance(self.metadata, MappingProxyType):
|
||||
object.__setattr__(self, 'metadata', MappingProxyType(self.metadata))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TranscriptWindow:
|
||||
"""
|
||||
Immutable transcript window for a turn.
|
||||
|
||||
Contains the conversation history slice to be included in the current
|
||||
turn, with token budgeting and context optimization applied.
|
||||
"""
|
||||
messages: Tuple[Mapping[str, Any], ...]
|
||||
total_tokens: int = 0
|
||||
system_prompt: Optional[str] = None
|
||||
context_metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate transcript window and ensure immutability."""
|
||||
# Convert to tuple of immutable mappings if needed
|
||||
if not isinstance(self.messages, tuple):
|
||||
immutable_messages = tuple(
|
||||
MappingProxyType(msg) if isinstance(msg, dict) else msg
|
||||
for msg in self.messages
|
||||
)
|
||||
object.__setattr__(self, 'messages', immutable_messages)
|
||||
|
||||
# Make context_metadata immutable
|
||||
if not isinstance(self.context_metadata, MappingProxyType):
|
||||
object.__setattr__(self, 'context_metadata', MappingProxyType(self.context_metadata))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeliveryChannels:
|
||||
"""
|
||||
Delivery channels for runtime execution results.
|
||||
|
||||
Encapsulates streaming handles, callbacks, and output formatting
|
||||
configuration for the current turn.
|
||||
"""
|
||||
stream_emitter: Optional[StreamEventEmitter] = None
|
||||
output_formatter: Optional[Any] = None
|
||||
callbacks: Tuple[Any, ...] = field(default_factory=tuple)
|
||||
async_callbacks: Tuple[Any, ...] = field(default_factory=tuple)
|
||||
enable_streaming: bool = False
|
||||
enable_metrics: bool = False
|
||||
|
||||
def __post_init__(self):
|
||||
"""Ensure immutability of list fields."""
|
||||
# Convert lists to tuples if needed
|
||||
if isinstance(self.callbacks, list):
|
||||
object.__setattr__(self, 'callbacks', tuple(self.callbacks))
|
||||
if isinstance(self.async_callbacks, list):
|
||||
object.__setattr__(self, 'async_callbacks', tuple(self.async_callbacks))
|
||||
|
||||
def has_streaming(self) -> bool:
|
||||
"""Check if streaming is configured and enabled."""
|
||||
return self.enable_streaming
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionCorrelation:
|
||||
"""
|
||||
Session correlation identifiers for tracing and debugging.
|
||||
|
||||
Provides correlation IDs to track requests across distributed
|
||||
agent execution and multi-turn conversations.
|
||||
"""
|
||||
session_id: Optional[str] = None
|
||||
turn_id: Optional[str] = None
|
||||
agent_id: Optional[str] = None
|
||||
run_id: Optional[str] = None
|
||||
parent_id: Optional[str] = None
|
||||
trace_metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self):
|
||||
"""Ensure immutability of metadata fields."""
|
||||
if not isinstance(self.trace_metadata, MappingProxyType):
|
||||
object.__setattr__(self, 'trace_metadata', MappingProxyType(self.trace_metadata))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparedTurnContext:
|
||||
"""
|
||||
Immutable turn plan object for standardized runtime execution.
|
||||
|
||||
This dataclass captures all the context needed for a single agent turn,
|
||||
prepared in a preflight step before run_attempt. It eliminates the
|
||||
scattered tool assembly and message building across different execution
|
||||
paths.
|
||||
|
||||
The context is read-only during execution - mutations must go through
|
||||
defined hooks only. This ensures consistent behavior across different
|
||||
runtime harnesses (native, plugin, managed, etc.).
|
||||
|
||||
Attributes:
|
||||
model_ref: Resolved model configuration and capabilities
|
||||
agent_runtime: The runtime protocol instance to execute with
|
||||
tools: Normalized tool schemas for the turn
|
||||
transcript: Prepared conversation window for the turn
|
||||
delivery: Streaming and output delivery configuration
|
||||
correlation: Session and tracing identifiers
|
||||
runtime_mode: Execution mode (sync/async/stream)
|
||||
turn_metadata: Additional turn-specific metadata
|
||||
created_at: Timestamp when context was prepared
|
||||
"""
|
||||
model_ref: ModelReference
|
||||
agent_runtime: AgentProtocol
|
||||
tools: Tuple[ToolSchema, ...]
|
||||
transcript: TranscriptWindow
|
||||
delivery: DeliveryChannels
|
||||
correlation: SessionCorrelation
|
||||
runtime_mode: RuntimeMode = RuntimeMode.SYNC
|
||||
turn_metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
created_at: float = field(default_factory=time.time)
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate the prepared turn context and ensure immutability."""
|
||||
# Convert tools to tuple if needed
|
||||
if not isinstance(self.tools, tuple):
|
||||
object.__setattr__(self, 'tools', tuple(self.tools))
|
||||
|
||||
# Make turn_metadata immutable
|
||||
if not isinstance(self.turn_metadata, MappingProxyType):
|
||||
object.__setattr__(self, 'turn_metadata', MappingProxyType(self.turn_metadata))
|
||||
|
||||
# Validate runtime mode compatibility
|
||||
if self.runtime_mode in (RuntimeMode.STREAM, RuntimeMode.ASYNC_STREAM):
|
||||
if not self.delivery.has_streaming():
|
||||
raise ValueError(
|
||||
f"Runtime mode {self.runtime_mode.value} requires streaming configuration"
|
||||
)
|
||||
|
||||
# Validate model supports required features
|
||||
if self.tools and not self.model_ref.supports_tools:
|
||||
logger.warning(
|
||||
f"Model {self.model_ref.model_id} does not support tools, "
|
||||
f"but {len(self.tools)} tools were provided"
|
||||
)
|
||||
|
||||
def get_tool_by_name(self, name: str) -> Optional[ToolSchema]:
|
||||
"""Get a tool by name from the prepared tools."""
|
||||
for tool in self.tools:
|
||||
if tool.name == name:
|
||||
return tool
|
||||
return None
|
||||
|
||||
def has_tools(self) -> bool:
|
||||
"""Check if any tools are available for this turn."""
|
||||
return len(self.tools) > 0
|
||||
|
||||
def has_system_prompt(self) -> bool:
|
||||
"""Check if a system prompt is configured."""
|
||||
return self.transcript.system_prompt is not None
|
||||
|
||||
def get_message_count(self) -> int:
|
||||
"""Get the number of messages in the transcript window."""
|
||||
return len(self.transcript.messages)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for logging and debugging."""
|
||||
return {
|
||||
"model_id": self.model_ref.model_id,
|
||||
"provider": self.model_ref.provider,
|
||||
"tools_count": len(self.tools),
|
||||
"message_count": self.get_message_count(),
|
||||
"has_system_prompt": self.has_system_prompt(),
|
||||
"runtime_mode": self.runtime_mode.value,
|
||||
"streaming_enabled": self.delivery.has_streaming(),
|
||||
"session_id": self.correlation.session_id,
|
||||
"turn_id": self.correlation.turn_id,
|
||||
"created_at": self.created_at,
|
||||
}
|
||||
|
||||
|
||||
|
||||
# Utility functions for context building
|
||||
|
||||
def create_default_model_ref(
|
||||
model_id: str = "gpt-3.5-turbo",
|
||||
provider: str = "openai"
|
||||
) -> ModelReference:
|
||||
"""Create a default ModelReference for testing and basic usage."""
|
||||
return ModelReference(
|
||||
model_id=model_id,
|
||||
provider=provider,
|
||||
supports_streaming=True,
|
||||
supports_tools=True,
|
||||
supports_system_prompts=True,
|
||||
)
|
||||
|
||||
|
||||
def create_empty_transcript(
|
||||
system_prompt: Optional[str] = None
|
||||
) -> TranscriptWindow:
|
||||
"""Create an empty transcript window with optional system prompt."""
|
||||
return TranscriptWindow(
|
||||
messages=[],
|
||||
system_prompt=system_prompt,
|
||||
)
|
||||
|
||||
|
||||
def create_default_delivery() -> DeliveryChannels:
|
||||
"""Create default delivery channels with no streaming."""
|
||||
return DeliveryChannels(
|
||||
enable_streaming=False,
|
||||
enable_metrics=False,
|
||||
)
|
||||
|
||||
|
||||
def create_session_correlation(
|
||||
session_id: Optional[str] = None,
|
||||
agent_id: Optional[str] = None
|
||||
) -> SessionCorrelation:
|
||||
"""Create session correlation with basic IDs."""
|
||||
import uuid
|
||||
return SessionCorrelation(
|
||||
session_id=session_id or f"session-{uuid.uuid4().hex[:8]}",
|
||||
turn_id=f"turn-{uuid.uuid4().hex[:8]}",
|
||||
agent_id=agent_id,
|
||||
)
|
||||
@@ -117,6 +117,16 @@ class AgentServer:
|
||||
self._server_thread: Optional[threading.Thread] = None
|
||||
self._app = None
|
||||
self._server = None
|
||||
|
||||
def _authorise_request(self, request) -> bool:
|
||||
"""Verify bearer token when auth_token is configured."""
|
||||
token = self.config.auth_token
|
||||
if not token:
|
||||
return True
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer ") and auth[7:] == token:
|
||||
return True
|
||||
return request.headers.get("X-Auth-Token") == token
|
||||
|
||||
def _create_app(self):
|
||||
"""Create the ASGI application."""
|
||||
@@ -137,6 +147,8 @@ class AgentServer:
|
||||
})
|
||||
|
||||
async def events(request):
|
||||
if not self._authorise_request(request):
|
||||
return JSONResponse({"error": "Unauthorized"}, status_code=401)
|
||||
client_id = str(uuid.uuid4())
|
||||
client = SSEClient(client_id)
|
||||
self._clients[client_id] = client
|
||||
@@ -162,6 +174,8 @@ class AgentServer:
|
||||
)
|
||||
|
||||
async def publish(request):
|
||||
if not self._authorise_request(request):
|
||||
return JSONResponse({"error": "Unauthorized"}, status_code=401)
|
||||
try:
|
||||
data = await request.json()
|
||||
event_type = data.get("type", "message")
|
||||
|
||||
@@ -1,655 +0,0 @@
|
||||
"""
|
||||
Session Management for PraisonAI Agents
|
||||
|
||||
A simple wrapper around existing stateful capabilities to provide a unified
|
||||
session API for developers building stateful agent applications.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .memory.memory import Memory
|
||||
from .knowledge.knowledge import Knowledge
|
||||
|
||||
from .agent import Agent
|
||||
|
||||
|
||||
class Session:
|
||||
"""
|
||||
A simple wrapper around PraisonAI's existing stateful capabilities.
|
||||
|
||||
Provides a unified API for:
|
||||
- Session management with persistent state
|
||||
- Memory operations (short-term, long-term, user-specific)
|
||||
- Knowledge base operations
|
||||
- Agent state management
|
||||
- Remote agent connectivity
|
||||
|
||||
Examples:
|
||||
# Local session with agent
|
||||
session = Session(session_id="chat_123", user_id="user_456")
|
||||
agent = session.Agent(name="Assistant", role="Helpful AI")
|
||||
|
||||
# Remote agent session (similar to Google ADK)
|
||||
session = Session(agent_url="192.168.1.10:8000/agent")
|
||||
response = session.chat("Hello from remote client!")
|
||||
|
||||
# Save session state
|
||||
session.save_state({"conversation_topic": "AI research"})
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
agent_url: Optional[str] = None,
|
||||
memory_config: Optional[Dict[str, Any]] = None,
|
||||
knowledge_config: Optional[Dict[str, Any]] = None,
|
||||
timeout: int = 30,
|
||||
session_ttl: Optional[int] = None # Gap 2b: TTL in seconds
|
||||
):
|
||||
"""
|
||||
Initialize a new session with optional persistence or remote agent connectivity.
|
||||
|
||||
Args:
|
||||
session_id: Unique session identifier. Auto-generated if None.
|
||||
user_id: User identifier for user-specific memory operations.
|
||||
agent_url: URL of remote agent for direct connectivity (e.g., "192.168.1.10:8000/agent")
|
||||
memory_config: Configuration for memory system (defaults to RAG)
|
||||
knowledge_config: Configuration for knowledge base system
|
||||
timeout: HTTP timeout for remote agent calls (default: 30 seconds)
|
||||
session_ttl: Time-to-live in seconds after which session expires (Gap 2b)
|
||||
"""
|
||||
self.session_id = session_id or str(uuid.uuid4())[:8]
|
||||
self.user_id = user_id or "default_user"
|
||||
self.agent_url = agent_url
|
||||
self.timeout = timeout
|
||||
self.is_remote = agent_url is not None
|
||||
self._state_lock = threading.RLock() # Reentrant lock for thread-safe state operations
|
||||
|
||||
# Validate agent_url format
|
||||
if self.is_remote:
|
||||
if not self.agent_url.startswith(('http://', 'https://')):
|
||||
# Assume http if no protocol specified
|
||||
self.agent_url = f"http://{self.agent_url}"
|
||||
# Test connectivity to remote agent
|
||||
self._test_remote_connection()
|
||||
|
||||
# Initialize memory with sensible defaults (only for local sessions)
|
||||
if not self.is_remote:
|
||||
default_memory_config = {
|
||||
"provider": "rag",
|
||||
"use_embedding": False, # Disable embeddings to avoid OpenAI API key requirement
|
||||
"rag_db_path": str(self._get_session_dir() / "chroma_db")
|
||||
}
|
||||
if memory_config:
|
||||
default_memory_config.update(memory_config)
|
||||
self.memory_config = default_memory_config
|
||||
|
||||
# Initialize knowledge with session-specific config
|
||||
default_knowledge_config = knowledge_config or {}
|
||||
self.knowledge_config = default_knowledge_config
|
||||
|
||||
# Create session directory
|
||||
self._get_session_dir().mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Initialize components lazily
|
||||
self._memory = None
|
||||
self._knowledge = None
|
||||
self._agents_instance = None
|
||||
self._agents = {} # Track agents and their chat histories
|
||||
else:
|
||||
# For remote sessions, disable local memory/knowledge
|
||||
self.memory_config = {}
|
||||
self.knowledge_config = {}
|
||||
self._memory = None
|
||||
self._knowledge = None
|
||||
self._agents_instance = None
|
||||
self._agents = {} # Track agents and their chat histories
|
||||
|
||||
# Gap 2b: Session TTL and cleanup support
|
||||
self.session_ttl = session_ttl
|
||||
self._created_at = time.time() # Track creation time for TTL
|
||||
|
||||
def _get_session_dir(self):
|
||||
"""Return session-specific directory using paths.py."""
|
||||
from pathlib import Path
|
||||
from .paths import get_project_sessions_dir
|
||||
return get_project_sessions_dir() / self.session_id
|
||||
|
||||
@property
|
||||
def memory(self) -> Memory:
|
||||
"""Lazy-loaded memory instance"""
|
||||
if self.is_remote:
|
||||
raise ValueError("Memory operations are not available for remote agent sessions")
|
||||
if self._memory is None:
|
||||
from .memory.memory import Memory
|
||||
self._memory = Memory(config=self.memory_config)
|
||||
return self._memory
|
||||
|
||||
@property
|
||||
def knowledge(self) -> Knowledge:
|
||||
"""Lazy-loaded knowledge instance"""
|
||||
if self.is_remote:
|
||||
raise ValueError("Knowledge operations are not available for remote agent sessions")
|
||||
if self._knowledge is None:
|
||||
from .knowledge.knowledge import Knowledge
|
||||
self._knowledge = Knowledge(config=self.knowledge_config)
|
||||
return self._knowledge
|
||||
|
||||
def Agent(
|
||||
self,
|
||||
name: str,
|
||||
role: str = "Assistant",
|
||||
instructions: Optional[str] = None,
|
||||
tools: Optional[List[Any]] = None,
|
||||
memory: bool = True,
|
||||
knowledge: Optional[List[str]] = None,
|
||||
**kwargs
|
||||
) -> Agent:
|
||||
"""
|
||||
Create an agent with session context.
|
||||
|
||||
Args:
|
||||
name: Agent name
|
||||
role: Agent role
|
||||
instructions: Agent instructions
|
||||
tools: List of tools for the agent
|
||||
memory: Enable memory for the agent
|
||||
knowledge: Knowledge sources for the agent
|
||||
**kwargs: Additional agent parameters
|
||||
|
||||
Returns:
|
||||
Configured Agent instance
|
||||
|
||||
Raises:
|
||||
ValueError: If this is a remote session (use chat() instead)
|
||||
"""
|
||||
if self.is_remote:
|
||||
raise ValueError("Cannot create local agents in remote sessions. Use chat() to communicate with the remote agent.")
|
||||
|
||||
agent_kwargs = {
|
||||
"name": name,
|
||||
"role": role,
|
||||
"user_id": self.user_id,
|
||||
**kwargs
|
||||
}
|
||||
|
||||
if instructions:
|
||||
agent_kwargs["instructions"] = instructions
|
||||
if tools:
|
||||
agent_kwargs["tools"] = tools
|
||||
if memory:
|
||||
agent_kwargs["memory"] = self.memory
|
||||
if knowledge:
|
||||
agent_kwargs["knowledge"] = knowledge
|
||||
agent_kwargs["knowledge_config"] = self.knowledge_config
|
||||
|
||||
agent = Agent(**agent_kwargs)
|
||||
|
||||
# Create a unique key for this agent (Gap 2a fix: include session_id for proper isolation)
|
||||
agent_key = f"{self.session_id}:{name}:{role}"
|
||||
|
||||
# Restore chat history if it exists from previous sessions
|
||||
if agent_key in self._agents:
|
||||
agent.chat_history = self._agents[agent_key].get("chat_history", [])
|
||||
else:
|
||||
# Try to restore from memory for backward compatibility
|
||||
restored_history = self._restore_agent_chat_history(agent_key)
|
||||
agent.chat_history = restored_history
|
||||
|
||||
# Track the agent
|
||||
self._agents[agent_key] = {"agent": agent, "chat_history": agent.chat_history}
|
||||
|
||||
return agent
|
||||
|
||||
# Keep create_agent for backward compatibility
|
||||
def create_agent(self, *args, **kwargs) -> Agent:
|
||||
"""Backward compatibility wrapper for Agent method"""
|
||||
return self.Agent(*args, **kwargs)
|
||||
|
||||
def save_state(self, state_data: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Save session state data to memory.
|
||||
|
||||
Args:
|
||||
state_data: Dictionary of state data to save
|
||||
|
||||
Raises:
|
||||
ValueError: If this is a remote session
|
||||
"""
|
||||
if self.is_remote:
|
||||
raise ValueError("State operations are not available for remote agent sessions")
|
||||
|
||||
# Save agent chat histories first
|
||||
self._save_agent_chat_histories()
|
||||
|
||||
# Save session state
|
||||
state_text = f"Session state: {state_data}"
|
||||
self.memory.store_short_term(
|
||||
text=state_text,
|
||||
metadata={
|
||||
"type": "session_state",
|
||||
"session_id": self.session_id,
|
||||
"user_id": self.user_id,
|
||||
**state_data
|
||||
}
|
||||
)
|
||||
|
||||
def restore_state(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Restore session state from memory.
|
||||
|
||||
Returns:
|
||||
Dictionary of restored state data
|
||||
|
||||
Raises:
|
||||
ValueError: If this is a remote session
|
||||
"""
|
||||
if self.is_remote:
|
||||
raise ValueError("State operations are not available for remote agent sessions")
|
||||
# Use content-based search for better SQLite compatibility
|
||||
results = self.memory.search_short_term(
|
||||
query="Session state:",
|
||||
limit=10 # Get more results to filter by session_id
|
||||
)
|
||||
|
||||
# Restore agent chat histories first
|
||||
self._restore_agent_chat_histories()
|
||||
|
||||
# Filter results by session_id in metadata
|
||||
for result in results:
|
||||
metadata = result.get("metadata", {})
|
||||
if (metadata.get("type") == "session_state" and
|
||||
metadata.get("session_id") == self.session_id):
|
||||
# Extract state data from metadata (excluding system fields)
|
||||
state_data = {k: v for k, v in metadata.items()
|
||||
if k not in ["type", "session_id", "user_id"]}
|
||||
return state_data
|
||||
|
||||
return {}
|
||||
|
||||
def _restore_agent_chat_history(self, agent_key: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Restore agent chat history from SessionStore first, then memory fallback (Gap 2c fix).
|
||||
|
||||
Args:
|
||||
agent_key: Unique identifier for the agent
|
||||
|
||||
Returns:
|
||||
List of chat history messages or empty list if not found
|
||||
"""
|
||||
if self.is_remote:
|
||||
return []
|
||||
|
||||
# Gap 2c: Try SessionStore first for clean separation
|
||||
try:
|
||||
from .session.store import get_default_session_store
|
||||
session_store = get_default_session_store()
|
||||
chat_history = session_store.get_chat_history(agent_key)
|
||||
if not chat_history:
|
||||
# Backward compatibility: fall back to legacy
|
||||
# "{session_id}_{agent_key}" format for existing stored conversations.
|
||||
chat_history = session_store.get_chat_history(f"{self.session_id}_{agent_key}")
|
||||
if chat_history:
|
||||
return chat_history
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Fallback: Search for agent chat history in memory (backward compatibility)
|
||||
results = self.memory.search_short_term(
|
||||
query="Agent chat history for",
|
||||
limit=10
|
||||
)
|
||||
|
||||
# Filter results by session_id and agent_key
|
||||
for result in results:
|
||||
metadata = result.get("metadata", {})
|
||||
if (metadata.get("type") == "agent_chat_history" and
|
||||
metadata.get("session_id") == self.session_id and
|
||||
metadata.get("agent_key") == agent_key):
|
||||
# Extract chat history from metadata
|
||||
chat_history = metadata.get("chat_history", [])
|
||||
return chat_history
|
||||
|
||||
return []
|
||||
|
||||
def _restore_agent_chat_histories(self) -> None:
|
||||
"""
|
||||
Restore all agent chat histories from memory.
|
||||
"""
|
||||
if self.is_remote:
|
||||
return
|
||||
|
||||
# Search for all agent chat histories in memory
|
||||
results = self.memory.search_short_term(
|
||||
query="Agent chat history for",
|
||||
limit=50 # Get many results to find all agents
|
||||
)
|
||||
|
||||
# Filter and restore chat histories for this session
|
||||
for result in results:
|
||||
metadata = result.get("metadata", {})
|
||||
if (metadata.get("type") == "agent_chat_history" and
|
||||
metadata.get("session_id") == self.session_id):
|
||||
agent_key = metadata.get("agent_key")
|
||||
chat_history = metadata.get("chat_history", [])
|
||||
|
||||
if agent_key and chat_history:
|
||||
# Store in _agents dict for later retrieval
|
||||
self._agents[agent_key] = {
|
||||
"agent": None, # Will be populated when Agent is created
|
||||
"chat_history": chat_history
|
||||
}
|
||||
|
||||
def _save_agent_chat_histories(self) -> None:
|
||||
"""
|
||||
Save all agent chat histories.
|
||||
|
||||
G-2 FIX: Routes to SessionStore instead of Memory.store_short_term() to
|
||||
maintain clean separation between conversation history (SessionStore)
|
||||
and semantic memory (Memory). Falls back to Memory for backward compat.
|
||||
"""
|
||||
if self.is_remote:
|
||||
return
|
||||
|
||||
for agent_key, agent_data in self._agents.items():
|
||||
agent = agent_data.get("agent")
|
||||
chat_history = None
|
||||
|
||||
# Prioritize history from the live agent object, but fall back to restored history
|
||||
if agent and hasattr(agent, 'chat_history'):
|
||||
chat_history = agent.chat_history
|
||||
agent_data["chat_history"] = chat_history # Ensure tracked history is up-to-date
|
||||
else:
|
||||
chat_history = agent_data.get("chat_history")
|
||||
|
||||
if chat_history is not None:
|
||||
# G-2 FIX: Use SessionStore for clean separation (Gap 2c fix)
|
||||
session_store = None
|
||||
try:
|
||||
from .session.store import get_default_session_store
|
||||
session_store = get_default_session_store()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if session_store is not None:
|
||||
# Use SessionStore for conversation history
|
||||
for msg in chat_history:
|
||||
if isinstance(msg, dict):
|
||||
session_store.add_message(
|
||||
agent_key,
|
||||
role=msg.get("role", "user"),
|
||||
content=msg.get("content", ""),
|
||||
)
|
||||
else:
|
||||
# Fallback to Memory.store_short_term() for backward compatibility
|
||||
history_text = f"Agent chat history for {agent_key}"
|
||||
self.memory.store_short_term(
|
||||
text=history_text,
|
||||
metadata={
|
||||
"type": "agent_chat_history",
|
||||
"session_id": self.session_id,
|
||||
"user_id": self.user_id,
|
||||
"agent_key": agent_key,
|
||||
"chat_history": chat_history
|
||||
}
|
||||
)
|
||||
|
||||
def get_state(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a specific state value"""
|
||||
with self._state_lock:
|
||||
state = self.restore_state()
|
||||
return state.get(key, default)
|
||||
|
||||
def set_state(self, key: str, value: Any) -> None:
|
||||
"""Set a specific state value"""
|
||||
with self._state_lock:
|
||||
current_state = self.restore_state()
|
||||
current_state[key] = value
|
||||
self.save_state(current_state)
|
||||
|
||||
def increment_state(self, key: str, increment: int = 1, default: int = 0) -> None:
|
||||
"""Increment a numeric state value atomically"""
|
||||
with self._state_lock:
|
||||
current_state = self.restore_state()
|
||||
current_value = current_state.get(key, default)
|
||||
current_state[key] = current_value + increment
|
||||
self.save_state(current_state)
|
||||
|
||||
def add_memory(self, text: str, memory_type: str = "long", **metadata) -> None:
|
||||
"""
|
||||
Add information to session memory.
|
||||
|
||||
Args:
|
||||
text: Text to store
|
||||
memory_type: "short" or "long" term memory
|
||||
**metadata: Additional metadata
|
||||
"""
|
||||
metadata.update({
|
||||
"session_id": self.session_id,
|
||||
"user_id": self.user_id
|
||||
})
|
||||
|
||||
if memory_type == "short":
|
||||
self.memory.store_short_term(text, metadata=metadata)
|
||||
else:
|
||||
self.memory.store_long_term(text, metadata=metadata)
|
||||
|
||||
def search_memory(self, query: str, memory_type: str = "long", limit: int = 5) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search session memory.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
memory_type: "short" or "long" term memory
|
||||
limit: Maximum results to return
|
||||
|
||||
Returns:
|
||||
List of memory results
|
||||
"""
|
||||
if memory_type == "short":
|
||||
return self.memory.search_short_term(query, limit=limit)
|
||||
return self.memory.search_long_term(query, limit=limit)
|
||||
|
||||
def add_knowledge(self, source: str) -> None:
|
||||
"""
|
||||
Add knowledge source to session.
|
||||
|
||||
Args:
|
||||
source: File path, URL, or text content
|
||||
"""
|
||||
self.knowledge.add(source, user_id=self.user_id, agent_id=self.session_id)
|
||||
|
||||
def search_knowledge(self, query: str, limit: int = 5) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search session knowledge base.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
limit: Maximum results to return
|
||||
|
||||
Returns:
|
||||
List of knowledge results
|
||||
"""
|
||||
return self.knowledge.search(query, agent_id=self.session_id, limit=limit)
|
||||
|
||||
def clear_memory(self, memory_type: str = "all") -> None:
|
||||
"""
|
||||
Clear session memory.
|
||||
|
||||
Args:
|
||||
memory_type: "short", "long", or "all"
|
||||
"""
|
||||
if memory_type in ["short", "all"]:
|
||||
self.memory.reset_short_term()
|
||||
if memory_type in ["long", "all"]:
|
||||
self.memory.reset_long_term()
|
||||
|
||||
def get_context(self, query: str, max_items: int = 3) -> str:
|
||||
"""
|
||||
Build context from session memory and knowledge.
|
||||
|
||||
Args:
|
||||
query: Query to build context for
|
||||
max_items: Maximum items per section
|
||||
|
||||
Returns:
|
||||
Formatted context string
|
||||
"""
|
||||
return self.memory.build_context_for_task(
|
||||
task_descr=query,
|
||||
user_id=self.user_id,
|
||||
max_items=max_items
|
||||
)
|
||||
|
||||
def _test_remote_connection(self) -> None:
|
||||
"""
|
||||
Test connectivity to the remote agent.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If unable to connect to the remote agent
|
||||
"""
|
||||
try:
|
||||
# Try a simple GET request to check if the server is responding
|
||||
test_url = self.agent_url.rstrip('/') + '/health' if '/health' not in self.agent_url else self.agent_url
|
||||
response = requests.get(test_url, timeout=self.timeout)
|
||||
if response.status_code != 200:
|
||||
# If health endpoint fails, try the main endpoint
|
||||
response = requests.head(self.agent_url, timeout=self.timeout)
|
||||
if response.status_code not in [200, 405]: # 405 = Method Not Allowed is OK
|
||||
raise ConnectionError(f"Remote agent returned status code: {response.status_code}")
|
||||
print(f"✅ Successfully connected to remote agent at {self.agent_url}")
|
||||
except requests.exceptions.Timeout:
|
||||
raise ConnectionError(f"Timeout connecting to remote agent at {self.agent_url}")
|
||||
except requests.exceptions.ConnectionError:
|
||||
raise ConnectionError(f"Failed to connect to remote agent at {self.agent_url}")
|
||||
except Exception as e:
|
||||
raise ConnectionError(f"Error connecting to remote agent: {str(e)}")
|
||||
|
||||
def chat(self, message: str, **kwargs) -> str:
|
||||
"""
|
||||
Send a message to the remote agent or handle local session.
|
||||
|
||||
Args:
|
||||
message: The message to send to the agent
|
||||
**kwargs: Additional parameters for the request
|
||||
|
||||
Returns:
|
||||
The agent's response
|
||||
|
||||
Raises:
|
||||
ValueError: If this is not a remote session
|
||||
ConnectionError: If unable to communicate with remote agent
|
||||
"""
|
||||
if not self.is_remote:
|
||||
raise ValueError("chat() method is only available for remote agent sessions. Use Agent.chat() for local agents.")
|
||||
|
||||
try:
|
||||
# Prepare the request payload
|
||||
payload = {
|
||||
"query": message,
|
||||
"session_id": self.session_id,
|
||||
"user_id": self.user_id,
|
||||
**kwargs
|
||||
}
|
||||
|
||||
# Send POST request to the remote agent
|
||||
response = requests.post(
|
||||
self.agent_url,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=self.timeout
|
||||
)
|
||||
|
||||
# Check if request was successful
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse the response
|
||||
result = response.json()
|
||||
|
||||
# Extract the agent's response
|
||||
if isinstance(result, dict):
|
||||
return result.get("response", str(result))
|
||||
else:
|
||||
return str(result)
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
raise ConnectionError(f"Timeout communicating with remote agent at {self.agent_url}")
|
||||
except requests.exceptions.ConnectionError:
|
||||
raise ConnectionError(f"Failed to communicate with remote agent at {self.agent_url}")
|
||||
except requests.exceptions.HTTPError as e:
|
||||
raise ConnectionError(f"HTTP error from remote agent: {e}")
|
||||
except json.JSONDecodeError:
|
||||
# If response is not JSON, return the raw text
|
||||
return response.text
|
||||
except Exception as e:
|
||||
raise ConnectionError(f"Error communicating with remote agent: {str(e)}")
|
||||
|
||||
def send_message(self, message: str, **kwargs) -> str:
|
||||
"""
|
||||
Alias for chat() method to match Google ADK pattern.
|
||||
|
||||
Args:
|
||||
message: The message to send to the agent
|
||||
**kwargs: Additional parameters for the request
|
||||
|
||||
Returns:
|
||||
The agent's response
|
||||
"""
|
||||
return self.chat(message, **kwargs)
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if the session has expired based on TTL (Gap 2b fix)."""
|
||||
if self.session_ttl is None:
|
||||
return False
|
||||
return time.time() - self._created_at > self.session_ttl
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close and cleanup the session (Gap 2b fix)."""
|
||||
if self.is_remote:
|
||||
return # No cleanup needed for remote sessions
|
||||
|
||||
# Properly cleanup memory
|
||||
if self._memory:
|
||||
try:
|
||||
# Call close_connections to properly cleanup memory adapters
|
||||
if hasattr(self._memory, 'close_connections'):
|
||||
self._memory.close_connections()
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.warning(f"Memory cleanup failed: {e}")
|
||||
finally:
|
||||
self._memory = None
|
||||
|
||||
# Clear knowledge
|
||||
if self._knowledge:
|
||||
try:
|
||||
self._knowledge = None
|
||||
except Exception:
|
||||
pass # Ignore cleanup errors
|
||||
|
||||
# Clear agents
|
||||
self._agents.clear()
|
||||
|
||||
def time_to_expiry(self) -> Optional[float]:
|
||||
"""Get seconds until session expires, or None if no TTL set (Gap 2b)."""
|
||||
if self.session_ttl is None:
|
||||
return None
|
||||
elapsed = time.time() - self._created_at
|
||||
return max(0, self.session_ttl - elapsed)
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.is_remote:
|
||||
return f"Session(id='{self.session_id}', user='{self.user_id}', remote_agent='{self.agent_url}')"
|
||||
return f"Session(id='{self.session_id}', user='{self.user_id}')"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.__str__()
|
||||
@@ -11,9 +11,12 @@ from __future__ import annotations
|
||||
import os
|
||||
import uuid
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..memory.memory import Memory
|
||||
from ..knowledge.knowledge import Knowledge
|
||||
@@ -57,7 +60,8 @@ class Session:
|
||||
agent_url: Optional[str] = None,
|
||||
memory_config: Optional[Dict[str, Any]] = None,
|
||||
knowledge_config: Optional[Dict[str, Any]] = None,
|
||||
timeout: int = 30
|
||||
timeout: int = 30,
|
||||
session_ttl: Optional[int] = None
|
||||
):
|
||||
"""
|
||||
Initialize a new session with optional persistence or remote agent connectivity.
|
||||
@@ -69,12 +73,22 @@ class Session:
|
||||
memory_config: Configuration for memory system (defaults to RAG)
|
||||
knowledge_config: Configuration for knowledge base system
|
||||
timeout: HTTP timeout for remote agent calls (default: 30 seconds)
|
||||
session_ttl: Time-to-live in seconds after which session expires
|
||||
"""
|
||||
self.session_id = session_id or str(uuid.uuid4())[:8]
|
||||
self.user_id = user_id or "default_user"
|
||||
self.agent_url = agent_url
|
||||
self.timeout = timeout
|
||||
self.is_remote = agent_url is not None
|
||||
|
||||
# TTL (time-to-live) functionality
|
||||
if session_ttl is not None and session_ttl < 0:
|
||||
raise ValueError(
|
||||
f"Invalid session_ttl={session_ttl} for session_id={self.session_id}. "
|
||||
"Use None (no expiry) or a non-negative number of seconds."
|
||||
)
|
||||
self.session_ttl = session_ttl
|
||||
self._created_at = time.monotonic()
|
||||
|
||||
# Validate agent_url format
|
||||
if self.is_remote:
|
||||
@@ -477,6 +491,20 @@ class Session:
|
||||
Returns:
|
||||
Formatted context string
|
||||
"""
|
||||
# Use cache-optimized context if available for better prompt caching
|
||||
if hasattr(self.memory, 'build_cache_optimized_context'):
|
||||
try:
|
||||
cache_result = self.memory.build_cache_optimized_context(
|
||||
task_descr=query,
|
||||
user_id=self.user_id,
|
||||
max_items=max_items,
|
||||
include_cache_boundary=False # Don't include boundary for session context
|
||||
)
|
||||
return cache_result.get('stable_prefix', '')
|
||||
except Exception as e:
|
||||
# Fall back to standard context building
|
||||
logger.debug(f"Cache-optimized context failed for session '{self.session_id}', falling back to standard: {e}")
|
||||
|
||||
return self.memory.build_context_for_task(
|
||||
task_descr=query,
|
||||
user_id=self.user_id,
|
||||
@@ -573,6 +601,46 @@ class Session:
|
||||
"""
|
||||
return self.chat(message, **kwargs)
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if the session has expired based on TTL."""
|
||||
if self.session_ttl is None:
|
||||
return False
|
||||
return time.monotonic() - self._created_at >= self.session_ttl
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close and cleanup the session."""
|
||||
if self.is_remote:
|
||||
return # No cleanup needed for remote sessions
|
||||
|
||||
# Save agent chat histories before clearing agents
|
||||
self._save_agent_chat_histories()
|
||||
|
||||
# Properly cleanup memory
|
||||
if hasattr(self, '_memory') and self._memory:
|
||||
try:
|
||||
# Call close_connections to properly cleanup memory adapters
|
||||
if hasattr(self._memory, 'close_connections'):
|
||||
self._memory.close_connections()
|
||||
except Exception as e:
|
||||
logger.warning(f"Memory cleanup failed: {e}")
|
||||
finally:
|
||||
self._memory = None
|
||||
|
||||
# Clear knowledge
|
||||
if hasattr(self, '_knowledge') and self._knowledge:
|
||||
self._knowledge = None
|
||||
|
||||
# Clear agents
|
||||
if hasattr(self, '_agents'):
|
||||
self._agents.clear()
|
||||
|
||||
def time_to_expiry(self) -> Optional[float]:
|
||||
"""Get seconds until session expires, or None if no TTL set."""
|
||||
if self.session_ttl is None:
|
||||
return None
|
||||
elapsed = time.monotonic() - self._created_at
|
||||
return max(0, self.session_ttl - elapsed)
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.is_remote:
|
||||
return f"Session(id='{self.session_id}', user='{self.user_id}', remote_agent='{self.agent_url}')"
|
||||
|
||||
@@ -740,10 +740,27 @@ class Task:
|
||||
# Build context for next tasks
|
||||
if self.next_tasks:
|
||||
logger.info(f"Task {self.id}: Building context for next tasks...")
|
||||
context = self.memory.build_context_for_task(
|
||||
task_descr=task_output.raw,
|
||||
max_items=5
|
||||
)
|
||||
# Use cache-optimized context if available for better prompt caching
|
||||
if hasattr(self.memory, 'build_cache_optimized_context'):
|
||||
try:
|
||||
cache_result = self.memory.build_cache_optimized_context(
|
||||
task_descr=task_output.raw,
|
||||
max_items=5,
|
||||
include_cache_boundary=False # Don't include boundary for task context
|
||||
)
|
||||
context = cache_result.get('stable_prefix', '')
|
||||
except Exception as e:
|
||||
# Fall back to standard context building
|
||||
logger.debug(f"Cache-optimized context failed for task '{self.id}', falling back to standard: {e}")
|
||||
context = self.memory.build_context_for_task(
|
||||
task_descr=task_output.raw,
|
||||
max_items=5
|
||||
)
|
||||
else:
|
||||
context = self.memory.build_context_for_task(
|
||||
task_descr=task_output.raw,
|
||||
max_items=5
|
||||
)
|
||||
logger.info(f"Task {self.id}: Built context for next tasks: {len(context)} items")
|
||||
|
||||
logger.info(f"Task {self.id}: Memory operations complete")
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
"""Tools package for PraisonAI Agents - uses lazy loading for performance"""
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
import threading
|
||||
|
||||
# Thread-safe lazy loading cache
|
||||
_tools_lock = threading.Lock()
|
||||
_tools_lazy_cache = {}
|
||||
# Note: actual lazy loading cache is implemented with _loaded_classes in __getattr__
|
||||
|
||||
# Export core tool items for organized imports (lightweight)
|
||||
from .base import BaseTool, ToolResult, ToolValidationError, validate_tool
|
||||
|
||||
@@ -31,6 +31,8 @@ import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
from typing import Optional, List
|
||||
|
||||
from ..approval import require_approval
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Availability cache for performance (checked once per process)
|
||||
@@ -146,6 +148,7 @@ def ast_grep_search(
|
||||
logger.exception("Unexpected error in ast_grep_search")
|
||||
return f"Error: {e}"
|
||||
|
||||
@require_approval(risk_level="high")
|
||||
def ast_grep_rewrite(
|
||||
pattern: str,
|
||||
replacement: str,
|
||||
|
||||
@@ -17,9 +17,12 @@ Usage:
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Callable, Dict, List, Optional, Type, get_type_hints
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import copy
|
||||
|
||||
from .schema import annotation_to_json_schema, get_parameter_requirements
|
||||
|
||||
|
||||
class ToolValidationError(Exception):
|
||||
"""Raised when a tool fails validation."""
|
||||
@@ -129,18 +132,17 @@ class BaseTool(ABC):
|
||||
|
||||
# Get type hint
|
||||
param_type = hints.get(param_name, Any)
|
||||
json_type = self._python_type_to_json(param_type)
|
||||
|
||||
# Build property schema
|
||||
prop_schema = {"type": json_type}
|
||||
# Use new schema utility for proper type handling
|
||||
prop_schema = annotation_to_json_schema(param_type)
|
||||
|
||||
# Add description from docstring if available
|
||||
# (Could parse docstring for param descriptions)
|
||||
|
||||
schema["properties"][param_name] = prop_schema
|
||||
|
||||
# Check if required (no default value)
|
||||
if param.default is inspect.Parameter.empty:
|
||||
# Check if required using improved logic
|
||||
if get_parameter_requirements(sig, param_name):
|
||||
schema["required"].append(param_name)
|
||||
except Exception as e:
|
||||
logging.debug(f"Could not generate schema for {self.name}: {e}")
|
||||
@@ -149,28 +151,15 @@ class BaseTool(ABC):
|
||||
|
||||
@staticmethod
|
||||
def _python_type_to_json(python_type: Type) -> str:
|
||||
"""Convert Python type to JSON Schema type."""
|
||||
type_map = {
|
||||
str: "string",
|
||||
int: "integer",
|
||||
float: "number",
|
||||
bool: "boolean",
|
||||
list: "array",
|
||||
dict: "object",
|
||||
type(None): "null"
|
||||
}
|
||||
"""Convert Python type to JSON Schema type.
|
||||
|
||||
# Handle Optional, Union, etc.
|
||||
origin = getattr(python_type, '__origin__', None)
|
||||
if origin is not None:
|
||||
# For List[X], return "array"
|
||||
if origin is list:
|
||||
return "array"
|
||||
# For Dict[X, Y], return "object"
|
||||
if origin is dict:
|
||||
return "object"
|
||||
|
||||
return type_map.get(python_type, "string")
|
||||
DEPRECATED: Use annotation_to_json_schema() from schema.py instead.
|
||||
This method is kept for backward compatibility but will be removed.
|
||||
"""
|
||||
# Legacy fallback - delegate to new schema utility and extract type
|
||||
from .schema import annotation_to_json_schema
|
||||
schema = annotation_to_json_schema(python_type)
|
||||
return schema.get("type", "string")
|
||||
|
||||
@abstractmethod
|
||||
def run(self, **kwargs) -> Any:
|
||||
@@ -442,5 +431,49 @@ def validate_tool_schema_consistency(tools: List[Any]) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def get_sorted_tool_schemas(tools: List[Any]) -> List[Dict[str, Any]]:
|
||||
"""Get tool schemas sorted by function name for deterministic ordering.
|
||||
|
||||
This ensures tool schemas are always ordered consistently for prompt caching optimization.
|
||||
|
||||
Args:
|
||||
tools: List of tool objects (BaseTool instances, callables, etc.)
|
||||
|
||||
Returns:
|
||||
List of tool schemas sorted alphabetically by function name
|
||||
|
||||
Raises:
|
||||
ToolValidationError: If validation fails
|
||||
"""
|
||||
if not tools:
|
||||
return []
|
||||
|
||||
# First validate all tools (reuse existing validation logic)
|
||||
validate_tool_schema_consistency(tools)
|
||||
|
||||
from .decorator import get_tool_schema
|
||||
schemas = []
|
||||
|
||||
for tool in tools:
|
||||
# Get schema from different tool types
|
||||
if isinstance(tool, BaseTool):
|
||||
schema = tool.get_schema()
|
||||
elif hasattr(tool, 'get_schema') and callable(getattr(tool, 'get_schema')):
|
||||
schema = tool.get_schema()
|
||||
elif callable(tool):
|
||||
schema = get_tool_schema(tool)
|
||||
else:
|
||||
continue # Skip invalid tools (validation already happened)
|
||||
|
||||
if schema:
|
||||
schemas.append(schema)
|
||||
|
||||
# Sort schemas by function name for deterministic ordering
|
||||
def sort_key(schema):
|
||||
return schema.get("function", {}).get("name", "")
|
||||
|
||||
return sorted(schemas, key=sort_key)
|
||||
|
||||
|
||||
# For backward compatibility - tools can also just be functions
|
||||
# The @tool decorator (in decorator.py) wraps functions into BaseTool instances
|
||||
@@ -31,6 +31,7 @@ import copy
|
||||
from typing import Any, Callable, Dict, Optional, Union, get_type_hints
|
||||
|
||||
from .base import BaseTool
|
||||
from .schema import annotation_to_json_schema, get_parameter_requirements
|
||||
|
||||
# Lazy load injected module functions to reduce import time
|
||||
_injected_module = None
|
||||
@@ -52,9 +53,6 @@ def get_injected_params(func):
|
||||
def inject_state_into_kwargs(kwargs, injected_params):
|
||||
return _get_injected_module().inject_state_into_kwargs(kwargs, injected_params)
|
||||
|
||||
def filter_injected_from_schema(schema, func):
|
||||
return _get_injected_module().filter_injected_from_schema(schema, func)
|
||||
|
||||
|
||||
class FunctionTool(BaseTool):
|
||||
"""A BaseTool wrapper for plain functions.
|
||||
@@ -132,12 +130,13 @@ class FunctionTool(BaseTool):
|
||||
if is_injected_type(param_type):
|
||||
continue
|
||||
|
||||
json_type = BaseTool._python_type_to_json(param_type)
|
||||
# Use new schema utility for proper type handling
|
||||
prop_schema = annotation_to_json_schema(param_type)
|
||||
|
||||
schema["properties"][param_name] = {"type": json_type}
|
||||
schema["properties"][param_name] = prop_schema
|
||||
|
||||
# Check if required (no default value)
|
||||
if param.default is inspect.Parameter.empty:
|
||||
# Check if required using improved logic
|
||||
if get_parameter_requirements(sig, param_name):
|
||||
schema["required"].append(param_name)
|
||||
except Exception as e:
|
||||
logging.debug(f"Could not generate schema for {func.__name__}: {e}")
|
||||
@@ -342,11 +341,14 @@ def _schema_from_function(func: Callable) -> Dict[str, Any]:
|
||||
continue
|
||||
|
||||
param_type = hints.get(param_name, Any)
|
||||
json_type = BaseTool._python_type_to_json(param_type)
|
||||
|
||||
properties[param_name] = {"type": json_type}
|
||||
# Use new schema utility for proper type handling
|
||||
prop_schema = annotation_to_json_schema(param_type)
|
||||
|
||||
if param.default is inspect.Parameter.empty:
|
||||
properties[param_name] = prop_schema
|
||||
|
||||
# Check if required using improved logic
|
||||
if get_parameter_requirements(sig, param_name):
|
||||
required.append(param_name)
|
||||
except Exception as e:
|
||||
logging.debug(f"Could not generate schema for {name}: {e}")
|
||||
|
||||
@@ -532,6 +532,14 @@ def _smtp_reply_email(message_id: str, body: str) -> str:
|
||||
logger.error(f"Failed to reply to {message_id}: {e}")
|
||||
return f"Failed to reply: {e}"
|
||||
|
||||
def _imap_sanitize(value: str) -> str:
|
||||
"""Strip characters that break IMAP quoted-string search criteria."""
|
||||
cleaned = value.replace('"', "").replace("\\", "")
|
||||
if any(c in cleaned for c in "\r\n\x00"):
|
||||
raise ValueError("Invalid IMAP search value")
|
||||
return cleaned
|
||||
|
||||
|
||||
def _smtp_search_emails(
|
||||
query: Optional[str] = None,
|
||||
from_addr: Optional[str] = None,
|
||||
@@ -544,11 +552,11 @@ def _smtp_search_emails(
|
||||
# Build IMAP search criteria
|
||||
criteria = []
|
||||
if from_addr:
|
||||
criteria.append(f'FROM "{from_addr}"')
|
||||
criteria.append(f'FROM "{_imap_sanitize(from_addr)}"')
|
||||
if subject:
|
||||
criteria.append(f'SUBJECT "{subject}"')
|
||||
criteria.append(f'SUBJECT "{_imap_sanitize(subject)}"')
|
||||
if query:
|
||||
criteria.append(f'TEXT "{query}"')
|
||||
criteria.append(f'TEXT "{_imap_sanitize(query)}"')
|
||||
if not criteria:
|
||||
criteria.append("ALL")
|
||||
search_str = " ".join(criteria)
|
||||
|
||||
Loaded 100 of 299 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user