mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-07 17:33:33 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a72e156c4d | ||
|
|
24385d6487 | ||
|
|
2f8162207f | ||
|
|
516d8b6d88 | ||
|
|
7eb98e895b | ||
|
|
ef79b7a056 | ||
|
|
3ea8376610 | ||
|
|
6360b24c06 | ||
|
|
b0d8f77752 | ||
|
|
179cab02db | ||
|
|
402d7ed9fc | ||
|
|
701e8e6a6a | ||
|
|
ef09341cc0 | ||
|
|
3a4d7e11c8 | ||
|
|
92644518c0 | ||
|
|
cd54574b20 | ||
|
|
68035af76d | ||
|
|
c989f2c31e | ||
|
|
a2c51a0760 | ||
|
|
7a601b5373 | ||
|
|
12f430e29c | ||
|
|
c3c78fbc7b | ||
|
|
bf120c8dca | ||
|
|
13d073b02a | ||
|
|
adcfa29941 | ||
|
|
7efd0075da | ||
|
|
9ec90361d6 | ||
|
|
2e5540a41b | ||
|
|
7fcd6af4c6 | ||
|
|
1fb71c46b4 | ||
|
|
ca46eb1297 | ||
|
|
422212105a | ||
|
|
b2611e5d61 | ||
|
|
78f0a71e71 | ||
|
|
e01f63dba2 | ||
|
|
99638a9129 | ||
|
|
a410bc88bd | ||
|
|
7bcdfafb4d | ||
|
|
a41a8a22d6 | ||
|
|
c18450bb35 | ||
|
|
9955d65f82 | ||
|
|
80c78071fc | ||
|
|
2e05f2c0f2 | ||
|
|
4985415e61 | ||
|
|
a29deefef7 | ||
|
|
7ffb4f87c5 | ||
|
|
057dab0ec0 | ||
|
|
89229c5c76 | ||
|
|
e20e426dd5 | ||
|
|
d49c559fce | ||
|
|
de84d22087 | ||
|
|
f3166b5bbd | ||
|
|
0d1cc32b23 | ||
|
|
e33b4b4b39 | ||
|
|
c9bbb5aee7 | ||
|
|
ed63d34ed7 | ||
|
|
d15c57d80c | ||
|
|
73eaacad35 | ||
|
|
0cec9fd1c3 | ||
|
|
7a7df247c8 | ||
|
|
cb96d03b58 | ||
|
|
d8a8a78691 | ||
|
|
120d299d85 | ||
|
|
2738e31d1f | ||
|
|
e5928449f7 | ||
|
|
2c84756b1f | ||
|
|
68cc9427ff | ||
|
|
37f979979c | ||
|
|
7d4d36a422 | ||
|
|
8c0fbcf5ef | ||
|
|
8aa1f1f785 | ||
|
|
75400e3b9c | ||
|
|
dfbb8d78ec | ||
|
|
750d9e7a62 | ||
|
|
392a708db9 | ||
|
|
a8394cfa15 | ||
|
|
bb43815a0e | ||
|
|
474acd457f | ||
|
|
0f6af9c754 | ||
|
|
004dcfef45 | ||
|
|
9a06d5741e | ||
|
|
e167e4875d | ||
|
|
641155d4c4 | ||
|
|
bbd69ab480 | ||
|
|
7bdffd804f | ||
|
|
d0d87a7408 | ||
|
|
7653e0342e | ||
|
|
6b04a1aa50 | ||
|
|
f1baa1e632 | ||
|
|
dfb0b24a0c | ||
|
|
754300eacc | ||
|
|
23d63aeb66 | ||
|
|
9d6d42b34a | ||
|
|
b6c9722333 | ||
|
|
1d3309f4b2 | ||
|
|
723ba6c38f | ||
|
|
324defa545 | ||
|
|
14aef1eed4 | ||
|
|
3d4b747000 | ||
|
|
d30ed3109e | ||
|
|
4b1103c683 | ||
|
|
00156b17fa | ||
|
|
1b37d3a44f | ||
|
|
6ded0dbc65 | ||
|
|
6b3a999687 | ||
|
|
4ce799cf01 | ||
|
|
d6c367f321 | ||
|
|
c373f1430e | ||
|
|
dc02422cc3 | ||
|
|
6b7d49d001 | ||
|
|
83fd376892 | ||
|
|
26d166d379 | ||
|
|
ac067f71d9 | ||
|
|
7bb5911f4d | ||
|
|
4faf056353 | ||
|
|
69b293ef30 | ||
|
|
4a82c9cc0a | ||
|
|
77381f11f8 | ||
|
|
0634923332 | ||
|
|
d2ebc372b3 | ||
|
|
32b4303f08 | ||
|
|
dcff27aaa0 | ||
|
|
914a1a24a9 | ||
|
|
39f2c79d97 | ||
|
|
b9b3594728 | ||
|
|
1138b89202 | ||
|
|
6821fb32c3 | ||
|
|
6ee8fdff49 | ||
|
|
e6bbd7af23 | ||
|
|
f8fe440789 | ||
|
|
1ae50d5686 | ||
|
|
245a2d11a0 | ||
|
|
26e8f5ba4f | ||
|
|
769571a85f | ||
|
|
fe66a855a5 | ||
|
|
51450ffd1e | ||
|
|
70c0d78b0f | ||
|
|
d57b0adb16 | ||
|
|
5c07f7cb3a | ||
|
|
447b5da657 | ||
|
|
9b6563aafb | ||
|
|
abdd63fda4 | ||
|
|
31b04daf70 | ||
|
|
00d603ff87 | ||
|
|
13097b5e87 | ||
|
|
bac89d2317 | ||
|
|
1123daf17c | ||
|
|
04edeac150 | ||
|
|
2284a84188 | ||
|
|
38897a2f74 | ||
|
|
37be902b3f | ||
|
|
96a885a0b8 | ||
|
|
9631140a28 | ||
|
|
d23c0513f2 | ||
|
|
af6fc7de53 | ||
|
|
ba1fadee93 | ||
|
|
ba4f8b574b | ||
|
|
d168fcbfe8 | ||
|
|
19bd60a45d | ||
|
|
06c947a85c | ||
|
|
83d53f3d0d | ||
|
|
714fb4889a | ||
|
|
b415af8b27 | ||
|
|
2084f1c3ed |
No files matched your search
@@ -0,0 +1,43 @@
|
||||
# PraisonAI Environment Variables
|
||||
# Copy this file to .env and update with your actual values
|
||||
|
||||
# === Required ===
|
||||
OPENAI_API_KEY=sk-... # OpenAI key for LLM calls. Get yours at https://platform.openai.com
|
||||
|
||||
# === Required for Claw web-search tool ===
|
||||
TAVILY_API_KEY=tvly-... # Get yours at https://app.tavily.com (free tier available)
|
||||
|
||||
# === Optional: Claw dashboard security ===
|
||||
PRAISONAI_ALLOW_LOCAL_TOOLS=0 # Set to 1 to enable local shell tools (security risk)
|
||||
|
||||
# === Optional: Alternative LLM Providers ===
|
||||
ANTHROPIC_API_KEY=sk-ant-... # Anthropic Claude models
|
||||
GOOGLE_API_KEY=... # Google Gemini models
|
||||
GROQ_API_KEY=gsk_... # Groq (fast inference)
|
||||
COHERE_API_KEY=... # Cohere models
|
||||
AZURE_OPENAI_API_KEY=... # Azure OpenAI
|
||||
AZURE_OPENAI_ENDPOINT=... # Azure OpenAI endpoint
|
||||
|
||||
# === Optional: Memory backends ===
|
||||
MEM0_API_KEY=... # Mem0 memory service
|
||||
REDIS_URL=redis://localhost:6379 # Redis for state management
|
||||
|
||||
# === Optional: Observability ===
|
||||
LANGFUSE_SECRET_KEY=sk-lf-... # Langfuse tracing
|
||||
LANGFUSE_PUBLIC_KEY=pk-lf-... # Langfuse public key
|
||||
|
||||
# === Optional: Bot platforms ===
|
||||
TELEGRAM_BOT_TOKEN=... # Telegram bot token
|
||||
DISCORD_BOT_TOKEN=... # Discord bot token
|
||||
SLACK_BOT_TOKEN=xoxb-... # Slack bot token
|
||||
SLACK_APP_TOKEN=xapp-... # Slack app token
|
||||
WHATSAPP_ACCESS_TOKEN=... # WhatsApp bot access token
|
||||
WHATSAPP_PHONE_NUMBER_ID=... # WhatsApp phone number ID
|
||||
|
||||
# === Optional: Database ===
|
||||
DATABASE_URL=sqlite:///~/.praison/database.sqlite # Database connection string
|
||||
|
||||
# === Optional: Development ===
|
||||
LOGLEVEL=INFO # Logging level (DEBUG, INFO, WARNING, ERROR)
|
||||
CHAINLIT_AUTH_SECRET=... # Chainlit authentication secret
|
||||
DEBUG=false # Enable debug mode
|
||||
@@ -181,12 +181,15 @@ jobs:
|
||||
|
||||
- name: Fetch PR branch and Setup Remote
|
||||
if: github.event.issue.pull_request
|
||||
env:
|
||||
PR_BRANCH: ${{ steps.check_fork.outputs.pr_branch }}
|
||||
IS_FORK: ${{ steps.check_fork.outputs.is_fork }}
|
||||
run: |
|
||||
# Fetch PR head from base repo and put it into local branch
|
||||
git fetch origin pull/${{ github.event.issue.number }}/head:${{ steps.check_fork.outputs.pr_branch }}
|
||||
git fetch origin pull/${{ github.event.issue.number }}/head:"${PR_BRANCH}"
|
||||
|
||||
# If it's a fork, make origin point to local to trick claude-code-action's `git fetch origin <branch>`
|
||||
if [ "${{ steps.check_fork.outputs.is_fork }}" == "true" ]; then
|
||||
if [ "${IS_FORK}" = "true" ]; then
|
||||
git remote set-url origin file://$(pwd)
|
||||
fi
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
PraisonAI 🦞 — **Hire a 24/7 AI Workforce.** Stop writing boilerplate and start shipping autonomous agents that research, plan, and execute tasks across your apps. From one agent to an entire organization, deployed in 5 lines of code.
|
||||
PraisonAI 🦞 — **Hire a 24/7 AI Workforce.** Stop writing boilerplate and start shipping autonomous, self-improving agents that research, plan, and execute tasks across your apps. From one agent to an entire organization, deployed in 5 lines of code.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://praison.ai/install.sh | bash
|
||||
@@ -294,8 +294,35 @@ def search(query: str) -> str:
|
||||
|
||||
@tool
|
||||
def calculate(expression: str) -> float:
|
||||
"""Evaluate a math expression."""
|
||||
return eval(expression)
|
||||
"""Safely evaluate a numeric arithmetic expression."""
|
||||
import ast
|
||||
import operator
|
||||
|
||||
# Define allowed operations
|
||||
_OPS = {
|
||||
ast.Add: operator.add,
|
||||
ast.Sub: operator.sub,
|
||||
ast.Mult: operator.mul,
|
||||
ast.Div: operator.truediv,
|
||||
ast.Pow: operator.pow,
|
||||
ast.USub: operator.neg,
|
||||
ast.UAdd: operator.pos,
|
||||
}
|
||||
|
||||
def _safe_eval(node):
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
|
||||
return node.value
|
||||
elif isinstance(node, ast.BinOp) and type(node.op) in _OPS:
|
||||
return _OPS[type(node.op)](_safe_eval(node.left), _safe_eval(node.right))
|
||||
elif isinstance(node, ast.UnaryOp) and type(node.op) in _OPS:
|
||||
return _OPS[type(node.op)](_safe_eval(node.operand))
|
||||
else:
|
||||
raise ValueError("Unsupported expression")
|
||||
|
||||
try:
|
||||
return _safe_eval(ast.parse(expression, mode="eval").body)
|
||||
except (ValueError, SyntaxError, TypeError, ZeroDivisionError, OverflowError):
|
||||
raise ValueError("Invalid arithmetic expression")
|
||||
|
||||
agent = Agent(
|
||||
instructions="You are a helpful assistant",
|
||||
@@ -304,6 +331,7 @@ agent = Agent(
|
||||
agent.start("Search for AI news and calculate 15*4")
|
||||
```
|
||||
|
||||
> ⚠️ **Security Note:** Never use `eval()`, `exec()`, or `subprocess` in tool functions that process LLM-generated or user-supplied input. Always validate and sanitize inputs to prevent code injection attacks.
|
||||
> 📖 [Full tools docs](https://docs.praison.ai/docs/tools/tools) — BaseTool, tool packages, 100+ built-in tools
|
||||
|
||||
### 5. Persistence (Databases)
|
||||
@@ -330,6 +358,15 @@ pip install "praisonai[claw]"
|
||||
praisonai claw
|
||||
```
|
||||
|
||||
#### Required Environment Variables
|
||||
|
||||
Copy `.env.example` to `.env` and configure the following variables:
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `OPENAI_API_KEY` | Yes | OpenAI API key for all LLM calls |
|
||||
| `TAVILY_API_KEY` | Yes (Claw) | Tavily key for the built-in web-search tool. Get one free at https://app.tavily.com |
|
||||
|
||||
Open **http://localhost:8082** — the dashboard comes with 13 built-in pages: Chat, Agents, Memory, Knowledge, Channels, Guardrails, Cron, and more. Add messaging channels directly from the UI.
|
||||
|
||||
> 📖 [Full Claw docs](https://docs.praison.ai/docs/concepts/claw) — platform tokens, CLI options, Docker, and YAML agent mode
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Security release checklist (maintainer)
|
||||
|
||||
After merging security PRs [#1684](https://github.com/MervinPraison/PraisonAI/pull/1684) and [#1685](https://github.com/MervinPraison/PraisonAI/pull/1685):
|
||||
|
||||
## 1. Publish PyPI (requires `PYPI_TOKEN`)
|
||||
|
||||
```bash
|
||||
cd src/praisonai-agents
|
||||
export PYPI_TOKEN=...
|
||||
praisonai publish pypi
|
||||
|
||||
cd ../praisonai
|
||||
python scripts/bump_and_release.py <WRAPPER_VERSION> --agents <AGENTS_VERSION> --wait
|
||||
```
|
||||
|
||||
## 2. Publish GitHub security advisories
|
||||
|
||||
Set versions to match PyPI, then:
|
||||
|
||||
```bash
|
||||
AGENTS_VERSION=1.6.40 WRAPPER_VERSION=4.6.40 ./scripts/security_publish_advisories.sh
|
||||
```
|
||||
|
||||
Add reporter `credits` per advisory via `gh api` before publishing if not already set.
|
||||
|
||||
## 3. Verify
|
||||
|
||||
```bash
|
||||
pip index versions praisonaiagents | head -1
|
||||
pip index versions praisonai | head -1
|
||||
gh api repos/MervinPraison/PraisonAI/security-advisories --jq '.[] | "\(.ghsa_id) \(.state) \(.cve_id // "pending")"'
|
||||
```
|
||||
|
||||
Resources: https://github.com/MervinPraison/PraisonAI · https://docs.praison.ai · https://praison.ai
|
||||
@@ -0,0 +1,26 @@
|
||||
# Security advisory triage (maintainer)
|
||||
|
||||
| GHSA | Status after batch 1+2 | Notes |
|
||||
|------|------------------------|-------|
|
||||
| GHSA-4mr5-g6f9-cfrh | fixed batch 1 | Sandbox AST |
|
||||
| GHSA-5c6w-wwfq-7qqm | fixed batch 1 | spider_tools SSRF |
|
||||
| GHSA-9cr9-25q5-8prj | fixed batch 1 | MCP yaml paths |
|
||||
| GHSA-86qc-r5v2-v6x6 | fixed batch 1 | call server auth |
|
||||
| GHSA-hvhp-v2gc-268q | fixed batch 1 | write_file cwd |
|
||||
| GHSA-5cxw-77wg-jrf3 | fixed batch 1 | @url mentions |
|
||||
| GHSA-xp85-6wwf-r67c | fixed batch 1 | GHA branch quote |
|
||||
| GHSA-3qg8-5g3r-79v5 | fixed batch 1+2 | JWT + issue guard |
|
||||
| GHSA-xwq8, 7p8g, w388, g8rr | fixed batch 1 | platform RBAC/IDOR partial |
|
||||
| GHSA-943m, 5jx9, 4x6r, 27p4, cp4f | fixed batch 2 | platform IDOR completion |
|
||||
| GHSA-vg22-4gmj-prxw | fixed batch 2 | example eval hardening |
|
||||
| GHSA-8444-4fhq-fxpq | already-fixed | APIConfig.auth_enabled default True |
|
||||
| GHSA-78r8-wwqv-r299 | already-fixed | load_user_module gate |
|
||||
| GHSA-gv23-xxxx-xxxx, GHSA-h8q5-cp56-rr65, GHSA-6h6v-xxxx-xxxx, GHSA-h37g-4h4p-9x97 | fixed batch 3 | service-layer workspace_id on get/update/delete |
|
||||
| GHSA-c2m8-xxxx-xxxx, GHSA-8g2p-xxxx-xxxx | fixed batch 3 | only owner assigns admin/owner |
|
||||
| GHSA-h8q5-cp56-rr65 | fixed batch 3 | bind default 127.0.0.1 (+ PLATFORM_HOST) |
|
||||
| GHSA-6xj3-927j-6pqw | not-applicable | Open WebUI path; not in this repo |
|
||||
| GHSA-gmjg, 9q28 | published | prior release |
|
||||
|
||||
**Code fixed on main; GHSA state still triage until PyPI publish + advisory publish.**
|
||||
|
||||
Resources: https://github.com/MervinPraison/PraisonAI · https://docs.praison.ai · https://praison.ai
|
||||
@@ -172,6 +172,7 @@ Methods:
|
||||
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">validate</a>() -> bool</code>
|
||||
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">validate_class</a>() -> bool</code>
|
||||
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">__call__</a>(*args, **kwargs) -> Any</code>
|
||||
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">check_availability</a>() -> tuple[bool, str]</code>
|
||||
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">injected_params</a>() -> Dict[str, Any]</code>
|
||||
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">run</a>(**kwargs) -> Any</code>
|
||||
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">clear</a>() -> None</code>
|
||||
@@ -179,6 +180,7 @@ Methods:
|
||||
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">discover_single_file_plugins</a>() -> int</code>
|
||||
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">get</a>(name: str) -> Optional[Union[BaseTool, Callable]]</code>
|
||||
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">get_all</a>() -> Dict[str, Union[BaseTool, Callable]]</code>
|
||||
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">list_available_tools</a>(context: Optional[Dict[str, Any]] = None) -> List[Union[BaseTool, Callable]]</code>
|
||||
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">list_base_tools</a>() -> List[BaseTool]</code>
|
||||
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">list_tools</a>() -> List[str]</code>
|
||||
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">register</a>(tool: Union[BaseTool, Callable], name: Optional[str] = None, overwrite: bool = False) -> None</code>
|
||||
@@ -206,11 +208,12 @@ Methods:
|
||||
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">execute_callback_sync</a>(task_output: TaskOutput) -> None</code>
|
||||
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">get_next_task</a>(context: Dict[str, Any]) -> Optional[str]</code>
|
||||
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">initialize_memory</a>()</code>
|
||||
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">initialize_memory_async</a>()</code>
|
||||
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">set_status</a>(new_status: str) -> None</code>
|
||||
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">store_in_memory</a>(content: str, agent_name: str = None, task_id: str = None)</code>
|
||||
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">to_dict</a>() -> Dict[str, Any]</code>
|
||||
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">loop</a>(step: Any = None, steps: Optional[List[Any]] = None, over: Optional[str] = None, from_csv: Optional[str] = None, from_file: Optional[str] = None, var_name: str = 'item', parallel: bool = False, max_workers: Optional[int] = None, output_variable: Optional[str] = None) -> Loop</code>
|
||||
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">parallel</a>(steps: List, max_workers: Optional[int] = None) -> Parallel</code>
|
||||
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">parallel</a>(steps: List, max_workers: Optional[int] = None, on_failure: str = 'partial_ok') -> Parallel</code>
|
||||
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">repeat</a>(step: Any, until: Optional[Callable[[WorkflowContext], bool]] = None, max_iterations: int = 10) -> Repeat</code>
|
||||
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">route</a>(routes: Dict[str, List], default: Optional[List] = None) -> Route</code>
|
||||
|
||||
@@ -467,7 +470,10 @@ Methods:
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">activate_by_name</a>(name: str) -> bool</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">add_skill</a>(skill_path: str) -> Optional[LoadedSkill]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">clear</a>() -> None</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">create_skill</a>(name: str, content: str, category: str = None) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">delete_skill</a>(name: str) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">discover</a>(skill_dirs: Optional[List[str]] = None, include_defaults: bool = True) -> int</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">edit_skill</a>(name: str, content: str) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_allowed_tools</a>(name: str) -> List[str]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_available_skills</a>() -> List[SkillMetadata]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_instructions</a>(name: str) -> Optional[str]</code>
|
||||
@@ -475,9 +481,12 @@ Methods:
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_user_invocable_skills</a>() -> List[LoadedSkill]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">invoke</a>(name: str, raw_args: str = '', session_id: Optional[str] = None, shell_exec: bool = False) -> Optional[str]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">load_resources</a>(name: str) -> bool</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">patch_skill</a>(name: str, old_string: str, new_string: str, file_path: str = None, replace_all: bool = False) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">remove_skill_file</a>(name: str, file_path: str) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">skill_names</a>() -> List[str]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">skills</a>() -> List[LoadedSkill]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">to_prompt</a>() -> str</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">write_skill_file</a>(name: str, file_path: str, file_content: str) -> dict</code>
|
||||
* <code title="class SkillMetadata">SkillMetadata.<a href="./src/praisonai-agents/praisonaiagents/skills/models.py">from_properties</a>(props: SkillProperties) -> 'SkillMetadata'</code>
|
||||
* <code title="class SkillProperties">SkillProperties.<a href="./src/praisonai-agents/praisonaiagents/skills/models.py">to_dict</a>() -> dict</code>
|
||||
* <code title="class SkillsConfig">SkillsConfig.<a href="./src/praisonai-agents/praisonaiagents/config/feature_configs.py">to_dict</a>() -> Dict[str, Any]</code>
|
||||
@@ -746,7 +755,7 @@ Methods:
|
||||
|
||||
Types:
|
||||
```python
|
||||
from praisonai import AgentApp, AgentOS, AnthropicManagedAgent, CloudProvider, DB, Deploy, DeployConfig, DeployType, LocalManagedAgent, LocalManagedConfig, ManagedAgent, ManagedConfig, PraisonAI, __version__
|
||||
from praisonai import Agent, AgentApp, AgentOS, AnthropicManagedAgent, CloudProvider, DB, Deploy, DeployConfig, DeployType, HostedAgent, HostedAgentConfig, LocalAgent, LocalAgentConfig, LocalManagedAgent, LocalManagedConfig, ManagedAgent, ManagedConfig, PraisonAI, __version__
|
||||
```
|
||||
|
||||
# CLI
|
||||
@@ -777,6 +786,10 @@ Methods:
|
||||
* <code title="cli">praisonai bot discord <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot email <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot install-daemon <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot linear <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot list <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot purge <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot replay <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot slack <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot start <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot telegram <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
@@ -873,6 +886,7 @@ Methods:
|
||||
* <code title="cli">praisonai gateway gateway-callback <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <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 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>
|
||||
@@ -945,6 +959,10 @@ Methods:
|
||||
* <code title="cli">praisonai package install <a href="./src/praisonai/praisonai/cli/commands/package.py">--help</a></code>
|
||||
* <code title="cli">praisonai package list <a href="./src/praisonai/praisonai/cli/commands/package.py">--help</a></code>
|
||||
* <code title="cli">praisonai package uninstall <a href="./src/praisonai/praisonai/cli/commands/package.py">--help</a></code>
|
||||
* <code title="cli">praisonai pairing approve <a href="./src/praisonai/praisonai/cli/commands/pairing.py">--help</a></code>
|
||||
* <code title="cli">praisonai pairing clear <a href="./src/praisonai/praisonai/cli/commands/pairing.py">--help</a></code>
|
||||
* <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 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>
|
||||
|
||||
@@ -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.24" \
|
||||
"praisonai>=4.6.40" \
|
||||
"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.24" \
|
||||
"praisonai>=4.6.40" \
|
||||
"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.24" \
|
||||
"praisonai>=4.6.40" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
Linear Agent Example for PraisonAI.
|
||||
|
||||
Demonstrates how to create a Linear bot that responds to issue mentions
|
||||
and assignments with full coding capabilities.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from praisonaiagents import Agent
|
||||
from praisonai.bots import LinearBot
|
||||
|
||||
# Create an agent with Linear tools and coding capabilities
|
||||
agent = Agent(
|
||||
name="PraisonAI Coder",
|
||||
instructions="""
|
||||
You are an autonomous coding agent integrated with Linear.
|
||||
|
||||
When you are mentioned or assigned an issue:
|
||||
1. Analyze the issue description and requirements
|
||||
2. Break down complex tasks into smaller steps
|
||||
3. Use available tools to implement solutions
|
||||
4. Update the issue with progress comments
|
||||
5. Create GitHub pull requests when code changes are needed
|
||||
|
||||
Focus on being helpful, thorough, and providing clear updates.
|
||||
""",
|
||||
llm="gpt-4o-mini",
|
||||
tools=[
|
||||
"linear_search_issues",
|
||||
"linear_get_issue",
|
||||
"linear_add_comment",
|
||||
"linear_update_issue",
|
||||
"linear_list_teams",
|
||||
"linear_list_issue_states",
|
||||
"github_create_branch",
|
||||
"github_commit_and_push",
|
||||
"github_create_pull_request",
|
||||
"read_file",
|
||||
"write_file",
|
||||
"execute_command"
|
||||
],
|
||||
memory=True,
|
||||
web_search=True,
|
||||
auto_approve_tools=True,
|
||||
)
|
||||
|
||||
# Create Linear bot
|
||||
bot = LinearBot(
|
||||
token="your-linear-oauth-token", # or set LINEAR_OAUTH_TOKEN env var
|
||||
signing_secret="your-webhook-secret", # or set LINEAR_WEBHOOK_SECRET env var
|
||||
agent=agent,
|
||||
webhook_port=8080,
|
||||
)
|
||||
|
||||
async def main():
|
||||
"""Start the Linear bot."""
|
||||
print("Starting Linear bot...")
|
||||
print("Set up your Linear webhook to point to: http://your-host:8080/webhook")
|
||||
print("Configure AgentSession and Comment events in Linear webhook settings")
|
||||
|
||||
try:
|
||||
await bot.start()
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopping bot...")
|
||||
await bot.stop()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1,7 +1,7 @@
|
||||
"""All compute providers — comprehensive test across Local, Docker, E2B, and Modal.
|
||||
|
||||
This example mirrors the Anthropic app.py but uses the local provider with
|
||||
various compute backends instead of Anthropic's managed infrastructure.
|
||||
This example demonstrates local agent loops with various compute backends for tool sandboxing.
|
||||
Uses the new LocalAgent class to clearly indicate local execution with optional cloud compute.
|
||||
|
||||
Requires:
|
||||
- Docker running locally
|
||||
@@ -9,7 +9,8 @@ Requires:
|
||||
- modal CLI configured (modal token set)
|
||||
"""
|
||||
import asyncio
|
||||
from praisonai import Agent, ManagedAgent, LocalManagedConfig
|
||||
from praisonai import Agent
|
||||
from praisonai.integrations import LocalAgent, LocalAgentConfig
|
||||
|
||||
|
||||
async def test_provider(name, compute, extra_provision_kwargs=None):
|
||||
@@ -18,10 +19,9 @@ async def test_provider(name, compute, extra_provision_kwargs=None):
|
||||
print(f" PROVIDER: {name}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
managed = ManagedAgent(
|
||||
provider="local",
|
||||
managed = LocalAgent(
|
||||
compute=compute,
|
||||
config=LocalManagedConfig(
|
||||
config=LocalAgentConfig(
|
||||
model="gpt-4o-mini",
|
||||
system="You are a helpful assistant. Be concise.",
|
||||
name=f"{name}Agent",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Run every managed-runtime example and report a summary.
|
||||
|
||||
Executes the runtime_*.py files in this directory as subprocesses with a
|
||||
30s per-example timeout. Exits non-zero if any example fails.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
EXAMPLES = [
|
||||
# ── LLM providers (where the LLM reasoning happens) ──
|
||||
"runtime_anthropic.py", # full loop in Anthropic cloud
|
||||
"runtime_openai.py", # LLM in OpenAI cloud
|
||||
"runtime_gemini.py", # LLM in Google cloud
|
||||
"runtime_ollama.py", # LLM on local/self-hosted Ollama
|
||||
# ── Cloud/compute providers (where tool execution happens) ──
|
||||
"runtime_e2b.py", # tools sandboxed in E2B cloud VM
|
||||
"runtime_modal.py", # tools sandboxed in Modal serverless cloud
|
||||
"runtime_fly.py", # tools sandboxed on Fly.io Machines
|
||||
"runtime_daytona.py", # tools sandboxed in Daytona cloud workspace
|
||||
"runtime_docker.py", # tools sandboxed in local Docker container
|
||||
]
|
||||
|
||||
results = []
|
||||
for name in EXAMPLES:
|
||||
path = os.path.join(HERE, name)
|
||||
start = time.time()
|
||||
proc = subprocess.run(
|
||||
[sys.executable, path],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
env=os.environ.copy(), # propagate API keys and PYTHONPATH
|
||||
)
|
||||
dur = time.time() - start
|
||||
ok = proc.returncode == 0
|
||||
results.append((name, ok, dur, proc.stdout.strip().splitlines()[-1] if proc.stdout.strip() else ""))
|
||||
print(f"[{'OK' if ok else 'FAIL'}] {name:30s} {dur:.2f}s")
|
||||
if not ok:
|
||||
print(proc.stdout)
|
||||
print(proc.stderr)
|
||||
|
||||
print("\nSummary:")
|
||||
for name, ok, dur, tail in results:
|
||||
print(f" {'✓' if ok else '✗'} {name:30s} {dur:5.2f}s {tail[:60]}")
|
||||
|
||||
sys.exit(0 if all(r[1] for r in results) else 1)
|
||||
@@ -23,12 +23,39 @@ print(f"[2] Updated to v{managed.agent_version}")
|
||||
result = agent.start("What is 123 + 456?", stream=True)
|
||||
|
||||
# ── 3. Custom tool ──
|
||||
def _safe_calc(expr: str) -> str:
|
||||
import ast
|
||||
allowed = set("0123456789+-*/.() ")
|
||||
if not all(c in allowed for c in expr):
|
||||
return "error"
|
||||
try:
|
||||
tree = ast.parse(expr, mode="eval")
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(
|
||||
node,
|
||||
(
|
||||
ast.Expression,
|
||||
ast.BinOp,
|
||||
ast.UnaryOp,
|
||||
ast.Constant,
|
||||
ast.Add,
|
||||
ast.Sub,
|
||||
ast.Mult,
|
||||
ast.Div,
|
||||
ast.USub,
|
||||
ast.UAdd,
|
||||
),
|
||||
):
|
||||
return "error"
|
||||
val = eval(compile(tree, "<expr>", "eval"), {"__builtins__": {}})
|
||||
return str(val)
|
||||
except Exception:
|
||||
return "error"
|
||||
|
||||
|
||||
def handle_calculator(tool_name, tool_input):
|
||||
expr = tool_input.get("expression", "0")
|
||||
try:
|
||||
val = eval(expr, {"__builtins__": {}})
|
||||
except Exception:
|
||||
val = "error"
|
||||
val = _safe_calc(str(expr))
|
||||
print(f" [Calculator: {expr} = {val}]")
|
||||
return str(val)
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"""Local provider — basic managed agent with gpt-4o-mini.
|
||||
"""Local agent — basic local execution with gpt-4o-mini.
|
||||
|
||||
No external infrastructure needed. Runs the agent loop locally.
|
||||
Uses the new canonical LocalAgent class for clarity.
|
||||
"""
|
||||
from praisonai import Agent, ManagedAgent, LocalManagedConfig
|
||||
from praisonai import Agent
|
||||
from praisonai.integrations import LocalAgent, LocalAgentConfig
|
||||
|
||||
# Create a local managed agent (auto-detects local when no ANTHROPIC_API_KEY)
|
||||
managed = ManagedAgent(
|
||||
provider="local",
|
||||
config=LocalManagedConfig(
|
||||
# Create a local agent (runs locally, no managed runtime)
|
||||
managed = LocalAgent(
|
||||
config=LocalAgentConfig(
|
||||
model="gpt-4o-mini",
|
||||
system="You are a helpful assistant. Be concise.",
|
||||
name="LocalAgent",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Anthropic managed runtime — full agent loop runs in Anthropic's cloud.
|
||||
|
||||
The ENTIRE loop (LLM calls, tool execution, memory, sessions) runs remotely
|
||||
on Anthropic's managed infrastructure. This is the only provider today where
|
||||
"the whole thing runs in that cloud".
|
||||
|
||||
Requires: ANTHROPIC_API_KEY environment variable set.
|
||||
"""
|
||||
import os
|
||||
|
||||
if not os.getenv("ANTHROPIC_API_KEY"):
|
||||
print("[skip] ANTHROPIC_API_KEY not set — set it to run this example.")
|
||||
raise SystemExit(0)
|
||||
|
||||
try:
|
||||
import anthropic # noqa: F401 (required by AnthropicManagedAgent)
|
||||
except ImportError:
|
||||
print("[skip] 'anthropic' SDK not installed — pip install 'anthropic>=0.94.0'")
|
||||
raise SystemExit(0) from None
|
||||
|
||||
try:
|
||||
from praisonai import Agent, ManagedAgent, ManagedConfig
|
||||
except ImportError as e:
|
||||
print(f"[skip] praisonai wrapper not importable ({e}).")
|
||||
raise SystemExit(0) from None
|
||||
|
||||
managed = ManagedAgent(
|
||||
provider="anthropic",
|
||||
config=ManagedConfig(
|
||||
model="claude-3-5-sonnet-latest",
|
||||
system="You are a concise coding assistant. One-sentence answers.",
|
||||
name="AnthropicRuntimeAgent",
|
||||
),
|
||||
)
|
||||
agent = Agent(name="anthropic-runtime", backend=managed)
|
||||
|
||||
# 1. Agent work entirely in the cloud
|
||||
print("[1] Agent:", agent.start("Write a Python one-liner that sums 1..10. Just the code."))
|
||||
|
||||
# 2. Multi-turn reuses the same cloud session
|
||||
print("[2] Agent:", agent.start("Now change it to factorial of 5."))
|
||||
|
||||
# 3. Cloud-side session metadata
|
||||
info = managed.retrieve_session()
|
||||
print(f"[3] session={managed.session_id} status={info.get('status')} "
|
||||
f"in={info.get('usage', {}).get('input_tokens', 0)} "
|
||||
f"out={info.get('usage', {}).get('output_tokens', 0)}")
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Agent runtime with tools sandboxed in a Daytona cloud workspace.
|
||||
|
||||
The agent's LLM loop runs locally; tool / code execution runs in a Daytona-
|
||||
managed workspace (disposable cloud dev environment, pre-configured images).
|
||||
|
||||
Requires:
|
||||
- ``OPENAI_API_KEY`` for the LLM
|
||||
- ``DAYTONA_API_KEY`` for the Daytona API
|
||||
- ``pip install daytona-sdk``
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
print("[skip] OPENAI_API_KEY not set.")
|
||||
raise SystemExit(0)
|
||||
if not os.getenv("DAYTONA_API_KEY"):
|
||||
print("[skip] DAYTONA_API_KEY not set.")
|
||||
raise SystemExit(0)
|
||||
|
||||
try:
|
||||
from praisonai import Agent
|
||||
from praisonai.integrations import SandboxedAgent, SandboxedAgentConfig
|
||||
except ImportError as e:
|
||||
print(f"[skip] praisonai wrapper not importable ({e}).")
|
||||
raise SystemExit(0) from None
|
||||
|
||||
|
||||
async def main():
|
||||
sandboxed = SandboxedAgent(
|
||||
compute="daytona", # tool execution runs in a Daytona workspace
|
||||
config=SandboxedAgentConfig(
|
||||
model="gpt-4o-mini",
|
||||
system="You are a concise coding assistant.",
|
||||
name="DaytonaRuntimeAgent",
|
||||
),
|
||||
)
|
||||
agent = Agent(name="daytona-runtime", backend=sandboxed)
|
||||
|
||||
info = await sandboxed.provision_compute(idle_timeout_s=120)
|
||||
print(f"[1] Daytona workspace: {info.instance_id} ({info.status})")
|
||||
|
||||
result = await sandboxed.execute_in_compute("python3 -c 'print(len([1,2,3,4,5]) ** 3)'")
|
||||
print(f"[2] Cloud compute result: {result['stdout'].strip()}")
|
||||
|
||||
print("[3] Agent:", agent.start("What is 5 cubed? Just the number."))
|
||||
|
||||
await sandboxed.shutdown_compute()
|
||||
print("[4] Daytona workspace shut down.")
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Agent runtime with tools sandboxed in a local Docker container.
|
||||
|
||||
The agent's LLM loop runs locally; tool / code execution is isolated in a
|
||||
Docker container. Not a cloud provider, but the same pattern — useful for
|
||||
local development and CI.
|
||||
|
||||
Requires:
|
||||
- ``OPENAI_API_KEY`` for the LLM
|
||||
- Docker daemon running (``docker info`` must succeed)
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
print("[skip] OPENAI_API_KEY not set.")
|
||||
raise SystemExit(0)
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
["docker", "info"],
|
||||
check=True, capture_output=True, timeout=5,
|
||||
)
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
|
||||
print("[skip] Docker daemon not available — start Docker Desktop.")
|
||||
raise SystemExit(0) from None
|
||||
|
||||
try:
|
||||
from praisonai import Agent
|
||||
from praisonai.integrations import SandboxedAgent, SandboxedAgentConfig
|
||||
except ImportError as e:
|
||||
print(f"[skip] praisonai wrapper not importable ({e}).")
|
||||
raise SystemExit(0) from None
|
||||
|
||||
|
||||
async def main():
|
||||
sandboxed = SandboxedAgent(
|
||||
compute="docker", # tool execution runs in a local Docker container
|
||||
config=SandboxedAgentConfig(
|
||||
model="gpt-4o-mini",
|
||||
system="You are a concise coding assistant.",
|
||||
name="DockerRuntimeAgent",
|
||||
),
|
||||
)
|
||||
agent = Agent(name="docker-runtime", backend=sandboxed)
|
||||
|
||||
info = await sandboxed.provision_compute(image="python:3.12-slim")
|
||||
print(f"[1] Docker container: {info.instance_id} ({info.status})")
|
||||
|
||||
result = await sandboxed.execute_in_compute("python3 -c 'print(sum(i*i for i in range(1, 11)))'")
|
||||
print(f"[2] Container compute result: {result['stdout'].strip()}")
|
||||
|
||||
print("[3] Agent:", agent.start("What is the sum of squares from 1 to 10? Just the number."))
|
||||
|
||||
await sandboxed.shutdown_compute()
|
||||
print("[4] Docker container shut down.")
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Agent runtime with tools sandboxed in an E2B cloud VM.
|
||||
|
||||
The agent's LLM loop runs locally; any tool / code execution is sandboxed
|
||||
in an ephemeral E2B cloud instance. Uses ``SandboxedAgent`` — the honest
|
||||
name for the "local loop + cloud tools" pattern.
|
||||
|
||||
Requires:
|
||||
- ``OPENAI_API_KEY`` for the LLM
|
||||
- ``E2B_API_KEY`` for the cloud sandbox
|
||||
- ``pip install e2b``
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
print("[skip] OPENAI_API_KEY not set.")
|
||||
raise SystemExit(0)
|
||||
if not os.getenv("E2B_API_KEY"):
|
||||
print("[skip] E2B_API_KEY not set — set it to run in the E2B cloud sandbox.")
|
||||
raise SystemExit(0)
|
||||
|
||||
try:
|
||||
from praisonai import Agent
|
||||
from praisonai.integrations import SandboxedAgent, SandboxedAgentConfig
|
||||
except ImportError as e:
|
||||
print(f"[skip] praisonai wrapper not importable ({e}).")
|
||||
raise SystemExit(0) from None
|
||||
|
||||
|
||||
async def main():
|
||||
sandboxed = SandboxedAgent(
|
||||
compute="e2b", # tool execution runs in E2B cloud
|
||||
config=SandboxedAgentConfig(
|
||||
model="gpt-4o-mini",
|
||||
system="You are a concise coding assistant.",
|
||||
name="E2BRuntimeAgent",
|
||||
),
|
||||
)
|
||||
agent = Agent(name="e2b-runtime", backend=sandboxed)
|
||||
|
||||
# 1. Provision the cloud sandbox
|
||||
info = await sandboxed.provision_compute(idle_timeout_s=120)
|
||||
print(f"[1] E2B sandbox: {info.instance_id} ({info.status})")
|
||||
|
||||
# 2. Execute code IN the cloud sandbox
|
||||
result = await sandboxed.execute_in_compute("python3 -c 'print(sum(range(1, 11)))'")
|
||||
print(f"[2] Cloud compute result: {result['stdout'].strip()}")
|
||||
|
||||
# 3. Agent loop (LLM local, any tool calls auto-sandboxed in E2B)
|
||||
print("[3] Agent:", agent.start("What is 13 * 17? Just the number."))
|
||||
|
||||
await sandboxed.shutdown_compute()
|
||||
print("[4] E2B sandbox shut down.")
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Agent runtime with tools sandboxed on Fly.io Machines.
|
||||
|
||||
The agent's LLM loop runs locally; tool / code execution runs in a Fly.io
|
||||
Machine (fast-booting micro-VM with per-second billing and global regions).
|
||||
|
||||
Requires:
|
||||
- ``OPENAI_API_KEY`` for the LLM
|
||||
- ``FLY_API_TOKEN`` for the Fly.io Machines API
|
||||
- ``FLY_APP_NAME`` (optional) existing Fly app; auto-created otherwise
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
print("[skip] OPENAI_API_KEY not set.")
|
||||
raise SystemExit(0)
|
||||
if not os.getenv("FLY_API_TOKEN"):
|
||||
print("[skip] FLY_API_TOKEN not set — get one via `fly auth token`.")
|
||||
raise SystemExit(0)
|
||||
|
||||
try:
|
||||
from praisonai import Agent
|
||||
from praisonai.integrations import SandboxedAgent, SandboxedAgentConfig
|
||||
except ImportError as e:
|
||||
print(f"[skip] praisonai wrapper not importable ({e}).")
|
||||
raise SystemExit(0) from None
|
||||
|
||||
|
||||
async def main():
|
||||
sandboxed = SandboxedAgent(
|
||||
compute="flyio", # tool execution runs on Fly.io Machines
|
||||
config=SandboxedAgentConfig(
|
||||
model="gpt-4o-mini",
|
||||
system="You are a concise coding assistant.",
|
||||
name="FlyioRuntimeAgent",
|
||||
),
|
||||
)
|
||||
agent = Agent(name="fly-runtime", backend=sandboxed)
|
||||
|
||||
info = await sandboxed.provision_compute(
|
||||
image="python:3.12-slim",
|
||||
cpu=1,
|
||||
memory_mb=512,
|
||||
idle_timeout_s=120,
|
||||
)
|
||||
print(f"[1] Fly Machine: {info.instance_id} ({info.status})")
|
||||
|
||||
result = await sandboxed.execute_in_compute("python3 -c 'print(2 ** 20)'")
|
||||
print(f"[2] Cloud compute result: {result['stdout'].strip()}")
|
||||
|
||||
print("[3] Agent:", agent.start("What is 2 raised to the 20th power? Just the number."))
|
||||
|
||||
await sandboxed.shutdown_compute()
|
||||
print("[4] Fly Machine shut down.")
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Gemini managed runtime — agent loop local, LLM calls go to Google's cloud.
|
||||
|
||||
``ManagedAgent(provider="gemini")`` wires a local managed loop to Gemini's
|
||||
hosted models via litellm.
|
||||
|
||||
Requires: GEMINI_API_KEY (or GOOGLE_API_KEY) environment variable set.
|
||||
"""
|
||||
import os
|
||||
|
||||
if not (os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")):
|
||||
print("[skip] GEMINI_API_KEY / GOOGLE_API_KEY not set — set one to run this example.")
|
||||
raise SystemExit(0)
|
||||
|
||||
try:
|
||||
from praisonai import Agent, ManagedAgent, LocalManagedConfig
|
||||
except ImportError as e:
|
||||
print(f"[skip] praisonai wrapper not importable ({e}).")
|
||||
raise SystemExit(0) from None
|
||||
|
||||
managed = ManagedAgent(
|
||||
provider="gemini",
|
||||
config=LocalManagedConfig(
|
||||
# Use explicit litellm prefix to guarantee routing to Google
|
||||
model="gemini/gemini-2.0-flash-exp",
|
||||
system="You are a concise assistant. One-sentence answers.",
|
||||
name="GeminiRuntimeAgent",
|
||||
),
|
||||
)
|
||||
agent = Agent(name="gemini-runtime", backend=managed)
|
||||
|
||||
# 1. Agent work (LLM call goes to Google cloud)
|
||||
print("[1] Agent:", agent.start("What does REST stand for? One sentence."))
|
||||
|
||||
# 2. Multi-turn reuses the session
|
||||
print("[2] Agent:", agent.start("How is it different from GraphQL?"))
|
||||
|
||||
info = managed.retrieve_session()
|
||||
print(f"[3] session={managed.session_id} "
|
||||
f"in={info.get('usage', {}).get('input_tokens', 0)} "
|
||||
f"out={info.get('usage', {}).get('output_tokens', 0)}")
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Anthropic hosted runtime — entire agent runs on Anthropic's managed infrastructure.
|
||||
|
||||
Uses the new canonical HostedAgent class which clearly communicates that the entire
|
||||
agent loop runs in Anthropic's cloud, not locally.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Skip guards - exit cleanly if prerequisites not met
|
||||
if not (os.getenv("ANTHROPIC_API_KEY") or os.getenv("CLAUDE_API_KEY")):
|
||||
print("[skip] ANTHROPIC_API_KEY or CLAUDE_API_KEY not set")
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
import anthropic
|
||||
except ImportError:
|
||||
print("[skip] anthropic SDK not installed")
|
||||
sys.exit(0)
|
||||
|
||||
# Heavy imports only after skip-guards pass
|
||||
from praisonai import Agent
|
||||
from praisonai.integrations import HostedAgent, HostedAgentConfig
|
||||
|
||||
# Create a hosted agent running entirely on Anthropic's managed runtime
|
||||
hosted = HostedAgent(
|
||||
provider="anthropic",
|
||||
config=HostedAgentConfig(
|
||||
model="claude-3-5-sonnet-latest",
|
||||
system="You are a helpful coding assistant. Be concise.",
|
||||
name="AnthropicHostedAgent",
|
||||
tools=[{"type": "agent_toolset_20260401"}],
|
||||
),
|
||||
)
|
||||
|
||||
agent = Agent(name="anthropic-hosted", backend=hosted)
|
||||
|
||||
# 1. Basic execution - runs entirely in Anthropic's cloud
|
||||
print("[1] Hosted execution on Anthropic infrastructure...")
|
||||
result = agent.start("What is the capital of France? One word.", stream=True)
|
||||
print(f" Result: {result}")
|
||||
|
||||
# 2. Agent metadata from Anthropic's API
|
||||
print(f"\n[2] Agent ID: {hosted.agent_id}")
|
||||
print(f" Version: {hosted.agent_version}")
|
||||
print(f" Env ID: {hosted.environment_id}")
|
||||
print(f" Session: {hosted.session_id}")
|
||||
|
||||
# 3. Multi-turn (same session keeps context in Anthropic's cloud)
|
||||
print("\n[3] Multi-turn conversation...")
|
||||
result = agent.start("What country is that city in?", stream=True)
|
||||
print(f" Result: {result}")
|
||||
|
||||
# 4. Usage tracking from Anthropic's usage API
|
||||
info = hosted.retrieve_session()
|
||||
print(f"\n[4] Usage: in={info['usage']['input_tokens']}, out={info['usage']['output_tokens']}")
|
||||
|
||||
# 5. List all sessions for this agent
|
||||
sessions = hosted.list_sessions()
|
||||
print(f"\n[5] Sessions: {len(sessions)}")
|
||||
for s in sessions[:3]: # Show first 3
|
||||
print(f" {s['id']} | {s['status']}")
|
||||
|
||||
print("\nDone! Agent loop ran entirely on Anthropic's managed infrastructure.")
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Local agent loop with Gemini LLM — runs locally, not in a managed runtime.
|
||||
|
||||
Uses the new canonical LocalAgent class which clearly communicates that only the
|
||||
agent loop runs locally. The LLM calls go to Google's Gemini API, but there's no managed runtime involved.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Skip guards - exit cleanly if prerequisites not met
|
||||
if not (os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")):
|
||||
print("[skip] GEMINI_API_KEY or GOOGLE_API_KEY not set")
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
import google.generativeai
|
||||
except ImportError:
|
||||
print("[skip] google-generativeai SDK not installed")
|
||||
sys.exit(0)
|
||||
|
||||
# Heavy imports only after skip-guards pass
|
||||
from praisonai import Agent
|
||||
from praisonai.integrations import LocalAgent, LocalAgentConfig
|
||||
|
||||
# Create a local agent using Gemini LLM
|
||||
local = LocalAgent(
|
||||
config=LocalAgentConfig(
|
||||
model="gemini/gemini-2.0-flash", # Use Gemini with litellm routing prefix
|
||||
system="You are a helpful coding assistant. Be concise.",
|
||||
name="LocalGeminiAgent",
|
||||
tools=["execute_command", "read_file", "write_file"],
|
||||
),
|
||||
)
|
||||
|
||||
agent = Agent(name="local-gemini", backend=local)
|
||||
|
||||
# 1. Basic execution - agent loop runs locally, LLM calls go to Gemini
|
||||
print("[1] Local execution with Gemini LLM...")
|
||||
result = agent.start("What is the capital of France? One word.", stream=True)
|
||||
print(f" Result: {result}")
|
||||
|
||||
# 2. Agent metadata (locally generated UUIDs)
|
||||
print(f"\n[2] Agent ID: {local.agent_id}")
|
||||
print(f" Version: {local.agent_version}")
|
||||
print(f" Env ID: {local.environment_id}")
|
||||
print(f" Session: {local.session_id}")
|
||||
|
||||
# 3. Multi-turn conversation (session state maintained locally)
|
||||
print("\n[3] Multi-turn conversation...")
|
||||
result = agent.start("What country is that city in?", stream=True)
|
||||
print(f" Result: {result}")
|
||||
|
||||
# 4. Usage tracking (accumulated locally)
|
||||
info = local.retrieve_session()
|
||||
print(f"\n[4] Usage: in={info['usage']['input_tokens']}, out={info['usage']['output_tokens']}")
|
||||
|
||||
# 5. Tool execution (runs in local subprocess)
|
||||
print("\n[5] Tool execution example...")
|
||||
result = agent.start("Create a file called hello_gemini.txt with 'Hello from Gemini agent!'", stream=True)
|
||||
print(f" Tool result: {result}")
|
||||
|
||||
print("\nDone! Agent loop ran locally with Gemini LLM calls.")
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Local agent loop with Ollama LLM — runs locally, not in a managed runtime.
|
||||
|
||||
Uses the new canonical LocalAgent class which clearly communicates that only the
|
||||
agent loop runs locally. The LLM calls go to a local Ollama instance, no managed runtime involved.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import requests
|
||||
|
||||
# Skip guards - exit cleanly if prerequisites not met
|
||||
# Check if Ollama daemon is running
|
||||
try:
|
||||
response = requests.get("http://localhost:11434/api/tags", timeout=2)
|
||||
if response.status_code != 200:
|
||||
raise Exception("Ollama not responding")
|
||||
except Exception:
|
||||
print("[skip] Ollama daemon not running at localhost:11434")
|
||||
sys.exit(0)
|
||||
|
||||
# Heavy imports only after skip-guards pass
|
||||
from praisonai import Agent
|
||||
from praisonai.integrations import LocalAgent, LocalAgentConfig
|
||||
|
||||
# Create a local agent using Ollama LLM
|
||||
local = LocalAgent(
|
||||
config=LocalAgentConfig(
|
||||
model="ollama/llama3.2", # Use Ollama with litellm routing prefix
|
||||
system="You are a helpful coding assistant. Be concise.",
|
||||
name="LocalOllamaAgent",
|
||||
tools=["execute_command", "read_file", "write_file"],
|
||||
),
|
||||
)
|
||||
|
||||
agent = Agent(name="local-ollama", backend=local)
|
||||
|
||||
# 1. Basic execution - agent loop runs locally, LLM calls go to local Ollama
|
||||
print("[1] Local execution with Ollama LLM...")
|
||||
result = agent.start("What is the capital of France? One word.", stream=True)
|
||||
print(f" Result: {result}")
|
||||
|
||||
# 2. Agent metadata (locally generated UUIDs)
|
||||
print(f"\n[2] Agent ID: {local.agent_id}")
|
||||
print(f" Version: {local.agent_version}")
|
||||
print(f" Env ID: {local.environment_id}")
|
||||
print(f" Session: {local.session_id}")
|
||||
|
||||
# 3. Multi-turn conversation (session state maintained locally)
|
||||
print("\n[3] Multi-turn conversation...")
|
||||
result = agent.start("What country is that city in?", stream=True)
|
||||
print(f" Result: {result}")
|
||||
|
||||
# 4. Usage tracking (accumulated locally)
|
||||
info = local.retrieve_session()
|
||||
print(f"\n[4] Usage: in={info['usage']['input_tokens']}, out={info['usage']['output_tokens']}")
|
||||
|
||||
# 5. Tool execution (runs in local subprocess)
|
||||
print("\n[5] Tool execution example...")
|
||||
result = agent.start("Create a file called hello_ollama.txt with 'Hello from Ollama agent!'", stream=True)
|
||||
print(f" Tool result: {result}")
|
||||
|
||||
print("\nDone! Agent loop ran locally with Ollama LLM calls.")
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Local agent loop with OpenAI LLM — runs locally, not in a managed runtime.
|
||||
|
||||
Uses the new canonical LocalAgent class which clearly communicates that only the
|
||||
agent loop runs locally. The LLM calls go to OpenAI, but there's no managed runtime involved.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Skip guards - exit cleanly if prerequisites not met
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
print("[skip] OPENAI_API_KEY not set")
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
import openai
|
||||
except ImportError:
|
||||
print("[skip] openai SDK not installed")
|
||||
sys.exit(0)
|
||||
|
||||
# Heavy imports only after skip-guards pass
|
||||
from praisonai import Agent
|
||||
from praisonai.integrations import LocalAgent, LocalAgentConfig
|
||||
|
||||
# Create a local agent using OpenAI's LLM
|
||||
local = LocalAgent(
|
||||
config=LocalAgentConfig(
|
||||
model="gpt-4o-mini",
|
||||
system="You are a helpful coding assistant. Be concise.",
|
||||
name="LocalOpenAIAgent",
|
||||
tools=["execute_command", "read_file", "write_file"],
|
||||
),
|
||||
)
|
||||
|
||||
agent = Agent(name="local-openai", backend=local)
|
||||
|
||||
# 1. Basic execution - agent loop runs locally, LLM calls go to OpenAI
|
||||
print("[1] Local execution with OpenAI LLM...")
|
||||
result = agent.start("What is the capital of France? One word.", stream=True)
|
||||
print(f" Result: {result}")
|
||||
|
||||
# 2. Agent metadata (locally generated UUIDs)
|
||||
print(f"\n[2] Agent ID: {local.agent_id}")
|
||||
print(f" Version: {local.agent_version}")
|
||||
print(f" Env ID: {local.environment_id}")
|
||||
print(f" Session: {local.session_id}")
|
||||
|
||||
# 3. Multi-turn conversation (session state maintained locally)
|
||||
print("\n[3] Multi-turn conversation...")
|
||||
result = agent.start("What country is that city in?", stream=True)
|
||||
print(f" Result: {result}")
|
||||
|
||||
# 4. Usage tracking (accumulated locally)
|
||||
info = local.retrieve_session()
|
||||
print(f"\n[4] Usage: in={info['usage']['input_tokens']}, out={info['usage']['output_tokens']}")
|
||||
|
||||
# 5. Tool execution (runs in local subprocess)
|
||||
print("\n[5] Tool execution example...")
|
||||
result = agent.start("Create a file called hello.txt with 'Hello from local agent!'", stream=True)
|
||||
print(f" Tool result: {result}")
|
||||
|
||||
print("\nDone! Agent loop ran locally with OpenAI LLM calls.")
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Agent runtime with tools sandboxed in Modal cloud functions.
|
||||
|
||||
The agent's LLM loop runs locally; tool / code execution is sandboxed in
|
||||
Modal's serverless cloud. Modal gives fast cold-starts and per-second billing.
|
||||
|
||||
Requires:
|
||||
- ``OPENAI_API_KEY`` for the LLM
|
||||
- Modal CLI configured: ``modal token set --token-id ... --token-secret ...``
|
||||
- ``pip install modal``
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
print("[skip] OPENAI_API_KEY not set.")
|
||||
raise SystemExit(0)
|
||||
|
||||
try:
|
||||
import modal # noqa: F401
|
||||
except ImportError:
|
||||
print("[skip] 'modal' package not installed — pip install modal")
|
||||
raise SystemExit(0) from None
|
||||
|
||||
# Modal auth: presence of ~/.modal.toml or MODAL_TOKEN_ID + MODAL_TOKEN_SECRET
|
||||
if not (os.path.exists(os.path.expanduser("~/.modal.toml"))
|
||||
or (os.getenv("MODAL_TOKEN_ID") and os.getenv("MODAL_TOKEN_SECRET"))):
|
||||
print("[skip] Modal not configured — run `modal token set ...`")
|
||||
raise SystemExit(0)
|
||||
|
||||
try:
|
||||
from praisonai import Agent
|
||||
from praisonai.integrations import SandboxedAgent, SandboxedAgentConfig
|
||||
except ImportError as e:
|
||||
print(f"[skip] praisonai wrapper not importable ({e}).")
|
||||
raise SystemExit(0) from None
|
||||
|
||||
|
||||
async def main():
|
||||
sandboxed = SandboxedAgent(
|
||||
compute="modal", # tool execution runs in Modal's cloud
|
||||
config=SandboxedAgentConfig(
|
||||
model="gpt-4o-mini",
|
||||
system="You are a concise coding assistant.",
|
||||
name="ModalRuntimeAgent",
|
||||
),
|
||||
)
|
||||
agent = Agent(name="modal-runtime", backend=sandboxed)
|
||||
|
||||
info = await sandboxed.provision_compute(idle_timeout_s=120)
|
||||
print(f"[1] Modal instance: {info.instance_id} ({info.status})")
|
||||
|
||||
result = await sandboxed.execute_in_compute("python3 -c 'import math; print(math.factorial(10))'")
|
||||
print(f"[2] Cloud compute result: {result['stdout'].strip()}")
|
||||
|
||||
print("[3] Agent:", agent.start("What is the 10th Fibonacci number? Just the number."))
|
||||
|
||||
await sandboxed.shutdown_compute()
|
||||
print("[4] Modal instance shut down.")
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Ollama managed runtime — agent runs against a local/self-hosted LLM.
|
||||
|
||||
``ManagedAgent(provider="ollama")`` wires the managed loop to an Ollama
|
||||
server. Model runs locally (or on a self-hosted box) instead of in a public
|
||||
cloud — useful when you want the "managed agent" developer experience with
|
||||
no external API dependency.
|
||||
|
||||
Requires: An Ollama server reachable (default ``http://localhost:11434``)
|
||||
with at least one model pulled (e.g., ``ollama pull llama3.2``).
|
||||
"""
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
base = os.getenv("OLLAMA_HOST", "http://localhost:11434")
|
||||
try:
|
||||
urllib.request.urlopen(f"{base}/api/tags", timeout=2).read()
|
||||
except (urllib.error.URLError, OSError) as e:
|
||||
print(f"[skip] Ollama not reachable at {base} ({e}). Start it with `ollama serve`.")
|
||||
raise SystemExit(0)
|
||||
|
||||
try:
|
||||
from praisonai import Agent, ManagedAgent, LocalManagedConfig
|
||||
except ImportError as e:
|
||||
print(f"[skip] praisonai wrapper not importable ({e}).")
|
||||
raise SystemExit(0) from None
|
||||
|
||||
managed = ManagedAgent(
|
||||
provider="ollama",
|
||||
config=LocalManagedConfig(
|
||||
model="llama3.2",
|
||||
system="You are a concise assistant. One-sentence answers.",
|
||||
name="OllamaRuntimeAgent",
|
||||
),
|
||||
)
|
||||
agent = Agent(name="ollama-runtime", backend=managed)
|
||||
|
||||
# 1. Agent work (LLM call goes to your Ollama server)
|
||||
print("[1] Agent:", agent.start("What is the capital of Japan? One word."))
|
||||
|
||||
# 2. Multi-turn reuses the session
|
||||
print("[2] Agent:", agent.start("Name its most famous mountain."))
|
||||
|
||||
info = managed.retrieve_session()
|
||||
print(f"[3] session={managed.session_id}")
|
||||
@@ -0,0 +1,41 @@
|
||||
"""OpenAI managed runtime — agent loop local, LLM calls go to OpenAI cloud.
|
||||
|
||||
``ManagedAgent(provider="openai")`` wires a local managed loop to OpenAI's
|
||||
hosted models. The LLM reasoning (the heavy-lifting) runs in OpenAI's cloud;
|
||||
the agent orchestration (tool routing, session state) runs in this process.
|
||||
|
||||
Requires: OPENAI_API_KEY environment variable set.
|
||||
"""
|
||||
import os
|
||||
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
print("[skip] OPENAI_API_KEY not set — set it to run this example.")
|
||||
raise SystemExit(0)
|
||||
|
||||
try:
|
||||
from praisonai import Agent, ManagedAgent, LocalManagedConfig
|
||||
except ImportError as e:
|
||||
print(f"[skip] praisonai wrapper not importable ({e}).")
|
||||
raise SystemExit(0) from None
|
||||
|
||||
managed = ManagedAgent(
|
||||
provider="openai",
|
||||
config=LocalManagedConfig(
|
||||
model="gpt-4o-mini",
|
||||
system="You are a concise assistant. One-sentence answers.",
|
||||
name="OpenAIRuntimeAgent",
|
||||
),
|
||||
)
|
||||
agent = Agent(name="openai-runtime", backend=managed)
|
||||
|
||||
# 1. Agent work (LLM call goes to OpenAI cloud)
|
||||
print("[1] Agent:", agent.start("What is the time complexity of binary search? One sentence."))
|
||||
|
||||
# 2. Multi-turn reuses the session
|
||||
print("[2] Agent:", agent.start("And for a linear scan?"))
|
||||
|
||||
# 3. Local session metadata (cloud usage accumulated)
|
||||
info = managed.retrieve_session()
|
||||
print(f"[3] session={managed.session_id} "
|
||||
f"in={info.get('usage', {}).get('input_tokens', 0)} "
|
||||
f"out={info.get('usage', {}).get('output_tokens', 0)}")
|
||||
@@ -0,0 +1,40 @@
|
||||
# Scientific paper workflow — YAML equivalent of multi_agent_paper.py
|
||||
# Run with:
|
||||
# praisonai agents run --file examples/scientific_writing/agents.yaml
|
||||
framework: praisonai
|
||||
topic: "Climate change effects on coral reef biodiversity"
|
||||
|
||||
roles:
|
||||
literature_reviewer:
|
||||
role: "Literature Reviewer"
|
||||
goal: "Survey recent academic work on {topic} and produce an APA review."
|
||||
backstory: "Expert in academic literature surveying and citation hygiene."
|
||||
llm: "gpt-4o-mini"
|
||||
tasks:
|
||||
review_literature:
|
||||
description: "Produce a concise literature review with 5-8 APA citations."
|
||||
expected_output: "APA-formatted literature review."
|
||||
|
||||
methodology_designer:
|
||||
role: "Methodology Designer"
|
||||
goal: "Design a reproducible research methodology for {topic}."
|
||||
backstory: "Specialises in rigorous experimental design."
|
||||
llm: "gpt-4o-mini"
|
||||
tasks:
|
||||
design_methodology:
|
||||
description: "Write a reproducible methods section in LaTeX."
|
||||
expected_output: "LaTeX methods section."
|
||||
|
||||
scientific_writer:
|
||||
role: "Scientific Writer"
|
||||
goal: "Assemble the final LaTeX paper on {topic}."
|
||||
backstory: >-
|
||||
Specialised in LaTeX-formatted academic papers, fine-tuned on
|
||||
scientific literature (CAJAL-4B checkpoint).
|
||||
llm: "huggingface/Agnuxo/CAJAL-4B-P2PCLAW"
|
||||
tasks:
|
||||
write_paper:
|
||||
description: >-
|
||||
Combine the literature review and methodology into a full
|
||||
scientific paper on {topic}. Use LaTeX formatting.
|
||||
expected_output: "Complete LaTeX paper."
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Multi-agent scientific paper workflow (Researcher -> Methodologist -> Writer).
|
||||
|
||||
Demonstrates the agent-centric pattern from AGENTS.md: compose specialised
|
||||
Agents with ``AgentTeam`` + sequential ``Task``s instead of creating custom
|
||||
Agent subclasses. The writer uses the CAJAL-4B HuggingFace model; the
|
||||
reviewer and methodologist use general-purpose LLMs.
|
||||
|
||||
Usage:
|
||||
python examples/scientific_writing/multi_agent_paper.py
|
||||
"""
|
||||
from praisonaiagents import Agent, AgentTeam, Task
|
||||
|
||||
from scientific_writer import ( # noqa: E402 — example imports a sibling file
|
||||
build_scientific_writer,
|
||||
format_citation,
|
||||
format_latex_section,
|
||||
)
|
||||
|
||||
|
||||
literature_reviewer = Agent(
|
||||
name="Literature Reviewer",
|
||||
instructions=(
|
||||
"You are an expert at surveying academic literature. "
|
||||
"Produce a concise literature review with 5-8 key citations in APA."
|
||||
),
|
||||
tools=[format_citation],
|
||||
)
|
||||
|
||||
methodology_designer = Agent(
|
||||
name="Methodology Designer",
|
||||
instructions=(
|
||||
"You design rigorous research methodologies. "
|
||||
"Output a clear, reproducible methods section."
|
||||
),
|
||||
tools=[format_latex_section],
|
||||
)
|
||||
|
||||
scientific_writer = build_scientific_writer()
|
||||
|
||||
|
||||
def run(topic: str) -> str:
|
||||
"""Run the 3-agent workflow for a given research ``topic``."""
|
||||
t1 = Task(
|
||||
name="review_literature",
|
||||
description=f"Review literature on: {topic}",
|
||||
agent=literature_reviewer,
|
||||
expected_output="APA literature review.",
|
||||
)
|
||||
t2 = Task(
|
||||
name="design_methodology",
|
||||
description=f"Design research methodology for: {topic}",
|
||||
agent=methodology_designer,
|
||||
expected_output="LaTeX methods section.",
|
||||
)
|
||||
t3 = Task(
|
||||
name="write_paper",
|
||||
description=(
|
||||
f"Combine the literature review and methodology into a full "
|
||||
f"scientific paper on: {topic}. Use LaTeX formatting."
|
||||
),
|
||||
agent=scientific_writer,
|
||||
expected_output="Complete LaTeX paper.",
|
||||
)
|
||||
team = AgentTeam(
|
||||
agents=[literature_reviewer, methodology_designer, scientific_writer],
|
||||
tasks=[t1, t2, t3],
|
||||
)
|
||||
return team.start()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = run("Transformer architectures for protein folding prediction")
|
||||
print(result)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Scientific Paper Writer — single-agent example using the CAJAL model.
|
||||
|
||||
CAJAL (``Agnuxo/CAJAL-4B-P2PCLAW``) is a 4B-parameter HuggingFace model
|
||||
specialised for LaTeX-formatted academic writing. This example shows how
|
||||
to use it with a regular ``Agent`` and ``@tool`` functions — no new Agent
|
||||
subclass is needed. Run 3 ways: Python (this file), CLI, or YAML.
|
||||
|
||||
Requirements:
|
||||
pip install "praisonaiagents[llm]" # for litellm / HuggingFace
|
||||
export HUGGINGFACE_API_KEY=... # or run a local inference server
|
||||
|
||||
Usage:
|
||||
python examples/scientific_writing/scientific_writer.py
|
||||
"""
|
||||
from praisonaiagents import Agent, tool
|
||||
|
||||
|
||||
@tool
|
||||
def format_latex_section(title: str, content: str) -> str:
|
||||
"""Wrap prose content in a LaTeX ``\\section{}`` block.
|
||||
|
||||
Args:
|
||||
title: Section heading (e.g. ``"Introduction"``).
|
||||
content: The body text.
|
||||
|
||||
Returns:
|
||||
LaTeX-formatted section string.
|
||||
"""
|
||||
return f"\\section{{{title}}}\n{content}\n"
|
||||
|
||||
|
||||
@tool
|
||||
def format_citation(authors: str, year: int, title: str, venue: str) -> str:
|
||||
"""Render a single APA-style citation line.
|
||||
|
||||
Args:
|
||||
authors: ``"Smith, J. & Jones, A."``
|
||||
year: Publication year.
|
||||
title: Paper title.
|
||||
venue: Journal / conference name.
|
||||
|
||||
Returns:
|
||||
One APA-formatted citation string.
|
||||
"""
|
||||
return f"{authors} ({year}). {title}. *{venue}*."
|
||||
|
||||
|
||||
SCIENTIFIC_INSTRUCTIONS = """You are a specialised scientific paper writer.
|
||||
|
||||
Produce high-quality academic content with:
|
||||
- Clear structure (Abstract, Introduction, Methodology, Results, Discussion,
|
||||
Conclusion, References)
|
||||
- Rigorous methodology and precise language
|
||||
- Proper APA citations and LaTeX formatting
|
||||
- Honest limitations and future work sections
|
||||
|
||||
Use ``format_latex_section`` to wrap each section in LaTeX, and
|
||||
``format_citation`` to build the References list.
|
||||
"""
|
||||
|
||||
|
||||
def build_scientific_writer(
|
||||
model: str = "huggingface/Agnuxo/CAJAL-4B-P2PCLAW",
|
||||
) -> Agent:
|
||||
"""Construct a scientific-writing Agent.
|
||||
|
||||
Override ``model`` for any HuggingFace / local / API model — the default
|
||||
targets the CAJAL-4B checkpoint for LaTeX-style academic output.
|
||||
"""
|
||||
return Agent(
|
||||
name="Scientific Writer",
|
||||
instructions=SCIENTIFIC_INSTRUCTIONS,
|
||||
llm=model,
|
||||
tools=[format_latex_section, format_citation],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
agent = build_scientific_writer()
|
||||
paper = agent.start(
|
||||
"Write a short scientific paper (Abstract + Introduction + Conclusion) "
|
||||
"on 'Climate change effects on coral reef biodiversity'. "
|
||||
"Use LaTeX formatting and include at least 3 APA citations."
|
||||
)
|
||||
print(paper)
|
||||
@@ -44,12 +44,33 @@ def main():
|
||||
|
||||
def calculate(expression: str) -> str:
|
||||
"""Calculate a math expression safely."""
|
||||
try:
|
||||
# Safe eval for basic math
|
||||
allowed = set("0123456789+-*/.() ")
|
||||
if all(c in allowed for c in expression):
|
||||
return str(eval(expression))
|
||||
import ast
|
||||
|
||||
allowed = set("0123456789+-*/.() ")
|
||||
if not all(c in allowed for c in expression):
|
||||
return "Error: Invalid expression"
|
||||
try:
|
||||
tree = ast.parse(expression, mode="eval")
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(
|
||||
node,
|
||||
(
|
||||
ast.Expression,
|
||||
ast.BinOp,
|
||||
ast.UnaryOp,
|
||||
ast.Constant,
|
||||
ast.Add,
|
||||
ast.Sub,
|
||||
ast.Mult,
|
||||
ast.Div,
|
||||
ast.USub,
|
||||
ast.UAdd,
|
||||
),
|
||||
):
|
||||
return "Error: Invalid expression"
|
||||
return str(
|
||||
eval(compile(tree, "<expr>", "eval"), {"__builtins__": {}})
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
framework: praisonai
|
||||
topic: coding
|
||||
roles:
|
||||
coder:
|
||||
role: Code refactorer
|
||||
goal: Refactor Python modules for better maintainability
|
||||
backstory: Senior engineer with expertise in clean code principles
|
||||
cli_backend: claude-code
|
||||
tasks:
|
||||
refactor:
|
||||
description: Refactor utils.py to improve code quality
|
||||
expected_output: Refactored code with improved structure and documentation
|
||||
@@ -0,0 +1,35 @@
|
||||
platform: linear
|
||||
token: ${LINEAR_OAUTH_TOKEN}
|
||||
signing_secret: ${LINEAR_WEBHOOK_SECRET}
|
||||
webhook_port: 8080
|
||||
|
||||
agent:
|
||||
name: "PraisonAI Coder"
|
||||
instructions: |
|
||||
You are an autonomous coding agent integrated with Linear.
|
||||
|
||||
When you are mentioned or assigned an issue:
|
||||
1. Analyze the issue description and requirements
|
||||
2. Break down complex tasks into smaller steps
|
||||
3. Use available tools to implement solutions
|
||||
4. Update the issue with progress comments
|
||||
5. Create GitHub pull requests when code changes are needed
|
||||
|
||||
Focus on being helpful, thorough, and providing clear updates.
|
||||
llm: gpt-4o-mini
|
||||
tools:
|
||||
- linear_search_issues
|
||||
- linear_get_issue
|
||||
- linear_add_comment
|
||||
- linear_update_issue
|
||||
- linear_list_teams
|
||||
- linear_list_issue_states
|
||||
- github_create_branch
|
||||
- github_commit_and_push
|
||||
- github_create_pull_request
|
||||
- read_file
|
||||
- write_file
|
||||
- execute_command
|
||||
memory: true
|
||||
web_search: true
|
||||
auto_approve_tools: true
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
# Publish security advisories after PyPI release.
|
||||
# Usage: AGENTS_VERSION=1.6.40 WRAPPER_VERSION=4.6.40 ./scripts/security_publish_advisories.sh
|
||||
set -euo pipefail
|
||||
|
||||
REPO="MervinPraison/PraisonAI"
|
||||
AGENTS_VERSION="${AGENTS_VERSION:?Set AGENTS_VERSION (e.g. 1.6.40)}"
|
||||
WRAPPER_VERSION="${WRAPPER_VERSION:?Set WRAPPER_VERSION (e.g. 4.6.40)}"
|
||||
LAST_AGENTS="${LAST_AGENTS:-1.6.39}"
|
||||
LAST_WRAPPER="${LAST_WRAPPER:-4.6.39}"
|
||||
|
||||
# Batch 1 + 2 GHSA IDs (update credits login per advisory via gh api first if needed)
|
||||
AGENTS_GHSAS=(
|
||||
GHSA-4mr5-g6f9-cfrh
|
||||
GHSA-5c6w-wwfq-7qqm
|
||||
GHSA-5cxw-77wg-jrf3
|
||||
)
|
||||
|
||||
WRAPPER_GHSAS=(
|
||||
GHSA-9cr9-25q5-8prj
|
||||
GHSA-86qc-r5v2-v6x6
|
||||
GHSA-hvhp-v2gc-268q
|
||||
GHSA-xp85-6wwf-r67c
|
||||
GHSA-8444-4fhq-fxpq
|
||||
GHSA-78r8-wwqv-r299
|
||||
GHSA-vg22-4gmj-prxw
|
||||
GHSA-6xj3-927j-6pqw
|
||||
)
|
||||
|
||||
patch_advisory() {
|
||||
local ghsa="$1" package="$2" last="$3" patched="$4"
|
||||
gh api "repos/${REPO}/security-advisories/${ghsa}" --method PATCH --input - <<EOF
|
||||
{
|
||||
"state": "draft",
|
||||
"vulnerabilities": [
|
||||
{
|
||||
"package": {"ecosystem": "pip", "name": "${package}"},
|
||||
"vulnerable_version_range": "<= ${last}",
|
||||
"patched_versions": ">= ${patched}"
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
gh api "repos/${REPO}/security-advisories/${ghsa}" --method PATCH -f state=published
|
||||
gh api "repos/${REPO}/security-advisories/${ghsa}/cve" --method POST || true
|
||||
echo "Published ${ghsa}"
|
||||
}
|
||||
|
||||
for ghsa in "${AGENTS_GHSAS[@]}"; do
|
||||
patch_advisory "$ghsa" "praisonaiagents" "$LAST_AGENTS" "$AGENTS_VERSION"
|
||||
done
|
||||
|
||||
for ghsa in "${WRAPPER_GHSAS[@]}"; do
|
||||
patch_advisory "$ghsa" "praisonai" "$LAST_WRAPPER" "$WRAPPER_VERSION"
|
||||
done
|
||||
|
||||
echo "Done. Verify: gh api repos/${REPO}/security-advisories --jq '.[] | select(.state==\"published\") | .ghsa_id'"
|
||||
@@ -202,6 +202,13 @@ _LAZY_IMPORTS = {
|
||||
'FlowDisplay': ('praisonaiagents.flow_display', 'FlowDisplay'),
|
||||
'track_workflow': ('praisonaiagents.flow_display', 'track_workflow'),
|
||||
|
||||
# CLI Backend Protocol (Protocol-driven external CLI integration)
|
||||
'CliBackendProtocol': ('praisonaiagents.cli_backend.protocols', 'CliBackendProtocol'),
|
||||
'CliBackendConfig': ('praisonaiagents.cli_backend.protocols', 'CliBackendConfig'),
|
||||
'CliSessionBinding': ('praisonaiagents.cli_backend.protocols', 'CliSessionBinding'),
|
||||
'CliBackendResult': ('praisonaiagents.cli_backend.protocols', 'CliBackendResult'),
|
||||
'CliBackendDelta': ('praisonaiagents.cli_backend.protocols', 'CliBackendDelta'),
|
||||
|
||||
# Agent classes
|
||||
'Agent': ('praisonaiagents.agent.agent', 'Agent'),
|
||||
'BudgetExceededError': ('praisonaiagents.errors', 'BudgetExceededError'),
|
||||
|
||||
@@ -33,6 +33,10 @@ def __getattr__(name):
|
||||
from .heartbeat import HeartbeatConfig
|
||||
_lazy_cache[name] = HeartbeatConfig
|
||||
return HeartbeatConfig
|
||||
if name == 'InterruptController':
|
||||
from .interrupt import InterruptController
|
||||
_lazy_cache[name] = InterruptController
|
||||
return InterruptController
|
||||
|
||||
# Specialized agents - lazy loaded (import rich)
|
||||
if name == 'ImageAgent':
|
||||
@@ -194,6 +198,7 @@ __all__ = [
|
||||
'BudgetExceededError',
|
||||
'Heartbeat',
|
||||
'HeartbeatConfig',
|
||||
'InterruptController',
|
||||
'ImageAgent',
|
||||
'VideoAgent',
|
||||
'VideoConfig',
|
||||
|
||||
@@ -245,6 +245,7 @@ if TYPE_CHECKING:
|
||||
from ..context.models import ContextConfig
|
||||
from ..context.manager import ContextManager
|
||||
from ..knowledge.knowledge import Knowledge
|
||||
from .interrupt import InterruptController
|
||||
from ..agent.autonomy import AutonomyConfig
|
||||
from ..task.task import Task
|
||||
from .handoff import Handoff, HandoffConfig, HandoffResult
|
||||
@@ -518,6 +519,7 @@ class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, Session
|
||||
model: Optional[Union[str, Any]] = None, # Alias for llm=
|
||||
base_url: Optional[str] = None, # Kept separate (connection/auth)
|
||||
api_key: Optional[str] = None, # Kept separate (connection/auth)
|
||||
auth: Optional[str] = None, # Subscription auth provider: "claude-code", "codex", etc.
|
||||
# Tools
|
||||
tools: Optional[List[Any]] = None,
|
||||
allow_delegation: bool = False, # Deprecated: use handoffs= instead
|
||||
@@ -531,7 +533,7 @@ class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, Session
|
||||
# CONSOLIDATED FEATURE PARAMS (agent-centric API)
|
||||
# Each follows: False=disabled, True=defaults, Config=custom
|
||||
# ============================================================
|
||||
memory: Optional[Union[bool, str, 'MemoryConfig', 'MemoryManager']] = None,
|
||||
memory: Optional[Union[bool, str, 'MemoryConfig', Any]] = None,
|
||||
knowledge: Optional[Union[bool, str, List[str], 'KnowledgeConfig', 'Knowledge']] = None,
|
||||
planning: Optional[Union[bool, str, 'PlanningConfig']] = False,
|
||||
reflection: Optional[Union[bool, str, 'ReflectionConfig']] = None,
|
||||
@@ -551,6 +553,8 @@ class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, Session
|
||||
parallel_tool_calls: bool = False, # Gap 2: Enable parallel execution of batched LLM tool calls
|
||||
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")
|
||||
interrupt_controller: Optional['InterruptController'] = None, # G2: Cooperative cancellation
|
||||
):
|
||||
"""Initialize an Agent instance.
|
||||
|
||||
@@ -575,7 +579,7 @@ class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, Session
|
||||
memory: Memory system configuration. Accepts:
|
||||
- bool: True enables defaults, False disables
|
||||
- MemoryConfig: Custom configuration
|
||||
- MemoryManager: Pre-configured instance
|
||||
- Any: Pre-configured memory instance
|
||||
knowledge: Knowledge sources. Accepts:
|
||||
- bool: True enables defaults
|
||||
- List[str]: File paths, URLs, or text content
|
||||
@@ -647,6 +651,13 @@ class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, Session
|
||||
- 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.
|
||||
|
||||
Raises:
|
||||
ValueError: If all of name, role, goal, backstory, and instructions are None.
|
||||
@@ -1458,6 +1469,8 @@ class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, Session
|
||||
self.instructions = instructions
|
||||
# Gap 2: Store parallel tool calls setting for ToolCallExecutor selection
|
||||
self.parallel_tool_calls = parallel_tool_calls
|
||||
# G2: Store interrupt controller for cooperative cancellation
|
||||
self.interrupt_controller = interrupt_controller
|
||||
# Check for model name in environment variable if not provided
|
||||
self._using_custom_llm = False
|
||||
# Flag to track if final result has been displayed to prevent duplicates
|
||||
@@ -1509,6 +1522,8 @@ class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, Session
|
||||
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
|
||||
self.llm_instance = LLM(**llm_config)
|
||||
self.llm = llm.get('model', Agent._get_default_model())
|
||||
@@ -1519,6 +1534,7 @@ class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, Session
|
||||
model=model_name,
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
auth=auth,
|
||||
metrics=metrics,
|
||||
web_search=web_search,
|
||||
web_fetch=web_fetch,
|
||||
@@ -1540,6 +1556,10 @@ class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, Session
|
||||
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
|
||||
@@ -1559,6 +1579,8 @@ class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, Session
|
||||
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
|
||||
@@ -1693,6 +1715,9 @@ Your Goal: {self.goal}
|
||||
self.prompt_caching = prompt_caching
|
||||
self.claude_memory = claude_memory
|
||||
|
||||
# Initialize closure flag for GC cleanup
|
||||
self._closed = False
|
||||
|
||||
# Session management
|
||||
self.auto_save = auto_save # Session name for auto-saving
|
||||
|
||||
@@ -1756,6 +1781,31 @@ Your Goal: {self.goal}
|
||||
self._approval_backend = None
|
||||
self._approve_all_tools = False
|
||||
self._approval_timeout = 0
|
||||
# No explicit approval kwarg — honour PRAISONAI_TOOL_SAFETY.
|
||||
# Default preset "default" blocks only destructive ops
|
||||
# (delete_*, execute_command, execute_code, kill_process, move/copy)
|
||||
# while leaving read / create / edit tools fully auto-approved.
|
||||
# Users who want the pre-4.6.27 "trust everything" behaviour
|
||||
# export PRAISONAI_TOOL_SAFETY=off. This adds zero Agent kwargs.
|
||||
_raw_safety_env = os.environ.get("PRAISONAI_TOOL_SAFETY")
|
||||
_safety_env = (_raw_safety_env or "").strip().lower()
|
||||
if _safety_env not in ("off", "full", "none", "0", "false"):
|
||||
from ..approval.registry import PERMISSION_PRESETS
|
||||
_resolved_safety_env = _safety_env or "default"
|
||||
_preset_deny = PERMISSION_PRESETS.get(_resolved_safety_env)
|
||||
if _preset_deny is None and _safety_env:
|
||||
# Unknown env value - fall back to safe default and log warning
|
||||
# (logging is already imported at module level; a local `import logging`
|
||||
# here would shadow it and cause UnboundLocalError on earlier uses
|
||||
# inside __init__ such as the custom-LLM tools branch.)
|
||||
logging.getLogger(__name__).warning(
|
||||
"Unknown PRAISONAI_TOOL_SAFETY value %r; falling back to 'default' preset.",
|
||||
_raw_safety_env,
|
||||
)
|
||||
_resolved_safety_env = "default"
|
||||
_preset_deny = PERMISSION_PRESETS.get(_resolved_safety_env)
|
||||
if _preset_deny is not None:
|
||||
self._perm_deny = _preset_deny
|
||||
elif isinstance(approval, ApprovalConfig):
|
||||
self._approval_backend = approval.backend
|
||||
self._approve_all_tools = approval.all_tools
|
||||
@@ -1781,6 +1831,7 @@ Your Goal: {self.goal}
|
||||
self._approval_backend = AutoApproveBackend()
|
||||
# Pending approvals for async (non-blocking) mode
|
||||
self._pending_approvals = {}
|
||||
self._approvals_lock = asyncio.Lock()
|
||||
|
||||
# P8/G11: Tool timeout - prevent slow tools from blocking
|
||||
self._tool_timeout = tool_timeout
|
||||
@@ -1886,6 +1937,11 @@ Your Goal: {self.goal}
|
||||
|
||||
# Backend - external managed agent backend for hybrid execution
|
||||
self.backend = backend
|
||||
|
||||
# CLI Backend - external CLI backend for delegating full turns
|
||||
self._cli_backend = None
|
||||
if cli_backend is not None:
|
||||
self._cli_backend = self._resolve_cli_backend(cli_backend)
|
||||
|
||||
# Telemetry - lazy initialized via property for performance
|
||||
self.__telemetry = None
|
||||
@@ -1910,13 +1966,29 @@ Your Goal: {self.goal}
|
||||
|
||||
@chat_history.setter
|
||||
def chat_history(self, value):
|
||||
"""Set chat history (updates the underlying async-safe state)."""
|
||||
self.__chat_history_state.value = value
|
||||
"""Set chat history (updates the underlying async-safe state with lock)."""
|
||||
with self.__chat_history_state.lock():
|
||||
self.__chat_history_state.value = value
|
||||
|
||||
@property
|
||||
def _history_lock(self):
|
||||
"""Get appropriate lock for chat history based on execution context."""
|
||||
return self.__chat_history_state
|
||||
|
||||
def _append_to_chat_history(self, message: dict):
|
||||
"""Thread-safe append to chat history using proper locking."""
|
||||
with self._history_lock.lock():
|
||||
self._history_lock.value.append(message)
|
||||
|
||||
def _truncate_chat_history(self, length: int):
|
||||
"""Thread-safe truncation of chat history using proper locking."""
|
||||
with self._history_lock.lock():
|
||||
self._history_lock.value[:] = self._history_lock.value[:length]
|
||||
|
||||
def _replace_chat_history(self, new_history: List[Dict[str, Any]]):
|
||||
"""Thread-safe replacement of entire chat history using proper locking."""
|
||||
with self._history_lock.lock():
|
||||
self._history_lock.value[:] = new_history
|
||||
|
||||
@property
|
||||
def _cache_lock(self):
|
||||
@@ -2835,6 +2907,19 @@ Summary:"""
|
||||
started_at=started_at,
|
||||
)
|
||||
|
||||
# G2: Check for interrupt request (cooperative cancellation) - sync version
|
||||
if self.interrupt_controller and self.interrupt_controller.is_set():
|
||||
reason = self.interrupt_controller.reason or "unknown"
|
||||
return AutonomyResult(
|
||||
success=False,
|
||||
output=f"Task interrupted: {reason}",
|
||||
completion_reason="interrupted",
|
||||
iterations=iterations,
|
||||
stage=stage,
|
||||
actions=actions_taken,
|
||||
duration_seconds=time_module.time() - start_time,
|
||||
started_at=started_at,
|
||||
)
|
||||
|
||||
# Execute one turn using the agent's chat method
|
||||
# Always use the original prompt (prompt re-injection)
|
||||
@@ -3232,6 +3317,20 @@ Summary:"""
|
||||
started_at=started_at,
|
||||
)
|
||||
|
||||
# G2: Check for interrupt request (cooperative cancellation)
|
||||
if self.interrupt_controller and self.interrupt_controller.is_set():
|
||||
reason = self.interrupt_controller.reason or "unknown"
|
||||
return AutonomyResult(
|
||||
success=False,
|
||||
output=f"Task interrupted: {reason}",
|
||||
completion_reason="interrupted",
|
||||
iterations=iterations,
|
||||
stage=stage,
|
||||
actions=actions_taken,
|
||||
duration_seconds=time_module.time() - start_time,
|
||||
started_at=started_at,
|
||||
)
|
||||
|
||||
|
||||
# Execute one turn using the agent's async chat method
|
||||
# Always use the original prompt (prompt re-injection)
|
||||
@@ -4604,9 +4703,141 @@ Answer:"""
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
# -------------------------------------------------------------------------
|
||||
# CLI Backend Management
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _resolve_cli_backend(self, cli_backend):
|
||||
"""Validate and return CLI backend protocol instance.
|
||||
|
||||
Args:
|
||||
cli_backend: CliBackendProtocol instance or callable that returns one
|
||||
|
||||
Returns:
|
||||
CliBackendProtocol instance
|
||||
|
||||
Note:
|
||||
String backend IDs must be resolved to instances in the wrapper layer
|
||||
before passing to Agent. This maintains proper dependency direction
|
||||
per AGENTS.md (core SDK should not import from wrapper).
|
||||
"""
|
||||
# Import protocols
|
||||
try:
|
||||
from ..cli_backend import CliBackendProtocol
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"CLI backend features requested but protocols not available. "
|
||||
"This should not happen in a properly installed praisonaiagents package."
|
||||
)
|
||||
|
||||
# If already a protocol instance, return as-is
|
||||
if hasattr(cli_backend, 'execute') and hasattr(cli_backend, 'stream'):
|
||||
return cli_backend
|
||||
|
||||
# If callable (factory function), call it to get instance
|
||||
if callable(cli_backend):
|
||||
instance = cli_backend()
|
||||
if hasattr(instance, 'execute') and hasattr(instance, 'stream'):
|
||||
return instance
|
||||
raise TypeError(f"CLI backend factory returned invalid type: {type(instance)}. Expected CliBackendProtocol.")
|
||||
|
||||
# String IDs are no longer supported at core level - must be resolved in wrapper
|
||||
if isinstance(cli_backend, str):
|
||||
raise TypeError(
|
||||
f"String CLI backend IDs ('{cli_backend}') must be resolved to instances "
|
||||
"in the wrapper layer. Use: praisonai.Agent(cli_backend='claude-code') "
|
||||
"or manually resolve: agent = praisonaiagents.Agent(cli_backend=resolve_cli_backend('claude-code'))"
|
||||
)
|
||||
|
||||
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]:
|
||||
"""Chat implementation using CLI backend delegation.
|
||||
|
||||
Args:
|
||||
prompt: User prompt
|
||||
**kwargs: Additional chat parameters (passed through as metadata)
|
||||
|
||||
Returns:
|
||||
CLI backend response content
|
||||
"""
|
||||
if not self._cli_backend:
|
||||
raise RuntimeError("CLI backend not configured")
|
||||
|
||||
try:
|
||||
# Import backend types
|
||||
from ..cli_backend import CliSessionBinding
|
||||
|
||||
# Build session binding for state management
|
||||
session_id = getattr(self, '_session_id', None) or f"agent-{self.agent_id}"
|
||||
session_binding = CliSessionBinding(session_id=session_id)
|
||||
|
||||
# Get system prompt from agent configuration
|
||||
system_prompt = None
|
||||
if hasattr(self, 'system_prompt') and self.system_prompt:
|
||||
system_prompt = self.system_prompt
|
||||
elif self.backstory or self.role or self.goal:
|
||||
# Build system prompt from agent identity
|
||||
parts = []
|
||||
if self.backstory:
|
||||
parts.append(self.backstory)
|
||||
if self.role:
|
||||
parts.append(f"Your Role: {self.role}")
|
||||
if self.goal:
|
||||
parts.append(f"Your Goal: {self.goal}")
|
||||
system_prompt = "\n".join(parts)
|
||||
|
||||
# Extract images from attachments (if any)
|
||||
images = kwargs.get('attachments')
|
||||
if images:
|
||||
# Filter for image files
|
||||
image_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'}
|
||||
images = [
|
||||
img for img in images
|
||||
if any(img.lower().endswith(ext) for ext in image_extensions)
|
||||
]
|
||||
if not images:
|
||||
images = None
|
||||
|
||||
# Execute CLI backend
|
||||
result = await self._cli_backend.execute(
|
||||
prompt=prompt,
|
||||
session=session_binding,
|
||||
images=images,
|
||||
system_prompt=system_prompt
|
||||
)
|
||||
|
||||
# Check for CLI backend errors
|
||||
if result is None:
|
||||
raise RuntimeError(
|
||||
f"CLI backend returned no result for agent={self.display_name!r}, "
|
||||
f"session_id={session_id!r}"
|
||||
)
|
||||
if getattr(result, "error", None):
|
||||
raise RuntimeError(
|
||||
f"CLI backend failed for agent={self.display_name!r}, "
|
||||
f"session_id={session_id!r}: {result.error}"
|
||||
)
|
||||
|
||||
# Update chat history with the exchange
|
||||
if hasattr(self, '_append_to_chat_history'):
|
||||
self._append_to_chat_history({
|
||||
"role": "user",
|
||||
"content": prompt
|
||||
})
|
||||
if result.content:
|
||||
self._append_to_chat_history({
|
||||
"role": "assistant",
|
||||
"content": result.content
|
||||
})
|
||||
|
||||
return result.content if result else None
|
||||
|
||||
except Exception as e:
|
||||
raise RuntimeError(
|
||||
f"CLI backend execution failed for agent={self.display_name!r}: {e}"
|
||||
) from e
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Resource Lifecycle Management
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -4631,10 +4862,10 @@ Answer:"""
|
||||
if hasattr(self.llm_instance, 'aclose'):
|
||||
# Try async close first
|
||||
try:
|
||||
import asyncio
|
||||
if asyncio.iscoroutinefunction(self.llm_instance.aclose):
|
||||
# We're in sync context, so use asyncio.run() for the cleanup
|
||||
asyncio.run(self.llm_instance.aclose())
|
||||
# Use async bridge to safely close from any context
|
||||
from ..utils.async_bridge import run_coroutine_from_any_context
|
||||
run_coroutine_from_any_context(self.llm_instance.aclose())
|
||||
else:
|
||||
self.llm_instance.aclose()
|
||||
except Exception:
|
||||
@@ -4681,6 +4912,19 @@ Answer:"""
|
||||
except Exception as e:
|
||||
logger.warning(f"Task cleanup failed: {e}")
|
||||
|
||||
# ThreadPoolExecutor cleanup
|
||||
try:
|
||||
if hasattr(self, '_tool_executor') and self._tool_executor:
|
||||
# Use cancel_futures only if supported (Python 3.9+)
|
||||
import sys
|
||||
if sys.version_info >= (3, 9):
|
||||
self._tool_executor.shutdown(wait=False, cancel_futures=True)
|
||||
else:
|
||||
self._tool_executor.shutdown(wait=False)
|
||||
delattr(self, '_tool_executor')
|
||||
except Exception as e:
|
||||
logger.warning(f"ThreadPoolExecutor cleanup failed: {e}")
|
||||
|
||||
# Always set closed flag
|
||||
self._closed = True
|
||||
|
||||
@@ -4715,6 +4959,23 @@ Answer:"""
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# ThreadPoolExecutor cleanup (async-safe)
|
||||
if hasattr(self, '_tool_executor') and self._tool_executor:
|
||||
import sys
|
||||
loop = asyncio.get_running_loop()
|
||||
# Use run_in_executor to avoid blocking the event loop
|
||||
if sys.version_info >= (3, 9):
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self._tool_executor.shutdown(wait=False, cancel_futures=True)
|
||||
)
|
||||
else:
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self._tool_executor.shutdown(wait=False)
|
||||
)
|
||||
delattr(self, '_tool_executor')
|
||||
|
||||
self._closed = True
|
||||
|
||||
@@ -4754,8 +5015,21 @@ Answer:"""
|
||||
await self.aclose()
|
||||
|
||||
def __del__(self):
|
||||
"""Destructor safely does nothing to avoid GC pollution in test loops."""
|
||||
pass
|
||||
"""Lightweight cleanup that only closes resources if not already closed."""
|
||||
if not getattr(self, '_closed', False):
|
||||
# Only close connections, skip anything that could fail during GC
|
||||
try:
|
||||
memory = getattr(self, "_memory_instance", None)
|
||||
if memory and hasattr(memory, 'close_connections'):
|
||||
memory.close_connections()
|
||||
except Exception as exc: # noqa: BLE001 - finalizers must not raise
|
||||
import contextlib
|
||||
with contextlib.suppress(Exception):
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.debug("Agent GC memory cleanup failed: %s", exc)
|
||||
finally:
|
||||
self._closed = True
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool:
|
||||
|
||||
@@ -8,6 +8,7 @@ import asyncio
|
||||
import threading
|
||||
from typing import Any, Optional, Union
|
||||
from contextlib import contextmanager, asynccontextmanager
|
||||
from weakref import WeakKeyDictionary
|
||||
|
||||
|
||||
class DualLock:
|
||||
@@ -28,15 +29,16 @@ class DualLock:
|
||||
pass
|
||||
|
||||
# In async context
|
||||
async with lock.async():
|
||||
async with lock.async_lock():
|
||||
# Uses asyncio.Lock
|
||||
pass
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize with unified thread-safe locking."""
|
||||
self._thread_lock = threading.Lock() # Single canonical lock for all contexts
|
||||
"""Initialize with separate threading and asyncio locks."""
|
||||
self._thread_lock = threading.RLock() # Re-entrant lock to handle nested acquisitions
|
||||
self._async_locks = WeakKeyDictionary() # Per-event-loop async locks
|
||||
|
||||
@contextmanager
|
||||
def sync(self):
|
||||
@@ -44,15 +46,27 @@ class DualLock:
|
||||
with self._thread_lock:
|
||||
yield
|
||||
|
||||
def _get_async_lock(self):
|
||||
"""Get or create an asyncio.Lock for the current event loop."""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
raise RuntimeError("async_lock() must be called from an async context")
|
||||
|
||||
if loop not in self._async_locks:
|
||||
self._async_locks[loop] = asyncio.Lock()
|
||||
return self._async_locks[loop]
|
||||
|
||||
@asynccontextmanager
|
||||
async def async_lock(self):
|
||||
"""Acquire lock in asynchronous context using threading.Lock via asyncio.to_thread()."""
|
||||
# Use asyncio.to_thread to acquire the thread lock without blocking the event loop
|
||||
await asyncio.to_thread(self._thread_lock.acquire)
|
||||
try:
|
||||
"""Acquire lock in asynchronous context using asyncio.Lock.
|
||||
|
||||
Uses a per-event-loop asyncio.Lock to ensure proper async coordination
|
||||
without blocking the event loop or violating thread ownership semantics.
|
||||
"""
|
||||
async_lock = self._get_async_lock()
|
||||
async with async_lock:
|
||||
yield
|
||||
finally:
|
||||
self._thread_lock.release()
|
||||
|
||||
def is_async_context(self) -> bool:
|
||||
"""Check if we're currently in an async context."""
|
||||
@@ -114,12 +128,14 @@ class AsyncSafeState:
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Support for asynchronous context manager protocol."""
|
||||
await asyncio.to_thread(self._lock._thread_lock.acquire)
|
||||
async_lock = self._lock._get_async_lock()
|
||||
await async_lock.acquire()
|
||||
return self.value
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Support for asynchronous context manager protocol."""
|
||||
self._lock._thread_lock.release()
|
||||
async_lock = self._lock._get_async_lock()
|
||||
async_lock.release()
|
||||
return None
|
||||
|
||||
def get(self) -> Any:
|
||||
|
||||
@@ -11,8 +11,6 @@ import time
|
||||
import json
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
import asyncio
|
||||
import threading
|
||||
from ..errors import BudgetExceededError
|
||||
|
||||
# Fallback helpers to avoid circular imports
|
||||
@@ -45,9 +43,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
import traceback
|
||||
from typing import List, Optional, Any, Dict, Union, Callable, Generator, TYPE_CHECKING
|
||||
from collections import OrderedDict
|
||||
from typing import List, Optional, Any, Dict, Union, Generator, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
@@ -511,7 +507,7 @@ Your Goal: {self.goal}"""
|
||||
emit_events=True
|
||||
)
|
||||
|
||||
def _chat_completion(self, messages, temperature=1.0, tools=None, stream=True, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None):
|
||||
def _chat_completion(self, messages, temperature=1.0, tools=None, stream=True, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, _retry_depth=0):
|
||||
start_time = time.time()
|
||||
|
||||
# --- Context compaction (opt-in via ExecutionConfig.context_compaction) ---
|
||||
@@ -560,7 +556,11 @@ Your Goal: {self.goal}"""
|
||||
temperature=temperature
|
||||
)
|
||||
self._hook_runner.execute_sync(HookEvent.BEFORE_LLM, before_llm_input)
|
||||
|
||||
# C7 — honour any BEFORE_LLM hook that mutated the message stream
|
||||
# (e.g. PII redactor). The runner applies modified_input in-place on
|
||||
# before_llm_input.messages; adopt that value for the actual LLM call.
|
||||
messages = before_llm_input.messages
|
||||
|
||||
logging.debug(f"{self.name} sending messages to LLM: {messages}")
|
||||
|
||||
# Emit LLM request trace event (zero overhead when not set)
|
||||
@@ -667,6 +667,7 @@ Your Goal: {self.goal}"""
|
||||
except BudgetExceededError:
|
||||
raise
|
||||
except Exception as e:
|
||||
from ..errors import LLMError
|
||||
error_str = str(e).lower()
|
||||
|
||||
# Check if this is a context overflow error
|
||||
@@ -699,11 +700,22 @@ Your Goal: {self.goal}"""
|
||||
f"{estimate_messages_tokens(truncated_messages)} tokens"
|
||||
)
|
||||
|
||||
# Retry with truncated messages (recursive call with truncated context)
|
||||
return self._chat_completion(
|
||||
truncated_messages, temperature, tools, stream,
|
||||
reasoning_steps, task_name, task_description, task_id, response_format
|
||||
)
|
||||
# Retry with truncated messages (recursive call with depth limit)
|
||||
if _retry_depth < 2:
|
||||
return self._chat_completion(
|
||||
truncated_messages, temperature, tools, stream,
|
||||
reasoning_steps, task_name, task_description, task_id, response_format,
|
||||
_retry_depth=_retry_depth + 1
|
||||
)
|
||||
else:
|
||||
logging.error(f"[{self.name}] Context overflow retry limit exceeded")
|
||||
raise LLMError(
|
||||
f"Context overflow could not be resolved after {_retry_depth} attempts",
|
||||
model_name=model_name, agent_id=self.name, is_retryable=False
|
||||
) from e
|
||||
except LLMError:
|
||||
# Re-raise LLMError (including depth limit errors) without swallowing
|
||||
raise
|
||||
except Exception as recovery_error:
|
||||
logging.error(f"[{self.name}] Overflow recovery failed: {recovery_error}")
|
||||
|
||||
@@ -715,8 +727,47 @@ Your Goal: {self.goal}"""
|
||||
finish_reason="error",
|
||||
response_content=str(e), # Include error for context replay
|
||||
)
|
||||
_get_display_functions()['display_error'](f"Error in chat completion: {e}")
|
||||
return None
|
||||
|
||||
# Classify and raise structured error with improved transient failure detection
|
||||
model_name = self.llm if isinstance(self.llm, str) else "unknown"
|
||||
session_id = getattr(self, '_session_id', 'unknown')
|
||||
|
||||
# Check for retryable errors (rate limits, transient network issues, provider errors)
|
||||
retryable_indicators = [
|
||||
"rate limit", "429", "too many requests",
|
||||
"timeout", "connection reset", "connection error", "socket error",
|
||||
"500", "502", "503", "504", "service unavailable", "internal server error",
|
||||
"dns", "network", "connection refused"
|
||||
]
|
||||
|
||||
# Check for non-retryable errors (auth issues)
|
||||
auth_indicators = ["401", "403", "authentication", "unauthorized", "invalid_api_key"]
|
||||
|
||||
if any(phrase in error_str.lower() for phrase in retryable_indicators):
|
||||
is_retryable = True
|
||||
elif any(phrase in error_str.lower() for phrase in auth_indicators):
|
||||
is_retryable = False
|
||||
else:
|
||||
# Default to retryable for unknown errors to be more resilient
|
||||
is_retryable = True
|
||||
|
||||
# Create LLMError with contextual metadata
|
||||
error = LLMError(
|
||||
str(e),
|
||||
model_name=model_name,
|
||||
agent_id=self.name,
|
||||
is_retryable=is_retryable,
|
||||
session_id=session_id
|
||||
)
|
||||
|
||||
# Call error hook if available for error interception
|
||||
if hasattr(self, 'on_error') and self.on_error:
|
||||
try:
|
||||
self.on_error(error)
|
||||
except Exception as hook_error:
|
||||
logging.debug(f"Error in on_error hook: {hook_error}")
|
||||
|
||||
raise error from e
|
||||
|
||||
def _execute_unified_chat_completion(
|
||||
self,
|
||||
@@ -1098,7 +1149,7 @@ Your Goal: {self.goal}"""
|
||||
)
|
||||
return rendered
|
||||
|
||||
def chat(self, prompt: str, temperature: float = 1.0, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None) -> Optional[str]:
|
||||
def chat(self, prompt: str, temperature: float = 1.0, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None, seed: Optional[int] = None, cancel_token: Optional[Any] = None) -> Optional[str]:
|
||||
"""
|
||||
Chat with the agent.
|
||||
|
||||
@@ -1142,11 +1193,17 @@ Your Goal: {self.goal}"""
|
||||
_trace_emitter.agent_start(self.name, {"role": self.role, "goal": self.goal})
|
||||
|
||||
try:
|
||||
return self._chat_impl(prompt, temperature, tools, output_json, output_pydantic, reasoning_steps, stream, task_name, task_description, task_id, config, force_retrieval, skip_retrieval, attachments, _trace_emitter, tool_choice)
|
||||
# C2 — cooperative cancellation: abort early if a pre-set token is given
|
||||
_cancel = cancel_token if cancel_token is not None else getattr(self, "interrupt_controller", None)
|
||||
if _cancel is not None and getattr(_cancel, "is_set", lambda: False)():
|
||||
reason = getattr(_cancel, "reason", None) or "cancelled before LLM call"
|
||||
raise InterruptedError(f"Agent chat cancelled: {reason}")
|
||||
|
||||
return self._chat_impl(prompt, temperature, tools, output_json, output_pydantic, reasoning_steps, stream, task_name, task_description, task_id, config, force_retrieval, skip_retrieval, attachments, _trace_emitter, tool_choice, seed=seed, cancel_token=_cancel)
|
||||
finally:
|
||||
_trace_emitter.agent_end(self.name)
|
||||
|
||||
def _chat_impl(self, prompt, temperature, tools, output_json, output_pydantic, reasoning_steps, stream, task_name, task_description, task_id, config, force_retrieval, skip_retrieval, attachments, _trace_emitter, tool_choice=None):
|
||||
def _chat_impl(self, prompt, temperature, tools, output_json, output_pydantic, reasoning_steps, stream, task_name, task_description, task_id, config, force_retrieval, skip_retrieval, attachments, _trace_emitter, tool_choice=None, seed=None, cancel_token=None):
|
||||
"""Internal chat implementation (extracted for trace wrapping)."""
|
||||
# Apply rate limiter if configured (before any LLM call)
|
||||
if self._rate_limiter is not None:
|
||||
@@ -1367,7 +1424,16 @@ Your Goal: {self.goal}"""
|
||||
effective_tool_choice = tool_choice or getattr(self, '_yaml_tool_choice', None)
|
||||
if effective_tool_choice:
|
||||
llm_kwargs['tool_choice'] = effective_tool_choice
|
||||
|
||||
|
||||
# C1 — per-call seed overrides llm_instance.seed for determinism
|
||||
if seed is not None:
|
||||
llm_kwargs['seed'] = seed
|
||||
|
||||
# C2 — last-chance cancel check before handing to the LLM
|
||||
if cancel_token is not None and getattr(cancel_token, 'is_set', lambda: False)():
|
||||
reason = getattr(cancel_token, 'reason', None) or 'cancelled'
|
||||
raise InterruptedError(f"Agent chat cancelled: {reason}")
|
||||
|
||||
response_text = self.llm_instance.get_response(**llm_kwargs)
|
||||
|
||||
self._add_to_chat_history("assistant", response_text)
|
||||
@@ -1388,11 +1454,11 @@ Your Goal: {self.goal}"""
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed for custom LLM: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self.chat_history = self.chat_history[:chat_history_length]
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
except Exception as e:
|
||||
# Rollback chat history if LLM call fails
|
||||
self.chat_history = self.chat_history[:chat_history_length]
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
_get_display_functions()['display_error'](f"Error in LLM chat: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
@@ -1433,7 +1499,7 @@ Your Goal: {self.goal}"""
|
||||
self.chat_history[-1].get("role") == "user" and
|
||||
self.chat_history[-1].get("content") == normalized_content):
|
||||
# Add user message to chat history BEFORE LLM call so handoffs can access it
|
||||
self.chat_history.append({"role": "user", "content": normalized_content})
|
||||
self._append_to_chat_history({"role": "user", "content": normalized_content})
|
||||
# Persist user message to DB (OpenAI path)
|
||||
self._persist_message("user", normalized_content)
|
||||
|
||||
@@ -1477,7 +1543,7 @@ Your Goal: {self.goal}"""
|
||||
response = self._chat_completion(messages, temperature=temperature, tools=tools if tools else None, reasoning_steps=reasoning_steps, stream=stream, task_name=task_name, task_description=task_description, task_id=task_id, response_format=response_format)
|
||||
if not response:
|
||||
# Rollback chat history on response failure
|
||||
self.chat_history = self.chat_history[:chat_history_length]
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
|
||||
# Handle None content (can happen with tool calls or empty responses)
|
||||
@@ -1488,7 +1554,7 @@ Your Goal: {self.goal}"""
|
||||
if output_json or output_pydantic:
|
||||
# Add to chat history and return raw response
|
||||
# User message already added before LLM call via _build_messages
|
||||
self.chat_history.append({"role": "assistant", "content": response_text})
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
# Persist assistant message to DB
|
||||
self._persist_message("assistant", response_text)
|
||||
# Apply guardrail validation even for JSON output
|
||||
@@ -1500,12 +1566,12 @@ Your Goal: {self.goal}"""
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed for JSON output: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self.chat_history = self.chat_history[:chat_history_length]
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
|
||||
if not self.self_reflect:
|
||||
# User message already added before LLM call via _build_messages
|
||||
self.chat_history.append({"role": "assistant", "content": response_text})
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
# Persist assistant message to DB (non-reflect path)
|
||||
self._persist_message("assistant", response_text)
|
||||
if self.verbose:
|
||||
@@ -1521,7 +1587,7 @@ Your Goal: {self.goal}"""
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed for reasoning content: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self.chat_history = self.chat_history[:chat_history_length]
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
# Apply guardrail to regular response
|
||||
try:
|
||||
@@ -1532,7 +1598,7 @@ Your Goal: {self.goal}"""
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self.chat_history = self.chat_history[:chat_history_length]
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
|
||||
reflection_prompt = f"""
|
||||
@@ -1591,7 +1657,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
if self.verbose:
|
||||
_get_display_functions()['display_self_reflection']("Agent marked the response as satisfactory after meeting minimum reflections", console=self.console)
|
||||
# User message already added before LLM call via _build_messages
|
||||
self.chat_history.append({"role": "assistant", "content": response_text})
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
# Apply guardrail validation after satisfactory reflection
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
@@ -1602,7 +1668,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed after reflection: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self.chat_history = self.chat_history[:chat_history_length]
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
self._end_run(None, "error", {"error": str(e)})
|
||||
return None
|
||||
|
||||
@@ -1611,7 +1677,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
if self.verbose:
|
||||
_get_display_functions()['display_self_reflection']("Maximum reflection count reached, returning current response", console=self.console)
|
||||
# User message already added before LLM call via _build_messages
|
||||
self.chat_history.append({"role": "assistant", "content": response_text})
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
# Apply guardrail validation after max reflections
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
@@ -1621,7 +1687,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed after max reflections: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self.chat_history = self.chat_history[:chat_history_length]
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
|
||||
# If not satisfactory and not at max reflections, continue with regeneration
|
||||
@@ -1648,7 +1714,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# Catch any exceptions that escape the while loop
|
||||
_get_display_functions()['display_error'](f"Unexpected error in chat: {e}", console=self.console)
|
||||
# Rollback chat history
|
||||
self.chat_history = self.chat_history[:chat_history_length]
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
|
||||
def clean_json_output(self, output: str) -> str:
|
||||
@@ -1663,7 +1729,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
cleaned = cleaned[:-3].strip()
|
||||
return cleaned
|
||||
|
||||
async def achat(self, prompt: str, temperature: float = 1.0, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None):
|
||||
async def achat(self, prompt: str, temperature: float = 1.0, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None, seed: Optional[int] = None, cancel_token: Optional[Any] = None):
|
||||
"""Async version of chat method with self-reflection support.
|
||||
|
||||
Args:
|
||||
@@ -1686,13 +1752,20 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
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, _trace_emitter=_trace_emitter, tool_choice=tool_choice
|
||||
attachments=attachments, _trace_emitter=_trace_emitter, tool_choice=tool_choice,
|
||||
seed=seed, cancel_token=cancel_token
|
||||
)
|
||||
finally:
|
||||
_trace_emitter.agent_end(self.name)
|
||||
|
||||
async def _achat_impl(self, prompt, temperature, tools, output_json, output_pydantic, reasoning_steps, stream, task_name, task_description, task_id, config, force_retrieval, skip_retrieval, attachments, _trace_emitter, tool_choice=None):
|
||||
async def _achat_impl(self, prompt, temperature, tools, output_json, output_pydantic, reasoning_steps, stream, task_name, task_description, task_id, config, force_retrieval, skip_retrieval, attachments, _trace_emitter, tool_choice=None, seed=None, cancel_token=None):
|
||||
"""Internal async chat implementation (extracted for trace wrapping)."""
|
||||
# C2 — cooperative cancellation: abort early if a pre-set token is given
|
||||
_cancel = cancel_token if cancel_token is not None else getattr(self, "interrupt_controller", None)
|
||||
if _cancel is not None and getattr(_cancel, "is_set", lambda: False)():
|
||||
reason = getattr(_cancel, "reason", None) or "cancelled before LLM call"
|
||||
raise InterruptedError(f"Agent chat cancelled: {reason}")
|
||||
|
||||
# Use agent's stream setting if not explicitly provided
|
||||
if stream is None:
|
||||
stream = self.stream
|
||||
@@ -1780,7 +1853,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
self.chat_history[-1].get("role") == "user" and
|
||||
self.chat_history[-1].get("content") == normalized_content):
|
||||
# Add user message to chat history BEFORE LLM call so handoffs can access it
|
||||
self.chat_history.append({"role": "user", "content": normalized_content})
|
||||
self._append_to_chat_history({"role": "user", "content": normalized_content})
|
||||
|
||||
# --- Context compaction (async custom LLM path) ---
|
||||
_exec_cfg = getattr(self, 'execution', None)
|
||||
@@ -1798,7 +1871,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
if getattr(self, '_strict_hooks', False):
|
||||
raise
|
||||
_ch, _cr = _cw.compact(self.chat_history)
|
||||
self.chat_history[:] = _ch
|
||||
self._replace_chat_history(_ch)
|
||||
logging.info(
|
||||
f"[compaction] {self.name}: {_cr.original_tokens}→{_cr.compacted_tokens} tokens "
|
||||
f"({_cr.messages_removed} messages removed)"
|
||||
@@ -1815,33 +1888,45 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
logging.debug(f"[compaction] skipped (non-fatal): {_ce}")
|
||||
|
||||
try:
|
||||
response_text = await self.llm_instance.get_response_async(
|
||||
prompt=prompt,
|
||||
system_prompt=self._build_system_prompt(tools),
|
||||
chat_history=self.chat_history,
|
||||
temperature=temperature,
|
||||
tools=tools,
|
||||
output_json=output_json,
|
||||
output_pydantic=output_pydantic,
|
||||
verbose=self.verbose,
|
||||
markdown=self.markdown,
|
||||
reflection=self.self_reflect,
|
||||
max_reflect=self.max_reflect,
|
||||
min_reflect=self.min_reflect,
|
||||
console=self.console,
|
||||
agent_name=self.name,
|
||||
agent_role=self.role,
|
||||
agent_tools=[t.__name__ if hasattr(t, '__name__') else str(t) for t in (tools if tools is not None else self.tools)],
|
||||
task_name=task_name,
|
||||
task_description=task_description,
|
||||
task_id=task_id,
|
||||
execute_tool_fn=self.execute_tool_async,
|
||||
parallel_tool_calls=getattr(getattr(self, "execution", None), "parallel_tool_calls", False),
|
||||
reasoning_steps=reasoning_steps,
|
||||
stream=stream
|
||||
)
|
||||
# C1 — per-call seed forwarding (async path)
|
||||
llm_kwargs = {
|
||||
'prompt': prompt,
|
||||
'system_prompt': self._build_system_prompt(tools),
|
||||
'chat_history': self.chat_history,
|
||||
'temperature': temperature,
|
||||
'tools': tools,
|
||||
'output_json': output_json,
|
||||
'output_pydantic': output_pydantic,
|
||||
'verbose': self.verbose,
|
||||
'markdown': self.markdown,
|
||||
'reflection': self.self_reflect,
|
||||
'max_reflect': self.max_reflect,
|
||||
'min_reflect': self.min_reflect,
|
||||
'console': self.console,
|
||||
'agent_name': self.name,
|
||||
'agent_role': self.role,
|
||||
'agent_tools': [t.__name__ if hasattr(t, '__name__') else str(t) for t in (tools if tools is not None else self.tools)],
|
||||
'task_name': task_name,
|
||||
'task_description': task_description,
|
||||
'task_id': task_id,
|
||||
'execute_tool_fn': self.execute_tool_async,
|
||||
'parallel_tool_calls': getattr(getattr(self, "execution", None), "parallel_tool_calls", False),
|
||||
'reasoning_steps': reasoning_steps,
|
||||
'stream': stream
|
||||
}
|
||||
|
||||
# C1 — per-call seed overrides llm_instance.seed for determinism
|
||||
if seed is not None:
|
||||
llm_kwargs['seed'] = seed
|
||||
|
||||
# C2 — last-chance cancel check before handing to the LLM
|
||||
if _cancel is not None and getattr(_cancel, 'is_set', lambda: False)():
|
||||
reason = getattr(_cancel, 'reason', None) or 'cancelled'
|
||||
raise InterruptedError(f"Agent chat cancelled: {reason}")
|
||||
|
||||
response_text = await self.llm_instance.get_response_async(**llm_kwargs)
|
||||
|
||||
self.chat_history.append({"role": "assistant", "content": response_text})
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
|
||||
if get_logger().getEffectiveLevel() == logging.DEBUG:
|
||||
total_time = time.time() - start_time
|
||||
@@ -1856,11 +1941,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed for custom LLM: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self.chat_history = self.chat_history[:chat_history_length]
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
except Exception as e:
|
||||
# Rollback chat history if LLM call fails
|
||||
self.chat_history = self.chat_history[:chat_history_length]
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
_get_display_functions()['display_error'](f"Error in LLM chat: {e}")
|
||||
if get_logger().getEffectiveLevel() == logging.DEBUG:
|
||||
total_time = time.time() - start_time
|
||||
@@ -1885,7 +1970,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
self.chat_history[-1].get("role") == "user" and
|
||||
self.chat_history[-1].get("content") == normalized_content):
|
||||
# Add user message to chat history BEFORE LLM call so handoffs can access it
|
||||
self.chat_history.append({"role": "user", "content": normalized_content})
|
||||
self._append_to_chat_history({"role": "user", "content": normalized_content})
|
||||
|
||||
# --- Context compaction (async standard OpenAI path) ---
|
||||
_exec_cfg2 = getattr(self, 'execution', None)
|
||||
@@ -2035,8 +2120,8 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
if self.verbose:
|
||||
_get_display_functions()['display_self_reflection'](f"Agent {self.name}: Self-reflection with structured output is not supported for custom LLM providers. Skipping reflection.", console=self.console)
|
||||
# Return the original response without reflection
|
||||
self.chat_history.append({"role": "user", "content": original_prompt})
|
||||
self.chat_history.append({"role": "assistant", "content": response_text})
|
||||
self._append_to_chat_history({"role": "user", "content": original_prompt})
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
if get_logger().getEffectiveLevel() == logging.DEBUG:
|
||||
total_time = time.time() - start_time
|
||||
logging.debug(f"Agent.achat completed in {total_time:.2f} seconds")
|
||||
@@ -2102,7 +2187,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed for OpenAI client: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self.chat_history = self.chat_history[:chat_history_length]
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
except Exception as e:
|
||||
_get_display_functions()['display_error'](f"Error in chat completion: {e}")
|
||||
@@ -2164,13 +2249,9 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
except Exception as _hook_err:
|
||||
logging.debug(f"BEFORE_TOOL hook error (non-fatal): {_hook_err}")
|
||||
|
||||
# Check if the tool is async
|
||||
if asyncio.iscoroutinefunction(tool):
|
||||
result = await tool(**arguments)
|
||||
else:
|
||||
# Run sync function in executor to avoid blocking
|
||||
loop = asyncio.get_running_loop()
|
||||
result = await loop.run_in_executor(None, lambda: tool(**arguments))
|
||||
# Route through safety pipeline instead of direct execution
|
||||
# Pass the tools list to honor task-scoped tools
|
||||
result = await self.execute_tool_async(function_name, arguments, tools_override=tools)
|
||||
|
||||
# --- AFTER_TOOL hook ---
|
||||
try:
|
||||
@@ -2345,7 +2426,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
if not (self.chat_history and
|
||||
self.chat_history[-1].get("role") == "user" and
|
||||
self.chat_history[-1].get("content") == normalized_content):
|
||||
self.chat_history.append({"role": "user", "content": normalized_content})
|
||||
self._append_to_chat_history({"role": "user", "content": normalized_content})
|
||||
|
||||
try:
|
||||
# Use the new streaming generator from LLM class
|
||||
@@ -2374,11 +2455,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
# Add complete response to chat history
|
||||
if response_content:
|
||||
self.chat_history.append({"role": "assistant", "content": response_content})
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_content})
|
||||
|
||||
except Exception as e:
|
||||
# Rollback chat history on error
|
||||
self.chat_history = self.chat_history[:chat_history_length]
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
logging.error(f"Custom LLM streaming error: {e}")
|
||||
raise
|
||||
|
||||
@@ -2421,7 +2502,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
if not (self.chat_history and
|
||||
self.chat_history[-1].get("role") == "user" and
|
||||
self.chat_history[-1].get("content") == normalized_content):
|
||||
self.chat_history.append({"role": "user", "content": normalized_content})
|
||||
self._append_to_chat_history({"role": "user", "content": normalized_content})
|
||||
|
||||
try:
|
||||
# Check if OpenAI client is available
|
||||
@@ -2533,7 +2614,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
"function": tc['function']
|
||||
} for tc in tool_calls_data if tc['id']
|
||||
]
|
||||
self.chat_history.append(assistant_message)
|
||||
self._append_to_chat_history(assistant_message)
|
||||
|
||||
# Execute tool calls and add results to chat history
|
||||
for tool_call in tool_calls_data:
|
||||
@@ -2552,7 +2633,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
tool_call_id=tool_call.get('id')
|
||||
)
|
||||
# Add tool result to chat history
|
||||
self.chat_history.append({
|
||||
self._append_to_chat_history({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call['id'],
|
||||
"content": str(tool_result)
|
||||
@@ -2560,7 +2641,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
except Exception as tool_error:
|
||||
logging.error(f"Tool execution error in streaming: {tool_error}")
|
||||
# Add error result to chat history
|
||||
self.chat_history.append({
|
||||
self._append_to_chat_history({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call['id'],
|
||||
"content": f"Error: {str(tool_error)}"
|
||||
@@ -2568,11 +2649,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
else:
|
||||
# Add complete response to chat history (text-only response)
|
||||
if response_text:
|
||||
self.chat_history.append({"role": "assistant", "content": response_text})
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
|
||||
except Exception as e:
|
||||
# Rollback chat history on error
|
||||
self.chat_history = self.chat_history[:chat_history_length]
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
logging.error(f"OpenAI streaming error: {e}")
|
||||
# Fall back to simulated streaming
|
||||
response = self.chat(prompt, **kwargs)
|
||||
|
||||
@@ -164,7 +164,8 @@ Follow best practices and coding standards."""
|
||||
messages=messages,
|
||||
**{k: v for k, v in kwargs.items() if k != "model"}
|
||||
)
|
||||
|
||||
if not response.choices or response.choices[0].message is None:
|
||||
raise ValueError("LLM returned empty or filtered response")
|
||||
code = response.choices[0].message.content
|
||||
self._log("Code generated successfully", "green")
|
||||
return code
|
||||
@@ -204,7 +205,8 @@ Follow best practices and coding standards."""
|
||||
messages=messages,
|
||||
**{k: v for k, v in kwargs.items() if k != "model"}
|
||||
)
|
||||
|
||||
if not response.choices or response.choices[0].message is None:
|
||||
raise ValueError("LLM returned empty or filtered response")
|
||||
code = response.choices[0].message.content
|
||||
self._log("Code generated successfully", "green")
|
||||
return code
|
||||
@@ -365,6 +367,8 @@ Provide clear, actionable feedback."""
|
||||
**{k: v for k, v in kwargs.items() if k != "model"}
|
||||
)
|
||||
|
||||
if not response.choices or response.choices[0].message is None:
|
||||
raise ValueError("LLM returned empty or filtered response")
|
||||
review = response.choices[0].message.content
|
||||
self._log("Review completed", "green")
|
||||
return review
|
||||
@@ -405,6 +409,8 @@ Include:
|
||||
**{k: v for k, v in kwargs.items() if k != "model"}
|
||||
)
|
||||
|
||||
if not response.choices or response.choices[0].message is None:
|
||||
raise ValueError("LLM returned empty or filtered response")
|
||||
explanation = response.choices[0].message.content
|
||||
self._log("Explanation completed", "green")
|
||||
return explanation
|
||||
@@ -451,6 +457,8 @@ Only output the refactored code."""
|
||||
**{k: v for k, v in kwargs.items() if k != "model"}
|
||||
)
|
||||
|
||||
if not response.choices or response.choices[0].message is None:
|
||||
raise ValueError("LLM returned empty or filtered response")
|
||||
refactored = response.choices[0].message.content
|
||||
self._log("Refactoring completed", "green")
|
||||
return refactored
|
||||
@@ -495,6 +503,8 @@ Ensure the fix:
|
||||
**{k: v for k, v in kwargs.items() if k != "model"}
|
||||
)
|
||||
|
||||
if not response.choices or response.choices[0].message is None:
|
||||
raise ValueError("LLM returned empty or filtered response")
|
||||
fixed = response.choices[0].message.content
|
||||
self._log("Fix completed", "green")
|
||||
return fixed
|
||||
@@ -5,12 +5,10 @@ Contains all methods for run/start/launch lifecycle, planning,
|
||||
and server endpoints. Extracted from agent.py for maintainability.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
import inspect
|
||||
from praisonaiagents._logging import get_logger
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
@@ -126,7 +124,7 @@ class ExecutionMixin:
|
||||
config=HandoffConfig(context_policy=ContextPolicy.NONE),
|
||||
)
|
||||
|
||||
async def arun(self, prompt: str, **kwargs):
|
||||
async def arun(self, prompt: str, **kwargs) -> Optional[str]:
|
||||
"""Async version of run() - silent, non-streaming, returns structured result.
|
||||
|
||||
Production-friendly async execution. Does not stream or display output.
|
||||
@@ -309,8 +307,8 @@ class ExecutionMixin:
|
||||
# ── Chat history & session linkage ──
|
||||
# Record prompt+response in chat_history so SessionStore/auto_save works
|
||||
if result is not None:
|
||||
self.chat_history.append({"role": "user", "content": prompt})
|
||||
self.chat_history.append({"role": "assistant", "content": str(result)})
|
||||
self._append_to_chat_history({"role": "user", "content": prompt})
|
||||
self._append_to_chat_history({"role": "assistant", "content": str(result)})
|
||||
# Link managed session ID into SessionStore gateway_session_id
|
||||
if hasattr(self.backend, 'managed_session_id'):
|
||||
msid = self.backend.managed_session_id
|
||||
@@ -319,8 +317,14 @@ class ExecutionMixin:
|
||||
sid = getattr(self, 'auto_save', None) or getattr(self, '_session_id', None)
|
||||
if sid and hasattr(self._session_store, 'set_gateway_info'):
|
||||
self._session_store.set_gateway_info(sid, gateway_session_id=msid)
|
||||
except Exception:
|
||||
pass # Best-effort linkage
|
||||
except Exception as e:
|
||||
# Log session linkage failures for debugging
|
||||
logger.warning(
|
||||
"Session gateway linkage failed: %s",
|
||||
e,
|
||||
extra={"session_id": sid, "managed_session_id": msid},
|
||||
exc_info=True,
|
||||
)
|
||||
self._auto_save_session()
|
||||
|
||||
return result
|
||||
@@ -942,7 +946,7 @@ Write the complete compiled report:"""
|
||||
task_id = getattr(task, 'id', None)
|
||||
return await self.achat(prompt, task_name=task_name, task_description=task_description, task_id=task_id)
|
||||
|
||||
async def execute_tool_async(self, function_name: str, arguments: Dict[str, Any], tool_call_id: Optional[str] = None) -> Any:
|
||||
async def execute_tool_async(self, function_name: str, arguments: Dict[str, Any], tool_call_id: Optional[str] = None, tools_override: Optional[List] = None) -> Any:
|
||||
"""Async version of execute_tool"""
|
||||
try:
|
||||
logging.info(f"Executing async tool: {function_name} with arguments: {arguments}")
|
||||
@@ -958,9 +962,10 @@ Write the complete compiled report:"""
|
||||
logging.error(error_msg)
|
||||
return {"error": error_msg, "approval_error": True}
|
||||
|
||||
# Try to find the function in the agent's tools list first
|
||||
# Try to find the function in the override tools list first, then agent's tools list
|
||||
func = None
|
||||
for tool in self.tools:
|
||||
tools_to_search = tools_override if tools_override is not None else self.tools
|
||||
for tool in tools_to_search:
|
||||
if (callable(tool) and getattr(tool, '__name__', '') == function_name):
|
||||
func = tool
|
||||
break
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
Interrupt Controller - Cooperative cancellation for agent runs.
|
||||
|
||||
Provides thread-safe, cooperative cancellation mechanism for long-running agent
|
||||
operations. Follows protocol-driven design with zero overhead when not used.
|
||||
"""
|
||||
|
||||
import threading
|
||||
from typing import Optional, Protocol
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
__all__ = ["InterruptControllerProtocol", "InterruptController"]
|
||||
|
||||
|
||||
class InterruptControllerProtocol(Protocol):
|
||||
"""Protocol for interrupt controller extension point."""
|
||||
|
||||
def request(self, reason: str = "user") -> None:
|
||||
"""Request cancellation with optional reason."""
|
||||
...
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear interrupt state."""
|
||||
...
|
||||
|
||||
def is_set(self) -> bool:
|
||||
"""Check if interrupt was requested."""
|
||||
...
|
||||
|
||||
@property
|
||||
def reason(self) -> Optional[str]:
|
||||
"""Get interrupt reason if set."""
|
||||
...
|
||||
|
||||
def check(self) -> None:
|
||||
"""Check for interrupt and raise if set."""
|
||||
...
|
||||
|
||||
|
||||
@dataclass
|
||||
class InterruptController:
|
||||
"""Thread-safe cooperative cancellation for agent runs.
|
||||
|
||||
Provides a lightweight mechanism for requesting cancellation of agent
|
||||
operations. Uses threading.Event for thread safety and cooperative
|
||||
checking patterns.
|
||||
|
||||
Examples:
|
||||
Basic usage:
|
||||
>>> controller = InterruptController()
|
||||
>>> # In another thread:
|
||||
>>> controller.request("user_cancel")
|
||||
>>> # In agent loop:
|
||||
>>> if controller.is_set():
|
||||
>>> return f"Cancelled: {controller.reason}"
|
||||
"""
|
||||
|
||||
_flag: threading.Event = field(default_factory=threading.Event, init=False, repr=False)
|
||||
_reason: Optional[str] = field(default=None, init=False)
|
||||
_lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
|
||||
|
||||
def request(self, reason: str = "user") -> None:
|
||||
"""Request cancellation with a reason.
|
||||
|
||||
Args:
|
||||
reason: Human-readable reason for cancellation
|
||||
"""
|
||||
with self._lock:
|
||||
if not self._flag.is_set():
|
||||
self._reason = reason
|
||||
self._flag.set()
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the cancellation request."""
|
||||
with self._lock:
|
||||
self._reason = None
|
||||
self._flag.clear()
|
||||
|
||||
def is_set(self) -> bool:
|
||||
"""Check if cancellation has been requested.
|
||||
|
||||
Returns:
|
||||
True if cancellation was requested
|
||||
"""
|
||||
return self._flag.is_set()
|
||||
|
||||
@property
|
||||
def reason(self) -> Optional[str]:
|
||||
"""Get the reason for cancellation.
|
||||
|
||||
Returns:
|
||||
Reason string if cancelled, None otherwise
|
||||
"""
|
||||
with self._lock:
|
||||
return self._reason
|
||||
|
||||
def check(self) -> None:
|
||||
"""Check for cancellation and raise if requested.
|
||||
|
||||
Raises:
|
||||
InterruptedError: If cancellation was requested
|
||||
"""
|
||||
if self.is_set():
|
||||
reason = self.reason or "unknown"
|
||||
raise InterruptedError(f"Operation cancelled: {reason}")
|
||||
@@ -9,7 +9,6 @@ for maintainability.
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional
|
||||
from praisonaiagents._logging import get_logger
|
||||
|
||||
# Fallback helpers to avoid circular imports
|
||||
def _get_console():
|
||||
@@ -42,7 +41,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
import contextlib
|
||||
from typing import List, Optional, Any, Dict, Generator, TYPE_CHECKING
|
||||
from typing import Optional, Dict, Generator, TYPE_CHECKING
|
||||
from collections import OrderedDict
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -82,7 +81,7 @@ class MemoryMixin:
|
||||
with self._history_lock:
|
||||
self.chat_history.append({"role": role, "content": content})
|
||||
|
||||
def _add_to_chat_history_if_not_duplicate(self, role, content):
|
||||
def _add_to_chat_history_if_not_duplicate(self, role, content) -> bool:
|
||||
"""Thread-safe method to add messages to chat history only if not duplicate.
|
||||
|
||||
Atomically checks for duplicate and adds message under the same lock to prevent TOCTOU races.
|
||||
|
||||
@@ -17,6 +17,8 @@ import concurrent.futures
|
||||
from typing import List, Optional, Any, Dict, Union, TYPE_CHECKING
|
||||
from ..errors import ToolExecutionError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
@@ -192,6 +194,21 @@ class ToolExecutionMixin:
|
||||
if res.output and res.output.modified_data:
|
||||
arguments.update(res.output.modified_data)
|
||||
|
||||
# C4 — optional tool-argument validation via ToolValidatorProtocol.
|
||||
# Zero overhead when not set. Users wire via `agent._tool_validator = MyValidator()`.
|
||||
_validator = getattr(self, '_tool_validator', None)
|
||||
if _validator is not None:
|
||||
try:
|
||||
_vres = _validator.validate_args(function_name, arguments)
|
||||
if _vres is not None and not getattr(_vres, 'valid', True):
|
||||
_errs = "; ".join(getattr(_vres, 'errors', []) or ["validation failed"])
|
||||
logging.warning(
|
||||
f"Tool {function_name} args rejected by validator: {_errs}"
|
||||
)
|
||||
return f"Tool arguments rejected: {_errs}"
|
||||
except Exception as _ve: # noqa: BLE001 — never break tool exec on validator bug
|
||||
logging.debug(f"Tool validator raised; skipping validation: {_ve}")
|
||||
|
||||
# P8/G11: Apply tool timeout if configured
|
||||
tool_timeout = getattr(self, '_tool_timeout', None)
|
||||
if tool_timeout and tool_timeout > 0:
|
||||
@@ -200,7 +217,7 @@ class ToolExecutionMixin:
|
||||
|
||||
def execute_with_context():
|
||||
with with_injection_context(state):
|
||||
return self._execute_tool_impl(function_name, arguments)
|
||||
return self._execute_tool_with_circuit_breaker(function_name, arguments)
|
||||
|
||||
# Use reusable executor to prevent resource leaks
|
||||
if not hasattr(self, '_tool_executor'):
|
||||
@@ -217,7 +234,7 @@ class ToolExecutionMixin:
|
||||
result = {"error": f"Tool timed out after {tool_timeout}s", "timeout": True}
|
||||
else:
|
||||
with with_injection_context(state):
|
||||
result = self._execute_tool_impl(function_name, arguments)
|
||||
result = self._execute_tool_with_circuit_breaker(function_name, arguments)
|
||||
|
||||
# Apply tool output truncation to prevent context overflow
|
||||
# Uses context manager budget if enabled, otherwise applies default limit
|
||||
@@ -346,9 +363,10 @@ class ToolExecutionMixin:
|
||||
try:
|
||||
nudge = self._maybe_emit_nudge(prompt if isinstance(prompt, str) else str(prompt))
|
||||
if nudge and hasattr(self, "chat_history") and isinstance(self.chat_history, list):
|
||||
self.chat_history.append({"role": "system", "content": nudge.strip()})
|
||||
except Exception:
|
||||
pass
|
||||
self._append_to_chat_history({"role": "system", "content": nudge.strip()})
|
||||
except Exception as e:
|
||||
# Log learning nudge failures for debugging
|
||||
logger.warning("Learning nudge generation failed: %s", e, exc_info=True)
|
||||
|
||||
return response
|
||||
|
||||
@@ -381,9 +399,10 @@ class ToolExecutionMixin:
|
||||
try:
|
||||
nudge = self._maybe_emit_nudge(prompt if isinstance(prompt, str) else str(prompt))
|
||||
if nudge and hasattr(self, "chat_history") and isinstance(self.chat_history, list):
|
||||
self.chat_history.append({"role": "system", "content": nudge.strip()})
|
||||
except Exception:
|
||||
pass
|
||||
self._append_to_chat_history({"role": "system", "content": nudge.strip()})
|
||||
except Exception as e:
|
||||
# Log learning nudge failures for debugging
|
||||
logger.warning("Learning nudge generation failed: %s", e, exc_info=True)
|
||||
|
||||
return response
|
||||
|
||||
@@ -590,6 +609,73 @@ class ToolExecutionMixin:
|
||||
logging.info(f"Using modified arguments: {arguments}")
|
||||
return None, arguments
|
||||
|
||||
def _execute_tool_with_circuit_breaker(self, function_name, arguments):
|
||||
"""Execute tool with circuit breaker protection.
|
||||
|
||||
Args:
|
||||
function_name: Name of the tool to execute
|
||||
arguments: Arguments for the tool
|
||||
|
||||
Returns:
|
||||
Tool execution result or circuit breaker error
|
||||
"""
|
||||
# Import circuit breaker components first (lazy import for performance)
|
||||
try:
|
||||
from ..tools.circuit_breaker import get_circuit_breaker, CircuitBreakerConfig, CircuitBreakerException
|
||||
except ImportError:
|
||||
# Circuit breaker not available - fallback to direct execution
|
||||
logging.debug("Circuit breaker not available, falling back to direct tool execution")
|
||||
return self._execute_tool_impl(function_name, arguments)
|
||||
|
||||
try:
|
||||
|
||||
# Get or create circuit breaker for this tool
|
||||
breaker_name = f"tool_{function_name}"
|
||||
config = CircuitBreakerConfig(
|
||||
failure_threshold=5, # Open after 5 failures
|
||||
recovery_timeout=60.0, # Wait 60s before trying half-open
|
||||
timeout=30.0, # Tool call timeout
|
||||
graceful_degradation=True # Return error instead of raising exception
|
||||
)
|
||||
breaker = get_circuit_breaker(breaker_name, config)
|
||||
|
||||
# Execute tool through circuit breaker with failure detection wrapper
|
||||
def _tool_wrapper():
|
||||
result = self._execute_tool_impl(function_name, arguments)
|
||||
# Convert error dicts to exceptions so circuit breaker can detect failures
|
||||
# Don't treat approval/permission denials as circuit breaker failures
|
||||
if isinstance(result, dict) and result.get("error") and \
|
||||
not result.get("approval_denied") and \
|
||||
not result.get("permission_denied") and \
|
||||
not result.get("approval_error"):
|
||||
# Create a sentinel exception to register failure with circuit breaker
|
||||
class _ToolFailure(Exception):
|
||||
def __init__(self, error_dict):
|
||||
self.error_dict = error_dict
|
||||
super().__init__(error_dict.get("error", "Tool execution failed"))
|
||||
raise _ToolFailure(result)
|
||||
return result
|
||||
|
||||
try:
|
||||
return breaker.call(_tool_wrapper)
|
||||
except Exception as e:
|
||||
# Check if this is our sentinel exception
|
||||
if hasattr(e, 'error_dict'):
|
||||
return e.error_dict # Return the original error dict
|
||||
else:
|
||||
raise # Re-raise other exceptions
|
||||
|
||||
except CircuitBreakerException as e:
|
||||
# Circuit breaker is open - return error dict instead of raising
|
||||
logging.warning(f"Tool '{function_name}' circuit breaker open: {e}")
|
||||
return {
|
||||
"error": f"Tool '{function_name}' circuit breaker open - too many recent failures",
|
||||
"circuit_open": True,
|
||||
"agent_name": getattr(self, "name", None),
|
||||
"session_id": getattr(self, "_session_id", None),
|
||||
"remediation": "Wait for recovery_timeout (60s) or investigate recent tool failures.",
|
||||
}
|
||||
|
||||
def _execute_tool_impl(self, function_name, arguments):
|
||||
"""Internal tool execution implementation."""
|
||||
|
||||
@@ -687,11 +773,9 @@ class ToolExecutionMixin:
|
||||
pass
|
||||
|
||||
if func is None:
|
||||
# If not found in tools or registry, try globals and main
|
||||
func = globals().get(function_name)
|
||||
if not func:
|
||||
import __main__
|
||||
func = getattr(__main__, function_name, None)
|
||||
# Tool not found in declared tools or registry — do not fall back to
|
||||
# globals() or __main__ as that allows undeclared callables to execute.
|
||||
pass
|
||||
|
||||
if func:
|
||||
try:
|
||||
@@ -756,12 +840,18 @@ class ToolExecutionMixin:
|
||||
|
||||
tracking_id = str(uuid.uuid4())[:8]
|
||||
task = asyncio.ensure_future(backend.request_approval(request))
|
||||
self._pending_approvals[tracking_id] = {
|
||||
"task": task,
|
||||
"function_name": function_name,
|
||||
"arguments": arguments,
|
||||
"request": request,
|
||||
}
|
||||
|
||||
# Make defensive copy to prevent TOCTOU mutations
|
||||
import copy
|
||||
frozen_args = copy.deepcopy(arguments)
|
||||
|
||||
async with self._approvals_lock:
|
||||
self._pending_approvals[tracking_id] = {
|
||||
"task": task,
|
||||
"function_name": function_name,
|
||||
"arguments": frozen_args,
|
||||
"request": request,
|
||||
}
|
||||
logging.info(f"Approval request submitted: {tracking_id} for {function_name}")
|
||||
return tracking_id
|
||||
|
||||
@@ -773,40 +863,63 @@ class ToolExecutionMixin:
|
||||
and their results included.
|
||||
"""
|
||||
results = {}
|
||||
completed_ids = []
|
||||
# Collect completed items under the lock, then execute tools outside the lock
|
||||
# to avoid holding it during potentially slow async tool execution.
|
||||
approved_items = []
|
||||
denied_items = []
|
||||
error_items = []
|
||||
|
||||
for tid, info in self._pending_approvals.items():
|
||||
task = info["task"]
|
||||
if task.done():
|
||||
completed_ids.append(tid)
|
||||
try:
|
||||
decision = task.result()
|
||||
if decision.approved:
|
||||
# Auto-execute the approved tool
|
||||
tool_result = await self.execute_tool_async(
|
||||
info["function_name"], info["arguments"],
|
||||
)
|
||||
results[tid] = {
|
||||
"status": "approved_and_executed",
|
||||
"tool_name": info["function_name"],
|
||||
"decision": decision,
|
||||
"result": tool_result,
|
||||
}
|
||||
else:
|
||||
results[tid] = {
|
||||
"status": "denied",
|
||||
"tool_name": info["function_name"],
|
||||
"decision": decision,
|
||||
}
|
||||
except Exception as e:
|
||||
results[tid] = {
|
||||
"status": "error",
|
||||
"tool_name": info["function_name"],
|
||||
"error": str(e),
|
||||
}
|
||||
async with self._approvals_lock:
|
||||
completed_ids = []
|
||||
for tid, info in list(self._pending_approvals.items()):
|
||||
task = info["task"]
|
||||
if task.done():
|
||||
completed_ids.append(tid)
|
||||
try:
|
||||
decision = task.result()
|
||||
if decision.approved:
|
||||
approved_items.append((tid, info, decision))
|
||||
else:
|
||||
denied_items.append((tid, info, decision))
|
||||
except Exception as e:
|
||||
error_items.append((tid, info, e))
|
||||
|
||||
for tid in completed_ids:
|
||||
del self._pending_approvals[tid]
|
||||
# Remove completed entries while still holding the lock
|
||||
for tid in completed_ids:
|
||||
del self._pending_approvals[tid]
|
||||
|
||||
# Execute approved tools outside the lock to avoid long lock hold
|
||||
for tid, info, decision in approved_items:
|
||||
try:
|
||||
tool_result = await self.execute_tool_async(
|
||||
info["function_name"], info["arguments"],
|
||||
)
|
||||
results[tid] = {
|
||||
"status": "approved_and_executed",
|
||||
"tool_name": info["function_name"],
|
||||
"decision": decision,
|
||||
"result": tool_result,
|
||||
}
|
||||
except Exception as e:
|
||||
results[tid] = {
|
||||
"status": "error",
|
||||
"tool_name": info["function_name"],
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
for tid, info, decision in denied_items:
|
||||
results[tid] = {
|
||||
"status": "denied",
|
||||
"tool_name": info["function_name"],
|
||||
"decision": decision,
|
||||
}
|
||||
|
||||
for tid, info, exc in error_items:
|
||||
results[tid] = {
|
||||
"status": "error",
|
||||
"tool_name": info["function_name"],
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@@ -78,9 +78,28 @@ class UnifiedExecutionMixin:
|
||||
_trace_emitter.agent_start(self.name, {"role": self.role, "goal": self.goal})
|
||||
|
||||
try:
|
||||
# CLI Backend routing - delegate entire turn if configured
|
||||
if hasattr(self, '_cli_backend') and self._cli_backend is not None:
|
||||
return await self._chat_via_cli_backend(
|
||||
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
|
||||
)
|
||||
# Apply rate limiter if configured (before any LLM call)
|
||||
if hasattr(self, '_rate_limiter') and self._rate_limiter is not None:
|
||||
self._rate_limiter.acquire()
|
||||
await self._rate_limiter.acquire_async()
|
||||
|
||||
# Process ephemeral attachments (DRY - builds multimodal prompt)
|
||||
# IMPORTANT: Original text 'prompt' is stored in history, attachments are NOT
|
||||
|
||||
@@ -126,7 +126,24 @@ class _AgentServerRegistry:
|
||||
|
||||
thread = threading.Thread(target=run_server, daemon=True)
|
||||
thread.start()
|
||||
ready_event.wait(timeout=5.0) # Deterministic wait instead of sleep(0.5)
|
||||
|
||||
# Check for configurable timeout via environment variable
|
||||
try:
|
||||
timeout = float(os.environ.get("PRAISONAI_SERVER_READY_TIMEOUT", "5.0"))
|
||||
except ValueError:
|
||||
logger.warning("Invalid PRAISONAI_SERVER_READY_TIMEOUT value. Using default 5.0s.")
|
||||
timeout = 5.0
|
||||
became_ready = ready_event.wait(timeout=timeout)
|
||||
|
||||
if not became_ready:
|
||||
logger.warning(
|
||||
"Agent server on port %s did not become ready within %.1fs. "
|
||||
"Proceeding, but some features may not work correctly. "
|
||||
"Check server logs for startup errors.",
|
||||
port,
|
||||
timeout,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -1010,20 +1027,74 @@ class AgentTeam:
|
||||
logger.info(f"Task with ID {task_id} is already completed")
|
||||
return
|
||||
|
||||
# Use per-task max_retries if available
|
||||
task_max = getattr(task, "max_retries", self.max_retries)
|
||||
retries = 0
|
||||
while task.status != "completed" and retries < self.max_retries:
|
||||
while task.status != "completed" and retries < task_max:
|
||||
logger.debug(f"Attempt {retries+1} for task {task_id}")
|
||||
if task.status in ["not started", "in progress"]:
|
||||
task_output = await self.aexecute_task(task_id)
|
||||
if task_output and self.completion_checker(task, task_output.raw):
|
||||
# Run guardrail validation BEFORE marking task complete
|
||||
if task._guardrail_fn:
|
||||
try:
|
||||
guardrail_result = task._process_guardrail(task_output)
|
||||
if not guardrail_result.success:
|
||||
if task.retry_count >= task.max_retries:
|
||||
raise Exception(
|
||||
f"Task failed guardrail validation after {task.max_retries} retries. "
|
||||
f"Last error: {guardrail_result.error}"
|
||||
)
|
||||
|
||||
task.retry_count += 1
|
||||
task.status = "in progress" # Keep task in progress for retry
|
||||
logger.warning(f"Task {task_id}: Guardrail validation failed (retry {task.retry_count}/{task.max_retries}): {guardrail_result.error}")
|
||||
retries += 1
|
||||
continue # Actually retry the task
|
||||
|
||||
# If guardrail passed and returned a modified result
|
||||
if guardrail_result.result is not None:
|
||||
if isinstance(guardrail_result.result, str):
|
||||
# Update the task output with the modified result
|
||||
task_output.raw = guardrail_result.result
|
||||
# Clear structured fields to avoid stale cache
|
||||
if hasattr(task_output, 'json_dict'):
|
||||
task_output.json_dict = None
|
||||
if hasattr(task_output, 'pydantic'):
|
||||
task_output.pydantic = None
|
||||
task.result = task_output
|
||||
elif hasattr(guardrail_result.result, 'raw'):
|
||||
# Replace with the new task output
|
||||
task_output = guardrail_result.result
|
||||
task.result = task_output
|
||||
|
||||
logger.info(f"Task {task_id}: Guardrail validation passed")
|
||||
except Exception as e:
|
||||
logger.error(f"Task {task_id}: Error in guardrail processing: {e}")
|
||||
# Handle guardrail failure with retry logic
|
||||
if task.retry_count >= task.max_retries:
|
||||
raise Exception(
|
||||
f"Task failed due to guardrail processing error after {task.max_retries} retries. "
|
||||
f"Last error: {e}"
|
||||
) from e
|
||||
task.retry_count += 1
|
||||
task.status = "in progress"
|
||||
logger.warning(f"Task {task_id}: Guardrail processing error (retry {task.retry_count}/{task.max_retries}): {e}")
|
||||
retries += 1
|
||||
continue # Retry the task
|
||||
|
||||
task.status = "completed"
|
||||
# Run execute_callback for memory operations
|
||||
try:
|
||||
# Use the new sync wrapper to avoid pending coroutine issues
|
||||
task.execute_callback_sync(task_output)
|
||||
await task.execute_callback(task_output)
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing memory callback for task {task_id}: {e}")
|
||||
logger.exception(e)
|
||||
# Respect task failure policies - re-raise if configured
|
||||
if hasattr(task, 'fail_on_callback_error') and task.fail_on_callback_error:
|
||||
raise
|
||||
if hasattr(task, 'fail_on_memory_error') and task.fail_on_memory_error:
|
||||
raise
|
||||
|
||||
# Run task callback if exists
|
||||
if task.callback:
|
||||
@@ -1046,7 +1117,11 @@ class AgentTeam:
|
||||
task.status = "in progress"
|
||||
if self.verbose >= 1:
|
||||
logger.info(f"Task {task_id} not completed, retrying")
|
||||
await asyncio.sleep(1)
|
||||
# Use task's retry policy instead of hardcoded sleep (with cap)
|
||||
delay = getattr(task, 'retry_delay', 1)
|
||||
max_delay = getattr(task, 'max_retry_delay', 300) # 5 min cap
|
||||
actual_delay = min(delay * (2 ** retries), max_delay)
|
||||
await asyncio.sleep(actual_delay)
|
||||
retries += 1
|
||||
else:
|
||||
if task.status == "failed":
|
||||
@@ -1056,8 +1131,9 @@ class AgentTeam:
|
||||
logger.info("Invalid Task status")
|
||||
break
|
||||
|
||||
if retries == self.max_retries and task.status != "completed":
|
||||
logger.info(f"Task {task_id} failed after {self.max_retries} retries.")
|
||||
if retries == task_max and task.status != "completed":
|
||||
task.status = "failed" # Set failed status to match sync behavior
|
||||
logger.info(f"Task {task_id} failed after {task_max} retries.")
|
||||
|
||||
async def arun_all_tasks(self):
|
||||
"""Async version of run_all_tasks method"""
|
||||
@@ -1239,12 +1315,61 @@ class AgentTeam:
|
||||
if self.variables and not getattr(task, 'variables', None):
|
||||
task.variables = self.variables
|
||||
|
||||
# Use per-task max_retries if available
|
||||
task_max = getattr(task, "max_retries", self.max_retries)
|
||||
retries = 0
|
||||
while task.status != "completed" and retries < self.max_retries:
|
||||
while task.status != "completed" and retries < task_max:
|
||||
logger.debug(f"Attempt {retries+1} for task {task_id}")
|
||||
if task.status in ["not started", "in progress"]:
|
||||
task_output = self.execute_task(task_id)
|
||||
if task_output and self.completion_checker(task, task_output.raw):
|
||||
# Add guardrail validation (matches arun_task logic)
|
||||
if task._guardrail_fn:
|
||||
try:
|
||||
guardrail_result = task._process_guardrail(task_output)
|
||||
if not guardrail_result.success:
|
||||
if task.retry_count >= task.max_retries:
|
||||
raise Exception(
|
||||
f"Task failed guardrail validation after {task.max_retries} retries. "
|
||||
f"Last error: {guardrail_result.error}"
|
||||
)
|
||||
task.retry_count += 1
|
||||
task.status = "in progress" # Keep task in progress for retry
|
||||
logger.warning(f"Task {task_id}: Guardrail validation failed (retry {task.retry_count}/{task.max_retries}): {guardrail_result.error}")
|
||||
retries += 1
|
||||
continue # Retry the task
|
||||
|
||||
# If guardrail passed and returned a modified result
|
||||
if guardrail_result.result is not None:
|
||||
if isinstance(guardrail_result.result, str):
|
||||
# Update the task output with the modified result
|
||||
task_output.raw = guardrail_result.result
|
||||
# Clear structured fields to avoid stale cache
|
||||
if hasattr(task_output, 'json_dict'):
|
||||
task_output.json_dict = None
|
||||
if hasattr(task_output, 'pydantic'):
|
||||
task_output.pydantic = None
|
||||
task.result = task_output
|
||||
elif hasattr(guardrail_result.result, 'raw'):
|
||||
# Replace with the new task output
|
||||
task_output = guardrail_result.result
|
||||
task.result = task_output
|
||||
|
||||
logger.info(f"Task {task_id}: Guardrail validation passed")
|
||||
except Exception as e:
|
||||
logger.error(f"Task {task_id}: Error in guardrail processing: {e}")
|
||||
# Handle guardrail failure with retry logic
|
||||
if task.retry_count >= task.max_retries:
|
||||
raise Exception(
|
||||
f"Task failed due to guardrail processing error after {task.max_retries} retries. "
|
||||
f"Last error: {e}"
|
||||
) from e
|
||||
task.retry_count += 1
|
||||
task.status = "in progress"
|
||||
logger.warning(f"Task {task_id}: Guardrail processing error (retry {task.retry_count}/{task.max_retries}): {e}")
|
||||
retries += 1
|
||||
continue # Retry the task
|
||||
|
||||
task.status = "completed"
|
||||
# Run execute_callback for memory operations
|
||||
try:
|
||||
@@ -1283,7 +1408,11 @@ class AgentTeam:
|
||||
task.status = "in progress"
|
||||
if self.verbose >= 1:
|
||||
logger.info(f"Task {task_id} not completed, retrying")
|
||||
time.sleep(1)
|
||||
# Use task's retry policy instead of hardcoded sleep (with cap)
|
||||
delay = getattr(task, 'retry_delay', 1)
|
||||
max_delay = getattr(task, 'max_retry_delay', 300) # 5 min cap
|
||||
actual_delay = min(delay * (2 ** retries), max_delay)
|
||||
time.sleep(actual_delay)
|
||||
retries += 1
|
||||
else:
|
||||
if task.status == "failed":
|
||||
@@ -1293,8 +1422,9 @@ class AgentTeam:
|
||||
logger.info("Invalid Task status")
|
||||
break
|
||||
|
||||
if retries == self.max_retries and task.status != "completed":
|
||||
logger.info(f"Task {task_id} failed after {self.max_retries} retries.")
|
||||
if retries == task_max and task.status != "completed":
|
||||
task.status = "failed" # Set failed status
|
||||
logger.info(f"Task {task_id} failed after {task_max} retries.")
|
||||
|
||||
def run_all_tasks(self):
|
||||
"""Synchronous version of run_all_tasks method"""
|
||||
@@ -1417,56 +1547,103 @@ class AgentTeam:
|
||||
))
|
||||
console.print()
|
||||
|
||||
# Execute tasks with verbose output
|
||||
# Use callbacks for verbose display while maintaining process orchestration
|
||||
total_agents = len(self.agents)
|
||||
workflow_start_time = time_module.time()
|
||||
task_times = {}
|
||||
task_idx = {}
|
||||
|
||||
for idx, (task_id, task) in enumerate(self.tasks.items(), 1):
|
||||
agent = task.agent
|
||||
agent_name = agent.display_name if agent else "Unknown"
|
||||
agent_model = getattr(agent, 'llm', 'gpt-4o-mini') if agent else "unknown"
|
||||
|
||||
# Show agent task panel with model info
|
||||
task_desc = task.description[:100] + "..." if len(task.description) > 100 else task.description
|
||||
panel_content = f"[bold {PRAISON_COLORS['task_text']}]📋 Task:[/] {task_desc}\n"
|
||||
panel_content += f"[dim]🤖 Model: {agent_model}[/dim]"
|
||||
console.print(Panel.fit(
|
||||
panel_content,
|
||||
title=f"[bold]Agent [{idx}/{total_agents}]: {agent_name}[/]",
|
||||
border_style=PRAISON_COLORS["task"]
|
||||
))
|
||||
|
||||
# Execute with timing and status
|
||||
start_time = time_module.time()
|
||||
|
||||
# Show working spinner
|
||||
with console.status(
|
||||
f"[bold yellow]Working...[/] {agent_name} generating response...",
|
||||
spinner="dots",
|
||||
spinner_style="yellow"
|
||||
):
|
||||
# Run the task
|
||||
if self.planning:
|
||||
self._run_with_planning()
|
||||
break # Planning mode handles all tasks
|
||||
else:
|
||||
self.run_task(task_id)
|
||||
|
||||
elapsed = time_module.time() - start_time
|
||||
|
||||
# Show response panel - FULL response, no truncation
|
||||
result = self.get_task_result(task_id)
|
||||
if result:
|
||||
response_text = str(result.raw)
|
||||
# No truncation - show full response in verbose mode
|
||||
from rich.markdown import Markdown
|
||||
console.print(Panel(
|
||||
Markdown(response_text),
|
||||
title=f"[bold]Agent [{idx}/{total_agents}] Complete ({elapsed:.1f}s)[/]",
|
||||
border_style=PRAISON_COLORS["response"],
|
||||
padding=(1, 2)
|
||||
# Set up index mapping for tasks
|
||||
for idx, task_id in enumerate(self.tasks.keys(), 1):
|
||||
task_idx[task_id] = idx
|
||||
|
||||
# Define callbacks to display progress while using proper orchestration
|
||||
def verbose_task_start_callback(task, task_id):
|
||||
try:
|
||||
agent = task.agent
|
||||
agent_name = agent.display_name if agent else "Unknown"
|
||||
agent_model = getattr(agent, 'llm', 'gpt-4o-mini') if agent else "unknown"
|
||||
idx = task_idx.get(task_id, 0)
|
||||
|
||||
# Show agent task panel with model info
|
||||
task_desc = task.description[:100] + "..." if len(task.description) > 100 else task.description
|
||||
panel_content = f"[bold {PRAISON_COLORS['task_text']}]📋 Task:[/] {task_desc}\n"
|
||||
panel_content += f"[dim]🤖 Model: {agent_model}[/dim]"
|
||||
console.print(Panel.fit(
|
||||
panel_content,
|
||||
title=f"[bold]Agent [{idx}/{total_agents}]: {agent_name}[/]",
|
||||
border_style=PRAISON_COLORS["task"]
|
||||
))
|
||||
console.print()
|
||||
|
||||
# Store start time for this task
|
||||
task_times[task_id] = time_module.time()
|
||||
|
||||
# Show working spinner
|
||||
console.print(f"[bold yellow]Working...[/] {agent_name} generating response...")
|
||||
except Exception as e:
|
||||
logging.debug(f"Error in verbose task start callback: {e}")
|
||||
|
||||
def verbose_task_complete_callback(task, task_output):
|
||||
try:
|
||||
task_id = getattr(task, 'id', 'unknown')
|
||||
start_time = task_times.get(task_id, time_module.time())
|
||||
elapsed = time_module.time() - start_time
|
||||
idx = task_idx.get(task_id, 0)
|
||||
|
||||
# Show response panel - FULL response, no truncation
|
||||
if task_output:
|
||||
response_text = str(task_output.raw)
|
||||
# No truncation - show full response in verbose mode
|
||||
from rich.markdown import Markdown
|
||||
console.print(Panel(
|
||||
Markdown(response_text),
|
||||
title=f"[bold]Agent [{idx}/{total_agents}] Complete ({elapsed:.1f}s)[/]",
|
||||
border_style=PRAISON_COLORS["response"],
|
||||
padding=(1, 2)
|
||||
))
|
||||
console.print()
|
||||
except Exception as e:
|
||||
logging.debug(f"Error in verbose task complete callback: {e}")
|
||||
|
||||
# Set callbacks for verbose display (compose with existing callbacks)
|
||||
original_on_task_start = self.on_task_start
|
||||
original_on_task_complete = self.on_task_complete
|
||||
|
||||
def composed_on_task_start(task):
|
||||
try:
|
||||
verbose_task_start_callback(task)
|
||||
except Exception as e:
|
||||
logging.debug(f"Error in verbose task start callback: {e}")
|
||||
if original_on_task_start:
|
||||
try:
|
||||
original_on_task_start(task)
|
||||
except Exception as e:
|
||||
logging.debug(f"Error in original task start callback: {e}")
|
||||
|
||||
def composed_on_task_complete(task):
|
||||
try:
|
||||
verbose_task_complete_callback(task)
|
||||
except Exception as e:
|
||||
logging.debug(f"Error in verbose task complete callback: {e}")
|
||||
if original_on_task_complete:
|
||||
try:
|
||||
original_on_task_complete(task)
|
||||
except Exception as e:
|
||||
logging.debug(f"Error in original task complete callback: {e}")
|
||||
|
||||
self.on_task_start = composed_on_task_start
|
||||
self.on_task_complete = composed_on_task_complete
|
||||
|
||||
# Use proper process orchestration with verbose callbacks
|
||||
try:
|
||||
if self.planning:
|
||||
self._run_with_planning()
|
||||
else:
|
||||
self.run_all_tasks()
|
||||
finally:
|
||||
# Restore original callbacks
|
||||
self.on_task_start = original_on_task_start
|
||||
self.on_task_complete = original_on_task_complete
|
||||
|
||||
# Workflow summary panel
|
||||
total_elapsed = time_module.time() - workflow_start_time
|
||||
|
||||
@@ -183,14 +183,21 @@ def require_approval(risk_level: RiskLevel = "high"):
|
||||
mark_approved(tool_name)
|
||||
return func(*args, **kwargs)
|
||||
try:
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
raise RuntimeError("Use sync fallback in async context")
|
||||
except RuntimeError:
|
||||
decision = asyncio.run(request_approval(tool_name, kwargs))
|
||||
from ..utils.async_bridge import is_async_context, run_coroutine_from_any_context
|
||||
registry = get_approval_registry()
|
||||
if is_async_context():
|
||||
# We're in an async context - cannot safely use console I/O
|
||||
raise RuntimeError(
|
||||
f"Tool '{tool_name}' requires approval but cannot use console I/O from async context. "
|
||||
f"Configure a non-console approval backend or call from sync context."
|
||||
)
|
||||
else:
|
||||
# Safe to run async approval using registry
|
||||
decision = run_coroutine_from_any_context(request_approval(tool_name, kwargs))
|
||||
except Exception as e:
|
||||
logging.warning(f"Async approval failed, using sync fallback: {e}")
|
||||
decision = console_approval_callback(tool_name, kwargs, risk_level)
|
||||
logging.warning(f"Approval request failed: {e}", exc_info=True)
|
||||
# Fail closed - do not fall back to console
|
||||
raise PermissionError(f"Approval request failed for {tool_name}: {e}") from e
|
||||
if not decision.approved:
|
||||
raise PermissionError(f"Execution of {tool_name} denied: {decision.reason}")
|
||||
mark_approved(tool_name)
|
||||
|
||||
@@ -47,16 +47,31 @@ DEFAULT_DANGEROUS_TOOLS: Dict[str, str] = {
|
||||
}
|
||||
|
||||
# Permission presets — resolved to deny frozensets at Agent.__init__ time.
|
||||
# Usage: Agent(approval="safe")
|
||||
# Usage: Agent(approval="safe") — or set PRAISONAI_TOOL_SAFETY=<preset>
|
||||
# which applies as the default when no ``approval=`` kwarg is passed.
|
||||
#
|
||||
# ``default`` is the baseline we apply when nothing is configured: it
|
||||
# only blocks operations the LLM should never execute unattended —
|
||||
# destructive file ops (delete/move/copy) and arbitrary shell/code
|
||||
# execution. Read, create and edit stay allowed because those are
|
||||
# what 99% of useful agent workflows need. Users who want the old
|
||||
# ``trust the LLM with everything`` behaviour pass ``approval="full"``
|
||||
# or set ``PRAISONAI_TOOL_SAFETY=off``; users who want stricter
|
||||
# controls can opt into ``approval="safe"`` or ``"read_only"``.
|
||||
PERMISSION_PRESETS = {
|
||||
# "default" — blocks delete + shell/code exec. Allows read/create/edit.
|
||||
"default": frozenset({
|
||||
"execute_command", "kill_process", "execute_code", "acp_execute_command",
|
||||
"delete_file", "move_file", "copy_file", "acp_delete_file",
|
||||
}),
|
||||
# "safe" — blocks all dangerous tools (file writes, shell exec, etc.)
|
||||
"safe": frozenset(DEFAULT_DANGEROUS_TOOLS.keys()),
|
||||
# "read_only" — blocks dangerous tools + write operations
|
||||
"read_only": frozenset(DEFAULT_DANGEROUS_TOOLS.keys()) | frozenset({
|
||||
"write_file", "copy_file", "move_file",
|
||||
}),
|
||||
# "full" — no restrictions
|
||||
# "read_only" — alias of "safe" (blocks all dangerous tools)
|
||||
"read_only": frozenset(DEFAULT_DANGEROUS_TOOLS.keys()),
|
||||
# "full" — no restrictions (trust the LLM). Equivalent to "off" env.
|
||||
"full": frozenset(),
|
||||
# "off" — alias of "full" for the env-var off-switch.
|
||||
"off": frozenset(),
|
||||
}
|
||||
|
||||
class ApprovalRegistry:
|
||||
|
||||
@@ -14,6 +14,13 @@ Quick start::
|
||||
|
||||
async def authorize(self, identity, resource, action):
|
||||
return "owner" in identity.roles
|
||||
|
||||
Subscription auth (lazy-loaded)::
|
||||
|
||||
from praisonaiagents.auth import resolve_subscription_credentials
|
||||
|
||||
creds = resolve_subscription_credentials("claude-code")
|
||||
print(creds.api_key, creds.headers)
|
||||
"""
|
||||
|
||||
from .protocols import (
|
||||
@@ -25,7 +32,36 @@ from .protocols import (
|
||||
|
||||
__all__ = [
|
||||
"AuthIdentity",
|
||||
"AuthBackendProtocol",
|
||||
"AuthBackendProtocol",
|
||||
"WorkspaceContextProtocol",
|
||||
"AuthConfig",
|
||||
# Subscription auth (lazy-loaded)
|
||||
"SubscriptionAuthProtocol",
|
||||
"SubscriptionCredentials",
|
||||
"register_subscription_provider",
|
||||
"list_subscription_providers",
|
||||
"resolve_subscription_credentials",
|
||||
"get_subscription_provider",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
"""Lazy-load subscription auth to avoid import overhead."""
|
||||
if name in (
|
||||
"SubscriptionAuthProtocol",
|
||||
"SubscriptionCredentials",
|
||||
"register_subscription_provider",
|
||||
"list_subscription_providers",
|
||||
"resolve_subscription_credentials",
|
||||
"get_subscription_provider",
|
||||
):
|
||||
from .subscription import (
|
||||
SubscriptionAuthProtocol,
|
||||
SubscriptionCredentials,
|
||||
register_subscription_provider,
|
||||
list_subscription_providers,
|
||||
resolve_subscription_credentials,
|
||||
get_subscription_provider,
|
||||
)
|
||||
return locals()[name]
|
||||
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Subscription / OAuth auth for LLM providers.
|
||||
|
||||
Opt-in, zero-overhead. Lazy-loaded via __getattr__ so importing
|
||||
praisonaiagents stays fast.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = [
|
||||
"SubscriptionAuthProtocol",
|
||||
"SubscriptionCredentials",
|
||||
"register_subscription_provider",
|
||||
"list_subscription_providers",
|
||||
"resolve_subscription_credentials",
|
||||
"get_subscription_provider",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
if name in ("SubscriptionAuthProtocol", "SubscriptionCredentials"):
|
||||
from .protocols import SubscriptionAuthProtocol, SubscriptionCredentials
|
||||
return locals()[name]
|
||||
if name in (
|
||||
"register_subscription_provider",
|
||||
"list_subscription_providers",
|
||||
"resolve_subscription_credentials",
|
||||
"get_subscription_provider",
|
||||
):
|
||||
from .registry import (
|
||||
register_subscription_provider,
|
||||
list_subscription_providers,
|
||||
resolve_subscription_credentials,
|
||||
get_subscription_provider,
|
||||
)
|
||||
return locals()[name]
|
||||
raise AttributeError(name)
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Claude Code OAuth — read ~/.claude/.credentials.json or macOS Keychain."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .protocols import AuthError, SubscriptionAuthProtocol, SubscriptionCredentials
|
||||
from .registry import register_subscription_provider
|
||||
|
||||
_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||
_TOKEN_ENDPOINTS = (
|
||||
"https://platform.claude.com/v1/oauth/token",
|
||||
"https://console.anthropic.com/v1/oauth/token",
|
||||
)
|
||||
_CLAUDE_CODE_VERSION_FALLBACK = "2.1.74"
|
||||
|
||||
|
||||
def _detect_claude_code_version() -> str:
|
||||
"""Detect the installed Claude Code version, fall back to a static constant.
|
||||
|
||||
Anthropic's OAuth infrastructure validates the user-agent version and may
|
||||
reject requests with a version that's too old. Detecting dynamically means
|
||||
users who keep Claude Code updated never hit stale-version 400s.
|
||||
"""
|
||||
cached = getattr(_detect_claude_code_version, "_cache", None)
|
||||
if cached:
|
||||
return cached
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["claude", "--version"], capture_output=True, text=True, timeout=2, check=False
|
||||
)
|
||||
match = re.match(r"(\d+\.\d+\.\d+)", out.stdout.strip())
|
||||
version = match.group(1) if match else _CLAUDE_CODE_VERSION_FALLBACK
|
||||
except Exception:
|
||||
version = _CLAUDE_CODE_VERSION_FALLBACK
|
||||
_detect_claude_code_version._cache = version
|
||||
return version
|
||||
|
||||
|
||||
_CLI_USER_AGENT_FALLBACK = f"claude-cli/{_CLAUDE_CODE_VERSION_FALLBACK} (external, cli)"
|
||||
|
||||
|
||||
def _read_keychain_credentials() -> Optional[Dict[str, Any]]:
|
||||
"""Read 'Claude Code-credentials' entry from macOS Keychain.
|
||||
|
||||
Returns dict with accessToken/refreshToken/expiresAt or None.
|
||||
"""
|
||||
if sys.platform != "darwin":
|
||||
return None
|
||||
import subprocess
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"],
|
||||
capture_output=True, text=True, timeout=5, check=False,
|
||||
)
|
||||
if out.returncode != 0 or not out.stdout.strip():
|
||||
return None
|
||||
data = json.loads(out.stdout)
|
||||
oauth = data.get("claudeAiOauth") or {}
|
||||
if oauth.get("accessToken"):
|
||||
return {
|
||||
"accessToken": oauth["accessToken"],
|
||||
"refreshToken": oauth.get("refreshToken", ""),
|
||||
"expiresAt": int(oauth.get("expiresAt", 0)),
|
||||
"source": "claude-code-keychain",
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _read_file_credentials() -> Optional[Dict[str, Any]]:
|
||||
"""Read ~/.claude/.credentials.json."""
|
||||
path = Path.home() / ".claude" / ".credentials.json"
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
oauth = data.get("claudeAiOauth") or {}
|
||||
if not oauth.get("accessToken"):
|
||||
return None
|
||||
return {
|
||||
"accessToken": oauth["accessToken"],
|
||||
"refreshToken": oauth.get("refreshToken", ""),
|
||||
"expiresAt": int(oauth.get("expiresAt", 0)),
|
||||
"source": "claude-code-file",
|
||||
}
|
||||
|
||||
|
||||
def _is_expiring(expires_at_ms: Optional[int], skew_ms: int = 60_000) -> bool:
|
||||
if not expires_at_ms:
|
||||
return False
|
||||
return int(time.time() * 1000) >= (expires_at_ms - skew_ms)
|
||||
|
||||
|
||||
def _refresh(refresh_token: str) -> Dict[str, Any]:
|
||||
"""Pure refresh — does not touch local files."""
|
||||
if not refresh_token:
|
||||
raise AuthError("no refresh_token; please re-run 'claude /login'")
|
||||
body = urllib.parse.urlencode({
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
"client_id": _OAUTH_CLIENT_ID,
|
||||
}).encode()
|
||||
last = None
|
||||
for endpoint in _TOKEN_ENDPOINTS:
|
||||
req = urllib.request.Request(
|
||||
endpoint, data=body, method="POST",
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": f"claude-cli/{_detect_claude_code_version()} (external, cli)",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
payload = json.loads(resp.read().decode())
|
||||
return {
|
||||
"accessToken": payload["access_token"],
|
||||
"refreshToken": payload.get("refresh_token", refresh_token),
|
||||
"expiresAt": int(time.time() * 1000) + int(payload.get("expires_in", 3600)) * 1000,
|
||||
}
|
||||
except Exception as exc:
|
||||
last = exc
|
||||
raise AuthError(f"Anthropic refresh failed: {last}")
|
||||
|
||||
|
||||
class ClaudeCodeAuth:
|
||||
"""Claude Code OAuth subscription auth."""
|
||||
|
||||
name = "claude-code"
|
||||
|
||||
def resolve_credentials(self) -> SubscriptionCredentials:
|
||||
# 1. ANTHROPIC_TOKEN / CLAUDE_CODE_OAUTH_TOKEN env (Hermes-style)
|
||||
for env_key in ("ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"):
|
||||
tok = os.environ.get(env_key, "").strip()
|
||||
if tok:
|
||||
return SubscriptionCredentials(
|
||||
api_key=tok,
|
||||
base_url="https://api.anthropic.com",
|
||||
headers=self.headers_for("https://api.anthropic.com", ""),
|
||||
auth_scheme="bearer",
|
||||
source=f"env:{env_key}",
|
||||
)
|
||||
|
||||
# 2. Keychain or file
|
||||
creds = _read_keychain_credentials() or _read_file_credentials()
|
||||
if not creds:
|
||||
raise AuthError(
|
||||
"No Claude Code credentials found. Install Claude Code and run "
|
||||
"'claude /login', or set ANTHROPIC_TOKEN."
|
||||
)
|
||||
if _is_expiring(creds["expiresAt"]) and creds.get("refreshToken"):
|
||||
creds.update(_refresh(creds["refreshToken"]))
|
||||
return SubscriptionCredentials(
|
||||
api_key=creds["accessToken"],
|
||||
base_url="https://api.anthropic.com",
|
||||
headers=self.headers_for("https://api.anthropic.com", ""),
|
||||
auth_scheme="bearer",
|
||||
expires_at_ms=creds.get("expiresAt"),
|
||||
source=creds["source"],
|
||||
)
|
||||
|
||||
def refresh(self) -> SubscriptionCredentials:
|
||||
creds = _read_keychain_credentials() or _read_file_credentials()
|
||||
if not creds or not creds.get("refreshToken"):
|
||||
raise AuthError("cannot refresh: no refresh token available")
|
||||
new = _refresh(creds["refreshToken"])
|
||||
return SubscriptionCredentials(
|
||||
api_key=new["accessToken"],
|
||||
base_url="https://api.anthropic.com",
|
||||
headers=self.headers_for("https://api.anthropic.com", ""),
|
||||
auth_scheme="bearer",
|
||||
expires_at_ms=new["expiresAt"],
|
||||
source="claude-code-refreshed",
|
||||
)
|
||||
|
||||
def headers_for(self, base_url: str, model: str) -> Dict[str, str]:
|
||||
# CRITICAL: without these headers Anthropic returns 500s on OAuth tokens.
|
||||
# Match Hermes' OAuth header set exactly. litellm auto-adds oauth-2025-04-20.
|
||||
return {
|
||||
"anthropic-beta": ",".join([
|
||||
"interleaved-thinking-2025-05-14",
|
||||
"fine-grained-tool-streaming-2025-05-14",
|
||||
"context-1m-2025-08-07",
|
||||
"claude-code-20250219",
|
||||
]),
|
||||
"user-agent": f"claude-cli/{_detect_claude_code_version()} (external, cli)",
|
||||
"x-app": "cli",
|
||||
}
|
||||
|
||||
|
||||
register_subscription_provider("claude-code", lambda: ClaudeCodeAuth())
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Codex / ChatGPT subscription — read ~/.codex/auth.json (or CODEX_HOME)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .protocols import AuthError, SubscriptionCredentials
|
||||
from .registry import register_subscription_provider
|
||||
|
||||
|
||||
_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
|
||||
|
||||
|
||||
def _read_codex_tokens() -> Optional[Dict[str, Any]]:
|
||||
home = os.environ.get("CODEX_HOME", "").strip() or str(Path.home() / ".codex")
|
||||
auth_path = Path(home).expanduser() / "auth.json"
|
||||
if not auth_path.is_file():
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(auth_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
tokens = payload.get("tokens") or {}
|
||||
if not tokens.get("access_token") or not tokens.get("refresh_token"):
|
||||
return None
|
||||
return tokens
|
||||
|
||||
|
||||
def _is_jwt_expiring(jwt: str, skew_seconds: int = 60) -> bool:
|
||||
"""Decode JWT exp claim without verifying signature."""
|
||||
import base64
|
||||
try:
|
||||
_hdr, payload_b64, _sig = jwt.split(".")
|
||||
pad = "=" * (-len(payload_b64) % 4)
|
||||
payload = json.loads(base64.urlsafe_b64decode(payload_b64 + pad))
|
||||
exp = int(payload.get("exp", 0))
|
||||
return exp and (time.time() + skew_seconds) >= exp
|
||||
except Exception:
|
||||
return False # treat unknown as not-expiring; refresh on actual 401
|
||||
|
||||
|
||||
class CodexAuth:
|
||||
name = "codex"
|
||||
|
||||
def resolve_credentials(self) -> SubscriptionCredentials:
|
||||
# EXPERIMENTAL: Codex uses Responses API, not Chat Completions
|
||||
# OpenAI chat-completions requests will 404 against this endpoint
|
||||
raise AuthError(
|
||||
"Codex auth is experimental and requires custom Responses API transport. "
|
||||
"Use a standard OpenAI API key or wait for proper Codex support in a future release."
|
||||
)
|
||||
|
||||
def refresh(self) -> SubscriptionCredentials:
|
||||
# OpenAI Codex tokens are refreshed by the Codex CLI itself; we rely
|
||||
# on the CLI being run periodically. A direct refresh would require
|
||||
# replicating their device-flow client — out of scope for v1.
|
||||
return self.resolve_credentials()
|
||||
|
||||
def headers_for(self, base_url: str, model: str) -> Dict[str, str]:
|
||||
return {
|
||||
"openai-beta": "responses=v1",
|
||||
"user-agent": "codex-cli/0.1.0 (external)",
|
||||
}
|
||||
|
||||
|
||||
register_subscription_provider("codex", lambda: CodexAuth())
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Gemini CLI OAuth — read ~/.gemini/oauth_creds.json."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .protocols import AuthError, SubscriptionCredentials
|
||||
from .registry import register_subscription_provider
|
||||
|
||||
|
||||
_GEMINI_BASE_URL = "https://cloudcode-pa.googleapis.com/v1internal"
|
||||
|
||||
|
||||
def _read_gemini_tokens() -> Optional[Dict[str, Any]]:
|
||||
"""Read Gemini CLI OAuth credentials."""
|
||||
auth_path = Path.home() / ".gemini" / "oauth_creds.json"
|
||||
if not auth_path.is_file():
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(auth_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
if not payload.get("access_token"):
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
class GeminiCliAuth:
|
||||
name = "gemini-cli"
|
||||
|
||||
def resolve_credentials(self) -> SubscriptionCredentials:
|
||||
# EXPERIMENTAL: Gemini CLI uses Code Assist API, not OpenAI chat format
|
||||
# Standard OpenAI chat-completions requests will not work with cloudcode-pa endpoints
|
||||
raise AuthError(
|
||||
"Gemini CLI auth is experimental and requires custom Code Assist transport. "
|
||||
"Use standard Gemini API keys or wait for proper Code Assist support in a future release."
|
||||
)
|
||||
|
||||
def refresh(self) -> SubscriptionCredentials:
|
||||
# Gemini CLI handles refresh internally
|
||||
return self.resolve_credentials()
|
||||
|
||||
def headers_for(self, base_url: str, model: str) -> Dict[str, str]:
|
||||
return {
|
||||
"user-agent": "gemini-cli/1.0.0 (external)",
|
||||
}
|
||||
|
||||
|
||||
register_subscription_provider("gemini-cli", lambda: GeminiCliAuth())
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Subscription-auth protocol — what every provider must implement."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional, Protocol, runtime_checkable
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubscriptionCredentials:
|
||||
"""Resolved credentials ready to inject into an LLM client."""
|
||||
api_key: str # access_token (or "" for SDK-default chains like AWS)
|
||||
base_url: str = "" # provider endpoint
|
||||
headers: Dict[str, str] = field(default_factory=dict) # extra headers (UA, x-app, betas)
|
||||
auth_scheme: str = "bearer" # "bearer" | "x-api-key" | "sdk-default"
|
||||
expires_at_ms: Optional[int] = None # epoch ms; None = no expiry tracked
|
||||
source: str = "" # "claude-code-keychain", "codex-cli-file", etc.
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SubscriptionAuthProtocol(Protocol):
|
||||
"""Discover, refresh, and inject subscription credentials."""
|
||||
|
||||
def resolve_credentials(self) -> SubscriptionCredentials:
|
||||
"""Return current valid credentials, refreshing if expiring soon.
|
||||
|
||||
Raises:
|
||||
AuthError: if no usable credentials can be resolved.
|
||||
"""
|
||||
...
|
||||
|
||||
def refresh(self) -> SubscriptionCredentials:
|
||||
"""Force a refresh and return the new credentials."""
|
||||
...
|
||||
|
||||
def headers_for(self, base_url: str, model: str) -> Dict[str, str]:
|
||||
"""Provider-specific headers (UA, beta flags) keyed off endpoint/model."""
|
||||
...
|
||||
|
||||
|
||||
class AuthError(RuntimeError):
|
||||
"""Raised when subscription credentials cannot be resolved."""
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Qwen CLI OAuth — read ~/.qwen/oauth_creds.json."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .protocols import AuthError, SubscriptionCredentials
|
||||
from .registry import register_subscription_provider
|
||||
|
||||
|
||||
_QWEN_BASE_URL = "https://portal.qwen.ai/v1"
|
||||
|
||||
|
||||
def _read_qwen_tokens() -> Optional[Dict[str, Any]]:
|
||||
"""Read Qwen CLI OAuth credentials."""
|
||||
auth_path = Path.home() / ".qwen" / "oauth_creds.json"
|
||||
if not auth_path.is_file():
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(auth_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
if not payload.get("access_token"):
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
class QwenCliAuth:
|
||||
name = "qwen-cli"
|
||||
|
||||
def resolve_credentials(self) -> SubscriptionCredentials:
|
||||
tokens = _read_qwen_tokens()
|
||||
if not tokens:
|
||||
raise AuthError(
|
||||
"No Qwen CLI credentials at ~/.qwen/oauth_creds.json. "
|
||||
"Install Qwen CLI and run 'qwen login'."
|
||||
)
|
||||
return SubscriptionCredentials(
|
||||
api_key=tokens["access_token"],
|
||||
base_url=os.environ.get("PRAISONAI_QWEN_BASE_URL", "").strip() or _QWEN_BASE_URL,
|
||||
headers=self.headers_for(_QWEN_BASE_URL, ""),
|
||||
auth_scheme="bearer",
|
||||
source="qwen-cli-file",
|
||||
)
|
||||
|
||||
def refresh(self) -> SubscriptionCredentials:
|
||||
# Qwen CLI handles refresh internally
|
||||
return self.resolve_credentials()
|
||||
|
||||
def headers_for(self, base_url: str, model: str) -> Dict[str, str]:
|
||||
return {
|
||||
"user-agent": "qwen-cli/1.0.0 (external)",
|
||||
}
|
||||
|
||||
|
||||
register_subscription_provider("qwen-cli", lambda: QwenCliAuth())
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Thread-safe provider registry for subscription auth."""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
from .protocols import AuthError, SubscriptionAuthProtocol, SubscriptionCredentials
|
||||
|
||||
_REGISTRY: Dict[str, Callable[[], SubscriptionAuthProtocol]] = {}
|
||||
_LOCK = threading.RLock()
|
||||
_BUILTIN_REGISTERED = False
|
||||
|
||||
|
||||
def register_subscription_provider(
|
||||
provider_id: str,
|
||||
factory: Callable[[], SubscriptionAuthProtocol],
|
||||
) -> None:
|
||||
"""Register a new subscription-auth provider.
|
||||
|
||||
Args:
|
||||
provider_id: short id e.g. "claude-code", "codex", "gemini-cli".
|
||||
factory: zero-arg callable returning a SubscriptionAuthProtocol impl.
|
||||
"""
|
||||
with _LOCK:
|
||||
_REGISTRY[provider_id] = factory
|
||||
|
||||
|
||||
def list_subscription_providers() -> List[str]:
|
||||
_ensure_builtins()
|
||||
with _LOCK:
|
||||
return sorted(_REGISTRY)
|
||||
|
||||
|
||||
def resolve_subscription_credentials(provider_id: str) -> SubscriptionCredentials:
|
||||
"""Look up provider by id and resolve its credentials."""
|
||||
_ensure_builtins()
|
||||
with _LOCK:
|
||||
factory = _REGISTRY.get(provider_id)
|
||||
if factory is None:
|
||||
raise AuthError(
|
||||
f"Unknown subscription provider {provider_id!r}. "
|
||||
f"Registered: {list_subscription_providers()}"
|
||||
)
|
||||
return factory().resolve_credentials()
|
||||
|
||||
|
||||
def get_subscription_provider(provider_id: str) -> Optional[SubscriptionAuthProtocol]:
|
||||
"""Return a new provider instance for *provider_id*, or None if not registered."""
|
||||
_ensure_builtins()
|
||||
with _LOCK:
|
||||
factory = _REGISTRY.get(provider_id)
|
||||
return factory() if factory is not None else None
|
||||
|
||||
|
||||
def _ensure_builtins() -> None:
|
||||
"""Lazy register built-in providers on first use."""
|
||||
global _BUILTIN_REGISTERED
|
||||
with _LOCK:
|
||||
if _BUILTIN_REGISTERED:
|
||||
return
|
||||
# Import modules inside the lock to prevent race conditions
|
||||
from . import claude_code, codex, gemini_cli, qwen_cli # noqa: F401
|
||||
# Each module registers itself at import time.
|
||||
_BUILTIN_REGISTERED = True
|
||||
@@ -407,7 +407,7 @@ def _get_bg_loop() -> asyncio.AbstractEventLoop:
|
||||
"""Return a running event loop on a background daemon thread.
|
||||
|
||||
Created once on first call; reused thereafter. The thread is a
|
||||
daemon so it won't prevent process exit.
|
||||
daemon so it won't prevent process exit, but includes graceful shutdown.
|
||||
"""
|
||||
global _bg_loop
|
||||
if _bg_loop is not None and _bg_loop.is_running():
|
||||
@@ -424,3 +424,31 @@ def _get_bg_loop() -> asyncio.AbstractEventLoop:
|
||||
t.start()
|
||||
|
||||
return _bg_loop
|
||||
|
||||
|
||||
def _shutdown_bg_loop():
|
||||
"""Gracefully shutdown the background event loop."""
|
||||
global _bg_loop
|
||||
if _bg_loop is None or not _bg_loop.is_running():
|
||||
return
|
||||
|
||||
async def _cancel_pending():
|
||||
current = asyncio.current_task()
|
||||
pending = [t for t in asyncio.all_tasks() if t is not current]
|
||||
for t in pending:
|
||||
t.cancel()
|
||||
if pending:
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
|
||||
try:
|
||||
fut = asyncio.run_coroutine_threadsafe(_cancel_pending(), _bg_loop)
|
||||
fut.result(timeout=2)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
_bg_loop.call_soon_threadsafe(_bg_loop.stop)
|
||||
|
||||
|
||||
# Register cleanup on process exit
|
||||
import atexit
|
||||
atexit.register(_shutdown_bg_loop)
|
||||
@@ -5,7 +5,9 @@ Provides configuration dataclasses for bot settings.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
|
||||
from .pairing_types import UnknownUserPolicy
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -48,16 +50,32 @@ class BotConfig:
|
||||
# Group message policy
|
||||
group_policy: str = "mention_only" # respond_all, mention_only, command_only
|
||||
|
||||
# Default tools enabled for all bots
|
||||
# Default safe tools (auto-injected for bots with no tools configured)
|
||||
# With workspace scoping, file operations are now safe by construction
|
||||
default_tools: List[str] = field(default_factory=lambda: [
|
||||
"execute_command", "search_web", "web_crawl",
|
||||
"schedule_add", "schedule_list", "schedule_remove",
|
||||
# Web (existing)
|
||||
"search_web", "web_crawl",
|
||||
# Memory / learning (existing)
|
||||
"store_memory", "search_memory",
|
||||
"store_learning", "search_learning",
|
||||
# Scheduling (existing)
|
||||
"schedule_add", "schedule_list", "schedule_remove",
|
||||
# Clarify tool (new from main)
|
||||
"clarify",
|
||||
# Files — NEW (workspace-scoped, safe by construction)
|
||||
"read_file", "write_file", "edit_file", "list_files", "search_files",
|
||||
# Planning — NEW
|
||||
"todo_add", "todo_list", "todo_update",
|
||||
# Skills (self-improving) — NEW
|
||||
"skills_list", "skill_view", "skill_manage",
|
||||
# Delegation & session tools are intentionally NOT auto-injected yet;
|
||||
# their reference implementations are placeholders. Users can opt in
|
||||
# via BotConfig(default_tools=[..., "delegate_task", "session_search"]).
|
||||
])
|
||||
|
||||
# Auto-approve tool calls (useful for trusted environments)
|
||||
auto_approve_tools: bool = False # If True, skip confirmation for tool execution
|
||||
# Default to True for chat bots - they can't show CLI approval prompts
|
||||
auto_approve_tools: bool = True # If True, skip confirmation for safe tool execution
|
||||
|
||||
# Inbound message debounce (ms). Coalesces rapid messages from same user.
|
||||
# 0 = disabled (default). Recommended: 1000-2000 for chat bots.
|
||||
@@ -73,6 +91,24 @@ class BotConfig:
|
||||
# When set, stale sessions older than this are auto-reaped.
|
||||
session_ttl: int = 0
|
||||
|
||||
# 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)
|
||||
workspace_scope: str = "session" # "shared" | "session" | "user" | "agent"
|
||||
|
||||
# Unknown user policy: "deny" (default), "pair", or "allow"
|
||||
unknown_user_policy: UnknownUserPolicy = "deny"
|
||||
|
||||
# Owner user ID for pairing approvals (platform-specific format)
|
||||
owner_user_id: Optional[str] = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.unknown_user_policy not in {"deny", "pair", "allow"}:
|
||||
raise ValueError(
|
||||
f"unknown_user_policy must be one of: deny, pair, allow. Got: {self.unknown_user_policy}"
|
||||
)
|
||||
|
||||
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
@@ -100,6 +136,11 @@ class BotConfig:
|
||||
"ack_emoji": self.ack_emoji,
|
||||
"done_emoji": self.done_emoji,
|
||||
"session_ttl": self.session_ttl,
|
||||
"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,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Pairing types and protocols for bot user approval.
|
||||
|
||||
Contains lightweight types and protocols for the pairing approval system.
|
||||
Heavy implementations are in the wrapper package.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, Optional
|
||||
|
||||
# Policy for handling unknown users
|
||||
UnknownUserPolicy = Literal["deny", "pair", "allow"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class PairingReply:
|
||||
"""Reply data for pairing approval requests."""
|
||||
|
||||
user_name: str
|
||||
user_id: str
|
||||
channel: str
|
||||
code: str
|
||||
message: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
if not self.message:
|
||||
self.message = f"User {self.user_name} wants to chat. Approve access?"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PairingApprovalResult:
|
||||
"""Result of a pairing approval action."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
user_id: Optional[str] = None
|
||||
channel: Optional[str] = None
|
||||
@@ -9,6 +9,7 @@ import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
import threading
|
||||
from typing import Any, Callable, Dict, List, Optional, Set, Union
|
||||
from ..utils.async_bridge import run_coroutine_from_any_context
|
||||
from dataclasses import dataclass, field
|
||||
from .event import Event, EventType
|
||||
|
||||
@@ -209,13 +210,13 @@ class EventBus:
|
||||
for sub in subscribers:
|
||||
try:
|
||||
if sub.is_async:
|
||||
# Schedule async callback
|
||||
# Schedule async callback safely
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.create_task(sub.callback(event))
|
||||
except RuntimeError:
|
||||
# No running loop, run synchronously
|
||||
asyncio.run(sub.callback(event))
|
||||
# No running loop, use safe async bridge
|
||||
run_coroutine_from_any_context(sub.callback(event))
|
||||
else:
|
||||
sub.callback(event)
|
||||
except Exception as e:
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""CLI Backend Protocol for PraisonAI Agents.
|
||||
|
||||
Provides protocol-driven core for integrating external CLI tools as agent backends.
|
||||
|
||||
This module follows AGENTS.md protocol-driven design:
|
||||
- Core SDK contains only protocols and dataclasses
|
||||
- Heavy implementations live in wrapper (praisonai package)
|
||||
- Zero optional dependencies at import time
|
||||
"""
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy loading for CLI backend components."""
|
||||
if name == "CliBackendProtocol":
|
||||
from .protocols import CliBackendProtocol
|
||||
return CliBackendProtocol
|
||||
elif name == "CliBackendConfig":
|
||||
from .protocols import CliBackendConfig
|
||||
return CliBackendConfig
|
||||
elif name == "CliSessionBinding":
|
||||
from .protocols import CliSessionBinding
|
||||
return CliSessionBinding
|
||||
elif name == "CliBackendResult":
|
||||
from .protocols import CliBackendResult
|
||||
return CliBackendResult
|
||||
elif name == "CliBackendDelta":
|
||||
from .protocols import CliBackendDelta
|
||||
return CliBackendDelta
|
||||
else:
|
||||
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
|
||||
|
||||
__all__ = [
|
||||
"CliBackendProtocol",
|
||||
"CliBackendConfig",
|
||||
"CliSessionBinding",
|
||||
"CliBackendResult",
|
||||
"CliBackendDelta"
|
||||
]
|
||||
@@ -0,0 +1,135 @@
|
||||
"""CLI Backend protocols and data structures.
|
||||
|
||||
Protocol-driven design following AGENTS.md:
|
||||
- Lightweight protocols only (no implementations)
|
||||
- Dataclasses for configuration
|
||||
- Async-first with proper typing
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Protocol, AsyncIterator, Optional, List, Dict, Any, runtime_checkable
|
||||
|
||||
|
||||
@dataclass
|
||||
class CliBackendConfig:
|
||||
"""Declarative CLI backend configuration.
|
||||
|
||||
Mirrors OpenClaw's CliBackendConfig for cross-ecosystem compatibility.
|
||||
"""
|
||||
# Core command
|
||||
command: str # e.g., "claude", "codex", "gemini"
|
||||
args: List[str] = field(default_factory=list)
|
||||
|
||||
# Session management
|
||||
resume_args: Optional[List[str]] = None # supports "{session_id}" placeholder
|
||||
session_arg: Optional[str] = None # e.g., "--session-id"
|
||||
session_mode: str = "none" # "always" | "existing" | "none"
|
||||
session_id_fields: List[str] = field(default_factory=list)
|
||||
|
||||
# Input/output format
|
||||
output: str = "text" # "text" | "json" | "jsonl"
|
||||
input: str = "arg" # "arg" | "stdin"
|
||||
max_prompt_arg_chars: Optional[int] = None
|
||||
|
||||
# Model configuration
|
||||
model_arg: Optional[str] = None # e.g., "--model"
|
||||
model_aliases: Dict[str, str] = field(default_factory=dict)
|
||||
|
||||
# System prompt handling
|
||||
system_prompt_arg: Optional[str] = None # e.g., "--append-system-prompt"
|
||||
system_prompt_when: str = "always" # "first" | "always" | "never"
|
||||
system_prompt_mode: str = "append" # "append" | "replace"
|
||||
|
||||
# Image support
|
||||
image_arg: Optional[str] = None # e.g., "--image"
|
||||
image_mode: str = "repeat" # "repeat" | "list"
|
||||
|
||||
# Environment
|
||||
clear_env: List[str] = field(default_factory=list) # Env vars to clear
|
||||
env: Dict[str, str] = field(default_factory=dict) # Env vars to set
|
||||
|
||||
# Advanced features
|
||||
live_session: Optional[str] = None # e.g., "claude-stdio"
|
||||
bundle_mcp: bool = False
|
||||
bundle_mcp_mode: Optional[str] = None # e.g., "claude-config-file"
|
||||
serialize: bool = False # Queue operations to avoid conflicts
|
||||
|
||||
# Timeouts
|
||||
no_output_timeout_ms: Optional[int] = None
|
||||
timeout_ms: int = 300_000
|
||||
|
||||
|
||||
@dataclass
|
||||
class CliSessionBinding:
|
||||
"""Session binding for CLI backend state tracking."""
|
||||
session_id: Optional[str] = None
|
||||
auth_profile_id: Optional[str] = None
|
||||
system_prompt_hash: Optional[str] = None
|
||||
mcp_config_hash: Optional[str] = None
|
||||
is_resume: bool = False # Whether this is resuming an existing session
|
||||
|
||||
|
||||
@dataclass
|
||||
class CliBackendResult:
|
||||
"""Result from CLI backend execution."""
|
||||
content: str
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
session_id: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CliBackendDelta:
|
||||
"""Streaming delta from CLI backend."""
|
||||
type: str # "text" | "tool_call" | "thinking" | "error"
|
||||
content: str = ""
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CliBackendProtocol(Protocol):
|
||||
"""Protocol for CLI backend implementations.
|
||||
|
||||
Any object implementing these methods can serve as an Agent backend.
|
||||
Follows typing.Protocol pattern from AGENTS.md.
|
||||
"""
|
||||
config: CliBackendConfig
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
prompt: str,
|
||||
*,
|
||||
session: Optional[CliSessionBinding] = None,
|
||||
images: Optional[List[str]] = None,
|
||||
system_prompt: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> CliBackendResult:
|
||||
"""Execute a single prompt and return result.
|
||||
|
||||
Args:
|
||||
prompt: User prompt/query
|
||||
session: Session binding for state management
|
||||
images: List of image paths for multimodal prompts
|
||||
system_prompt: System prompt override
|
||||
**kwargs: Additional backend-specific options
|
||||
|
||||
Returns:
|
||||
CliBackendResult with response content and metadata
|
||||
"""
|
||||
...
|
||||
|
||||
async def stream(
|
||||
self,
|
||||
prompt: str,
|
||||
**kwargs
|
||||
) -> AsyncIterator[CliBackendDelta]:
|
||||
"""Stream response deltas from CLI backend.
|
||||
|
||||
Args:
|
||||
prompt: User prompt/query
|
||||
**kwargs: Additional options (session, images, etc.)
|
||||
|
||||
Yields:
|
||||
CliBackendDelta objects with incremental response content
|
||||
"""
|
||||
...
|
||||
@@ -7,7 +7,7 @@ Inspired by Agno's SessionContextStore pattern.
|
||||
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Any, Dict, List, Optional
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
@@ -97,8 +97,8 @@ class SessionContextTracker:
|
||||
|
||||
self._state = SessionState(
|
||||
session_id=self.session_id,
|
||||
created_at=datetime.utcnow().isoformat() + "Z",
|
||||
updated_at=datetime.utcnow().isoformat() + "Z",
|
||||
created_at=datetime.now(timezone.utc).isoformat(),
|
||||
updated_at=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
|
||||
def _generate_session_id(self) -> str:
|
||||
@@ -163,7 +163,7 @@ class SessionContextTracker:
|
||||
|
||||
def _mark_updated(self) -> None:
|
||||
"""Update the updated_at timestamp."""
|
||||
self._state.updated_at = datetime.utcnow().isoformat() + "Z"
|
||||
self._state.updated_at = datetime.now(timezone.utc).isoformat()
|
||||
self._state.turn_count += 1
|
||||
|
||||
def to_context_string(self) -> str:
|
||||
@@ -257,7 +257,7 @@ Current messages take precedence if there's any conflict with this summary.
|
||||
self._state = SessionState(
|
||||
session_id=self.session_id,
|
||||
created_at=self._state.created_at,
|
||||
updated_at=datetime.utcnow().isoformat() + "Z",
|
||||
updated_at=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
|
||||
@@ -12,7 +12,7 @@ import os
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -39,7 +39,7 @@ class GradeResult:
|
||||
input_text: str = ""
|
||||
output: str = ""
|
||||
expected_output: Optional[str] = None
|
||||
timestamp: str = field(default_factory=lambda: datetime.utcnow().isoformat())
|
||||
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
@@ -55,7 +55,7 @@ class GradeResult:
|
||||
input_text=data.get("input_text", ""),
|
||||
output=data.get("output", ""),
|
||||
expected_output=data.get("expected_output"),
|
||||
timestamp=data.get("timestamp", datetime.utcnow().isoformat()),
|
||||
timestamp=data.get("timestamp", datetime.now(timezone.utc).isoformat()),
|
||||
)
|
||||
|
||||
class BaseLLMGrader:
|
||||
|
||||
@@ -197,6 +197,7 @@ class GatewayConfig:
|
||||
host: Host to bind to
|
||||
port: Port to listen on
|
||||
cors_origins: Allowed CORS origins
|
||||
allowed_origins: Allowed origins for WebSocket connections (CSWSH defense)
|
||||
auth_token: Optional authentication token
|
||||
max_connections: Maximum concurrent connections
|
||||
max_sessions_per_agent: Maximum sessions per agent (0 = unlimited)
|
||||
@@ -210,7 +211,9 @@ class GatewayConfig:
|
||||
|
||||
host: str = "127.0.0.1"
|
||||
port: int = 8765
|
||||
bind_host: Optional[str] = None
|
||||
cors_origins: List[str] = field(default_factory=lambda: [])
|
||||
allowed_origins: List[str] = field(default_factory=lambda: [])
|
||||
auth_token: Optional[str] = None
|
||||
max_connections: int = 1000
|
||||
max_sessions_per_agent: int = 0 # 0 = unlimited
|
||||
@@ -220,6 +223,11 @@ class GatewayConfig:
|
||||
ssl_cert: Optional[str] = None
|
||||
ssl_key: Optional[str] = None
|
||||
push: PushConfig = field(default_factory=PushConfig)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Post-initialization to set bind_host from host if not specified."""
|
||||
if self.bind_host is None:
|
||||
self.bind_host = self.host
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary (hides sensitive data)."""
|
||||
@@ -227,6 +235,7 @@ class GatewayConfig:
|
||||
"host": self.host,
|
||||
"port": self.port,
|
||||
"cors_origins": self.cors_origins,
|
||||
"allowed_origins": self.allowed_origins,
|
||||
"auth_token": "***" if self.auth_token else None,
|
||||
"max_connections": self.max_connections,
|
||||
"max_sessions_per_agent": self.max_sessions_per_agent,
|
||||
@@ -360,6 +369,7 @@ class MultiChannelGatewayConfig:
|
||||
host=gw_data.get("host", "127.0.0.1"),
|
||||
port=gw_data.get("port", 8765),
|
||||
cors_origins=gw_data.get("cors_origins", []),
|
||||
allowed_origins=gw_data.get("allowed_origins", []),
|
||||
auth_token=gw_data.get("auth_token"),
|
||||
max_connections=gw_data.get("max_connections", 1000),
|
||||
)
|
||||
|
||||
@@ -20,6 +20,7 @@ from typing import (
|
||||
Callable,
|
||||
Dict,
|
||||
List,
|
||||
Literal,
|
||||
Optional,
|
||||
Protocol,
|
||||
Union,
|
||||
@@ -27,6 +28,7 @@ from typing import (
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from praisonai.gateway.pairing import PairedChannel
|
||||
from ..agent import Agent
|
||||
|
||||
|
||||
@@ -684,3 +686,221 @@ class DeliveryGuaranteeProtocol(Protocol):
|
||||
Number of messages purged
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth Mode protocols and helpers (bind-aware authentication posture)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
AuthMode = Literal["local", "token", "password", "trusted-proxy"]
|
||||
"""Authentication mode for gateway/UI components.
|
||||
|
||||
- "local": Permissive mode for loopback interfaces (127.0.0.1, localhost, ::1)
|
||||
- "token": Token-based authentication required (default for external interfaces)
|
||||
- "password": Username/password authentication
|
||||
- "trusted-proxy": Authentication handled by upstream proxy
|
||||
"""
|
||||
|
||||
|
||||
def is_loopback(host: str) -> bool:
|
||||
"""Check if a host is a loopback interface.
|
||||
|
||||
Args:
|
||||
host: Host/IP address to check
|
||||
|
||||
Returns:
|
||||
True if the host is a loopback address
|
||||
|
||||
Examples:
|
||||
>>> is_loopback("127.0.0.1")
|
||||
True
|
||||
>>> is_loopback("localhost")
|
||||
True
|
||||
>>> is_loopback("::1")
|
||||
True
|
||||
>>> is_loopback("0.0.0.0")
|
||||
False
|
||||
>>> is_loopback("192.168.1.1")
|
||||
False
|
||||
"""
|
||||
import ipaddress
|
||||
|
||||
# Handle localhost specially
|
||||
if host in ("localhost", "0:0:0:0:0:0:0:1"):
|
||||
return True
|
||||
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
return ip.is_loopback
|
||||
except ValueError:
|
||||
# Not a valid IP address (e.g., domain name)
|
||||
return False
|
||||
|
||||
|
||||
def resolve_auth_mode(bind_host: str, configured: Optional[AuthMode] = None) -> AuthMode:
|
||||
"""Resolve authentication mode based on bind host and explicit configuration.
|
||||
|
||||
Args:
|
||||
bind_host: Host/IP that the service is bound to
|
||||
configured: Explicitly configured auth mode (takes precedence)
|
||||
|
||||
Returns:
|
||||
The resolved authentication mode
|
||||
|
||||
Examples:
|
||||
>>> resolve_auth_mode("127.0.0.1")
|
||||
'local'
|
||||
>>> resolve_auth_mode("0.0.0.0")
|
||||
'token'
|
||||
>>> resolve_auth_mode("127.0.0.1", "token")
|
||||
'token'
|
||||
"""
|
||||
if configured is not None:
|
||||
return configured
|
||||
|
||||
return "local" if is_loopback(bind_host) else "token"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth, Pairing, and Session Binding Protocols (Issue #1588 Gap 3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@runtime_checkable
|
||||
class AuthProtocol(Protocol):
|
||||
"""Protocol for authentication implementations.
|
||||
|
||||
Enables structural typing for different authentication strategies
|
||||
(token-based, local loopback, trusted proxy, etc.).
|
||||
"""
|
||||
|
||||
def check(self, request: Any) -> Dict[str, Any]:
|
||||
"""Check authentication for a request.
|
||||
|
||||
Args:
|
||||
request: The request object to authenticate
|
||||
|
||||
Returns:
|
||||
Authentication decision with metadata:
|
||||
- success: bool - whether authentication succeeded
|
||||
- user_id: Optional[str] - authenticated user ID
|
||||
- role: Optional[str] - user role/permissions
|
||||
- metadata: Dict[str, Any] - additional auth context
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PairingProtocol(Protocol):
|
||||
"""Protocol for channel pairing implementations.
|
||||
|
||||
Manages the authorization of external channels (Telegram, Slack, UI)
|
||||
to communicate with the gateway through signed codes.
|
||||
"""
|
||||
|
||||
def generate_code(
|
||||
self,
|
||||
channel_type: str = "unknown",
|
||||
channel_id: Optional[str] = None
|
||||
) -> str:
|
||||
"""Generate a new pairing code for a channel.
|
||||
|
||||
Args:
|
||||
channel_type: Type of channel (e.g., "telegram", "slack", "ui")
|
||||
channel_id: Optional channel identifier
|
||||
|
||||
Returns:
|
||||
The generated pairing code
|
||||
"""
|
||||
...
|
||||
|
||||
def approve(
|
||||
self,
|
||||
channel_type: str,
|
||||
code: str,
|
||||
user_id: str = "",
|
||||
user_name: str = ""
|
||||
) -> bool:
|
||||
"""Approve a pairing code, authorizing the channel.
|
||||
|
||||
Args:
|
||||
channel_type: Type of channel
|
||||
code: The pairing code to approve
|
||||
user_id: User identifier (optional, defaults to empty string)
|
||||
user_name: Human-readable username (optional, defaults to empty string)
|
||||
|
||||
Returns:
|
||||
True if approval successful, False if code invalid/expired
|
||||
"""
|
||||
...
|
||||
|
||||
def is_paired(self, channel_id: str, channel_type: str) -> bool:
|
||||
"""Check if a channel is authorized.
|
||||
|
||||
Args:
|
||||
channel_id: Channel identifier
|
||||
channel_type: Type of channel
|
||||
|
||||
Returns:
|
||||
True if channel is paired/authorized
|
||||
"""
|
||||
...
|
||||
|
||||
def list_paired(self) -> List["PairedChannel"]:
|
||||
"""List all authorized channels.
|
||||
|
||||
Returns:
|
||||
List of paired channel information
|
||||
"""
|
||||
...
|
||||
|
||||
def revoke(self, channel_id: str, channel_type: str) -> bool:
|
||||
"""Revoke authorization for a channel.
|
||||
|
||||
Args:
|
||||
channel_id: Channel identifier
|
||||
channel_type: Type of channel
|
||||
|
||||
Returns:
|
||||
True if revocation successful, False if not found
|
||||
"""
|
||||
...
|
||||
|
||||
def list_pending(self, channel_type: Optional[str] = None) -> List[Dict[str, any]]:
|
||||
"""List pending pairing requests.
|
||||
|
||||
Args:
|
||||
channel_type: Optional filter by channel type
|
||||
|
||||
Returns:
|
||||
List of pending requests with channel, code, user info, and age
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SessionBindingProtocol(Protocol):
|
||||
"""Protocol for session binding implementations.
|
||||
|
||||
Manages the association between sessions and authenticated principals
|
||||
(users, agents, etc.) for state tracking and authorization.
|
||||
"""
|
||||
|
||||
def bind(self, session_id: str, principal: Dict[str, Any]) -> None:
|
||||
"""Bind a session to an authenticated principal.
|
||||
|
||||
Args:
|
||||
session_id: Unique session identifier
|
||||
principal: Principal information (user_id, roles, metadata, etc.)
|
||||
"""
|
||||
...
|
||||
|
||||
def lookup(self, session_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Look up the principal bound to a session.
|
||||
|
||||
Args:
|
||||
session_id: Session identifier to look up
|
||||
|
||||
Returns:
|
||||
Principal information if found, None otherwise
|
||||
"""
|
||||
...
|
||||
@@ -146,12 +146,12 @@ class HookRunner:
|
||||
loop = None
|
||||
|
||||
if loop is not None and loop.is_running():
|
||||
# Already inside a running async loop — cannot use run_until_complete.
|
||||
# Schedule as fire-and-forget task to avoid leaking unawaited coroutines.
|
||||
# Callers that need results in async context should use
|
||||
# ``await runner.execute(...)`` directly.
|
||||
loop.create_task(self.execute(event, input_data, target))
|
||||
return []
|
||||
# Cannot execute sync in running event loop - would block the loop
|
||||
# Guide users to use the async API instead
|
||||
raise RuntimeError(
|
||||
"execute_sync() cannot be called from within a running event loop. "
|
||||
"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))
|
||||
|
||||
@@ -71,7 +71,8 @@ class Mem0Adapter:
|
||||
from mem0 import Memory
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"mem0 is not installed. Install with: pip install mem0ai"
|
||||
"mem0ai is not installed. "
|
||||
"Run: pip install 'praisonaiagents[memory]'"
|
||||
)
|
||||
|
||||
if self._config:
|
||||
|
||||
@@ -159,6 +159,26 @@ def __getattr__(name):
|
||||
from .unified_adapters import create_llm_dispatcher
|
||||
_lazy_cache[name] = create_llm_dispatcher
|
||||
return create_llm_dispatcher
|
||||
elif name == "sanitize_messages":
|
||||
from .sanitize import sanitize_messages
|
||||
_lazy_cache[name] = sanitize_messages
|
||||
return sanitize_messages
|
||||
elif name == "strip_surrogates":
|
||||
from .sanitize import strip_surrogates
|
||||
_lazy_cache[name] = strip_surrogates
|
||||
return strip_surrogates
|
||||
elif name == "sanitize_text":
|
||||
from .sanitize import sanitize_text
|
||||
_lazy_cache[name] = sanitize_text
|
||||
return sanitize_text
|
||||
elif name == "ErrorCategory":
|
||||
from .error_classifier import ErrorCategory
|
||||
_lazy_cache[name] = ErrorCategory
|
||||
return ErrorCategory
|
||||
elif name == "classify_error":
|
||||
from .error_classifier import classify_error
|
||||
_lazy_cache[name] = classify_error
|
||||
return classify_error
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
@@ -199,5 +219,12 @@ __all__ = [
|
||||
"LLMProviderError",
|
||||
"RateLimitError",
|
||||
"ModelNotAvailableError",
|
||||
"ContextLengthExceededError"
|
||||
"ContextLengthExceededError",
|
||||
# Sanitization (G6)
|
||||
"sanitize_messages",
|
||||
"strip_surrogates",
|
||||
"sanitize_text",
|
||||
# Error Classification (G5)
|
||||
"ErrorCategory",
|
||||
"classify_error"
|
||||
]
|
||||
@@ -0,0 +1,252 @@
|
||||
"""
|
||||
Error Classification - Multi-category error classifier for intelligent retry logic.
|
||||
|
||||
Extends the existing single-category rate limit detection with comprehensive
|
||||
error classification for better handling of different failure modes.
|
||||
"""
|
||||
|
||||
import re
|
||||
import random
|
||||
from enum import Enum
|
||||
from typing import Dict, Tuple, List, Optional
|
||||
|
||||
__all__ = [
|
||||
"ErrorCategory",
|
||||
"classify_error",
|
||||
"should_retry",
|
||||
"get_retry_delay",
|
||||
"extract_retry_after",
|
||||
"get_error_context",
|
||||
]
|
||||
|
||||
|
||||
class ErrorCategory(str, Enum):
|
||||
"""Categories of LLM provider errors for intelligent handling."""
|
||||
RATE_LIMIT = "rate_limit" # Too many requests, temporary
|
||||
CONTEXT_LIMIT = "context_limit" # Input too long, need compression
|
||||
AUTH = "auth" # Authentication/authorization failure
|
||||
INVALID_REQUEST = "invalid_request" # Malformed request, permanent
|
||||
TRANSIENT = "transient" # Network/server issues, temporary
|
||||
PERMANENT = "permanent" # Unrecoverable error
|
||||
|
||||
|
||||
# Error patterns for classification (case-insensitive)
|
||||
_ERROR_PATTERNS: Dict[ErrorCategory, List[str]] = {
|
||||
ErrorCategory.RATE_LIMIT: [
|
||||
r"rate.?limit",
|
||||
r"429",
|
||||
r"too.?many.?request",
|
||||
r"resource.?exhausted",
|
||||
r"quota.?exceeded",
|
||||
r"tokens.?per.?minute",
|
||||
r"requests.?per.?minute",
|
||||
r"concurrent.?requests",
|
||||
],
|
||||
|
||||
ErrorCategory.CONTEXT_LIMIT: [
|
||||
r"context.?length",
|
||||
r"maximum.?context",
|
||||
r"token.?limit",
|
||||
r"input.?too.?long",
|
||||
r"sequence.?too.?long",
|
||||
r"context.?window",
|
||||
r"413", # Request Entity Too Large
|
||||
r"payload.?too.?large",
|
||||
],
|
||||
|
||||
ErrorCategory.AUTH: [
|
||||
r"authenticat",
|
||||
r"authoriz",
|
||||
r"401",
|
||||
r"403",
|
||||
r"invalid.?api.?key",
|
||||
r"api.?key.*invalid",
|
||||
r"permission.?denied",
|
||||
r"access.?denied",
|
||||
r"forbidden",
|
||||
r"unauthorized",
|
||||
r"invalid.?token",
|
||||
r"expired.*token",
|
||||
],
|
||||
|
||||
ErrorCategory.INVALID_REQUEST: [
|
||||
r"invalid.*request",
|
||||
r"bad.?request",
|
||||
r"400",
|
||||
r"malformed",
|
||||
r"invalid.*parameter",
|
||||
r"unsupported.*model",
|
||||
r"model.*not.*found",
|
||||
r"validation.*error",
|
||||
r"schema.*error",
|
||||
],
|
||||
|
||||
ErrorCategory.TRANSIENT: [
|
||||
r"timeout",
|
||||
r"timed.?out",
|
||||
r"500", r"502", r"503", r"504",
|
||||
r"internal.?server.?error",
|
||||
r"bad.?gateway",
|
||||
r"service.?unavailable",
|
||||
r"gateway.?timeout",
|
||||
r"connection.*error",
|
||||
r"network.*error",
|
||||
r"temporary.*unavailable",
|
||||
r"server.?overload",
|
||||
r"retry.?after",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def classify_error(error: Exception) -> ErrorCategory:
|
||||
"""Classify an error into a category for intelligent handling.
|
||||
|
||||
Args:
|
||||
error: Exception to classify
|
||||
|
||||
Returns:
|
||||
ErrorCategory indicating how the error should be handled
|
||||
|
||||
Examples:
|
||||
>>> classify_error(Exception("Rate limit exceeded"))
|
||||
ErrorCategory.RATE_LIMIT
|
||||
>>> classify_error(Exception("Context length 8192 exceeded"))
|
||||
ErrorCategory.CONTEXT_LIMIT
|
||||
>>> classify_error(Exception("Invalid API key"))
|
||||
ErrorCategory.AUTH
|
||||
"""
|
||||
error_text = f"{type(error).__name__} {error}".lower()
|
||||
|
||||
# Check each category's patterns
|
||||
for category, patterns in _ERROR_PATTERNS.items():
|
||||
for pattern in patterns:
|
||||
if re.search(pattern, error_text, re.IGNORECASE):
|
||||
return category
|
||||
|
||||
# Default to permanent for unknown errors
|
||||
return ErrorCategory.PERMANENT
|
||||
|
||||
|
||||
def should_retry(category: ErrorCategory) -> bool:
|
||||
"""Determine if an error category should be retried.
|
||||
|
||||
Args:
|
||||
category: Error category from classify_error()
|
||||
|
||||
Returns:
|
||||
True if the error type should be retried
|
||||
"""
|
||||
return category in {
|
||||
ErrorCategory.RATE_LIMIT,
|
||||
ErrorCategory.CONTEXT_LIMIT, # Could retry with compression
|
||||
ErrorCategory.TRANSIENT,
|
||||
}
|
||||
|
||||
|
||||
def get_retry_delay(category: ErrorCategory, attempt: int = 1, base_delay: float = 1.0) -> float:
|
||||
"""Get the appropriate delay before retrying based on error category.
|
||||
|
||||
Uses full jitter to prevent thundering herd problems in multi-agent setups
|
||||
where multiple agents hit rate limits simultaneously.
|
||||
|
||||
Args:
|
||||
category: Error category
|
||||
attempt: Current attempt number (1-based)
|
||||
base_delay: Base delay in seconds
|
||||
|
||||
Returns:
|
||||
Delay in seconds, or 0 if should not retry
|
||||
|
||||
Examples:
|
||||
>>> # With jitter, these will return random values in range:
|
||||
>>> get_retry_delay(ErrorCategory.RATE_LIMIT, attempt=1) # 0.0 to 3.0
|
||||
>>> get_retry_delay(ErrorCategory.TRANSIENT, attempt=3) # 0.0 to 8.0
|
||||
>>> get_retry_delay(ErrorCategory.AUTH, attempt=1) # Always 0
|
||||
"""
|
||||
attempt = max(1, attempt)
|
||||
|
||||
if not should_retry(category):
|
||||
return 0
|
||||
|
||||
if category == ErrorCategory.RATE_LIMIT:
|
||||
# Exponential backoff with equal jitter for rate limits (minimum floor to prevent instant retries)
|
||||
max_delay = min(base_delay * (3 ** attempt), 60.0)
|
||||
return base_delay + random.uniform(0, max_delay - base_delay)
|
||||
|
||||
elif category == ErrorCategory.CONTEXT_LIMIT:
|
||||
# Short delay for context limits (no jitter needed - not a contention issue)
|
||||
return base_delay * 0.5
|
||||
|
||||
elif category == ErrorCategory.TRANSIENT:
|
||||
# Exponential backoff with equal jitter for transient errors (minimum floor to prevent instant retries)
|
||||
max_delay = min(base_delay * (2 ** attempt), 30.0)
|
||||
return base_delay + random.uniform(0, max_delay - base_delay)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def extract_retry_after(error: Exception) -> Optional[float]:
|
||||
"""Extract Retry-After header value from rate limit errors.
|
||||
|
||||
Args:
|
||||
error: Exception potentially containing Retry-After info
|
||||
|
||||
Returns:
|
||||
Delay in seconds if found, None otherwise
|
||||
"""
|
||||
error_str = str(error)
|
||||
|
||||
# Look for common Retry-After patterns
|
||||
patterns = [
|
||||
r"retry.?after[:\s]+(\d+)",
|
||||
r"retry[:\s]+(\d+)",
|
||||
r"wait[:\s]+(\d+)",
|
||||
r"(\d+).*second",
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, error_str, re.IGNORECASE)
|
||||
if match:
|
||||
try:
|
||||
delay = float(match.group(1))
|
||||
return min(delay, 300.0) # Cap at 5 minutes
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_error_context(error: Exception) -> Dict[str, str]:
|
||||
"""Extract structured context from an error for logging/debugging.
|
||||
|
||||
Args:
|
||||
error: Exception to analyze
|
||||
|
||||
Returns:
|
||||
Dictionary with error context information
|
||||
"""
|
||||
category = classify_error(error)
|
||||
|
||||
context = {
|
||||
"error_type": type(error).__name__,
|
||||
"category": category.value,
|
||||
"should_retry": str(should_retry(category)),
|
||||
"message": str(error)[:500], # Truncate long messages
|
||||
}
|
||||
|
||||
# Add category-specific context
|
||||
if category == ErrorCategory.RATE_LIMIT:
|
||||
retry_after = extract_retry_after(error)
|
||||
if retry_after:
|
||||
context["retry_after"] = str(retry_after)
|
||||
|
||||
elif category == ErrorCategory.CONTEXT_LIMIT:
|
||||
context["suggestion"] = "Try reducing input size or enabling compression"
|
||||
|
||||
elif category == ErrorCategory.AUTH:
|
||||
context["suggestion"] = "Check API key configuration"
|
||||
|
||||
elif category == ErrorCategory.INVALID_REQUEST:
|
||||
context["suggestion"] = "Review request parameters"
|
||||
|
||||
return context
|
||||
@@ -6,11 +6,17 @@ import re
|
||||
import inspect
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Union, Literal, Callable, TYPE_CHECKING
|
||||
from typing import Any, Dict, List, Optional, Union, Literal, Callable, TYPE_CHECKING, Protocol
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from rich.console import Console
|
||||
from rich.live import Live
|
||||
|
||||
class FailoverManagerProtocol(Protocol):
|
||||
"""Protocol for failover manager implementations."""
|
||||
def get_next_profile(self) -> Optional["AuthProfile"]: ...
|
||||
def mark_failure(self, profile: "AuthProfile", error: str, is_rate_limit: bool = False) -> None: ...
|
||||
def mark_success(self, profile: "AuthProfile") -> None: ...
|
||||
from pydantic import BaseModel
|
||||
import time
|
||||
import json
|
||||
@@ -354,6 +360,8 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
web_fetch: Optional[Union[bool, Dict[str, Any]]] = None,
|
||||
prompt_caching: Optional[bool] = None,
|
||||
claude_memory: Optional[Union[bool, Any]] = None,
|
||||
failover_manager: Optional[FailoverManagerProtocol] = None,
|
||||
auth: Optional[str] = None, # NEW: "claude-code", "codex", "gemini-cli", "qwen-cli"
|
||||
**extra_settings
|
||||
):
|
||||
# Configure logging only once at the class level
|
||||
@@ -429,6 +437,18 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
self._rate_limiter = extra_settings.get('rate_limiter', None)
|
||||
self._max_retries = extra_settings.get('max_retries', 3)
|
||||
self._retry_delay = extra_settings.get('retry_delay', 60) # Default 60 seconds
|
||||
|
||||
# Subscription auth
|
||||
self._auth_provider_id = auth
|
||||
self._cached_subscription_creds = None # SubscriptionCredentials | None
|
||||
|
||||
# Failover management
|
||||
self._failover_manager = failover_manager
|
||||
self._current_profile = None # Track current auth profile for failover
|
||||
if self._failover_manager:
|
||||
self._current_profile = self._failover_manager.get_next_profile()
|
||||
if self._current_profile:
|
||||
self._switch_to_profile(self._current_profile)
|
||||
|
||||
# Cache for formatted tools and messages
|
||||
self._formatted_tools_cache = {}
|
||||
@@ -650,8 +670,87 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
|
||||
return any(indicator in error_str or indicator in error_type for indicator in indicators)
|
||||
|
||||
def _classify_error_and_should_retry(self, error: Exception, attempt: int = 1) -> tuple[str, bool, float]:
|
||||
"""Classify error and determine retry strategy using G5 error classifier.
|
||||
|
||||
Args:
|
||||
error: Exception to classify
|
||||
attempt: Current attempt number (1-based)
|
||||
|
||||
Returns:
|
||||
Tuple of (category, should_retry, retry_delay)
|
||||
"""
|
||||
try:
|
||||
from .error_classifier import classify_error, should_retry, get_retry_delay, extract_retry_after
|
||||
|
||||
category = classify_error(error)
|
||||
can_retry = should_retry(category)
|
||||
|
||||
if not can_retry:
|
||||
return category.value, False, 0.0
|
||||
|
||||
# For rate limits, try to extract specific retry-after first
|
||||
if category.value == "rate_limit":
|
||||
retry_after = extract_retry_after(error)
|
||||
if retry_after:
|
||||
return category.value, True, retry_after
|
||||
|
||||
# Use category-specific delay calculation with proper attempt
|
||||
delay = get_retry_delay(category, attempt=attempt, base_delay=self._retry_delay)
|
||||
return category.value, True, delay
|
||||
|
||||
except ImportError:
|
||||
# Fallback to legacy rate limit detection
|
||||
is_rate_limit = self._is_rate_limit_error(error)
|
||||
delay = self._parse_retry_delay(str(error)) if is_rate_limit else 0.0
|
||||
return "rate_limit" if is_rate_limit else "unknown", is_rate_limit, delay
|
||||
|
||||
def _switch_to_profile(self, profile: "AuthProfile") -> None:
|
||||
"""Switch to a new auth profile for failover.
|
||||
|
||||
Args:
|
||||
profile: AuthProfile to switch to
|
||||
"""
|
||||
if profile.api_key:
|
||||
self.api_key = profile.api_key
|
||||
if profile.base_url:
|
||||
self.base_url = profile.base_url
|
||||
if profile.model and profile.model != self.model:
|
||||
# Only log if model actually changes
|
||||
logging.info(f"Failover: switching from {self.model} to {profile.model}")
|
||||
self.model = profile.model
|
||||
|
||||
def _resolve_subscription_creds(self):
|
||||
"""Lazy resolve + cache subscription credentials, checking expiration."""
|
||||
if not self._auth_provider_id:
|
||||
return None
|
||||
|
||||
# Check if cached credentials are expired
|
||||
if (self._cached_subscription_creds and
|
||||
self._cached_subscription_creds.expires_at_ms and
|
||||
self._cached_subscription_creds.expires_at_ms <= int(time.time() * 1000)):
|
||||
self._cached_subscription_creds = None
|
||||
|
||||
if self._cached_subscription_creds is None:
|
||||
from ..auth import resolve_subscription_credentials
|
||||
self._cached_subscription_creds = resolve_subscription_credentials(
|
||||
self._auth_provider_id,
|
||||
)
|
||||
return self._cached_subscription_creds
|
||||
|
||||
def _refresh_subscription_creds(self):
|
||||
"""Force refresh of subscription credentials."""
|
||||
if not self._auth_provider_id:
|
||||
return None
|
||||
from ..auth.subscription.registry import get_subscription_provider
|
||||
provider = get_subscription_provider(self._auth_provider_id)
|
||||
if provider is None:
|
||||
return None
|
||||
self._cached_subscription_creds = provider.refresh()
|
||||
return self._cached_subscription_creds
|
||||
|
||||
def _call_with_retry(self, func, *args, **kwargs):
|
||||
"""Call a function with automatic retry on rate limit errors.
|
||||
"""Call a function with automatic retry on rate limit errors and failover support.
|
||||
|
||||
Args:
|
||||
func: The function to call (e.g., litellm.completion)
|
||||
@@ -672,20 +771,63 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
if self._rate_limiter is not None:
|
||||
self._rate_limiter.acquire()
|
||||
|
||||
return func(*args, **kwargs)
|
||||
result = func(*args, **kwargs)
|
||||
|
||||
# Mark success if failover is configured
|
||||
if self._failover_manager and self._current_profile:
|
||||
self._failover_manager.mark_success(self._current_profile)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
if not self._is_rate_limit_error(e):
|
||||
raise
|
||||
|
||||
category, can_retry, retry_delay = self._classify_error_and_should_retry(e, attempt + 1)
|
||||
|
||||
last_error = e
|
||||
error_str = str(e)
|
||||
|
||||
if attempt < self._max_retries:
|
||||
retry_delay = self._parse_retry_delay(error_str)
|
||||
# Check for auth errors and try refreshing subscription credentials
|
||||
if category == "auth" and self._auth_provider_id and attempt == 0:
|
||||
try:
|
||||
logging.info("Authentication error detected - attempting credential refresh")
|
||||
refreshed_creds = self._refresh_subscription_creds()
|
||||
if refreshed_creds:
|
||||
# Update parameters with refreshed credentials (don't clear cache)
|
||||
kwargs = self._build_completion_params(**kwargs)
|
||||
# Retry immediately with refreshed credentials
|
||||
can_retry = True
|
||||
retry_delay = 0.0
|
||||
logging.info("Subscription credentials refreshed, retrying...")
|
||||
except Exception as refresh_error:
|
||||
logging.warning(f"Failed to refresh subscription credentials: {refresh_error}")
|
||||
|
||||
# Failover: mark failure and try next profile (do this before early exit)
|
||||
if self._failover_manager and self._current_profile:
|
||||
is_rate_limit = (category == "rate_limit")
|
||||
self._failover_manager.mark_failure(
|
||||
self._current_profile, error_str, is_rate_limit=is_rate_limit
|
||||
)
|
||||
next_profile = self._failover_manager.get_next_profile()
|
||||
if next_profile and next_profile != self._current_profile:
|
||||
self._switch_to_profile(next_profile)
|
||||
self._current_profile = next_profile
|
||||
# Update the kwargs with new profile values for the next retry
|
||||
if "api_key" in kwargs:
|
||||
kwargs["api_key"] = self.api_key
|
||||
if "base_url" in kwargs:
|
||||
kwargs["base_url"] = self.base_url
|
||||
if "model" in kwargs:
|
||||
kwargs["model"] = self.model
|
||||
# Enable retry for profile switch even if originally non-retryable
|
||||
can_retry = True
|
||||
retry_delay = 0.0
|
||||
logging.info(f"Failover: switched to profile '{next_profile.name}'")
|
||||
|
||||
if not can_retry:
|
||||
raise
|
||||
|
||||
if attempt < self._max_retries:
|
||||
logging.warning(
|
||||
f"Rate limit hit (attempt {attempt + 1}/{self._max_retries + 1}), "
|
||||
f"{category} error hit (attempt {attempt + 1}/{self._max_retries + 1}), "
|
||||
f"waiting {retry_delay:.1f}s before retry..."
|
||||
)
|
||||
|
||||
@@ -712,7 +854,7 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
raise last_error
|
||||
|
||||
async def _call_with_retry_async(self, func, *args, **kwargs):
|
||||
"""Async version of _call_with_retry.
|
||||
"""Async version of _call_with_retry with failover support.
|
||||
|
||||
Args:
|
||||
func: The async function to call
|
||||
@@ -733,20 +875,63 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
if self._rate_limiter is not None:
|
||||
await self._rate_limiter.acquire_async()
|
||||
|
||||
return await func(*args, **kwargs)
|
||||
result = await func(*args, **kwargs)
|
||||
|
||||
# Mark success if failover is configured
|
||||
if self._failover_manager and self._current_profile:
|
||||
self._failover_manager.mark_success(self._current_profile)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
if not self._is_rate_limit_error(e):
|
||||
raise
|
||||
|
||||
category, can_retry, retry_delay = self._classify_error_and_should_retry(e, attempt + 1)
|
||||
|
||||
last_error = e
|
||||
error_str = str(e)
|
||||
|
||||
if attempt < self._max_retries:
|
||||
retry_delay = self._parse_retry_delay(error_str)
|
||||
# Check for auth errors and try refreshing subscription credentials
|
||||
if category == "auth" and self._auth_provider_id and attempt == 0:
|
||||
try:
|
||||
logging.info("Authentication error detected - attempting credential refresh")
|
||||
refreshed_creds = self._refresh_subscription_creds()
|
||||
if refreshed_creds:
|
||||
# Update parameters with refreshed credentials (don't clear cache)
|
||||
kwargs = self._build_completion_params(**kwargs)
|
||||
# Retry immediately with refreshed credentials
|
||||
can_retry = True
|
||||
retry_delay = 0.0
|
||||
logging.info("Subscription credentials refreshed, retrying...")
|
||||
except Exception as refresh_error:
|
||||
logging.warning(f"Failed to refresh subscription credentials: {refresh_error}")
|
||||
|
||||
# Failover: mark failure and try next profile (do this before early exit)
|
||||
if self._failover_manager and self._current_profile:
|
||||
is_rate_limit = (category == "rate_limit")
|
||||
self._failover_manager.mark_failure(
|
||||
self._current_profile, error_str, is_rate_limit=is_rate_limit
|
||||
)
|
||||
next_profile = self._failover_manager.get_next_profile()
|
||||
if next_profile and next_profile != self._current_profile:
|
||||
self._switch_to_profile(next_profile)
|
||||
self._current_profile = next_profile
|
||||
# Update the kwargs with new profile values for the next retry
|
||||
if "api_key" in kwargs:
|
||||
kwargs["api_key"] = self.api_key
|
||||
if "base_url" in kwargs:
|
||||
kwargs["base_url"] = self.base_url
|
||||
if "model" in kwargs:
|
||||
kwargs["model"] = self.model
|
||||
# Enable retry for profile switch even if originally non-retryable
|
||||
can_retry = True
|
||||
retry_delay = 0.0
|
||||
logging.info(f"Failover: switched to profile '{next_profile.name}'")
|
||||
|
||||
if not can_retry:
|
||||
raise
|
||||
|
||||
if attempt < self._max_retries:
|
||||
logging.warning(
|
||||
f"Rate limit hit (attempt {attempt + 1}/{self._max_retries + 1}), "
|
||||
f"{category} error hit (attempt {attempt + 1}/{self._max_retries + 1}), "
|
||||
f"waiting {retry_delay:.1f}s before retry..."
|
||||
)
|
||||
|
||||
@@ -3368,7 +3553,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
# Continue conversation after tool execution - get follow-up response
|
||||
try:
|
||||
follow_up_response = litellm.completion(
|
||||
follow_up_response = self._completion_with_retry(
|
||||
**self._build_completion_params(
|
||||
messages=messages,
|
||||
tools=formatted_tools,
|
||||
@@ -3384,7 +3569,16 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# Yield the follow-up response after tool execution
|
||||
yield follow_up_content
|
||||
except Exception as e:
|
||||
logging.error(f"Follow-up response failed: {e}")
|
||||
import time
|
||||
error_ref = f"followup-{int(time.time() * 1000)}"
|
||||
logging.error(
|
||||
"Follow-up response failed after retries (ref=%s, model=%s): %s",
|
||||
error_ref, getattr(self, 'model', 'unknown'), e
|
||||
)
|
||||
yield (
|
||||
f"\n\n[Error: Failed to generate final response after tool execution "
|
||||
f"(ref: {error_ref}). Please retry. If it continues, try reducing prompt size.]"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
@@ -3403,7 +3597,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
if not use_streaming:
|
||||
# Fall back to non-streaming and yield the complete response
|
||||
try:
|
||||
response = litellm.completion(
|
||||
response = self._completion_with_retry(
|
||||
**self._build_completion_params(
|
||||
messages=messages,
|
||||
tools=formatted_tools,
|
||||
@@ -4463,6 +4657,18 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
filtered_extra_settings = {k: v for k, v in self.extra_settings.items() if k != 'metrics'}
|
||||
params.update(filtered_extra_settings)
|
||||
|
||||
# Inject subscription credentials if auth provider is set
|
||||
creds = self._resolve_subscription_creds()
|
||||
if creds:
|
||||
# Use litellm's native OAuth detection (auto-detects sk-ant-oat-* and switches to Bearer)
|
||||
params["api_key"] = creds.api_key
|
||||
if creds.base_url:
|
||||
params["base_url"] = creds.base_url
|
||||
if creds.headers:
|
||||
extra_headers = dict(params.get("extra_headers") or {})
|
||||
extra_headers.update(creds.headers)
|
||||
params["extra_headers"] = extra_headers
|
||||
|
||||
# Override with any provided parameters
|
||||
params.update(override_params)
|
||||
|
||||
|
||||
@@ -907,6 +907,8 @@ class OpenAIClient:
|
||||
final_response = self._responses_to_chat_completion(raw)
|
||||
|
||||
# Display the response text if display_fn provided
|
||||
if not final_response.choices or final_response.choices[0].message is None:
|
||||
raise ValueError("LLM returned empty or filtered response")
|
||||
response_text = final_response.choices[0].message.content or ""
|
||||
if display_fn and response_text:
|
||||
from rich.markdown import Markdown
|
||||
@@ -1167,6 +1169,8 @@ class OpenAIClient:
|
||||
final_response = self._responses_to_chat_completion(raw)
|
||||
|
||||
# Display the response text if display_fn provided
|
||||
if not final_response.choices or final_response.choices[0].message is None:
|
||||
raise ValueError("LLM returned empty or filtered response")
|
||||
response_text = final_response.choices[0].message.content or ""
|
||||
if display_fn and response_text:
|
||||
from rich.markdown import Markdown
|
||||
@@ -1638,8 +1642,10 @@ class OpenAIClient:
|
||||
)
|
||||
|
||||
# Check for tool calls
|
||||
if not final_response.choices or final_response.choices[0].message is None:
|
||||
raise ValueError("LLM returned empty or filtered response")
|
||||
tool_calls = getattr(final_response.choices[0].message, 'tool_calls', None)
|
||||
|
||||
|
||||
# Emit llm_content for intermediate narrative display
|
||||
# (gpt-4.1+ models produce text alongside tool calls)
|
||||
response_content = getattr(final_response.choices[0].message, 'content', None)
|
||||
@@ -1835,10 +1841,12 @@ class OpenAIClient:
|
||||
|
||||
if not final_response:
|
||||
return None
|
||||
|
||||
|
||||
# Check for tool calls
|
||||
if not final_response.choices or final_response.choices[0].message is None:
|
||||
raise ValueError("LLM returned empty or filtered response")
|
||||
tool_calls = getattr(final_response.choices[0].message, 'tool_calls', None)
|
||||
|
||||
|
||||
# Emit llm_content for intermediate narrative display
|
||||
# (gpt-4.1+ models produce text alongside tool calls)
|
||||
response_content = getattr(final_response.choices[0].message, 'content', None)
|
||||
@@ -2037,6 +2045,8 @@ class OpenAIClient:
|
||||
return
|
||||
|
||||
# Check for tool calls
|
||||
if not final_response.choices or final_response.choices[0].message is None:
|
||||
raise ValueError("LLM returned empty or filtered response")
|
||||
tool_calls = getattr(final_response.choices[0].message, 'tool_calls', None)
|
||||
|
||||
if tool_calls and execute_tool_fn:
|
||||
|
||||
@@ -69,12 +69,14 @@ class RateLimiter:
|
||||
_last_update: float = field(default=None, init=False, repr=False)
|
||||
_lock: asyncio.Lock = field(default=None, init=False, repr=False)
|
||||
_lock_init: "threading.Lock" = field(default=None, init=False, repr=False)
|
||||
_sync_lock: "threading.Lock" = field(default=None, init=False, repr=False)
|
||||
|
||||
# Internal state for token-based limiting
|
||||
_api_tokens: float = field(default=None, init=False, repr=False)
|
||||
_api_tokens_last_update: float = field(default=None, init=False, repr=False)
|
||||
_api_tokens_lock: asyncio.Lock = field(default=None, init=False, repr=False)
|
||||
_api_tokens_lock_init: "threading.Lock" = field(default=None, init=False, repr=False)
|
||||
_sync_api_tokens_lock: "threading.Lock" = field(default=None, init=False, repr=False)
|
||||
|
||||
# Injectable functions for testing
|
||||
_get_time: Callable[[], float] = field(default=None, init=False, repr=False)
|
||||
@@ -93,6 +95,7 @@ class RateLimiter:
|
||||
self._lock = None
|
||||
if self._lock_init is None:
|
||||
self._lock_init = threading.Lock() # Threading lock for async lock initialization
|
||||
self._sync_lock = threading.Lock()
|
||||
|
||||
# Token-based limiting
|
||||
if self.tokens_per_minute is not None:
|
||||
@@ -105,6 +108,7 @@ class RateLimiter:
|
||||
self._api_tokens_lock = None
|
||||
if self._api_tokens_lock_init is None:
|
||||
self._api_tokens_lock_init = threading.Lock() # Threading lock for async lock initialization
|
||||
self._sync_api_tokens_lock = threading.Lock()
|
||||
|
||||
# Default implementations (can be overridden for testing)
|
||||
if self._get_time is None:
|
||||
@@ -184,15 +188,15 @@ class RateLimiter:
|
||||
if self.requests_per_minute is None:
|
||||
return
|
||||
|
||||
self._refill()
|
||||
|
||||
wait = self._wait_time()
|
||||
if wait > 0:
|
||||
while True:
|
||||
with self._sync_lock:
|
||||
self._refill()
|
||||
if self._tokens >= 1.0:
|
||||
self._tokens -= 1.0
|
||||
return
|
||||
wait = self._wait_time()
|
||||
logger.debug(f"Rate limit: waiting {wait:.2f}s before next request")
|
||||
self._sleep(wait)
|
||||
self._refill()
|
||||
|
||||
self._tokens -= 1.0
|
||||
|
||||
def acquire_tokens(self, num_tokens: int) -> None:
|
||||
"""Acquire API tokens, blocking if necessary.
|
||||
@@ -208,17 +212,17 @@ class RateLimiter:
|
||||
if self.tokens_per_minute is None:
|
||||
return
|
||||
|
||||
self._refill_api_tokens()
|
||||
|
||||
wait = self._wait_time_for_tokens(num_tokens)
|
||||
if wait > 0:
|
||||
while True:
|
||||
with self._sync_api_tokens_lock:
|
||||
self._refill_api_tokens()
|
||||
if self._api_tokens >= num_tokens:
|
||||
self._api_tokens -= num_tokens
|
||||
return
|
||||
wait = self._wait_time_for_tokens(num_tokens)
|
||||
# Do NOT cap internal wait time - this ensures correct rate limiting
|
||||
# The wait time is calculated based on token refill rate and must be honored
|
||||
logger.debug(f"Token limit: waiting {wait:.2f}s for {num_tokens} tokens")
|
||||
self._sleep(wait)
|
||||
self._refill_api_tokens()
|
||||
|
||||
self._api_tokens -= num_tokens
|
||||
|
||||
async def acquire_async(self) -> None:
|
||||
"""Acquire a request token asynchronously, waiting if necessary.
|
||||
@@ -274,23 +278,24 @@ class RateLimiter:
|
||||
if self.requests_per_minute is None:
|
||||
return True
|
||||
|
||||
self._refill()
|
||||
|
||||
if self._tokens >= 1.0:
|
||||
self._tokens -= 1.0
|
||||
return True
|
||||
with self._sync_lock:
|
||||
self._refill()
|
||||
if self._tokens >= 1.0:
|
||||
self._tokens -= 1.0
|
||||
return True
|
||||
return False
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset the rate limiter to initial state."""
|
||||
if self.requests_per_minute is not None:
|
||||
self._tokens = float(self.burst)
|
||||
self._last_update = self._get_time()
|
||||
|
||||
if self.tokens_per_minute is not None:
|
||||
token_burst = max(self.burst, self.tokens_per_minute // 60)
|
||||
self._api_tokens = float(token_burst)
|
||||
self._api_tokens_last_update = self._get_time()
|
||||
with self._sync_lock:
|
||||
if self.requests_per_minute is not None:
|
||||
self._tokens = float(self.burst)
|
||||
self._last_update = self._get_time()
|
||||
with self._sync_api_tokens_lock:
|
||||
if self.tokens_per_minute is not None:
|
||||
token_burst = max(self.burst, self.tokens_per_minute // 60)
|
||||
self._api_tokens = float(token_burst)
|
||||
self._api_tokens_last_update = self._get_time()
|
||||
|
||||
def wait_for_retry(self, retry_delay: float) -> None:
|
||||
"""Wait for a specified retry delay (for handling 429 errors).
|
||||
@@ -315,14 +320,16 @@ class RateLimiter:
|
||||
@property
|
||||
def available_tokens(self) -> float:
|
||||
"""Get current available request tokens (for monitoring)."""
|
||||
self._refill()
|
||||
return self._tokens
|
||||
with self._sync_lock:
|
||||
self._refill()
|
||||
return self._tokens
|
||||
|
||||
@property
|
||||
def available_api_tokens(self) -> float:
|
||||
"""Get current available API tokens (for monitoring)."""
|
||||
self._refill_api_tokens()
|
||||
return self._api_tokens
|
||||
with self._sync_api_tokens_lock:
|
||||
self._refill_api_tokens()
|
||||
return self._api_tokens
|
||||
|
||||
def __repr__(self) -> str:
|
||||
parts = []
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
Message Sanitization - Clean surrogate/non-ASCII characters before LLM calls.
|
||||
|
||||
Prevents Unicode encoding issues that can cause silent failures with some
|
||||
providers when processing emoji, non-Latin text, or corrupted Unicode.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import List, Dict, Any, Union
|
||||
|
||||
__all__ = ["sanitize_messages", "strip_surrogates"]
|
||||
|
||||
|
||||
def strip_surrogates(text: str) -> str:
|
||||
"""Remove surrogate pairs and malformed Unicode from text.
|
||||
|
||||
Surrogate pairs (U+D800-U+DFFF) are used in UTF-16 encoding but are
|
||||
invalid in UTF-8/Unicode strings. They can appear from:
|
||||
- Incorrect Unicode conversion
|
||||
- Corrupted text data
|
||||
- Invalid emoji/character sequences
|
||||
|
||||
Args:
|
||||
text: Input text potentially containing surrogates
|
||||
|
||||
Returns:
|
||||
Text with surrogates removed or replaced
|
||||
|
||||
Examples:
|
||||
>>> strip_surrogates("Hello \\uD83D World") # Missing low surrogate
|
||||
'Hello World'
|
||||
>>> strip_surrogates("Valid text 🌍")
|
||||
'Valid text 🌍'
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
try:
|
||||
# Method 1: Encode with surrogatepass, decode with ignore
|
||||
# This converts surrogates to UTF-16, then back to UTF-8 safely
|
||||
return text.encode('utf-16', 'surrogatepass').decode('utf-16', 'ignore')
|
||||
except (UnicodeError, LookupError):
|
||||
# Fallback: Remove surrogate code points directly
|
||||
return re.sub(r'[\uD800-\uDFFF]', '', text)
|
||||
|
||||
|
||||
def _sanitize_value_recursive(value: Any) -> tuple[Any, bool]:
|
||||
"""Recursively sanitize any nested string values in a data structure.
|
||||
|
||||
Args:
|
||||
value: Value to sanitize (can be dict, list, str, or other)
|
||||
|
||||
Returns:
|
||||
Tuple of (sanitized_value, changed_flag)
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
sanitized = strip_surrogates(value)
|
||||
return sanitized, sanitized != value
|
||||
|
||||
elif isinstance(value, list):
|
||||
changed = False
|
||||
sanitized_items = []
|
||||
for item in value:
|
||||
sanitized_item, item_changed = _sanitize_value_recursive(item)
|
||||
sanitized_items.append(sanitized_item)
|
||||
changed = changed or item_changed
|
||||
return sanitized_items, changed
|
||||
|
||||
elif isinstance(value, dict):
|
||||
changed = False
|
||||
sanitized_dict = {}
|
||||
for key, nested_value in value.items():
|
||||
# Sanitize the key if it's a string
|
||||
if isinstance(key, str):
|
||||
sanitized_key = strip_surrogates(key)
|
||||
if sanitized_key != key:
|
||||
changed = True
|
||||
key = sanitized_key
|
||||
|
||||
# Recursively sanitize the value
|
||||
sanitized_value, value_changed = _sanitize_value_recursive(nested_value)
|
||||
sanitized_dict[key] = sanitized_value
|
||||
changed = changed or value_changed
|
||||
return sanitized_dict, changed
|
||||
|
||||
else:
|
||||
# Return other types unchanged
|
||||
return value, False
|
||||
|
||||
|
||||
def sanitize_messages(messages: List[Dict[str, Any]]) -> bool:
|
||||
"""Sanitize message content in-place, removing problematic Unicode.
|
||||
|
||||
Processes all string content in message dictionaries, including:
|
||||
- message.content (string or list)
|
||||
- message.name
|
||||
- Any nested string values (including tool_calls[].function.arguments)
|
||||
|
||||
Args:
|
||||
messages: List of message dicts to sanitize in-place
|
||||
|
||||
Returns:
|
||||
True if any changes were made, False otherwise
|
||||
|
||||
Examples:
|
||||
>>> messages = [{"content": "Hello \\uD83D World", "role": "user"}]
|
||||
>>> changed = sanitize_messages(messages)
|
||||
>>> assert changed == True
|
||||
>>> assert messages[0]["content"] == "Hello World"
|
||||
"""
|
||||
if not messages:
|
||||
return False
|
||||
|
||||
changed = False
|
||||
|
||||
for message in messages:
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
|
||||
# Recursively sanitize the entire message structure
|
||||
sanitized_message, message_changed = _sanitize_value_recursive(message)
|
||||
if message_changed:
|
||||
message.clear()
|
||||
message.update(sanitized_message)
|
||||
changed = True
|
||||
|
||||
return changed
|
||||
|
||||
|
||||
def sanitize_text(text: Union[str, None]) -> Union[str, None]:
|
||||
"""Sanitize a single text string.
|
||||
|
||||
Convenience function for sanitizing individual strings.
|
||||
|
||||
Args:
|
||||
text: Text to sanitize, or None
|
||||
|
||||
Returns:
|
||||
Sanitized text, or None if input was None
|
||||
"""
|
||||
if text is None:
|
||||
return None
|
||||
if not isinstance(text, str):
|
||||
return text
|
||||
return strip_surrogates(text)
|
||||
@@ -676,6 +676,8 @@ class TaskOutput(BaseModel):
|
||||
agent: str
|
||||
output_format: Literal["RAW", "JSON", "Pydantic"] = "RAW"
|
||||
token_metrics: Optional['TokenMetrics'] = None # Add token metrics field
|
||||
callback_error: Optional[str] = None
|
||||
non_fatal_errors: Optional[list[str]] = None
|
||||
|
||||
def json(self) -> Optional[str]:
|
||||
if self.output_format == "JSON" and self.json_dict:
|
||||
@@ -696,4 +698,4 @@ class TaskOutput(BaseModel):
|
||||
elif self.json_dict:
|
||||
return json.dumps(self.json_dict)
|
||||
else:
|
||||
return self.raw
|
||||
return self.raw
|
||||
@@ -25,6 +25,7 @@ from .protocols import (
|
||||
ComputeConfig,
|
||||
InstanceInfo,
|
||||
InstanceStatus,
|
||||
ManagedRuntimeProtocol,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -41,6 +42,7 @@ __all__ = [
|
||||
"ComputeConfig",
|
||||
"InstanceInfo",
|
||||
"InstanceStatus",
|
||||
"ManagedRuntimeProtocol",
|
||||
"ManagedBackendProtocol",
|
||||
]
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import (
|
||||
Any,
|
||||
AsyncIterator,
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
@@ -216,3 +217,140 @@ class ComputeProviderProtocol(Protocol):
|
||||
List of InstanceInfo for running instances.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ManagedRuntimeProtocol(Protocol):
|
||||
"""Protocol for hosted agent runtime — the agent LOOP runs on remote infra.
|
||||
|
||||
Separates from ComputeProviderProtocol which only sandboxes tool execution.
|
||||
Here, the entire agent loop (LLM calls, tool calling, event streaming,
|
||||
memory, session management) runs remotely.
|
||||
|
||||
This is the protocol that Anthropic Managed Agents, E2B Managed Runtime,
|
||||
Modal Managed Functions, etc. should implement.
|
||||
|
||||
Example lifecycle::
|
||||
|
||||
runtime = AnthropicManagedAgent(config=cfg)
|
||||
agent_id = await runtime.create_agent(config)
|
||||
env_id = await runtime.create_environment(config)
|
||||
session_id = await runtime.create_session(agent_id, env_id)
|
||||
|
||||
# Send user message and stream agent responses
|
||||
await runtime.send_event(session_id, {"type": "user.message", ...})
|
||||
async for event in runtime.stream_events(session_id):
|
||||
if event["type"] == "agent.message":
|
||||
print(event["content"])
|
||||
"""
|
||||
|
||||
async def create_agent(self, config: Dict[str, Any]) -> str:
|
||||
"""Create agent definition on remote infrastructure.
|
||||
|
||||
Args:
|
||||
config: Agent configuration (model, system, tools, etc.)
|
||||
|
||||
Returns:
|
||||
Agent ID assigned by the remote provider
|
||||
"""
|
||||
...
|
||||
|
||||
async def create_environment(self, config: Dict[str, Any]) -> str:
|
||||
"""Create environment (container/sandbox) on remote infrastructure.
|
||||
|
||||
Args:
|
||||
config: Environment configuration (packages, networking, etc.)
|
||||
|
||||
Returns:
|
||||
Environment ID assigned by the remote provider
|
||||
"""
|
||||
...
|
||||
|
||||
async def create_session(
|
||||
self,
|
||||
agent_id: str,
|
||||
environment_id: str,
|
||||
**kwargs
|
||||
) -> str:
|
||||
"""Create running session (agent-in-environment) on remote infrastructure.
|
||||
|
||||
Args:
|
||||
agent_id: Agent ID from create_agent()
|
||||
environment_id: Environment ID from create_environment()
|
||||
**kwargs: Additional session config (vault_ids, resources, etc.)
|
||||
|
||||
Returns:
|
||||
Session ID assigned by the remote provider
|
||||
"""
|
||||
...
|
||||
|
||||
async def send_event(self, session_id: str, event: Dict[str, Any]) -> None:
|
||||
"""Send event to running session.
|
||||
|
||||
Args:
|
||||
session_id: Session ID from create_session()
|
||||
event: Event dict (e.g. {"type": "user.message", "content": [...]})
|
||||
"""
|
||||
...
|
||||
|
||||
def stream_events(self, session_id: str) -> AsyncIterator[Dict[str, Any]]:
|
||||
"""Stream events from running session.
|
||||
|
||||
Implementations should be async generators: ``async def`` with ``yield``.
|
||||
Declared without ``async def`` here so the protocol signature matches the
|
||||
async-generator return type (``AsyncIterator``) rather than a coroutine
|
||||
that resolves to one, letting callers write ``async for e in runtime.stream_events(sid)``.
|
||||
|
||||
Args:
|
||||
session_id: Session ID from create_session()
|
||||
|
||||
Yields:
|
||||
Event dicts (agent.message, agent.tool_use, session.status_idle, etc.)
|
||||
"""
|
||||
...
|
||||
|
||||
async def interrupt(self, session_id: str) -> None:
|
||||
"""Send interrupt signal to running session.
|
||||
|
||||
Args:
|
||||
session_id: Session ID to interrupt
|
||||
"""
|
||||
...
|
||||
|
||||
async def retrieve_session(self, session_id: str) -> Dict[str, Any]:
|
||||
"""Retrieve session metadata and status.
|
||||
|
||||
Args:
|
||||
session_id: Session ID to retrieve
|
||||
|
||||
Returns:
|
||||
Session info dict with id, status, title, usage, etc.
|
||||
"""
|
||||
...
|
||||
|
||||
async def list_sessions(self, **filters) -> List[Dict[str, Any]]:
|
||||
"""List sessions with optional filtering.
|
||||
|
||||
Args:
|
||||
**filters: Filter criteria (agent_id, status, limit, etc.)
|
||||
|
||||
Returns:
|
||||
List of session info dicts
|
||||
"""
|
||||
...
|
||||
|
||||
async def archive_session(self, session_id: str) -> None:
|
||||
"""Archive session (preserve but mark inactive).
|
||||
|
||||
Args:
|
||||
session_id: Session ID to archive
|
||||
"""
|
||||
...
|
||||
|
||||
async def delete_session(self, session_id: str) -> None:
|
||||
"""Delete session permanently.
|
||||
|
||||
Args:
|
||||
session_id: Session ID to delete
|
||||
"""
|
||||
...
|
||||
@@ -40,7 +40,8 @@ def create_mem0_memory_adapter(**kwargs) -> MemoryProtocol:
|
||||
import mem0
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"mem0 is required for mem0 adapter. Install with: pip install mem0ai"
|
||||
"mem0ai is not installed. "
|
||||
"Run: pip install 'praisonaiagents[memory]'"
|
||||
)
|
||||
|
||||
return Mem0MemoryAdapter(mem0_config=kwargs)
|
||||
@@ -304,6 +305,12 @@ class ChromaMemoryAdapter:
|
||||
else:
|
||||
sanitized[k] = str(v)
|
||||
return sanitized
|
||||
|
||||
def close(self):
|
||||
"""Clean up ChromaDB resources."""
|
||||
# ChromaDB PersistentClient has no explicit close; release references.
|
||||
self.client = None
|
||||
self.collection = None
|
||||
|
||||
|
||||
class MongoDBMemoryAdapter:
|
||||
@@ -357,7 +364,7 @@ class MongoDBMemoryAdapter:
|
||||
|
||||
def store_short_term(self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> str:
|
||||
"""Store in MongoDB short-term collection."""
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
import time
|
||||
|
||||
doc_id = str(time.time_ns())
|
||||
@@ -365,7 +372,7 @@ class MongoDBMemoryAdapter:
|
||||
"_id": doc_id,
|
||||
"content": text,
|
||||
"metadata": metadata or {},
|
||||
"created_at": datetime.now(datetime.timezone.utc),
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"memory_type": "short_term"
|
||||
}
|
||||
|
||||
@@ -389,7 +396,7 @@ class MongoDBMemoryAdapter:
|
||||
|
||||
def store_long_term(self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> str:
|
||||
"""Store in MongoDB long-term collection."""
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
import time
|
||||
|
||||
doc_id = str(time.time_ns())
|
||||
@@ -397,7 +404,7 @@ class MongoDBMemoryAdapter:
|
||||
"_id": doc_id,
|
||||
"content": text,
|
||||
"metadata": metadata or {},
|
||||
"created_at": datetime.now(datetime.timezone.utc),
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"memory_type": "long_term"
|
||||
}
|
||||
|
||||
@@ -492,4 +499,15 @@ class MongoDBMemoryAdapter:
|
||||
result = embedding(text, model="text-embedding-3-small")
|
||||
return result.embeddings[0] if result.embeddings else None
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
def close(self):
|
||||
"""Clean up MongoDB resources."""
|
||||
if hasattr(self, 'client') and self.client:
|
||||
try:
|
||||
self.client.close()
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.warning(f"MongoDB cleanup failed: {e}")
|
||||
finally:
|
||||
self.client = None
|
||||
@@ -23,17 +23,27 @@ class InMemoryAdapter:
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
self._data: List[Dict[str, Any]] = []
|
||||
self._max_size: int = kwargs.get("max_size", 10_000) # Default limit
|
||||
self._next_id: int = 0 # Monotonic counter to prevent ID collisions after eviction
|
||||
|
||||
def _evict_if_needed(self):
|
||||
"""Evict old entries if we exceed max size (FIFO eviction)."""
|
||||
if len(self._data) > self._max_size:
|
||||
# Keep only the most recent entries
|
||||
self._data = self._data[len(self._data) - self._max_size:]
|
||||
|
||||
def store_short_term(
|
||||
self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs
|
||||
) -> str:
|
||||
entry: Dict[str, Any] = {
|
||||
"id": str(len(self._data)),
|
||||
"id": str(self._next_id),
|
||||
"text": text,
|
||||
"type": "short",
|
||||
"metadata": metadata,
|
||||
}
|
||||
self._next_id += 1
|
||||
self._data.append(entry)
|
||||
self._evict_if_needed()
|
||||
return entry["id"]
|
||||
|
||||
def search_short_term(
|
||||
@@ -50,12 +60,14 @@ class InMemoryAdapter:
|
||||
self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs
|
||||
) -> str:
|
||||
entry: Dict[str, Any] = {
|
||||
"id": str(len(self._data)),
|
||||
"id": str(self._next_id),
|
||||
"text": text,
|
||||
"type": "long",
|
||||
"metadata": metadata,
|
||||
}
|
||||
self._next_id += 1
|
||||
self._data.append(entry)
|
||||
self._evict_if_needed()
|
||||
return entry["id"]
|
||||
|
||||
def search_long_term(
|
||||
|
||||
@@ -416,7 +416,7 @@ class MemoryCoreMixin:
|
||||
|
||||
# Async variants to prevent event loop blocking
|
||||
async def store_short_term_async(self, content: str, metadata: Optional[Dict] = None, quality_score: Optional[float] = None,
|
||||
user_id: Optional[str] = None, auto_promote: bool = True) -> str:
|
||||
user_id: Optional[str] = None, auto_promote: bool = True, **kwargs) -> str:
|
||||
"""
|
||||
Async version of store_short_term to prevent event loop blocking.
|
||||
|
||||
@@ -426,6 +426,7 @@ class MemoryCoreMixin:
|
||||
quality_score: Optional pre-calculated quality score
|
||||
user_id: Optional user identifier
|
||||
auto_promote: Whether to automatically promote to LTM if quality is high
|
||||
**kwargs: Additional provider-specific arguments forwarded to the adapter
|
||||
|
||||
Returns:
|
||||
The memory ID of the stored content
|
||||
@@ -442,8 +443,7 @@ class MemoryCoreMixin:
|
||||
raw_metadata = metadata.copy() if metadata else {}
|
||||
raw_metadata.update({
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"quality_score": quality_score,
|
||||
"memory_type": "short_term"
|
||||
"quality": quality_score,
|
||||
})
|
||||
if user_id:
|
||||
raw_metadata["user_id"] = user_id
|
||||
@@ -463,7 +463,7 @@ class MemoryCoreMixin:
|
||||
# Only use SQLite fallback if primary storage failed completely
|
||||
if not memory_id and hasattr(self, '_sqlite_adapter') and self._sqlite_adapter != getattr(self, 'memory_adapter', None):
|
||||
try:
|
||||
memory_id = await asyncio.to_thread(self._store_sqlite_stm, content, clean_metadata, quality_score)
|
||||
memory_id = await asyncio.to_thread(self._sqlite_adapter.store_short_term, content, metadata=clean_metadata, **kwargs)
|
||||
self._log_verbose(f"Stored in SQLite async STM as fallback: {content[:100]}...")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to store in SQLite async STM fallback: {e}")
|
||||
@@ -472,7 +472,7 @@ class MemoryCoreMixin:
|
||||
# Auto-promote to long-term memory if quality is high (async)
|
||||
if auto_promote and quality_score >= 7.5: # High quality threshold
|
||||
try:
|
||||
await self.store_long_term_async(content, clean_metadata, quality_score, user_id)
|
||||
await self.store_long_term_async(content, clean_metadata, quality_score, user_id, **kwargs)
|
||||
self._log_verbose(f"Auto-promoted STM content to LTM (score: {quality_score:.2f})")
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to auto-promote to LTM: {e}")
|
||||
@@ -485,7 +485,7 @@ class MemoryCoreMixin:
|
||||
return memory_id or ""
|
||||
|
||||
async def store_long_term_async(self, content: str, metadata: Optional[Dict] = None, quality_score: Optional[float] = None,
|
||||
user_id: Optional[str] = None) -> str:
|
||||
user_id: Optional[str] = None, **kwargs) -> str:
|
||||
"""
|
||||
Async version of store_long_term to prevent event loop blocking.
|
||||
|
||||
@@ -494,6 +494,7 @@ class MemoryCoreMixin:
|
||||
metadata: Optional metadata dictionary
|
||||
quality_score: Optional pre-calculated quality score
|
||||
user_id: Optional user identifier
|
||||
**kwargs: Additional provider-specific arguments forwarded to the adapter
|
||||
|
||||
Returns:
|
||||
The memory ID of the stored content
|
||||
@@ -506,4 +507,4 @@ class MemoryCoreMixin:
|
||||
quality_score = self.compute_quality_score(content, metadata)
|
||||
|
||||
# Use sync version in thread to avoid blocking event loop
|
||||
return await asyncio.to_thread(self.store_long_term, content, metadata, quality_score, user_id)
|
||||
return await asyncio.to_thread(self.store_long_term, content, metadata, quality_score, user_id, **kwargs)
|
||||
@@ -7,7 +7,7 @@ import threading
|
||||
from typing import Any, Dict, List, Optional, Union, Literal
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Decomposed memory functionality - imported as mixins for backward compatibility
|
||||
from .storage import StorageMixin
|
||||
@@ -143,6 +143,9 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
adapter_name = provider_mapping.get(provider, provider)
|
||||
|
||||
# Try to get preferred adapter, fallback to available ones
|
||||
adapter = None
|
||||
provider_explicitly_requested = provider != "rag" # "rag" is default, others are explicit
|
||||
|
||||
try:
|
||||
adapter_config = self._get_adapter_config_for_provider(adapter_name)
|
||||
adapter = get_memory_adapter(adapter_name, **adapter_config)
|
||||
@@ -154,6 +157,37 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
adapter = None
|
||||
|
||||
if adapter is None:
|
||||
# If the provider was explicitly requested, try to give a helpful error message
|
||||
if provider_explicitly_requested:
|
||||
try:
|
||||
# Try the factory function directly to get the specific ImportError message
|
||||
from .adapters.factories import (
|
||||
create_mem0_memory_adapter,
|
||||
create_chroma_memory_adapter,
|
||||
create_mongodb_memory_adapter,
|
||||
)
|
||||
factory_map = {
|
||||
"mem0": create_mem0_memory_adapter,
|
||||
"chroma": create_chroma_memory_adapter,
|
||||
"mongodb": create_mongodb_memory_adapter,
|
||||
}
|
||||
if adapter_name in factory_map:
|
||||
# Call the factory to get ImportError with installation hint
|
||||
# or capture successful adapter creation
|
||||
adapter_config = self._get_adapter_config_for_provider(adapter_name)
|
||||
adapter = factory_map[adapter_name](**adapter_config)
|
||||
except ImportError:
|
||||
# Re-raise ImportError with installation instructions
|
||||
raise
|
||||
# All other exceptions (e.g., ValueError for missing API key)
|
||||
# propagate naturally, preserving backward compatibility
|
||||
|
||||
# If factory succeeded, use that adapter
|
||||
if adapter is not None:
|
||||
self.memory_adapter = adapter
|
||||
self.provider = adapter_name
|
||||
return
|
||||
|
||||
# Fallback to first available adapter
|
||||
self._log_verbose(f"Provider '{adapter_name}' not available, trying fallbacks")
|
||||
# Try each fallback preference individually
|
||||
@@ -460,6 +494,9 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
def _init_mongodb(self):
|
||||
"""Initialize MongoDB client for memory storage."""
|
||||
try:
|
||||
# Import MongoClient locally to handle optional dependency
|
||||
from pymongo import MongoClient
|
||||
|
||||
mongo_cfg = self.cfg.get("config", {})
|
||||
self.connection_string = mongo_cfg.get("connection_string", "mongodb://localhost:27017/")
|
||||
self.database_name = mongo_cfg.get("database", "praisonai")
|
||||
@@ -635,36 +672,49 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
ident = str(time.time_ns())
|
||||
created_at = time.time()
|
||||
|
||||
# Store in MongoDB if enabled
|
||||
if self.use_mongodb and hasattr(self, "mongo_short_term"):
|
||||
# Protocol-driven storage: Try adapter first if available
|
||||
adapter_success = False
|
||||
if hasattr(self, 'memory_adapter') and self.memory_adapter:
|
||||
try:
|
||||
doc = {
|
||||
"_id": ident,
|
||||
"content": text,
|
||||
"metadata": metadata,
|
||||
"created_at": datetime.utcnow(),
|
||||
"memory_type": "short_term"
|
||||
}
|
||||
self.mongo_short_term.insert_one(doc)
|
||||
logger.info(f"Successfully stored in MongoDB short-term memory with ID: {ident}")
|
||||
result_id = self.memory_adapter.store_short_term(text, metadata=metadata)
|
||||
logger.info(f"Successfully stored via memory adapter with ID: {result_id}")
|
||||
adapter_success = True
|
||||
ident = result_id # Use adapter-provided ID
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store in MongoDB short-term memory: {e}")
|
||||
raise
|
||||
logger.warning(f"Failed to store via memory adapter, falling back to direct storage: {e}")
|
||||
|
||||
# Only use direct storage if adapter failed or doesn't exist
|
||||
if not adapter_success:
|
||||
# Store in MongoDB if enabled
|
||||
if self.use_mongodb and hasattr(self, "mongo_short_term"):
|
||||
try:
|
||||
doc = {
|
||||
"_id": ident,
|
||||
"content": text,
|
||||
"metadata": metadata,
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"memory_type": "short_term"
|
||||
}
|
||||
self.mongo_short_term.insert_one(doc)
|
||||
logger.info(f"Successfully stored in MongoDB short-term memory with ID: {ident}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store in MongoDB short-term memory: {e}")
|
||||
raise
|
||||
|
||||
# Existing SQLite store logic (with write lock for concurrency safety)
|
||||
try:
|
||||
conn = self._get_stm_conn()
|
||||
with self._write_lock: # Serialize write operations
|
||||
conn.execute(
|
||||
"INSERT INTO short_mem (id, content, meta, created_at) VALUES (?,?,?,?)",
|
||||
(ident, text, json.dumps(metadata), created_at)
|
||||
)
|
||||
conn.commit()
|
||||
logger.info(f"Successfully stored in SQLite short-term memory with ID: {ident}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store in SQLite short-term memory: {e}")
|
||||
if not self.use_mongodb: # Only raise if we're not using MongoDB as fallback
|
||||
raise
|
||||
# Existing SQLite store logic (with write lock for concurrency safety)
|
||||
try:
|
||||
conn = self._get_stm_conn()
|
||||
with self._write_lock: # Serialize write operations
|
||||
conn.execute(
|
||||
"INSERT INTO short_mem (id, content, meta, created_at) VALUES (?,?,?,?)",
|
||||
(ident, text, json.dumps(metadata), created_at)
|
||||
)
|
||||
conn.commit()
|
||||
logger.info(f"Successfully stored in SQLite short-term memory with ID: {ident}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store in SQLite short-term memory: {e}")
|
||||
if not self.use_mongodb: # Only raise if we're not using MongoDB as fallback
|
||||
raise
|
||||
|
||||
# Emit trace event for memory store
|
||||
self._emit_memory_event("store", "short_term", len(text), metadata=metadata)
|
||||
@@ -868,7 +918,7 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
"_id": ident,
|
||||
"content": text,
|
||||
"metadata": metadata,
|
||||
"created_at": datetime.utcnow(),
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"memory_type": "long_term"
|
||||
}
|
||||
|
||||
@@ -965,9 +1015,9 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
# Filter by quality
|
||||
filtered = [r for r in results if r.get("metadata", {}).get("quality", 0.0) >= min_quality]
|
||||
logger.info(f"Found {len(filtered)} results in Mem0")
|
||||
return filtered
|
||||
found.extend(filtered)
|
||||
|
||||
elif self.use_mongodb and hasattr(self, "mongo_long_term"):
|
||||
if not found and self.use_mongodb and hasattr(self, "mongo_long_term"):
|
||||
try:
|
||||
results = []
|
||||
|
||||
@@ -1031,13 +1081,13 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
})
|
||||
|
||||
logger.info(f"Found {len(results)} results in MongoDB")
|
||||
return results
|
||||
found.extend(results)
|
||||
|
||||
except Exception as e:
|
||||
self._log_verbose(f"Error searching MongoDB long-term memory: {e}", logging.ERROR)
|
||||
# Fall through to SQLite search
|
||||
|
||||
elif self.use_rag and hasattr(self, "chroma_col"):
|
||||
if not found and self.use_rag and hasattr(self, "chroma_col"):
|
||||
try:
|
||||
from praisonaiagents.embedding import embedding as get_embedding
|
||||
result = get_embedding(query, model=self.embedding_model)
|
||||
@@ -1045,29 +1095,28 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
|
||||
if query_embedding is None:
|
||||
self._log_verbose("Failed to get embedding for query", logging.WARNING)
|
||||
return []
|
||||
|
||||
# Search ChromaDB with embedding
|
||||
resp = self.chroma_col.query(
|
||||
query_embeddings=[query_embedding],
|
||||
n_results=limit,
|
||||
include=["documents", "metadatas", "distances"]
|
||||
)
|
||||
|
||||
results = []
|
||||
if resp["ids"]:
|
||||
for i in range(len(resp["ids"][0])):
|
||||
metadata = resp["metadatas"][0][i] if "metadatas" in resp else {}
|
||||
text = resp["documents"][0][i]
|
||||
# Add memory record citation
|
||||
text = f"{text} (Memory record: {resp['ids'][0][i]})"
|
||||
found.append({
|
||||
"id": resp["ids"][0][i],
|
||||
"text": text,
|
||||
"metadata": metadata,
|
||||
"score": 1.0 - (resp["distances"][0][i] if "distances" in resp else 0.0)
|
||||
})
|
||||
logger.info(f"Found {len(found)} results in ChromaDB")
|
||||
else:
|
||||
# Search ChromaDB with embedding
|
||||
resp = self.chroma_col.query(
|
||||
query_embeddings=[query_embedding],
|
||||
n_results=limit,
|
||||
include=["documents", "metadatas", "distances"]
|
||||
)
|
||||
|
||||
results = []
|
||||
if resp["ids"]:
|
||||
for i in range(len(resp["ids"][0])):
|
||||
metadata = resp["metadatas"][0][i] if "metadatas" in resp else {}
|
||||
text = resp["documents"][0][i]
|
||||
# Add memory record citation
|
||||
text = f"{text} (Memory record: {resp['ids'][0][i]})"
|
||||
found.append({
|
||||
"id": resp["ids"][0][i],
|
||||
"text": text,
|
||||
"metadata": metadata,
|
||||
"score": 1.0 - (resp["distances"][0][i] if "distances" in resp else 0.0)
|
||||
})
|
||||
logger.info(f"Found {len(found)} results in ChromaDB")
|
||||
|
||||
except Exception as e:
|
||||
self._log_verbose(f"Error searching ChromaDB: {e}", logging.ERROR)
|
||||
@@ -1085,7 +1134,7 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
text = row[1]
|
||||
# Add memory record citation if not already present
|
||||
if "(Memory record:" not in text:
|
||||
text = f"{text} (Memory record: {text})"
|
||||
text = f"{text} (Memory record: {row[0]})"
|
||||
# Only add if not already found by ChromaDB/Mem0
|
||||
if not any(f["id"] == row[0] for f in found):
|
||||
found.append({
|
||||
@@ -1392,14 +1441,14 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
self.mem0_client.add(text, user_id=user_id, metadata=meta)
|
||||
elif self.use_mongodb and hasattr(self, "mongo_users"):
|
||||
try:
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
ident = str(time.time_ns())
|
||||
doc = {
|
||||
"_id": ident,
|
||||
"user_id": user_id,
|
||||
"content": text,
|
||||
"metadata": meta,
|
||||
"created_at": datetime.utcnow()
|
||||
"created_at": datetime.now(timezone.utc)
|
||||
}
|
||||
self.mongo_users.insert_one(doc)
|
||||
self._log_verbose(f"Successfully stored user memory for {user_id}")
|
||||
@@ -1945,14 +1994,19 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
def close_connections(self):
|
||||
"""
|
||||
Close database connections.
|
||||
|
||||
Closes the current thread's connections and attempts to close all known
|
||||
connections from other threads. Each thread should call this method before
|
||||
terminating to ensure proper cleanup.
|
||||
|
||||
Closes only the calling thread's STM/LTM SQLite connections (and the
|
||||
process-wide MongoDB client, if any). Connections owned by other threads
|
||||
are left untouched so concurrent agents are not interfered with — each
|
||||
thread is responsible for calling this before terminating. For full
|
||||
process shutdown, use the adapter's own bulk-close path.
|
||||
"""
|
||||
# Close current thread's connections
|
||||
if hasattr(self._local, 'stm_conn') and self._local.stm_conn:
|
||||
try:
|
||||
# Remove from registry before closing
|
||||
with self._connection_lock:
|
||||
self._all_connections.discard(self._local.stm_conn)
|
||||
self._local.stm_conn.close()
|
||||
self._local.stm_conn = None
|
||||
except Exception as e:
|
||||
@@ -1960,21 +2014,37 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
|
||||
if hasattr(self._local, 'ltm_conn') and self._local.ltm_conn:
|
||||
try:
|
||||
# Remove from registry before closing
|
||||
with self._connection_lock:
|
||||
self._all_connections.discard(self._local.ltm_conn)
|
||||
self._local.ltm_conn.close()
|
||||
self._local.ltm_conn = None
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing current thread's LTM connection: {e}")
|
||||
|
||||
# Close all known connections from the registry
|
||||
with self._connection_lock: # Ensure thread safety during cleanup
|
||||
connections_to_close = list(self._all_connections)
|
||||
for conn in connections_to_close:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.debug(f"Error closing registered connection: {e}")
|
||||
# Clear the registry
|
||||
self._all_connections.clear()
|
||||
# NOTE: Only close THIS thread's connections, not ALL connections
|
||||
# Other active threads should manage their own connections
|
||||
# Only clear full registry in __del__ or explicit shutdown
|
||||
|
||||
# Close memory adapter if it exists (protocol-driven path)
|
||||
if hasattr(self, 'memory_adapter') and self.memory_adapter:
|
||||
try:
|
||||
if hasattr(self.memory_adapter, 'close') and callable(self.memory_adapter.close):
|
||||
self.memory_adapter.close()
|
||||
logger.debug("Memory adapter closed successfully")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing memory adapter: {e}")
|
||||
finally:
|
||||
self.memory_adapter = None
|
||||
|
||||
# Close MongoDB client if it exists (legacy path)
|
||||
if hasattr(self, 'mongo_client') and self.mongo_client:
|
||||
try:
|
||||
self.mongo_client.close()
|
||||
self.mongo_client = None
|
||||
logger.debug("MongoDB client closed successfully")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing MongoDB client: {e}")
|
||||
|
||||
def __enter__(self):
|
||||
"""Allow Memory to be used as a context manager."""
|
||||
|
||||
@@ -24,6 +24,8 @@ class TaskOutput(BaseModel):
|
||||
agent: str
|
||||
output_format: Literal["RAW", "JSON", "Pydantic"] = "RAW"
|
||||
token_metrics: Optional['TokenMetrics'] = None
|
||||
callback_error: Optional[str] = None
|
||||
non_fatal_errors: Optional[list[str]] = None
|
||||
|
||||
def json(self) -> Optional[str]:
|
||||
if self.output_format == "JSON" and self.json_dict:
|
||||
|
||||
@@ -181,6 +181,14 @@ class Process:
|
||||
logging.error(f"Failed to create subtasks for loop task {loop_task.name}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
# Mark task as failed to ensure error is visible
|
||||
loop_task.status = "failed"
|
||||
if hasattr(loop_task, 'result') and loop_task.result is None:
|
||||
from ..output.models import TaskOutput
|
||||
loop_task.result = TaskOutput(
|
||||
raw=f"Failed to read input file: {e}",
|
||||
task_id=loop_task.id
|
||||
)
|
||||
|
||||
def _build_task_context(self, current_task: Task) -> str:
|
||||
"""Build context for a task based on its retain_full_context setting"""
|
||||
@@ -238,6 +246,12 @@ class Process:
|
||||
break # Only include the most recent one
|
||||
|
||||
return context
|
||||
|
||||
async def _set_workflow_finished(self, value: bool):
|
||||
"""Thread-safe setter for workflow_finished flag."""
|
||||
lock = await self._get_state_lock()
|
||||
async with lock:
|
||||
self.workflow_finished = value
|
||||
|
||||
def _find_next_not_started_task(self) -> Optional[Task]:
|
||||
"""Fallback mechanism to find the next 'not started' task."""
|
||||
@@ -245,9 +259,10 @@ class Process:
|
||||
temp_current_task = None
|
||||
|
||||
# Clear previous task context before finding next task
|
||||
# NOTE: Fixed to only clear _execution_context instead of mutating descriptions
|
||||
for task in self.tasks.values():
|
||||
if hasattr(task, 'description') and 'Input data from previous tasks:' in task.description:
|
||||
task.description = task.description.split('Input data from previous tasks:')[0].strip()
|
||||
if hasattr(task, '_execution_context'):
|
||||
task._execution_context = None
|
||||
|
||||
while fallback_attempts < self.max_retries and not temp_current_task:
|
||||
fallback_attempts += 1
|
||||
@@ -263,18 +278,18 @@ class Process:
|
||||
|
||||
if not leads_to_task and not task_candidate.next_tasks:
|
||||
continue # Skip if no valid path exists
|
||||
|
||||
if self.task_retry_counter.get(task_candidate.id, 0) < self.max_retries:
|
||||
# Atomic increment using thread lock to prevent race conditions
|
||||
with self._state_lock_init:
|
||||
current_count = self.task_retry_counter.get(task_candidate.id, 0)
|
||||
|
||||
# Fix TOCTOU bug: Move the entire check-and-increment inside the lock
|
||||
with self._state_lock_init:
|
||||
current_count = self.task_retry_counter.get(task_candidate.id, 0)
|
||||
if current_count < self.max_retries:
|
||||
self.task_retry_counter[task_candidate.id] = current_count + 1
|
||||
temp_current_task = task_candidate
|
||||
logging.debug(f"Fallback attempt {fallback_attempts}: Found 'not started' task: {temp_current_task.name}, retry count: {self.task_retry_counter[temp_current_task.id]}")
|
||||
return temp_current_task # Return the found task immediately
|
||||
else:
|
||||
logging.debug(f"Max retries ({self.max_retries}) reached for task {task_candidate.name} in fallback mode, marking as failed.")
|
||||
task_candidate.status = "failed"
|
||||
temp_current_task = task_candidate
|
||||
logging.debug(f"Fallback attempt {fallback_attempts}: Found 'not started' task: {temp_current_task.name}, retry count: {self.task_retry_counter[temp_current_task.id]}")
|
||||
return temp_current_task # Return the found task immediately
|
||||
else:
|
||||
logging.debug(f"Max retries ({self.max_retries}) reached for task {task_candidate.name} in fallback mode, marking as failed.")
|
||||
task_candidate.status = "failed"
|
||||
if not temp_current_task:
|
||||
logging.debug(f"Fallback attempt {fallback_attempts}: No 'not started' task found within retry limit.")
|
||||
return None # Return None if no task found after all attempts
|
||||
@@ -386,7 +401,7 @@ class Process:
|
||||
# Parse JSON and validate with Pydantic
|
||||
return self._parse_manager_instructions(response, ManagerInstructions)
|
||||
|
||||
def _check_all_tasks_completed(self) -> bool:
|
||||
async def _check_all_tasks_completed(self) -> bool:
|
||||
"""Check if all tasks are completed and handle workflow completion.
|
||||
|
||||
Returns:
|
||||
@@ -394,9 +409,27 @@ class Process:
|
||||
"""
|
||||
if all(task.status == "completed" for task in self.tasks.values()):
|
||||
logging.info("All tasks are completed.")
|
||||
self.workflow_finished = True
|
||||
await self._set_workflow_finished(True)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _check_all_tasks_completed_sync(self) -> bool:
|
||||
"""Synchronous version of _check_all_tasks_completed.
|
||||
|
||||
Returns:
|
||||
bool: True if all tasks are completed and workflow should exit, False otherwise.
|
||||
"""
|
||||
if all(task.status == "completed" for task in self.tasks.values()):
|
||||
logging.info("All tasks are completed.")
|
||||
# Use thread-safe setter for sync context
|
||||
self._set_workflow_finished_sync(True)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _set_workflow_finished_sync(self, value: bool):
|
||||
"""Thread-safe setter for workflow_finished flag in sync context."""
|
||||
with self._state_lock_init:
|
||||
self.workflow_finished = value
|
||||
|
||||
async def aworkflow(self) -> AsyncGenerator[str, None]:
|
||||
"""Async version of workflow method"""
|
||||
@@ -473,7 +506,7 @@ Tasks by type:
|
||||
""")
|
||||
|
||||
# ADDED: Check if all tasks are completed and set workflow_finished flag
|
||||
if self._check_all_tasks_completed():
|
||||
if await self._check_all_tasks_completed():
|
||||
break # Exit immediately to prevent task reset
|
||||
|
||||
task_id = current_task.id
|
||||
@@ -488,13 +521,10 @@ Context tasks: {[t.name for t in current_task.context] if current_task.context e
|
||||
Description length: {len(current_task.description)}
|
||||
""")
|
||||
|
||||
# Build context and set description for this execution pass only
|
||||
# Build context and store separately instead of mutating description
|
||||
context = self._build_task_context(current_task)
|
||||
# Store original description if not already stored
|
||||
if not hasattr(current_task, '_original_description'):
|
||||
current_task._original_description = current_task.description
|
||||
# Set description with context for execution; reset after yield to prevent accumulation
|
||||
current_task.description = current_task._original_description + (context if context else "")
|
||||
# Store context in dedicated field instead of concatenating to description
|
||||
current_task._execution_context = context if context else ""
|
||||
|
||||
# Skip execution for loop tasks, only process their subtasks
|
||||
if current_task.task_type == "loop":
|
||||
@@ -565,7 +595,7 @@ Subtask: {st.name}
|
||||
if next_task:
|
||||
next_task.status = "not started" # Reset status to allow execution
|
||||
logging.debug(f"Routing to {next_task.name} based on decision: {decision_str}")
|
||||
self.workflow_finished = False
|
||||
await self._set_workflow_finished(False)
|
||||
current_task = next_task
|
||||
# Ensure the task is yielded for execution
|
||||
if current_task.id not in visited_tasks:
|
||||
@@ -574,7 +604,7 @@ Subtask: {st.name}
|
||||
else:
|
||||
# End workflow if no valid next task found
|
||||
logging.info(f"No valid next task found for decision: {decision_str}")
|
||||
self.workflow_finished = True
|
||||
await self._set_workflow_finished(True)
|
||||
current_task = None
|
||||
break
|
||||
else:
|
||||
@@ -611,7 +641,7 @@ Subtask: {st.name}
|
||||
for t in self.tasks.values()
|
||||
):
|
||||
logging.info(f"Task {current_task.name} has no next tasks, ending workflow")
|
||||
self.workflow_finished = True
|
||||
await self._set_workflow_finished(True)
|
||||
current_task = None
|
||||
break
|
||||
|
||||
@@ -674,7 +704,7 @@ Subtask: {st.name}
|
||||
# Handle all forms of exit conditions
|
||||
if not target_tasks or target_tasks == "exit" or (isinstance(target_tasks, list) and (not target_tasks or target_tasks[0] == "exit")):
|
||||
logging.info(f"Workflow exit condition met on decision: {decision_str}")
|
||||
self.workflow_finished = True
|
||||
await self._set_workflow_finished(True)
|
||||
current_task = None
|
||||
break
|
||||
else:
|
||||
@@ -714,7 +744,7 @@ Subtask: {st.name}
|
||||
|
||||
logging.debug(f"Routing to {next_task.name} based on decision: {decision_str}")
|
||||
# Don't mark workflow as finished when following condition path
|
||||
self.workflow_finished = False
|
||||
await self._set_workflow_finished(False)
|
||||
|
||||
# If no condition-based routing, use next_tasks
|
||||
if not next_task and current_task and current_task.next_tasks:
|
||||
@@ -728,7 +758,7 @@ Subtask: {st.name}
|
||||
next_task.next_tasks and
|
||||
next_task.next_tasks[0] in self.tasks and
|
||||
next_task.name in self.tasks[next_task.next_tasks[0]].previous_tasks):
|
||||
self.workflow_finished = False
|
||||
await self._set_workflow_finished(False)
|
||||
logging.debug(f"Following next_tasks to {next_task.name}")
|
||||
|
||||
current_task = next_task
|
||||
@@ -806,6 +836,11 @@ Workflow Finished: {self.workflow_finished} # ADDED: Workflow Finished Status
|
||||
completed_count = 0
|
||||
total_tasks = len(self.tasks) - 1
|
||||
logging.info(f"Need to complete {total_tasks} tasks (excluding manager task)")
|
||||
|
||||
# Track invalid selection attempts and error context
|
||||
invalid_selection_attempts = 0
|
||||
MAX_INVALID_SELECTIONS = 3
|
||||
error_context = ""
|
||||
|
||||
while completed_count < total_tasks:
|
||||
tasks_summary = []
|
||||
@@ -832,22 +867,44 @@ Provide a JSON with the structure:
|
||||
"agent_name": "<string>",
|
||||
"action": "<execute or stop>"
|
||||
}}
|
||||
"""
|
||||
""" + error_context
|
||||
|
||||
try:
|
||||
logging.info("Requesting manager instructions...")
|
||||
if manager_task.async_execution:
|
||||
parsed_instructions = await self._get_manager_instructions_with_fallback_async(
|
||||
manager_task, manager_prompt, ManagerInstructions
|
||||
)
|
||||
else:
|
||||
parsed_instructions = self._get_manager_instructions_with_fallback(
|
||||
manager_task, manager_prompt, ManagerInstructions
|
||||
)
|
||||
logging.info(f"Manager instructions: {parsed_instructions}")
|
||||
except Exception as e:
|
||||
display_error(f"Manager parse error: {e}")
|
||||
logging.error(f"Manager parse error: {str(e)}", exc_info=True)
|
||||
# Retry logic for manager instruction failures
|
||||
MAX_MANAGER_RETRIES = 3
|
||||
parsed_instructions = None
|
||||
|
||||
for manager_attempt in range(MAX_MANAGER_RETRIES):
|
||||
try:
|
||||
logging.info(f"Requesting manager instructions (attempt {manager_attempt + 1}/{MAX_MANAGER_RETRIES})...")
|
||||
if manager_task.async_execution:
|
||||
parsed_instructions = await self._get_manager_instructions_with_fallback_async(
|
||||
manager_task, manager_prompt, ManagerInstructions
|
||||
)
|
||||
else:
|
||||
# Offload sync call to thread to avoid blocking event loop
|
||||
parsed_instructions = await asyncio.to_thread(
|
||||
self._get_manager_instructions_with_fallback,
|
||||
manager_task, manager_prompt, ManagerInstructions
|
||||
)
|
||||
logging.info(f"Manager instructions: {parsed_instructions}")
|
||||
break # Success, exit retry loop
|
||||
except Exception as e:
|
||||
if manager_attempt < MAX_MANAGER_RETRIES - 1:
|
||||
# Not the last attempt, wait with exponential backoff
|
||||
delay = 2 ** manager_attempt
|
||||
logging.warning(
|
||||
f"Manager parse error (attempt {manager_attempt + 1}/{MAX_MANAGER_RETRIES}), "
|
||||
f"retrying in {delay}s: {e}"
|
||||
)
|
||||
# Always use async sleep in async context
|
||||
await asyncio.sleep(delay)
|
||||
else:
|
||||
# Final attempt failed
|
||||
display_error(f"Manager failed after {MAX_MANAGER_RETRIES} attempts: {e}")
|
||||
logging.error(f"Manager parse error: {str(e)}", exc_info=True)
|
||||
|
||||
# If all retries failed, break out of the main loop
|
||||
if parsed_instructions is None:
|
||||
break
|
||||
|
||||
selected_task_id = parsed_instructions.task_id
|
||||
@@ -861,10 +918,29 @@ Provide a JSON with the structure:
|
||||
break
|
||||
|
||||
if selected_task_id not in self.tasks:
|
||||
error_msg = f"Manager selected invalid task id {selected_task_id}"
|
||||
display_error(error_msg)
|
||||
logging.error(error_msg)
|
||||
break
|
||||
# Re-prompt the manager with valid task IDs instead of terminating
|
||||
invalid_selection_attempts += 1
|
||||
if invalid_selection_attempts > MAX_INVALID_SELECTIONS:
|
||||
logging.error(
|
||||
f"Manager produced {invalid_selection_attempts} invalid task selections; aborting."
|
||||
)
|
||||
break
|
||||
|
||||
valid_task_ids = list(self.tasks.keys())
|
||||
logging.warning(
|
||||
f"Manager selected invalid task_id={selected_task_id} "
|
||||
f"(attempt {invalid_selection_attempts}/{MAX_INVALID_SELECTIONS}); valid IDs: {valid_task_ids}"
|
||||
)
|
||||
# Set error context for next iteration (instead of appending to prompt that gets rebuilt)
|
||||
error_context = (
|
||||
f"\n\n[ERROR] Your previous selection of task_id={selected_task_id} was invalid. "
|
||||
f"Valid task IDs are: {valid_task_ids}. Please select again from the valid options."
|
||||
)
|
||||
continue # Re-prompt the manager instead of breaking
|
||||
|
||||
# Reset on valid selection
|
||||
invalid_selection_attempts = 0
|
||||
error_context = ""
|
||||
|
||||
original_agent = self.tasks[selected_task_id].agent.name if self.tasks[selected_task_id].agent else "None"
|
||||
for a in self.agents:
|
||||
@@ -918,6 +994,7 @@ Provide a JSON with the structure:
|
||||
stacklevel=3
|
||||
)
|
||||
current_iter = 0 # Track how many times we've looped
|
||||
workflow_start = time.monotonic() # For timeout enforcement
|
||||
# Build workflow relationships first
|
||||
for task in self.tasks.values():
|
||||
if task.next_tasks:
|
||||
@@ -1046,6 +1123,14 @@ Provide a JSON with the structure:
|
||||
logging.info(f"Max iteration limit {self.max_iter} reached, ending workflow.")
|
||||
break
|
||||
|
||||
# Enforce workflow timeout if set
|
||||
if self.workflow_timeout is not None:
|
||||
elapsed = time.monotonic() - workflow_start
|
||||
if elapsed > self.workflow_timeout:
|
||||
logging.warning(f"Workflow timeout ({self.workflow_timeout}s) exceeded after {elapsed:.1f}s, ending workflow.")
|
||||
self.workflow_cancelled = True
|
||||
break
|
||||
|
||||
# ADDED: Check workflow finished flag at the start of each cycle
|
||||
if self.workflow_finished:
|
||||
logging.info("Workflow finished early as all tasks are completed.")
|
||||
@@ -1073,7 +1158,7 @@ Tasks by type:
|
||||
""")
|
||||
|
||||
# ADDED: Check if all tasks are completed and set workflow_finished flag
|
||||
if self._check_all_tasks_completed():
|
||||
if self._check_all_tasks_completed_sync():
|
||||
break # Exit immediately to prevent task reset
|
||||
|
||||
|
||||
@@ -1162,13 +1247,10 @@ Context tasks: {[t.name for t in current_task.context] if current_task.context e
|
||||
Description length: {len(current_task.description)}
|
||||
""")
|
||||
|
||||
# Build context and set description for this execution pass only
|
||||
# Build context and store separately instead of mutating description
|
||||
context = self._build_task_context(current_task)
|
||||
# Store original description if not already stored
|
||||
if not hasattr(current_task, '_original_description'):
|
||||
current_task._original_description = current_task.description
|
||||
# Set description with context for execution; reset after yield to prevent accumulation
|
||||
current_task.description = current_task._original_description + (context if context else "")
|
||||
# Store context in dedicated field instead of concatenating to description
|
||||
current_task._execution_context = context if context else ""
|
||||
|
||||
# Skip execution for loop tasks, only process their subtasks
|
||||
if current_task.task_type == "loop":
|
||||
@@ -1239,7 +1321,7 @@ Subtask: {st.name}
|
||||
if next_task:
|
||||
next_task.status = "not started" # Reset status to allow execution
|
||||
logging.debug(f"Routing to {next_task.name} based on decision: {decision_str}")
|
||||
self.workflow_finished = False
|
||||
self._set_workflow_finished_sync(False)
|
||||
current_task = next_task
|
||||
# Ensure the task is yielded for execution
|
||||
if current_task.id not in visited_tasks:
|
||||
@@ -1248,7 +1330,7 @@ Subtask: {st.name}
|
||||
else:
|
||||
# End workflow if no valid next task found
|
||||
logging.info(f"No valid next task found for decision: {decision_str}")
|
||||
self.workflow_finished = True
|
||||
self._set_workflow_finished_sync(True)
|
||||
current_task = None
|
||||
break
|
||||
else:
|
||||
@@ -1285,7 +1367,7 @@ Subtask: {st.name}
|
||||
for t in self.tasks.values()
|
||||
):
|
||||
logging.info(f"Task {current_task.name} has no next tasks, ending workflow")
|
||||
self.workflow_finished = True
|
||||
self._set_workflow_finished_sync(True)
|
||||
current_task = None
|
||||
break
|
||||
|
||||
@@ -1346,7 +1428,7 @@ Subtask: {st.name}
|
||||
# Handle all forms of exit conditions
|
||||
if not target_tasks or target_tasks == "exit" or (isinstance(target_tasks, list) and (not target_tasks or target_tasks[0] == "exit")):
|
||||
logging.info(f"Workflow exit condition met on decision: {decision_str}")
|
||||
self.workflow_finished = True
|
||||
self._set_workflow_finished_sync(True)
|
||||
current_task = None
|
||||
break
|
||||
else:
|
||||
@@ -1386,7 +1468,7 @@ Subtask: {st.name}
|
||||
|
||||
logging.debug(f"Routing to {next_task.name} based on decision: {decision_str}")
|
||||
# Don't mark workflow as finished when following condition path
|
||||
self.workflow_finished = False
|
||||
self._set_workflow_finished_sync(False)
|
||||
|
||||
# If no condition-based routing, use next_tasks
|
||||
if not next_task and current_task and current_task.next_tasks:
|
||||
@@ -1400,7 +1482,7 @@ Subtask: {st.name}
|
||||
next_task.next_tasks and
|
||||
next_task.next_tasks[0] in self.tasks and
|
||||
next_task.name in self.tasks[next_task.next_tasks[0]].previous_tasks):
|
||||
self.workflow_finished = False
|
||||
self._set_workflow_finished_sync(False)
|
||||
logging.debug(f"Following next_tasks to {next_task.name}")
|
||||
|
||||
current_task = next_task
|
||||
@@ -1445,7 +1527,19 @@ Workflow Finished: {self.workflow_finished} # ADDED: Workflow Finished Status
|
||||
def sequential(self):
|
||||
"""Synchronous version of sequential method"""
|
||||
for task_id in self.tasks:
|
||||
if self.tasks[task_id].status != "completed":
|
||||
task = self.tasks[task_id]
|
||||
if task.status == "failed":
|
||||
continue # Skip failed tasks
|
||||
# Check if any context dependency failed
|
||||
if hasattr(task, 'context') and task.context:
|
||||
deps_failed = any(
|
||||
self.tasks[dep.id].status == "failed"
|
||||
for dep in task.context if hasattr(dep, 'id') and dep.id in self.tasks
|
||||
)
|
||||
if deps_failed:
|
||||
task.status = "failed"
|
||||
continue
|
||||
if task.status != "completed":
|
||||
yield task_id
|
||||
|
||||
def hierarchical(self):
|
||||
@@ -1478,6 +1572,11 @@ Workflow Finished: {self.workflow_finished} # ADDED: Workflow Finished Status
|
||||
completed_count = 0
|
||||
total_tasks = len(self.tasks) - 1
|
||||
logging.info(f"Need to complete {total_tasks} tasks (excluding manager task)")
|
||||
|
||||
# Track invalid selection attempts and error context
|
||||
invalid_selection_attempts = 0
|
||||
MAX_INVALID_SELECTIONS = 3
|
||||
error_context = ""
|
||||
|
||||
while completed_count < total_tasks:
|
||||
tasks_summary = []
|
||||
@@ -1504,7 +1603,7 @@ Provide a JSON with the structure:
|
||||
"agent_name": "<string>",
|
||||
"action": "<execute or stop>"
|
||||
}}
|
||||
"""
|
||||
""" + error_context
|
||||
|
||||
try:
|
||||
logging.info("Requesting manager instructions...")
|
||||
@@ -1528,10 +1627,29 @@ Provide a JSON with the structure:
|
||||
break
|
||||
|
||||
if selected_task_id not in self.tasks:
|
||||
error_msg = f"Manager selected invalid task id {selected_task_id}"
|
||||
display_error(error_msg)
|
||||
logging.error(error_msg)
|
||||
break
|
||||
# Re-prompt the manager with valid task IDs instead of terminating
|
||||
invalid_selection_attempts += 1
|
||||
if invalid_selection_attempts > MAX_INVALID_SELECTIONS:
|
||||
logging.error(
|
||||
f"Manager produced {invalid_selection_attempts} invalid task selections; aborting."
|
||||
)
|
||||
break
|
||||
|
||||
valid_task_ids = list(self.tasks.keys())
|
||||
logging.warning(
|
||||
f"Manager selected invalid task_id={selected_task_id} "
|
||||
f"(attempt {invalid_selection_attempts}/{MAX_INVALID_SELECTIONS}); valid IDs: {valid_task_ids}"
|
||||
)
|
||||
# Set error context for next iteration (instead of appending to prompt that gets rebuilt)
|
||||
error_context = (
|
||||
f"\n\n[ERROR] Your previous selection of task_id={selected_task_id} was invalid. "
|
||||
f"Valid task IDs are: {valid_task_ids}. Please select again from the valid options."
|
||||
)
|
||||
continue # Re-prompt the manager instead of breaking
|
||||
|
||||
# Reset on valid selection
|
||||
invalid_selection_attempts = 0
|
||||
error_context = ""
|
||||
|
||||
original_agent = self.tasks[selected_task_id].agent.name if self.tasks[selected_task_id].agent else "None"
|
||||
for a in self.agents:
|
||||
|
||||
@@ -617,15 +617,17 @@ class Session:
|
||||
if self.is_remote:
|
||||
return # No cleanup needed for remote sessions
|
||||
|
||||
# Clear memory
|
||||
# Properly cleanup memory
|
||||
if self._memory:
|
||||
try:
|
||||
# Clear short-term and long-term memory for this session
|
||||
# Note: This is a basic implementation - specific memory backends
|
||||
# might need more sophisticated cleanup
|
||||
# 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
|
||||
except Exception:
|
||||
pass # Ignore cleanup errors
|
||||
|
||||
# Clear knowledge
|
||||
if self._knowledge:
|
||||
|
||||
@@ -90,7 +90,43 @@ def __getattr__(name: str):
|
||||
from .hierarchy import ExtendedSessionData
|
||||
_module_cache[name] = ExtendedSessionData
|
||||
return ExtendedSessionData
|
||||
|
||||
|
||||
# W1 — Identity resolver
|
||||
if name == "IdentityResolverProtocol":
|
||||
from .identity import IdentityResolverProtocol
|
||||
_module_cache[name] = IdentityResolverProtocol
|
||||
return IdentityResolverProtocol
|
||||
if name == "IdentityLink":
|
||||
from .identity import IdentityLink
|
||||
_module_cache[name] = IdentityLink
|
||||
return IdentityLink
|
||||
if name == "InMemoryIdentityResolver":
|
||||
from .identity import InMemoryIdentityResolver
|
||||
_module_cache[name] = InMemoryIdentityResolver
|
||||
return InMemoryIdentityResolver
|
||||
if name == "FileIdentityResolver":
|
||||
from .identity import FileIdentityResolver
|
||||
_module_cache[name] = FileIdentityResolver
|
||||
return FileIdentityResolver
|
||||
|
||||
# W1 — Task-local session context
|
||||
if name == "SessionContext":
|
||||
from .context import SessionContext
|
||||
_module_cache[name] = SessionContext
|
||||
return SessionContext
|
||||
if name == "set_session_context":
|
||||
from .context import set_session_context
|
||||
_module_cache[name] = set_session_context
|
||||
return set_session_context
|
||||
if name == "get_session_context":
|
||||
from .context import get_session_context
|
||||
_module_cache[name] = get_session_context
|
||||
return get_session_context
|
||||
if name == "clear_session_context":
|
||||
from .context import clear_session_context
|
||||
_module_cache[name] = clear_session_context
|
||||
return clear_session_context
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
@@ -105,4 +141,13 @@ __all__ = [
|
||||
"get_hierarchical_session_store",
|
||||
"SessionSnapshot",
|
||||
"ExtendedSessionData",
|
||||
# W1
|
||||
"IdentityResolverProtocol",
|
||||
"IdentityLink",
|
||||
"InMemoryIdentityResolver",
|
||||
"FileIdentityResolver",
|
||||
"SessionContext",
|
||||
"set_session_context",
|
||||
"get_session_context",
|
||||
"clear_session_context",
|
||||
]
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Task-local session context for concurrent message handlers.
|
||||
|
||||
W1 — Replaces process-global ``os.environ`` based session metadata
|
||||
with ``contextvars.ContextVar`` so that two messages handled
|
||||
concurrently never overwrite each other's platform / chat / user IDs.
|
||||
|
||||
Each ``set_session_context`` call returns a token; pass it to
|
||||
``clear_session_context`` in a ``finally`` block to restore the
|
||||
previous state.
|
||||
|
||||
Usage in a bot handler::
|
||||
|
||||
from praisonaiagents.session.context import (
|
||||
set_session_context,
|
||||
clear_session_context,
|
||||
get_session_context,
|
||||
)
|
||||
|
||||
token = set_session_context(
|
||||
platform="telegram",
|
||||
chat_id="100",
|
||||
user_id="alice",
|
||||
unified_user_id="alice-global",
|
||||
)
|
||||
try:
|
||||
# ... agent handles message; tools can call get_session_context()
|
||||
ctx = get_session_context()
|
||||
finally:
|
||||
clear_session_context(token)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextvars import ContextVar, Token
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionContext:
|
||||
"""Snapshot of the current message-handling context."""
|
||||
|
||||
platform: str = ""
|
||||
chat_id: str = ""
|
||||
chat_name: str = ""
|
||||
thread_id: str = ""
|
||||
user_id: str = ""
|
||||
user_name: str = ""
|
||||
unified_user_id: str = ""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "SessionContext":
|
||||
return cls(
|
||||
platform=d.get("platform", ""),
|
||||
chat_id=d.get("chat_id", ""),
|
||||
chat_name=d.get("chat_name", ""),
|
||||
thread_id=d.get("thread_id", ""),
|
||||
user_id=d.get("user_id", ""),
|
||||
user_name=d.get("user_name", ""),
|
||||
unified_user_id=d.get("unified_user_id", ""),
|
||||
)
|
||||
|
||||
|
||||
_CTX: ContextVar[SessionContext] = ContextVar(
|
||||
"praisonai_session_context", default=SessionContext()
|
||||
)
|
||||
|
||||
|
||||
def set_session_context(
|
||||
platform: str = "",
|
||||
chat_id: str = "",
|
||||
chat_name: str = "",
|
||||
thread_id: str = "",
|
||||
user_id: str = "",
|
||||
user_name: str = "",
|
||||
unified_user_id: str = "",
|
||||
) -> Token:
|
||||
"""Set the task-local session context. Returns a token for ``clear``."""
|
||||
ctx = SessionContext(
|
||||
platform=platform,
|
||||
chat_id=chat_id,
|
||||
chat_name=chat_name,
|
||||
thread_id=thread_id,
|
||||
user_id=user_id,
|
||||
user_name=user_name,
|
||||
unified_user_id=unified_user_id,
|
||||
)
|
||||
return _CTX.set(ctx)
|
||||
|
||||
|
||||
def get_session_context() -> SessionContext:
|
||||
"""Return the current task's session context (empty if unset)."""
|
||||
return _CTX.get()
|
||||
|
||||
|
||||
def clear_session_context(token: Token) -> None:
|
||||
"""Restore the previous session context using the token from ``set``."""
|
||||
try:
|
||||
_CTX.reset(token)
|
||||
except (LookupError, ValueError):
|
||||
# Token from a different context; reset to empty
|
||||
_CTX.set(SessionContext())
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SessionContext",
|
||||
"set_session_context",
|
||||
"get_session_context",
|
||||
"clear_session_context",
|
||||
]
|
||||
@@ -481,6 +481,74 @@ class HierarchicalSessionStore(DefaultSessionStore):
|
||||
session.title = title
|
||||
return self._save_extended_session(session)
|
||||
|
||||
async def auto_title(self, session_id: str) -> bool:
|
||||
"""Generate and set title automatically from first exchange.
|
||||
|
||||
Args:
|
||||
session_id: Session to generate title for
|
||||
|
||||
Returns:
|
||||
True if title was generated and set, False otherwise
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
# Load session in thread to avoid blocking event loop
|
||||
session = await asyncio.to_thread(self._load_extended_session, session_id)
|
||||
|
||||
# Skip if already has a title
|
||||
if session.title and session.title.strip():
|
||||
return False
|
||||
|
||||
# Need at least one user and one assistant message
|
||||
messages = session.messages
|
||||
if not messages or len(messages) < 2:
|
||||
return False
|
||||
|
||||
# Find first user message and first assistant response
|
||||
user_msg = None
|
||||
assistant_msg = None
|
||||
|
||||
for msg in messages:
|
||||
# Handle both SessionMessage dataclass and dict formats
|
||||
if hasattr(msg, 'role'):
|
||||
role = msg.role
|
||||
content = msg.content
|
||||
else:
|
||||
role = msg.get("role")
|
||||
content = msg.get("content", "")
|
||||
|
||||
if role == "user" and not user_msg:
|
||||
if isinstance(content, str) and content.strip():
|
||||
user_msg = content
|
||||
elif role == "assistant" and not assistant_msg and user_msg:
|
||||
if isinstance(content, str) and content.strip():
|
||||
assistant_msg = content
|
||||
break
|
||||
|
||||
if not user_msg or not assistant_msg:
|
||||
return False
|
||||
|
||||
try:
|
||||
# Generate title using title module
|
||||
from .title import generate_title_async
|
||||
title = await generate_title_async(user_msg, assistant_msg)
|
||||
|
||||
if title and title.strip():
|
||||
# Reload session to avoid overwriting concurrent updates
|
||||
fresh_session = await asyncio.to_thread(self._load_extended_session, session_id)
|
||||
# Only set title if it's still empty
|
||||
if not fresh_session.title or not fresh_session.title.strip():
|
||||
fresh_session.title = title.strip()
|
||||
return await asyncio.to_thread(self._save_extended_session, fresh_session)
|
||||
|
||||
except Exception as e:
|
||||
# Title generation failed - log with context instead of silent failure
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.debug("Auto title generation failed for session %s: %s", session_id, str(e))
|
||||
|
||||
return False
|
||||
|
||||
def get_extended_session(self, session_id: str) -> ExtendedSessionData:
|
||||
"""Get extended session data."""
|
||||
return self._load_extended_session(session_id)
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Cross-platform identity resolution for agents.
|
||||
|
||||
W1 — Optional, opt-in mapping from ``(platform, platform_user_id)``
|
||||
to a single ``unified_user_id`` so the same human is recognised across
|
||||
Telegram, Discord, Slack, etc.
|
||||
|
||||
By default (no links registered) ``resolve()`` returns the
|
||||
platform-prefixed string unchanged — no surprises, no privacy leak.
|
||||
|
||||
Linking is performed explicitly, typically after a DM-based pairing
|
||||
flow has cryptographically verified ownership of both accounts.
|
||||
|
||||
Usage::
|
||||
|
||||
from praisonaiagents.session.identity import InMemoryIdentityResolver
|
||||
|
||||
resolver = InMemoryIdentityResolver()
|
||||
resolver.link("telegram", "12345", "alice")
|
||||
resolver.link("discord", "snowflake-1", "alice")
|
||||
assert resolver.resolve("telegram", "12345") == "alice"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Protocol, runtime_checkable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IdentityLink:
|
||||
"""Immutable mapping from a platform-scoped user ID to a unified ID."""
|
||||
|
||||
platform: str
|
||||
platform_user_id: str
|
||||
unified_user_id: str
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class IdentityResolverProtocol(Protocol):
|
||||
"""Maps platform-scoped user IDs to a unified identity.
|
||||
|
||||
Implementations MUST be thread-safe. Implementations SHOULD return
|
||||
``f"{platform}:{platform_user_id}"`` for unlinked users so callers
|
||||
never need to special-case the unlinked path.
|
||||
"""
|
||||
|
||||
def resolve(self, platform: str, platform_user_id: str) -> str:
|
||||
"""Return ``unified_user_id`` for the given platform user.
|
||||
|
||||
If no link is registered, return ``f"{platform}:{platform_user_id}"``.
|
||||
"""
|
||||
...
|
||||
|
||||
def link(
|
||||
self, platform: str, platform_user_id: str, unified_user_id: str
|
||||
) -> None:
|
||||
"""Register a mapping. Overwrites any existing mapping for the same
|
||||
``(platform, platform_user_id)`` pair.
|
||||
"""
|
||||
...
|
||||
|
||||
def unlink(self, platform: str, platform_user_id: str) -> None:
|
||||
"""Remove a mapping. No-op when no link exists."""
|
||||
...
|
||||
|
||||
def links_for(self, unified_user_id: str) -> List[IdentityLink]:
|
||||
"""Return all links pointing to the given unified ID."""
|
||||
...
|
||||
|
||||
|
||||
class InMemoryIdentityResolver:
|
||||
"""Thread-safe in-memory ``IdentityResolverProtocol`` implementation.
|
||||
|
||||
Suitable for tests and single-process deployments. Production
|
||||
deployments with multiple processes should use a persistent
|
||||
implementation backed by SQLite, Redis, or a database.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._links: dict[tuple[str, str], str] = {}
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def resolve(self, platform: str, platform_user_id: str) -> str:
|
||||
with self._lock:
|
||||
return self._links.get(
|
||||
(platform, platform_user_id),
|
||||
f"{platform}:{platform_user_id}",
|
||||
)
|
||||
|
||||
def link(
|
||||
self, platform: str, platform_user_id: str, unified_user_id: str
|
||||
) -> None:
|
||||
with self._lock:
|
||||
self._links[(platform, platform_user_id)] = unified_user_id
|
||||
|
||||
def unlink(self, platform: str, platform_user_id: str) -> None:
|
||||
with self._lock:
|
||||
self._links.pop((platform, platform_user_id), None)
|
||||
|
||||
def links_for(self, unified_user_id: str) -> List[IdentityLink]:
|
||||
with self._lock:
|
||||
return [
|
||||
IdentityLink(p, u, unified_user_id)
|
||||
for (p, u), uid in self._links.items()
|
||||
if uid == unified_user_id
|
||||
]
|
||||
|
||||
|
||||
_DEFAULT_IDENTITY_PATH = Path(
|
||||
os.environ.get(
|
||||
"PRAISONAI_IDENTITY_PATH",
|
||||
os.path.expanduser("~/.praisonai/identity.json"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class FileIdentityResolver(InMemoryIdentityResolver):
|
||||
"""JSON-file-backed ``IdentityResolverProtocol`` implementation.
|
||||
|
||||
Loads links from disk on construction; persists on every mutation
|
||||
via atomic temp-file + ``os.replace``. Thread-safe.
|
||||
|
||||
Default path: ``$PRAISONAI_IDENTITY_PATH`` or ``~/.praisonai/identity.json``.
|
||||
|
||||
Storage format::
|
||||
|
||||
{
|
||||
"links": {
|
||||
"telegram::12345": "alice",
|
||||
"discord::snowflake-1": "alice"
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, path: Optional[Path | str] = None) -> None:
|
||||
super().__init__()
|
||||
self._path: Path = Path(path) if path else _DEFAULT_IDENTITY_PATH
|
||||
# Serialises concurrent disk writes so that two simultaneous
|
||||
# link()/unlink() calls cannot interleave their os.replace() and
|
||||
# lose the second writer's data.
|
||||
self._flush_lock = threading.Lock()
|
||||
self._load()
|
||||
|
||||
@staticmethod
|
||||
def _encode_key(platform: str, platform_user_id: str) -> str:
|
||||
return f"{platform}::{platform_user_id}"
|
||||
|
||||
@staticmethod
|
||||
def _decode_key(encoded: str) -> tuple[str, str]:
|
||||
platform, _, user = encoded.partition("::")
|
||||
return platform, user
|
||||
|
||||
def _load(self) -> None:
|
||||
try:
|
||||
if not self._path.exists():
|
||||
return
|
||||
data = json.loads(self._path.read_text(encoding="utf-8"))
|
||||
links = data.get("links", {}) if isinstance(data, dict) else {}
|
||||
with self._lock:
|
||||
self._links = {
|
||||
self._decode_key(k): v
|
||||
for k, v in links.items()
|
||||
if isinstance(v, str)
|
||||
}
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
logger.warning("FileIdentityResolver: failed to load %s: %s", self._path, e)
|
||||
|
||||
def _flush(self) -> None:
|
||||
try:
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Hold _flush_lock for the entire write (snapshot + disk I/O) so
|
||||
# that concurrent calls always write the latest in-memory state and
|
||||
# the last os.replace() wins rather than an older snapshot.
|
||||
with self._flush_lock:
|
||||
with self._lock:
|
||||
payload = {
|
||||
"links": {
|
||||
self._encode_key(p, u): uid
|
||||
for (p, u), uid in self._links.items()
|
||||
}
|
||||
}
|
||||
# Keep the _lock held across the entire write operation to prevent
|
||||
# TOCTOU race where a concurrent link() could modify _links between
|
||||
# payload snapshot and os.replace() completion.
|
||||
fd, tmp = tempfile.mkstemp(
|
||||
dir=str(self._path.parent), prefix=".identity-", suffix=".tmp"
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, indent=2, ensure_ascii=False)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp, self._path)
|
||||
try:
|
||||
os.chmod(self._path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
except OSError as e:
|
||||
logger.warning("FileIdentityResolver: failed to flush %s: %s", self._path, e)
|
||||
|
||||
def link(
|
||||
self, platform: str, platform_user_id: str, unified_user_id: str
|
||||
) -> None:
|
||||
super().link(platform, platform_user_id, unified_user_id)
|
||||
self._flush()
|
||||
|
||||
def unlink(self, platform: str, platform_user_id: str) -> None:
|
||||
super().unlink(platform, platform_user_id)
|
||||
self._flush()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"IdentityLink",
|
||||
"IdentityResolverProtocol",
|
||||
"InMemoryIdentityResolver",
|
||||
"FileIdentityResolver",
|
||||
]
|
||||
@@ -5,7 +5,6 @@ JSON-based session persistence with file locking and atomic writes.
|
||||
Zero dependencies beyond stdlib.
|
||||
"""
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
@@ -14,6 +13,13 @@ import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
# fcntl is Unix-only; on Windows, use msvcrt for file locking
|
||||
if sys.platform != 'win32':
|
||||
import fcntl
|
||||
_HAS_FCNTL = True
|
||||
else:
|
||||
_HAS_FCNTL = False
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
@@ -22,6 +28,9 @@ from ..paths import get_sessions_dir
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Module-level sentinel to track if we've warned about degraded locking
|
||||
_WARNED_NO_FCNTL = False
|
||||
|
||||
# Default session directory (uses centralized paths - DRY)
|
||||
DEFAULT_SESSION_DIR = str(get_sessions_dir())
|
||||
|
||||
@@ -150,7 +159,17 @@ class FileLock:
|
||||
msvcrt.locking(self._lock_file.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
else:
|
||||
# Unix locking
|
||||
fcntl.flock(self._lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
if _HAS_FCNTL:
|
||||
fcntl.flock(self._lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
else:
|
||||
# Warn once about degraded locking (should never happen with current platform detection)
|
||||
global _WARNED_NO_FCNTL
|
||||
if not _WARNED_NO_FCNTL:
|
||||
logger.warning(
|
||||
"File locking unavailable on this platform (no fcntl/msvcrt); "
|
||||
"concurrent writers may corrupt session files."
|
||||
)
|
||||
_WARNED_NO_FCNTL = True
|
||||
return True
|
||||
except (IOError, OSError, BlockingIOError):
|
||||
if self._lock_file:
|
||||
@@ -171,7 +190,9 @@ class FileLock:
|
||||
import msvcrt
|
||||
msvcrt.locking(self._lock_file.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
else:
|
||||
fcntl.flock(self._lock_file.fileno(), fcntl.LOCK_UN)
|
||||
if _HAS_FCNTL:
|
||||
fcntl.flock(self._lock_file.fileno(), fcntl.LOCK_UN)
|
||||
# Note: No warning needed in release() as it mirrors acquire() logic
|
||||
except (IOError, OSError):
|
||||
pass
|
||||
finally:
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
Session Title Auto-Generation - Generate descriptive titles for chat sessions.
|
||||
|
||||
Automatically creates concise, meaningful titles based on the first user-assistant
|
||||
exchange in a conversation. Uses a lightweight model for fast generation.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
|
||||
__all__ = ["generate_title", "generate_title_async"]
|
||||
|
||||
|
||||
def generate_title(
|
||||
user_msg: str,
|
||||
assistant_msg: str,
|
||||
llm_model: str = "gpt-4o-mini",
|
||||
timeout: float = 10.0,
|
||||
max_length: int = 60
|
||||
) -> str:
|
||||
"""Generate a session title from the first exchange.
|
||||
|
||||
Args:
|
||||
user_msg: First user message
|
||||
assistant_msg: First assistant response
|
||||
llm_model: Model to use for title generation (default: fast, cheap model)
|
||||
timeout: Timeout in seconds for generation
|
||||
max_length: Maximum title length in characters
|
||||
|
||||
Returns:
|
||||
Generated title string, or fallback based on user message
|
||||
|
||||
Examples:
|
||||
>>> title = generate_title(
|
||||
... "Help me debug this Python code",
|
||||
... "I'd be happy to help debug your Python code..."
|
||||
... )
|
||||
>>> # Returns something like: "Python Code Debugging Help"
|
||||
"""
|
||||
# Fallback title from user message if generation fails
|
||||
fallback_title = _create_fallback_title(user_msg, max_length)
|
||||
|
||||
try:
|
||||
# Check if we're already in an event loop
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
# No running loop - safe to use asyncio.run
|
||||
try:
|
||||
return asyncio.run(generate_title_async(
|
||||
user_msg, assistant_msg, llm_model, timeout, max_length
|
||||
))
|
||||
except Exception:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.debug("Title generation failed in sync context", exc_info=True)
|
||||
return fallback_title
|
||||
else:
|
||||
# Already in event loop - cannot use asyncio.run
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(
|
||||
"generate_title() called from running event loop; "
|
||||
"use generate_title_async() instead. Returning fallback title."
|
||||
)
|
||||
return fallback_title
|
||||
|
||||
|
||||
async def generate_title_async(
|
||||
user_msg: str,
|
||||
assistant_msg: str,
|
||||
llm_model: str = "gpt-4o-mini",
|
||||
timeout: float = 10.0,
|
||||
max_length: int = 60
|
||||
) -> str:
|
||||
"""Async version of generate_title.
|
||||
|
||||
Args:
|
||||
user_msg: First user message
|
||||
assistant_msg: First assistant response
|
||||
llm_model: Model to use for title generation
|
||||
timeout: Timeout in seconds for generation
|
||||
max_length: Maximum title length in characters
|
||||
|
||||
Returns:
|
||||
Generated title string, or fallback based on user message
|
||||
"""
|
||||
fallback_title = _create_fallback_title(user_msg, max_length)
|
||||
|
||||
try:
|
||||
# Lazy import to avoid circular dependencies
|
||||
from ..llm import LLM
|
||||
|
||||
# Create prompt for title generation
|
||||
prompt = f"""Generate a concise, descriptive title (3-8 words) for this conversation.
|
||||
|
||||
USER: {user_msg[:400]}
|
||||
ASSISTANT: {assistant_msg[:400]}
|
||||
|
||||
Return ONLY the title text, no quotes, no explanation."""
|
||||
|
||||
# Use lightweight model for fast, cheap generation
|
||||
llm = LLM(model=llm_model)
|
||||
|
||||
# Generate with timeout
|
||||
try:
|
||||
response = await asyncio.wait_for(
|
||||
llm.aget_response(prompt=prompt),
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
if response and isinstance(response, str):
|
||||
# Clean up the response
|
||||
title = response.strip().strip('"').strip("'")
|
||||
title = title.replace('\n', ' ').replace('\r', '')
|
||||
|
||||
# Truncate if too long
|
||||
if len(title) > max_length:
|
||||
title = title[:max_length-3] + "..."
|
||||
|
||||
# Return if non-empty
|
||||
if title and len(title.strip()) > 0:
|
||||
return title
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
# Title generation timed out
|
||||
pass
|
||||
except Exception:
|
||||
# Any other LLM error
|
||||
pass
|
||||
|
||||
except ImportError:
|
||||
# LLM module not available
|
||||
pass
|
||||
except Exception:
|
||||
# Unexpected error
|
||||
pass
|
||||
|
||||
return fallback_title
|
||||
|
||||
|
||||
def _create_fallback_title(user_msg: str, max_length: int) -> str:
|
||||
"""Create a fallback title from the user message.
|
||||
|
||||
Args:
|
||||
user_msg: User message to base title on
|
||||
max_length: Maximum length for the title
|
||||
|
||||
Returns:
|
||||
Simple title based on user message
|
||||
"""
|
||||
if not user_msg or not user_msg.strip():
|
||||
return "Chat Session"
|
||||
|
||||
# Clean up the message
|
||||
clean_msg = user_msg.strip()
|
||||
|
||||
# Remove common question words and make it more title-like
|
||||
clean_msg = clean_msg.replace('?', '')
|
||||
clean_msg = clean_msg.replace('!', '')
|
||||
|
||||
# Take first sentence or reasonable chunk
|
||||
if '.' in clean_msg:
|
||||
clean_msg = clean_msg.split('.')[0]
|
||||
|
||||
# Truncate if too long
|
||||
if len(clean_msg) > max_length:
|
||||
clean_msg = clean_msg[:max_length-3] + "..."
|
||||
|
||||
return clean_msg if clean_msg else "Chat Session"
|
||||
@@ -1,7 +1,10 @@
|
||||
"""SkillManager for Agent Skills integration."""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional, Dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from .models import SkillMetadata
|
||||
from .discovery import discover_skills
|
||||
from .loader import SkillLoader, LoadedSkill
|
||||
@@ -243,6 +246,327 @@ class SkillManager:
|
||||
self._loader.load_all_resources(skill)
|
||||
return True
|
||||
|
||||
def create_skill(self, name: str, content: str, category: str = None) -> dict:
|
||||
"""Create a new skill with the given content.
|
||||
|
||||
Args:
|
||||
name: Skill name (must be valid identifier)
|
||||
content: Skill instructions/content for SKILL.md
|
||||
category: Optional skill category
|
||||
|
||||
Returns:
|
||||
Dict with success status and skill info
|
||||
"""
|
||||
try:
|
||||
# Validate name
|
||||
if not self._validate_skill_name(name):
|
||||
return {"success": False, "error": f"Invalid skill name: {name}"}
|
||||
|
||||
# Check if skill already exists
|
||||
if name in self._skills:
|
||||
return {"success": False, "error": f"Skill '{name}' already exists"}
|
||||
|
||||
# Validate content size
|
||||
if len(content) > 100_000:
|
||||
return {"success": False, "error": "Skill content exceeds maximum size (100KB)"}
|
||||
|
||||
# Create skill directory
|
||||
from .discovery import get_default_skill_directories
|
||||
skill_dirs = get_default_skill_directories()
|
||||
base_dir = skill_dirs[0] if skill_dirs else "~/.praisonai/skills"
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
base_path = Path(base_dir).expanduser()
|
||||
base_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
skill_path = base_path / name
|
||||
skill_path.mkdir(exist_ok=True)
|
||||
|
||||
# Write SKILL.md with frontmatter
|
||||
skill_content = f"""---
|
||||
name: {name}
|
||||
version: 1.0.0
|
||||
"""
|
||||
if category:
|
||||
skill_content += f"category: {category}\n"
|
||||
skill_content += f"""description: Generated skill
|
||||
author: agent
|
||||
---
|
||||
|
||||
{content}
|
||||
"""
|
||||
|
||||
skill_file = skill_path / "SKILL.md"
|
||||
self._write_skill_atomically(skill_file, skill_content)
|
||||
|
||||
# Load the new skill
|
||||
skill = self.add_skill(str(skill_path))
|
||||
if skill:
|
||||
return {"success": True, "skill": skill.properties.name, "path": str(skill_path)}
|
||||
else:
|
||||
return {"success": False, "error": "Failed to load created skill"}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Error creating skill: {str(e)}"}
|
||||
|
||||
def edit_skill(self, name: str, content: str) -> dict:
|
||||
"""Edit an existing skill's content (full rewrite).
|
||||
|
||||
Args:
|
||||
name: Skill name to edit
|
||||
content: New skill content
|
||||
|
||||
Returns:
|
||||
Dict with success status
|
||||
"""
|
||||
try:
|
||||
skill = self.get_skill(name)
|
||||
if not skill:
|
||||
return {"success": False, "error": f"Skill '{name}' not found"}
|
||||
|
||||
if not skill.properties.path:
|
||||
return {"success": False, "error": f"Cannot edit skill '{name}' - no path available"}
|
||||
|
||||
# Validate content size
|
||||
if len(content) > 100_000:
|
||||
return {"success": False, "error": "Skill content exceeds maximum size (100KB)"}
|
||||
|
||||
# Read existing frontmatter
|
||||
skill_file = skill.properties.path / "SKILL.md"
|
||||
if not skill_file.exists():
|
||||
return {"success": False, "error": f"Skill file not found: {skill_file}"}
|
||||
|
||||
with open(skill_file, 'r', encoding='utf-8') as f:
|
||||
existing_content = f.read()
|
||||
|
||||
# Extract frontmatter (preserve verbatim, only strip leading fence)
|
||||
frontmatter = ""
|
||||
if existing_content.startswith('---\n'):
|
||||
parts = existing_content.split('\n---\n', 1)
|
||||
if len(parts) == 2:
|
||||
fm_body = parts[0].removeprefix('---\n')
|
||||
frontmatter = f"---\n{fm_body}\n---\n\n"
|
||||
|
||||
# Write updated content
|
||||
new_content = frontmatter + content
|
||||
self._write_skill_atomically(skill_file, new_content)
|
||||
|
||||
# Reload the skill
|
||||
skill.instructions = None # Clear cached content
|
||||
self.activate(skill)
|
||||
|
||||
return {"success": True, "skill": name}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Error editing skill: {str(e)}"}
|
||||
|
||||
def patch_skill(self, name: str, old_string: str, new_string: str,
|
||||
file_path: str = None, replace_all: bool = False) -> dict:
|
||||
"""Apply a patch to a skill using fuzzy find-and-replace.
|
||||
|
||||
Args:
|
||||
name: Skill name to patch
|
||||
old_string: String to find and replace
|
||||
new_string: Replacement string
|
||||
file_path: Optional relative path within skill (defaults to SKILL.md)
|
||||
replace_all: Replace all occurrences
|
||||
|
||||
Returns:
|
||||
Dict with success status
|
||||
"""
|
||||
try:
|
||||
skill = self.get_skill(name)
|
||||
if not skill:
|
||||
return {"success": False, "error": f"Skill '{name}' not found"}
|
||||
|
||||
if not skill.properties.path:
|
||||
return {"success": False, "error": f"Cannot patch skill '{name}' - no path available"}
|
||||
|
||||
# Determine target file
|
||||
if file_path:
|
||||
from pathlib import Path
|
||||
relative_path = Path(file_path)
|
||||
if relative_path.is_absolute() or ".." in relative_path.parts:
|
||||
return {"success": False, "error": f"Path traversal detected: {file_path}"}
|
||||
target_file = (skill.properties.path / relative_path).resolve()
|
||||
try:
|
||||
target_file.relative_to(skill.properties.path.resolve())
|
||||
except ValueError:
|
||||
return {"success": False, "error": f"Path traversal detected: {file_path}"}
|
||||
else:
|
||||
target_file = skill.properties.path / "SKILL.md"
|
||||
|
||||
if not target_file.exists():
|
||||
return {"success": False, "error": f"File not found: {target_file}"}
|
||||
|
||||
# Perform fuzzy find and replace
|
||||
with open(target_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Simple string replacement
|
||||
if old_string in content:
|
||||
if replace_all:
|
||||
new_content = content.replace(old_string, new_string)
|
||||
else:
|
||||
# Replace only first occurrence
|
||||
new_content = content.replace(old_string, new_string, 1)
|
||||
|
||||
self._write_skill_atomically(target_file, new_content)
|
||||
|
||||
# Clear cached content if SKILL.md was modified
|
||||
if file_path is None or file_path == "SKILL.md":
|
||||
skill.instructions = None
|
||||
self.activate(skill)
|
||||
|
||||
return {"success": True, "skill": name, "replacements": 1}
|
||||
else:
|
||||
return {"success": False, "error": f"String not found: '{old_string[:50]}...'"}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Error patching skill: {str(e)}"}
|
||||
|
||||
def delete_skill(self, name: str) -> dict:
|
||||
"""Delete a skill and its directory.
|
||||
|
||||
Args:
|
||||
name: Skill name to delete
|
||||
|
||||
Returns:
|
||||
Dict with success status
|
||||
"""
|
||||
try:
|
||||
skill = self.get_skill(name)
|
||||
if not skill:
|
||||
return {"success": False, "error": f"Skill '{name}' not found"}
|
||||
|
||||
if not skill.properties.path:
|
||||
return {"success": False, "error": f"Cannot delete skill '{name}' - no path available"}
|
||||
|
||||
# Remove directory first, then update in-memory index.
|
||||
import shutil
|
||||
shutil.rmtree(skill.properties.path)
|
||||
self._skills.pop(name, None)
|
||||
|
||||
return {"success": True, "skill": name, "path": str(skill.properties.path)}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Error deleting skill: {str(e)}"}
|
||||
|
||||
def write_skill_file(self, name: str, file_path: str, file_content: str) -> dict:
|
||||
"""Write a file within a skill's directory.
|
||||
|
||||
Args:
|
||||
name: Skill name
|
||||
file_path: Relative path within skill (must be under allowed subdirs)
|
||||
file_content: File content to write
|
||||
|
||||
Returns:
|
||||
Dict with success status
|
||||
"""
|
||||
try:
|
||||
skill = self.get_skill(name)
|
||||
if not skill:
|
||||
return {"success": False, "error": f"Skill '{name}' not found"}
|
||||
|
||||
if not skill.properties.path:
|
||||
return {"success": False, "error": f"Cannot write to skill '{name}' - no path available"}
|
||||
|
||||
# Validate file path is in allowed subdirectories
|
||||
from pathlib import Path
|
||||
allowed_subdirs = ['references', 'templates', 'scripts', 'assets']
|
||||
relative_path = Path(file_path)
|
||||
path_parts = relative_path.parts
|
||||
if relative_path.is_absolute() or ".." in path_parts:
|
||||
return {"success": False, "error": f"Path traversal detected: {file_path}"}
|
||||
if not path_parts or path_parts[0] not in allowed_subdirs:
|
||||
return {"success": False, "error": f"File path must be under: {allowed_subdirs}"}
|
||||
|
||||
# Validate file size
|
||||
if len(file_content) > 1_048_576: # 1 MB
|
||||
return {"success": False, "error": "File content exceeds maximum size (1MB)"}
|
||||
|
||||
# Create target file
|
||||
target_file = (skill.properties.path / relative_path).resolve()
|
||||
try:
|
||||
target_file.relative_to(skill.properties.path.resolve())
|
||||
except ValueError:
|
||||
return {"success": False, "error": f"Path traversal detected: {file_path}"}
|
||||
target_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._write_skill_atomically(target_file, file_content)
|
||||
|
||||
return {"success": True, "skill": name, "file": file_path}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Error writing skill file: {str(e)}"}
|
||||
|
||||
def remove_skill_file(self, name: str, file_path: str) -> dict:
|
||||
"""Remove a file from a skill's directory.
|
||||
|
||||
Args:
|
||||
name: Skill name
|
||||
file_path: Relative path within skill to remove
|
||||
|
||||
Returns:
|
||||
Dict with success status
|
||||
"""
|
||||
try:
|
||||
skill = self.get_skill(name)
|
||||
if not skill:
|
||||
return {"success": False, "error": f"Skill '{name}' not found"}
|
||||
|
||||
if not skill.properties.path:
|
||||
return {"success": False, "error": f"Cannot remove from skill '{name}' - no path available"}
|
||||
|
||||
target_file = skill.properties.path / file_path
|
||||
if not target_file.exists():
|
||||
return {"success": False, "error": f"File not found: {file_path}"}
|
||||
|
||||
# Security check - file must be within skill directory
|
||||
try:
|
||||
target_file.resolve().relative_to(skill.properties.path.resolve())
|
||||
except ValueError:
|
||||
return {"success": False, "error": f"Path traversal detected: {file_path}"}
|
||||
|
||||
target_file.unlink()
|
||||
|
||||
return {"success": True, "skill": name, "file": file_path}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Error removing skill file: {str(e)}"}
|
||||
|
||||
def _validate_skill_name(self, name: str) -> bool:
|
||||
"""Validate skill name according to security constraints."""
|
||||
import re
|
||||
if len(name) > 64:
|
||||
return False
|
||||
# Allow letters, numbers, dots, underscores, hyphens
|
||||
return bool(re.match(r'^[a-z0-9][a-z0-9._-]*$', name))
|
||||
|
||||
def _write_skill_atomically(self, file_path, content: str) -> None:
|
||||
"""Write file content atomically using temp file + rename."""
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
# Create temp file in same directory
|
||||
temp_fd, temp_path = tempfile.mkstemp(
|
||||
dir=file_path.parent,
|
||||
prefix=f".{file_path.name}.",
|
||||
suffix=".tmp"
|
||||
)
|
||||
|
||||
try:
|
||||
with os.fdopen(temp_fd, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
os.replace(temp_path, file_path)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(temp_path)
|
||||
except OSError:
|
||||
logger.debug("Failed to clean up temp file %s", temp_path, exc_info=True)
|
||||
raise
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all loaded skills."""
|
||||
self._skills.clear()
|
||||
|
||||
@@ -15,6 +15,9 @@ from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional, Protocol
|
||||
import time
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StreamEventType(Enum):
|
||||
@@ -242,9 +245,9 @@ class StreamEventEmitter:
|
||||
for callback in self._callbacks:
|
||||
try:
|
||||
callback(event)
|
||||
except Exception:
|
||||
# Don't let callback errors break streaming
|
||||
pass
|
||||
except Exception as e:
|
||||
# Log callback errors but don't let them break streaming
|
||||
logger.warning("Streaming callback failed: %s", e, exc_info=True)
|
||||
|
||||
async def emit_async(self, event: StreamEvent) -> None:
|
||||
"""
|
||||
@@ -260,15 +263,17 @@ class StreamEventEmitter:
|
||||
for callback in self._callbacks:
|
||||
try:
|
||||
callback(event)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
# Log callback errors but don't let them break streaming
|
||||
logger.warning("Streaming sync callback failed in async context: %s", e, exc_info=True)
|
||||
|
||||
# Call async callbacks
|
||||
for callback in self._async_callbacks:
|
||||
try:
|
||||
await callback(event)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
# Log callback errors but don't let them break streaming
|
||||
logger.warning("Streaming async callback failed: %s", e, exc_info=True)
|
||||
|
||||
@property
|
||||
def has_callbacks(self) -> bool:
|
||||
|
||||
@@ -128,6 +128,9 @@ class Task:
|
||||
caching: Optional[Any] = None,
|
||||
# Output variable name for workflow variable assignment
|
||||
output_variable: Optional[str] = None,
|
||||
# Failure handling policy configuration
|
||||
fail_on_callback_error: bool = False,
|
||||
fail_on_memory_error: bool = False,
|
||||
):
|
||||
# Add check if memory config is provided
|
||||
if memory is not None or (config and config.get('memory_config')):
|
||||
@@ -221,6 +224,11 @@ class Task:
|
||||
self.validation_feedback = None # Store validation failure feedback for retry attempts
|
||||
self.agent_config = agent_config # Per-task agent configuration {role, goal, backstory, llm}
|
||||
self.variables = variables if variables else {} # Variables for substitution in description
|
||||
self.non_fatal_errors = [] # Accumulate non-fatal errors for visibility
|
||||
|
||||
# Failure handling policy configuration
|
||||
self.fail_on_callback_error = fail_on_callback_error
|
||||
self.fail_on_memory_error = fail_on_memory_error
|
||||
|
||||
# ============================================================
|
||||
# ROBUSTNESS PARAMS (graceful degradation & retry control)
|
||||
@@ -529,24 +537,99 @@ class Task:
|
||||
}
|
||||
|
||||
def initialize_memory(self):
|
||||
"""Initialize memory if config exists but memory doesn't"""
|
||||
"""Synchronous memory initialization with thread-safe locking"""
|
||||
if not self.memory and self.config.get('memory_config'):
|
||||
try:
|
||||
from ..memory.memory import Memory
|
||||
logger.info(f"Task {self.id}: Initializing memory from config: {self.config['memory_config']}")
|
||||
self.memory = Memory(config=self.config['memory_config'])
|
||||
logger.info(f"Task {self.id}: Memory initialized successfully")
|
||||
# Thread-safe memory initialization using double-checked locking
|
||||
import threading
|
||||
if not hasattr(self, '_memory_init_lock'):
|
||||
self._memory_init_lock = threading.Lock()
|
||||
|
||||
with self._memory_init_lock:
|
||||
# Double-check after acquiring lock
|
||||
if not self.memory:
|
||||
try:
|
||||
from ..memory.memory import Memory
|
||||
logger.info(f"Task {self.id}: Initializing memory from config: {self.config['memory_config']}")
|
||||
self.memory = Memory(config=self.config['memory_config'])
|
||||
logger.info(f"Task {self.id}: Memory initialized successfully")
|
||||
|
||||
# Verify database was created
|
||||
if os.path.exists(self.config['memory_config']['storage']['path']):
|
||||
logger.info(f"Task {self.id}: Memory database exists after initialization")
|
||||
else:
|
||||
logger.error(f"Task {self.id}: Failed to create memory database!")
|
||||
return self.memory
|
||||
except Exception as e:
|
||||
logger.error(f"Task {self.id}: Failed to initialize memory: {e}")
|
||||
logger.exception(e)
|
||||
# Verify database was created (defensive config access)
|
||||
storage_path = (
|
||||
self.config.get('memory_config', {})
|
||||
.get('storage', {})
|
||||
.get('path')
|
||||
)
|
||||
if storage_path:
|
||||
if os.path.exists(storage_path):
|
||||
logger.info(f"Task {self.id}: Memory database exists after initialization")
|
||||
else:
|
||||
logger.error(f"Task {self.id}: Failed to create memory database!")
|
||||
except Exception as e:
|
||||
logger.error(f"Task {self.id}: Failed to initialize memory: {e}")
|
||||
logger.exception(e)
|
||||
return None
|
||||
return self.memory
|
||||
return None
|
||||
|
||||
async def initialize_memory_async(self):
|
||||
"""Async-safe memory initialization with asyncio coordination"""
|
||||
if not self.memory and self.config.get('memory_config'):
|
||||
import asyncio
|
||||
# Initialize async lock once in __init__ if needed
|
||||
if not hasattr(self, '_memory_init_lock_async'):
|
||||
self._memory_init_lock_async = asyncio.Lock()
|
||||
|
||||
async with self._memory_init_lock_async:
|
||||
# Double-check after acquiring lock
|
||||
if not self.memory:
|
||||
try:
|
||||
# Use asyncio.to_thread to avoid blocking the event loop
|
||||
def create_memory():
|
||||
from ..memory.memory import Memory
|
||||
return Memory(config=self.config['memory_config'])
|
||||
|
||||
logger.info(f"Task {self.id}: Initializing memory from config: {self.config['memory_config']}")
|
||||
self.memory = await asyncio.to_thread(create_memory)
|
||||
logger.info(f"Task {self.id}: Memory initialized successfully")
|
||||
|
||||
# Verify database was created (defensive config access)
|
||||
storage_path = (
|
||||
self.config.get('memory_config', {})
|
||||
.get('storage', {})
|
||||
.get('path')
|
||||
)
|
||||
if storage_path:
|
||||
if os.path.exists(storage_path):
|
||||
logger.info(f"Task {self.id}: Memory database exists after initialization")
|
||||
else:
|
||||
logger.error(f"Task {self.id}: Failed to create memory database!")
|
||||
except Exception as e:
|
||||
logger.error(f"Task {self.id}: Failed to initialize memory: {e}")
|
||||
logger.exception(e)
|
||||
return None
|
||||
return self.memory
|
||||
return None
|
||||
|
||||
def _verify_memory_ready(self) -> bool:
|
||||
"""Verify that memory is properly initialized and ready for operations."""
|
||||
if not self.memory:
|
||||
return False
|
||||
|
||||
# Check if memory has required adapters available
|
||||
try:
|
||||
# Check if memory adapter is available for basic operations
|
||||
has_adapter = hasattr(self.memory, 'memory_adapter') and self.memory.memory_adapter is not None
|
||||
# Also check for SQLite fallback
|
||||
has_sqlite = hasattr(self.memory, '_sqlite_adapter') and self.memory._sqlite_adapter is not None
|
||||
|
||||
if not (has_adapter or has_sqlite):
|
||||
logger.warning(f"Task {self.id}: Memory initialized but no adapter available — check memory configuration")
|
||||
|
||||
return has_adapter or has_sqlite
|
||||
except Exception as e:
|
||||
# Surface configuration errors instead of hiding them
|
||||
logger.error(f"Task {self.id}: Memory readiness check failed: {e}")
|
||||
return False
|
||||
|
||||
def store_in_memory(self, content: str, agent_name: str = None, task_id: str = None):
|
||||
"""Store content in memory with metadata"""
|
||||
@@ -563,51 +646,27 @@ class Task:
|
||||
)
|
||||
logger.info(f"Task {self.id}: Content stored in memory")
|
||||
except Exception as e:
|
||||
error_msg = f"store_in_memory: {e}"
|
||||
self.non_fatal_errors.append(error_msg)
|
||||
logger.error(f"Task {self.id}: Failed to store content in memory: {e}")
|
||||
logger.exception(e)
|
||||
if self.fail_on_memory_error:
|
||||
raise
|
||||
|
||||
async def execute_callback(self, task_output: TaskOutput) -> None:
|
||||
"""Execute callback and store quality metrics if enabled"""
|
||||
logger.info(f"Task {self.id}: execute_callback called")
|
||||
logger.info(f"Quality check enabled: {self.quality_check}")
|
||||
|
||||
# Process guardrail if configured
|
||||
if self._guardrail_fn:
|
||||
try:
|
||||
guardrail_result = self._process_guardrail(task_output)
|
||||
if not guardrail_result.success:
|
||||
if self.retry_count >= self.max_retries:
|
||||
raise Exception(
|
||||
f"Task failed guardrail validation after {self.max_retries} retries. "
|
||||
f"Last error: {guardrail_result.error}"
|
||||
)
|
||||
|
||||
self.retry_count += 1
|
||||
logger.warning(f"Task {self.id}: Guardrail validation failed (retry {self.retry_count}/{self.max_retries}): {guardrail_result.error}")
|
||||
# Note: In a real execution, this would trigger a retry, but since this is a callback
|
||||
# the retry logic would need to be handled at the agent/execution level
|
||||
return
|
||||
|
||||
# If guardrail passed and returned a modified result
|
||||
if guardrail_result.result is not None:
|
||||
if isinstance(guardrail_result.result, str):
|
||||
# Update the task output with the modified result
|
||||
task_output.raw = guardrail_result.result
|
||||
elif isinstance(guardrail_result.result, TaskOutput):
|
||||
# Replace with the new task output
|
||||
task_output = guardrail_result.result
|
||||
|
||||
logger.info(f"Task {self.id}: Guardrail validation passed")
|
||||
except Exception as e:
|
||||
logger.error(f"Task {self.id}: Error in guardrail processing: {e}")
|
||||
# Continue execution even if guardrail fails to avoid breaking the task
|
||||
# Note: Guardrail validation has been moved to the execution path in agents.py
|
||||
# to ensure proper retry behavior. This callback now only handles memory/user callbacks.
|
||||
|
||||
# Initialize memory if not already initialized
|
||||
if not self.memory:
|
||||
self.memory = self.initialize_memory()
|
||||
self.memory = await self.initialize_memory_async()
|
||||
|
||||
logger.info(f"Memory object exists: {self.memory is not None}")
|
||||
if self.memory:
|
||||
if self.memory and self._verify_memory_ready():
|
||||
logger.info(f"Memory config: {self.memory.cfg}")
|
||||
# Store task output in memory
|
||||
try:
|
||||
@@ -621,10 +680,13 @@ class Task:
|
||||
except Exception as e:
|
||||
logger.error(f"Task {self.id}: Failed to store task output in memory: {e}")
|
||||
logger.exception(e)
|
||||
# store_in_memory already appended to non_fatal_errors; respect policy
|
||||
if self.fail_on_memory_error:
|
||||
raise
|
||||
|
||||
logger.info(f"Task output: {task_output.raw[:100]}...")
|
||||
|
||||
if self.quality_check and self.memory:
|
||||
if self.quality_check and self.memory and self._verify_memory_ready():
|
||||
try:
|
||||
logger.info(f"Task {self.id}: Starting memory operations")
|
||||
logger.info(f"Task {self.id}: Calculating quality metrics for output: {task_output.raw[:100]}...")
|
||||
@@ -643,7 +705,7 @@ class Task:
|
||||
elif hasattr(self.agent, 'llm') and self.agent.llm:
|
||||
# For standard model strings
|
||||
llm_model = self.agent.llm
|
||||
|
||||
|
||||
metrics = self.memory.calculate_quality_metrics(
|
||||
task_output.raw,
|
||||
self.expected_output,
|
||||
@@ -675,14 +737,6 @@ class Task:
|
||||
metrics=metrics
|
||||
)
|
||||
|
||||
# Store in both short and long-term memory with higher threshold
|
||||
self.memory.finalize_task_output(
|
||||
content=task_output.raw,
|
||||
agent_name=self.agent.name if self.agent else "Agent",
|
||||
quality_score=quality_score,
|
||||
threshold=0.7 # Only high quality outputs in long-term memory
|
||||
)
|
||||
|
||||
# Build context for next tasks
|
||||
if self.next_tasks:
|
||||
logger.info(f"Task {self.id}: Building context for next tasks...")
|
||||
@@ -694,17 +748,37 @@ class Task:
|
||||
|
||||
logger.info(f"Task {self.id}: Memory operations complete")
|
||||
except Exception as e:
|
||||
error_msg = f"memory operations: {e}"
|
||||
self.non_fatal_errors.append(error_msg)
|
||||
logger.error(f"Task {self.id}: Failed to process memory operations: {e}")
|
||||
logger.exception(e) # Print full stack trace
|
||||
# Continue execution even if memory operations fail
|
||||
elif self.quality_check and self.config.get('memory_config'):
|
||||
# Only log as error if quality_check was enabled AND memory was explicitly requested via config
|
||||
logger.warning(f"Task {self.id}: Memory requested for quality checks but not available, skipping memory operations")
|
||||
self.non_fatal_errors.append("memory requested for quality checks but not available")
|
||||
|
||||
# Execute original callback with metadata support
|
||||
if self.callback:
|
||||
try:
|
||||
await self._execute_callback_with_metadata(task_output)
|
||||
except Exception as e:
|
||||
error_msg = f"callback: {e}"
|
||||
self.non_fatal_errors.append(error_msg)
|
||||
logger.error(f"Task {self.id}: Failed to execute callback: {e}")
|
||||
logger.exception(e)
|
||||
# Attach error to output for workflow orchestrator visibility
|
||||
task_output.callback_error = str(e)
|
||||
if self.fail_on_callback_error:
|
||||
# Attach errors before re-raising
|
||||
if self.non_fatal_errors:
|
||||
task_output.non_fatal_errors = list(self.non_fatal_errors)
|
||||
raise
|
||||
# Attach non_fatal_errors to output if not already attached
|
||||
# (TaskOutput.non_fatal_errors is a Pydantic field that defaults to None,
|
||||
# so check the value, not field existence)
|
||||
if self.non_fatal_errors and getattr(task_output, 'non_fatal_errors', None) is None:
|
||||
task_output.non_fatal_errors = list(self.non_fatal_errors)
|
||||
|
||||
task_prompt = f"""
|
||||
You need to do the following task: {self.description}.
|
||||
@@ -738,20 +812,12 @@ Context:
|
||||
|
||||
def execute_callback_sync(self, task_output: TaskOutput) -> None:
|
||||
"""
|
||||
Synchronous wrapper to ensure that execute_callback is awaited,
|
||||
preventing 'Task was destroyed but pending!' warnings if called
|
||||
from non-async code.
|
||||
Synchronous wrapper that properly awaits execute_callback instead of
|
||||
using fire-and-forget create_task() to prevent lost exceptions.
|
||||
"""
|
||||
import asyncio
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
if loop.is_running():
|
||||
loop.create_task(self.execute_callback(task_output))
|
||||
else:
|
||||
loop.run_until_complete(self.execute_callback(task_output))
|
||||
except RuntimeError:
|
||||
# If no loop is running in this context
|
||||
asyncio.run(self.execute_callback(task_output))
|
||||
from ..utils.async_bridge import run_coroutine_from_any_context
|
||||
# Always use the async bridge for proper exception propagation
|
||||
run_coroutine_from_any_context(self.execute_callback(task_output))
|
||||
|
||||
def _process_guardrail(self, task_output: TaskOutput):
|
||||
"""Process the guardrail validation for a task output.
|
||||
@@ -891,4 +957,4 @@ Context:
|
||||
if self.evaluate_when(context):
|
||||
return self.then_task
|
||||
else:
|
||||
return self.else_task
|
||||
return self.else_task
|
||||
@@ -10,7 +10,7 @@ _tools_lazy_cache = {}
|
||||
# Export core tool items for organized imports (lightweight)
|
||||
from .base import BaseTool, ToolResult, ToolValidationError, validate_tool
|
||||
from .decorator import tool, FunctionTool
|
||||
from .registry import get_registry, register_tool, get_tool, add_tool, has_tool, remove_tool, list_tools, ToolRegistry
|
||||
from .registry import get_registry, register_tool, get_tool, add_tool, has_tool, remove_tool, list_tools, list_available_tools, ToolRegistry
|
||||
from .tools import Tools
|
||||
|
||||
# Export Injected type directly for easy access
|
||||
@@ -75,12 +75,12 @@ TOOL_MAPPINGS = {
|
||||
|
||||
|
||||
# Python Tools
|
||||
'execute_code': ('.python_tools', 'PythonTools'),
|
||||
'analyze_code': ('.python_tools', 'PythonTools'),
|
||||
'format_code': ('.python_tools', 'PythonTools'),
|
||||
'lint_code': ('.python_tools', 'PythonTools'),
|
||||
'disassemble_code': ('.python_tools', 'PythonTools'),
|
||||
'python_tools': ('.python_tools', 'PythonTools'),
|
||||
'execute_code': ('.python_tools', None),
|
||||
'analyze_code': ('.python_tools', None),
|
||||
'format_code': ('.python_tools', None),
|
||||
'lint_code': ('.python_tools', None),
|
||||
'disassemble_code': ('.python_tools', None),
|
||||
'python_tools': ('.python_tools', None),
|
||||
|
||||
|
||||
# Chain of Thought Training Tools
|
||||
@@ -204,6 +204,13 @@ TOOL_MAPPINGS = {
|
||||
'smtp_send_email': ('.email_tools', None),
|
||||
'smtp_read_inbox': ('.email_tools', None),
|
||||
'email_tools': ('.email_tools', None),
|
||||
|
||||
# Clarify tool
|
||||
'clarify': ('.clarify', None), # Direct import of clarify instance
|
||||
'ClarifyTool': ('.clarify', 'ClarifyTool'),
|
||||
'ClarifyHandler': ('.clarify', 'ClarifyHandler'),
|
||||
'create_cli_clarify_handler': ('.clarify', 'create_cli_clarify_handler'),
|
||||
'create_bot_clarify_handler': ('.clarify', 'create_bot_clarify_handler'),
|
||||
}
|
||||
|
||||
# Tool factory functions - caches classes but creates fresh instances
|
||||
@@ -311,13 +318,15 @@ def __getattr__(name: str) -> Any:
|
||||
module = import_module(module_path, __package__)
|
||||
return getattr(module, class_name)
|
||||
|
||||
# Remove the special case since it's now handled by None class_name
|
||||
|
||||
if class_name is None:
|
||||
# Direct function import
|
||||
module = import_module(module_path, __package__)
|
||||
if name in [
|
||||
'duckduckgo', 'internet_search', 'searxng_search', 'searxng',
|
||||
'scrape_page', 'extract_links', 'crawl', 'extract_text',
|
||||
'execute_command', 'list_processes', 'kill_process', 'get_system_info',
|
||||
'execute_command', 'execute_code', 'analyze_code', 'format_code', 'lint_code', 'disassemble_code', 'list_processes', 'kill_process', 'get_system_info',
|
||||
'tavily', 'tavily_search', 'tavily_extract', 'tavily_crawl', 'tavily_map',
|
||||
'tavily_search_async', 'tavily_extract_async',
|
||||
'ydc', 'ydc_search', 'ydc_contents', 'ydc_news', 'ydc_images',
|
||||
@@ -334,7 +343,9 @@ def __getattr__(name: str) -> Any:
|
||||
'store_memory', 'search_memory',
|
||||
'store_learning', 'search_learning',
|
||||
'send_email', 'list_emails', 'read_email', 'reply_email', 'list_inboxes', 'create_inbox',
|
||||
'smtp_send_email', 'smtp_read_inbox'
|
||||
'smtp_send_email', 'smtp_read_inbox',
|
||||
'create_cli_clarify_handler', 'create_bot_clarify_handler',
|
||||
'clarify'
|
||||
]:
|
||||
return getattr(module, name)
|
||||
if name in ['file_tools', 'spider_tools', 'python_tools', 'shell_tools', 'cot_tools', 'tavily_tools', 'youdotcom_tools', 'exa_tools', 'crawl4ai_tools', 'skill_tools', 'github_tools', 'schedule_tools', 'ast_grep_tools', 'email_tools']:
|
||||
@@ -351,7 +362,8 @@ __all__ = list(TOOL_MAPPINGS.keys()) + [
|
||||
'Injected', 'AgentState',
|
||||
'BaseTool', 'ToolResult', 'ToolValidationError', 'validate_tool',
|
||||
'tool', 'FunctionTool',
|
||||
'get_registry', 'register_tool', 'get_tool', 'add_tool', 'has_tool', 'remove_tool', 'list_tools', 'ToolRegistry',
|
||||
'get_registry', 'register_tool', 'get_tool', 'add_tool', 'has_tool', 'remove_tool',
|
||||
'list_tools', 'list_available_tools', 'ToolRegistry',
|
||||
'Tools',
|
||||
# Validation and retry protocols
|
||||
'ValidationResult', 'ToolValidatorProtocol', 'AsyncToolValidatorProtocol', 'PassthroughValidator',
|
||||
|
||||
@@ -138,6 +138,21 @@ class ParallelToolCallExecutor:
|
||||
sequential_executor = SequentialToolCallExecutor()
|
||||
return sequential_executor.execute_batch(tool_calls, execute_tool_fn)
|
||||
|
||||
# G4: Check for path conflicts - fallback to sequential if conflicts detected
|
||||
try:
|
||||
from .path_overlap import has_write_conflicts
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"Path conflict detection unavailable; using sequential execution for safety"
|
||||
)
|
||||
sequential_executor = SequentialToolCallExecutor()
|
||||
return sequential_executor.execute_batch(tool_calls, execute_tool_fn)
|
||||
|
||||
if has_write_conflicts(tool_calls):
|
||||
logger.info(f"Path conflicts detected in {len(tool_calls)} tool calls, using sequential execution")
|
||||
sequential_executor = SequentialToolCallExecutor()
|
||||
return sequential_executor.execute_batch(tool_calls, execute_tool_fn)
|
||||
|
||||
def _execute_single_tool(tool_call: ToolCall) -> ToolResult:
|
||||
"""Execute a single tool call with error handling."""
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"""
|
||||
Clarify Tool for PraisonAI Agents.
|
||||
|
||||
Allows agents to ask clarifying questions when they need more information
|
||||
to proceed with a task. The tool pauses execution and waits for user input.
|
||||
|
||||
This is a first-class tool that integrates with different contexts:
|
||||
- CLI: Uses stdin prompt
|
||||
- Bot: Sends message and waits for reply
|
||||
- UI: Uses interactive components
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional, List, Any, Dict, Callable
|
||||
|
||||
from .base import BaseTool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ClarifyHandler:
|
||||
"""Handler for clarify requests in different contexts."""
|
||||
|
||||
def __init__(self, ask_callback: Optional[Callable[[str, Optional[List[str]]], str]] = None):
|
||||
self.ask_callback = ask_callback
|
||||
|
||||
async def ask(self, question: str, choices: Optional[List[str]] = None) -> str:
|
||||
"""Ask a clarifying question and wait for response.
|
||||
|
||||
Args:
|
||||
question: The question to ask the user
|
||||
choices: Optional list of predefined choices
|
||||
|
||||
Returns:
|
||||
User's response
|
||||
"""
|
||||
if self.ask_callback:
|
||||
try:
|
||||
result = self.ask_callback(question, choices)
|
||||
# Handle both sync and async callbacks
|
||||
if hasattr(result, '__await__'):
|
||||
return await result
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning(f"Clarify handler callback failed: {e}")
|
||||
|
||||
# Fallback: return instruction to proceed with best guess
|
||||
return f"No interactive channel available. Please proceed with your best judgment for: {question}"
|
||||
|
||||
|
||||
class ClarifyTool(BaseTool):
|
||||
"""Tool that allows agents to ask clarifying questions.
|
||||
|
||||
This tool pauses the agent's execution and requests input from the user
|
||||
through the appropriate channel (CLI, bot, UI, etc.).
|
||||
"""
|
||||
|
||||
name = "clarify"
|
||||
description = (
|
||||
"Ask the user a focused clarifying question when genuinely ambiguous. "
|
||||
"Use sparingly - only when you cannot proceed without their input."
|
||||
)
|
||||
|
||||
def __init__(self, handler: Optional[ClarifyHandler] = None):
|
||||
super().__init__()
|
||||
self.handler = handler or ClarifyHandler()
|
||||
|
||||
async def run(
|
||||
self,
|
||||
question: str,
|
||||
choices: Optional[List[str]] = None,
|
||||
**kwargs
|
||||
) -> str:
|
||||
"""Ask a clarifying question and return the user's response.
|
||||
|
||||
Args:
|
||||
question: The question to ask
|
||||
choices: Optional list of predefined choices
|
||||
**kwargs: Additional context (may contain 'ctx' with agent context)
|
||||
|
||||
Returns:
|
||||
User's response or fallback instruction
|
||||
"""
|
||||
logger.debug(f"Clarify tool asking: {question}")
|
||||
|
||||
# Check if there's a clarify handler in the context
|
||||
ctx = kwargs.get('ctx')
|
||||
if ctx and hasattr(ctx, 'get'):
|
||||
context_handler = ctx.get('clarify_handler')
|
||||
if context_handler:
|
||||
if hasattr(context_handler, 'ask'):
|
||||
return await context_handler.ask(question, choices)
|
||||
elif callable(context_handler):
|
||||
result = context_handler(question, choices)
|
||||
if hasattr(result, '__await__'):
|
||||
return await result
|
||||
return result
|
||||
|
||||
# Use the tool's handler
|
||||
return await self.handler.ask(question, choices)
|
||||
|
||||
def get_schema(self) -> Dict[str, Any]:
|
||||
"""Get OpenAI-compatible schema for this tool."""
|
||||
schema = super().get_schema()
|
||||
|
||||
# Customize the schema to include choices parameter
|
||||
schema["function"]["parameters"]["properties"]["choices"] = {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional list of predefined answer choices"
|
||||
}
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
# Create a default instance for easy import
|
||||
clarify = ClarifyTool()
|
||||
|
||||
|
||||
def create_cli_clarify_handler() -> ClarifyHandler:
|
||||
"""Create a clarify handler for CLI usage."""
|
||||
|
||||
def cli_ask(question: str, choices: Optional[List[str]] = None) -> str:
|
||||
"""CLI implementation of clarify asking."""
|
||||
print(f"\n🤔 {question}")
|
||||
|
||||
if choices:
|
||||
print("\nChoices:")
|
||||
for i, choice in enumerate(choices, 1):
|
||||
print(f" {i}. {choice}")
|
||||
print()
|
||||
|
||||
while True:
|
||||
try:
|
||||
response = input("Your choice (number or text): ").strip()
|
||||
|
||||
# Check if it's a number choice
|
||||
if response.isdigit():
|
||||
idx = int(response) - 1
|
||||
if 0 <= idx < len(choices):
|
||||
return choices[idx]
|
||||
else:
|
||||
print(f"Please enter a number between 1 and {len(choices)}")
|
||||
continue
|
||||
|
||||
# Return the raw response
|
||||
return response
|
||||
|
||||
except KeyboardInterrupt:
|
||||
return "skip"
|
||||
except EOFError:
|
||||
return "skip"
|
||||
else:
|
||||
try:
|
||||
response = input("Your answer: ").strip()
|
||||
return response or "no answer provided"
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
return "skip"
|
||||
|
||||
return ClarifyHandler(ask_callback=cli_ask)
|
||||
|
||||
|
||||
def create_bot_clarify_handler(send_message_fn: Callable, wait_for_reply_fn: Callable) -> ClarifyHandler:
|
||||
"""Create a clarify handler for bot usage.
|
||||
|
||||
Args:
|
||||
send_message_fn: Function to send a message to the user
|
||||
wait_for_reply_fn: Function to wait for user reply
|
||||
"""
|
||||
|
||||
async def bot_ask(question: str, choices: Optional[List[str]] = None) -> str:
|
||||
"""Bot implementation of clarify asking."""
|
||||
formatted_question = f"🤔 {question}"
|
||||
|
||||
if choices:
|
||||
formatted_question += "\n\nChoices:\n"
|
||||
for i, choice in enumerate(choices, 1):
|
||||
formatted_question += f" {i}. {choice}\n"
|
||||
|
||||
await send_message_fn(formatted_question)
|
||||
response = await wait_for_reply_fn()
|
||||
|
||||
# Handle choice selection for bots
|
||||
if choices and response.isdigit():
|
||||
try:
|
||||
idx = int(response) - 1
|
||||
if 0 <= idx < len(choices):
|
||||
return choices[idx]
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return response or "no answer provided"
|
||||
|
||||
return ClarifyHandler(ask_callback=bot_ask)
|
||||
|
||||
|
||||
# Export the tool for registration
|
||||
__all__ = ['clarify', 'ClarifyTool', 'ClarifyHandler', 'create_cli_clarify_handler', 'create_bot_clarify_handler']
|
||||
@@ -67,12 +67,14 @@ class FunctionTool(BaseTool):
|
||||
func: Callable,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
version: str = "1.0.0"
|
||||
version: str = "1.0.0",
|
||||
availability: Optional[Callable[[], tuple[bool, str]]] = None
|
||||
):
|
||||
self._func = func
|
||||
self.name = name or func.__name__
|
||||
self.description = description or func.__doc__ or f"Tool: {self.name}"
|
||||
self.version = version
|
||||
self._availability = availability
|
||||
|
||||
# Detect injected parameters
|
||||
self._injected_params = get_injected_params(func)
|
||||
@@ -151,6 +153,21 @@ class FunctionTool(BaseTool):
|
||||
# Inject state for any Injected parameters
|
||||
kwargs = inject_state_into_kwargs(kwargs, self._injected_params)
|
||||
return self._func(*args, **kwargs)
|
||||
|
||||
def check_availability(self) -> tuple[bool, str]:
|
||||
"""Check if this tool is currently available to run.
|
||||
|
||||
Returns:
|
||||
tuple of (is_available, reason_if_not)
|
||||
"""
|
||||
if self._availability:
|
||||
try:
|
||||
return self._availability()
|
||||
except Exception as e:
|
||||
logging.warning(f"Tool {self.name} availability check failed: {e}")
|
||||
return False, f"Availability check failed: {e}"
|
||||
# Default: always available if no check function provided
|
||||
return True, ""
|
||||
|
||||
|
||||
def tool(
|
||||
@@ -158,7 +175,8 @@ def tool(
|
||||
*,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
version: str = "1.0.0"
|
||||
version: str = "1.0.0",
|
||||
availability: Optional[Callable[[], tuple[bool, str]]] = None
|
||||
) -> Union[FunctionTool, Callable[[Callable], FunctionTool]]:
|
||||
"""Decorator to convert a function into a tool.
|
||||
|
||||
@@ -172,12 +190,17 @@ def tool(
|
||||
@tool(name="custom_name", description="Custom description")
|
||||
def my_func(x: str) -> str:
|
||||
return x
|
||||
|
||||
@tool(availability=lambda: (bool(os.getenv("API_KEY")), "API_KEY missing"))
|
||||
def my_func(x: str) -> str:
|
||||
return x
|
||||
|
||||
Args:
|
||||
func: The function to wrap (when used without parentheses)
|
||||
name: Override the tool name (default: function name)
|
||||
description: Override description (default: function docstring)
|
||||
version: Tool version (default: "1.0.0")
|
||||
availability: Function that returns (is_available, reason) tuple
|
||||
|
||||
Returns:
|
||||
FunctionTool instance that wraps the function
|
||||
@@ -187,7 +210,8 @@ def tool(
|
||||
func=fn,
|
||||
name=name,
|
||||
description=description,
|
||||
version=version
|
||||
version=version,
|
||||
availability=availability
|
||||
)
|
||||
|
||||
# Register with global registry if available
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Delegation and sub-agent tools.
|
||||
|
||||
This module provides tools for delegating tasks to sub-agents and managing
|
||||
multi-agent workflows.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
from ..approval import require_approval
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DelegationTools:
|
||||
"""Tools for task delegation and sub-agent management."""
|
||||
|
||||
def __init__(self, workspace=None):
|
||||
"""Initialize DelegationTools.
|
||||
|
||||
Args:
|
||||
workspace: Optional Workspace instance for path containment
|
||||
"""
|
||||
self._workspace = workspace
|
||||
|
||||
@require_approval(risk_level="medium")
|
||||
def delegate_task(self, task_description: str, agent_type: str = "general",
|
||||
priority: str = "medium", timeout: int = 300) -> str:
|
||||
"""Delegate a task to a sub-agent.
|
||||
|
||||
Args:
|
||||
task_description: Description of the task to delegate
|
||||
agent_type: Type of agent to delegate to
|
||||
priority: Task priority (low, medium, high)
|
||||
timeout: Maximum execution time in seconds
|
||||
|
||||
Returns:
|
||||
JSON string with delegation result
|
||||
"""
|
||||
try:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"task_description": task_description,
|
||||
"agent_type": agent_type,
|
||||
"error": "delegate_task is not configured: no sub-agent runtime is wired up",
|
||||
}, indent=2)
|
||||
except Exception as e:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": f"Error delegating task: {e!s}"
|
||||
})
|
||||
|
||||
|
||||
# Create default instance for direct function access
|
||||
_delegation_tools = DelegationTools()
|
||||
|
||||
@require_approval(risk_level="medium")
|
||||
def delegate_task(task_description: str, agent_type: str = "general",
|
||||
priority: str = "medium", timeout: int = 300) -> str:
|
||||
"""Delegate a task to a sub-agent.
|
||||
|
||||
Args:
|
||||
task_description: Description of the task to delegate
|
||||
agent_type: Type of agent to delegate to
|
||||
priority: Task priority (low, medium, high)
|
||||
timeout: Maximum execution time in seconds
|
||||
|
||||
Returns:
|
||||
JSON string with delegation result
|
||||
"""
|
||||
return _delegation_tools.delegate_task(task_description, agent_type, priority, timeout)
|
||||
|
||||
|
||||
def create_delegation_tools(workspace=None) -> DelegationTools:
|
||||
"""Create DelegationTools instance with optional workspace containment.
|
||||
|
||||
Args:
|
||||
workspace: Optional Workspace instance for path containment
|
||||
|
||||
Returns:
|
||||
DelegationTools instance configured with workspace
|
||||
"""
|
||||
return DelegationTools(workspace=workspace)
|
||||
@@ -0,0 +1,196 @@
|
||||
"""File editing tools for fuzzy find-and-replace operations.
|
||||
|
||||
This module provides tools for making targeted edits to files,
|
||||
supporting fuzzy matching and patch operations similar to those
|
||||
used in skill management.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional
|
||||
from ..approval import require_approval
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EditTools:
|
||||
"""Tools for file editing and patching operations."""
|
||||
|
||||
def __init__(self, workspace=None):
|
||||
"""Initialize EditTools with optional workspace containment.
|
||||
|
||||
Args:
|
||||
workspace: Optional Workspace instance for path containment
|
||||
"""
|
||||
self._workspace = workspace
|
||||
|
||||
def _validate_path(self, filepath: str) -> str:
|
||||
"""Validate and resolve a file path within workspace constraints."""
|
||||
if self._workspace is not None:
|
||||
return str(self._workspace.resolve(filepath))
|
||||
|
||||
# Fallback to basic validation
|
||||
filepath = os.path.expanduser(filepath)
|
||||
if '..' in filepath:
|
||||
raise ValueError(f"Path traversal detected: {filepath}")
|
||||
return os.path.abspath(filepath)
|
||||
|
||||
@require_approval(risk_level="high")
|
||||
def edit_file(self, filepath: str, old_string: str, new_string: str,
|
||||
replace_all: bool = False) -> str:
|
||||
"""Edit a file by replacing text using fuzzy find-and-replace.
|
||||
|
||||
Args:
|
||||
filepath: Path to the file to edit
|
||||
old_string: Text to find and replace
|
||||
new_string: Replacement text
|
||||
replace_all: Whether to replace all occurrences (default: first only)
|
||||
|
||||
Returns:
|
||||
Success message or error description
|
||||
"""
|
||||
try:
|
||||
safe_path = self._validate_path(filepath)
|
||||
|
||||
if not os.path.exists(safe_path):
|
||||
return f"Error: File not found: {filepath}"
|
||||
|
||||
# Read file content
|
||||
with open(safe_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Perform replacement
|
||||
if old_string == "":
|
||||
return "Error: old_string must be non-empty"
|
||||
|
||||
if old_string not in content:
|
||||
return f"Error: String not found in file: '{old_string[:50]}...'"
|
||||
|
||||
if replace_all:
|
||||
new_content = content.replace(old_string, new_string)
|
||||
replacements = content.count(old_string)
|
||||
else:
|
||||
new_content = content.replace(old_string, new_string, 1)
|
||||
replacements = 1
|
||||
|
||||
# Write updated content back
|
||||
with open(safe_path, 'w', encoding='utf-8') as f:
|
||||
f.write(new_content)
|
||||
|
||||
return f"Success: Made {replacements} replacement(s) in {filepath}"
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error editing file {filepath}: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return error_msg
|
||||
|
||||
def search_files(self, directory: str, pattern: str,
|
||||
file_pattern: str = "*") -> str:
|
||||
"""Search for text patterns within files.
|
||||
|
||||
Args:
|
||||
directory: Directory to search in
|
||||
pattern: Text pattern to search for
|
||||
file_pattern: File name pattern (glob) to filter files
|
||||
|
||||
Returns:
|
||||
JSON string with search results
|
||||
"""
|
||||
import json
|
||||
import glob
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
safe_dir = self._validate_path(directory)
|
||||
|
||||
if not os.path.exists(safe_dir):
|
||||
return json.dumps({"error": f"Directory not found: {directory}"})
|
||||
|
||||
results = []
|
||||
search_pattern = os.path.join(safe_dir, "**", file_pattern)
|
||||
|
||||
for filepath in glob.glob(search_pattern, recursive=True):
|
||||
if os.path.isfile(filepath):
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
matches = []
|
||||
for line_num, line in enumerate(lines, 1):
|
||||
if pattern.lower() in line.lower():
|
||||
matches.append({
|
||||
"line_number": line_num,
|
||||
"line": line.strip(),
|
||||
"match_start": line.lower().find(pattern.lower())
|
||||
})
|
||||
|
||||
if matches:
|
||||
relative_path = os.path.relpath(filepath, safe_dir)
|
||||
results.append({
|
||||
"file": relative_path,
|
||||
"matches": matches
|
||||
})
|
||||
|
||||
except (UnicodeDecodeError, PermissionError):
|
||||
# Skip files that can't be read
|
||||
continue
|
||||
|
||||
return json.dumps({
|
||||
"pattern": pattern,
|
||||
"directory": directory,
|
||||
"results": results,
|
||||
"total_files": len(results),
|
||||
"total_matches": sum(len(r["matches"]) for r in results)
|
||||
}, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error searching files: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return json.dumps({"error": error_msg})
|
||||
|
||||
|
||||
# Create default instance for direct function access
|
||||
_edit_tools = EditTools()
|
||||
|
||||
@require_approval(risk_level="high")
|
||||
def edit_file(filepath: str, old_string: str, new_string: str,
|
||||
replace_all: bool = False) -> str:
|
||||
"""Edit a file by replacing text using fuzzy find-and-replace.
|
||||
|
||||
Args:
|
||||
filepath: Path to the file to edit
|
||||
old_string: Text to find and replace
|
||||
new_string: Replacement text
|
||||
replace_all: Whether to replace all occurrences (default: first only)
|
||||
|
||||
Returns:
|
||||
Success message or error description
|
||||
"""
|
||||
return _edit_tools.edit_file(filepath, old_string, new_string, replace_all)
|
||||
|
||||
|
||||
def search_files(directory: str, pattern: str,
|
||||
file_pattern: str = "*") -> str:
|
||||
"""Search for text patterns within files.
|
||||
|
||||
Args:
|
||||
directory: Directory to search in
|
||||
pattern: Text pattern to search for
|
||||
file_pattern: File name pattern (glob) to filter files
|
||||
|
||||
Returns:
|
||||
JSON string with search results
|
||||
"""
|
||||
return _edit_tools.search_files(directory, pattern, file_pattern)
|
||||
|
||||
|
||||
def create_edit_tools(workspace=None) -> EditTools:
|
||||
"""Create EditTools instance with optional workspace containment.
|
||||
|
||||
Args:
|
||||
workspace: Optional Workspace instance for path containment
|
||||
|
||||
Returns:
|
||||
EditTools instance configured with workspace
|
||||
"""
|
||||
return EditTools(workspace=workspace)
|
||||
@@ -21,8 +21,25 @@ from ..approval import require_approval
|
||||
class FileTools:
|
||||
"""Tools for file operations including read, write, list, and information."""
|
||||
|
||||
@staticmethod
|
||||
def _validate_path(filepath: str) -> str:
|
||||
def __init__(self, workspace=None):
|
||||
"""Initialize FileTools with optional workspace containment.
|
||||
|
||||
Args:
|
||||
workspace: Optional Workspace instance for path containment
|
||||
"""
|
||||
self._workspace = workspace
|
||||
|
||||
def _require_workspace_access(self, *, write: bool) -> None:
|
||||
"""Check workspace access permissions for read/write operations."""
|
||||
if self._workspace is None:
|
||||
return
|
||||
access = getattr(self._workspace, "access", "rw")
|
||||
if access == "none":
|
||||
raise PermissionError("Workspace access is disabled")
|
||||
if write and access != "rw":
|
||||
raise PermissionError(f"Workspace is not writable: access={access!r}")
|
||||
|
||||
def _validate_path(self, filepath: str) -> str:
|
||||
"""
|
||||
Validate and normalize a file path to prevent path traversal attacks.
|
||||
|
||||
@@ -35,6 +52,11 @@ class FileTools:
|
||||
Raises:
|
||||
ValueError: If path contains suspicious patterns
|
||||
"""
|
||||
# If workspace is configured, use workspace validation
|
||||
if self._workspace is not None:
|
||||
return str(self._workspace.resolve(filepath))
|
||||
|
||||
# Fallback to basic validation (when workspace=None)
|
||||
# Expand ~ to user home directory FIRST (before any validation)
|
||||
if filepath.startswith('~'):
|
||||
filepath = os.path.expanduser(filepath)
|
||||
@@ -55,8 +77,7 @@ class FileTools:
|
||||
|
||||
return absolute
|
||||
|
||||
@staticmethod
|
||||
def read_file(filepath: str, encoding: str = 'utf-8') -> str:
|
||||
def read_file(self, filepath: str, encoding: str = 'utf-8') -> str:
|
||||
"""
|
||||
Read content from a file.
|
||||
|
||||
@@ -69,7 +90,7 @@ class FileTools:
|
||||
"""
|
||||
try:
|
||||
# Validate path to prevent traversal attacks
|
||||
safe_path = FileTools._validate_path(filepath)
|
||||
safe_path = self._validate_path(filepath)
|
||||
with open(safe_path, 'r', encoding=encoding) as f:
|
||||
return f.read()
|
||||
except Exception as e:
|
||||
@@ -77,9 +98,8 @@ class FileTools:
|
||||
logging.error(error_msg)
|
||||
return error_msg
|
||||
|
||||
@staticmethod
|
||||
@require_approval(risk_level="high")
|
||||
def write_file(filepath: str, content: str, encoding: str = 'utf-8') -> bool:
|
||||
def write_file(self, filepath: str, content: str, encoding: str = 'utf-8') -> bool:
|
||||
"""
|
||||
Write content to a file.
|
||||
|
||||
@@ -92,8 +112,10 @@ class FileTools:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Check workspace permissions for write operations
|
||||
self._require_workspace_access(write=True)
|
||||
# Validate path to prevent traversal attacks
|
||||
safe_path = FileTools._validate_path(filepath)
|
||||
safe_path = self._validate_path(filepath)
|
||||
# Create directory if it doesn't exist
|
||||
os.makedirs(os.path.dirname(safe_path), exist_ok=True)
|
||||
# Auto-coerce non-string content (LLMs sometimes pass dicts/lists)
|
||||
@@ -110,8 +132,7 @@ class FileTools:
|
||||
logging.error(error_msg)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def list_files(directory: str, pattern: Optional[str] = None) -> List[Dict[str, Union[str, int]]]:
|
||||
def list_files(self, directory: str, pattern: Optional[str] = None) -> List[Dict[str, Union[str, int]]]:
|
||||
"""
|
||||
List files in a directory with optional pattern matching.
|
||||
|
||||
@@ -124,9 +145,15 @@ class FileTools:
|
||||
"""
|
||||
try:
|
||||
# Validate directory path
|
||||
safe_dir = FileTools._validate_path(directory)
|
||||
safe_dir = self._validate_path(directory)
|
||||
path = Path(safe_dir)
|
||||
if pattern:
|
||||
pattern_path = Path(pattern)
|
||||
if pattern_path.is_absolute() or any(
|
||||
part == ".." or part.startswith("..")
|
||||
for part in pattern_path.parts
|
||||
):
|
||||
raise ValueError(f"Invalid file pattern: {pattern}")
|
||||
files = path.glob(pattern)
|
||||
else:
|
||||
files = path.iterdir()
|
||||
@@ -148,8 +175,7 @@ class FileTools:
|
||||
logging.error(error_msg)
|
||||
return [{'error': error_msg}]
|
||||
|
||||
@staticmethod
|
||||
def get_file_info(filepath: str) -> Dict[str, Union[str, int]]:
|
||||
def get_file_info(self, filepath: str) -> Dict[str, Union[str, int]]:
|
||||
"""
|
||||
Get detailed information about a file.
|
||||
|
||||
@@ -161,7 +187,7 @@ class FileTools:
|
||||
"""
|
||||
try:
|
||||
# Validate file path
|
||||
safe_path = FileTools._validate_path(filepath)
|
||||
safe_path = self._validate_path(filepath)
|
||||
path = Path(safe_path)
|
||||
if not path.exists():
|
||||
return {'error': f'File not found: {filepath}'}
|
||||
@@ -183,9 +209,8 @@ class FileTools:
|
||||
logging.error(error_msg)
|
||||
return {'error': error_msg}
|
||||
|
||||
@staticmethod
|
||||
@require_approval(risk_level="high")
|
||||
def copy_file(src: str, dst: str) -> bool:
|
||||
def copy_file(self, src: str, dst: str) -> bool:
|
||||
"""
|
||||
Copy a file from source to destination.
|
||||
|
||||
@@ -197,9 +222,11 @@ class FileTools:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Check workspace permissions for write operations
|
||||
self._require_workspace_access(write=True)
|
||||
# Validate paths to prevent traversal attacks
|
||||
safe_src = FileTools._validate_path(src)
|
||||
safe_dst = FileTools._validate_path(dst)
|
||||
safe_src = self._validate_path(src)
|
||||
safe_dst = self._validate_path(dst)
|
||||
# Create destination directory if it doesn't exist
|
||||
os.makedirs(os.path.dirname(safe_dst), exist_ok=True)
|
||||
shutil.copy2(safe_src, safe_dst)
|
||||
@@ -209,9 +236,8 @@ class FileTools:
|
||||
logging.error(error_msg)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
@require_approval(risk_level="high")
|
||||
def move_file(src: str, dst: str) -> bool:
|
||||
def move_file(self, src: str, dst: str) -> bool:
|
||||
"""
|
||||
Move a file from source to destination.
|
||||
|
||||
@@ -223,9 +249,11 @@ class FileTools:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Check workspace permissions for write operations
|
||||
self._require_workspace_access(write=True)
|
||||
# Validate paths to prevent traversal attacks
|
||||
safe_src = FileTools._validate_path(src)
|
||||
safe_dst = FileTools._validate_path(dst)
|
||||
safe_src = self._validate_path(src)
|
||||
safe_dst = self._validate_path(dst)
|
||||
# Create destination directory if it doesn't exist
|
||||
os.makedirs(os.path.dirname(safe_dst), exist_ok=True)
|
||||
shutil.move(safe_src, safe_dst)
|
||||
@@ -235,9 +263,8 @@ class FileTools:
|
||||
logging.error(error_msg)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
@require_approval(risk_level="high")
|
||||
def delete_file(filepath: str) -> bool:
|
||||
def delete_file(self, filepath: str) -> bool:
|
||||
"""
|
||||
Delete a file.
|
||||
|
||||
@@ -248,8 +275,10 @@ class FileTools:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Check workspace permissions for write operations
|
||||
self._require_workspace_access(write=True)
|
||||
# Validate path to prevent traversal attacks
|
||||
safe_path = FileTools._validate_path(filepath)
|
||||
safe_path = self._validate_path(filepath)
|
||||
os.remove(safe_path)
|
||||
return True
|
||||
except Exception as e:
|
||||
@@ -257,9 +286,9 @@ class FileTools:
|
||||
logging.error(error_msg)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
@require_approval(risk_level="medium")
|
||||
def download_file(
|
||||
self,
|
||||
url: str,
|
||||
destination: str,
|
||||
timeout: int = 30,
|
||||
@@ -289,8 +318,10 @@ class FileTools:
|
||||
}
|
||||
|
||||
try:
|
||||
# Check workspace permissions for write operations
|
||||
self._require_workspace_access(write=True)
|
||||
# Validate destination path
|
||||
safe_path = FileTools._validate_path(destination)
|
||||
safe_path = self._validate_path(destination)
|
||||
|
||||
# Validate URL to prevent SSRF
|
||||
from urllib.parse import urlparse
|
||||
@@ -361,7 +392,7 @@ class FileTools:
|
||||
"error": error_msg
|
||||
}
|
||||
|
||||
# Create instance for direct function access
|
||||
# Create default instance for direct function access
|
||||
_file_tools = FileTools()
|
||||
read_file = _file_tools.read_file
|
||||
write_file = _file_tools.write_file
|
||||
@@ -372,6 +403,18 @@ move_file = _file_tools.move_file
|
||||
delete_file = _file_tools.delete_file
|
||||
download_file = _file_tools.download_file
|
||||
|
||||
|
||||
def create_file_tools(workspace=None):
|
||||
"""Create FileTools instance with optional workspace containment.
|
||||
|
||||
Args:
|
||||
workspace: Optional Workspace instance for path containment
|
||||
|
||||
Returns:
|
||||
FileTools instance configured with workspace
|
||||
"""
|
||||
return FileTools(workspace=workspace)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example usage
|
||||
print("\n==================================================")
|
||||
|
||||
Loaded 100 of 403 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user