mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-08 17:31:00 -10:00
Compare commits
No files matched your search
@@ -41,4 +41,4 @@ jobs:
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
cd src/praisonai && python -m pytest tests/unit/ -v --tb=short --disable-warnings --timeout=60 || echo "Some tests failed"
|
||||
cd src/praisonai && python -m pytest tests/unit/ -v --tb=short --disable-warnings --timeout=60
|
||||
@@ -155,7 +155,11 @@ jobs:
|
||||
tests/unit/plugins/ tests/unit/runtime/ tests/unit/tools/
|
||||
tests/unit/bus/ tests/unit/skills/ tests/unit/agents/
|
||||
tests/unit/rag/ tests/unit/streaming/
|
||||
tests/unit/test_adapter_registry.py
|
||||
tests/unit/test_adapter_registry.py tests/unit/test_token_budget.py
|
||||
tests/unit/test_streaming_events.py tests/unit/test_tool_decorator_approval.py
|
||||
tests/unit/test_task_execution_config.py
|
||||
tests/unit/test_agent_unknown_kwarg_guidance.py
|
||||
tests/unit/test_agent_instantiation_lazy.py tests/unit/test_agent_os_exports.py
|
||||
# Both tests previously deselected here now pass in combination.
|
||||
# test_lazy_litellm_import was looking the loader module up by
|
||||
# name and could get a different object than the one get_litellm()
|
||||
|
||||
@@ -14,27 +14,145 @@
|
||||
<a href="https://github.com/MervinPraison/PraisonAI"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License" /></a>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
# PraisonAI
|
||||
|
||||
# PraisonAI 🦞
|
||||
**Private AI that earns trust one task at a time.**
|
||||
|
||||
<a href="https://trendshift.io/repositories/9130" target="_blank"><img src="https://trendshift.io/api/badge/repositories/9130" alt="MervinPraison%2FPraisonAI | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
PraisonAI runs AI agents on your own computers, even fully offline, so private data never has to leave the building.
|
||||
|
||||
</div>
|
||||
- **For highly regulated teams (early access):** each task moves to your own AI only after it passes a test. If quality slips, it moves back automatically, and every step is recorded for regulators. [How it works](#how-the-handover-works-early-access)
|
||||
- **For everyone else:** a free, open-source Python agent framework. With a local model you need no API key and pay no cloud bill, and your files stay yours.
|
||||
|
||||
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.
|
||||
[2M+ downloads](https://pepy.tech/projects/praisonai) · [9.3K stars](https://github.com/MervinPraison/PraisonAI) · [600+ releases in the last year](https://github.com/MervinPraison/PraisonAI/releases) · [80+ contributors](https://github.com/MervinPraison/PraisonAI/graphs/contributors) · Python
|
||||
|
||||
```bash
|
||||
curl -fsSL https://praison.ai/install.sh | bash
|
||||
pip install praisonai
|
||||
```
|
||||
|
||||
Only need the Python library? `pip install praisonaiagents` installs just the lightweight core.
|
||||
|
||||
## Using PraisonAI for something else?
|
||||
|
||||
PraisonAI is still a general-purpose, open-source agent framework, and everything works as before: the same packages, imports, commands and examples. Go straight to what you came for:
|
||||
|
||||
- **Agents and multi-agent teams:** [Agents](https://praison.ai/docs/concepts/agents) · [Handoffs](https://praison.ai/docs/concepts/handoffs)
|
||||
- **Workflows:** [AgentFlow: route, parallel, loop, repeat](https://praison.ai/docs/concepts/agentflow)
|
||||
- **Memory and knowledge:** [Memory](https://praison.ai/docs/concepts/memory) · [Knowledge](https://praison.ai/docs/concepts/knowledge) · [RAG](https://praison.ai/docs/concepts/rag)
|
||||
- **Tools and MCP:** [Tools](https://praison.ai/docs/tools/tools) · [MCP](https://praison.ai/docs/mcp/transports)
|
||||
- **Bots (Telegram, Discord, Slack, WhatsApp):** [Bot CLI](https://praison.ai/docs/cli/bot) · [Bot gateway](https://praison.ai/docs/features/bot-gateway)
|
||||
- **Terminal CLI:** [`praisonai code`](https://praison.ai/docs/cli/code) · [`praisonai run`](https://praison.ai/docs/cli/run) · [`praisonai chat`](https://praison.ai/docs/cli/chat) · [CLI reference](https://praison.ai/docs/cli/cli-reference)
|
||||
- **Training and fine-tuning:** [PraisonAI Train](https://praison.ai/docs/train)
|
||||
- **Browser automation:** [Browser agent](https://praison.ai/docs/features/browser-agent) · [Browser CLI](https://praison.ai/docs/cli/browser)
|
||||
- **Sandboxes:** [Sandboxed code execution](https://praison.ai/docs/features/sandbox)
|
||||
- **Deploy:** [Deploy guide](https://praison.ai/docs/deploy)
|
||||
- **Dashboard and UI:** [Claw dashboard](https://praison.ai/docs/concepts/claw) · [Chat UI](https://praison.ai/docs/ui)
|
||||
- **YAML, no code:** [YAML agents](#-using-yaml-no-code) · [YAML reference](https://praison.ai/docs/features/yaml-configuration-reference)
|
||||
- **JavaScript and TypeScript:** [JavaScript SDK](https://praison.ai/docs/js)
|
||||
- **Desktop app:** [PraisonAI Desktop](https://praison.ai/docs/features/desktop/index) · [Downloads](#desktop-app)
|
||||
- **Everything else:** the [full feature reference](#full-feature-reference) further down this page
|
||||
|
||||
## Quick start
|
||||
|
||||
Set a key for any supported provider (OpenAI shown here), or skip the key and use a local model as in the next section.
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="your-api-key"
|
||||
```
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(instructions="You are a helpful assistant.")
|
||||
print(agent.start("In two sentences, why do some companies keep their data on their own computers?"))
|
||||
```
|
||||
|
||||
## Run fully offline
|
||||
|
||||
Run the same agent on a model on your own computer with [Ollama](https://ollama.com). You need no API key, and your prompts and answers stay on the machine.
|
||||
|
||||
Install the package and pull the model while you still have internet; after that, the agent runs with no network at all. If you installed only the core library, use `pip install "praisonaiagents[llm]"`, which adds the local-model support.
|
||||
|
||||
```bash
|
||||
# Start Ollama first (the app, or "ollama serve" in another terminal)
|
||||
ollama pull llama3.2
|
||||
export DO_NOT_TRACK=true # keeps telemetry off (PraisonAI sends none unless you turn it on)
|
||||
```
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(instructions="You are a helpful assistant.", model="ollama/llama3.2")
|
||||
print(agent.start("Why is the sky blue?"))
|
||||
```
|
||||
|
||||
If Ollama runs on another computer on your network, pass `base_url` (or set `OLLAMA_API_BASE`):
|
||||
|
||||
```python
|
||||
agent = Agent(instructions="...", model="ollama/llama3.2", base_url="http://192.168.1.10:11434")
|
||||
```
|
||||
|
||||
vLLM and LM Studio work the same way: use `hosted_vllm/` (vLLM) or `lm_studio/` with your server's `base_url`. More in the [Ollama guide](https://praison.ai/docs/models/ollama) and [Models docs](https://praison.ai/docs/models).
|
||||
|
||||
## How the handover works (early access)
|
||||
|
||||
1. **Watch:** records how each task is done today.
|
||||
2. **Test:** your own AI takes an exam made from real past work it never studied.
|
||||
3. **Hand over:** a task moves to your own AI only when it passes.
|
||||
4. **Roll back:** if quality drops, the task moves back automatically.
|
||||
5. **Record:** every handover leaves a record your regulator can check.
|
||||
|
||||
**What is "your own AI"?** An open model, such as Llama, Qwen or Gemma, running on your own computers. Use it as it is, or train it on your own past work with [PraisonAI Train](https://praison.ai/docs/train) on your own GPU servers. Your team decides when to train; nothing trains automatically. Either way, a task moves to it only after it passes that exam.
|
||||
|
||||
Highly regulated teams, such as banks, insurers and hospitals, want AI but can't send customer data to outside models. From 6 January 2027, OpenAI also takes no new self-serve fine-tuning jobs ([notice](https://developers.openai.com/api/docs/deprecations)), so teams that customise models need models they own. The handover lets these teams move work to AI they own, one task at a time, with a record that shows why each step was trusted.
|
||||
|
||||
It is built on parts of this framework that are live today: tracing, evaluation, fine-tuning, model routing and local models. The handover itself is in early access, and we are taking on our first design partners. To take part, see [praison.ai/enterprise](https://praison.ai/enterprise/).
|
||||
|
||||
## Features
|
||||
|
||||
- **Agents and multi-agent teams:** [Agents](https://praison.ai/docs/concepts/agents) · [Handoffs](https://praison.ai/docs/concepts/handoffs) · [Workflows](https://praison.ai/docs/concepts/agentflow)
|
||||
- **Memory:** [Memory](https://praison.ai/docs/concepts/memory) · [Knowledge and RAG](https://praison.ai/docs/concepts/rag) · [Sessions](https://praison.ai/docs/concepts/session-management)
|
||||
- **Tools and MCP:** [Tools](https://praison.ai/docs/tools/tools) · [MCP](https://praison.ai/docs/mcp/transports)
|
||||
- **Guardrails and approval:** [Guardrails](https://praison.ai/docs/concepts/guardrails) · [Human approval](https://praison.ai/docs/concepts/approval) · [Policy engine](https://praison.ai/docs/features/policy-engine)
|
||||
- **Tracing and evaluation:** [Tracing](https://praison.ai/docs/observability/overview) · [Telemetry](https://praison.ai/docs/features/telemetry) · [Langfuse](https://praison.ai/docs/observability/langfuse) · [Evaluation](https://praison.ai/docs/concepts/evaluation)
|
||||
- **Your choice of model:** [Models](https://praison.ai/docs/models) · [Model router](https://praison.ai/docs/features/model-router)
|
||||
- **Fine-tuning:** [PraisonAI Train](https://praison.ai/docs/train)
|
||||
- **Deploy:** [Deploy guide](https://praison.ai/docs/deploy)
|
||||
|
||||
## JavaScript and TypeScript
|
||||
|
||||
```bash
|
||||
npm install praisonai
|
||||
```
|
||||
|
||||
The TypeScript and JavaScript SDK has its own guide at [praison.ai/docs/js](https://praison.ai/docs/js). A short example is in [Using JavaScript Code](#-using-javascript-code) below.
|
||||
|
||||
## The company
|
||||
|
||||
- **Who:** Praison AI Limited in Cambridge, UK, building PraisonAI since September 2023. Co-founders Mervin Praison (CEO), Leebana Smith (COO) and Karthick Subramanian (CBO). [About us](https://praison.ai/about/)
|
||||
- **What we're building:** the task handover above, as a self-hosted product on top of this framework. It is in early access, and we are taking on our first design partners.
|
||||
- **Why now:** highly regulated teams want AI but can't send customer data to outside models, and from 6 January 2027 OpenAI takes no new self-serve fine-tuning jobs.
|
||||
- **How it fits together:** the framework stays free and MIT-licensed, and brings developers in. Regulated teams license the handover to run inside their own walls.
|
||||
- **Traction:** every number at the top of this page is public and linked to its source.
|
||||
- **Get in touch:** design partners, investors and press: enterprise@praison.ai or [praison.ai/enterprise](https://praison.ai/enterprise/)
|
||||
|
||||
## Docs, community and licence
|
||||
|
||||
- **Documentation:** [praison.ai/docs](https://praison.ai/docs)
|
||||
- **Questions and bugs:** [Discussions](https://github.com/MervinPraison/PraisonAI/discussions) · [Issues](https://github.com/MervinPraison/PraisonAI/issues)
|
||||
- **Contributing:** [Contributing guide](https://github.com/MervinPraison/PraisonAI/blob/main/CONTRIBUTING.md)
|
||||
- **Videos and news:** [YouTube](https://youtube.com/@MervinPraison) · [X](https://x.com/MervinPraison) · [LinkedIn](https://linkedin.com/in/mervinpraison)
|
||||
- **Desktop app:** macOS, Windows and Linux builds are on the [latest release](https://github.com/MervinPraison/PraisonAI/releases/latest)
|
||||
- **Licence:** [MIT](https://github.com/MervinPraison/PraisonAI/blob/main/LICENSE)
|
||||
|
||||
---
|
||||
|
||||
## Full feature reference
|
||||
|
||||
Everything below is the full reference that was on this page before. Nothing has been removed; the longest parts are folded, so click a heading's summary to open it.
|
||||
|
||||
### Desktop app
|
||||
|
||||
<div align="center">
|
||||
<br>
|
||||
<a href="https://x.com/elonmusk/status/1893870468249141688" target="_blank">
|
||||
<img src="https://img.shields.io/badge/Highlighted_by_Elon_Musk-000000?style=for-the-badge&logo=x&logoColor=white" alt="Highlighted by Elon Musk" />
|
||||
</a>
|
||||
<br>
|
||||
<br>
|
||||
<a href="https://github.com/MervinPraison/PraisonAI/releases/latest">
|
||||
<img src="https://img.shields.io/badge/Download_for_macOS-000000?style=for-the-badge&logo=apple&logoColor=white" alt="Download for macOS" />
|
||||
</a>
|
||||
@@ -46,12 +164,17 @@ curl -fsSL https://praison.ai/install.sh | bash
|
||||
<a href="https://github.com/MervinPraison/PraisonAI/releases/latest">
|
||||
<img src="https://img.shields.io/badge/Download_for_Linux-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Download for Linux" />
|
||||
</a>
|
||||
<br>
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/images/dashboard.png" alt="PraisonAI Dashboard" width="800" />
|
||||
</p>
|
||||
### Other ways to install
|
||||
|
||||
One-line installer:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://praison.ai/install.sh | bash
|
||||
```
|
||||
|
||||
The full `praisonai` package, with the CLI, bots, dashboard and integrations:
|
||||
|
||||
```
|
||||
██████╗ ██████╗ █████╗ ██╗███████╗ ██████╗ ███╗ ██╗ █████╗ ██╗
|
||||
@@ -64,10 +187,20 @@ curl -fsSL https://praison.ai/install.sh | bash
|
||||
pip install praisonai
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/images/dashboard.png" alt="PraisonAI Dashboard" width="800" />
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
<a href="https://docs.praison.ai">
|
||||
|
||||
<a href="https://trendshift.io/repositories/9130" target="_blank"><img src="https://trendshift.io/api/badge/repositories/9130" alt="MervinPraison%2FPraisonAI | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<a href="https://praison.ai/docs">
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/📚_Documentation-Visit_docs.praison.ai-blue?style=for-the-badge&logo=bookstack&logoColor=white" alt="Documentation" />
|
||||
<img src="https://img.shields.io/badge/📚_Documentation-Visit_praison.ai%2Fdocs-blue?style=for-the-badge&logo=bookstack&logoColor=white" alt="Documentation" />
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
@@ -114,6 +247,9 @@ Most frameworks hand you one or two layers and leave the rest as homework. Prais
|
||||
|
||||
Each layer wraps the one inside it. When an agent misbehaves, the layer tells you where to look.
|
||||
|
||||
<details>
|
||||
<summary><strong>Show the five layers, examples and Managed Agents</strong></summary>
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ⬡ MANAGED AGENTS — Where does it actually run? │
|
||||
@@ -174,7 +310,7 @@ agent = Agent(
|
||||
)
|
||||
```
|
||||
|
||||
> **Isolate** is `handoffs=[specialist]` — a sub-agent inherits the last few messages and the intersection of your tools, not your whole transcript. [📖 Handoffs](https://docs.praison.ai/docs/concepts/handoffs)
|
||||
> **Isolate** is `handoffs=[specialist]` — a sub-agent inherits the last few messages and the intersection of your tools, not your whole transcript. [📖 Handoffs](https://praison.ai/docs/concepts/handoffs)
|
||||
|
||||
### Layer 3 · Harness — *Can it act, and be checked?*
|
||||
|
||||
@@ -217,7 +353,7 @@ print(result.completion_reason)
|
||||
# surfaced here as completion_reason="error")
|
||||
```
|
||||
|
||||
> **Doom-loop detection is on by default.** Repeated identical tool calls and A→B→A→B oscillation get caught — while a poller whose output keeps changing does not. [📖 Doom Loop Detection](https://docs.praison.ai/docs/features/doom-loop-detection)
|
||||
> **Doom-loop detection is on by default.** Repeated identical tool calls and A→B→A→B oscillation get caught — while a poller whose output keeps changing does not. [📖 Doom Loop Detection](https://praison.ai/docs/features/doom-loop-detection)
|
||||
|
||||
### Layer 5 · Graph — *Who runs when, and who checks whom?*
|
||||
|
||||
@@ -237,7 +373,7 @@ flow = AgentFlow(steps=[
|
||||
flow.run("Ticket #123: login fails on Safari")
|
||||
```
|
||||
|
||||
> The same graph is expressible in YAML with no Python at all. [📖 AgentFlow](https://docs.praison.ai/docs/concepts/agentflow)
|
||||
> The same graph is expressible in YAML with no Python at all. [📖 AgentFlow](https://praison.ai/docs/concepts/agentflow)
|
||||
|
||||
### ⬡ Outside the stack: Managed Agents — *Where does it actually run?*
|
||||
|
||||
@@ -332,6 +468,8 @@ Sandboxes shut themselves down when idle (`auto_shutdown`, `idle_timeout_s`), an
|
||||
|
||||
<sub>Stack framing adapted from [The Five-Layer Agent Stack](https://mer.vin/2026/07/five-layer-agent-stack-match-bug-to-right-layer/) and [Agent Harnesses vs Orbs](https://mer.vin/2026/08/agent-harnesses-vs-orbs-why-remote-sandboxes-beat-local-agent-loops/).</sub>
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🌌 The PraisonAI Ecosystem
|
||||
@@ -411,6 +549,11 @@ Powered by 100+ LLMs (OpenAI, Anthropic, Gemini & local models).
|
||||
</details>
|
||||
|
||||
<div align="center">
|
||||
<a href="https://x.com/elonmusk/status/1893870468249141688" target="_blank">
|
||||
<img src="https://img.shields.io/badge/Highlighted_by_Elon_Musk-000000?style=for-the-badge&logo=x&logoColor=white" alt="Highlighted by Elon Musk" />
|
||||
</a>
|
||||
<br>
|
||||
<br>
|
||||
<a href="https://x.com/elonmusk/status/1893870468249141688" target="_blank">
|
||||
<img src=".github/images/elon_musk_praisonai.png" alt="Highlighted by Elon Musk" width="600" />
|
||||
</a>
|
||||
@@ -426,13 +569,13 @@ Powered by 100+ LLMs (OpenAI, Anthropic, Gemini & local models).
|
||||
|--|---------|-----|
|
||||
| 🔌 | **MCP Protocol** — stdio, HTTP, WebSocket, SSE | `tools=MCP("npx ...")` |
|
||||
| 🧠 | **Planning Mode** — plan → execute → reason | `planning=True` |
|
||||
| 🔍 | **Deep Research** — multi-step autonomous research | [Docs](https://docs.praison.ai/docs/agents/deep-research) |
|
||||
| 🤖 | **External Agents** — orchestrate Claude Code, Gemini CLI, Codex | [Docs](https://docs.praison.ai/docs/code/external-agents) |
|
||||
| 🔍 | **Deep Research** — multi-step autonomous research | [Docs](https://praison.ai/docs/agents/deep-research) |
|
||||
| 🤖 | **External Agents** — orchestrate Claude Code, Gemini CLI, Codex | [Docs](https://praison.ai/docs/code/external-agents) |
|
||||
| 🔄 | **Agent Handoffs** — seamless conversation passing | `handoffs=[other_agent]` |
|
||||
| 🛡️ | **Guardrails** — input/output validation | [Docs](https://docs.praison.ai/docs/concepts/guardrails) |
|
||||
| 🛡️ | **Guardrails** — input/output validation | [Docs](https://praison.ai/docs/concepts/guardrails) |
|
||||
| | **Web Search + Fetch** — native browsing | `web=True` |
|
||||
| 🪞 | **Self Reflection** — agent reviews its own output | [Docs](https://docs.praison.ai/docs/concepts/reflection) |
|
||||
| 🔀 | **Workflow Patterns** — route, parallel, loop, repeat | [Docs](https://docs.praison.ai/docs/concepts/agentflow) |
|
||||
| 🪞 | **Self Reflection** — agent reviews its own output | [Docs](https://praison.ai/docs/concepts/reflection) |
|
||||
| 🔀 | **Workflow Patterns** — route, parallel, loop, repeat | [Docs](https://praison.ai/docs/concepts/agentflow) |
|
||||
| 🧠 | **Memory (zero deps)** — works out of the box | `memory=True` |
|
||||
|
||||
<details>
|
||||
@@ -443,18 +586,18 @@ Powered by 100+ LLMs (OpenAI, Anthropic, Gemini & local models).
|
||||
| 💡 | **Prompt Caching** — reduce latency + cost | `caching=True` |
|
||||
| 💾 | **Sessions + Auto-Save** — persistent state across restarts | `auto_save="my-project"` |
|
||||
| 💭 | **Thinking Budgets** — control reasoning depth | `agent.thinking_budget = 1024` |
|
||||
| 📚 | **RAG + Quality-Based RAG** — auto quality scoring retrieval | [Docs](https://docs.praison.ai/docs/concepts/rag) |
|
||||
| 📊 | **Model Router** — auto-routes to cheapest capable model | [Docs](https://docs.praison.ai/docs/features/model-router) |
|
||||
| 🧊 | **Shadow Git Checkpoints** — auto-rollback on failure | [Docs](https://docs.praison.ai/docs/features/checkpoints) |
|
||||
| 📡 | **A2A Protocol** — agent-to-agent interop | [Docs](https://docs.praison.ai/docs/features/a2a) |
|
||||
| 📏 | **Context Compaction** — never hit token limits | [Docs](https://docs.praison.ai/docs/features/context-compaction) |
|
||||
| 📡 | **Telemetry** — OpenTelemetry traces, spans, metrics | [Docs](https://docs.praison.ai/docs/features/telemetry) |
|
||||
| 📜 | **Policy Engine** — declarative agent behavior control | [Docs](https://docs.praison.ai/docs/features/policy-engine) |
|
||||
| 🔄 | **Background Tasks** — fire-and-forget agents | [Docs](https://docs.praison.ai/docs/features/background-tasks) |
|
||||
| 🔁 | **Doom Loop Detection** — auto-recovery from stuck agents | [Docs](https://docs.praison.ai/docs/features/doom-loop-detection) |
|
||||
| 🕸️ | **Graph Memory** — Neo4j-style relationship tracking | [Docs](https://docs.praison.ai/docs/features/graph-memory) |
|
||||
| 🏖️ | **Sandbox Execution** — isolated code execution | [Docs](https://docs.praison.ai/docs/features/sandbox) |
|
||||
| 🖥️ | **Bot Gateway** — multi-agent routing across channels | [Docs](https://docs.praison.ai/docs/features/bot-gateway) |
|
||||
| 📚 | **RAG + Quality-Based RAG** — auto quality scoring retrieval | [Docs](https://praison.ai/docs/concepts/rag) |
|
||||
| 📊 | **Model Router** — auto-routes to cheapest capable model | [Docs](https://praison.ai/docs/features/model-router) |
|
||||
| 🧊 | **Shadow Git Checkpoints** — auto-rollback on failure | [Docs](https://praison.ai/docs/features/checkpoints) |
|
||||
| 📡 | **A2A Protocol** — agent-to-agent interop | [Docs](https://praison.ai/docs/features/a2a) |
|
||||
| 📏 | **Context Compaction** — never hit token limits | [Docs](https://praison.ai/docs/features/context-compaction) |
|
||||
| 📡 | **Telemetry** — OpenTelemetry traces, spans, metrics | [Docs](https://praison.ai/docs/features/telemetry) |
|
||||
| 📜 | **Policy Engine** — declarative agent behavior control | [Docs](https://praison.ai/docs/features/policy-engine) |
|
||||
| 🔄 | **Background Tasks** — fire-and-forget agents | [Docs](https://praison.ai/docs/features/background-tasks) |
|
||||
| 🔁 | **Doom Loop Detection** — auto-recovery from stuck agents | [Docs](https://praison.ai/docs/features/doom-loop-detection) |
|
||||
| 🕸️ | **Graph Memory** — Neo4j-style relationship tracking | [Docs](https://praison.ai/docs/features/graph-memory) |
|
||||
| 🏖️ | **Sandbox Execution** — isolated code execution | [Docs](https://praison.ai/docs/features/sandbox) |
|
||||
| 🖥️ | **Bot Gateway** — multi-agent routing across channels | [Docs](https://praison.ai/docs/features/bot-gateway) |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -465,6 +608,9 @@ Powered by 100+ LLMs (OpenAI, Anthropic, Gemini & local models).
|
||||
|
||||
## 📘 Using Python Code
|
||||
|
||||
<details>
|
||||
<summary><strong>Show Python examples: single agent, multi-agent, MCP, tools, databases, Claw, Langflow, UI</strong></summary>
|
||||
|
||||
> Unsure whether a result is a bug or model randomness? Check the [SDK Behavior Matrix](https://github.com/MervinPraison/PraisonAI/blob/main/src/praisonai-agents/docs/sdk-behavior-matrix.md) for `stream`, memory prefetch, structured output, teams and AG-UI.
|
||||
|
||||
### 1. Single Agent
|
||||
@@ -510,7 +656,7 @@ agent = Agent(
|
||||
)
|
||||
```
|
||||
|
||||
> 📖 [Full MCP docs](https://docs.praison.ai/docs/mcp/transports) — stdio, HTTP, WebSocket, SSE transports
|
||||
> 📖 [Full MCP docs](https://praison.ai/docs/mcp/transports) — stdio, HTTP, WebSocket, SSE transports
|
||||
|
||||
### 4. Custom Tools
|
||||
|
||||
@@ -562,7 +708,7 @@ 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
|
||||
> 📖 [Full tools docs](https://praison.ai/docs/tools/tools) — BaseTool, tool packages, 100+ built-in tools
|
||||
|
||||
### 5. Persistence (Databases)
|
||||
|
||||
@@ -579,7 +725,7 @@ agent = Agent(
|
||||
agent.chat("Hello!") # Auto-persists messages, runs, traces
|
||||
```
|
||||
|
||||
> 📖 [Full persistence docs](https://docs.praison.ai/docs/databases/overview) — PostgreSQL, MySQL, SQLite, MongoDB, Redis, and 20+ more
|
||||
> 📖 [Full persistence docs](https://praison.ai/docs/databases/overview) — PostgreSQL, MySQL, SQLite, MongoDB, Redis, and 20+ more
|
||||
|
||||
### 6. PraisonAI Claw 🦞 (Dashboard UI)
|
||||
|
||||
@@ -601,7 +747,7 @@ Copy `.env.example` to `.env` and configure the following variables:
|
||||
|
||||
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
|
||||
> 📖 [Full Claw docs](https://praison.ai/docs/concepts/claw) — platform tokens, CLI options, Docker, and YAML agent mode
|
||||
|
||||
### 7. Langflow Integration 🔗 (Visual Flow Builder)
|
||||
|
||||
@@ -614,7 +760,7 @@ praisonai flow
|
||||
|
||||
Open **http://localhost:7861** — use the **Agent** and **Agent Team** components to create sequential or parallel workflows. Connect Chat Input → Agent Team → Chat Output for instant multi-agent pipelines.
|
||||
|
||||
> 📖 [Full Flow docs](https://docs.praison.ai/docs/concepts/agentflow) — visual agent building, component reference, and deployment
|
||||
> 📖 [Full Flow docs](https://praison.ai/docs/concepts/agentflow) — visual agent building, component reference, and deployment
|
||||
|
||||
### 8. PraisonAI UI 🤖 (Clean Chat)
|
||||
|
||||
@@ -625,10 +771,15 @@ pip install "praisonai[ui]"
|
||||
praisonai ui
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 📄 Using YAML (No Code)
|
||||
|
||||
<details>
|
||||
<summary><strong>Show YAML examples</strong></summary>
|
||||
|
||||
### Example 1: Two Agents Working Together
|
||||
|
||||
Create `agents.yaml`:
|
||||
@@ -700,6 +851,8 @@ praisonai agents.yaml
|
||||
> - Tools in `tools.py` are automatically discovered
|
||||
> - The function's docstring helps the AI understand how to use it
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🎯 CLI Quick Reference
|
||||
@@ -718,23 +871,26 @@ praisonai agents.yaml
|
||||
| **Development** | `commit`, `docs`, `checkpoint`, `hooks` |
|
||||
| **Scheduling** | `schedule start`, `schedule list`, `schedule stop` |
|
||||
|
||||
> 📖 [Full CLI reference](https://docs.praison.ai/docs/cli/cli-reference)
|
||||
> 📖 [Full CLI reference](https://praison.ai/docs/cli/cli-reference)
|
||||
|
||||
---
|
||||
|
||||
## ✨ Key Features
|
||||
|
||||
<details>
|
||||
<summary><strong>Show all features by category</strong></summary>
|
||||
|
||||
<details open>
|
||||
<summary><strong>🤖 Core Agents</strong></summary>
|
||||
|
||||
| Feature | Code | Docs |
|
||||
|---------|:----:|:----:|
|
||||
| Single Agent | [Example](examples/python/agents/single-agent.py) | [📖](https://docs.praison.ai/docs/agents/single) |
|
||||
| Multi Agents | [Example](examples/python/general/mini_agents_example.py) | [📖](https://docs.praison.ai/docs/concepts/agents) |
|
||||
| Auto Agents | [Example](examples/python/general/auto_agents_example.py) | [📖](https://docs.praison.ai/docs/features/autoagents) |
|
||||
| Self Reflection AI Agents | [Example](examples/python/concepts/self-reflection-details.py) | [📖](https://docs.praison.ai/docs/concepts/reflection) |
|
||||
| Reasoning AI Agents | [Example](examples/python/concepts/reasoning-extraction.py) | [📖](https://docs.praison.ai/docs/features/reasoning) |
|
||||
| Multi Modal AI Agents | [Example](examples/python/general/multimodal.py) | [📖](https://docs.praison.ai/docs/features/multimodal) |
|
||||
| Single Agent | [Example](examples/python/agents/single-agent.py) | [📖](https://praison.ai/docs/agents/single) |
|
||||
| Multi Agents | [Example](examples/python/general/mini_agents_example.py) | [📖](https://praison.ai/docs/concepts/agents) |
|
||||
| Auto Agents | [Example](examples/python/general/auto_agents_example.py) | [📖](https://praison.ai/docs/features/autoagents) |
|
||||
| Self Reflection AI Agents | [Example](examples/python/concepts/self-reflection-details.py) | [📖](https://praison.ai/docs/concepts/reflection) |
|
||||
| Reasoning AI Agents | [Example](examples/python/concepts/reasoning-extraction.py) | [📖](https://praison.ai/docs/features/reasoning) |
|
||||
| Multi Modal AI Agents | [Example](examples/python/general/multimodal.py) | [📖](https://praison.ai/docs/features/multimodal) |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -743,16 +899,16 @@ praisonai agents.yaml
|
||||
|
||||
| Feature | Code | Docs |
|
||||
|---------|:----:|:----:|
|
||||
| Simple Workflow | [Example](examples/python/workflows/simple_workflow.py) | [📖](https://docs.praison.ai/docs/concepts/agentflow) |
|
||||
| Workflow with Agents | [Example](examples/python/workflows/workflow_with_agents.py) | [📖](https://docs.praison.ai/docs/concepts/agentflow) |
|
||||
| Agentic Routing (`route()`) | [Example](examples/python/workflows/workflow_routing.py) | [📖](https://docs.praison.ai/docs/features/routing) |
|
||||
| Parallel Execution (`parallel()`) | [Example](examples/python/workflows/workflow_parallel.py) | [📖](https://docs.praison.ai/docs/features/parallelisation) |
|
||||
| Loop over List/CSV (`loop()`) | [Example](examples/python/workflows/workflow_loop_csv.py) | [📖](https://docs.praison.ai/docs/features/repetitive) |
|
||||
| Evaluator-Optimizer (`repeat()`) | [Example](examples/python/workflows/workflow_repeat.py) | [📖](https://docs.praison.ai/docs/concepts/evaluation) |
|
||||
| Conditional Steps | [Example](examples/python/workflows/workflow_conditional.py) | [📖](https://docs.praison.ai/docs/concepts/agentflow) |
|
||||
| Workflow Branching | [Example](examples/python/workflows/workflow_branching.py) | [📖](https://docs.praison.ai/docs/concepts/agentflow) |
|
||||
| Workflow Early Stop | [Example](examples/python/workflows/workflow_early_stop.py) | [📖](https://docs.praison.ai/docs/concepts/agentflow) |
|
||||
| Workflow Checkpoints | [Example](examples/python/workflows/workflow_checkpoints.py) | [📖](https://docs.praison.ai/docs/concepts/agentflow) |
|
||||
| Simple Workflow | [Example](examples/python/workflows/simple_workflow.py) | [📖](https://praison.ai/docs/concepts/agentflow) |
|
||||
| Workflow with Agents | [Example](examples/python/workflows/workflow_with_agents.py) | [📖](https://praison.ai/docs/concepts/agentflow) |
|
||||
| Agentic Routing (`route()`) | [Example](examples/python/workflows/workflow_routing.py) | [📖](https://praison.ai/docs/features/routing) |
|
||||
| Parallel Execution (`parallel()`) | [Example](examples/python/workflows/workflow_parallel.py) | [📖](https://praison.ai/docs/features/parallelisation) |
|
||||
| Loop over List/CSV (`loop()`) | [Example](examples/python/workflows/workflow_loop_csv.py) | [📖](https://praison.ai/docs/features/repetitive) |
|
||||
| Evaluator-Optimizer (`repeat()`) | [Example](examples/python/workflows/workflow_repeat.py) | [📖](https://praison.ai/docs/concepts/evaluation) |
|
||||
| Conditional Steps | [Example](examples/python/workflows/workflow_conditional.py) | [📖](https://praison.ai/docs/concepts/agentflow) |
|
||||
| Workflow Branching | [Example](examples/python/workflows/workflow_branching.py) | [📖](https://praison.ai/docs/concepts/agentflow) |
|
||||
| Workflow Early Stop | [Example](examples/python/workflows/workflow_early_stop.py) | [📖](https://praison.ai/docs/concepts/agentflow) |
|
||||
| Workflow Checkpoints | [Example](examples/python/workflows/workflow_checkpoints.py) | [📖](https://praison.ai/docs/concepts/agentflow) |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -761,13 +917,13 @@ praisonai agents.yaml
|
||||
|
||||
| Feature | Code | Docs |
|
||||
|---------|:----:|:----:|
|
||||
| Code Interpreter Agents | [Example](examples/python/agents/code-agent.py) | [📖](https://docs.praison.ai/docs/features/codeagent) |
|
||||
| AI Code Editing Tools | [Example](examples/python/code/code_editing_example.py) | [📖](https://docs.praison.ai/docs/code/editing) |
|
||||
| External Agents (All) | [Example](examples/python/code/external_agents_example.py) | [📖](https://docs.praison.ai/docs/code/external-agents) |
|
||||
| Claude Code CLI | [Example](examples/python/code/claude_code_example.py) | [📖](https://docs.praison.ai/docs/code/claude-code) |
|
||||
| Gemini CLI | [Example](examples/python/code/gemini_cli_example.py) | [📖](https://docs.praison.ai/docs/code/gemini-cli) |
|
||||
| Codex CLI | [Example](examples/python/code/codex_cli_example.py) | [📖](https://docs.praison.ai/docs/code/codex-cli) |
|
||||
| Cursor CLI | [Example](examples/python/code/cursor_cli_example.py) | [📖](https://docs.praison.ai/docs/code/cursor-cli) |
|
||||
| Code Interpreter Agents | [Example](examples/python/agents/code-agent.py) | [📖](https://praison.ai/docs/features/codeagent) |
|
||||
| AI Code Editing Tools | [Example](examples/python/code/code_editing_example.py) | [📖](https://praison.ai/docs/code/editing) |
|
||||
| External Agents (All) | [Example](examples/python/code/external_agents_example.py) | [📖](https://praison.ai/docs/code/external-agents) |
|
||||
| Claude Code CLI | [Example](examples/python/code/claude_code_example.py) | [📖](https://praison.ai/docs/code/claude-code) |
|
||||
| Gemini CLI | [Example](examples/python/code/gemini_cli_example.py) | [📖](https://praison.ai/docs/code/gemini-cli) |
|
||||
| Codex CLI | [Example](examples/python/code/codex_cli_example.py) | [📖](https://praison.ai/docs/code/codex-cli) |
|
||||
| Cursor CLI | [Example](examples/python/code/cursor_cli_example.py) | [📖](https://praison.ai/docs/code/cursor-cli) |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -776,18 +932,18 @@ praisonai agents.yaml
|
||||
|
||||
| Feature | Code | Docs |
|
||||
|---------|:----:|:----:|
|
||||
| Memory (Short & Long Term) | [Example](examples/python/general/memory_example.py) | [📖](https://docs.praison.ai/docs/concepts/memory) |
|
||||
| File-Based Memory | [Example](examples/python/general/memory_example.py) | [📖](https://docs.praison.ai/docs/concepts/memory) |
|
||||
| Claude Memory Tool | [Example](examples/python/memory/claude_memory_example.py) | [📖](https://docs.praison.ai/docs/features/claude-memory-tool) |
|
||||
| Add Custom Knowledge | [Example](examples/python/concepts/knowledge-agents.py) | [📖](https://docs.praison.ai/docs/concepts/knowledge) |
|
||||
| RAG Agents | [Example](examples/python/concepts/rag-agents.py) | [📖](https://docs.praison.ai/docs/concepts/rag) |
|
||||
| Chat with PDF Agents | [Example](examples/python/concepts/chat-with-pdf.py) | [📖](https://docs.praison.ai/docs/features/chat-with-pdf) |
|
||||
| Data Readers (PDF, DOCX, etc.) | [CLI](https://docs.praison.ai/docs/cli/knowledge) | [📖](https://docs.praison.ai/docs/features/chunking-strategies) |
|
||||
| Vector Store Selection | [CLI](https://docs.praison.ai/docs/cli/knowledge) | [📖](https://docs.praison.ai/docs/features/knowledge-backends) |
|
||||
| Retrieval Strategies | [CLI](https://docs.praison.ai/docs/cli/knowledge) | [📖](https://docs.praison.ai/docs/features/retrieval-strategies) |
|
||||
| Rerankers | [CLI](https://docs.praison.ai/docs/cli/knowledge) | [📖](https://docs.praison.ai/docs/features/smart-retrieval) |
|
||||
| Index Types (Vector/Keyword/Hybrid) | [CLI](https://docs.praison.ai/docs/cli/knowledge) | [📖](https://docs.praison.ai/docs/features/incremental-indexing) |
|
||||
| Query Engines (Sub-Question, etc.) | [CLI](https://docs.praison.ai/docs/cli/knowledge) | [📖](https://docs.praison.ai/docs/features/retrieval) |
|
||||
| Memory (Short & Long Term) | [Example](examples/python/general/memory_example.py) | [📖](https://praison.ai/docs/concepts/memory) |
|
||||
| File-Based Memory | [Example](examples/python/general/memory_example.py) | [📖](https://praison.ai/docs/concepts/memory) |
|
||||
| Claude Memory Tool | [Example](examples/python/memory/claude_memory_example.py) | [📖](https://praison.ai/docs/features/claude-memory-tool) |
|
||||
| Add Custom Knowledge | [Example](examples/python/concepts/knowledge-agents.py) | [📖](https://praison.ai/docs/concepts/knowledge) |
|
||||
| RAG Agents | [Example](examples/python/concepts/rag-agents.py) | [📖](https://praison.ai/docs/concepts/rag) |
|
||||
| Chat with PDF Agents | [Example](examples/python/concepts/chat-with-pdf.py) | [📖](https://praison.ai/docs/features/chat-with-pdf) |
|
||||
| Data Readers (PDF, DOCX, etc.) | [CLI](https://praison.ai/docs/cli/knowledge) | [📖](https://praison.ai/docs/features/chunking-strategies) |
|
||||
| Vector Store Selection | [CLI](https://praison.ai/docs/cli/knowledge) | [📖](https://praison.ai/docs/features/knowledge-backends) |
|
||||
| Retrieval Strategies | [CLI](https://praison.ai/docs/cli/knowledge) | [📖](https://praison.ai/docs/features/retrieval-strategies) |
|
||||
| Rerankers | [CLI](https://praison.ai/docs/cli/knowledge) | [📖](https://praison.ai/docs/features/smart-retrieval) |
|
||||
| Index Types (Vector/Keyword/Hybrid) | [CLI](https://praison.ai/docs/cli/knowledge) | [📖](https://praison.ai/docs/features/incremental-indexing) |
|
||||
| Query Engines (Sub-Question, etc.) | [CLI](https://praison.ai/docs/cli/knowledge) | [📖](https://praison.ai/docs/features/retrieval) |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -796,12 +952,12 @@ praisonai agents.yaml
|
||||
|
||||
| Feature | Code | Docs |
|
||||
|---------|:----:|:----:|
|
||||
| Deep Research Agents | [Example](examples/python/agents/research-agent.py) | [📖](https://docs.praison.ai/docs/agents/deep-research) |
|
||||
| Query Rewriter Agent | [Example](examples/python/agents/query-rewriter-agent.py) | [📖](https://docs.praison.ai/docs/agents/query-rewriter) |
|
||||
| Native Web Search | [Example](examples/python/agents/websearch-agent.py) | [📖](https://docs.praison.ai/docs/agents/websearch) |
|
||||
| Built-in Search Tools | [Example](examples/python/agents/websearch-agent.py) | [📖](https://docs.praison.ai/docs/tools/tavily) |
|
||||
| Unified Web Search | [Example](examples/python/web_search_example.py) | [📖](https://docs.praison.ai/docs/tools/web-search) |
|
||||
| Web Fetch (Anthropic) | [Example](examples/python/agents/web-fetch-agent.py) | [📖](https://docs.praison.ai/docs/features/model-capabilities) |
|
||||
| Deep Research Agents | [Example](examples/python/agents/research-agent.py) | [📖](https://praison.ai/docs/agents/deep-research) |
|
||||
| Query Rewriter Agent | [Example](examples/python/agents/query-rewriter-agent.py) | [📖](https://praison.ai/docs/agents/query-rewriter) |
|
||||
| Native Web Search | [Example](examples/python/agents/websearch-agent.py) | [📖](https://praison.ai/docs/agents/websearch) |
|
||||
| Built-in Search Tools | [Example](examples/python/agents/websearch-agent.py) | [📖](https://praison.ai/docs/tools/tavily) |
|
||||
| Unified Web Search | [Example](examples/python/web_search_example.py) | [📖](https://praison.ai/docs/tools/web-search) |
|
||||
| Web Fetch (Anthropic) | [Example](examples/python/agents/web-fetch-agent.py) | [📖](https://praison.ai/docs/features/model-capabilities) |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -810,12 +966,12 @@ praisonai agents.yaml
|
||||
|
||||
| Feature | Code | Docs |
|
||||
|---------|:----:|:----:|
|
||||
| Planning Mode | [Example](examples/python/agents/planning-agent.py) | [📖](https://docs.praison.ai/docs/concepts/planning) |
|
||||
| Planning Tools | [Example](examples/python/agents/planning-agent.py) | [📖](https://docs.praison.ai/docs/concepts/planning) |
|
||||
| Planning Reasoning | [Example](examples/python/agents/planning-agent.py) | [📖](https://docs.praison.ai/docs/concepts/planning) |
|
||||
| Prompt Chaining | [Example](examples/python/general/prompt_chaining.py) | [📖](https://docs.praison.ai/docs/features/promptchaining) |
|
||||
| Evaluator Optimiser | [Example](examples/python/general/evaluator-optimiser.py) | [📖](https://docs.praison.ai/docs/concepts/evaluation) |
|
||||
| Orchestrator Workers | [Example](examples/python/general/orchestrator-workers.py) | [📖](https://docs.praison.ai/docs/concepts/orchestration) |
|
||||
| Planning Mode | [Example](examples/python/agents/planning-agent.py) | [📖](https://praison.ai/docs/concepts/planning) |
|
||||
| Planning Tools | [Example](examples/python/agents/planning-agent.py) | [📖](https://praison.ai/docs/concepts/planning) |
|
||||
| Planning Reasoning | [Example](examples/python/agents/planning-agent.py) | [📖](https://praison.ai/docs/concepts/planning) |
|
||||
| Prompt Chaining | [Example](examples/python/general/prompt_chaining.py) | [📖](https://praison.ai/docs/features/promptchaining) |
|
||||
| Evaluator Optimiser | [Example](examples/python/general/evaluator-optimiser.py) | [📖](https://praison.ai/docs/concepts/evaluation) |
|
||||
| Orchestrator Workers | [Example](examples/python/general/orchestrator-workers.py) | [📖](https://praison.ai/docs/concepts/orchestration) |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -824,15 +980,15 @@ praisonai agents.yaml
|
||||
|
||||
| Feature | Code | Docs |
|
||||
|---------|:----:|:----:|
|
||||
| Data Analyst Agent | [Example](examples/python/agents/data-analyst-agent.py) | [📖](https://docs.praison.ai/docs/agents/data-analyst) |
|
||||
| Finance Agent | [Example](examples/python/agents/finance-agent.py) | [📖](https://docs.praison.ai/docs/agents/finance) |
|
||||
| Shopping Agent | [Example](examples/python/agents/shopping-agent.py) | [📖](https://docs.praison.ai/docs/agents/shopping) |
|
||||
| Recommendation Agent | [Example](examples/python/agents/recommendation-agent.py) | [📖](https://docs.praison.ai/docs/agents/recommendation) |
|
||||
| Wikipedia Agent | [Example](examples/python/agents/wikipedia-agent.py) | [📖](https://docs.praison.ai/docs/agents/wikipedia) |
|
||||
| Programming Agent | [Example](examples/python/agents/programming-agent.py) | [📖](https://docs.praison.ai/docs/agents/programming) |
|
||||
| Math Agents | [Example](examples/python/agents/math-agent.py) | [📖](https://docs.praison.ai/docs/features/mathagent) |
|
||||
| Markdown Agent | [Example](examples/python/agents/markdown-agent.py) | [📖](https://docs.praison.ai/docs/agents/markdown) |
|
||||
| Prompt Expander Agent | [Example](examples/python/agents/prompt-expander-agent.py) | [📖](https://docs.praison.ai/docs/agents/prompt-expander) |
|
||||
| Data Analyst Agent | [Example](examples/python/agents/data-analyst-agent.py) | [📖](https://praison.ai/docs/agents/data-analyst) |
|
||||
| Finance Agent | [Example](examples/python/agents/finance-agent.py) | [📖](https://praison.ai/docs/agents/finance) |
|
||||
| Shopping Agent | [Example](examples/python/agents/shopping-agent.py) | [📖](https://praison.ai/docs/agents/shopping) |
|
||||
| Recommendation Agent | [Example](examples/python/agents/recommendation-agent.py) | [📖](https://praison.ai/docs/agents/recommendation) |
|
||||
| Wikipedia Agent | [Example](examples/python/agents/wikipedia-agent.py) | [📖](https://praison.ai/docs/agents/wikipedia) |
|
||||
| Programming Agent | [Example](examples/python/agents/programming-agent.py) | [📖](https://praison.ai/docs/agents/programming) |
|
||||
| Math Agents | [Example](examples/python/agents/math-agent.py) | [📖](https://praison.ai/docs/features/mathagent) |
|
||||
| Markdown Agent | [Example](examples/python/agents/markdown-agent.py) | [📖](https://praison.ai/docs/agents/markdown) |
|
||||
| Prompt Expander Agent | [Example](examples/python/agents/prompt-expander-agent.py) | [📖](https://praison.ai/docs/agents/prompt-expander) |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -841,10 +997,10 @@ praisonai agents.yaml
|
||||
|
||||
| Feature | Code | Docs |
|
||||
|---------|:----:|:----:|
|
||||
| Image Generation Agent | [Example](examples/python/image/image-agent.py) | [📖](https://docs.praison.ai/docs/features/image-generation) |
|
||||
| Image to Text Agent | [Example](examples/python/agents/image-to-text-agent.py) | [📖](https://docs.praison.ai/docs/agents/image-to-text) |
|
||||
| Video Agent | [Example](examples/python/agents/video-agent.py) | [📖](https://docs.praison.ai/docs/agents/video) |
|
||||
| Camera Integration | [Example](examples/python/camera/) | [📖](https://docs.praison.ai/docs/features/camera-integration) |
|
||||
| Image Generation Agent | [Example](examples/python/image/image-agent.py) | [📖](https://praison.ai/docs/features/image-generation) |
|
||||
| Image to Text Agent | [Example](examples/python/agents/image-to-text-agent.py) | [📖](https://praison.ai/docs/agents/image-to-text) |
|
||||
| Video Agent | [Example](examples/python/agents/video-agent.py) | [📖](https://praison.ai/docs/agents/video) |
|
||||
| Camera Integration | [Example](examples/python/camera/) | [📖](https://praison.ai/docs/features/camera-integration) |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -853,12 +1009,12 @@ praisonai agents.yaml
|
||||
|
||||
| Feature | Code | Docs |
|
||||
|---------|:----:|:----:|
|
||||
| MCP Transports | [Example](examples/python/mcp/mcp-transports-overview.py) | [📖](https://docs.praison.ai/docs/mcp/transports) |
|
||||
| WebSocket MCP | [Example](examples/python/mcp/websocket-mcp.py) | [📖](https://docs.praison.ai/docs/mcp/sse-transport) |
|
||||
| MCP Security | [Example](examples/python/mcp/mcp-security.py) | [📖](https://docs.praison.ai/docs/mcp/transports) |
|
||||
| MCP Resumability | [Example](examples/python/mcp/mcp-resumability.py) | [📖](https://docs.praison.ai/docs/mcp/sse-transport) |
|
||||
| MCP Config Management | [Docs](https://docs.praison.ai/docs/cli/mcp) | [📖](https://docs.praison.ai/docs/cli/mcp) |
|
||||
| LangChain Integrated Agents | [Example](examples/python/general/langchain_example.py) | [📖](https://docs.praison.ai/docs/features/langchain) |
|
||||
| MCP Transports | [Example](examples/python/mcp/mcp-transports-overview.py) | [📖](https://praison.ai/docs/mcp/transports) |
|
||||
| WebSocket MCP | [Example](examples/python/mcp/websocket-mcp.py) | [📖](https://praison.ai/docs/mcp/sse-transport) |
|
||||
| MCP Security | [Example](examples/python/mcp/mcp-security.py) | [📖](https://praison.ai/docs/mcp/transports) |
|
||||
| MCP Resumability | [Example](examples/python/mcp/mcp-resumability.py) | [📖](https://praison.ai/docs/mcp/sse-transport) |
|
||||
| MCP Config Management | [Docs](https://praison.ai/docs/cli/mcp) | [📖](https://praison.ai/docs/cli/mcp) |
|
||||
| LangChain Integrated Agents | [Example](examples/python/general/langchain_example.py) | [📖](https://praison.ai/docs/features/langchain) |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -867,9 +1023,9 @@ praisonai agents.yaml
|
||||
|
||||
| Feature | Code | Docs |
|
||||
|---------|:----:|:----:|
|
||||
| Guardrails | [Example](examples/python/guardrails/comprehensive-guardrails-example.py) | [📖](https://docs.praison.ai/docs/concepts/guardrails) |
|
||||
| Human Approval | [Example](examples/python/general/human_approval_example.py) | [📖](https://docs.praison.ai/docs/concepts/approval) |
|
||||
| Rules & Instructions | [Docs](https://docs.praison.ai/docs/features/rules) | [📖](https://docs.praison.ai/docs/features/rules) |
|
||||
| Guardrails | [Example](examples/python/guardrails/comprehensive-guardrails-example.py) | [📖](https://praison.ai/docs/concepts/guardrails) |
|
||||
| Human Approval | [Example](examples/python/general/human_approval_example.py) | [📖](https://praison.ai/docs/concepts/approval) |
|
||||
| Rules & Instructions | [Docs](https://praison.ai/docs/features/rules) | [📖](https://praison.ai/docs/features/rules) |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -878,16 +1034,16 @@ praisonai agents.yaml
|
||||
|
||||
| Feature | Code | Docs |
|
||||
|---------|:----:|:----:|
|
||||
| Async & Parallel Processing | [Example](examples/python/general/async_example.py) | [📖](https://docs.praison.ai/docs/features/async) |
|
||||
| Parallelisation | [Example](examples/python/general/parallelisation.py) | [📖](https://docs.praison.ai/docs/features/parallelisation) |
|
||||
| Repetitive Agents | [Example](examples/python/concepts/repetitive-agents.py) | [📖](https://docs.praison.ai/docs/features/repetitive) |
|
||||
| Agent Handoffs | [Example](examples/python/handoff/handoff_basic.py) | [📖](https://docs.praison.ai/docs/concepts/handoffs) |
|
||||
| Stateful Agents | [Example](examples/python/stateful/workflow-state-example.py) | [📖](https://docs.praison.ai/docs/features/stateful-agents) |
|
||||
| Autonomous Workflow | [Example](examples/python/general/autonomous-agent.py) | [📖](https://docs.praison.ai/docs/concepts/autonomy) |
|
||||
| Structured Output Agents | [Example](examples/python/general/structured_agents_example.py) | [📖](https://docs.praison.ai/docs/features/structured) |
|
||||
| Model Router | [Example](examples/python/agents/router-agent-cost-optimization.py) | [📖](https://docs.praison.ai/docs/features/model-router) |
|
||||
| Prompt Caching | [Example](examples/python/agents/prompt-caching-agent.py) | [📖](https://docs.praison.ai/docs/features/model-capabilities) |
|
||||
| Fast Context | [Example](examples/context/00_agent_fast_context_basic.py) | [📖](https://docs.praison.ai/docs/features/fast-context) |
|
||||
| Async & Parallel Processing | [Example](examples/python/general/async_example.py) | [📖](https://praison.ai/docs/features/async) |
|
||||
| Parallelisation | [Example](examples/python/general/parallelisation.py) | [📖](https://praison.ai/docs/features/parallelisation) |
|
||||
| Repetitive Agents | [Example](examples/python/concepts/repetitive-agents.py) | [📖](https://praison.ai/docs/features/repetitive) |
|
||||
| Agent Handoffs | [Example](examples/python/handoff/handoff_basic.py) | [📖](https://praison.ai/docs/concepts/handoffs) |
|
||||
| Stateful Agents | [Example](examples/python/stateful/workflow-state-example.py) | [📖](https://praison.ai/docs/features/stateful-agents) |
|
||||
| Autonomous Workflow | [Example](examples/python/general/autonomous-agent.py) | [📖](https://praison.ai/docs/concepts/autonomy) |
|
||||
| Structured Output Agents | [Example](examples/python/general/structured_agents_example.py) | [📖](https://praison.ai/docs/features/structured) |
|
||||
| Model Router | [Example](examples/python/agents/router-agent-cost-optimization.py) | [📖](https://praison.ai/docs/features/model-router) |
|
||||
| Prompt Caching | [Example](examples/python/agents/prompt-caching-agent.py) | [📖](https://praison.ai/docs/features/model-capabilities) |
|
||||
| Fast Context | [Example](examples/context/00_agent_fast_context_basic.py) | [📖](https://praison.ai/docs/features/fast-context) |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -896,21 +1052,21 @@ praisonai agents.yaml
|
||||
|
||||
| Feature | Code | Docs |
|
||||
|---------|:----:|:----:|
|
||||
| 100+ Custom Tools | [Example](examples/python/general/tools_example.py) | [📖](https://docs.praison.ai/docs/tools/tools) |
|
||||
| YAML Configuration | [Example](examples/cookbooks/yaml/secondary_market_research_agents.yaml) | [📖](https://docs.praison.ai/docs/developers/agents-playbook) |
|
||||
| 100+ LLM Support | [Example](examples/python/providers/openai/openai_gpt4_example.py) | [📖](https://docs.praison.ai/docs/models) |
|
||||
| Callback Agents | [Example](examples/python/general/advanced-callback-systems.py) | [📖](https://docs.praison.ai/docs/concepts/hooks) |
|
||||
| Hooks | [Example](examples/python/hooks/hooks_example.py) | [📖](https://docs.praison.ai/docs/concepts/hooks) |
|
||||
| Middleware System | [Example](examples/middleware/basic_middleware.py) | [📖](https://docs.praison.ai/docs/features/middleware) |
|
||||
| Configurable Model | [Example](examples/middleware/configurable_model.py) | [📖](https://docs.praison.ai/docs/features/configurable-model) |
|
||||
| Rate Limiter | [Example](examples/middleware/rate_limiter.py) | [📖](https://docs.praison.ai/docs/features/rate-limiter) |
|
||||
| Injected Tool State | [Example](examples/middleware/injected_state.py) | [📖](https://docs.praison.ai/docs/features/injected-state) |
|
||||
| Shadow Git Checkpoints | [Example](examples/checkpoints/basic_checkpoint.py) | [📖](https://docs.praison.ai/docs/features/checkpoints) |
|
||||
| Background Tasks | [Example](examples/background/basic_background.py) | [📖](https://docs.praison.ai/docs/features/background-tasks) |
|
||||
| Policy Engine | [Example](examples/policy/basic_policy.py) | [📖](https://docs.praison.ai/docs/features/policy-engine) |
|
||||
| Thinking Budgets | [Example](examples/thinking/basic_thinking.py) | [📖](https://docs.praison.ai/docs/features/thinking-budgets) |
|
||||
| Output Styles | [Example](examples/output/basic_output.py) | [📖](https://docs.praison.ai/docs/features/output-styles) |
|
||||
| Context Compaction | [Example](examples/compaction/basic_compaction.py) | [📖](https://docs.praison.ai/docs/features/context-compaction) |
|
||||
| 100+ Custom Tools | [Example](examples/python/general/tools_example.py) | [📖](https://praison.ai/docs/tools/tools) |
|
||||
| YAML Configuration | [Example](examples/cookbooks/yaml/secondary_market_research_agents.yaml) | [📖](https://praison.ai/docs/developers/agents-playbook) |
|
||||
| 100+ LLM Support | [Example](examples/python/providers/openai/openai_gpt4_example.py) | [📖](https://praison.ai/docs/models) |
|
||||
| Callback Agents | [Example](examples/python/general/advanced-callback-systems.py) | [📖](https://praison.ai/docs/concepts/hooks) |
|
||||
| Hooks | [Example](examples/python/hooks/hooks_example.py) | [📖](https://praison.ai/docs/concepts/hooks) |
|
||||
| Middleware System | [Example](examples/middleware/basic_middleware.py) | [📖](https://praison.ai/docs/features/middleware) |
|
||||
| Configurable Model | [Example](examples/middleware/configurable_model.py) | [📖](https://praison.ai/docs/features/configurable-model) |
|
||||
| Rate Limiter | [Example](examples/middleware/rate_limiter.py) | [📖](https://praison.ai/docs/features/rate-limiter) |
|
||||
| Injected Tool State | [Example](examples/middleware/injected_state.py) | [📖](https://praison.ai/docs/features/injected-state) |
|
||||
| Shadow Git Checkpoints | [Example](examples/checkpoints/basic_checkpoint.py) | [📖](https://praison.ai/docs/features/checkpoints) |
|
||||
| Background Tasks | [Example](examples/background/basic_background.py) | [📖](https://praison.ai/docs/features/background-tasks) |
|
||||
| Policy Engine | [Example](examples/policy/basic_policy.py) | [📖](https://praison.ai/docs/features/policy-engine) |
|
||||
| Thinking Budgets | [Example](examples/thinking/basic_thinking.py) | [📖](https://praison.ai/docs/features/thinking-budgets) |
|
||||
| Output Styles | [Example](examples/output/basic_output.py) | [📖](https://praison.ai/docs/features/output-styles) |
|
||||
| Context Compaction | [Example](examples/compaction/basic_compaction.py) | [📖](https://praison.ai/docs/features/context-compaction) |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -919,14 +1075,14 @@ praisonai agents.yaml
|
||||
|
||||
| Feature | Code | Docs |
|
||||
|---------|:----:|:----:|
|
||||
| Sessions Management | [Example](examples/python/sessions/comprehensive-session-management.py) | [📖](https://docs.praison.ai/docs/concepts/session-management) |
|
||||
| Auto-Save Sessions | [Docs](https://docs.praison.ai/docs/cli/session) | [📖](https://docs.praison.ai/docs/cli/session) |
|
||||
| History in Context | [Docs](https://docs.praison.ai/docs/cli/session) | [📖](https://docs.praison.ai/docs/cli/session) |
|
||||
| Telemetry | [Example](examples/python/telemetry/production-telemetry-example.py) | [📖](https://docs.praison.ai/docs/features/telemetry) |
|
||||
| Langfuse Tracing | [Docs](https://docs.praison.ai/docs/observability/langfuse) | [📖](https://docs.praison.ai/docs/observability/langfuse) |
|
||||
| Project Docs (.praison/docs/) | [Docs](https://docs.praison.ai/docs/cli/docs) | [📖](https://docs.praison.ai/docs/cli/docs) |
|
||||
| AI Commit Messages | [Docs](https://docs.praison.ai/docs/cli/commit) | [📖](https://docs.praison.ai/docs/cli/commit) |
|
||||
| @Mentions in Prompts | [Docs](https://docs.praison.ai/docs/cli/mentions) | [📖](https://docs.praison.ai/docs/cli/mentions) |
|
||||
| Sessions Management | [Example](examples/python/sessions/comprehensive-session-management.py) | [📖](https://praison.ai/docs/concepts/session-management) |
|
||||
| Auto-Save Sessions | [Docs](https://praison.ai/docs/cli/session) | [📖](https://praison.ai/docs/cli/session) |
|
||||
| History in Context | [Docs](https://praison.ai/docs/cli/session) | [📖](https://praison.ai/docs/cli/session) |
|
||||
| Telemetry | [Example](examples/python/telemetry/production-telemetry-example.py) | [📖](https://praison.ai/docs/features/telemetry) |
|
||||
| Langfuse Tracing | [Docs](https://praison.ai/docs/observability/langfuse) | [📖](https://praison.ai/docs/observability/langfuse) |
|
||||
| Project Docs (.praison/docs/) | [Docs](https://praison.ai/docs/cli/docs) | [📖](https://praison.ai/docs/cli/docs) |
|
||||
| AI Commit Messages | [Docs](https://praison.ai/docs/cli/commit) | [📖](https://praison.ai/docs/cli/commit) |
|
||||
| @Mentions in Prompts | [Docs](https://praison.ai/docs/cli/mentions) | [📖](https://praison.ai/docs/cli/mentions) |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -935,20 +1091,20 @@ praisonai agents.yaml
|
||||
|
||||
| Feature | Code | Docs |
|
||||
|---------|:----:|:----:|
|
||||
| Slash Commands | [Example](examples/python/cli/slash_commands_example.py) | [📖](https://docs.praison.ai/docs/cli/slash-commands) |
|
||||
| Autonomy Modes | [Example](examples/python/cli/autonomy_modes_example.py) | [📖](https://docs.praison.ai/docs/cli/autonomy-modes) |
|
||||
| Cost Tracking | [Example](examples/python/cli/cost_tracking_example.py) | [📖](https://docs.praison.ai/docs/cli/cost-tracking) |
|
||||
| Repository Map | [Example](examples/python/cli/repo_map_example.py) | [📖](https://docs.praison.ai/docs/cli/repo-map) |
|
||||
| Interactive TUI | [Example](examples/python/cli/interactive_tui_example.py) | [📖](https://docs.praison.ai/docs/cli/interactive-tui) |
|
||||
| Git Integration | [Example](examples/python/cli/git_integration_example.py) | [📖](https://docs.praison.ai/docs/cli/git-integration) |
|
||||
| Sandbox Execution | [Example](examples/python/cli/sandbox_execution_example.py) | [📖](https://docs.praison.ai/docs/cli/sandbox-execution) |
|
||||
| CLI Compare | [Example](examples/compare/cli_compare_basic.py) | [📖](https://docs.praison.ai/docs/cli/compare) |
|
||||
| Profile/Benchmark | [Docs](https://docs.praison.ai/docs/cli/profile) | [📖](https://docs.praison.ai/docs/cli/profile) |
|
||||
| Auto Mode | [Docs](https://docs.praison.ai/docs/cli/auto) | [📖](https://docs.praison.ai/docs/cli/auto) |
|
||||
| Init | [Docs](https://docs.praison.ai/docs/cli/init) | [📖](https://docs.praison.ai/docs/cli/init) |
|
||||
| File Input | [Docs](https://docs.praison.ai/docs/cli/file-input) | [📖](https://docs.praison.ai/docs/cli/file-input) |
|
||||
| Final Agent | [Docs](https://docs.praison.ai/docs/cli/final-agent) | [📖](https://docs.praison.ai/docs/cli/final-agent) |
|
||||
| Max Tokens | [Docs](https://docs.praison.ai/docs/cli/max-tokens) | [📖](https://docs.praison.ai/docs/cli/max-tokens) |
|
||||
| Slash Commands | [Example](examples/python/cli/slash_commands_example.py) | [📖](https://praison.ai/docs/cli/slash-commands) |
|
||||
| Autonomy Modes | [Example](examples/python/cli/autonomy_modes_example.py) | [📖](https://praison.ai/docs/cli/autonomy-modes) |
|
||||
| Cost Tracking | [Example](examples/python/cli/cost_tracking_example.py) | [📖](https://praison.ai/docs/cli/cost-tracking) |
|
||||
| Repository Map | [Example](examples/python/cli/repo_map_example.py) | [📖](https://praison.ai/docs/cli/repo-map) |
|
||||
| Interactive TUI | [Example](examples/python/cli/interactive_tui_example.py) | [📖](https://praison.ai/docs/cli/interactive-tui) |
|
||||
| Git Integration | [Example](examples/python/cli/git_integration_example.py) | [📖](https://praison.ai/docs/cli/git-integration) |
|
||||
| Sandbox Execution | [Example](examples/python/cli/sandbox_execution_example.py) | [📖](https://praison.ai/docs/cli/sandbox-execution) |
|
||||
| CLI Compare | [Example](examples/compare/cli_compare_basic.py) | [📖](https://praison.ai/docs/cli/compare) |
|
||||
| Profile/Benchmark | [Docs](https://praison.ai/docs/cli/profile) | [📖](https://praison.ai/docs/cli/profile) |
|
||||
| Auto Mode | [Docs](https://praison.ai/docs/cli/auto) | [📖](https://praison.ai/docs/cli/auto) |
|
||||
| Init | [Docs](https://praison.ai/docs/cli/init) | [📖](https://praison.ai/docs/cli/init) |
|
||||
| File Input | [Docs](https://praison.ai/docs/cli/file-input) | [📖](https://praison.ai/docs/cli/file-input) |
|
||||
| Final Agent | [Docs](https://praison.ai/docs/cli/final-agent) | [📖](https://praison.ai/docs/cli/final-agent) |
|
||||
| Max Tokens | [Docs](https://praison.ai/docs/cli/max-tokens) | [📖](https://praison.ai/docs/cli/max-tokens) |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -957,10 +1113,10 @@ praisonai agents.yaml
|
||||
|
||||
| Feature | Code | Docs |
|
||||
|---------|:----:|:----:|
|
||||
| Accuracy Evaluation | [Example](examples/eval/accuracy_example.py) | [📖](https://docs.praison.ai/docs/cli/eval) |
|
||||
| Performance Evaluation | [Example](examples/eval/performance_example.py) | [📖](https://docs.praison.ai/docs/cli/eval) |
|
||||
| Reliability Evaluation | [Example](examples/eval/reliability_example.py) | [📖](https://docs.praison.ai/docs/cli/eval) |
|
||||
| Criteria Evaluation | [Example](examples/eval/criteria_example.py) | [📖](https://docs.praison.ai/docs/cli/eval) |
|
||||
| Accuracy Evaluation | [Example](examples/eval/accuracy_example.py) | [📖](https://praison.ai/docs/cli/eval) |
|
||||
| Performance Evaluation | [Example](examples/eval/performance_example.py) | [📖](https://praison.ai/docs/cli/eval) |
|
||||
| Reliability Evaluation | [Example](examples/eval/reliability_example.py) | [📖](https://praison.ai/docs/cli/eval) |
|
||||
| Criteria Evaluation | [Example](examples/eval/criteria_example.py) | [📖](https://praison.ai/docs/cli/eval) |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -969,8 +1125,8 @@ praisonai agents.yaml
|
||||
|
||||
| Feature | Code | Docs |
|
||||
|---------|:----:|:----:|
|
||||
| Skills Management | [Example](examples/skills/basic_skill_usage.py) | [📖](https://docs.praison.ai/docs/concepts/skills) |
|
||||
| Custom Skills | [Example](examples/skills/custom_skill_example.py) | [📖](https://docs.praison.ai/docs/concepts/skills) |
|
||||
| Skills Management | [Example](examples/skills/basic_skill_usage.py) | [📖](https://praison.ai/docs/concepts/skills) |
|
||||
| Custom Skills | [Example](examples/skills/custom_skill_example.py) | [📖](https://praison.ai/docs/concepts/skills) |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -979,7 +1135,9 @@ praisonai agents.yaml
|
||||
|
||||
| Feature | Code | Docs |
|
||||
|---------|:----:|:----:|
|
||||
| Agent Scheduler | [Example](examples/python/scheduled_agents/news_checker_live.py) | [📖](https://docs.praison.ai/docs/cli/scheduler) |
|
||||
| Agent Scheduler | [Example](examples/python/scheduled_agents/news_checker_live.py) | [📖](https://praison.ai/docs/cli/scheduler) |
|
||||
|
||||
</details>
|
||||
|
||||
</details>
|
||||
|
||||
@@ -1008,7 +1166,7 @@ PraisonAI keeps framework overhead microsecond-class: creating an `Agent` (one t
|
||||
|--------------------|-----------|----------|
|
||||
| Avg Agent instantiation (1 tool, 1000 iterations) | **46 μs** | 2026-10-15 · praisonaiagents 1.7.12 |
|
||||
|
||||
Methodology, comparisons and how to reproduce: [benchmarks/BENCHMARK_RESULTS.md](https://github.com/MervinPraison/PraisonAI/blob/main/src/praisonai-agents/benchmarks/BENCHMARK_RESULTS.md) · [docs](https://docs.praison.ai/docs/developers/benchmarks)
|
||||
Methodology, comparisons and how to reproduce: [benchmarks/BENCHMARK_RESULTS.md](https://github.com/MervinPraison/PraisonAI/blob/main/src/praisonai-agents/benchmarks/BENCHMARK_RESULTS.md) · [docs](https://praison.ai/docs/developers/benchmarks)
|
||||
|
||||
---
|
||||
|
||||
@@ -1018,7 +1176,7 @@ Methodology, comparisons and how to reproduce: [benchmarks/BENCHMARK_RESULTS.md]
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
[](https://docs.praison.ai)
|
||||
[](https://praison.ai/docs)
|
||||
|
||||
---
|
||||
|
||||
@@ -1089,6 +1247,9 @@ We welcome contributions! Fork the repo, create a branch, and submit a PR → [C
|
||||
|
||||
## ❓ FAQ & Troubleshooting
|
||||
|
||||
<details>
|
||||
<summary><strong>Show FAQ and troubleshooting</strong></summary>
|
||||
|
||||
<details>
|
||||
<summary><strong>ModuleNotFoundError: No module named 'praisonaiagents'</strong></summary>
|
||||
|
||||
@@ -1107,7 +1268,7 @@ Ensure your API key is set:
|
||||
export OPENAI_API_KEY=your_key_here
|
||||
```
|
||||
|
||||
For other providers, see [Models docs](https://docs.praison.ai/docs/models).
|
||||
For other providers, see [Models docs](https://praison.ai/docs/models).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -1148,7 +1309,7 @@ agent = Agent(instructions="...", llm="ollama/llama3.2", base_url="http://192.16
|
||||
The same shape works for other local runtimes — `lm_studio/`, `vllm/` and
|
||||
`hosted_vllm/` with their server's `base_url`.
|
||||
|
||||
See [Models docs](https://docs.praison.ai/docs/models) for more details.
|
||||
See [Models docs](https://praison.ai/docs/models) for more details.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -1168,7 +1329,7 @@ agent = Agent(
|
||||
)
|
||||
```
|
||||
|
||||
See [Persistence docs](https://docs.praison.ai/docs/databases/overview) for supported databases.
|
||||
See [Persistence docs](https://praison.ai/docs/databases/overview) for supported databases.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -1185,7 +1346,7 @@ agent = Agent(
|
||||
)
|
||||
```
|
||||
|
||||
See [Memory docs](https://docs.praison.ai/docs/concepts/memory) for more options.
|
||||
See [Memory docs](https://praison.ai/docs/concepts/memory) for more options.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -1201,7 +1362,7 @@ agents = Agents(agents=[agent1, agent2])
|
||||
agents.start()
|
||||
```
|
||||
|
||||
See [Agents docs](https://docs.praison.ai/docs/concepts/agents) for more examples.
|
||||
See [Agents docs](https://praison.ai/docs/concepts/agents) for more examples.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -1216,13 +1377,15 @@ agent = Agent(
|
||||
)
|
||||
```
|
||||
|
||||
See [MCP docs](https://docs.praison.ai/docs/mcp/transports) for all transport options.
|
||||
See [MCP docs](https://praison.ai/docs/mcp/transports) for all transport options.
|
||||
|
||||
</details>
|
||||
|
||||
</details>
|
||||
|
||||
### Getting Help
|
||||
|
||||
- 📚 [Full Documentation](https://docs.praison.ai)
|
||||
- 📚 [Full Documentation](https://praison.ai/docs)
|
||||
- 🐛 [Report Issues](https://github.com/MervinPraison/PraisonAI/issues)
|
||||
- 💬 [Discussions](https://github.com/MervinPraison/PraisonAI/discussions)
|
||||
|
||||
@@ -1231,7 +1394,7 @@ See [MCP docs](https://docs.praison.ai/docs/mcp/transports) for all transport op
|
||||
<div align="center">
|
||||
<p><strong>Made with ❤️ by the PraisonAI Team</strong></p>
|
||||
<p>
|
||||
<a href="https://docs.praison.ai">📚 Documentation</a> •
|
||||
<a href="https://praison.ai/docs">📚 Documentation</a> •
|
||||
<a href="https://github.com/MervinPraison/PraisonAI">GitHub</a> •
|
||||
<a href="https://youtube.com/@MervinPraison">▶️ YouTube</a> •
|
||||
<a href="https://x.com/MervinPraison">𝕏 X</a> •
|
||||
|
||||
@@ -132,17 +132,17 @@ Methods:
|
||||
* <code title="class DeepResearchAgent">DeepResearchAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/deep_research_agent.py">openai_client</a>()</code>
|
||||
* <code title="class DeepResearchAgent">DeepResearchAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/deep_research_agent.py">research</a>(query: str, instructions: Optional[str] = None, model: Optional[str] = None, summary_mode: Optional[Literal['auto', 'detailed', 'concise']] = None, web_search: Optional[bool] = None, code_interpreter: Optional[bool] = None, mcp_servers: Optional[List[Dict[str, Any]]] = None, file_ids: Optional[List[str]] = None, file_search: Optional[bool] = None, file_search_stores: Optional[List[str]] = None, stream: bool = True) -> DeepResearchResponse</code>
|
||||
* <code title="class DeepResearchAgent">DeepResearchAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/deep_research_agent.py">rewrite_query</a>(query: str, model: Optional[str] = None) -> str</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">achat</a>(prompt: str, temperature: float = 0.2, tools: Optional[List[Any]] = None, output_json: Optional[str] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, **kwargs) -> Union[str, Dict[str, Any]]</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">aedit</a>(image: str, prompt: str, mask: Optional[str] = None, n: int = 1, size: Optional[str] = None, **kwargs) -> Dict[str, Any]</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">agenerate</a>(prompt: str, **kwargs) -> Dict[str, Any]</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">agenerate_image</a>(prompt: str, **kwargs) -> Dict[str, Any]</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">avariation</a>(image: str, n: int = 1, size: Optional[str] = None, **kwargs) -> Dict[str, Any]</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">chat</a>(prompt: str, **kwargs) -> Dict[str, Any]</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">edit</a>(image: str, prompt: str, mask: Optional[str] = None, n: int = 1, size: Optional[str] = None, **kwargs) -> Dict[str, Any]</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">generate</a>(prompt: str, **kwargs) -> Dict[str, Any]</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">generate_image</a>(prompt: str, **kwargs) -> Dict[str, Any]</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">achat</a>(prompt: str, temperature: float = 0.2, tools: Optional[List[Any]] = None, output_json: Optional[str] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, **kwargs) -> Union[ImageResult, Dict[str, Any]]</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">aedit</a>(image: str, prompt: str, mask: Optional[str] = None, n: int = 1, size: Optional[str] = None, **kwargs) -> ImageResult</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">agenerate</a>(prompt: str, **kwargs) -> ImageResult</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">agenerate_image</a>(prompt: str, **kwargs) -> ImageResult</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">avariation</a>(image: str, n: int = 1, size: Optional[str] = None, **kwargs) -> ImageResult</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">chat</a>(prompt: str, **kwargs) -> Union[ImageResult, Dict[str, Any]]</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">edit</a>(image: str, prompt: str, mask: Optional[str] = None, n: int = 1, size: Optional[str] = None, **kwargs) -> ImageResult</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">generate</a>(prompt: str, **kwargs) -> ImageResult</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">generate_image</a>(prompt: str, **kwargs) -> ImageResult</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">litellm</a>()</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">variation</a>(image: str, n: int = 1, size: Optional[str] = None, **kwargs) -> Dict[str, Any]</code>
|
||||
* <code title="class ImageAgent">ImageAgent.<a href="./src/praisonai-agents/praisonaiagents/agent/image_agent.py">variation</a>(image: str, n: int = 1, size: Optional[str] = None, **kwargs) -> ImageResult</code>
|
||||
* <code title="class PlanningAgent">PlanningAgent.<a href="./src/praisonai-agents/praisonaiagents/planning/planner.py">analyze_context</a>(context: str) -> str</code>
|
||||
* <code title="class PlanningAgent">PlanningAgent.<a href="./src/praisonai-agents/praisonaiagents/planning/planner.py">analyze_context_sync</a>(context: str) -> str</code>
|
||||
* <code title="class PlanningAgent">PlanningAgent.<a href="./src/praisonai-agents/praisonaiagents/planning/planner.py">create_plan</a>(request: str, agents: List['Agent'], tasks: Optional[List['Task']] = None, context: Optional[str] = None) -> Plan</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.7.22" \
|
||||
"praisonai>=4.7.24" \
|
||||
"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.7.22" \
|
||||
"praisonai>=4.7.24" \
|
||||
"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.7.22" \
|
||||
"praisonai>=4.7.24" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -5,23 +5,39 @@ PraisonAI Agents - Real Execution Benchmark
|
||||
Benchmarks ACTUAL agent execution with LLM API calls.
|
||||
Tests single agent running a simple task across frameworks.
|
||||
|
||||
Each framework gets untimed warm-up calls first (imports, client set-up and
|
||||
the first connection are not what this measures), then the timed runs are
|
||||
interleaved round by round so network drift hits every framework alike.
|
||||
A run that raises or returns an empty answer is counted as a failure, not
|
||||
timed. The summary reports the median.
|
||||
|
||||
Usage:
|
||||
export OPENAI_API_KEY=your_key
|
||||
python benchmarks/execution_benchmark.py
|
||||
python benchmarks/execution_benchmark.py --no-save
|
||||
python benchmarks/execution_benchmark.py --iterations 5
|
||||
python benchmarks/execution_benchmark.py --save
|
||||
python benchmarks/execution_benchmark.py --iterations 10
|
||||
python benchmarks/execution_benchmark.py --model gpt-4o --iterations 10
|
||||
"""
|
||||
|
||||
import time
|
||||
import os
|
||||
import argparse
|
||||
import os
|
||||
import statistics
|
||||
import time
|
||||
from importlib.metadata import version as get_version
|
||||
|
||||
# Defaults (can be overridden via CLI)
|
||||
DEFAULT_MODEL = "gpt-4o-mini"
|
||||
DEFAULT_ITERATIONS = 3
|
||||
DEFAULT_ITERATIONS = 10
|
||||
DEFAULT_WARMUP = 1
|
||||
DEFAULT_PROMPT = "What is 2+2? Reply with just the number."
|
||||
INSTRUCTIONS = "You are a helpful assistant. Be very brief."
|
||||
|
||||
METHODS = {
|
||||
'PraisonAI': 'agent.start()',
|
||||
'PraisonAI (LiteLLM)': 'agent.start()',
|
||||
'Agno': 'agent.run()',
|
||||
'CrewAI': 'crew.kickoff()',
|
||||
}
|
||||
|
||||
|
||||
def get_package_versions():
|
||||
@@ -35,260 +51,235 @@ def get_package_versions():
|
||||
for name, pkg in packages.items():
|
||||
try:
|
||||
versions[name] = get_version(pkg)
|
||||
except:
|
||||
except Exception:
|
||||
versions[name] = 'not installed'
|
||||
return versions
|
||||
|
||||
|
||||
def benchmark_praisonai(model, iterations, prompt):
|
||||
"""Benchmark PraisonAI agent execution using agent.start()"""
|
||||
def praisonai_runner(model, prompt):
|
||||
"""PraisonAI: a new agent per run; only agent.start() is timed."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
print("\n--- PraisonAI ---")
|
||||
print("Method: agent.start()")
|
||||
|
||||
times = []
|
||||
for i in range(iterations):
|
||||
agent = Agent(
|
||||
name="Calculator",
|
||||
instructions="You are a helpful assistant. Be very brief.",
|
||||
llm=model
|
||||
)
|
||||
|
||||
start = time.perf_counter()
|
||||
response = agent.start(prompt, output="silent")
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
times.append(elapsed)
|
||||
print(f" Run {i+1}: {elapsed:.2f}s - Response: {str(response)[:50]}")
|
||||
|
||||
avg = sum(times) / len(times)
|
||||
print(f" Average: {avg:.2f}s")
|
||||
return avg
|
||||
|
||||
def build():
|
||||
agent = Agent(name="Calculator", instructions=INSTRUCTIONS, llm=model)
|
||||
return lambda: agent.start(prompt, output="silent")
|
||||
return build
|
||||
|
||||
|
||||
def benchmark_praisonai_litellm(model, iterations, prompt):
|
||||
"""Benchmark PraisonAI with LiteLLM backend using agent.start()"""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
print("\n--- PraisonAI (LiteLLM) ---")
|
||||
print("Method: agent.start()")
|
||||
|
||||
times = []
|
||||
for i in range(iterations):
|
||||
agent = Agent(
|
||||
name="Calculator",
|
||||
instructions="You are a helpful assistant. Be very brief.",
|
||||
llm=f"openai/{model}"
|
||||
)
|
||||
|
||||
start = time.perf_counter()
|
||||
response = agent.start(prompt, output="silent")
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
times.append(elapsed)
|
||||
print(f" Run {i+1}: {elapsed:.2f}s - Response: {str(response)[:50]}")
|
||||
|
||||
avg = sum(times) / len(times)
|
||||
print(f" Average: {avg:.2f}s")
|
||||
return avg
|
||||
|
||||
|
||||
def benchmark_agno(model, iterations, prompt):
|
||||
"""Benchmark Agno agent execution using agent.run()"""
|
||||
def agno_runner(model, prompt):
|
||||
"""Agno: a new agent per run; only agent.run() is timed."""
|
||||
from agno.agent import Agent
|
||||
from agno.models.openai import OpenAIChat
|
||||
|
||||
print("\n--- Agno ---")
|
||||
print("Method: agent.run()")
|
||||
|
||||
times = []
|
||||
|
||||
# One model object per runner: each OpenAIChat opens its own HTTP client,
|
||||
# so a fresh one per round would put a new connection inside the timer,
|
||||
# while PraisonAI and CrewAI agents share theirs.
|
||||
llm = OpenAIChat(id=model)
|
||||
|
||||
def build():
|
||||
agent = Agent(model=llm, instructions=[INSTRUCTIONS])
|
||||
|
||||
def call():
|
||||
response = agent.run(prompt)
|
||||
# Agno reports a failed run in ``status`` and puts the error text in
|
||||
# ``content``, so the content alone would pass for an answer.
|
||||
if str(getattr(response, 'status', '')).lower().endswith('error'):
|
||||
raise RuntimeError(f"Agno run failed: {str(response.content)[:200]}")
|
||||
return getattr(response, 'content', response)
|
||||
return call
|
||||
return build
|
||||
|
||||
|
||||
def crewai_runner(model, prompt):
|
||||
"""CrewAI: a new one-agent crew per run; only crew.kickoff() is timed."""
|
||||
from crewai import Agent, Crew, Task
|
||||
|
||||
def build():
|
||||
agent = Agent(role="Calculator", goal="Answer math questions",
|
||||
backstory=INSTRUCTIONS, llm=model, verbose=False)
|
||||
task = Task(description=prompt, expected_output="A number", agent=agent)
|
||||
return Crew(agents=[agent], tasks=[task], verbose=False).kickoff
|
||||
return build
|
||||
|
||||
|
||||
def build_runners(model, prompt):
|
||||
"""Return {framework: build()} for every installed framework.
|
||||
|
||||
``build()`` creates fresh objects (untimed) and returns the call to time.
|
||||
"""
|
||||
factories = [
|
||||
('PraisonAI', lambda: praisonai_runner(model, prompt)),
|
||||
('PraisonAI (LiteLLM)', lambda: praisonai_runner(f"openai/{model}", prompt)),
|
||||
('Agno', lambda: agno_runner(model, prompt)),
|
||||
('CrewAI', lambda: crewai_runner(model, prompt)),
|
||||
]
|
||||
runners = {}
|
||||
for name, factory in factories:
|
||||
try:
|
||||
runners[name] = factory()
|
||||
except ImportError as e:
|
||||
print(f" {name}: skipped ({e})")
|
||||
return runners
|
||||
|
||||
|
||||
def answer_text(response):
|
||||
"""The answer as text, or '' when the run produced nothing usable."""
|
||||
if response is None:
|
||||
return ''
|
||||
text = str(response).strip()
|
||||
return '' if text == 'None' else text
|
||||
|
||||
|
||||
def run_benchmark(runners, iterations, warmup):
|
||||
"""Warm up each framework, then time interleaved rounds.
|
||||
|
||||
A failed warm-up is reported but the framework stays in the timed rounds,
|
||||
so one transient error does not discard it. The order within a round
|
||||
rotates every round. Object construction happens
|
||||
before the timer starts; only the execution call is timed.
|
||||
|
||||
Returns {framework: {"times": [...], "failures": [...]}}.
|
||||
"""
|
||||
results = {name: {"times": [], "failures": []} for name in runners}
|
||||
|
||||
for name, build in runners.items():
|
||||
for _ in range(warmup):
|
||||
try:
|
||||
if not answer_text(build()()):
|
||||
raise RuntimeError("empty answer")
|
||||
except Exception as e:
|
||||
print(f" {name}: warm-up failed ({type(e).__name__}: {str(e)[:120]})")
|
||||
|
||||
# Rotate the start each round so no framework always holds the same slot.
|
||||
names = list(runners)
|
||||
for i in range(iterations):
|
||||
agent = Agent(
|
||||
model=OpenAIChat(id=model),
|
||||
instructions=["You are a helpful assistant. Be very brief."],
|
||||
)
|
||||
|
||||
start = time.perf_counter()
|
||||
response = agent.run(prompt)
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
times.append(elapsed)
|
||||
# Extract content from response
|
||||
content = str(response.content)[:50] if hasattr(response, 'content') else str(response)[:50]
|
||||
print(f" Run {i+1}: {elapsed:.2f}s - Response: {content}")
|
||||
|
||||
avg = sum(times) / len(times)
|
||||
print(f" Average: {avg:.2f}s")
|
||||
return avg
|
||||
shift = i % len(names) if names else 0
|
||||
for name in names[shift:] + names[:shift]:
|
||||
build = runners[name]
|
||||
try:
|
||||
call = build()
|
||||
start = time.perf_counter()
|
||||
text = answer_text(call())
|
||||
elapsed = time.perf_counter() - start
|
||||
error = None if text else "empty answer"
|
||||
except Exception as e:
|
||||
text, error = '', f"{type(e).__name__}: {e}"
|
||||
if error:
|
||||
results[name]["failures"].append(error)
|
||||
print(f" Round {i + 1} {name}: FAILED ({error[:120]})")
|
||||
else:
|
||||
results[name]["times"].append(elapsed)
|
||||
print(f" Round {i + 1} {name}: {elapsed:.2f}s - {text[:40]}")
|
||||
return results
|
||||
|
||||
|
||||
def benchmark_crewai(model, iterations, prompt):
|
||||
"""Benchmark CrewAI agent execution using crew.kickoff()"""
|
||||
from crewai import Agent, Task, Crew
|
||||
|
||||
print("\n--- CrewAI ---")
|
||||
print("Method: crew.kickoff()")
|
||||
|
||||
times = []
|
||||
for i in range(iterations):
|
||||
agent = Agent(
|
||||
role="Calculator",
|
||||
goal="Answer math questions",
|
||||
backstory="You are a helpful assistant.",
|
||||
llm=model,
|
||||
verbose=False
|
||||
)
|
||||
|
||||
task = Task(
|
||||
description=prompt,
|
||||
expected_output="A number",
|
||||
agent=agent
|
||||
)
|
||||
|
||||
crew = Crew(
|
||||
agents=[agent],
|
||||
tasks=[task],
|
||||
verbose=False
|
||||
)
|
||||
|
||||
start = time.perf_counter()
|
||||
response = crew.kickoff()
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
times.append(elapsed)
|
||||
print(f" Run {i+1}: {elapsed:.2f}s - Response: {str(response)[:50]}")
|
||||
|
||||
avg = sum(times) / len(times)
|
||||
print(f" Average: {avg:.2f}s")
|
||||
return avg
|
||||
def summarise(results):
|
||||
"""Rows of (framework, median, mean, ok, failed), fastest median first."""
|
||||
rows = []
|
||||
for name, r in results.items():
|
||||
times = r["times"]
|
||||
if times:
|
||||
rows.append((name, statistics.median(times), statistics.fmean(times),
|
||||
len(times), len(r["failures"])))
|
||||
return sorted(rows, key=lambda row: row[1])
|
||||
|
||||
|
||||
def save_results(results: dict, model: str, iterations: int, prompt: str):
|
||||
def save_results(rows, results, model, iterations, warmup, prompt):
|
||||
"""Save results to markdown file."""
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
filepath = os.path.join(os.path.dirname(__file__), 'EXECUTION_BENCHMARK_RESULTS.md')
|
||||
versions = get_package_versions()
|
||||
|
||||
# Sort by time
|
||||
sorted_results = sorted(results.items(), key=lambda x: x[1])
|
||||
fastest = sorted_results[0][1]
|
||||
|
||||
fastest = rows[0][1]
|
||||
|
||||
with open(filepath, 'w') as f:
|
||||
f.write('# PraisonAI Agents - Real Execution Benchmark\n\n')
|
||||
f.write(f'**Generated:** {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}\n')
|
||||
f.write(f'**Model:** {model}\n')
|
||||
f.write(f'**Iterations:** {iterations}\n')
|
||||
f.write(f'**Iterations:** {iterations} interleaved rounds after {warmup} warm-up call(s) per framework\n')
|
||||
f.write(f'**Prompt:** "{prompt}"\n\n')
|
||||
f.write('## Results\n\n')
|
||||
f.write('| Framework | Method | Avg Time | Relative |\n')
|
||||
f.write('|-----------|--------|----------|----------|\n')
|
||||
|
||||
methods = {
|
||||
'PraisonAI': 'agent.start()',
|
||||
'PraisonAI (LiteLLM)': 'agent.start()',
|
||||
'Agno': 'agent.run()',
|
||||
'CrewAI': 'crew.kickoff()',
|
||||
}
|
||||
|
||||
for name, avg in sorted_results:
|
||||
ratio = avg / fastest
|
||||
method = methods.get(name, 'unknown')
|
||||
f.write('| Framework | Method | Median | Mean | Runs ok / failed | Relative |\n')
|
||||
f.write('|-----------|--------|--------|------|------------------|----------|\n')
|
||||
for name, median, mean, ok, failed in rows:
|
||||
ratio = median / fastest
|
||||
method = METHODS.get(name, 'unknown')
|
||||
if ratio == 1.0:
|
||||
f.write(f'| **{name}** | `{method}` | **{avg:.2f}s** | **1.00x (fastest)** |\n')
|
||||
f.write(f'| **{name}** | `{method}` | **{median:.2f}s** | {mean:.2f}s | {ok} / {failed} | **1.00x (fastest)** |\n')
|
||||
else:
|
||||
f.write(f'| {name} | `{method}` | {avg:.2f}s | {ratio:.2f}x |\n')
|
||||
|
||||
f.write(f'| {name} | `{method}` | {median:.2f}s | {mean:.2f}s | {ok} / {failed} | {ratio:.2f}x |\n')
|
||||
excluded = [name for name, r in results.items() if not r["times"]]
|
||||
if excluded:
|
||||
f.write(f'\nNo successful runs (not ranked): {", ".join(excluded)}\n')
|
||||
|
||||
f.write('\n## Package Versions\n\n')
|
||||
f.write('| Package | Version |\n')
|
||||
f.write('|---------|--------|\n')
|
||||
for pkg, ver in versions.items():
|
||||
f.write(f'| {pkg} | {ver} |\n')
|
||||
|
||||
|
||||
f.write('\n## How to Reproduce\n\n')
|
||||
f.write('```bash\n')
|
||||
f.write('export OPENAI_API_KEY=your_key\n')
|
||||
f.write('cd praisonai-agents\n')
|
||||
f.write('python benchmarks/execution_benchmark.py\n')
|
||||
f.write('cd src/praisonai-agents\n')
|
||||
f.write('python benchmarks/execution_benchmark.py --save\n')
|
||||
f.write('```\n')
|
||||
|
||||
|
||||
print(f'\nResults saved to: {filepath}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='PraisonAI Agents - Real Execution Benchmark')
|
||||
parser.add_argument('--save', action='store_true', help='Save results to file')
|
||||
parser.add_argument('--save', action='store_true', help='Save results to EXECUTION_BENCHMARK_RESULTS.md')
|
||||
parser.add_argument('--model', '-m', type=str, default=DEFAULT_MODEL, help=f'Model to use (default: {DEFAULT_MODEL})')
|
||||
parser.add_argument('--iterations', '-i', type=int, default=DEFAULT_ITERATIONS, help=f'Number of iterations (default: {DEFAULT_ITERATIONS})')
|
||||
parser.add_argument('--iterations', '-i', type=int, default=DEFAULT_ITERATIONS, help=f'Timed rounds (default: {DEFAULT_ITERATIONS})')
|
||||
parser.add_argument('--warmup', '-w', type=int, default=DEFAULT_WARMUP, help=f'Untimed warm-up calls per framework (default: {DEFAULT_WARMUP})')
|
||||
parser.add_argument('--prompt', '-p', type=str, default=DEFAULT_PROMPT, help=f'Prompt to use (default: "{DEFAULT_PROMPT}")')
|
||||
args = parser.parse_args()
|
||||
|
||||
# Check API key
|
||||
if args.iterations < 1:
|
||||
parser.error('--iterations must be at least 1')
|
||||
if args.warmup < 0:
|
||||
parser.error('--warmup must be 0 or more')
|
||||
|
||||
if not os.environ.get('OPENAI_API_KEY'):
|
||||
print("ERROR: Set OPENAI_API_KEY environment variable")
|
||||
exit(1)
|
||||
|
||||
model = args.model
|
||||
iterations = args.iterations
|
||||
prompt = args.prompt
|
||||
|
||||
return 1
|
||||
|
||||
print('=' * 60)
|
||||
print('PraisonAI Agents - Real Execution Benchmark')
|
||||
print('=' * 60)
|
||||
print(f'\nModel: {model}')
|
||||
print(f'Iterations: {iterations}')
|
||||
print(f'Prompt: "{prompt}"')
|
||||
|
||||
versions = get_package_versions()
|
||||
print(f'\nModel: {args.model}')
|
||||
print(f'Rounds: {args.iterations} (after {args.warmup} warm-up call(s) per framework)')
|
||||
print(f'Prompt: "{args.prompt}"')
|
||||
print('\nPackage versions:')
|
||||
for pkg, ver in versions.items():
|
||||
for pkg, ver in get_package_versions().items():
|
||||
print(f' {pkg}: {ver}')
|
||||
|
||||
results = {}
|
||||
|
||||
# Run benchmarks
|
||||
try:
|
||||
results['PraisonAI'] = benchmark_praisonai(model, iterations, prompt)
|
||||
except Exception as e:
|
||||
print(f"PraisonAI error: {e}")
|
||||
|
||||
try:
|
||||
results['PraisonAI (LiteLLM)'] = benchmark_praisonai_litellm(model, iterations, prompt)
|
||||
except Exception as e:
|
||||
print(f"PraisonAI (LiteLLM) error: {e}")
|
||||
|
||||
try:
|
||||
results['Agno'] = benchmark_agno(model, iterations, prompt)
|
||||
except Exception as e:
|
||||
print(f"Agno error: {e}")
|
||||
|
||||
try:
|
||||
results['CrewAI'] = benchmark_crewai(model, iterations, prompt)
|
||||
except Exception as e:
|
||||
print(f"CrewAI error: {e}")
|
||||
|
||||
# Summary
|
||||
print()
|
||||
|
||||
runners = build_runners(args.model, args.prompt)
|
||||
results = run_benchmark(runners, args.iterations, args.warmup)
|
||||
rows = summarise(results)
|
||||
|
||||
print('\n' + '=' * 60)
|
||||
print('SUMMARY')
|
||||
print('SUMMARY (median of successful runs)')
|
||||
print('=' * 60)
|
||||
|
||||
if results:
|
||||
sorted_results = sorted(results.items(), key=lambda x: x[1])
|
||||
fastest = sorted_results[0][1]
|
||||
|
||||
print(f"\n{'Framework':<25} {'Avg Time':<12} {'Relative':<10}")
|
||||
print('-' * 47)
|
||||
|
||||
for name, avg in sorted_results:
|
||||
ratio = avg / fastest
|
||||
print(f'{name:<25} {avg:<12.2f}s {ratio:.2f}x')
|
||||
|
||||
if rows:
|
||||
fastest = rows[0][1]
|
||||
print(f"\n{'Framework':<22} {'Median':>8} {'Mean':>8} {'ok/failed':>10} {'Relative':>9}")
|
||||
print('-' * 61)
|
||||
for name, median, mean, ok, failed in rows:
|
||||
print(f'{name:<22} {median:>7.2f}s {mean:>7.2f}s {ok:>5}/{failed:<4} {median / fastest:>8.2f}x')
|
||||
for name, r in results.items():
|
||||
if not r["times"]:
|
||||
print(f'{name:<22} no successful runs: {(r["failures"] or ["?"])[0][:80]}')
|
||||
print('\n' + '=' * 60)
|
||||
|
||||
# Save results (if --save)
|
||||
if results and args.save:
|
||||
save_results(results, model, iterations, prompt)
|
||||
|
||||
if rows and args.save:
|
||||
save_results(rows, results, args.model, args.iterations, args.warmup, args.prompt)
|
||||
elif not args.save:
|
||||
print('\nResults not saved (use --save flag to save results to file)')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -109,17 +109,11 @@ def run_benchmark(skip_crewai: bool = False, quiet: bool = False,
|
||||
pass
|
||||
|
||||
try:
|
||||
from langchain_core.tools import tool as langchain_tool
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langgraph.prebuilt import create_react_agent
|
||||
|
||||
@langchain_tool
|
||||
def get_weather_lg(city: Literal['nyc', 'sf']):
|
||||
"""Get weather info."""
|
||||
return 'sunny' if city == 'sf' else 'cloudy'
|
||||
|
||||
|
||||
results['LangGraph'] = measure(
|
||||
lambda: create_react_agent(model=ChatOpenAI(model='gpt-4o-mini'), tools=[get_weather_lg])
|
||||
lambda: create_react_agent(model=ChatOpenAI(model='gpt-4o-mini'), tools=[])
|
||||
)
|
||||
except ImportError:
|
||||
pass
|
||||
@@ -129,19 +123,12 @@ def run_benchmark(skip_crewai: bool = False, quiet: bool = False,
|
||||
else:
|
||||
try:
|
||||
from crewai.agent import Agent as CrewAgent
|
||||
from crewai.tools import tool as crewai_tool
|
||||
|
||||
@crewai_tool("Weather Tool")
|
||||
def get_weather_crew(city: Literal['nyc', 'sf']):
|
||||
"""Get weather info."""
|
||||
return 'sunny' if city == 'sf' else 'cloudy'
|
||||
|
||||
results['CrewAI'] = measure(
|
||||
lambda: CrewAgent(
|
||||
role='Weather Agent',
|
||||
goal='Provide weather info',
|
||||
backstory='A weather expert',
|
||||
tools=[get_weather_crew],
|
||||
verbose=False
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Savable wrapper for ImageAgent responses (keeps the raw response API intact)."""
|
||||
import base64
|
||||
from typing import Any, List
|
||||
|
||||
_MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024
|
||||
|
||||
|
||||
class ImageResult:
|
||||
"""Wraps a provider image response and adds ``save()``.
|
||||
|
||||
Attribute and item access fall through to the wrapped response, so
|
||||
``result.data[0].b64_json`` and ``result["data"]`` keep working.
|
||||
"""
|
||||
|
||||
def __init__(self, response: Any):
|
||||
self._response = response
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
if "_response" not in self.__dict__:
|
||||
raise AttributeError(name)
|
||||
return getattr(self.__dict__["_response"], name)
|
||||
|
||||
def __getitem__(self, key: Any) -> Any:
|
||||
return self._response[key]
|
||||
|
||||
def __contains__(self, key: Any) -> bool:
|
||||
return key in self._response
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._response)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"ImageResult({self._response!r})"
|
||||
|
||||
@property
|
||||
def images(self) -> List[Any]:
|
||||
"""List of image objects returned by the provider (one per ``n``)."""
|
||||
data = self._response.get("data") if isinstance(self._response, dict) else getattr(self._response, "data", None)
|
||||
return list(data or [])
|
||||
|
||||
def save(self, path: str, index: int = 0) -> str:
|
||||
"""Write image ``index`` to ``path`` (base64 is decoded, http(s) URLs are downloaded)."""
|
||||
images = self.images
|
||||
if not images:
|
||||
raise ValueError("No image data in response")
|
||||
image = images[index]
|
||||
get = image.get if isinstance(image, dict) else (lambda k: getattr(image, k, None))
|
||||
b64, url = get("b64_json"), get("url")
|
||||
if b64:
|
||||
data = base64.b64decode(b64)
|
||||
elif url:
|
||||
data = _download(url)
|
||||
else:
|
||||
raise ValueError("Image has neither b64_json nor url")
|
||||
with open(path, "wb") as f:
|
||||
f.write(data)
|
||||
return path
|
||||
|
||||
|
||||
def _download(url: str) -> bytes:
|
||||
import urllib.request
|
||||
|
||||
if not url.startswith(("https://", "http://")):
|
||||
raise ValueError("Image URL must be http(s)")
|
||||
with urllib.request.urlopen(url, timeout=30) as resp: # noqa: S310 - scheme checked above
|
||||
data = resp.read(_MAX_DOWNLOAD_BYTES + 1)
|
||||
if len(data) > _MAX_DOWNLOAD_BYTES:
|
||||
raise ValueError("Image exceeds maximum download size")
|
||||
return data
|
||||
@@ -54,6 +54,7 @@ _lazy_import_lock = threading.Lock()
|
||||
# mode wins.
|
||||
_output_mode_lock = threading.Lock()
|
||||
_llm_module = None
|
||||
_RULES_NOTICE_SEEN: set = set() # rule-name sets already announced (issue #6257)
|
||||
_hooks_module = None
|
||||
_stream_emitter_class = None
|
||||
|
||||
@@ -2698,6 +2699,8 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
|
||||
self.system_prompt = f"""{self.backstory}\n
|
||||
Your Role: {self.role}\n
|
||||
Your Goal: {self.goal}
|
||||
""" if self.goal != self.backstory else f"""{self.backstory}\n
|
||||
Your Role: {self.role}
|
||||
"""
|
||||
|
||||
# Per-invocation system-prompt append (never persisted). Sourced from the
|
||||
@@ -3571,8 +3574,26 @@ Your Goal: {self.goal}
|
||||
|
||||
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
|
||||
# Share _auto_save_session's lock so a save cannot interleave with the
|
||||
# rewrite and the cursor update below (#6217).
|
||||
with self._auto_save_lock, self._history_lock.lock():
|
||||
history = self._history_lock.value
|
||||
# Re-sync the positional auto-save cursor (#6217): resume at the
|
||||
# first unsaved survivor, or right after the last survivor so
|
||||
# appended messages (e.g. durable replays) are saved; summaries
|
||||
# placed before survivors are not re-persisted.
|
||||
last = getattr(self, '_auto_save_last_index', 0)
|
||||
old = {id(m): i >= last for i, m in enumerate(history)}
|
||||
cursor = len(new_history)
|
||||
survivors = [i for i, m in enumerate(new_history) if id(m) in old]
|
||||
if survivors:
|
||||
cursor = survivors[-1] + 1
|
||||
for i in survivors:
|
||||
if old[id(new_history[i])]:
|
||||
cursor = i
|
||||
break
|
||||
history[:] = new_history
|
||||
self._auto_save_last_index = cursor
|
||||
|
||||
@property
|
||||
def _cache_lock(self):
|
||||
@@ -6316,7 +6337,12 @@ Summary:"""
|
||||
# matches on reload. Zero-config runs still avoid injection cost.
|
||||
stats = self._rules_manager.get_stats()
|
||||
if stats["total_rules"] > 0:
|
||||
logging.debug(f"RulesManager: Discovered {stats['total_rules']} rules")
|
||||
# Only rules injected without a file/@mention; warn once per set so
|
||||
# the notice is visible under default logging without repeating per agent.
|
||||
names = ", ".join(sorted({r.name for r in self._rules_manager.get_active_rules()}))
|
||||
if names and names not in _RULES_NOTICE_SEEN:
|
||||
_RULES_NOTICE_SEEN.add(names)
|
||||
logging.warning(f"Applied rules: {names} (pass rules=False to turn off)")
|
||||
elif not extra_files:
|
||||
self._rules_manager = None
|
||||
except ImportError:
|
||||
@@ -7325,7 +7351,8 @@ Answer:"""
|
||||
task_output = TaskOutput(
|
||||
description="Agent response output",
|
||||
raw=response_text,
|
||||
agent=self.name
|
||||
# TaskOutput.agent is a required str; an Agent built without name= has name None.
|
||||
agent=self.name or getattr(self, "role", None) or "Agent"
|
||||
)
|
||||
|
||||
guardrail_result = self._process_guardrail(task_output)
|
||||
|
||||
@@ -543,17 +543,53 @@ class ChatMixin:
|
||||
return int(getattr(execution, "max_tool_calls_per_turn", default))
|
||||
return default
|
||||
|
||||
def _resolve_harness_base_prompt(self):
|
||||
_EDIT_TOOL_NAMES = frozenset({"edit_file", "apply_patch"})
|
||||
|
||||
def _offers_edit_tool(self, tools=None):
|
||||
"""True when ``tools`` (default: the agent's tools) include a file-edit primitive."""
|
||||
for tool in (tools if tools is not None else getattr(self, "tools", None)) or ():
|
||||
if isinstance(tool, list):
|
||||
# _format_tools_for_completion flattens a nested list but keeps
|
||||
# only its function-schema dicts, so only those can offer an edit tool.
|
||||
if self._offers_edit_tool([
|
||||
t for t in tool
|
||||
if isinstance(t, dict) and t.get("type") == "function"
|
||||
and isinstance(t.get("function"), dict)
|
||||
]):
|
||||
return True
|
||||
continue
|
||||
if isinstance(tool, str):
|
||||
name = tool
|
||||
elif isinstance(tool, dict):
|
||||
function = tool.get("function")
|
||||
name = function.get("name") if isinstance(function, dict) else tool.get("name")
|
||||
else:
|
||||
# A tool's ``name`` is what the model is sent (``@tool(name=...)``
|
||||
# keeps the function's own ``__name__``), so it wins.
|
||||
name = getattr(tool, "name", None)
|
||||
if not isinstance(name, str):
|
||||
name = getattr(tool, "__name__", None)
|
||||
# _prune_denied_tools drops denied tools from the payload, so a
|
||||
# denied edit tool must not earn the hint either.
|
||||
if name in self._EDIT_TOOL_NAMES and self._tool_name_allowed(name):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _resolve_harness_base_prompt(self, tools=None):
|
||||
"""Resolve the model-family harness base-prompt fragment, if any.
|
||||
|
||||
Honours an explicit ``harness_base_prompt`` override on the agent when
|
||||
set; otherwise resolves lazily from the active model id. Returns an
|
||||
empty string when no fragment applies (behaviour-preserving default).
|
||||
Never raises: any resolution error collapses to no fragment.
|
||||
set; otherwise resolves lazily from the active model id. The family
|
||||
fragments are file-editing guidance, so they apply only when ``tools``
|
||||
(default: the agent's tools) include ``edit_file`` or ``apply_patch``.
|
||||
Returns an empty string when no fragment applies. Never raises: any
|
||||
resolution error collapses to no fragment.
|
||||
"""
|
||||
override = getattr(self, "harness_base_prompt", None)
|
||||
if override is not None:
|
||||
return override
|
||||
if not self._offers_edit_tool(tools):
|
||||
return ""
|
||||
try:
|
||||
from ..model_harness import resolve_harness
|
||||
# self.llm may be a string model id or an LLM object; resolve the
|
||||
@@ -580,7 +616,10 @@ class ChatMixin:
|
||||
# Check cache first (skip cache if memory is enabled since context is dynamic)
|
||||
if not self._memory_instance:
|
||||
tools_key = self._get_tools_cache_key(tools)
|
||||
cache_key = f"{self.role}:{self.goal}:{tools_key}"
|
||||
# tools=None keys as "none" whatever self.tools holds, so key the
|
||||
# edit-hint decision too; otherwise a cached prefix would keep (or
|
||||
# miss) the hint after the agent's tools change.
|
||||
cache_key = f"{self.role}:{self.goal}:{hash(self.backstory)}:{tools_key}:{self._offers_edit_tool(tools)}"
|
||||
|
||||
cached_prompt = self._cache_get(self._system_prompt_cache, cache_key)
|
||||
if cached_prompt is not None:
|
||||
@@ -591,13 +630,16 @@ class ChatMixin:
|
||||
cache_key = None # Don't cache when memory is enabled
|
||||
|
||||
system_prompt = f"""{self.backstory}\n
|
||||
Your Role: {self.role}\n
|
||||
Your Goal: {self.goal}"""
|
||||
Your Role: {self.role}"""
|
||||
# Agent(instructions=...) sets backstory and goal to the same text;
|
||||
# state it once.
|
||||
if self.goal != self.backstory:
|
||||
system_prompt += f"\n\nYour Goal: {self.goal}"
|
||||
|
||||
# Prepend the model-family harness base prompt when one applies.
|
||||
# Resolution is lazy and falls back to a behaviour-preserving default
|
||||
# (no fragment) for unknown models, so output is unchanged by default.
|
||||
harness_prompt = self._resolve_harness_base_prompt()
|
||||
# (no fragment) for unknown models or agents without edit tools.
|
||||
harness_prompt = self._resolve_harness_base_prompt(tools)
|
||||
if harness_prompt:
|
||||
system_prompt = f"{harness_prompt}\n\n{system_prompt}"
|
||||
|
||||
@@ -1204,7 +1246,8 @@ Your Goal: {self.goal}"""
|
||||
if cached_entry is not None:
|
||||
cached_tools, cached_metadata = cached_entry
|
||||
self._tool_search_metadata = cached_metadata
|
||||
return cached_tools
|
||||
# Re-prune: permissions may have changed since the entry was cached.
|
||||
return self._prune_denied_tools(cached_tools)
|
||||
|
||||
formatted_tools = []
|
||||
# Provider-hosted tool specs have no local callable; the provider
|
||||
@@ -1324,20 +1367,19 @@ Your Goal: {self.goal}"""
|
||||
return sorted(tools_list, key=sort_key)
|
||||
|
||||
cleaned_tools = sort_formatted_tools(cleaned_tools)
|
||||
|
||||
# Prune permission-denied tools from the advertised set so the model is
|
||||
# only ever offered tools it can actually call. Mirrors the execution-time
|
||||
# enforcement in tool_execution.py (_perm_deny / _perm_allow). No-op when
|
||||
# no deny set and no allow set are configured (backward compatible).
|
||||
cleaned_tools = self._prune_denied_tools(cleaned_tools)
|
||||
|
||||
# Cache the formatted tools with LRU eviction, including tool search metadata
|
||||
|
||||
# Cache the unpruned tools with LRU eviction, including tool search
|
||||
# metadata, so a later change to the deny/allow sets takes effect.
|
||||
self._cache_put(
|
||||
self._formatted_tools_cache,
|
||||
cache_key,
|
||||
(cleaned_tools, getattr(self, "_tool_search_metadata", None)),
|
||||
)
|
||||
return cleaned_tools
|
||||
# Prune permission-denied tools from the advertised set so the model is
|
||||
# only ever offered tools it can actually call. Mirrors the execution-time
|
||||
# enforcement in tool_execution.py (_perm_deny / _perm_allow). No-op when
|
||||
# no deny set and no allow set are configured (backward compatible).
|
||||
return self._prune_denied_tools(cleaned_tools)
|
||||
|
||||
def _build_before_tool_definitions_input(self, formatted_tools):
|
||||
"""Build the BEFORE_TOOL_DEFINITIONS hook input.
|
||||
@@ -2042,6 +2084,21 @@ Your Goal: {self.goal}"""
|
||||
return fn(*args, **kwargs)
|
||||
return {**llm_kwargs, 'execute_tool_fn': tracked}, ran
|
||||
|
||||
@staticmethod
|
||||
def _history_before_turn(history, content):
|
||||
"""``history`` without the copy of this turn's user message stored before the call.
|
||||
|
||||
``LLM._build_messages`` appends ``prompt`` after ``chat_history``, so
|
||||
forwarding the stored copy as well sends the prompt twice. Only a
|
||||
trailing match is dropped: messages a durable replay restored after
|
||||
the turn keep their order.
|
||||
"""
|
||||
if history:
|
||||
last = history[-1]
|
||||
if isinstance(last, dict) and last.get("role") == "user" and last.get("content") == content:
|
||||
return list(history[:-1])
|
||||
return history
|
||||
|
||||
def _custom_llm_hook_messages(self, llm_kwargs):
|
||||
"""The custom-LLM request as the message list BEFORE_LLM / AFTER_LLM hooks see."""
|
||||
messages = []
|
||||
@@ -3018,6 +3075,23 @@ Your Goal: {self.goal}"""
|
||||
# Raise the enriched error
|
||||
raise error
|
||||
|
||||
def _native_reasoning_kwargs(self, model=None):
|
||||
"""Provider-native reasoning effort for the direct OpenAI client path.
|
||||
|
||||
An LLM instance translates ``reasoning_effort`` itself; the plain
|
||||
OpenAI client only sees what the dispatcher forwards, so resolve the
|
||||
unified level (Issue #4452) here, as the streaming path does. Returns
|
||||
{} when unset/off or when the model has no native effort control.
|
||||
"""
|
||||
effort = getattr(self, 'reasoning_effort', None)
|
||||
if effort is None or getattr(self, '_using_custom_llm', False):
|
||||
return {}
|
||||
from ..thinking.effort import resolve_reasoning_params
|
||||
native = resolve_reasoning_params(effort, model or self.llm)
|
||||
if 'reasoning_effort' not in native:
|
||||
return {}
|
||||
return {'reasoning_effort': native['reasoning_effort']}
|
||||
|
||||
def _execute_unified_chat_completion(
|
||||
self,
|
||||
messages,
|
||||
@@ -3072,6 +3146,7 @@ Your Goal: {self.goal}"""
|
||||
temperature=temperature,
|
||||
max_tokens=getattr(self, 'max_tokens', None),
|
||||
stream=True, # Try streaming first
|
||||
**self._native_reasoning_kwargs(getattr(self._unified_dispatcher, 'model', None)),
|
||||
response_format=response_format,
|
||||
execute_tool_fn=self._durable_sync_tool_executor(
|
||||
getattr(self, 'execute_tool', None)
|
||||
@@ -3121,6 +3196,7 @@ Your Goal: {self.goal}"""
|
||||
temperature=temperature,
|
||||
max_tokens=getattr(self, 'max_tokens', None),
|
||||
stream=stream,
|
||||
**self._native_reasoning_kwargs(getattr(self._unified_dispatcher, 'model', None)),
|
||||
response_format=response_format,
|
||||
execute_tool_fn=self._durable_sync_tool_executor(
|
||||
getattr(self, 'execute_tool', None)
|
||||
@@ -3272,6 +3348,7 @@ Your Goal: {self.goal}"""
|
||||
temperature=temperature,
|
||||
max_tokens=getattr(self, 'max_tokens', None),
|
||||
stream=stream,
|
||||
**self._native_reasoning_kwargs(getattr(dispatcher, 'model', None)),
|
||||
response_format=response_format,
|
||||
execute_tool_fn=self._durable_async_tool_executor(
|
||||
getattr(self, 'execute_tool_async', None)
|
||||
@@ -4090,7 +4167,7 @@ Your Goal: {self.goal}"""
|
||||
llm_kwargs = dict(
|
||||
prompt=llm_prompt,
|
||||
system_prompt=system_prompt_for_llm,
|
||||
chat_history=processed_history,
|
||||
chat_history=self._history_before_turn(processed_history, normalized_content),
|
||||
temperature=temperature,
|
||||
tools=tool_param,
|
||||
output_json=output_json,
|
||||
@@ -4207,6 +4284,7 @@ Your Goal: {self.goal}"""
|
||||
# Pass llm_prompt (which includes multimodal content if attachments present)
|
||||
messages, original_prompt = self._build_messages(
|
||||
llm_prompt, temperature, output_json, output_pydantic,
|
||||
tools=tools,
|
||||
use_native_format=use_native_format,
|
||||
memory_prefetch_context=memory_prefetch_context,
|
||||
)
|
||||
@@ -4485,7 +4563,9 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
except Exception:
|
||||
# Catch any exception from the inner try block and re-raise to outer handler
|
||||
raise
|
||||
except ToolExecutionError:
|
||||
except (ToolExecutionError, BudgetExceededError):
|
||||
# Parity with the custom-LLM and async paths: a budget stop must
|
||||
# reach the caller, not turn into a ``None`` reply.
|
||||
raise
|
||||
except Exception as e:
|
||||
self._last_stop_reason = "error"
|
||||
@@ -4832,7 +4912,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
tools,
|
||||
memory_prefetch_context=memory_prefetch_context,
|
||||
),
|
||||
'chat_history': effective_history,
|
||||
'chat_history': self._history_before_turn(effective_history, normalized_content),
|
||||
'temperature': temperature,
|
||||
'tools': tools,
|
||||
'output_json': output_json,
|
||||
@@ -4926,6 +5006,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
temperature,
|
||||
output_json,
|
||||
output_pydantic,
|
||||
tools=tools,
|
||||
restore_durable=False,
|
||||
memory_prefetch_context=memory_prefetch_context,
|
||||
)
|
||||
@@ -5264,7 +5345,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
response = await self._openai_client.async_client.chat.completions.create(
|
||||
**tool_call_kwargs
|
||||
)
|
||||
result = await self._achat_completion(response, tools)
|
||||
result = await self._achat_completion(response, tools, messages=messages)
|
||||
if get_logger().getEffectiveLevel() == logging.DEBUG:
|
||||
total_time = time.time() - start_time
|
||||
logging.debug(f"Agent.achat completed in {total_time:.2f} seconds")
|
||||
@@ -5444,8 +5525,12 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# the except blocks above already read ownership before this runs.
|
||||
self._clear_turn_tracking()
|
||||
|
||||
async def _achat_completion(self, response, tools, reasoning_steps=False):
|
||||
"""Async version of _chat_completion method"""
|
||||
async def _achat_completion(self, response, tools, reasoning_steps=False, messages=None):
|
||||
"""Async version of _chat_completion method.
|
||||
|
||||
``messages`` is the conversation that produced ``response``; it is kept in
|
||||
the follow-up request so the final answer still sees the user's question.
|
||||
"""
|
||||
try:
|
||||
message = response.choices[0].message
|
||||
if not hasattr(message, 'tool_calls') or not message.tool_calls:
|
||||
@@ -5531,17 +5616,20 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
if results:
|
||||
formatted_results = "\n".join([str(r) for r in results if r is not None])
|
||||
if formatted_results:
|
||||
messages = [
|
||||
{"role": "system", "content": self.system_prompt},
|
||||
if messages:
|
||||
follow_up_messages = list(messages)
|
||||
else:
|
||||
follow_up_messages = [{"role": "system", "content": self.system_prompt}]
|
||||
follow_up_messages.extend([
|
||||
{"role": "assistant", "content": "Here are the tool results:"},
|
||||
{"role": "user", "content": formatted_results + "\nPlease process these results and provide a final response."}
|
||||
]
|
||||
])
|
||||
try:
|
||||
final_response = await self._openai_client.async_client.chat.completions.create(
|
||||
model=self.llm,
|
||||
messages=messages,
|
||||
messages=follow_up_messages,
|
||||
temperature=1.0,
|
||||
stream=stream
|
||||
stream=True
|
||||
)
|
||||
full_response_text = ""
|
||||
reasoning_content = ""
|
||||
@@ -5551,6 +5639,9 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# Process stream without display_generating since streaming is active
|
||||
async for chunk in final_response:
|
||||
chunks.append(chunk)
|
||||
if not chunk.choices:
|
||||
# e.g. Azure content-filter annotation chunks
|
||||
continue
|
||||
if chunk.choices[0].delta.content:
|
||||
full_response_text += chunk.choices[0].delta.content
|
||||
|
||||
@@ -5558,9 +5649,18 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
rc = chunk.choices[0].delta.reasoning_content
|
||||
if rc:
|
||||
reasoning_content += rc
|
||||
|
||||
if not full_response_text.strip() and not reasoning_content.strip():
|
||||
# No answer text (only annotation, contentless or
|
||||
# whitespace-only chunks): fall back to the tool results via the
|
||||
# except below.
|
||||
raise ValueError("follow-up stream returned no answer text")
|
||||
|
||||
self.console.print()
|
||||
console = self.console # None unless verbose
|
||||
if console is not None:
|
||||
console.print()
|
||||
|
||||
from .agent import _get_llm_functions
|
||||
final_response = _get_llm_functions()['process_stream_chunks'](chunks)
|
||||
# Return only reasoning content if reasoning_steps is True
|
||||
if reasoning_steps and hasattr(final_response.choices[0].message, 'reasoning_content') and final_response.choices[0].message.reasoning_content:
|
||||
@@ -5878,7 +5978,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
for chunk in self.llm_instance.get_response_stream(
|
||||
prompt=actual_prompt,
|
||||
system_prompt=stream_system_prompt,
|
||||
chat_history=stream_history,
|
||||
chat_history=self._history_before_turn(stream_history, normalized_content),
|
||||
temperature=kwargs.get('temperature', 1.0),
|
||||
tools=tool_param,
|
||||
output_json=kwargs.get('output_json'),
|
||||
@@ -5953,6 +6053,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# Build messages using the helper method
|
||||
messages, original_prompt = self._build_messages(actual_prompt, kwargs.get('temperature', 1.0),
|
||||
kwargs.get('output_json'), kwargs.get('output_pydantic'),
|
||||
tools=tool_param,
|
||||
memory_prefetch_context=memory_prefetch_context)
|
||||
|
||||
# Apply context management so the streaming path compacts long
|
||||
@@ -7099,8 +7200,12 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
model_name=str(getattr(self, 'llm', '') or ''),
|
||||
),
|
||||
)
|
||||
# Copy ContextVars into the worker so the re-scheduled model call
|
||||
# keeps the caller's context (session identity, AG-UI run owner).
|
||||
import contextvars
|
||||
_ctx = contextvars.copy_context()
|
||||
response = await loop.run_in_executor(
|
||||
None, lambda: manager.execute_model_call(request, _model_fn)
|
||||
None, lambda: _ctx.run(manager.execute_model_call, request, _model_fn)
|
||||
)
|
||||
return self._unwrap_model_response(response)
|
||||
|
||||
|
||||
@@ -213,7 +213,8 @@ class ContextAgent(Agent):
|
||||
self.output_dir / "agent_responses",
|
||||
self.output_dir / "markdown_outputs",
|
||||
self.output_dir / "debug_logs",
|
||||
self.output_dir / "final_results"
|
||||
self.output_dir / "final_results",
|
||||
self.output_dir / "PRPs",
|
||||
]
|
||||
|
||||
for directory in directories:
|
||||
@@ -1270,11 +1271,12 @@ Every agent interaction has been saved for full audit trail and reproducibility.
|
||||
return self._generate_combined_analysis(feature_request)
|
||||
|
||||
# Otherwise just save and return the PRP
|
||||
from ..utils.atomic_io import atomic_write_text, safe_join
|
||||
prp_dir = os.path.join(self.output_dir, "PRPs")
|
||||
os.makedirs(prp_dir, exist_ok=True)
|
||||
safe_filename = feature_request.lower().replace(' ', '_').replace('-', '_')[:50]
|
||||
prp_path = os.path.join(self.output_dir, "PRPs", f"{safe_filename}.md")
|
||||
|
||||
with open(prp_path, "w", encoding="utf-8") as f:
|
||||
f.write(prp)
|
||||
prp_path = safe_join(prp_dir, safe_filename, ".md")
|
||||
atomic_write_text(prp_path, prp)
|
||||
|
||||
print(f"📄 Comprehensive PRP following PRD template saved to: {prp_path}")
|
||||
return prp
|
||||
|
||||
@@ -22,12 +22,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
from typing import List, Optional, Any, Dict, Union, Generator, TYPE_CHECKING
|
||||
|
||||
# Shared HTTP launch state — the process-wide registry is defined in
|
||||
# agent/server_registry.py and shared with PraisonAIAgents.launch() so a
|
||||
# standalone Agent and a team launched on the same port cannot double-bind it or
|
||||
# hide each other's endpoints.
|
||||
from .server_registry import _server_registry
|
||||
|
||||
|
||||
class _TimeoutCancelToken:
|
||||
"""cancel_token for caller-mode ``start(timeout=...)``.
|
||||
@@ -76,6 +70,8 @@ def cleanup_launch_registration(agent_id: str) -> None:
|
||||
``_AgentServerRegistry.teardown_routes_for``) so cleanup cannot race a
|
||||
concurrent launch that reserves and registers a route on the same port.
|
||||
"""
|
||||
from .server_registry import _server_registry # lazy: launch path only
|
||||
|
||||
_server_registry.teardown_routes_for(agent_id)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -2394,7 +2390,11 @@ Write the complete compiled report:"""
|
||||
host = resolve_launch_host(host)
|
||||
|
||||
# Get or create the shared FastAPI app for this port (shared with
|
||||
# PraisonAIAgents.launch via the same _server_registry singleton).
|
||||
# PraisonAIAgents.launch via the same _server_registry singleton, so a
|
||||
# standalone Agent and a team on one port cannot double-bind it).
|
||||
# Imported lazily: only the HTTP launch path needs the registry.
|
||||
from .server_registry import _server_registry
|
||||
|
||||
app, is_new_app = _server_registry.get_or_create_app(
|
||||
port,
|
||||
title=f"PraisonAI Agents API (Port {port})",
|
||||
|
||||
@@ -6,12 +6,31 @@ including support for different image models, sizes, and quality settings.
|
||||
import asyncio
|
||||
from typing import Optional, Any, Dict, Union, List
|
||||
from ..agent.agent import Agent
|
||||
from ._image_result import ImageResult
|
||||
from pydantic import BaseModel, Field
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
import warnings
|
||||
# Filter out Pydantic warning about fields
|
||||
warnings.filterwarnings("ignore", "Valid config keys have changed in V2", UserWarning)
|
||||
# dall-e-2/dall-e-3 were retired by OpenAI on 2026-05-12; gpt-image-1 on 2026-10-23.
|
||||
DEFAULT_IMAGE_MODEL = "gpt-image-2.5-flare"
|
||||
|
||||
|
||||
def _reject_markup(kwargs: Dict[str, Any]) -> None:
|
||||
"""Fail loudly instead of sending a broken ``markup`` edit.
|
||||
|
||||
LiteLLM only uploads ``image``/``mask`` as file parts; any ``markup`` value is
|
||||
either dropped or sent as a plain text field, so the edit would silently
|
||||
ignore the user's markup.
|
||||
"""
|
||||
if kwargs.get("markup") is not None:
|
||||
raise ValueError(
|
||||
"'markup' is not supported through LiteLLM's image_edit yet (it is not "
|
||||
"uploaded as a file). Use 'mask' or call the OpenAI SDK's images.edit directly."
|
||||
)
|
||||
|
||||
|
||||
class ImageGenerationConfig(BaseModel):
|
||||
"""Configuration for image generation settings."""
|
||||
style: Optional[str] = Field(default=None, description="Style of the generated image (only sent when set)")
|
||||
@@ -135,13 +154,13 @@ class ImageAgent(Agent):
|
||||
model_info = self.llm_model
|
||||
return model_info.model if hasattr(model_info, 'model') else str(model_info)
|
||||
|
||||
def generate_image(self, prompt: str, **kwargs) -> Dict[str, Any]:
|
||||
def generate_image(self, prompt: str, **kwargs) -> ImageResult:
|
||||
"""Generate an image based on the provided prompt."""
|
||||
# Merge default config with any provided kwargs
|
||||
config = self.image_config.dict(exclude_none=True)
|
||||
config.update(kwargs)
|
||||
|
||||
model_name = self._image_model("dall-e-3")
|
||||
model_name = self._image_model(DEFAULT_IMAGE_MODEL)
|
||||
|
||||
# Use the model name in config
|
||||
config['model'] = model_name
|
||||
@@ -190,27 +209,27 @@ class ImageAgent(Agent):
|
||||
|
||||
# Mark task as complete
|
||||
progress.update(task, completed=True)
|
||||
return response
|
||||
return ImageResult(response)
|
||||
except Exception as e:
|
||||
error_msg = f"Error generating image: {str(e)}"
|
||||
if self.verbose:
|
||||
self.console.print(f"[red]{error_msg}[/red]")
|
||||
logging.error(error_msg)
|
||||
raise
|
||||
async def agenerate_image(self, prompt: str, **kwargs) -> Dict[str, Any]:
|
||||
async def agenerate_image(self, prompt: str, **kwargs) -> ImageResult:
|
||||
"""Offload the synchronous generation path to keep the event loop responsive."""
|
||||
return await asyncio.to_thread(self.generate_image, prompt, **kwargs)
|
||||
|
||||
# Aliases for consistency with other agents
|
||||
def generate(self, prompt: str, **kwargs) -> Dict[str, Any]:
|
||||
def generate(self, prompt: str, **kwargs) -> ImageResult:
|
||||
"""Alias for generate_image() - for consistency with VideoAgent/AudioAgent."""
|
||||
return self.generate_image(prompt, **kwargs)
|
||||
|
||||
async def agenerate(self, prompt: str, **kwargs) -> Dict[str, Any]:
|
||||
async def agenerate(self, prompt: str, **kwargs) -> ImageResult:
|
||||
"""Async alias for generate_image()."""
|
||||
return await self.agenerate_image(prompt, **kwargs)
|
||||
|
||||
def chat(self, prompt: str, **kwargs) -> Dict[str, Any]:
|
||||
def chat(self, prompt: str, **kwargs) -> Union[ImageResult, Dict[str, Any]]:
|
||||
"""Generate an image from the prompt."""
|
||||
try:
|
||||
result = self.generate_image(prompt, **kwargs)
|
||||
@@ -231,7 +250,7 @@ class ImageAgent(Agent):
|
||||
output_pydantic: Optional[Any] = None,
|
||||
reasoning_steps: bool = False,
|
||||
**kwargs
|
||||
) -> Union[str, Dict[str, Any]]:
|
||||
) -> Union[ImageResult, Dict[str, Any]]:
|
||||
"""Async chat method for image generation."""
|
||||
try:
|
||||
image_result = await self.agenerate_image(prompt, **kwargs)
|
||||
@@ -252,7 +271,7 @@ class ImageAgent(Agent):
|
||||
n: int = 1,
|
||||
size: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> Dict[str, Any]:
|
||||
) -> ImageResult:
|
||||
"""
|
||||
Edit an existing image with a prompt.
|
||||
|
||||
@@ -269,15 +288,16 @@ class ImageAgent(Agent):
|
||||
|
||||
Example:
|
||||
```python
|
||||
agent = ImageAgent(llm="openai/dall-e-2")
|
||||
result = agent.edit("photo.png", "Add a sunset in the background")
|
||||
agent = ImageAgent(llm="openai/gpt-image-2.5-flare")
|
||||
agent.edit("photo.png", "Add a sunset in the background").save("out.png")
|
||||
```
|
||||
"""
|
||||
try:
|
||||
import litellm
|
||||
litellm.telemetry = False
|
||||
|
||||
model_name = self._image_model("dall-e-2")
|
||||
_reject_markup(kwargs)
|
||||
model_name = self._image_model(DEFAULT_IMAGE_MODEL)
|
||||
|
||||
config = {
|
||||
"model": model_name,
|
||||
@@ -299,7 +319,7 @@ class ImageAgent(Agent):
|
||||
if self.verbose:
|
||||
self.console.print(f"[cyan]Editing image with {model_name}...[/cyan]")
|
||||
|
||||
response = litellm.image_edit(**config)
|
||||
response = ImageResult(litellm.image_edit(**config))
|
||||
|
||||
if self.verbose:
|
||||
self.console.print(f"[green]✓ Image edited successfully[/green]")
|
||||
@@ -319,7 +339,7 @@ class ImageAgent(Agent):
|
||||
n: int = 1,
|
||||
size: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> Dict[str, Any]:
|
||||
) -> ImageResult:
|
||||
"""Offload the synchronous editing path to keep the event loop responsive."""
|
||||
return await asyncio.to_thread(self.edit, image, prompt, mask, n, size, **kwargs)
|
||||
|
||||
@@ -329,7 +349,7 @@ class ImageAgent(Agent):
|
||||
n: int = 1,
|
||||
size: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> Dict[str, Any]:
|
||||
) -> ImageResult:
|
||||
"""
|
||||
Generate variations of an existing image.
|
||||
|
||||
@@ -371,7 +391,7 @@ class ImageAgent(Agent):
|
||||
if self.verbose:
|
||||
self.console.print(f"[cyan]Generating variations with {model_name}...[/cyan]")
|
||||
|
||||
response = litellm.image_variation(**config)
|
||||
response = ImageResult(litellm.image_variation(**config))
|
||||
|
||||
if self.verbose:
|
||||
self.console.print(f"[green]✓ Variations generated successfully[/green]")
|
||||
@@ -389,6 +409,6 @@ class ImageAgent(Agent):
|
||||
n: int = 1,
|
||||
size: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> Dict[str, Any]:
|
||||
) -> ImageResult:
|
||||
"""Offload the synchronous variation path to keep the event loop responsive."""
|
||||
return await asyncio.to_thread(self.variation, image, n, size, **kwargs)
|
||||
@@ -55,6 +55,27 @@ def _live_turn_message_ids() -> set:
|
||||
ids.update(id(m) for m in owned)
|
||||
return ids
|
||||
|
||||
|
||||
def _exchange_spans(history: list) -> list:
|
||||
"""Split history into ``(start, end)`` units that must stay together.
|
||||
|
||||
An assistant turn carrying ``tool_calls`` and the ``role="tool"`` results
|
||||
that follow it form one unit; every other message is its own unit. History
|
||||
mutators cut/delete on unit boundaries so they never leave an orphaned tool
|
||||
result or a dangling ``tool_calls`` turn, which providers reject (#6217).
|
||||
"""
|
||||
spans = []
|
||||
i, n = 0, len(history)
|
||||
while i < n:
|
||||
j = i + 1
|
||||
msg = history[i]
|
||||
if msg.get("role") == "assistant" and msg.get("tool_calls"):
|
||||
while j < n and history[j].get("role") == "tool":
|
||||
j += 1
|
||||
spans.append((i, j))
|
||||
i = j
|
||||
return spans
|
||||
|
||||
# Shared lazy display helpers (cached, thread-safe; avoid circular imports)
|
||||
from ._lazy_display import _get_console, _get_live, _get_display_functions
|
||||
|
||||
@@ -178,6 +199,11 @@ class MemoryMixin:
|
||||
length: Target length for chat history
|
||||
"""
|
||||
with self._history_lock:
|
||||
# Never cut inside a tool exchange: back off to its start (#6217).
|
||||
for start, end in _exchange_spans(self.chat_history):
|
||||
if start < length < end:
|
||||
length = start
|
||||
break
|
||||
self.chat_history = self.chat_history[:length]
|
||||
|
||||
def _begin_turn_tracking(self):
|
||||
@@ -299,8 +325,14 @@ class MemoryMixin:
|
||||
if len(self.chat_history) <= keep_last:
|
||||
return 0
|
||||
|
||||
deleted_count = len(self.chat_history) - keep_last
|
||||
self.chat_history = self.chat_history[-keep_last:]
|
||||
cut = len(self.chat_history) - keep_last
|
||||
# Never start the kept tail inside a tool exchange (#6217).
|
||||
for start, end in _exchange_spans(self.chat_history):
|
||||
if start < cut < end:
|
||||
cut = end
|
||||
break
|
||||
deleted_count = cut
|
||||
self.chat_history = self.chat_history[cut:]
|
||||
# Reset auto-save index to match new history length
|
||||
self._auto_save_last_index = len(self.chat_history)
|
||||
return deleted_count
|
||||
@@ -318,8 +350,17 @@ class MemoryMixin:
|
||||
True if deleted, False if index out of range
|
||||
"""
|
||||
with self._history_lock:
|
||||
n = len(self.chat_history)
|
||||
if not -n <= index < n:
|
||||
return False
|
||||
index %= n
|
||||
# Deleting part of a tool exchange removes the whole exchange so no
|
||||
# orphaned tool result / dangling tool_calls is left (#6217).
|
||||
for start, end in _exchange_spans(self.chat_history):
|
||||
if start <= index < end:
|
||||
break
|
||||
try:
|
||||
del self.chat_history[index]
|
||||
del self.chat_history[start:end]
|
||||
# Adjust auto-save index if deletion affects saved range
|
||||
if hasattr(self, '_auto_save_last_index'):
|
||||
self._auto_save_last_index = min(
|
||||
@@ -344,10 +385,18 @@ class MemoryMixin:
|
||||
"""
|
||||
with self._history_lock:
|
||||
original_len = len(self.chat_history)
|
||||
self.chat_history = [
|
||||
msg for msg in self.chat_history
|
||||
if pattern.lower() not in msg.get("content", "").lower()
|
||||
]
|
||||
needle = pattern.lower()
|
||||
history = self.chat_history
|
||||
kept = []
|
||||
# Match per exchange so a hit inside a tool exchange drops the whole
|
||||
# exchange; content may be None on tool-calling turns (#6217).
|
||||
for start, end in _exchange_spans(history):
|
||||
unit = history[start:end]
|
||||
if not any(
|
||||
needle in str(m.get("content") or "").lower() for m in unit
|
||||
):
|
||||
kept.extend(unit)
|
||||
self.chat_history = kept
|
||||
deleted_count = original_len - len(self.chat_history)
|
||||
# Adjust auto-save index if deletion affects saved range
|
||||
if deleted_count > 0 and hasattr(self, '_auto_save_last_index'):
|
||||
@@ -569,11 +618,10 @@ class MemoryMixin:
|
||||
if history:
|
||||
# Only restore if chat_history is empty (avoid duplicates)
|
||||
if not self.chat_history:
|
||||
# The store returns LLM-shaped dicts (to_llm_message), so
|
||||
# keep tool_calls / tool_call_id intact (#6217).
|
||||
for msg in history:
|
||||
self.chat_history.append({
|
||||
"role": msg["role"],
|
||||
"content": msg["content"]
|
||||
})
|
||||
self.chat_history.append(dict(msg))
|
||||
logging.info(f"Restored session {self._session_id} with {len(history)} messages from JSON store")
|
||||
|
||||
# Set agent info
|
||||
@@ -862,7 +910,7 @@ class MemoryMixin:
|
||||
except Exception as e:
|
||||
logging.debug(f"Discard partial assistant turn skipped: {e}")
|
||||
|
||||
def _add_message_with_tool_fields(self, role, content, **tool_fields):
|
||||
def _add_message_with_tool_fields(self, role, content, session_id=None, **tool_fields):
|
||||
"""Add a message carrying tool fields, tolerating stores without them.
|
||||
|
||||
The default JSON store and the SQLite store accept ``tool_calls`` /
|
||||
@@ -874,11 +922,13 @@ class MemoryMixin:
|
||||
back to a plain-text turn so no exchange is silently lost.
|
||||
"""
|
||||
add_message = self._session_store.add_message
|
||||
if self._store_supports_tool_fields(add_message):
|
||||
add_message(self._session_id, role, content, **tool_fields)
|
||||
if session_id is None:
|
||||
session_id = self._session_id
|
||||
if tool_fields and self._store_supports_tool_fields(add_message):
|
||||
add_message(session_id, role, content, **tool_fields)
|
||||
return
|
||||
# Legacy store: preserve the turn as plain text so resume still sees it.
|
||||
add_message(self._session_id, role, content)
|
||||
add_message(session_id, role, content)
|
||||
|
||||
def _store_supports_tool_fields(self, add_message) -> bool:
|
||||
"""Return True if ``add_message`` accepts the tool keyword fields."""
|
||||
@@ -1195,10 +1245,17 @@ class MemoryMixin:
|
||||
if self._session_store is not None:
|
||||
# Persist only NEW messages to SessionStore
|
||||
for msg in new_messages:
|
||||
self._session_store.add_message(
|
||||
self.auto_save, # Use auto_save name as session_id
|
||||
role=msg.get("role", "user"),
|
||||
content=msg.get("content", ""),
|
||||
tool_fields = {
|
||||
k: msg[k]
|
||||
for k in ("tool_calls", "tool_call_id")
|
||||
if msg.get(k) is not None
|
||||
}
|
||||
# Persist tool fields so resumed tool turns stay valid (#6217)
|
||||
self._add_message_with_tool_fields(
|
||||
msg.get("role", "user"),
|
||||
msg.get("content") or "",
|
||||
session_id=self.auto_save,
|
||||
**tool_fields,
|
||||
)
|
||||
# Update last saved index
|
||||
self._auto_save_last_index = len(clean_history)
|
||||
@@ -1228,34 +1285,18 @@ class MemoryMixin:
|
||||
return False
|
||||
|
||||
try:
|
||||
import os
|
||||
from praisonaiagents.tools.path_safety import resolve_within_root
|
||||
from ..utils.output_file import write_output_file
|
||||
|
||||
project_root = os.environ.get("PRAISONAI_PROJECT_ROOT") or os.getcwd()
|
||||
file_path = resolve_within_root(self._output_file, project_root)
|
||||
# Shared hardened writer (root containment, O_NOFOLLOW, hardlink-safe).
|
||||
file_path = write_output_file(self._output_file, str(content))
|
||||
if file_path is None:
|
||||
logging.warning(
|
||||
"Output file %r is outside project root %r; skipping save",
|
||||
self._output_file,
|
||||
project_root,
|
||||
)
|
||||
print(f"⚠️ Output path outside project root: {self._output_file}")
|
||||
return False
|
||||
|
||||
# Create parent directories if they don't exist
|
||||
parent_dir = os.path.dirname(file_path)
|
||||
if parent_dir and not os.path.exists(parent_dir):
|
||||
os.makedirs(parent_dir, exist_ok=True)
|
||||
|
||||
# Write content to file
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.write(str(content))
|
||||
|
||||
# Print success message to terminal
|
||||
|
||||
print(f"✅ Output saved to {file_path}")
|
||||
logging.debug(f"Output saved to file: {file_path}")
|
||||
return True
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to save output to file '{self._output_file}': {e}")
|
||||
print(f"⚠️ Failed to save output to {self._output_file}: {e}")
|
||||
|
||||
@@ -4362,8 +4362,13 @@ class ToolExecutionMixin:
|
||||
return "Tool search not in bridge mode"
|
||||
|
||||
# Get deferrable tools from metadata
|
||||
deferrable_tools = metadata.get("deferrable_tools", [])
|
||||
|
||||
# Hide permission-denied tools from search/describe; the cached
|
||||
# metadata is built before pruning, so filter locally here.
|
||||
deferrable_tools = [
|
||||
t for t in metadata.get("deferrable_tools", [])
|
||||
if self._tool_name_allowed(t.get("function", {}).get("name", ""))
|
||||
]
|
||||
|
||||
if function_name == "tool_search":
|
||||
# Handle tool_search bridge call
|
||||
try:
|
||||
|
||||
@@ -121,6 +121,12 @@ class PlatformCapabilities:
|
||||
anchoring). When False, ``create_thread`` returns a typed
|
||||
``unsupported`` outcome and callers fall back to the parent channel
|
||||
rather than raising.
|
||||
supports_status_text: Whether the adapter can render a short,
|
||||
replaceable textual status line (e.g. "🔍 Searching the web...")
|
||||
without editing a message or adding reactions. When True the
|
||||
adapter SHOULD implement ``async set_status_text(channel_id, text)``
|
||||
(an empty ``text`` clears the line when the turn ends); streaming ``auto``/``progress`` on a non-edit channel then routes the
|
||||
curated activity line there instead of degrading to ``off``.
|
||||
"""
|
||||
|
||||
max_message_length: int = 4096
|
||||
@@ -142,6 +148,7 @@ class PlatformCapabilities:
|
||||
# Appended after all pre-existing fields to preserve the positional
|
||||
# constructor contract for external callers (do not reorder above).
|
||||
supports_delete: bool = False
|
||||
supports_status_text: bool = False
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
@@ -163,6 +170,7 @@ class PlatformCapabilities:
|
||||
"supports_idempotency_token": self.supports_idempotency_token,
|
||||
"supports_media": self.supports_media,
|
||||
"supports_threads": self.supports_threads,
|
||||
"supports_status_text": self.supports_status_text,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -186,6 +194,7 @@ class PlatformCapabilities:
|
||||
supports_idempotency_token=data.get("supports_idempotency_token", False),
|
||||
supports_media=data.get("supports_media", False),
|
||||
supports_threads=data.get("supports_threads", False),
|
||||
supports_status_text=data.get("supports_status_text", False),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -957,7 +957,8 @@ def _validate_feature_config(feature: str, config: Dict[str, Any]) -> List[str]:
|
||||
"guardrails": {"enabled", "max_retries", "validator"},
|
||||
"web": {"enabled", "search", "fetch", "provider"},
|
||||
"output": {"enabled", "verbose", "markdown", "stream", "metrics", "preset"},
|
||||
"execution": {"enabled", "max_iter", "max_rpm", "max_execution_time", "preset"},
|
||||
"execution": {"enabled", "max_iter", "max_rpm", "max_execution_time", "preset",
|
||||
"max_budget", "on_budget_exceeded", "durable", "journal_path", "resume_run_id"},
|
||||
"caching": {"enabled", "prompt_caching", "ttl"},
|
||||
"autonomy": {"enabled", "max_steps", "approval_required"},
|
||||
"context": {"enabled", "max_tokens", "strategy"},
|
||||
|
||||
@@ -5,6 +5,7 @@ Implements progressive escalation from direct response to full autonomous mode.
|
||||
"""
|
||||
|
||||
from praisonaiagents._logging import get_logger
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Callable
|
||||
|
||||
@@ -284,6 +285,14 @@ class EscalationPipeline:
|
||||
else:
|
||||
return {"response": "", "success": False}
|
||||
|
||||
async def _run_chat(self, prompt: str, **kwargs) -> Any:
|
||||
"""Run the blocking ``agent.chat`` in a worker thread.
|
||||
|
||||
``asyncio.to_thread`` copies contextvars, so request-scoped state
|
||||
(user, trace, budget) survives, and the event loop stays responsive.
|
||||
"""
|
||||
return await asyncio.to_thread(self.agent.chat, prompt, **kwargs)
|
||||
|
||||
async def _execute_direct(self, prompt: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Execute direct response (Stage 0).
|
||||
@@ -298,7 +307,7 @@ class EscalationPipeline:
|
||||
|
||||
try:
|
||||
# Direct chat without tools
|
||||
response = self.agent.chat(prompt)
|
||||
response = await self._run_chat(prompt)
|
||||
|
||||
self._current_context.add_step(
|
||||
action="direct_response",
|
||||
@@ -335,16 +344,12 @@ class EscalationPipeline:
|
||||
# Limit tools for heuristic stage
|
||||
selected_tools = selected_tools[:self.config.heuristic_max_tools]
|
||||
|
||||
# Execute with selected tools
|
||||
# Execute with selected tools passed per call, never mutating
|
||||
# the shared, caller-owned agent (safe under concurrent execute()).
|
||||
if hasattr(self.agent, 'tools'):
|
||||
original_tools = self.agent.tools
|
||||
self.agent.tools = selected_tools
|
||||
|
||||
try:
|
||||
response = self.agent.chat(prompt)
|
||||
finally:
|
||||
if hasattr(self.agent, 'tools'):
|
||||
self.agent.tools = original_tools
|
||||
response = await self._run_chat(prompt, tools=selected_tools)
|
||||
else:
|
||||
response = await self._run_chat(prompt)
|
||||
|
||||
self._current_context.add_step(
|
||||
action="heuristic_response",
|
||||
@@ -381,7 +386,7 @@ class EscalationPipeline:
|
||||
|
||||
Respond with numbered steps, then execute them."""
|
||||
|
||||
response = self.agent.chat(plan_prompt)
|
||||
response = await self._run_chat(plan_prompt)
|
||||
|
||||
self._current_context.add_step(
|
||||
action="planned_response",
|
||||
@@ -427,7 +432,7 @@ Respond with numbered steps, then execute them."""
|
||||
|
||||
Use all available tools as needed. Verify your work before completing."""
|
||||
|
||||
response = self.agent.chat(autonomous_prompt)
|
||||
response = await self._run_chat(autonomous_prompt)
|
||||
|
||||
self._current_context.add_step(
|
||||
action="autonomous_response",
|
||||
|
||||
@@ -18,6 +18,8 @@ from .._lazy import create_lazy_getattr
|
||||
# 283 KB / 7,201-line ``protocols.py`` (and siblings) out of package-import
|
||||
# time; each submodule is parsed only when one of its symbols is first
|
||||
# touched. Mirrors the ``mcp``/``skills`` packages (AGENTS.md §4.2).
|
||||
_PROTOCOLS_MODULE = "praisonaiagents.gateway.protocols"
|
||||
|
||||
_LAZY_IMPORTS = {
|
||||
# protocols.py (parsed lazily, on first touch)
|
||||
"GatewayProtocol": ("praisonaiagents.gateway.protocols", "GatewayProtocol"),
|
||||
@@ -229,7 +231,9 @@ _LAZY_IMPORTS = {
|
||||
# Schema-validated inbound frame codec (Issue #2831)
|
||||
"HelloParams": ("praisonaiagents.gateway.protocols", "HelloParams"),
|
||||
# Device-identity handshake contract (Issue #5998)
|
||||
"DeviceAuth": ("praisonaiagents.gateway.protocols", "DeviceAuth"),
|
||||
# Module path via a constant: GitGuardian reads a ("...", "DeviceAuth")
|
||||
# string pair as an "authentication tuple" and fails every PR that merges main.
|
||||
"DeviceAuth": (_PROTOCOLS_MODULE, "DeviceAuth"),
|
||||
"DeviceIdentityProtocol": ("praisonaiagents.gateway.protocols", "DeviceIdentityProtocol"),
|
||||
"device_id_from_public_key": ("praisonaiagents.gateway.protocols", "device_id_from_public_key"),
|
||||
"HelloResult": ("praisonaiagents.gateway.protocols", "HelloResult"),
|
||||
|
||||
@@ -128,6 +128,56 @@ def _check_unknown_keys(
|
||||
)
|
||||
|
||||
|
||||
_GRANT_KEYS = {"scopes", "agents"}
|
||||
|
||||
|
||||
def _parse_grant(scopes: Any, agents: Any, path: str) -> Any:
|
||||
"""Normalise one operator grant to a scope list or ``{scopes, agents}``.
|
||||
|
||||
Issue #6253: unknown grant keys always fail closed — a typo such as
|
||||
``agnts`` would otherwise silently drop the agent confinement and leave
|
||||
the token able to reach every agent.
|
||||
"""
|
||||
if isinstance(scopes, Mapping):
|
||||
_check_unknown_keys(scopes, _GRANT_KEYS, path, strict=True)
|
||||
scopes, agents = scopes.get("scopes", []), scopes.get("agents")
|
||||
scopes = list(scopes or [])
|
||||
if agents is None:
|
||||
return scopes
|
||||
if not isinstance(agents, (list, tuple)):
|
||||
# ``agents: support`` would otherwise iterate into ['s', 'u', ...].
|
||||
raise ValueError(f"{path}: 'agents' must be a list of agent ids")
|
||||
return {"scopes": scopes, "agents": [str(a) for a in agents]}
|
||||
|
||||
|
||||
def _parse_auth_scopes(gw_data: Mapping[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Parse the YAML ``gateway.auth.tokens`` / ``gateway.auth_scopes`` policy.
|
||||
|
||||
Returns ``None`` when neither form is present so callers can leave an
|
||||
existing policy untouched.
|
||||
"""
|
||||
auth_section = gw_data.get("auth")
|
||||
if isinstance(auth_section, Mapping) and isinstance(auth_section.get("tokens"), list):
|
||||
policy: Dict[str, Any] = {}
|
||||
for entry in auth_section["tokens"]:
|
||||
if isinstance(entry, Mapping) and entry.get("token"):
|
||||
# A typo such as ``agnts`` must fail closed here too.
|
||||
_check_unknown_keys(
|
||||
entry, _GRANT_KEYS | {"token"}, "gateway.auth.tokens", strict=True
|
||||
)
|
||||
policy[str(entry["token"])] = _parse_grant(
|
||||
entry.get("scopes", []), entry.get("agents"),
|
||||
"gateway.auth.tokens",
|
||||
)
|
||||
return policy
|
||||
if isinstance(gw_data.get("auth_scopes"), Mapping):
|
||||
return {
|
||||
str(tok): _parse_grant(grant, None, "gateway.auth_scopes")
|
||||
for tok, grant in gw_data["auth_scopes"].items()
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _check_typo_keys(
|
||||
data: Mapping[str, Any],
|
||||
reserved: Set[str],
|
||||
@@ -1150,7 +1200,13 @@ class GatewayConfig:
|
||||
scope names (see OperatorScope). When None/empty (default), any
|
||||
successfully authenticated client is granted all scopes — identical
|
||||
to the previous binary behaviour. Single-operator setups are
|
||||
unaffected.
|
||||
unaffected. A token's value may instead be a mapping
|
||||
``{"scopes": [...], "agents": [...]}`` to also confine the operator
|
||||
to the listed agent ids (omitted ``agents`` = all agents).
|
||||
Confinement covers WebSocket bind/resume and event delivery only;
|
||||
the HTTP approval endpoints are not yet filtered per agent
|
||||
(tracked in #5151), so do not grant ``approvals`` to a token that
|
||||
must not act on other agents' requests.
|
||||
"""
|
||||
|
||||
host: str = "127.0.0.1"
|
||||
@@ -1208,7 +1264,9 @@ class GatewayConfig:
|
||||
# (0 = disabled).
|
||||
max_unauthorized_frames: int = 10
|
||||
push: PushConfig = field(default_factory=PushConfig)
|
||||
auth_scopes: Optional[Dict[str, List[str]]] = None
|
||||
# Issue #6253: a value is either a scope list or
|
||||
# ``{"scopes": [...], "agents": [...]}`` (agent confinement).
|
||||
auth_scopes: Optional[Dict[str, Any]] = None
|
||||
# Additive protocol surfaces (OpenAI-compatible / MCP) served on the same
|
||||
# app and auth. Opt-in; disabled by default so the gateway is unchanged.
|
||||
api: ApiConfig = field(default_factory=ApiConfig)
|
||||
@@ -1329,6 +1387,8 @@ class GatewayConfig:
|
||||
if token is None:
|
||||
return []
|
||||
granted = self.auth_scopes.get(token)
|
||||
if isinstance(granted, dict):
|
||||
granted = granted.get("scopes") or []
|
||||
if granted is None:
|
||||
return []
|
||||
resolved: List[str] = []
|
||||
@@ -1348,6 +1408,22 @@ class GatewayConfig:
|
||||
)
|
||||
return resolved
|
||||
|
||||
def resolve_agents(self, token: Optional[str]) -> Optional[List[str]]:
|
||||
"""Resolve the agent ids ``token`` is confined to (Issue #6253).
|
||||
|
||||
``None`` means unconfined (every agent) — the result whenever no
|
||||
policy is set or the token's entry carries no ``agents`` list, so
|
||||
existing ``token -> [scopes]`` policies are unchanged. A token absent
|
||||
from a configured policy holds no scopes, so it never reaches an
|
||||
agent regardless of this value.
|
||||
"""
|
||||
if not self.auth_scopes or token is None:
|
||||
return None
|
||||
entry = self.auth_scopes.get(token)
|
||||
if not isinstance(entry, dict) or entry.get("agents") is None:
|
||||
return None
|
||||
return [str(a) for a in entry["agents"]]
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary (hides sensitive data)."""
|
||||
return {
|
||||
@@ -1578,18 +1654,10 @@ class MultiChannelGatewayConfig:
|
||||
# gateway:
|
||||
# auth_scopes:
|
||||
# "${VIEWER_TOKEN}": [read]
|
||||
auth_scopes: Optional[Dict[str, List[str]]] = None
|
||||
auth_section = gw_data.get("auth")
|
||||
if isinstance(auth_section, dict) and isinstance(auth_section.get("tokens"), list):
|
||||
auth_scopes = {}
|
||||
for entry in auth_section["tokens"]:
|
||||
if isinstance(entry, dict) and entry.get("token"):
|
||||
auth_scopes[str(entry["token"])] = list(entry.get("scopes", []))
|
||||
elif isinstance(gw_data.get("auth_scopes"), dict):
|
||||
auth_scopes = {
|
||||
str(tok): list(scopes)
|
||||
for tok, scopes in gw_data["auth_scopes"].items()
|
||||
}
|
||||
# Either shape may add ``agents: [...]`` per token (Issue #6253):
|
||||
# auth_scopes:
|
||||
# "${SUPPORT_TOKEN}": {scopes: [read, write], agents: [support]}
|
||||
auth_scopes = _parse_auth_scopes(gw_data)
|
||||
|
||||
raw_trusted = gw_data.get("trusted_proxies", [])
|
||||
trusted_proxies = (
|
||||
|
||||
@@ -3841,12 +3841,19 @@ class ConversationRequestProtocol(Protocol):
|
||||
# import lives in core.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
GatewayRequestKind = Literal["approval", "choice", "input"]
|
||||
GatewayRequestKind = Literal["approval", "choice", "input", "secret"]
|
||||
"""Closed set of server→client interactive request kinds.
|
||||
|
||||
* ``approval`` — allow/deny; the reply ``value`` is ``"allow"`` or ``"deny"``.
|
||||
* ``choice`` — select one of ``options``; the reply ``value`` is the option.
|
||||
* ``input`` — free text; the reply ``value`` is the entered text.
|
||||
* ``secret`` — a credential (password, API key, one-time code) captured
|
||||
mid-turn (Issue #6223). Clients MUST render a masked input. Isolation
|
||||
contract: the reply ``value`` is returned *only* to the caller of
|
||||
:meth:`GatewayRequestChannelProtocol.request`; implementations MUST NOT log,
|
||||
journal, echo, or persist it, and the caller (a tool/callback) MUST NOT
|
||||
return it to the model or append it to history. A tool obtains one via
|
||||
``get_request_channel().request(GatewayServerRequest(kind="secret", ...))``.
|
||||
"""
|
||||
|
||||
|
||||
@@ -3893,11 +3900,13 @@ class GatewayServerReply:
|
||||
Attributes:
|
||||
request_id: The id of the request being answered.
|
||||
value: ``"allow"``/``"deny"`` for approval, the chosen option for
|
||||
choice, or the entered text for input.
|
||||
choice, the entered text for input, or the credential for secret.
|
||||
Excluded from ``repr`` so a logged/traced reply never leaks a
|
||||
secret.
|
||||
"""
|
||||
|
||||
request_id: str
|
||||
value: str
|
||||
value: str = field(repr=False)
|
||||
|
||||
def as_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to a serializable dictionary."""
|
||||
|
||||
@@ -14,7 +14,6 @@ from typing import Any, Optional, Tuple, TYPE_CHECKING
|
||||
|
||||
from praisonaiagents._logging import get_logger
|
||||
|
||||
from .judge import judge_goal, _tail
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .models import GoalCriteria, GoalState
|
||||
@@ -168,6 +167,8 @@ class GoalLoopMixin:
|
||||
self._persist_goal_state()
|
||||
return "continue", reason
|
||||
|
||||
from .judge import judge_goal, _tail # lazy: goal loop is opt-in
|
||||
|
||||
state.turns_used += 1
|
||||
verdict, reason = judge_goal(
|
||||
state,
|
||||
|
||||
@@ -310,8 +310,9 @@ class SubQuestionEngine:
|
||||
context: Optional[List[str]] = None,
|
||||
**kwargs
|
||||
) -> QueryResult:
|
||||
"""Async version (just calls sync)."""
|
||||
return self.query(question, context, **kwargs)
|
||||
"""Async version; runs the sync query (which may call the LLM) in a worker thread."""
|
||||
import asyncio
|
||||
return await asyncio.to_thread(self.query, question, context, **kwargs)
|
||||
|
||||
def _decompose_with_llm(self, question: str) -> List[str]:
|
||||
"""Decompose question using LLM."""
|
||||
|
||||
@@ -3598,6 +3598,19 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
reason = _cancel_reason()
|
||||
logging.debug(f"LLM tool loop cancelled before tool dispatch: {reason}")
|
||||
return _prepare_return_value(f"Task interrupted: {reason}")
|
||||
# Same per-turn cap as the chat-completions loop, applied
|
||||
# before recording the assistant turn so every declared
|
||||
# tool_call_id gets exactly one tool reply.
|
||||
if tool_call_count >= max_tool_calls_per_turn:
|
||||
logging.warning(f"Tool call limit reached ({max_tool_calls_per_turn}). Stopping to prevent infinite loop.")
|
||||
self._last_stop_reason = "max_steps"
|
||||
if not (final_response_text and final_response_text.strip()):
|
||||
final_response_text = f"Tool call limit reached ({max_tool_calls_per_turn} calls). Task may be too complex or there may be a broken tool causing repeated calls."
|
||||
break
|
||||
if tool_call_count + len(tool_calls) > max_tool_calls_per_turn:
|
||||
tool_calls = tool_calls[:max_tool_calls_per_turn - tool_call_count]
|
||||
logging.warning(f"Limiting batch to {len(tool_calls)} tool calls to stay within limit of {max_tool_calls_per_turn}.")
|
||||
tool_call_count += len(tool_calls)
|
||||
serializable_tool_calls = self._serialize_tool_calls(tool_calls)
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
@@ -3680,7 +3693,7 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
|
||||
# Append tool result as standard tool message
|
||||
messages.append(self._create_tool_message(
|
||||
function_name, tool_result,
|
||||
tool_result_obj.function_name, tool_result,
|
||||
tool_result_obj.tool_call_id, is_ollama=False))
|
||||
|
||||
# Report any unparseable tool-call arguments so the
|
||||
@@ -4316,6 +4329,22 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
reason = _cancel_reason()
|
||||
logging.debug(f"LLM tool loop cancelled before tool dispatch: {reason}")
|
||||
return _prepare_return_value(f"Task interrupted: {reason}")
|
||||
# Apply the cap BEFORE recording the assistant turn so every
|
||||
# declared tool_call_id gets exactly one tool reply.
|
||||
# Guardrail: Check tool call limit to prevent infinite loops
|
||||
# Allow execution if we haven't reached the limit yet, but limit the batch size
|
||||
if tool_call_count >= max_tool_calls_per_turn:
|
||||
logging.warning(f"Tool call limit reached ({max_tool_calls_per_turn}). Stopping to prevent infinite loop.")
|
||||
self._last_stop_reason = "max_steps"
|
||||
if not (final_response_text and final_response_text.strip()):
|
||||
final_response_text = f"Tool call limit reached ({max_tool_calls_per_turn} calls). Task may be too complex or there may be a broken tool causing repeated calls."
|
||||
break
|
||||
elif tool_call_count + len(tool_calls) > max_tool_calls_per_turn:
|
||||
# Limit the batch to stay within the total limit
|
||||
remaining_calls = max_tool_calls_per_turn - tool_call_count
|
||||
tool_calls = tool_calls[:remaining_calls]
|
||||
logging.warning(f"Limiting batch to {remaining_calls} tool calls to stay within limit of {max_tool_calls_per_turn}.")
|
||||
|
||||
# Convert tool_calls to a serializable format for all providers
|
||||
serializable_tool_calls = self._serialize_tool_calls(tool_calls)
|
||||
# Check if this is Ollama provider
|
||||
@@ -4337,20 +4366,6 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
tool_results = [] # Store current iteration tool results
|
||||
tool_result_mapping = {} # Store function results by name for Ollama chaining
|
||||
|
||||
# Guardrail: Check tool call limit to prevent infinite loops
|
||||
# Allow execution if we haven't reached the limit yet, but limit the batch size
|
||||
if tool_call_count >= max_tool_calls_per_turn:
|
||||
logging.warning(f"Tool call limit reached ({max_tool_calls_per_turn}). Stopping to prevent infinite loop.")
|
||||
self._last_stop_reason = "max_steps"
|
||||
if not (final_response_text and final_response_text.strip()):
|
||||
final_response_text = f"Tool call limit reached ({max_tool_calls_per_turn} calls). Task may be too complex or there may be a broken tool causing repeated calls."
|
||||
break
|
||||
elif tool_call_count + len(tool_calls) > max_tool_calls_per_turn:
|
||||
# Limit the batch to stay within the total limit
|
||||
remaining_calls = max_tool_calls_per_turn - tool_call_count
|
||||
tool_calls = tool_calls[:remaining_calls]
|
||||
logging.warning(f"Limiting batch to {remaining_calls} tool calls to stay within limit of {max_tool_calls_per_turn}.")
|
||||
|
||||
# Collect media-bearing follow-up messages so they are
|
||||
# flushed only AFTER every tool reply for this assistant
|
||||
# turn, keeping all tool replies consecutive (provider contract).
|
||||
@@ -5542,24 +5557,15 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
break
|
||||
last_tool_call_fingerprint = fingerprint
|
||||
|
||||
# Record the assistant turn before the tool replies, so
|
||||
# the follow-up request is a valid conversation.
|
||||
if is_ollama:
|
||||
messages.append({"role": "assistant", "content": content or ""})
|
||||
else:
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": content or "",
|
||||
"tool_calls": self._serialize_tool_calls(tool_calls),
|
||||
})
|
||||
|
||||
# Any prose the model emitted alongside the calls is
|
||||
# still part of the answer.
|
||||
# still part of the answer -- yield it before the cap
|
||||
# check so it is not lost when the limit is reached.
|
||||
if content:
|
||||
yield content
|
||||
|
||||
# Enforce the same per-turn guardrail the streaming and
|
||||
# non-streaming loops apply, so the fallback cannot run
|
||||
# non-streaming loops apply (before recording the assistant turn, so no
|
||||
# declared tool_call_id is left without a reply), so the fallback cannot run
|
||||
# tools past the caller's configured limit.
|
||||
if tool_call_count >= max_tool_calls_per_turn:
|
||||
logging.warning(
|
||||
@@ -5574,6 +5580,17 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
f"Limiting batch to {remaining_calls} tool calls to stay "
|
||||
f"within limit of {max_tool_calls_per_turn}.")
|
||||
|
||||
# Record the assistant turn before the tool replies, so
|
||||
# the follow-up request is a valid conversation.
|
||||
if is_ollama:
|
||||
messages.append({"role": "assistant", "content": content or ""})
|
||||
else:
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": content or "",
|
||||
"tool_calls": self._serialize_tool_calls(tool_calls),
|
||||
})
|
||||
|
||||
for tool_call in tool_calls:
|
||||
tool_call_count += 1
|
||||
function_name, arguments, tool_call_id = (
|
||||
@@ -5965,13 +5982,8 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
reason = _cancel_reason()
|
||||
logging.debug(f"Async LLM tool loop cancelled before tool dispatch: {reason}")
|
||||
return _prepare_return_value(f"Task interrupted: {reason}")
|
||||
serializable_tool_calls = self._serialize_tool_calls(tool_calls)
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": response_text,
|
||||
"tool_calls": serializable_tool_calls,
|
||||
})
|
||||
|
||||
# Apply the cap BEFORE recording the assistant turn so every
|
||||
# declared tool_call_id gets exactly one tool reply.
|
||||
# Guardrail: Check tool call limit to prevent infinite loops
|
||||
# Allow execution if we haven't reached the limit yet, but limit the batch size
|
||||
if tool_call_count >= max_tool_calls_per_turn:
|
||||
@@ -5986,6 +5998,13 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
tool_calls = tool_calls[:remaining_calls]
|
||||
logging.warning(f"Limiting batch to {remaining_calls} tool calls to stay within limit of {max_tool_calls_per_turn}.")
|
||||
|
||||
serializable_tool_calls = self._serialize_tool_calls(tool_calls)
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": response_text,
|
||||
"tool_calls": serializable_tool_calls,
|
||||
})
|
||||
|
||||
# Pre-resolve each tool call, then dispatch the valid ones
|
||||
# as a batch (concurrently when parallel_tool_calls is set).
|
||||
_parsed_calls = []
|
||||
@@ -6247,6 +6266,22 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
reason = _cancel_reason()
|
||||
logging.debug(f"Async LLM tool loop cancelled before tool dispatch: {reason}")
|
||||
return _prepare_return_value(f"Task interrupted: {reason}")
|
||||
# Apply the cap BEFORE recording the assistant turn so every
|
||||
# declared tool_call_id gets exactly one tool reply.
|
||||
# Guardrail: Check tool call limit to prevent infinite loops
|
||||
# Allow execution if we haven't reached the limit yet, but limit the batch size
|
||||
if tool_call_count >= max_tool_calls_per_turn:
|
||||
logging.warning(f"Tool call limit reached ({max_tool_calls_per_turn}). Stopping to prevent infinite loop.")
|
||||
self._last_stop_reason = "max_steps"
|
||||
if not (final_response_text and final_response_text.strip()):
|
||||
final_response_text = f"Tool call limit reached ({max_tool_calls_per_turn} calls). Task may be too complex or there may be a broken tool causing repeated calls."
|
||||
break
|
||||
elif tool_call_count + len(tool_calls) > max_tool_calls_per_turn:
|
||||
# Limit the batch to stay within the total limit
|
||||
remaining_calls = max_tool_calls_per_turn - tool_call_count
|
||||
tool_calls = tool_calls[:remaining_calls]
|
||||
logging.warning(f"Limiting batch to {remaining_calls} tool calls to stay within limit of {max_tool_calls_per_turn}.")
|
||||
|
||||
# Convert tool_calls to a serializable format for all providers
|
||||
serializable_tool_calls = self._serialize_tool_calls(tool_calls)
|
||||
# Check if it's Ollama provider
|
||||
@@ -6264,20 +6299,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
"tool_calls": serializable_tool_calls
|
||||
})
|
||||
|
||||
# Guardrail: Check tool call limit to prevent infinite loops
|
||||
# Allow execution if we haven't reached the limit yet, but limit the batch size
|
||||
if tool_call_count >= max_tool_calls_per_turn:
|
||||
logging.warning(f"Tool call limit reached ({max_tool_calls_per_turn}). Stopping to prevent infinite loop.")
|
||||
self._last_stop_reason = "max_steps"
|
||||
if not (final_response_text and final_response_text.strip()):
|
||||
final_response_text = f"Tool call limit reached ({max_tool_calls_per_turn} calls). Task may be too complex or there may be a broken tool causing repeated calls."
|
||||
break
|
||||
elif tool_call_count + len(tool_calls) > max_tool_calls_per_turn:
|
||||
# Limit the batch to stay within the total limit
|
||||
remaining_calls = max_tool_calls_per_turn - tool_call_count
|
||||
tool_calls = tool_calls[:remaining_calls]
|
||||
logging.warning(f"Limiting batch to {remaining_calls} tool calls to stay within limit of {max_tool_calls_per_turn}.")
|
||||
|
||||
tool_results = [] # Store current iteration tool results
|
||||
# Collect media-bearing follow-up messages so they are
|
||||
# flushed only AFTER every tool reply for this assistant
|
||||
@@ -7825,11 +7846,24 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
'task_name', 'task_description', 'task_id',
|
||||
'execute_tool_fn', 'stream_callback', 'emit_events',
|
||||
'console', 'reasoning_steps',
|
||||
'reasoning_effort', 'thinking_budget', # translated below
|
||||
}
|
||||
for k, v in kwargs.items():
|
||||
if k not in _internal and v is not None:
|
||||
params[k] = v
|
||||
|
||||
# ── Reasoning effort ────────────────────────────────────────────
|
||||
# Translate the unified level (Issue #4452) like
|
||||
# _build_completion_params does, but in the Responses API shape
|
||||
# ``reasoning: {"effort": ...}``. A per-call value wins over the
|
||||
# instance setting; an explicit ``reasoning`` object is kept as is.
|
||||
effort = kwargs.get('reasoning_effort', getattr(self, 'reasoning_effort', None))
|
||||
if effort is not None and 'reasoning' not in params:
|
||||
from ..thinking.effort import resolve_reasoning_params
|
||||
native = resolve_reasoning_params(effort, params['model'])
|
||||
if 'reasoning_effort' in native:
|
||||
params['reasoning'] = {'effort': native['reasoning_effort']}
|
||||
|
||||
# Match Chat Completions after merging overrides: reasoning models
|
||||
# must not regain unsupported sampling parameters through either path.
|
||||
from .model_capabilities import is_reasoning_model
|
||||
|
||||
@@ -377,6 +377,15 @@ def process_stream_chunks(chunks):
|
||||
print(f"Error processing chunks: {e}")
|
||||
return None
|
||||
|
||||
def _env_timeout() -> Optional[float]:
|
||||
"""Return the OPENAI_TIMEOUT env var as seconds, or None when unset/invalid."""
|
||||
value = os.environ.get("OPENAI_TIMEOUT")
|
||||
try:
|
||||
return float(value) if value else None
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
class OpenAIClient:
|
||||
"""
|
||||
Unified OpenAI client wrapper for sync/async operations.
|
||||
@@ -411,6 +420,8 @@ class OpenAIClient:
|
||||
except (TypeError, ValueError):
|
||||
max_retries = None
|
||||
self.max_retries = max_retries
|
||||
# Per-request timeout (seconds) from OPENAI_TIMEOUT; unset keeps the SDK default (600s).
|
||||
self.timeout = _env_timeout()
|
||||
|
||||
# For local servers like LM Studio, allow minimal API key
|
||||
if self.base_url and not self.api_key:
|
||||
@@ -479,6 +490,8 @@ class OpenAIClient:
|
||||
client_kwargs = {"api_key": self.api_key, "base_url": self.base_url}
|
||||
if self.max_retries is not None:
|
||||
client_kwargs["max_retries"] = self.max_retries
|
||||
if getattr(self, "timeout", None) is not None:
|
||||
client_kwargs["timeout"] = self.timeout
|
||||
self._sync_client = OpenAI(**client_kwargs)
|
||||
return self._sync_client
|
||||
|
||||
@@ -521,6 +534,8 @@ class OpenAIClient:
|
||||
client_kwargs = {"api_key": self.api_key, "base_url": self.base_url}
|
||||
if self.max_retries is not None:
|
||||
client_kwargs["max_retries"] = self.max_retries
|
||||
if getattr(self, "timeout", None) is not None:
|
||||
client_kwargs["timeout"] = self.timeout
|
||||
# Use the plain default httpx client rather than openai's
|
||||
# internal wrapper: the wrapper's ``__del__`` schedules
|
||||
# ``aclose()`` on whichever loop is running at GC time. When a
|
||||
@@ -938,6 +953,12 @@ class OpenAIClient:
|
||||
if not is_reasoning_model(model):
|
||||
params["temperature"] = temperature
|
||||
|
||||
# Chat Completions takes ``reasoning_effort``; the Responses API takes
|
||||
# the same level as ``reasoning: {"effort": ...}``.
|
||||
reasoning_effort = kwargs.get("reasoning_effort")
|
||||
if reasoning_effort:
|
||||
params["reasoning"] = {"effort": reasoning_effort}
|
||||
|
||||
return params
|
||||
|
||||
@staticmethod
|
||||
@@ -3080,7 +3101,7 @@ def get_openai_client(api_key: Optional[str] = None, base_url: Optional[str] = N
|
||||
normalized_max_retries = int(_env_retries)
|
||||
except (TypeError, ValueError):
|
||||
normalized_max_retries = None
|
||||
current_params = (normalized_api_key, normalized_base_url, normalized_max_retries)
|
||||
current_params = (normalized_api_key, normalized_base_url, normalized_max_retries, _env_timeout())
|
||||
|
||||
# Thread-safe client creation
|
||||
with _global_client_lock:
|
||||
|
||||
@@ -14,10 +14,11 @@ and only runs when a server actually challenges for OAuth, so servers without
|
||||
auth — or with pre-supplied client info/tokens — behave exactly as before.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
import webbrowser
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from threading import Thread
|
||||
from threading import Lock, Thread
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import urlencode, urlparse, parse_qs
|
||||
|
||||
@@ -69,6 +70,38 @@ def _make_callback_server(handler: OAuthCallbackHandler, port: int) -> HTTPServe
|
||||
return HTTPServer(("127.0.0.1", port), _Handler)
|
||||
|
||||
|
||||
# One loopback callback server per process, shared by every in-flight flow.
|
||||
# The redirect URI is fixed (it is what dynamic registration whitelisted), so
|
||||
# concurrent flows must not each try to bind the port; the state-keyed
|
||||
# ``OAuthCallbackHandler`` already routes each code to the right flow.
|
||||
_callback_lock = Lock()
|
||||
_callback_server: Optional[HTTPServer] = None
|
||||
_callback_handler: Optional[OAuthCallbackHandler] = None
|
||||
_callback_refs = 0
|
||||
|
||||
|
||||
def _acquire_callback() -> OAuthCallbackHandler:
|
||||
global _callback_server, _callback_handler, _callback_refs
|
||||
with _callback_lock:
|
||||
if _callback_server is None:
|
||||
handler = OAuthCallbackHandler()
|
||||
server = _make_callback_server(handler, OAUTH_CALLBACK_PORT)
|
||||
Thread(target=server.serve_forever, daemon=True).start()
|
||||
_callback_server, _callback_handler = server, handler
|
||||
_callback_refs += 1
|
||||
return _callback_handler
|
||||
|
||||
|
||||
def _release_callback() -> None:
|
||||
global _callback_server, _callback_handler, _callback_refs
|
||||
with _callback_lock:
|
||||
_callback_refs -= 1
|
||||
if _callback_refs <= 0 and _callback_server is not None:
|
||||
_callback_server.shutdown()
|
||||
_callback_server.server_close()
|
||||
_callback_server, _callback_handler, _callback_refs = None, None, 0
|
||||
|
||||
|
||||
class MCPOAuthProvider:
|
||||
"""
|
||||
Orchestrates zero-config OAuth for a single remote MCP server.
|
||||
@@ -198,11 +231,11 @@ class MCPOAuthProvider:
|
||||
if not self.open_browser:
|
||||
raise InteractiveAuthRequired(
|
||||
f"Interactive OAuth is required to authenticate with "
|
||||
f"{self.server_url}. Run this from an interactive session, or "
|
||||
f"{self.server_url}. Run `praisonai mcp auth {self.mcp_name}` "
|
||||
f"from a terminal on this host (works headless/over SSH), or "
|
||||
f"pre-supply tokens, to complete authentication."
|
||||
)
|
||||
|
||||
callback = OAuthCallbackHandler()
|
||||
state = generate_state()
|
||||
code_verifier = generate_code_verifier()
|
||||
code_challenge = generate_code_challenge(code_verifier)
|
||||
@@ -222,15 +255,30 @@ class MCPOAuthProvider:
|
||||
|
||||
auth_url = f"{metadata['authorization_endpoint']}?{urlencode(params)}"
|
||||
|
||||
server = _make_callback_server(callback, OAUTH_CALLBACK_PORT)
|
||||
thread = Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
callback = _acquire_callback()
|
||||
try:
|
||||
logger.debug(f"Opening browser for OAuth authorization: {auth_url}")
|
||||
webbrowser.open(auth_url)
|
||||
try:
|
||||
opened = webbrowser.open(auth_url)
|
||||
except Exception: # noqa: BLE001 - no usable browser
|
||||
opened = False
|
||||
if not opened:
|
||||
# Headless/remote host: let the operator consent on any device.
|
||||
# No stdin reading: a library must not consume the host's input.
|
||||
print(
|
||||
f"Open this URL on any device to authorise "
|
||||
f"{self.mcp_name}:\n {auth_url}\n"
|
||||
f"After consenting, the browser is redirected to "
|
||||
f"{get_redirect_url()}. On another device either forward "
|
||||
f"that port (ssh -L {OAUTH_CALLBACK_PORT}:127.0.0.1:"
|
||||
f"{OAUTH_CALLBACK_PORT} <host>) or copy the redirect URL "
|
||||
f"and run `curl '<redirect URL>'` on this host.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
code = callback.wait_for_callback(state, timeout=300.0)
|
||||
finally:
|
||||
server.shutdown()
|
||||
callback.clear_state(state)
|
||||
_release_callback()
|
||||
|
||||
tokens = self._exchange_code(
|
||||
metadata, client_info, code, code_verifier, timeout=timeout
|
||||
|
||||
@@ -572,6 +572,14 @@ class FileMemory:
|
||||
promoted.append(item.id)
|
||||
|
||||
if promoted:
|
||||
# Promotion writes the same store as add_long_term, so retain the
|
||||
# same highest-importance, newest-first items within its capacity.
|
||||
limit = self.config["long_term_limit"]
|
||||
if len(self._long_term) > limit:
|
||||
self._long_term.sort(
|
||||
key=lambda item: (item.importance, item.created_at), reverse=True
|
||||
)
|
||||
self._long_term = self._long_term[:limit]
|
||||
self._save_long_term()
|
||||
self._log(f"Auto-promoted {len(promoted)} items to long-term memory")
|
||||
|
||||
|
||||
@@ -281,18 +281,25 @@ class BaseStore(ABC):
|
||||
if not entries:
|
||||
return True
|
||||
try:
|
||||
import json
|
||||
from ...storage.base import FileLock
|
||||
from ...utils.atomic_io import atomic_write_json, quarantine_corrupt_file
|
||||
|
||||
archive_path = self._archive_path()
|
||||
existing: List[Dict[str, Any]] = []
|
||||
if archive_path.exists():
|
||||
with open(archive_path, "r", encoding="utf-8") as f:
|
||||
existing = json.load(f)
|
||||
if not isinstance(existing, list):
|
||||
existing = []
|
||||
existing.extend(e.to_dict() for e in entries)
|
||||
archive_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(archive_path, "w", encoding="utf-8") as f:
|
||||
json.dump(existing, f, ensure_ascii=False, indent=2)
|
||||
with FileLock(archive_path, timeout=10):
|
||||
existing: Any = []
|
||||
if archive_path.exists():
|
||||
try:
|
||||
with open(archive_path, "r", encoding="utf-8") as f:
|
||||
existing = json.load(f)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
existing = None
|
||||
if not isinstance(existing, list):
|
||||
# Keep the bad bytes as a sidecar and keep evicting.
|
||||
quarantine_corrupt_file(str(archive_path))
|
||||
existing = []
|
||||
existing.extend(e.to_dict() for e in entries)
|
||||
atomic_write_json(str(archive_path), existing, ensure_ascii=False, indent=2)
|
||||
return True
|
||||
except Exception as e:
|
||||
import logging
|
||||
|
||||
@@ -104,7 +104,12 @@ class PlanStorage:
|
||||
if filename is None:
|
||||
filename = self._get_plan_filename(plan)
|
||||
|
||||
path = os.path.join(self.plans_dir, filename)
|
||||
from ..utils.atomic_io import safe_join
|
||||
# Keep ".md" as the suffix so listing/lookup still find the file.
|
||||
stem, ext = os.path.splitext(filename)
|
||||
if ext.lower() != ".md":
|
||||
stem, ext = filename, ".md"
|
||||
path = safe_join(self.plans_dir, stem, ext)
|
||||
|
||||
# Convert plan to markdown
|
||||
markdown = plan.to_markdown()
|
||||
@@ -215,6 +220,29 @@ class PlanStorage:
|
||||
|
||||
return False
|
||||
|
||||
def _todo_path(self, name: str) -> str:
|
||||
"""Confined path for a todo name, reusing a pre-existing legacy file.
|
||||
|
||||
Legacy files (``<name>.json`` written before Issue #6238) are reused
|
||||
only when ``name`` is a plain filename, so upgrades keep working
|
||||
without ever allowing traversal out of ``todos_dir``.
|
||||
"""
|
||||
from ..utils.atomic_io import safe_join
|
||||
path = safe_join(self.todos_dir, name, ".json")
|
||||
name = str(name)
|
||||
if (
|
||||
not os.path.exists(path)
|
||||
and name not in ("", ".", "..")
|
||||
and "\0" not in name
|
||||
and "%" not in name
|
||||
and os.path.basename(name) == name
|
||||
and not (os.altsep and os.altsep in name)
|
||||
):
|
||||
legacy = os.path.join(self.todos_dir, f"{name}.json")
|
||||
if os.path.isfile(legacy):
|
||||
return legacy
|
||||
return path
|
||||
|
||||
def save_todo(self, todo: TodoList, name: str = "current") -> str:
|
||||
"""
|
||||
Save a todo list to storage.
|
||||
@@ -226,11 +254,10 @@ class PlanStorage:
|
||||
Returns:
|
||||
Path to the saved file
|
||||
"""
|
||||
filename = f"{name}.json"
|
||||
path = os.path.join(self.todos_dir, filename)
|
||||
from ..utils.atomic_io import atomic_write_text
|
||||
path = self._todo_path(name)
|
||||
|
||||
# Atomic write so a crash mid-write never truncates the previous todos.
|
||||
from ..utils.atomic_io import atomic_write_text
|
||||
atomic_write_text(path, todo.to_json())
|
||||
|
||||
logger.debug(f"Saved todo list to {path}")
|
||||
@@ -246,8 +273,7 @@ class PlanStorage:
|
||||
Returns:
|
||||
TodoList instance, or None if not found
|
||||
"""
|
||||
filename = f"{name}.json"
|
||||
path = os.path.join(self.todos_dir, filename)
|
||||
path = self._todo_path(name)
|
||||
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
@@ -265,12 +291,13 @@ class PlanStorage:
|
||||
Returns:
|
||||
List of todo list names
|
||||
"""
|
||||
from urllib.parse import unquote
|
||||
todos = []
|
||||
|
||||
for filename in os.listdir(self.todos_dir):
|
||||
if filename.endswith(".json"):
|
||||
name = filename[:-5] # Remove .json extension
|
||||
todos.append(name)
|
||||
# Decode so listed names round-trip through load/delete_todo.
|
||||
todos.append(unquote(filename[:-5]))
|
||||
|
||||
return todos
|
||||
|
||||
@@ -284,8 +311,7 @@ class PlanStorage:
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
filename = f"{name}.json"
|
||||
path = os.path.join(self.todos_dir, filename)
|
||||
path = self._todo_path(name)
|
||||
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
|
||||
@@ -430,20 +430,18 @@ class RAG:
|
||||
Currently wraps sync version. Can be extended for true async.
|
||||
"""
|
||||
import asyncio
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.retrieve(
|
||||
query=query,
|
||||
top_k=top_k,
|
||||
hybrid=hybrid,
|
||||
rerank=rerank,
|
||||
filters=filters,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
run_id=run_id,
|
||||
**kwargs,
|
||||
),
|
||||
# asyncio.to_thread copies contextvars into the worker thread
|
||||
return await asyncio.to_thread(
|
||||
self.retrieve,
|
||||
query=query,
|
||||
top_k=top_k,
|
||||
hybrid=hybrid,
|
||||
rerank=rerank,
|
||||
filters=filters,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
run_id=run_id,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def query(
|
||||
@@ -523,19 +521,15 @@ class RAG:
|
||||
|
||||
Currently wraps sync version. Can be extended for true async.
|
||||
"""
|
||||
# For now, wrap sync version
|
||||
# TODO: Implement true async when Knowledge supports it
|
||||
import asyncio
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.query(
|
||||
question=question,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
run_id=run_id,
|
||||
**kwargs,
|
||||
),
|
||||
# asyncio.to_thread copies contextvars into the worker thread
|
||||
return await asyncio.to_thread(
|
||||
self.query,
|
||||
question=question,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
run_id=run_id,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def stream(
|
||||
@@ -580,6 +574,10 @@ class RAG:
|
||||
prompt = self._build_prompt(question, context)
|
||||
|
||||
# 5. Stream generation
|
||||
yield from self._stream_generate(prompt, **kwargs)
|
||||
|
||||
def _stream_generate(self, prompt: str, **kwargs) -> Iterator[str]:
|
||||
"""Synchronously stream the LLM answer for an already-built prompt."""
|
||||
if self.llm is None:
|
||||
yield "LLM not available."
|
||||
return
|
||||
@@ -615,10 +613,15 @@ class RAG:
|
||||
"""
|
||||
Async streaming RAG response.
|
||||
|
||||
Yields tokens as they are generated.
|
||||
Yields tokens as they are generated. Blocking retrieval, reranking
|
||||
and sync LLM streaming run in worker threads so the event loop
|
||||
stays responsive.
|
||||
"""
|
||||
# 1. Retrieve
|
||||
results = self._retrieve(
|
||||
import asyncio
|
||||
|
||||
# 1. Retrieve (embedding + vector search are blocking)
|
||||
results = await asyncio.to_thread(
|
||||
self._retrieve,
|
||||
query=question,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
@@ -627,7 +630,7 @@ class RAG:
|
||||
)
|
||||
|
||||
# 2. Rerank
|
||||
results = self._rerank(question, results)
|
||||
results = await asyncio.to_thread(self._rerank, question, results)
|
||||
|
||||
# 3. Build context
|
||||
context, used_results = self._build_context(results)
|
||||
@@ -651,8 +654,14 @@ class RAG:
|
||||
async for chunk in self.llm.astream(messages=messages, **kwargs):
|
||||
yield chunk
|
||||
else:
|
||||
# Fallback to sync streaming wrapped
|
||||
for chunk in self.stream(question, user_id, agent_id, run_id, **kwargs):
|
||||
# Fallback: pump the sync generator from a worker thread
|
||||
# (reuses the context already retrieved above)
|
||||
it = self._stream_generate(prompt, **kwargs)
|
||||
sentinel = object()
|
||||
while True:
|
||||
chunk = await asyncio.to_thread(next, it, sentinel)
|
||||
if chunk is sentinel:
|
||||
break
|
||||
yield chunk
|
||||
except Exception as e:
|
||||
logger.error(f"Async streaming failed: {e}")
|
||||
|
||||
@@ -39,6 +39,7 @@ if TYPE_CHECKING:
|
||||
from .sqlite_transcript_store import SqliteTranscriptStore
|
||||
from .protocols import SessionStoreProtocol, SessionMirrorProtocol
|
||||
from .hierarchy import HierarchicalSessionStore, SessionSnapshot, ExtendedSessionData
|
||||
from .encrypted_store import EncryptedSessionStore, SessionEncryptionError, generate_session_key
|
||||
|
||||
# Lazy loading for zero import overhead
|
||||
_module_cache = {}
|
||||
@@ -191,6 +192,12 @@ def __getattr__(name: str):
|
||||
_module_cache[name] = workspace_id
|
||||
return workspace_id
|
||||
|
||||
# Session manifest: plane refs + run-in-flight marker (Issue #6248)
|
||||
if name in ("FileSessionManifest", "ManifestEntry", "SessionManifestProtocol"):
|
||||
from . import manifest as _manifest
|
||||
_module_cache[name] = getattr(_manifest, name)
|
||||
return _module_cache[name]
|
||||
|
||||
# Cross-context continuation prompt assembler (session handoff)
|
||||
if name == "build_handoff_prompt":
|
||||
from .handoff import build_handoff_prompt
|
||||
@@ -207,6 +214,12 @@ def __getattr__(name: str):
|
||||
_module_cache[name] = wrap_inter_agent
|
||||
return wrap_inter_agent
|
||||
|
||||
if name in ("EncryptedSessionStore", "SessionEncryptionError", "generate_session_key"):
|
||||
from . import encrypted_store
|
||||
value = getattr(encrypted_store, name)
|
||||
_module_cache[name] = value
|
||||
return value
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
@@ -242,6 +255,9 @@ __all__ = [
|
||||
"clear_session_context",
|
||||
"workspace_id",
|
||||
"build_handoff_prompt",
|
||||
"FileSessionManifest",
|
||||
"ManifestEntry",
|
||||
"SessionManifestProtocol",
|
||||
"MessageOrigin",
|
||||
"wrap_inter_agent",
|
||||
# Encryption at rest
|
||||
@@ -249,9 +265,3 @@ __all__ = [
|
||||
"SessionEncryptionError",
|
||||
"generate_session_key",
|
||||
]
|
||||
|
||||
from .encrypted_store import ( # noqa: E402
|
||||
EncryptedSessionStore,
|
||||
SessionEncryptionError,
|
||||
generate_session_key,
|
||||
)
|
||||
@@ -0,0 +1,220 @@
|
||||
"""
|
||||
Session manifest: one durable record per ``session_id`` holding a write-ahead
|
||||
"run in flight" marker (Issue #6248), so a later launch can tell that a run on
|
||||
this session died before reaching a terminal outcome.
|
||||
|
||||
Usage::
|
||||
|
||||
from praisonaiagents.session.manifest import FileSessionManifest
|
||||
|
||||
manifest = FileSessionManifest()
|
||||
entry = manifest.begin_run("my-session", "run-42", run_input="fix the bug")
|
||||
... # run the agent
|
||||
manifest.end_run("my-session", "completed", run_id="run-42")
|
||||
|
||||
# Next launch:
|
||||
stale = manifest.interrupted("my-session") # entry or None
|
||||
|
||||
The run input itself is never written to disk: only a SHA-256 digest of it
|
||||
is kept (``run_digest``), enough to recognise a retry of the same task without
|
||||
storing prompts (which may contain secrets) in plaintext.
|
||||
|
||||
A run that keeps dying is bounded by ``max_attempts``: once the budget is
|
||||
spent, ``begin_run`` logs the stale run as abandoned and restarts the attempt
|
||||
counter instead of counting it up forever.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterator, Optional, Protocol, runtime_checkable
|
||||
|
||||
from .store import FileLock, _safe_session_filename
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_MAX_ATTEMPTS = 3
|
||||
|
||||
|
||||
@dataclass
|
||||
class ManifestEntry:
|
||||
"""A session's last run state."""
|
||||
|
||||
session_id: str
|
||||
run_id: Optional[str] = None
|
||||
run_status: Optional[str] = None
|
||||
run_in_flight: bool = False
|
||||
run_digest: Optional[str] = None
|
||||
attempt: int = 0
|
||||
updated_at: float = 0.0
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "ManifestEntry":
|
||||
return cls(**{k: data[k] for k in cls.__dataclass_fields__ if k in data})
|
||||
|
||||
|
||||
def _digest(run_input: Optional[str]) -> Optional[str]:
|
||||
if run_input is None:
|
||||
return None
|
||||
return hashlib.sha256(run_input.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SessionManifestProtocol(Protocol):
|
||||
"""Per-session run-in-flight marker."""
|
||||
|
||||
def get(self, session_id: str) -> Optional[ManifestEntry]: ...
|
||||
|
||||
def begin_run(
|
||||
self,
|
||||
session_id: str,
|
||||
run_id: str,
|
||||
run_input: Optional[str] = None,
|
||||
max_attempts: int = DEFAULT_MAX_ATTEMPTS,
|
||||
) -> ManifestEntry: ...
|
||||
|
||||
def end_run(
|
||||
self, session_id: str, status: str, run_id: Optional[str] = None
|
||||
) -> Optional[ManifestEntry]: ...
|
||||
|
||||
def interrupted(self, session_id: str) -> Optional[ManifestEntry]: ...
|
||||
|
||||
|
||||
class FileSessionManifest:
|
||||
"""Default JSON-file manifest: ``<dir>/<session_id>.json``, atomic writes."""
|
||||
|
||||
def __init__(self, directory: Optional[str] = None):
|
||||
"""``directory`` defaults to ``<sessions>/manifests``; callers whose
|
||||
session store is scoped (e.g. per project) should pass the matching
|
||||
directory so equal ids in different scopes never share a manifest."""
|
||||
if directory is None:
|
||||
from ..paths import get_sessions_dir
|
||||
|
||||
directory = str(get_sessions_dir() / "manifests")
|
||||
self._dir = Path(directory)
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def _path(self, session_id: str) -> Path:
|
||||
if not session_id:
|
||||
raise ValueError("session_id is required")
|
||||
# Same injective naming as the conversation store: ids that differ
|
||||
# only in ``:``/``.``/``/`` never share a file, and none escape ``_dir``.
|
||||
return self._dir / f"{_safe_session_filename(session_id)}.json"
|
||||
|
||||
@contextmanager
|
||||
def _locked(self, session_id: str) -> Iterator[None]:
|
||||
"""Serialise read-modify-write across threads *and* processes."""
|
||||
with self._lock, FileLock(str(self._path(session_id))):
|
||||
yield
|
||||
|
||||
def _read(self, session_id: str) -> Optional[ManifestEntry]:
|
||||
path = self._path(session_id)
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except (OSError, ValueError) as exc:
|
||||
logger.warning("Unreadable session manifest %s: %s", path, exc)
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
data["session_id"] = session_id
|
||||
return ManifestEntry.from_dict(data)
|
||||
|
||||
def _write(self, entry: ManifestEntry) -> ManifestEntry:
|
||||
entry.updated_at = time.time()
|
||||
path = self._path(entry.session_id)
|
||||
self._dir.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=str(self._dir), suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
json.dump(asdict(entry), fh, default=str)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
return entry
|
||||
|
||||
def get(self, session_id: str) -> Optional[ManifestEntry]:
|
||||
with self._lock:
|
||||
return self._read(session_id)
|
||||
|
||||
def begin_run(
|
||||
self,
|
||||
session_id: str,
|
||||
run_id: str,
|
||||
run_input: Optional[str] = None,
|
||||
max_attempts: int = DEFAULT_MAX_ATTEMPTS,
|
||||
) -> ManifestEntry:
|
||||
"""Commit the write-ahead in-flight marker for a run on this session.
|
||||
|
||||
Only a digest of ``run_input`` is persisted, never the text itself.
|
||||
If the previous run never released its marker it was interrupted; a new
|
||||
run with the same ``run_input`` counts as another attempt of it. Once
|
||||
``max_attempts`` is exceeded the stale run is abandoned and the counter
|
||||
restarts, so a run that keeps dying is not counted up forever.
|
||||
"""
|
||||
with self._locked(session_id):
|
||||
entry = self._read(session_id) or ManifestEntry(session_id=session_id)
|
||||
digest = _digest(run_input)
|
||||
if entry.run_in_flight and entry.run_digest == digest:
|
||||
attempt = entry.attempt + 1
|
||||
if attempt > max_attempts:
|
||||
logger.warning(
|
||||
"Session %s: run %s abandoned after %d attempts",
|
||||
session_id, entry.run_id, entry.attempt,
|
||||
)
|
||||
attempt = 1
|
||||
else:
|
||||
attempt = 1
|
||||
entry.run_id = run_id
|
||||
entry.run_digest = digest
|
||||
entry.run_status = "running"
|
||||
entry.run_in_flight = True
|
||||
entry.attempt = attempt
|
||||
return self._write(entry)
|
||||
|
||||
def end_run(
|
||||
self, session_id: str, status: str, run_id: Optional[str] = None
|
||||
) -> Optional[ManifestEntry]:
|
||||
"""Release the in-flight marker with a terminal ``status``.
|
||||
|
||||
With ``run_id``, only that run's own marker is released: if another
|
||||
run on the same session has since taken the marker, it is left alone
|
||||
(returns ``None``) so that run's later crash is still reported.
|
||||
"""
|
||||
with self._locked(session_id):
|
||||
entry = self._read(session_id)
|
||||
if entry is None or (run_id is not None and entry.run_id != run_id):
|
||||
return None
|
||||
entry.run_in_flight = False
|
||||
entry.run_status = status
|
||||
return self._write(entry)
|
||||
|
||||
def interrupted(self, session_id: str) -> Optional[ManifestEntry]:
|
||||
"""Return the entry if its last run never reached a terminal outcome."""
|
||||
entry = self.get(session_id)
|
||||
return entry if entry is not None and entry.run_in_flight else None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_MAX_ATTEMPTS",
|
||||
"FileSessionManifest",
|
||||
"ManifestEntry",
|
||||
"SessionManifestProtocol",
|
||||
]
|
||||
@@ -474,6 +474,7 @@ class SqliteTranscriptStore(DefaultSessionStore):
|
||||
"model": data.get("model") or data.get("llm") or meta.get("model"),
|
||||
"total_tokens": data.get("total_tokens") or data.get("token_count") or meta.get("total_tokens"),
|
||||
"cost": data.get("cost") or meta.get("cost"),
|
||||
"tool_stats": meta.get("tool_stats"),
|
||||
})
|
||||
if len(sessions) >= limit:
|
||||
break
|
||||
|
||||
@@ -2076,6 +2076,8 @@ class DefaultSessionStore:
|
||||
"model": data.get("model") or data.get("llm") or (data.get("metadata") or {}).get("model"),
|
||||
"total_tokens": data.get("total_tokens") or data.get("token_count") or (data.get("metadata") or {}).get("total_tokens"),
|
||||
"cost": data.get("cost") or (data.get("metadata") or {}).get("cost"),
|
||||
# Per-tool outcome tally for `usage --by tool` (Issue #6158).
|
||||
"tool_stats": (data.get("metadata") or {}).get("tool_stats"),
|
||||
})
|
||||
except (UnicodeDecodeError, json.JSONDecodeError, IOError) as exc:
|
||||
self._report_unreadable_session(filename[:-5], filepath, exc)
|
||||
|
||||
@@ -73,28 +73,15 @@ def generate_title(
|
||||
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,
|
||||
primary_model=primary_model,
|
||||
))
|
||||
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
|
||||
# Works with or without a running event loop (Issue #6238).
|
||||
from ..utils.async_bridge import run_coroutine_from_any_context
|
||||
return run_coroutine_from_any_context(generate_title_async(
|
||||
user_msg, assistant_msg, llm_model, timeout, max_length,
|
||||
primary_model=primary_model,
|
||||
), timeout=None)
|
||||
except Exception:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(
|
||||
"generate_title() called from running event loop; "
|
||||
"use generate_title_async() instead. Returning fallback title."
|
||||
)
|
||||
logging.getLogger(__name__).debug("Title generation failed", exc_info=True)
|
||||
return fallback_title
|
||||
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ logger = get_logger(__name__)
|
||||
# Default snapshot directory (uses centralized paths - DRY)
|
||||
DEFAULT_SNAPSHOT_DIR = str(get_snapshots_dir())
|
||||
|
||||
# Bound every git call and never block on an interactive prompt.
|
||||
_GIT_TIMEOUT = 60
|
||||
|
||||
@dataclass
|
||||
class FileDiff:
|
||||
"""Represents a diff for a single file."""
|
||||
@@ -158,8 +161,13 @@ class FileSnapshot:
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=check,
|
||||
timeout=_GIT_TIMEOUT,
|
||||
env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
|
||||
)
|
||||
return result
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("Git command timed out after %ss: %s", _GIT_TIMEOUT, " ".join(cmd))
|
||||
raise
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"Git command failed: {' '.join(cmd)}")
|
||||
logger.error(f"stderr: {e.stderr}")
|
||||
|
||||
@@ -645,9 +645,8 @@ def crawl4ai_sync(
|
||||
Returns:
|
||||
Dict with crawl results
|
||||
"""
|
||||
return asyncio.run(
|
||||
crawl4ai(url, css_selector, js_code, wait_for)
|
||||
)
|
||||
from ..utils.async_bridge import run_coroutine_from_any_context
|
||||
return run_coroutine_from_any_context(crawl4ai(url, css_selector, js_code, wait_for), timeout=None)
|
||||
|
||||
def crawl4ai_extract_sync(
|
||||
url: str,
|
||||
@@ -664,9 +663,8 @@ def crawl4ai_extract_sync(
|
||||
Returns:
|
||||
Dict with extracted data
|
||||
"""
|
||||
return asyncio.run(
|
||||
crawl4ai_extract(url, schema, js_code)
|
||||
)
|
||||
from ..utils.async_bridge import run_coroutine_from_any_context
|
||||
return run_coroutine_from_any_context(crawl4ai_extract(url, schema, js_code), timeout=None)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example usage
|
||||
|
||||
@@ -18,6 +18,13 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Minimum similarity ratio for an accepted block-anchor (fuzzy) match.
|
||||
_BLOCK_ANCHOR_THRESHOLD = 0.7
|
||||
# Minimum similarity for a "closest match" hint on the no-match error path.
|
||||
_CLOSEST_HINT_THRESHOLD = 0.6
|
||||
# Bounds for the hint search/output: skip long targets, cap total comparison
|
||||
# work (sum of len(window) * len(target)), and truncate long displayed lines.
|
||||
_CLOSEST_HINT_MAX_CHARS = 2000
|
||||
_CLOSEST_HINT_BUDGET = 2_000_000
|
||||
_CLOSEST_HINT_LINE_CHARS = 200
|
||||
|
||||
# Maximum characters of diagnostics output appended to a tool result.
|
||||
_DIAGNOSTICS_MAX_CHARS = 2000
|
||||
@@ -800,6 +807,69 @@ class EditTools:
|
||||
return spans
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _closest_region(content: str, old: str, max_lines: int = 20000):
|
||||
"""Find the block of ``content`` most similar to ``old``.
|
||||
|
||||
Used only on the no-match path. Returns ``(start_index, n_lines)`` or
|
||||
``None`` when nothing is similar enough or the search would be too
|
||||
expensive (bounded by input size and a total comparison budget).
|
||||
"""
|
||||
if len(old) > _CLOSEST_HINT_MAX_CHARS:
|
||||
return None
|
||||
old_lines = [line.strip() for line in old.splitlines()]
|
||||
lines = content.splitlines()
|
||||
n = len(old_lines)
|
||||
if not n or not lines or len(lines) > max_lines:
|
||||
return None
|
||||
n = min(n, len(lines))
|
||||
stripped = [line.strip() for line in lines]
|
||||
target = '\n'.join(old_lines)
|
||||
matcher = difflib.SequenceMatcher(autojunk=False)
|
||||
matcher.set_seq2(target)
|
||||
budget = _CLOSEST_HINT_BUDGET
|
||||
best_ratio, best_i = _CLOSEST_HINT_THRESHOLD, None
|
||||
for i in range(len(lines) - n + 1):
|
||||
window = '\n'.join(stripped[i:i + n])
|
||||
matcher.set_seq1(window)
|
||||
if matcher.real_quick_ratio() <= best_ratio:
|
||||
continue
|
||||
budget -= len(window) # quick_ratio() is a pure-Python pass over window
|
||||
if budget < 0:
|
||||
break
|
||||
if matcher.quick_ratio() <= best_ratio:
|
||||
continue
|
||||
budget -= len(window) * len(target)
|
||||
if budget < 0:
|
||||
break # optional hint: keep the best found so far
|
||||
ratio = matcher.ratio()
|
||||
if ratio > best_ratio:
|
||||
best_ratio, best_i = ratio, i
|
||||
return None if best_i is None else (best_i, n)
|
||||
|
||||
@staticmethod
|
||||
def _format_region(lines, start: int, n: int) -> str:
|
||||
"""Render up to 10 lines from ``start`` with line numbers, each truncated."""
|
||||
shown = lines[start:start + min(n, 10)]
|
||||
width = _CLOSEST_HINT_LINE_CHARS
|
||||
body = '\n'.join(
|
||||
f"{start + k + 1:>5}| "
|
||||
+ (line if len(line) <= width else line[:width] + "... [truncated]")
|
||||
for k, line in enumerate(shown))
|
||||
if n > len(shown):
|
||||
body += "\n ..."
|
||||
return f"lines {start + 1}-{start + n}:\n{body}"
|
||||
|
||||
def _not_found_error(self, prefix: str, content: str, old: str, region) -> str:
|
||||
"""Build the no-match error, adding ``region`` (from _closest_region) if any."""
|
||||
preview = old[:50] + ("..." if len(old) > 50 else "")
|
||||
if not region:
|
||||
return f"{prefix}: '{preview}'"
|
||||
hint = self._format_region(content.splitlines(), *region)
|
||||
return (f"{prefix}: '{preview}'. Closest match in the file, {hint}\n"
|
||||
f"Adjust old_string to match these lines exactly, "
|
||||
f"or re-read the file if it changed.")
|
||||
|
||||
@require_approval(risk_level="high")
|
||||
def edit_file(self, filepath: str, old_string: str, new_string: str,
|
||||
replace_all: bool = False, expected_hash: Optional[str] = None,
|
||||
@@ -885,8 +955,13 @@ class EditTools:
|
||||
spans = self._find_spans(content, old_string)
|
||||
|
||||
if not spans:
|
||||
preview = old_string[:50] + ("..." if len(old_string) > 50 else "")
|
||||
return f"Error: String not found in file: '{preview}'"
|
||||
# No "already applied" shortcut: similarity cannot prove
|
||||
# *where* a prior edit landed (ties pick the wrong site),
|
||||
# and a false success silently drops an edit. The hint
|
||||
# shows the closest lines, so a retried edit is visible.
|
||||
return self._not_found_error(
|
||||
"Error: String not found in file", content, old_string,
|
||||
self._closest_region(content, old_string))
|
||||
|
||||
occurrences = len(spans)
|
||||
|
||||
@@ -1180,8 +1255,9 @@ class EditTools:
|
||||
return f"Error: Empty hunk in move for '{op['src']}'"
|
||||
spans = self._find_spans(updated, old_block)
|
||||
if not spans:
|
||||
preview = old_block[:50] + ("..." if len(old_block) > 50 else "")
|
||||
return (f"Error: Hunk not found in '{op['src']}': '{preview}'")
|
||||
return self._not_found_error(
|
||||
f"Error: Hunk not found in '{op['src']}'", updated, old_block,
|
||||
self._closest_region(updated, old_block))
|
||||
if len(spans) > 1:
|
||||
preview = old_block[:30] + ("..." if len(old_block) > 30 else "")
|
||||
return (f"Error: Ambiguous hunk in '{op['src']}': '{preview}' "
|
||||
@@ -1264,8 +1340,9 @@ class EditTools:
|
||||
return f"Error: Empty hunk in update for '{op['path']}'"
|
||||
spans = self._find_spans(updated, old_block)
|
||||
if not spans:
|
||||
preview = old_block[:50] + ("..." if len(old_block) > 50 else "")
|
||||
return (f"Error: Hunk not found in '{op['path']}': '{preview}'")
|
||||
return self._not_found_error(
|
||||
f"Error: Hunk not found in '{op['path']}'", updated, old_block,
|
||||
self._closest_region(updated, old_block))
|
||||
if len(spans) > 1:
|
||||
preview = old_block[:30] + ("..." if len(old_block) > 30 else "")
|
||||
return (f"Error: Ambiguous hunk in '{op['path']}': '{preview}' "
|
||||
|
||||
@@ -7,6 +7,15 @@ from datetime import datetime
|
||||
from typing import Dict, List, Optional, Any
|
||||
from .decorator import tool
|
||||
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
"praisonaiagents.tools.jira_tools is deprecated and will be removed in a future release; "
|
||||
"use praisonai_tools.tools.jira_watch_tool instead (pip install praisonai-tools)",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def _get_jira_connection(
|
||||
|
||||
@@ -126,11 +126,31 @@ def _matches_glob(fname: str, rel: str, glob: Optional[str]) -> bool:
|
||||
if not glob:
|
||||
return True
|
||||
posix = rel.replace(os.sep, "/")
|
||||
return (
|
||||
if (
|
||||
fnmatch.fnmatch(fname, glob)
|
||||
or fnmatch.fnmatch(rel, glob)
|
||||
or fnmatch.fnmatch(posix, glob)
|
||||
)
|
||||
):
|
||||
return True
|
||||
|
||||
# fnmatch treats "**/" as requiring a slash. Recursive glob segments
|
||||
# also match zero directories, as they do on the ripgrep path.
|
||||
segments = glob.split("/")
|
||||
if "**" not in segments:
|
||||
return False
|
||||
parts = posix.split("/")
|
||||
positions = {0}
|
||||
for segment in segments:
|
||||
if not positions:
|
||||
return False
|
||||
if segment == "**":
|
||||
positions = set(range(min(positions), len(parts) + 1))
|
||||
else:
|
||||
positions = {
|
||||
i + 1 for i in positions
|
||||
if i < len(parts) and fnmatch.fnmatch(parts[i], segment)
|
||||
}
|
||||
return len(parts) in positions
|
||||
|
||||
|
||||
def _iter_files(root: str, glob: Optional[str], ignore_matcher):
|
||||
|
||||
@@ -26,6 +26,15 @@ import logging
|
||||
import os
|
||||
from importlib import util
|
||||
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
"praisonaiagents.tools.youdotcom_tools is deprecated and will be removed in a future release; "
|
||||
"use praisonai_tools.tools.youdotcom_tool instead (pip install praisonai-tools)",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
|
||||
def _check_youdotcom_available() -> tuple[bool, Optional[str]]:
|
||||
"""Check if You.com SDK is available and API key is set.
|
||||
|
||||
@@ -387,6 +387,8 @@ class A2A:
|
||||
user_input=user_input,
|
||||
task_id=task.id,
|
||||
context_id=task.context_id,
|
||||
task_store=self.task_store,
|
||||
running=self._running,
|
||||
),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
|
||||
@@ -4,6 +4,7 @@ A2A Streaming Events
|
||||
SSE streaming support for A2A task updates.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import AsyncIterator, Optional
|
||||
|
||||
@@ -15,7 +16,11 @@ from praisonaiagents.ui.a2a.types import (
|
||||
TaskArtifactUpdateEvent,
|
||||
Artifact,
|
||||
TextPart,
|
||||
Message,
|
||||
)
|
||||
from praisonaiagents._logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class A2AEventEncoder:
|
||||
@@ -65,6 +70,7 @@ def create_status_event(
|
||||
state: TaskState,
|
||||
context_id: Optional[str] = None,
|
||||
final: bool = False,
|
||||
message: Optional[Message] = None,
|
||||
) -> TaskStatusUpdateEvent:
|
||||
"""
|
||||
Create a TaskStatusUpdateEvent.
|
||||
@@ -74,6 +80,7 @@ def create_status_event(
|
||||
state: New task state
|
||||
context_id: Optional context ID
|
||||
final: Whether this is the final event
|
||||
message: Optional status message (e.g. failure reason)
|
||||
|
||||
Returns:
|
||||
TaskStatusUpdateEvent
|
||||
@@ -81,7 +88,7 @@ def create_status_event(
|
||||
return TaskStatusUpdateEvent(
|
||||
task_id=task_id,
|
||||
context_id=context_id,
|
||||
status=TaskStatus(state=state),
|
||||
status=TaskStatus(state=state, message=message),
|
||||
final=final,
|
||||
)
|
||||
|
||||
@@ -130,60 +137,123 @@ async def stream_agent_response(
|
||||
user_input: str,
|
||||
task_id: str,
|
||||
context_id: Optional[str] = None,
|
||||
*,
|
||||
task_store=None,
|
||||
running: Optional[dict] = None,
|
||||
) -> AsyncIterator[str]:
|
||||
"""
|
||||
Stream agent response as A2A events.
|
||||
|
||||
The sync agent call runs in a worker thread so the event loop stays
|
||||
responsive. When ``task_store``/``running`` are given, the task's
|
||||
lifecycle is mirrored into the store and the call is registered so
|
||||
``tasks/get`` and ``tasks/cancel`` work for streamed tasks.
|
||||
|
||||
Args:
|
||||
agent: PraisonAI Agent instance
|
||||
agent: PraisonAI Agent (uses ``chat``) or AgentTeam (uses ``start``)
|
||||
user_input: User input text
|
||||
task_id: Task ID
|
||||
context_id: Optional context ID
|
||||
task_store: Optional TaskStore to keep in sync
|
||||
running: Optional dict of task_id -> future, used for cancellation
|
||||
|
||||
Yields:
|
||||
SSE-formatted event strings
|
||||
"""
|
||||
from praisonaiagents.ui.a2a.conversion import praisonai_to_a2a_message
|
||||
|
||||
encoder = A2AEventEncoder()
|
||||
|
||||
# Emit working status
|
||||
working_event = create_status_event(
|
||||
task_id=task_id,
|
||||
state=TaskState.WORKING,
|
||||
context_id=context_id,
|
||||
)
|
||||
yield encoder.encode_task_status(working_event)
|
||||
if task_store is not None:
|
||||
task_store.update_status(task_id, TaskState.WORKING)
|
||||
# Keep the task while the agent runs: a cancel can make it
|
||||
# terminal (and evictable) before we finish.
|
||||
task_store._pinned.add(task_id)
|
||||
|
||||
fut = None
|
||||
try:
|
||||
# Get response from agent
|
||||
response = agent.chat(user_input)
|
||||
# Emit working status
|
||||
yield encoder.encode_task_status(create_status_event(
|
||||
task_id=task_id,
|
||||
state=TaskState.WORKING,
|
||||
context_id=context_id,
|
||||
))
|
||||
|
||||
# tasks/cancel may have landed while we were suspended at the
|
||||
# yield above, before the call was registered: don't start it.
|
||||
if task_store is not None:
|
||||
task = task_store.get_task(task_id)
|
||||
if task is not None and task.status.state == TaskState.CANCELLED:
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
call = agent.chat if hasattr(agent, "chat") else agent.start
|
||||
fut = asyncio.ensure_future(asyncio.to_thread(call, user_input))
|
||||
if running is not None:
|
||||
running[task_id] = fut
|
||||
response = str(await fut)
|
||||
|
||||
# Emit artifact with response
|
||||
artifact_event = create_artifact_event(
|
||||
task_id=task_id,
|
||||
content=str(response),
|
||||
content=response,
|
||||
context_id=context_id,
|
||||
last_chunk=True,
|
||||
)
|
||||
if task_store is not None:
|
||||
task_store.add_to_history(
|
||||
task_id, praisonai_to_a2a_message(response, task_id=task_id)
|
||||
)
|
||||
task_store.add_artifact(task_id, artifact_event.artifact)
|
||||
task_store.update_status(task_id, TaskState.COMPLETED)
|
||||
yield encoder.encode_task_artifact(artifact_event)
|
||||
|
||||
# Emit completed status
|
||||
completed_event = create_status_event(
|
||||
yield encoder.encode_task_status(create_status_event(
|
||||
task_id=task_id,
|
||||
state=TaskState.COMPLETED,
|
||||
context_id=context_id,
|
||||
final=True,
|
||||
)
|
||||
yield encoder.encode_task_status(completed_event)
|
||||
|
||||
except Exception:
|
||||
# Emit failed status
|
||||
failed_event = create_status_event(
|
||||
))
|
||||
|
||||
except asyncio.CancelledError:
|
||||
# tasks/cancel marks the task CANCELLED before cancelling the call;
|
||||
# report that to the client. Any other cancellation (e.g. client
|
||||
# disconnect) is propagated.
|
||||
task = task_store.get_task(task_id) if task_store is not None else None
|
||||
if task is None or task.status.state != TaskState.CANCELLED:
|
||||
if task_store is not None:
|
||||
task_store.update_status(task_id, TaskState.CANCELLED)
|
||||
raise
|
||||
yield encoder.encode_task_status(create_status_event(
|
||||
task_id=task_id,
|
||||
state=TaskState.CANCELLED,
|
||||
context_id=context_id,
|
||||
final=True,
|
||||
))
|
||||
|
||||
except Exception as e:
|
||||
logger.error("A2A stream failed for task %s: %s", task_id, e, exc_info=True)
|
||||
reason = praisonai_to_a2a_message(str(e), task_id=task_id)
|
||||
if task_store is not None:
|
||||
task_store.update_status(task_id, TaskState.FAILED, message=reason)
|
||||
yield encoder.encode_task_status(create_status_event(
|
||||
task_id=task_id,
|
||||
state=TaskState.FAILED,
|
||||
context_id=context_id,
|
||||
final=True,
|
||||
)
|
||||
yield encoder.encode_task_status(failed_event)
|
||||
message=reason,
|
||||
))
|
||||
|
||||
except GeneratorExit:
|
||||
# Stream closed early (client went away): leave a terminal state.
|
||||
# No-op if the task already finished.
|
||||
if task_store is not None:
|
||||
task_store.update_status(task_id, TaskState.CANCELLED)
|
||||
raise
|
||||
|
||||
finally:
|
||||
if running is not None and running.get(task_id) is fut:
|
||||
running.pop(task_id, None)
|
||||
if task_store is not None:
|
||||
task_store._pinned.discard(task_id)
|
||||
|
||||
# Emit done
|
||||
yield encoder.encode_done()
|
||||
@@ -5,6 +5,7 @@ Provides utilities for streaming PraisonAI agent responses as AG-UI events.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextvars
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
@@ -30,6 +31,12 @@ from praisonaiagents.ui.agui.types import (
|
||||
from praisonaiagents.ui.protocols import A2UI_MIME_TYPE
|
||||
from praisonaiagents.streaming.events import StreamEventType as _SE
|
||||
|
||||
# Identifies which AG-UI run owns the current task/thread. The agent's
|
||||
# stream_emitter is shared by every request served by one Agent, and its
|
||||
# callbacks fire synchronously in the emitting context, so each run's callback
|
||||
# only accepts events emitted while its own owner token is active.
|
||||
_RUN_OWNER: contextvars.ContextVar = contextvars.ContextVar("agui_run_owner", default=None)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EventBuffer:
|
||||
@@ -466,20 +473,29 @@ async def async_stream_agent_response(
|
||||
text_started = False
|
||||
text_ended = False
|
||||
original_emitter = getattr(agent, "stream_emitter", None)
|
||||
owner = object()
|
||||
|
||||
def on_stream_event(event: Any) -> None:
|
||||
# Drop events emitted by another concurrent run on the same agent.
|
||||
if _RUN_OWNER.get() is not owner:
|
||||
return
|
||||
loop.call_soon_threadsafe(queue.put_nowait, ("stream", event))
|
||||
|
||||
if original_emitter is not None:
|
||||
original_emitter.add_callback(on_stream_event)
|
||||
|
||||
async def run_chat() -> Any:
|
||||
# Tasks run in a copy of the creator's context, so this set is local
|
||||
# to the chat task and never leaks into the caller's context.
|
||||
_RUN_OWNER.set(owner)
|
||||
try:
|
||||
if hasattr(agent, "achat"):
|
||||
return await agent.achat(user_input, stream=True)
|
||||
if hasattr(agent, "chat"):
|
||||
# run_in_executor does not copy ContextVars; carry the owner.
|
||||
ctx = contextvars.copy_context()
|
||||
return await loop.run_in_executor(
|
||||
None, lambda: agent.chat(user_input, stream=True)
|
||||
None, lambda: ctx.run(agent.chat, user_input, stream=True)
|
||||
)
|
||||
raise ValueError("Agent must have 'chat' or 'achat' method")
|
||||
finally:
|
||||
|
||||
@@ -9,8 +9,10 @@ the previous good file: the old bytes stay intact and ``os.replace`` swaps in th
|
||||
new content atomically on both POSIX and Windows.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
@@ -21,9 +23,52 @@ __all__ = [
|
||||
"atomic_write_json",
|
||||
"atomic_write_text",
|
||||
"quarantine_corrupt_file",
|
||||
"safe_join",
|
||||
"update_json",
|
||||
]
|
||||
|
||||
# Max UTF-8 bytes for the final basename; leaves room under the common
|
||||
# 255-byte limit for ``atomic_write_text``'s ``<basename>.XXXXXXXX.tmp`` file.
|
||||
_MAX_NAME_BYTES = 200
|
||||
_HASHED = re.compile(r"(.*)%~([0-9a-f]{16})", re.DOTALL)
|
||||
_ESCAPE = re.compile(r'[%/\\:*?"<>|\x00-\x1f\x7f]')
|
||||
|
||||
|
||||
def _escape(name: str) -> str:
|
||||
stem = _ESCAPE.sub(lambda m: "%%%02X" % ord(m.group()), name)
|
||||
return "%2E" + stem[1:] if stem.startswith(".") else stem
|
||||
|
||||
|
||||
def safe_join(base: str, name: str, suffix: str = "") -> str:
|
||||
"""Join a caller-supplied ``name`` under ``base`` without escaping it.
|
||||
|
||||
Only ``%``, path separators, control and Windows-reserved characters (and
|
||||
a leading ``.``) are percent-encoded, so ordinary names - including spaces
|
||||
and non-ASCII - keep their pre-#6238 filename, ``..`` and separators can
|
||||
never leave ``base``, and ``urllib.parse.unquote`` recovers the name.
|
||||
Names whose filename would exceed ``_MAX_NAME_BYTES`` are truncated and
|
||||
tagged ``%~<sha256[:16]>``; that listed (unquoted) form maps back to the
|
||||
same file, while no ordinary name can produce a raw ``%~`` (Issue #6238).
|
||||
"""
|
||||
name = str(name)
|
||||
hashed = _HASHED.fullmatch(name)
|
||||
if hashed:
|
||||
stem = _escape(hashed.group(1)) + "%~" + hashed.group(2)
|
||||
else:
|
||||
stem = _escape(name)
|
||||
budget = _MAX_NAME_BYTES - len(suffix.encode("utf-8"))
|
||||
if len(stem.encode("utf-8", "surrogatepass")) > budget:
|
||||
tag = "%~" + hashlib.sha256(name.encode("utf-8", "surrogatepass")).hexdigest()[:16]
|
||||
kept, used = [], len(tag)
|
||||
for tok in re.findall(r"%[0-9A-F]{2}|.", _escape(name), re.DOTALL):
|
||||
used += len(tok.encode("utf-8", "surrogatepass"))
|
||||
if used > budget:
|
||||
break
|
||||
kept.append(tok)
|
||||
stem = "".join(kept) + tag
|
||||
return os.path.join(base, stem + suffix)
|
||||
|
||||
|
||||
# Per-path in-process mutexes so threads in one process serialise before they
|
||||
# even reach the cross-process ``FileLock``. Keyed by absolute path.
|
||||
_path_mutexes: "defaultdict[str, threading.RLock]" = defaultdict(threading.RLock)
|
||||
|
||||
@@ -7,6 +7,7 @@ after it.
|
||||
|
||||
flow = AgentFlow(steps=[...], cache=True) # in-memory, this run
|
||||
flow = AgentFlow(steps=[...], cache=InMemoryStepCache(max_entries=256))
|
||||
flow = AgentFlow(steps=[...], cache=FileStepCache("run.json")) # durable resume
|
||||
|
||||
The key is (step identity, input, previous output, the variables the step can
|
||||
see). Two different steps, or the same step on different input, never collide.
|
||||
@@ -20,11 +21,15 @@ codebase has been full of. Nothing caches unless asked.
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
__all__ = ["StepCacheProtocol", "InMemoryStepCache", "make_step_key", "resolve_step_cache"]
|
||||
__all__ = [
|
||||
"StepCacheProtocol", "InMemoryStepCache", "FileStepCache",
|
||||
"make_step_key", "resolve_step_cache",
|
||||
]
|
||||
|
||||
|
||||
def _snapshot(value: Any) -> Any:
|
||||
@@ -51,6 +56,106 @@ def _identify(step: Any) -> str:
|
||||
return f"{type(step).__name__}:{id(step)}"
|
||||
|
||||
|
||||
_SCALARS = (str, int, float, complex, bool, bytes, type(None), type(Ellipsis))
|
||||
|
||||
|
||||
def _stable(value: Any, seen: set) -> str:
|
||||
# A text form that is the same in every process. repr() is not: a set or
|
||||
# frozenset (``x in {"a", "b"}`` compiles to a frozenset constant) iterates
|
||||
# in an order that depends on the per-process string hash seed.
|
||||
if hasattr(value, "co_code"):
|
||||
return _code_fingerprint(value, seen)
|
||||
if isinstance(value, (set, frozenset)):
|
||||
return "{" + ",".join(sorted(_stable(v, seen) for v in value)) + "}"
|
||||
if isinstance(value, (tuple, list)):
|
||||
return type(value).__name__ + "(" + ",".join(_stable(v, seen) for v in value) + ")"
|
||||
return repr(value)
|
||||
|
||||
|
||||
def _code_fingerprint(code: Any, seen: set) -> str:
|
||||
# Bytecode, names and constants, recursing into nested code objects (whose
|
||||
# repr carries a memory address that would differ in every process).
|
||||
parts = [code.co_code.hex(), repr(code.co_names)]
|
||||
parts.extend(_stable(const, seen) for const in code.co_consts)
|
||||
return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def _immutable(value: Any) -> bool:
|
||||
if isinstance(value, _SCALARS):
|
||||
return True
|
||||
if isinstance(value, (tuple, frozenset)):
|
||||
return all(_immutable(v) for v in value)
|
||||
return False
|
||||
|
||||
|
||||
def _function_fingerprint(fn: Any, seen: set) -> str:
|
||||
# The handler's code plus what it was configured with: immutable defaults
|
||||
# and closure values (a factory's ``output="v2"``), and the module-level
|
||||
# Python functions it calls. Mutable captured state (a list it appends to,
|
||||
# a client object) is left out, or the key would change as the run goes.
|
||||
if id(fn) in seen:
|
||||
return "recursive"
|
||||
seen.add(id(fn))
|
||||
parts = [_code_fingerprint(fn.__code__, seen)]
|
||||
captured = list(getattr(fn, "__defaults__", None) or ())
|
||||
captured += sorted((getattr(fn, "__kwdefaults__", None) or {}).items())
|
||||
for cell in getattr(fn, "__closure__", None) or ():
|
||||
try:
|
||||
captured.append(cell.cell_contents)
|
||||
except ValueError: # empty cell
|
||||
continue
|
||||
for value in captured:
|
||||
if hasattr(value, "__code__"):
|
||||
parts.append(_function_fingerprint(value, seen))
|
||||
elif _immutable(value):
|
||||
parts.append(_stable(value, seen))
|
||||
fn_globals = getattr(fn, "__globals__", None) or {}
|
||||
for name in fn.__code__.co_names:
|
||||
helper = fn_globals.get(name)
|
||||
if hasattr(helper, "__code__") and not isinstance(helper, type):
|
||||
parts.append(name + "=" + _function_fingerprint(helper, seen))
|
||||
return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
_CONFIG_ATTRS = ("role", "goal", "backstory", "instructions", "llm", "action",
|
||||
"description", "expected_output")
|
||||
|
||||
|
||||
def _config(obj: Any) -> Dict[str, Any]:
|
||||
config: Dict[str, Any] = {
|
||||
a: getattr(obj, a) for a in _CONFIG_ATTRS if isinstance(getattr(obj, a, None), str)
|
||||
}
|
||||
tools = getattr(obj, "tools", None)
|
||||
if isinstance(tools, (list, tuple)):
|
||||
config["tools"] = sorted(
|
||||
t if isinstance(t, str) else str(getattr(t, "name", None) or getattr(t, "__name__", type(t).__name__))
|
||||
for t in tools
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def _definition(step: Any) -> Optional[str]:
|
||||
# A step edited but kept under the same name must not be served its old
|
||||
# result from a durable cache, so its definition is part of the key: a
|
||||
# handler's code and configuration, or an agent's instructions, model and
|
||||
# tool names.
|
||||
fn = step if hasattr(step, "__code__") else getattr(step, "handler", None)
|
||||
if hasattr(fn, "__code__"):
|
||||
return _function_fingerprint(fn, set())
|
||||
config = _config(step)
|
||||
# AgentFlow wraps an Agent step in a Task(agent=...), so the agent's own
|
||||
# role, instructions and model live one level down.
|
||||
agent = getattr(step, "agent", None)
|
||||
if agent is not None and not isinstance(agent, str):
|
||||
config["agent"] = _config(agent)
|
||||
agent_config = getattr(step, "agent_config", None)
|
||||
if isinstance(agent_config, dict):
|
||||
config["agent_config"] = {k: str(v) for k, v in sorted(agent_config.items())}
|
||||
if not config:
|
||||
return None
|
||||
return hashlib.sha256(json.dumps(config, sort_keys=True).encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def make_step_key(step: Any, previous_output: Any, input_text: str, variables: Dict[str, Any]) -> str:
|
||||
"""A stable key for (this step, these inputs).
|
||||
|
||||
@@ -60,12 +165,13 @@ def make_step_key(step: Any, previous_output: Any, input_text: str, variables: D
|
||||
"""
|
||||
payload = {
|
||||
"step": _identify(step),
|
||||
"definition": _definition(step),
|
||||
"previous": previous_output if isinstance(previous_output, str) else str(previous_output),
|
||||
"input": input_text,
|
||||
# Tag each variable with its type so True and "True" (or 1 and "1") do
|
||||
# not collapse to the same key and serve one call's answer to another.
|
||||
"variables": {
|
||||
k: [type(v).__name__, str(v)]
|
||||
k: [type(v).__name__, _stable(v, set()) if isinstance(v, (set, frozenset)) else str(v)]
|
||||
for k, v in sorted((variables or {}).items())
|
||||
},
|
||||
}
|
||||
@@ -163,6 +269,78 @@ class InMemoryStepCache:
|
||||
return len(self._entries)
|
||||
|
||||
|
||||
class FileStepCache:
|
||||
"""Durable step cache: resume an interrupted flow in a later process.
|
||||
|
||||
flow = AgentFlow(steps=[...], cache=FileStepCache("run.json"))
|
||||
flow.run("input") # crashes at step 4
|
||||
flow.run("input") # steps 1-3 are served from run.json; step 4 runs
|
||||
|
||||
Resume is content-addressed, not index-based: each key covers the step's
|
||||
name and definition (a handler's code, its immutable defaults and closure
|
||||
values and the module-level functions it calls; an agent's role, goal,
|
||||
instructions, model and tool names), the input, the previous step's output
|
||||
and the variables, so renaming or editing a step or changing the input
|
||||
misses the cache from that step on and it runs again. Not covered: a
|
||||
change in mutable captured state, a method or imported library a handler
|
||||
calls, or a tool's implementation. After such an edit, call ``clear()``
|
||||
(or delete the file) to re-run. Steps without a stable name never hit
|
||||
across processes; they re-run.
|
||||
|
||||
Every ``set`` is a locked read-modify-write of the file, so several caches
|
||||
(or processes) on one path add to it rather than erase each other's
|
||||
entries, and a crash mid-write keeps the last good file. A value that JSON
|
||||
cannot store unchanged (an object, int dict keys, a tuple) is rejected (the
|
||||
workflow logs a warning and carries on) rather than stored lossily.
|
||||
"""
|
||||
|
||||
def __init__(self, path: str):
|
||||
self.path = os.path.abspath(os.path.expanduser(str(path)))
|
||||
self._lock = threading.Lock()
|
||||
self._entries: Dict[str, Dict[str, Any]] = {}
|
||||
if os.path.exists(self.path):
|
||||
with open(self.path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"{self.path} is not a step cache file.")
|
||||
self._entries = data
|
||||
|
||||
def get(self, key: str) -> Optional[Dict[str, Any]]:
|
||||
with self._lock:
|
||||
entry = self._entries.get(key)
|
||||
return copy.deepcopy(entry) if entry is not None else None
|
||||
|
||||
def set(self, key: str, value: Dict[str, Any]) -> None:
|
||||
# Round-trip through JSON first so an unserialisable value raises here
|
||||
# and never reaches memory or disk in a form a later run cannot read.
|
||||
snapshot = json.loads(json.dumps(value))
|
||||
if snapshot != value:
|
||||
# e.g. {1: "x"} -> {"1": "x"} or (a, b) -> [a, b]: a resumed run
|
||||
# would see different data than the live run did.
|
||||
raise TypeError("Step cache value does not survive a JSON round trip unchanged.")
|
||||
from ..utils.atomic_io import update_json
|
||||
|
||||
def _add(entries: Any) -> Dict[str, Any]:
|
||||
if not isinstance(entries, dict):
|
||||
raise ValueError(f"{self.path} is not a step cache file.")
|
||||
entries[key] = snapshot
|
||||
return dict(entries)
|
||||
|
||||
with self._lock:
|
||||
os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
|
||||
self._entries = update_json(self.path, _add)
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
self._entries = {}
|
||||
if os.path.exists(self.path):
|
||||
os.remove(self.path)
|
||||
|
||||
def __len__(self) -> int:
|
||||
with self._lock:
|
||||
return len(self._entries)
|
||||
|
||||
|
||||
def resolve_step_cache(cache: Any) -> Optional[Any]:
|
||||
"""Turn a ``cache=`` value into a cache object, or None for 'do not cache'."""
|
||||
if cache is None or cache is False:
|
||||
|
||||
@@ -3005,17 +3005,16 @@ Create a brief execution plan (2-3 sentences) describing how to best accomplish
|
||||
result = agent.generate(prompt)
|
||||
# Extract URL from result and store in variables for next step
|
||||
if hasattr(result, 'data') and result.data:
|
||||
image_url = result.data[0].url if hasattr(result.data[0], 'url') else None
|
||||
image_url = getattr(result.data[0], 'url', None)
|
||||
if image_url:
|
||||
variables['_last_image_url'] = image_url
|
||||
# Save to file if output path specified
|
||||
if output_path:
|
||||
try:
|
||||
import urllib.request
|
||||
urllib.request.urlretrieve(image_url, output_path)
|
||||
variables['_last_image_file'] = output_path
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save image to {output_path}: {e}")
|
||||
# Save to file if output path specified (handles b64 and URL responses)
|
||||
if output_path:
|
||||
try:
|
||||
result.save(output_path)
|
||||
variables['_last_image_file'] = output_path
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save image to {output_path}: {e}")
|
||||
return result
|
||||
|
||||
# OCRAgent - extract with retry logic for external API failures
|
||||
|
||||
@@ -473,7 +473,10 @@ class YAMLWorkflowParser:
|
||||
|
||||
if 'reasoning' in role_config:
|
||||
agent['reasoning'] = role_config['reasoning']
|
||||
|
||||
|
||||
if isinstance(role_config.get('execution'), dict):
|
||||
agent['execution'] = role_config['execution']
|
||||
|
||||
agents[role_id] = agent
|
||||
|
||||
return agents
|
||||
@@ -591,7 +594,11 @@ class YAMLWorkflowParser:
|
||||
# so we don't override the Agent's own caching default.
|
||||
if cache is False:
|
||||
agent_kwargs['caching'] = False
|
||||
_execution: Dict[str, Any] = {}
|
||||
# An agent-level `execution:` mapping passes straight through to
|
||||
# ExecutionConfig (e.g. max_budget, on_budget_exceeded, durable,
|
||||
# journal_path, resume_run_id); flat max_iter/max_execution_time win.
|
||||
_yaml_execution = config.get('execution')
|
||||
_execution: Dict[str, Any] = dict(_yaml_execution) if isinstance(_yaml_execution, dict) else {}
|
||||
if max_iter is not None:
|
||||
_execution['max_iter'] = max_iter
|
||||
if max_execution_time is not None:
|
||||
|
||||
@@ -9,9 +9,13 @@ repository or git is unavailable.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
# Bound every git call and never block on an interactive prompt.
|
||||
_GIT_TIMEOUT = 60
|
||||
|
||||
|
||||
def _slugify(name: str) -> str:
|
||||
"""Return a git-ref-safe, collision-resistant slug for ``name``.
|
||||
@@ -67,12 +71,16 @@ class GitWorktreeAdapter:
|
||||
cwd=str(cwd or self._root),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_GIT_TIMEOUT,
|
||||
env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
|
||||
)
|
||||
|
||||
def _is_git_repo(self) -> bool:
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
result = self._git("rev-parse", "--is-inside-work-tree")
|
||||
except (FileNotFoundError, OSError):
|
||||
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
return result.returncode == 0 and result.stdout.strip() == "true"
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "1.7.20"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
version = "1.7.22"
|
||||
description = "Open-source Python AI agent framework that runs on your own computers, even fully offline."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
authors = [
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Concurrent AG-UI runs on one agent must not receive each other's tokens (#6150)."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from praisonaiagents.streaming.events import StreamEvent, StreamEventEmitter, StreamEventType
|
||||
from praisonaiagents.ui.agui.streaming import async_stream_agent_response
|
||||
|
||||
|
||||
class _AsyncAgent:
|
||||
def __init__(self):
|
||||
self.stream_emitter = StreamEventEmitter()
|
||||
|
||||
async def achat(self, text, stream=True):
|
||||
for tok in (f"[{text}-1]", f"[{text}-2]"):
|
||||
self.stream_emitter.emit(StreamEvent(type=StreamEventType.DELTA_TEXT, content=tok))
|
||||
await asyncio.sleep(0.02)
|
||||
return text
|
||||
|
||||
|
||||
class _SyncAgent:
|
||||
def __init__(self):
|
||||
self.stream_emitter = StreamEventEmitter()
|
||||
|
||||
def chat(self, text, stream=True):
|
||||
import time
|
||||
for tok in (f"[{text}-1]", f"[{text}-2]"):
|
||||
self.stream_emitter.emit(StreamEvent(type=StreamEventType.DELTA_TEXT, content=tok))
|
||||
time.sleep(0.02)
|
||||
return text
|
||||
|
||||
|
||||
async def _collect(agent, who):
|
||||
out = []
|
||||
async for ev in async_stream_agent_response(agent, who, "th-" + who, "run-" + who):
|
||||
if getattr(ev, "delta", None):
|
||||
out.append(ev.delta)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _run_pair(agent):
|
||||
async def main():
|
||||
return await asyncio.gather(_collect(agent, "ALICE"), _collect(agent, "BOB"))
|
||||
return asyncio.run(main())
|
||||
|
||||
|
||||
def test_concurrent_async_runs_do_not_leak_tokens():
|
||||
alice, bob = _run_pair(_AsyncAgent())
|
||||
assert alice == "[ALICE-1][ALICE-2]"
|
||||
assert bob == "[BOB-1][BOB-2]"
|
||||
|
||||
|
||||
def test_concurrent_sync_runs_do_not_leak_tokens():
|
||||
alice, bob = _run_pair(_SyncAgent())
|
||||
assert alice == "[ALICE-1][ALICE-2]"
|
||||
assert bob == "[BOB-1][BOB-2]"
|
||||
|
||||
|
||||
def test_model_hook_path_keeps_run_owner():
|
||||
"""With a wrap_model_call hook, achat re-schedules the model call through an
|
||||
executor; the run owner must survive so events still reach the right run."""
|
||||
from praisonaiagents.agent.chat_mixin import ChatMixin
|
||||
from praisonaiagents.hooks import wrap_model_call
|
||||
|
||||
@wrap_model_call
|
||||
def passthrough(request, call_next):
|
||||
return call_next(request)
|
||||
|
||||
class _HookedAgent(ChatMixin):
|
||||
name = "hooked"
|
||||
llm = "fake"
|
||||
|
||||
def __init__(self):
|
||||
self.stream_emitter = StreamEventEmitter()
|
||||
self._hooks = [passthrough]
|
||||
|
||||
async def _achat_completion_with_retry_core(self, messages, *a, **kw):
|
||||
text = messages
|
||||
for tok in (f"[{text}-1]", f"[{text}-2]"):
|
||||
self.stream_emitter.emit(StreamEvent(type=StreamEventType.DELTA_TEXT, content=tok))
|
||||
await asyncio.sleep(0.02)
|
||||
return text
|
||||
|
||||
async def achat(self, text, stream=True):
|
||||
return await self._achat_completion_with_retry_dispatch(text, stream=stream)
|
||||
|
||||
agent = _HookedAgent()
|
||||
assert agent._get_model_middleware_manager() is not None
|
||||
alice, bob = _run_pair(agent)
|
||||
assert alice == "[ALICE-1][ALICE-2]"
|
||||
assert bob == "[BOB-1][BOB-2]"
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Live check that async chat with a tool returns a model-written final answer.
|
||||
|
||||
Exercises the post-tool follow-up request in ``ChatMixin._achat_completion``
|
||||
by turning off the default unified dispatch, so ``_achat_impl`` takes the
|
||||
legacy OpenAI-client branch. Skipped unless ``OPENAI_API_KEY`` is set.
|
||||
"""
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents import Agent
|
||||
|
||||
|
||||
def get_secret_number() -> str:
|
||||
"""Return the secret number."""
|
||||
return "4217"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.skipif(not os.getenv("OPENAI_API_KEY"), reason="No API key")
|
||||
async def test_achat_with_tool_returns_final_answer_live():
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="You are a helpful assistant",
|
||||
llm="gpt-4o-mini",
|
||||
tools=[get_secret_number],
|
||||
)
|
||||
# The default unified path never calls _achat_completion.
|
||||
agent._use_unified_llm_dispatch = False
|
||||
result = await agent.astart(
|
||||
"Use the get_secret_number tool, then reply with one sentence that "
|
||||
"contains the secret number."
|
||||
)
|
||||
print(f"Agent output: {result}")
|
||||
assert result
|
||||
assert "4217" in str(result)
|
||||
# Without the fix the raw tool output ("4217") is returned unchanged.
|
||||
assert str(result).strip() != "4217"
|
||||
@@ -169,3 +169,264 @@ async def test_execute_tool_async_resolves_aliased_function_name():
|
||||
)
|
||||
|
||||
assert result == "aliased-ran"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_achat_completion_streams_final_response_after_tool_calls():
|
||||
"""After running tools, the legacy async path must ask the model for a
|
||||
final answer instead of failing and returning the raw tool output.
|
||||
|
||||
``_achat_completion`` used to reference an undefined ``stream`` name and
|
||||
``_get_llm_functions`` (only defined in ``agent.py``), so the follow-up
|
||||
completion always raised ``NameError``, which was swallowed and the raw
|
||||
tool results were returned.
|
||||
"""
|
||||
agent = Agent(name="test", instructions="You are helpful", llm="gpt-4o-mini")
|
||||
|
||||
def lookup(query: str) -> str:
|
||||
return "tool output"
|
||||
|
||||
tool_call = SimpleNamespace(
|
||||
id="call-1",
|
||||
function=SimpleNamespace(name="lookup", arguments='{"query": "x"}'),
|
||||
)
|
||||
response = SimpleNamespace(
|
||||
choices=[SimpleNamespace(message=SimpleNamespace(tool_calls=[tool_call]))]
|
||||
)
|
||||
|
||||
def _chunk(content):
|
||||
return SimpleNamespace(
|
||||
choices=[SimpleNamespace(delta=SimpleNamespace(content=content))]
|
||||
)
|
||||
|
||||
async def _stream():
|
||||
for part in ("final ", "answer"):
|
||||
yield _chunk(part)
|
||||
|
||||
create = AsyncMock(return_value=_stream())
|
||||
fake_client = SimpleNamespace(
|
||||
async_client=SimpleNamespace(
|
||||
chat=SimpleNamespace(completions=SimpleNamespace(create=create))
|
||||
)
|
||||
)
|
||||
final = SimpleNamespace(
|
||||
choices=[SimpleNamespace(message=SimpleNamespace(content="final answer"))]
|
||||
)
|
||||
process_stream_chunks = MagicMock(return_value=final)
|
||||
|
||||
with patch.object(
|
||||
type(agent), "_openai_client", new=fake_client
|
||||
), patch.object(
|
||||
agent, "execute_tool_async", new_callable=AsyncMock, return_value="tool output"
|
||||
), patch(
|
||||
"praisonaiagents.agent.agent._get_llm_functions",
|
||||
return_value={"process_stream_chunks": process_stream_chunks},
|
||||
), patch(
|
||||
"praisonaiagents.agent.chat_mixin._get_display_functions"
|
||||
) as display:
|
||||
result = await agent._achat_completion(response, tools=[lookup])
|
||||
|
||||
display.return_value.__getitem__.return_value.assert_not_called()
|
||||
create.assert_awaited_once()
|
||||
assert create.await_args.kwargs["stream"] is True
|
||||
process_stream_chunks.assert_called_once()
|
||||
assert result == "final answer"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_achat_completion_follow_up_keeps_conversation_and_skips_empty_chunks():
|
||||
"""The post-tool follow-up request must keep the original conversation (so
|
||||
the final answer still sees the user's question), and stream chunks with no
|
||||
``choices`` (e.g. Azure content-filter annotations) must be skipped rather
|
||||
than raising ``IndexError`` and falling back to the raw tool output.
|
||||
"""
|
||||
agent = Agent(name="test", instructions="You are helpful", llm="gpt-4o-mini")
|
||||
|
||||
def lookup(query: str) -> str:
|
||||
return "tool output"
|
||||
|
||||
tool_call = SimpleNamespace(
|
||||
id="call-1",
|
||||
function=SimpleNamespace(name="lookup", arguments='{"query": "x"}'),
|
||||
)
|
||||
response = SimpleNamespace(
|
||||
choices=[SimpleNamespace(message=SimpleNamespace(tool_calls=[tool_call]))]
|
||||
)
|
||||
conversation = [
|
||||
{"role": "system", "content": "You are helpful"},
|
||||
{"role": "user", "content": "Look up x and answer in French"},
|
||||
]
|
||||
|
||||
async def _stream():
|
||||
yield SimpleNamespace(choices=[])
|
||||
yield SimpleNamespace(
|
||||
choices=[SimpleNamespace(delta=SimpleNamespace(content="réponse"))]
|
||||
)
|
||||
|
||||
create = AsyncMock(return_value=_stream())
|
||||
fake_client = SimpleNamespace(
|
||||
async_client=SimpleNamespace(
|
||||
chat=SimpleNamespace(completions=SimpleNamespace(create=create))
|
||||
)
|
||||
)
|
||||
final = SimpleNamespace(
|
||||
choices=[SimpleNamespace(message=SimpleNamespace(content="réponse"))]
|
||||
)
|
||||
process_stream_chunks = MagicMock(return_value=final)
|
||||
|
||||
with patch.object(
|
||||
type(agent), "_openai_client", new=fake_client
|
||||
), patch.object(
|
||||
agent, "execute_tool_async", new_callable=AsyncMock, return_value="tool output"
|
||||
), patch(
|
||||
"praisonaiagents.agent.agent._get_llm_functions",
|
||||
return_value={"process_stream_chunks": process_stream_chunks},
|
||||
), patch(
|
||||
"praisonaiagents.agent.chat_mixin._get_display_functions"
|
||||
) as display:
|
||||
result = await agent._achat_completion(
|
||||
response, tools=[lookup], messages=conversation
|
||||
)
|
||||
|
||||
display.return_value.__getitem__.return_value.assert_not_called()
|
||||
sent = create.await_args.kwargs["messages"]
|
||||
assert sent[: len(conversation)] == conversation
|
||||
assert "tool output" in sent[-1]["content"]
|
||||
# The caller's list is not mutated.
|
||||
assert len(conversation) == 2
|
||||
assert result == "réponse"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_achat_impl_forwards_conversation_to_tool_follow_up():
|
||||
"""The legacy ``_achat_impl`` tool branch must pass the conversation to
|
||||
``_achat_completion`` so the follow-up request still has the user's prompt.
|
||||
"""
|
||||
agent = Agent(name="test", instructions="You are helpful", llm="gpt-4o-mini")
|
||||
agent._using_custom_llm = False
|
||||
agent._use_unified_llm_dispatch = False
|
||||
|
||||
def lookup(query: str) -> str:
|
||||
return "tool output"
|
||||
|
||||
tool_call = SimpleNamespace(
|
||||
id="call-1",
|
||||
function=SimpleNamespace(name="lookup", arguments='{"query": "x"}'),
|
||||
)
|
||||
tool_response = SimpleNamespace(
|
||||
choices=[SimpleNamespace(message=SimpleNamespace(tool_calls=[tool_call]))]
|
||||
)
|
||||
|
||||
async def _stream():
|
||||
yield SimpleNamespace(
|
||||
choices=[SimpleNamespace(delta=SimpleNamespace(content="final answer"))]
|
||||
)
|
||||
|
||||
create = AsyncMock(side_effect=[tool_response, _stream()])
|
||||
fake_client = SimpleNamespace(
|
||||
async_client=SimpleNamespace(
|
||||
chat=SimpleNamespace(completions=SimpleNamespace(create=create))
|
||||
)
|
||||
)
|
||||
final = SimpleNamespace(
|
||||
choices=[SimpleNamespace(message=SimpleNamespace(content="final answer"))]
|
||||
)
|
||||
prompt_messages = [{"role": "user", "content": "Look up x for me"}]
|
||||
|
||||
with patch.object(type(agent), "_openai_client", new=fake_client), patch.object(
|
||||
agent, "execute_tool_async", new_callable=AsyncMock, return_value="tool output"
|
||||
), patch(
|
||||
"praisonaiagents.agent.agent._get_llm_functions",
|
||||
return_value={"process_stream_chunks": MagicMock(return_value=final)},
|
||||
), patch.object(
|
||||
agent, "_atrigger_after_agent_hook", new_callable=AsyncMock, side_effect=lambda p, r, t: r
|
||||
), patch.object(agent, "_execute_callback_and_display"), patch.object(
|
||||
agent, "_build_messages", return_value=(list(prompt_messages), "Look up x for me")
|
||||
), patch.object(agent, "_persist_message"), patch.object(
|
||||
agent, "_format_tools_for_completion", return_value=[{"type": "function"}]
|
||||
):
|
||||
result = await agent._achat_impl(
|
||||
prompt="Look up x for me",
|
||||
temperature=1.0,
|
||||
tools=[lookup],
|
||||
output_json=None,
|
||||
output_pydantic=None,
|
||||
reasoning_steps=False,
|
||||
stream=False,
|
||||
task_name=None,
|
||||
task_description=None,
|
||||
task_id=None,
|
||||
config=None,
|
||||
force_retrieval=False,
|
||||
skip_retrieval=True,
|
||||
attachments=None,
|
||||
_trace_emitter=MagicMock(),
|
||||
)
|
||||
|
||||
assert create.await_count == 2
|
||||
follow_up = create.await_args_list[1].kwargs["messages"]
|
||||
assert {"role": "user", "content": "Look up x for me"} in follow_up
|
||||
assert result == "final answer"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"follow_up_chunk",
|
||||
[
|
||||
pytest.param(SimpleNamespace(choices=[]), id="empty-choices"),
|
||||
pytest.param(
|
||||
SimpleNamespace(
|
||||
choices=[SimpleNamespace(delta=SimpleNamespace(content=None))]
|
||||
),
|
||||
id="choice-without-content",
|
||||
),
|
||||
pytest.param(
|
||||
SimpleNamespace(
|
||||
choices=[SimpleNamespace(delta=SimpleNamespace(content="\n "))]
|
||||
),
|
||||
id="whitespace-only-content",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_achat_completion_falls_back_when_follow_up_has_no_answer_chunks(follow_up_chunk):
|
||||
"""A follow-up stream with no answer text (only empty-``choices`` chunks,
|
||||
choices without ``delta.content`` or whitespace-only content) must log an error and return the tool
|
||||
results, not an empty string.
|
||||
"""
|
||||
agent = Agent(name="test", instructions="You are helpful", llm="gpt-4o-mini")
|
||||
|
||||
def lookup(query: str) -> str:
|
||||
return "tool output"
|
||||
|
||||
tool_call = SimpleNamespace(
|
||||
id="call-1",
|
||||
function=SimpleNamespace(name="lookup", arguments='{"query": "x"}'),
|
||||
)
|
||||
response = SimpleNamespace(
|
||||
choices=[SimpleNamespace(message=SimpleNamespace(tool_calls=[tool_call]))]
|
||||
)
|
||||
|
||||
async def _stream():
|
||||
yield follow_up_chunk
|
||||
|
||||
create = AsyncMock(return_value=_stream())
|
||||
fake_client = SimpleNamespace(
|
||||
async_client=SimpleNamespace(
|
||||
chat=SimpleNamespace(completions=SimpleNamespace(create=create))
|
||||
)
|
||||
)
|
||||
process_stream_chunks = MagicMock()
|
||||
|
||||
with patch.object(type(agent), "_openai_client", new=fake_client), patch.object(
|
||||
agent, "execute_tool_async", new_callable=AsyncMock, return_value="tool output"
|
||||
), patch(
|
||||
"praisonaiagents.agent.agent._get_llm_functions",
|
||||
return_value={"process_stream_chunks": process_stream_chunks},
|
||||
), patch(
|
||||
"praisonaiagents.agent.chat_mixin._get_display_functions"
|
||||
) as display:
|
||||
result = await agent._achat_completion(response, tools=[lookup])
|
||||
|
||||
display.return_value.__getitem__.return_value.assert_called()
|
||||
process_stream_chunks.assert_not_called()
|
||||
assert result == "tool output"
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Sync OpenAI-client chat must surface BudgetExceededError, not return None."""
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents import Agent, BudgetExceededError, ExecutionConfig
|
||||
|
||||
|
||||
def test_sync_chat_reraises_budget_error(monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-test") # client is built lazily; no request is sent
|
||||
agent = Agent(
|
||||
name="b", instructions="x", llm="gpt-4o-mini", output="silent",
|
||||
execution=ExecutionConfig(max_budget=0.01),
|
||||
)
|
||||
with patch.object(
|
||||
agent, "_chat_completion",
|
||||
side_effect=BudgetExceededError("b", 0.02, 0.01),
|
||||
):
|
||||
with pytest.raises(BudgetExceededError):
|
||||
agent.chat("hello")
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Chat-history integrity for tool turns (Issue #6217)."""
|
||||
import pytest
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.config import MemoryConfig
|
||||
from praisonaiagents.session import DefaultSessionStore
|
||||
|
||||
|
||||
def _tool_history():
|
||||
return [
|
||||
{"role": "user", "content": "weather in Paris?"},
|
||||
{"role": "assistant", "content": None, "tool_calls": [{
|
||||
"id": "call_1", "type": "function",
|
||||
"function": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"},
|
||||
}]},
|
||||
{"role": "tool", "tool_call_id": "call_1", "content": "18C sunny"},
|
||||
{"role": "assistant", "content": "It is 18C and sunny."},
|
||||
]
|
||||
|
||||
|
||||
def _agent(session_id=None, store=None):
|
||||
kwargs = {}
|
||||
if session_id:
|
||||
kwargs["memory"] = MemoryConfig(history=True, auto_save=session_id)
|
||||
a = Agent(name="a", instructions="x", model="gpt-4o-mini", **kwargs)
|
||||
if store is not None:
|
||||
a._session_store = store
|
||||
a._init_session_store()
|
||||
return a
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path):
|
||||
return DefaultSessionStore(session_dir=str(tmp_path))
|
||||
|
||||
|
||||
def _roles(a):
|
||||
return [m["role"] for m in a.chat_history]
|
||||
|
||||
|
||||
def test_auto_save_round_trips_tool_fields(store):
|
||||
a = _agent("sess_rt", store)
|
||||
a.chat_history.extend(_tool_history())
|
||||
a._auto_save_session()
|
||||
|
||||
b = _agent("sess_rt", store)
|
||||
restored = b.chat_history
|
||||
assert restored[1]["tool_calls"][0]["id"] == "call_1"
|
||||
assert restored[2]["tool_call_id"] == "call_1"
|
||||
assert restored[2]["content"] == "18C sunny"
|
||||
|
||||
|
||||
def test_replace_chat_history_resyncs_auto_save_cursor(store):
|
||||
a = _agent("sess_cmp", store)
|
||||
|
||||
def turn(i):
|
||||
a._append_to_chat_history({"role": "user", "content": f"q{i}"})
|
||||
a._append_to_chat_history({"role": "assistant", "content": f"a{i}"})
|
||||
a._auto_save_session()
|
||||
|
||||
for i in range(3):
|
||||
turn(i)
|
||||
a._replace_chat_history([
|
||||
{"role": "system", "content": "summary"},
|
||||
{"role": "assistant", "content": "a2"},
|
||||
])
|
||||
for i in range(3, 6):
|
||||
turn(i)
|
||||
saved = [m["content"] for m in store.get_chat_history("sess_cmp")]
|
||||
assert saved == ["q0", "a0", "q1", "a1", "q2", "a2",
|
||||
"q3", "a3", "q4", "a4", "q5", "a5"]
|
||||
|
||||
|
||||
def test_replace_chat_history_keeps_unsaved_survivors(store):
|
||||
a = _agent("sess_unsaved", store)
|
||||
a._append_to_chat_history({"role": "user", "content": "q0"})
|
||||
a._auto_save_session()
|
||||
pending = {"role": "user", "content": "q1"}
|
||||
a._append_to_chat_history(pending)
|
||||
# Compaction mid-turn keeps the not-yet-saved message in its tail.
|
||||
a._replace_chat_history([{"role": "system", "content": "summary"}, pending])
|
||||
a._auto_save_session()
|
||||
saved = [m["content"] for m in store.get_chat_history("sess_unsaved")]
|
||||
assert saved == ["q0", "q1"]
|
||||
|
||||
|
||||
def test_replace_chat_history_saves_appended_messages(store):
|
||||
# Durable resume appends replayed tool pairs to a fully-saved history.
|
||||
a = _agent("sess_replay", store)
|
||||
a._append_to_chat_history({"role": "user", "content": "q0"})
|
||||
a._auto_save_session()
|
||||
replay = _tool_history()[1:3]
|
||||
a._replace_chat_history(list(a.chat_history) + replay)
|
||||
a._auto_save_session()
|
||||
saved = store.get_chat_history("sess_replay")
|
||||
assert [m["role"] for m in saved] == ["user", "assistant", "tool"]
|
||||
assert saved[2]["tool_call_id"] == "call_1"
|
||||
|
||||
|
||||
def test_prune_history_does_not_orphan_tool_result():
|
||||
a = _agent()
|
||||
a.chat_history.extend(_tool_history())
|
||||
a.prune_history(keep_last=2)
|
||||
assert _roles(a) == ["assistant"]
|
||||
|
||||
|
||||
def test_delete_history_removes_whole_exchange():
|
||||
for idx in (1, 2):
|
||||
a = _agent()
|
||||
a.chat_history.extend(_tool_history())
|
||||
assert a.delete_history(idx) is True
|
||||
assert _roles(a) == ["user", "assistant"]
|
||||
a = _agent()
|
||||
a.chat_history.extend(_tool_history())
|
||||
assert a.delete_history(10) is False
|
||||
assert a.delete_history(-1) is True
|
||||
assert len(a.chat_history) == 3
|
||||
|
||||
|
||||
def test_truncate_does_not_leave_dangling_tool_calls():
|
||||
a = _agent()
|
||||
a.chat_history.extend(_tool_history())
|
||||
a._truncate_chat_history(2)
|
||||
assert _roles(a) == ["user"]
|
||||
a = _agent()
|
||||
a.chat_history.extend(_tool_history())
|
||||
a._truncate_chat_history(3)
|
||||
assert len(a.chat_history) == 3
|
||||
|
||||
|
||||
def test_delete_history_matching_tolerates_none_content():
|
||||
a = _agent()
|
||||
a.chat_history.extend(_tool_history())
|
||||
assert a.delete_history_matching("image") == 0
|
||||
# A hit on the tool result drops its whole exchange.
|
||||
assert a.delete_history_matching("18c sunny") == 2
|
||||
assert _roles(a) == ["user", "assistant"]
|
||||
@@ -0,0 +1,113 @@
|
||||
"""The custom-LLM (``provider/model``) path sends each user turn once.
|
||||
|
||||
``chat()`` stores the user turn in ``chat_history`` before the model call, and
|
||||
``LLM._build_messages`` appends ``prompt`` after ``chat_history`` itself, so
|
||||
the stored copy must not be forwarded as history too. It was, on every
|
||||
litellm route (``ollama/``, ``anthropic/``, ``openai/``, ``base_url=``), which
|
||||
billed the prompt twice. ``litellm.completion`` is stubbed; nothing leaves the
|
||||
machine.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("litellm")
|
||||
|
||||
from praisonaiagents import Agent # noqa: E402
|
||||
|
||||
MODEL = "anthropic/claude-3-5-sonnet-20240620"
|
||||
|
||||
|
||||
def _litellm():
|
||||
import litellm
|
||||
return litellm
|
||||
|
||||
|
||||
def _reply(model, text):
|
||||
return _litellm().ModelResponse(model=model, usage={
|
||||
"prompt_tokens": 10, "completion_tokens": 1, "total_tokens": 11},
|
||||
choices=[{"index": 0, "finish_reason": "stop",
|
||||
"message": {"role": "assistant", "content": text}}])
|
||||
|
||||
|
||||
def _chunks(model, text):
|
||||
litellm = _litellm()
|
||||
yield litellm.ModelResponseStream(model=model, choices=[{
|
||||
"index": 0, "delta": {"role": "assistant", "content": text}}])
|
||||
yield litellm.ModelResponseStream(model=model, choices=[{
|
||||
"index": 0, "delta": {}, "finish_reason": "stop"}])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sent(monkeypatch):
|
||||
"""Record the ``messages`` of every model call; answer "A1", "A2", ..."""
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
|
||||
monkeypatch.setattr(Agent, "_default_model", None)
|
||||
monkeypatch.setattr(Agent, "_default_model_checked", False)
|
||||
requests = []
|
||||
|
||||
def fake(*a, **k):
|
||||
requests.append(k.get("messages"))
|
||||
text = f"A{len(requests)}"
|
||||
if k.get("stream"):
|
||||
return _chunks(k.get("model"), text)
|
||||
return _reply(k.get("model"), text)
|
||||
|
||||
async def afake(*a, **k):
|
||||
return fake(*a, **k)
|
||||
|
||||
litellm = _litellm()
|
||||
monkeypatch.setattr(litellm, "completion", fake)
|
||||
monkeypatch.setattr(litellm, "acompletion", afake)
|
||||
return requests
|
||||
|
||||
|
||||
def _turns(messages):
|
||||
return [(m["role"], m["content"]) for m in messages if m["role"] != "system"]
|
||||
|
||||
|
||||
def _agent():
|
||||
return Agent(name="p", instructions="Be brief.", llm=MODEL, output="silent")
|
||||
|
||||
|
||||
def test_chat_sends_the_prompt_once(sent):
|
||||
agent = _agent()
|
||||
agent.chat("Q1")
|
||||
agent.chat("Q2")
|
||||
assert _turns(sent[0]) == [("user", "Q1")]
|
||||
assert _turns(sent[1]) == [("user", "Q1"), ("assistant", "A1"), ("user", "Q2")]
|
||||
|
||||
|
||||
def test_achat_sends_the_prompt_once(sent):
|
||||
agent = _agent()
|
||||
|
||||
async def run():
|
||||
await agent.achat("Q1")
|
||||
await agent.achat("Q2")
|
||||
|
||||
asyncio.run(run())
|
||||
assert _turns(sent[0]) == [("user", "Q1")]
|
||||
assert _turns(sent[1]) == [("user", "Q1"), ("assistant", "A1"), ("user", "Q2")]
|
||||
|
||||
|
||||
def test_streamed_turn_sends_the_prompt_once(sent):
|
||||
agent = _agent()
|
||||
assert "".join(agent.start("Q1", stream=True)) == "A1"
|
||||
assert _turns(sent[0]) == [("user", "Q1")]
|
||||
|
||||
|
||||
def test_history_still_records_each_turn_once(sent):
|
||||
agent = _agent()
|
||||
agent.chat("Q1")
|
||||
agent.chat("Q2")
|
||||
assert [(m["role"], m["content"]) for m in agent.chat_history] == [
|
||||
("user", "Q1"), ("assistant", "A1"), ("user", "Q2"), ("assistant", "A2")]
|
||||
|
||||
|
||||
def test_repeating_the_last_question_is_still_one_turn(sent):
|
||||
# A user who asks the same thing twice in a row sends it once per request,
|
||||
# with the earlier exchange kept as history.
|
||||
agent = _agent()
|
||||
agent.chat("Q1")
|
||||
agent.chat("Q1")
|
||||
assert _turns(sent[1]) == [("user", "Q1"), ("assistant", "A1"), ("user", "Q1")]
|
||||
@@ -38,7 +38,7 @@ def test_cache_hit_refreshes_current_default_tools(agent, monkeypatch):
|
||||
builds = []
|
||||
monkeypatch.setattr(
|
||||
agent, '_resolve_harness_base_prompt',
|
||||
lambda: builds.append('build') or 'stable prefix',
|
||||
lambda tools=None: builds.append('build') or 'stable prefix',
|
||||
)
|
||||
first = agent._build_system_prompt(tools=None)
|
||||
assert 'sample_tool' in first
|
||||
@@ -73,7 +73,7 @@ def test_cache_hit_refreshes_session_context(agent, monkeypatch):
|
||||
def test_cache_hit_retains_prefix_reuse_and_appends_suffix_once(agent, monkeypatch):
|
||||
"""Reuse the stable prefix while adding the suffix exactly once."""
|
||||
calls = []
|
||||
agent._resolve_harness_base_prompt = lambda: calls.append('build') or 'stable prefix'
|
||||
agent._resolve_harness_base_prompt = lambda tools=None: calls.append('build') or 'stable prefix'
|
||||
monkeypatch.setenv('PRAISONAI_APPEND_SYSTEM_PROMPT', 'per-turn suffix')
|
||||
first = agent._build_system_prompt()
|
||||
second = agent._build_system_prompt()
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""The system prompt carries only guidance that applies to the agent.
|
||||
|
||||
* The model-family file-editing hint ("use edit_file ..." / "use
|
||||
apply_patch ...") is for agents that can edit files; every GPT or Claude
|
||||
agent got it, tools or not.
|
||||
* ``Agent(instructions=...)`` sets both backstory and goal to the
|
||||
instructions, so the prompt repeated them under "Your Goal:".
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.tools.edit_tools import apply_patch, edit_file
|
||||
|
||||
OPENAI_HINT = "use edit_file to modify existing files precisely"
|
||||
ANTHROPIC_HINT = "use apply_patch"
|
||||
|
||||
|
||||
def lookup(query: str) -> str:
|
||||
"""Look something up."""
|
||||
return query
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make(monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-test-not-used")
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test-not-used")
|
||||
made = []
|
||||
|
||||
def _make(**kw):
|
||||
kw.setdefault("instructions", "Be brief.")
|
||||
agent = Agent(context=False, **kw)
|
||||
made.append(agent)
|
||||
return agent
|
||||
|
||||
yield _make
|
||||
for agent in made:
|
||||
agent.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tools", [None, [lookup]])
|
||||
def test_agent_without_edit_tools_gets_no_edit_hint(make, tools):
|
||||
agent = make(llm="gpt-4o-mini", tools=tools)
|
||||
assert OPENAI_HINT not in agent._build_system_prompt()
|
||||
|
||||
|
||||
def test_agent_with_edit_file_gets_the_openai_hint(make):
|
||||
agent = make(llm="gpt-4o-mini", tools=[edit_file, lookup])
|
||||
assert OPENAI_HINT in agent._build_system_prompt()
|
||||
|
||||
|
||||
def test_agent_with_apply_patch_gets_the_anthropic_hint(make):
|
||||
agent = make(llm="anthropic/claude-sonnet-4-5", tools=[apply_patch])
|
||||
assert ANTHROPIC_HINT in agent._build_system_prompt()
|
||||
|
||||
|
||||
def test_edit_tool_named_by_string_counts(make):
|
||||
agent = make(llm="gpt-4o-mini")
|
||||
assert OPENAI_HINT in agent._build_system_prompt(tools=["edit_file"])
|
||||
|
||||
|
||||
def test_per_call_tools_decide_the_hint(make):
|
||||
agent = make(llm="gpt-4o-mini", tools=[edit_file])
|
||||
assert OPENAI_HINT not in agent._build_system_prompt(tools=[lookup])
|
||||
assert OPENAI_HINT in agent._build_system_prompt(tools=[edit_file])
|
||||
|
||||
|
||||
def test_cached_prompt_follows_a_change_to_the_agents_tools(make):
|
||||
agent = make(llm="gpt-4o-mini", tools=[lookup])
|
||||
assert OPENAI_HINT not in agent._build_system_prompt()
|
||||
agent.tools = [edit_file]
|
||||
assert OPENAI_HINT in agent._build_system_prompt()
|
||||
agent.tools = [lookup]
|
||||
assert OPENAI_HINT not in agent._build_system_prompt()
|
||||
|
||||
|
||||
def test_explicit_harness_override_is_always_used(make):
|
||||
agent = make(llm="gpt-4o-mini")
|
||||
agent.harness_base_prompt = "House rules."
|
||||
assert agent._build_system_prompt().startswith("House rules.")
|
||||
|
||||
|
||||
def test_instructions_appear_once(make):
|
||||
agent = make(llm="gpt-4o-mini", instructions="Answer in French.")
|
||||
prompt = agent._build_system_prompt()
|
||||
assert prompt.count("Answer in French.") == 1
|
||||
assert "Your Goal:" not in prompt
|
||||
|
||||
|
||||
def test_a_distinct_goal_is_still_stated(make):
|
||||
agent = make(llm="gpt-4o-mini", instructions=None, role="Editor",
|
||||
goal="Tighten the copy", backstory="A veteran sub-editor")
|
||||
prompt = agent._build_system_prompt()
|
||||
assert "Your Goal: Tighten the copy" in prompt
|
||||
assert "A veteran sub-editor" in prompt
|
||||
assert "Your Role: Editor" in prompt
|
||||
|
||||
|
||||
def test_a_tool_renamed_to_edit_file_counts(make):
|
||||
from praisonaiagents import tool
|
||||
|
||||
@tool(name="edit_file")
|
||||
def my_editor(filepath: str, old_string: str, new_string: str) -> str:
|
||||
"""Edit a file."""
|
||||
return "ok"
|
||||
|
||||
agent = make(llm="gpt-4o-mini", tools=[my_editor])
|
||||
assert OPENAI_HINT in agent._build_system_prompt()
|
||||
|
||||
|
||||
def test_nested_tool_list_offers_the_edit_hint(make):
|
||||
# _format_tools_for_completion flattens nested lists of tool dicts.
|
||||
agent = make(llm="gpt-4o-mini", tools=[[EDIT_SCHEMA]])
|
||||
assert OPENAI_HINT in agent._build_system_prompt()
|
||||
|
||||
|
||||
def test_nested_names_the_formatter_drops_get_no_hint(make):
|
||||
# A nested string is not sent to the model, so neither is the hint.
|
||||
agent = make(llm="gpt-4o-mini", tools=[["edit_file"]])
|
||||
assert OPENAI_HINT not in agent._build_system_prompt()
|
||||
|
||||
|
||||
def test_nested_bare_name_dict_gets_no_hint(make):
|
||||
# A nested dict without a function schema is dropped by the formatter.
|
||||
agent = make(llm="gpt-4o-mini", tools=[[{"name": "edit_file"}]])
|
||||
assert agent._format_tools_for_completion() == []
|
||||
assert OPENAI_HINT not in agent._build_system_prompt()
|
||||
|
||||
|
||||
EDIT_SCHEMA = {"type": "function", "function": {
|
||||
"name": "edit_file", "description": "Edit a file.",
|
||||
"parameters": {"type": "object", "properties": {}}}}
|
||||
|
||||
|
||||
def _system_sent(agent, monkeypatch, run):
|
||||
"""Run ``run(agent)`` on the native path; return the system prompt and tool names sent."""
|
||||
from types import SimpleNamespace
|
||||
sent = []
|
||||
sent_tools = []
|
||||
|
||||
def fake(messages=None, temperature=None, tools=None, *a, **k):
|
||||
sent.append(messages if messages is not None else k.get("messages"))
|
||||
# Record the payload the real request builds (inherit, flatten, prune).
|
||||
formatted = agent._format_tools_for_completion(tools)
|
||||
sent_tools.append([t.get("function", {}).get("name") for t in formatted])
|
||||
message = SimpleNamespace(content="ok", tool_calls=None, role="assistant", reasoning_content=None)
|
||||
return SimpleNamespace(choices=[SimpleNamespace(message=message, finish_reason="stop", index=0)],
|
||||
usage=SimpleNamespace(prompt_tokens=1, completion_tokens=1, total_tokens=2),
|
||||
model="gpt-4o-mini")
|
||||
|
||||
async def afake(*a, **k):
|
||||
return fake(*a, **k)
|
||||
|
||||
monkeypatch.setattr(agent, "_chat_completion", fake)
|
||||
monkeypatch.setattr(agent, "_achat_completion_with_retry", afake)
|
||||
run(agent)
|
||||
return next(m["content"] for m in sent[-1] if m["role"] == "system"), sent_tools[-1]
|
||||
|
||||
|
||||
def test_native_chat_follows_per_call_edit_tools(make, monkeypatch):
|
||||
agent = make(llm="gpt-4o-mini")
|
||||
system, tool_names = _system_sent(agent, monkeypatch, lambda a: a.chat("hi", tools=[EDIT_SCHEMA]))
|
||||
assert OPENAI_HINT in system
|
||||
assert "edit_file" in tool_names
|
||||
|
||||
|
||||
def test_nested_edit_schema_is_sent_with_the_hint(make, monkeypatch):
|
||||
agent = make(llm="gpt-4o-mini", tools=[[EDIT_SCHEMA]])
|
||||
system, tool_names = _system_sent(agent, monkeypatch, lambda a: a.chat("hi"))
|
||||
assert OPENAI_HINT in system
|
||||
assert "edit_file" in tool_names
|
||||
|
||||
|
||||
def test_permission_denied_edit_tool_gets_no_hint(make, monkeypatch):
|
||||
agent = make(llm="gpt-4o-mini", tools=[edit_file, lookup])
|
||||
agent._perm_deny = frozenset({"edit_file"})
|
||||
system, tool_names = _system_sent(agent, monkeypatch, lambda a: a.chat("hi"))
|
||||
assert "edit_file" not in tool_names
|
||||
assert OPENAI_HINT not in system
|
||||
|
||||
|
||||
def test_deny_added_after_caching_prunes_cached_tools(make):
|
||||
agent = make(llm="gpt-4o-mini", tools=[edit_file, lookup])
|
||||
assert "edit_file" in [t["function"]["name"] for t in agent._format_tools_for_completion()]
|
||||
agent._perm_deny = frozenset({"edit_file"})
|
||||
assert "edit_file" not in [t["function"]["name"] for t in agent._format_tools_for_completion()]
|
||||
assert OPENAI_HINT not in agent._build_system_prompt()
|
||||
# Lifting the denial restores the tool from the same cache entry.
|
||||
agent._perm_deny = frozenset()
|
||||
assert "edit_file" in [t["function"]["name"] for t in agent._format_tools_for_completion()]
|
||||
|
||||
|
||||
def test_deny_before_first_format_restores_after_lift(make):
|
||||
agent = make(llm="gpt-4o-mini", tools=[edit_file, lookup])
|
||||
agent._perm_deny = frozenset({"edit_file"})
|
||||
assert "edit_file" not in [t["function"]["name"] for t in agent._format_tools_for_completion()]
|
||||
agent._perm_deny = frozenset()
|
||||
assert "edit_file" in [t["function"]["name"] for t in agent._format_tools_for_completion()]
|
||||
|
||||
|
||||
def test_bridge_tool_search_hides_denied_tools(make):
|
||||
agent = make(llm="gpt-4o-mini")
|
||||
agent._tool_search_config = None
|
||||
agent._tool_search_metadata = {"bridge_mode": True, "deferrable_tools": [EDIT_SCHEMA]}
|
||||
agent._perm_deny = frozenset({"edit_file"})
|
||||
out = agent._handle_bridge_tool_call("tool_describe", {"tool_name": "edit_file"})
|
||||
assert "not found" in out
|
||||
agent._perm_deny = frozenset()
|
||||
assert "not found" not in agent._handle_bridge_tool_call("tool_describe", {"tool_name": "edit_file"})
|
||||
|
||||
|
||||
def test_native_achat_follows_per_call_edit_tools(make, monkeypatch):
|
||||
import asyncio
|
||||
agent = make(llm="gpt-4o-mini")
|
||||
system, tool_names = _system_sent(agent, monkeypatch, lambda a: asyncio.run(a.achat("hi", tools=[EDIT_SCHEMA])))
|
||||
assert OPENAI_HINT in system
|
||||
assert "edit_file" in tool_names
|
||||
|
||||
|
||||
def test_system_prompt_attribute_states_instructions_once(make):
|
||||
agent = make(llm="gpt-4o-mini", instructions="Answer in French.")
|
||||
assert agent.system_prompt.count("Answer in French.") == 1
|
||||
|
||||
|
||||
def test_cached_prompt_follows_a_backstory_change(make):
|
||||
agent = make(llm="gpt-4o-mini", instructions="Answer in French.")
|
||||
assert "Your Goal:" not in agent._build_system_prompt()
|
||||
agent.backstory = "A translator"
|
||||
prompt = agent._build_system_prompt()
|
||||
assert "A translator" in prompt
|
||||
assert "Your Goal: Answer in French." in prompt
|
||||
+5
-1
@@ -32,6 +32,7 @@ def _agent_with_history(messages: int, words: int):
|
||||
|
||||
def fake_stream(**kwargs):
|
||||
sent["history"] = list(kwargs.get("chat_history") or [])
|
||||
sent["prompt"] = kwargs.get("prompt")
|
||||
yield "ok"
|
||||
|
||||
agent._using_custom_llm = True
|
||||
@@ -60,7 +61,10 @@ class TestCustomLlmStreamCompaction:
|
||||
"""Compaction must not fire when it is not needed."""
|
||||
agent, sent = _agent_with_history(6, 5)
|
||||
history = _drain(agent, sent)
|
||||
assert len(history) == 7, "a short history was compacted unnecessarily"
|
||||
# The six earlier turns go as history; this turn goes once, as the prompt.
|
||||
assert len(history) == 6, "a short history was compacted unnecessarily"
|
||||
assert sent["prompt"] == "hello"
|
||||
assert all(m["content"] != "hello" for m in history)
|
||||
|
||||
def test_the_context_manager_is_actually_configured_here(self):
|
||||
"""Guards the premise: tools present auto-enable context management."""
|
||||
|
||||
@@ -173,7 +173,7 @@ class TestGoalLoopGate:
|
||||
verdicts = iter([("continue", "not yet"), ("done", "goal met")])
|
||||
|
||||
with patch.object(agent, "chat", side_effect=_unique_chat()), \
|
||||
patch("praisonaiagents.goal.loop.judge_goal",
|
||||
patch("praisonaiagents.goal.judge.judge_goal",
|
||||
side_effect=lambda *a, **k: next(verdicts)):
|
||||
result = agent.run_goal("do the task", goal="finish it", max_turns=10)
|
||||
|
||||
@@ -193,7 +193,7 @@ class TestGoalLoopGate:
|
||||
return f"still working {counter['n']}"
|
||||
|
||||
with patch.object(agent, "chat", side_effect=fake_chat), \
|
||||
patch("praisonaiagents.goal.loop.judge_goal",
|
||||
patch("praisonaiagents.goal.judge.judge_goal",
|
||||
side_effect=lambda *a, **k: next(verdicts)):
|
||||
result = agent.run_goal("task", goal="g", max_turns=10)
|
||||
|
||||
@@ -205,7 +205,7 @@ class TestGoalLoopGate:
|
||||
def test_budget_exhaust_pauses_recoverably(self):
|
||||
agent = _make_agent()
|
||||
with patch.object(agent, "chat", side_effect=_unique_chat()), \
|
||||
patch("praisonaiagents.goal.loop.judge_goal",
|
||||
patch("praisonaiagents.goal.judge.judge_goal",
|
||||
return_value=("continue", "never done")):
|
||||
result = agent.run_goal("task", goal="g", max_turns=3)
|
||||
|
||||
@@ -218,7 +218,7 @@ class TestGoalLoopGate:
|
||||
agent = _make_agent()
|
||||
# judge_goal itself fails open to ("continue", "judge unavailable")
|
||||
with patch.object(agent, "chat", side_effect=_unique_chat()), \
|
||||
patch("praisonaiagents.goal.loop.judge_goal",
|
||||
patch("praisonaiagents.goal.judge.judge_goal",
|
||||
return_value=("continue", "judge unavailable")):
|
||||
result = agent.run_goal("task", goal="g", max_turns=2)
|
||||
assert result.completion_reason == "budget_paused"
|
||||
@@ -226,7 +226,7 @@ class TestGoalLoopGate:
|
||||
def test_consecutive_parse_failures_autopause(self):
|
||||
agent = _make_agent()
|
||||
with patch.object(agent, "chat", side_effect=_unique_chat()), \
|
||||
patch("praisonaiagents.goal.loop.judge_goal",
|
||||
patch("praisonaiagents.goal.judge.judge_goal",
|
||||
return_value=("continue", "judge response unparseable")):
|
||||
result = agent.run_goal("task", goal="g", max_turns=20)
|
||||
# auto-pause after 3 consecutive parse failures, well before max_turns
|
||||
@@ -259,7 +259,7 @@ class TestConstraintViolation:
|
||||
)
|
||||
# Simulate a judge that respects constraints: constraint violated → continue
|
||||
with patch.object(agent, "chat", side_effect=_unique_chat()), \
|
||||
patch("praisonaiagents.goal.loop.judge_goal",
|
||||
patch("praisonaiagents.goal.judge.judge_goal",
|
||||
return_value=("continue", "constraint violated")):
|
||||
result = agent.run_goal("task", goal="g", criteria=criteria, max_turns=2)
|
||||
assert result.completion_reason == "budget_paused"
|
||||
@@ -282,7 +282,7 @@ class TestAsyncGoalGate:
|
||||
state = GoalState(goal="g", max_turns=5)
|
||||
state.status = "active"
|
||||
agent._goal_state = state
|
||||
with patch("praisonaiagents.goal.loop.judge_goal",
|
||||
with patch("praisonaiagents.goal.judge.judge_goal",
|
||||
return_value=("done", "goal met")):
|
||||
outcome = asyncio.run(agent._goal_gate_async("final response"))
|
||||
assert outcome == ("done", "goal met")
|
||||
@@ -314,7 +314,7 @@ class TestAsyncGoalGate:
|
||||
return ("done", "eventually met")
|
||||
|
||||
async def scenario():
|
||||
with patch("praisonaiagents.goal.loop.judge_goal",
|
||||
with patch("praisonaiagents.goal.judge.judge_goal",
|
||||
side_effect=slow_judge):
|
||||
task = asyncio.ensure_future(agent._goal_gate_async("resp"))
|
||||
await asyncio.to_thread(started.wait, 2.0)
|
||||
@@ -330,7 +330,7 @@ class TestAsyncGoalGate:
|
||||
def test_run_until_with_goal_delegates(self):
|
||||
agent = _make_agent()
|
||||
with patch.object(agent, "chat", side_effect=_unique_chat()), \
|
||||
patch("praisonaiagents.goal.loop.judge_goal",
|
||||
patch("praisonaiagents.goal.judge.judge_goal",
|
||||
return_value=("done", "met")):
|
||||
result = agent.run_until("task", goal="reach the goal", max_iterations=5)
|
||||
assert result.completion_reason == "goal_met"
|
||||
@@ -7,7 +7,7 @@ at ``turns_used=0``. A crashed run (still ``active``) was also never resumed.
|
||||
|
||||
import pytest
|
||||
|
||||
import praisonaiagents.goal.loop as goal_loop
|
||||
import praisonaiagents.goal.judge as goal_judge
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.goal.models import GoalState
|
||||
from praisonaiagents.model_harness import ScriptedModel
|
||||
@@ -20,7 +20,7 @@ GOAL = "all tests pass"
|
||||
def _isolated_home(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("PRAISONAI_HOME", str(tmp_path / ".praisonai"))
|
||||
monkeypatch.setattr(
|
||||
goal_loop, "judge_goal", lambda state, *a, **k: ("continue", "not yet")
|
||||
goal_judge, "judge_goal", lambda state, *a, **k: ("continue", "not yet")
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -228,7 +228,7 @@ class TestGoalLoopEvidence:
|
||||
|
||||
with patch.object(agent, "chat",
|
||||
side_effect=lambda p: f"step {counter.__setitem__('n', counter['n'] + 1) or counter['n']}"), \
|
||||
patch("praisonaiagents.goal.loop.judge_goal", side_effect=fake_judge):
|
||||
patch("praisonaiagents.goal.judge.judge_goal", side_effect=fake_judge):
|
||||
result = agent.run_goal("task", goal="g", max_turns=2)
|
||||
|
||||
# Blocking hook fails every turn → judge never consulted, ends paused.
|
||||
@@ -247,7 +247,7 @@ class TestGoalLoopEvidence:
|
||||
return ("done", "met")
|
||||
|
||||
with patch.object(agent, "chat", side_effect=lambda pr: "did it"), \
|
||||
patch("praisonaiagents.goal.loop.judge_goal", side_effect=fake_judge):
|
||||
patch("praisonaiagents.goal.judge.judge_goal", side_effect=fake_judge):
|
||||
result = agent.run_goal("task", goal="g", max_turns=5)
|
||||
|
||||
assert result.completion_reason == "goal_met"
|
||||
@@ -283,7 +283,7 @@ class TestGoalLoopEvidence:
|
||||
return "trying"
|
||||
|
||||
with patch.object(agent, "chat", side_effect=fake_chat), \
|
||||
patch("praisonaiagents.goal.loop.judge_goal",
|
||||
patch("praisonaiagents.goal.judge.judge_goal",
|
||||
side_effect=lambda *a, **k: ("done", "met")):
|
||||
result = agent.run_goal("task", goal="g", max_turns=2)
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""reasoning_effort must reach the provider on the Responses API path too.
|
||||
|
||||
OpenAI reasoning models (o1/o3/o4, gpt-5 on the native client) are sent to the
|
||||
Responses API by default. That API takes the level as
|
||||
``reasoning: {"effort": ...}``; neither Responses request builder applied the
|
||||
unified ``reasoning_effort`` (Issue #4452), so ``Agent(llm="o4-mini",
|
||||
reasoning_effort="high")`` produced a request without any effort on the
|
||||
default, non-streaming path. These tests assert on what the transport receives.
|
||||
"""
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("OPENAI_API_KEY", "sk-test-not-real")
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
from praisonaiagents.llm.openai_client import OpenAIClient
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_responses_endpoint(monkeypatch):
|
||||
"""The Responses path is only taken without a custom endpoint."""
|
||||
monkeypatch.delenv('OPENAI_API_BASE', raising=False)
|
||||
monkeypatch.delenv('OPENAI_BASE_URL', raising=False)
|
||||
|
||||
|
||||
def _response(text='ok'):
|
||||
return {'status': 'completed', 'output': [
|
||||
{'type': 'message', 'content': [{'type': 'output_text', 'text': text}]}]}
|
||||
|
||||
|
||||
MESSAGES = [{'role': 'user', 'content': 'question'}]
|
||||
|
||||
|
||||
class TestLLMResponsesParams:
|
||||
|
||||
@pytest.mark.parametrize('model', ['o4-mini', 'openai/o3', 'azure/o1'])
|
||||
def test_instance_effort_becomes_reasoning_effort_object(self, model):
|
||||
params = LLM(model=model, reasoning_effort='high')._build_responses_params(MESSAGES)
|
||||
assert params['reasoning'] == {'effort': 'high'}
|
||||
assert 'reasoning_effort' not in params
|
||||
|
||||
def test_per_call_effort_overrides_instance(self):
|
||||
llm = LLM(model='o4-mini', reasoning_effort='low')
|
||||
params = llm._build_responses_params(MESSAGES, reasoning_effort='high')
|
||||
assert params['reasoning'] == {'effort': 'high'}
|
||||
assert 'reasoning_effort' not in params
|
||||
|
||||
@pytest.mark.parametrize('effort', [None, 'off'])
|
||||
def test_unset_or_off_sends_nothing(self, effort):
|
||||
params = LLM(model='o4-mini', reasoning_effort=effort)._build_responses_params(MESSAGES)
|
||||
assert 'reasoning' not in params
|
||||
|
||||
def test_model_without_reasoning_control_is_untouched(self):
|
||||
params = LLM(model='gpt-4o-mini', reasoning_effort='high')._build_responses_params(MESSAGES)
|
||||
assert 'reasoning' not in params
|
||||
assert 'reasoning_effort' not in params
|
||||
|
||||
def test_explicit_reasoning_object_is_kept(self):
|
||||
llm = LLM(model='o4-mini', reasoning_effort='high')
|
||||
explicit = {'effort': 'low', 'summary': 'auto'}
|
||||
params = llm._build_responses_params(MESSAGES, reasoning=explicit)
|
||||
assert params['reasoning'] == explicit
|
||||
|
||||
def test_public_get_response_sends_effort(self):
|
||||
llm = LLM(model='o4-mini', reasoning_effort='high')
|
||||
requests = []
|
||||
|
||||
def respond(**kwargs):
|
||||
requests.append(kwargs)
|
||||
return _response()
|
||||
|
||||
llm._call_responses_api = respond
|
||||
assert llm.get_response('question', stream=False, verbose=False) == 'ok'
|
||||
assert requests[0]['reasoning'] == {'effort': 'high'}
|
||||
|
||||
|
||||
class TestOpenAIClientResponsesInput:
|
||||
|
||||
def test_reasoning_effort_kwarg_becomes_reasoning_object(self):
|
||||
client = OpenAIClient.__new__(OpenAIClient)
|
||||
params = client._build_responses_input(MESSAGES, 'o4-mini', None, None, reasoning_effort='high')
|
||||
assert params['reasoning'] == {'effort': 'high'}
|
||||
assert 'reasoning_effort' not in params
|
||||
|
||||
def test_no_effort_no_reasoning_object(self):
|
||||
client = OpenAIClient.__new__(OpenAIClient)
|
||||
params = client._build_responses_input(MESSAGES, 'o4-mini', None, None)
|
||||
assert 'reasoning' not in params
|
||||
|
||||
|
||||
def _agent_request(model, **agent_kwargs):
|
||||
"""Drive a real Agent on the native OpenAI client, faking only responses.create."""
|
||||
requests = []
|
||||
|
||||
def respond(**kwargs):
|
||||
requests.append(kwargs)
|
||||
return _response()
|
||||
|
||||
async def arespond(**kwargs):
|
||||
return respond(**kwargs)
|
||||
|
||||
client = OpenAIClient(api_key='sk-test-not-real')
|
||||
client._sync_client = SimpleNamespace(responses=SimpleNamespace(create=respond))
|
||||
client._async_client = SimpleNamespace(responses=SimpleNamespace(create=arespond))
|
||||
agent = Agent(name='t', instructions='x', llm=model, **agent_kwargs)
|
||||
agent._Agent__openai_client = client
|
||||
assert agent.start('hi') == 'ok'
|
||||
assert requests, 'no Responses API request was made'
|
||||
return requests[-1]
|
||||
|
||||
|
||||
class TestAgentNativeOpenAIPath:
|
||||
|
||||
def test_reasoning_model_gets_effort(self):
|
||||
assert _agent_request('o4-mini', reasoning_effort='high')['reasoning'] == {'effort': 'high'}
|
||||
|
||||
def test_unset_effort_sends_nothing(self):
|
||||
assert 'reasoning' not in _agent_request('o4-mini')
|
||||
|
||||
def test_model_without_reasoning_control_is_untouched(self):
|
||||
assert 'reasoning' not in _agent_request('gpt-4o-mini', reasoning_effort='high')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Headless and concurrent OAuth callback handling (issue #6179)."""
|
||||
|
||||
import threading
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def provider_env(tmp_path, monkeypatch):
|
||||
from praisonaiagents.mcp import oauth_provider
|
||||
from praisonaiagents.mcp.mcp_auth_storage import MCPAuthStorage
|
||||
|
||||
started = []
|
||||
|
||||
class FakeServer:
|
||||
def serve_forever(self):
|
||||
pass
|
||||
|
||||
def shutdown(self):
|
||||
pass
|
||||
|
||||
def server_close(self):
|
||||
pass
|
||||
|
||||
def fake_make(handler, port):
|
||||
started.append(handler)
|
||||
return FakeServer()
|
||||
|
||||
monkeypatch.setattr(oauth_provider, "_make_callback_server", fake_make)
|
||||
monkeypatch.setattr(
|
||||
oauth_provider.MCPOAuthProvider,
|
||||
"_exchange_code",
|
||||
lambda self, m, c, code, v, timeout: {"access_token": f"tok-{code}"},
|
||||
)
|
||||
storage = MCPAuthStorage(filepath=str(tmp_path / "auth.json"))
|
||||
return oauth_provider, storage, started
|
||||
|
||||
|
||||
METADATA = {
|
||||
"authorization_endpoint": "https://auth.example.com/authorize",
|
||||
"token_endpoint": "https://auth.example.com/token",
|
||||
}
|
||||
|
||||
|
||||
def _state_of(url):
|
||||
return parse_qs(urlparse(url).query)["state"][0]
|
||||
|
||||
|
||||
def test_concurrent_flows_share_one_callback_server(provider_env, monkeypatch):
|
||||
oauth_provider, storage, started = provider_env
|
||||
urls = []
|
||||
both_opened = threading.Barrier(2)
|
||||
|
||||
def fake_open(url):
|
||||
urls.append((threading.current_thread().name, url))
|
||||
both_opened.wait(timeout=5)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(oauth_provider.webbrowser, "open", fake_open)
|
||||
|
||||
results = {}
|
||||
|
||||
def run(name):
|
||||
p = oauth_provider.MCPOAuthProvider(name, f"https://{name}.example/mcp", storage=storage)
|
||||
results[name] = p._run_pkce_flow(METADATA, {"client_id": "c"}, None, timeout=5)
|
||||
|
||||
threads = [threading.Thread(target=run, args=(n,), name=n) for n in ("a", "b")]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for _ in range(100):
|
||||
if len(urls) == 2:
|
||||
break
|
||||
threading.Event().wait(0.05)
|
||||
assert len(started) == 1 # single bind, no port collision
|
||||
handler = started[0]
|
||||
for name, url in urls:
|
||||
handler.receive_callback(_state_of(url), f"code-{name}")
|
||||
for t in threads:
|
||||
t.join(timeout=5)
|
||||
|
||||
# Each flow must get the code sent for *its* state, not just any code.
|
||||
assert results == {"a": "tok-code-a", "b": "tok-code-b"}
|
||||
assert oauth_provider._callback_server is None # released after last flow
|
||||
|
||||
|
||||
def test_headless_prints_url_and_never_reads_stdin(provider_env, monkeypatch, capsys):
|
||||
"""Headless consent completes via the loopback callback; host stdin is untouched."""
|
||||
oauth_provider, storage, started = provider_env
|
||||
monkeypatch.setattr(oauth_provider.webbrowser, "open", lambda url: False)
|
||||
|
||||
def no_input(prompt=""):
|
||||
raise AssertionError("library must not read the host's stdin")
|
||||
|
||||
monkeypatch.setattr("builtins.input", no_input)
|
||||
|
||||
def deliver():
|
||||
# Simulates an SSH-forwarded browser or `curl <redirect>` on the host.
|
||||
for _ in range(100):
|
||||
err = capsys.readouterr().err
|
||||
if "Open this URL" in err:
|
||||
auth_url = err.split("\n")[1].strip()
|
||||
started[0].receive_callback(_state_of(auth_url), "xyz")
|
||||
return
|
||||
threading.Event().wait(0.05)
|
||||
|
||||
t = threading.Thread(target=deliver)
|
||||
t.start()
|
||||
p = oauth_provider.MCPOAuthProvider("n", "https://n.example/mcp", storage=storage)
|
||||
assert p._run_pkce_flow(METADATA, {"client_id": "c"}, None, timeout=5) == "tok-xyz"
|
||||
t.join(timeout=5)
|
||||
assert oauth_provider._callback_server is None
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Automatic promotion must honor the same capacity as explicit long-term writes."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from praisonaiagents.memory.file_memory import FileMemory
|
||||
|
||||
|
||||
@pytest.mark.parametrize("limit", [1, 2, 3])
|
||||
def test_promotion_enforces_persisted_long_term_limit(tmp_path, limit):
|
||||
memory = FileMemory(
|
||||
base_path=str(tmp_path),
|
||||
config={"short_term_limit": 2, "long_term_limit": limit},
|
||||
)
|
||||
for index in range(6):
|
||||
memory.add_short_term(f"Important fact {index}", importance=0.8)
|
||||
assert len(memory.get_long_term()) == limit
|
||||
assert len(FileMemory(base_path=str(tmp_path)).get_long_term()) == limit
|
||||
assert len(memory.get_short_term()) == 2
|
||||
|
||||
|
||||
def test_promotion_keeps_highest_importance_and_newest_ties(tmp_path):
|
||||
memory = FileMemory(
|
||||
base_path=str(tmp_path),
|
||||
config={"short_term_limit": 3, "long_term_limit": 2},
|
||||
)
|
||||
memory.add_long_term("Keep strongest", importance=1.0)
|
||||
memory.add_long_term("Older tie", importance=0.8)
|
||||
memory.add_short_term("Lower priority", importance=0.7)
|
||||
memory.add_short_term("Newer tie", importance=0.8, metadata={"source": "fixture"})
|
||||
memory.add_short_term("Below promotion threshold", importance=0.2)
|
||||
for path, timestamp in [(memory.long_term_file, 100), (memory.short_term_file, 200)]:
|
||||
records = json.loads(path.read_text())
|
||||
for record in records:
|
||||
record["created_at"] = timestamp
|
||||
path.write_text(json.dumps(records))
|
||||
promoted_before = next(
|
||||
item for item in json.loads(memory.short_term_file.read_text())
|
||||
if item["content"] == "Newer tie"
|
||||
)
|
||||
memory.add_short_term("Trigger overflow", importance=0.2)
|
||||
contents = {item.content for item in memory.get_long_term()}
|
||||
assert contents == {"Keep strongest", "Newer tie"}
|
||||
promoted_after = next(item for item in memory.get_long_term() if item.content == "Newer tie")
|
||||
assert promoted_after.to_dict() == promoted_before
|
||||
assert {item.content for item in memory.get_short_term()} == {
|
||||
"Newer tie", "Below promotion threshold", "Trigger overflow",
|
||||
}
|
||||
assert {item.content for item in FileMemory(base_path=str(tmp_path)).get_long_term()} == contents
|
||||
|
||||
|
||||
def test_disabled_promotion_preserves_existing_long_term(tmp_path):
|
||||
memory = FileMemory(
|
||||
base_path=str(tmp_path),
|
||||
config={"short_term_limit": 1, "long_term_limit": 1, "auto_promote": False},
|
||||
)
|
||||
memory.add_long_term("Existing fact")
|
||||
for index in range(3):
|
||||
memory.add_short_term(f"Unpromoted {index}", importance=1.0)
|
||||
assert [item.content for item in memory.get_long_term()] == ["Existing fact"]
|
||||
|
||||
|
||||
def test_promotion_zero_capacity_matches_explicit_long_term(tmp_path):
|
||||
memory = FileMemory(
|
||||
base_path=str(tmp_path),
|
||||
config={"short_term_limit": 1, "long_term_limit": 0},
|
||||
)
|
||||
memory.add_long_term("Explicit fact", importance=1.0)
|
||||
assert memory.get_long_term() == []
|
||||
memory.add_short_term("Promoted one", importance=1.0)
|
||||
memory.add_short_term("Promoted two", importance=1.0)
|
||||
assert memory.get_long_term() == []
|
||||
assert FileMemory(base_path=str(tmp_path)).get_long_term() == []
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Tests for the session manifest (Issue #6248)."""
|
||||
|
||||
from praisonaiagents.session import FileSessionManifest, SessionManifestProtocol
|
||||
|
||||
|
||||
def test_satisfies_protocol(tmp_path):
|
||||
assert isinstance(FileSessionManifest(str(tmp_path)), SessionManifestProtocol)
|
||||
|
||||
|
||||
def test_missing_session_has_no_entry(tmp_path):
|
||||
m = FileSessionManifest(str(tmp_path))
|
||||
assert m.get("missing") is None and m.interrupted("missing") is None
|
||||
|
||||
|
||||
def test_legacy_entry_with_unknown_keys_still_loads(tmp_path):
|
||||
(tmp_path / "s1.json").write_text(
|
||||
'{"session_id": "s1", "refs": {"plan": "p"}, "run_in_flight": true}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert FileSessionManifest(str(tmp_path)).interrupted("s1") is not None
|
||||
|
||||
|
||||
def test_completed_run_is_not_interrupted(tmp_path):
|
||||
m = FileSessionManifest(str(tmp_path))
|
||||
m.begin_run("s1", "r1", run_input="task")
|
||||
assert m.interrupted("s1") is not None
|
||||
m.end_run("s1", "completed")
|
||||
assert m.interrupted("s1") is None
|
||||
assert m.get("s1").run_status == "completed"
|
||||
|
||||
|
||||
def test_interrupted_run_survives_new_process(tmp_path):
|
||||
FileSessionManifest(str(tmp_path)).begin_run("s1", "r1", run_input="task")
|
||||
# A new process (new instance) sees the surviving write-ahead marker.
|
||||
stale = FileSessionManifest(str(tmp_path)).interrupted("s1")
|
||||
assert stale is not None and stale.run_id == "r1" and stale.run_digest
|
||||
|
||||
|
||||
def test_attempt_budget_is_bounded(tmp_path):
|
||||
m = FileSessionManifest(str(tmp_path))
|
||||
attempts = [m.begin_run("s1", f"r{i}", run_input="task", max_attempts=2).attempt
|
||||
for i in range(4)]
|
||||
# Retries of the same interrupted task count up, then the budget resets.
|
||||
assert attempts == [1, 2, 1, 2]
|
||||
|
||||
|
||||
def test_new_task_after_interruption_is_a_fresh_attempt(tmp_path):
|
||||
m = FileSessionManifest(str(tmp_path))
|
||||
m.begin_run("s1", "r1", run_input="task a")
|
||||
assert m.begin_run("s1", "r2", run_input="task b").attempt == 1
|
||||
|
||||
|
||||
def test_unsafe_session_id_stays_in_directory(tmp_path):
|
||||
m = FileSessionManifest(str(tmp_path))
|
||||
m.begin_run("../../etc/x", "r1")
|
||||
assert all(p.parent == tmp_path for p in tmp_path.iterdir())
|
||||
assert m.get("../../etc/x").run_id == "r1"
|
||||
|
||||
|
||||
def test_run_input_is_never_written_in_plaintext(tmp_path):
|
||||
secret = "deploy with token sk-SECRET-12345"
|
||||
m = FileSessionManifest(str(tmp_path))
|
||||
m.begin_run("s1", "r1", run_input=secret)
|
||||
for f in tmp_path.iterdir():
|
||||
assert "sk-SECRET-12345" not in f.read_text(encoding="utf-8")
|
||||
# The digest still recognises a retry of the same task.
|
||||
assert m.begin_run("s1", "r2", run_input=secret).attempt == 2
|
||||
|
||||
|
||||
def test_absolute_and_backslash_ids_stay_in_directory(tmp_path):
|
||||
m = FileSessionManifest(str(tmp_path))
|
||||
for sid in ("/etc/passwd", "..\\..\\x", "..", "C:\\x"):
|
||||
m.begin_run(sid, sid)
|
||||
assert m.get(sid).run_id == sid
|
||||
assert all(p.parent == tmp_path for p in tmp_path.iterdir())
|
||||
|
||||
|
||||
def test_ids_differing_only_in_punctuation_stay_separate(tmp_path):
|
||||
m = FileSessionManifest(str(tmp_path))
|
||||
m.begin_run("a:b", "colon")
|
||||
m.begin_run("a_b", "underscore")
|
||||
assert m.get("a:b").run_id == "colon"
|
||||
assert m.get("a_b").run_id == "underscore"
|
||||
|
||||
|
||||
def test_end_run_only_releases_its_own_marker(tmp_path):
|
||||
m = FileSessionManifest(str(tmp_path))
|
||||
m.begin_run("s1", "r1", run_input="first")
|
||||
m.begin_run("s1", "r2", run_input="second") # second terminal takes over
|
||||
assert m.end_run("s1", "completed", run_id="r1") is None
|
||||
stale = m.interrupted("s1")
|
||||
assert stale is not None and stale.run_id == "r2"
|
||||
m.end_run("s1", "completed", run_id="r2")
|
||||
assert m.interrupted("s1") is None
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Tests for A2A message/stream lifecycle (issue #6151)."""
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.ui.a2a.streaming import stream_agent_response
|
||||
from praisonaiagents.ui.a2a.task_store import TaskStore
|
||||
from praisonaiagents.ui.a2a.types import Message, Role, TaskState, TextPart
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _restore_event_loop():
|
||||
# asyncio.run() leaves no current loop; later tests using
|
||||
# get_event_loop() would break.
|
||||
yield
|
||||
asyncio.set_event_loop(asyncio.new_event_loop())
|
||||
|
||||
|
||||
def _new_task(store):
|
||||
msg = Message(message_id="m1", role=Role.USER, parts=[TextPart(text="hi")])
|
||||
return store.create_task(msg)
|
||||
|
||||
|
||||
async def _consume(agen):
|
||||
return [e async for e in agen]
|
||||
|
||||
|
||||
class _Slow:
|
||||
def chat(self, x):
|
||||
time.sleep(0.5)
|
||||
return "done"
|
||||
|
||||
|
||||
class _Team:
|
||||
def start(self, x):
|
||||
return f"team:{x}"
|
||||
|
||||
|
||||
class _Boom:
|
||||
def chat(self, x):
|
||||
raise ValueError("kaboom")
|
||||
|
||||
|
||||
def test_stream_does_not_block_event_loop():
|
||||
async def main():
|
||||
ticks = 0
|
||||
|
||||
async def heartbeat():
|
||||
nonlocal ticks
|
||||
while True:
|
||||
await asyncio.sleep(0.02)
|
||||
ticks += 1
|
||||
|
||||
hb = asyncio.create_task(heartbeat())
|
||||
t = time.time()
|
||||
await asyncio.gather(
|
||||
_consume(stream_agent_response(_Slow(), "hi", "a")),
|
||||
_consume(stream_agent_response(_Slow(), "hi", "b")),
|
||||
)
|
||||
elapsed = time.time() - t
|
||||
hb.cancel()
|
||||
return elapsed, ticks
|
||||
|
||||
elapsed, ticks = asyncio.run(main())
|
||||
assert elapsed < 0.9
|
||||
assert ticks > 5
|
||||
|
||||
|
||||
def test_stream_updates_task_store_and_supports_team():
|
||||
store = TaskStore()
|
||||
task = _new_task(store)
|
||||
running = {}
|
||||
events = asyncio.run(_consume(stream_agent_response(
|
||||
_Team(), "x", task.id, task_store=store, running=running)))
|
||||
assert any("team:x" in e for e in events)
|
||||
assert any('"completed"' in e for e in events)
|
||||
t = store.get_task(task.id)
|
||||
assert t.status.state == TaskState.COMPLETED
|
||||
assert t.artifacts and running == {}
|
||||
assert task.id not in store._pinned
|
||||
|
||||
|
||||
def test_stream_failure_reports_reason():
|
||||
store = TaskStore()
|
||||
task = _new_task(store)
|
||||
events = asyncio.run(_consume(stream_agent_response(
|
||||
_Boom(), "x", task.id, task_store=store)))
|
||||
final = [e for e in events if '"failed"' in e]
|
||||
assert final and "kaboom" in final[0]
|
||||
assert store.get_task(task.id).status.state == TaskState.FAILED
|
||||
|
||||
|
||||
def test_stream_cancel_via_running_handle():
|
||||
store = TaskStore()
|
||||
task = _new_task(store)
|
||||
running = {}
|
||||
|
||||
async def main():
|
||||
consumer = asyncio.create_task(_consume(stream_agent_response(
|
||||
_Slow(), "x", task.id, task_store=store, running=running)))
|
||||
while task.id not in running:
|
||||
await asyncio.sleep(0.01)
|
||||
# Mirrors tasks/cancel: mark cancelled, then cancel the handle
|
||||
store.cancel_task(task.id)
|
||||
running[task.id].cancel()
|
||||
return await consumer
|
||||
|
||||
events = asyncio.run(main())
|
||||
assert any('"canceled"' in e or '"cancelled"' in e for e in events)
|
||||
assert store.get_task(task.id).status.state == TaskState.CANCELLED
|
||||
assert running == {}
|
||||
|
||||
|
||||
class _Counting:
|
||||
calls = 0
|
||||
|
||||
def chat(self, x):
|
||||
_Counting.calls += 1
|
||||
return "ran"
|
||||
|
||||
|
||||
def test_cancel_before_call_registered_skips_agent():
|
||||
store = TaskStore()
|
||||
task = _new_task(store)
|
||||
running = {}
|
||||
|
||||
async def main():
|
||||
agen = stream_agent_response(
|
||||
_Counting(), "x", task.id, task_store=store, running=running)
|
||||
first = await agen.__anext__()
|
||||
assert '"working"' in first
|
||||
# tasks/cancel arrives before the call is registered
|
||||
store.cancel_task(task.id)
|
||||
return [first] + [e async for e in agen]
|
||||
|
||||
events = asyncio.run(main())
|
||||
assert _Counting.calls == 0
|
||||
assert not any('"completed"' in e for e in events)
|
||||
assert store.get_task(task.id).status.state == TaskState.CANCELLED
|
||||
assert running == {} and task.id not in store._pinned
|
||||
|
||||
|
||||
def test_early_close_leaves_terminal_state_and_unpins():
|
||||
store = TaskStore()
|
||||
task = _new_task(store)
|
||||
|
||||
async def main():
|
||||
agen = stream_agent_response(_Team(), "x", task.id, task_store=store)
|
||||
await agen.__anext__()
|
||||
await agen.aclose()
|
||||
|
||||
asyncio.run(main())
|
||||
assert store.get_task(task.id).status.state == TaskState.CANCELLED
|
||||
assert task.id not in store._pinned
|
||||
@@ -34,11 +34,11 @@ def _restore_approval_state():
|
||||
from praisonaiagents.approval import get_approval_registry, clear_approval_context
|
||||
|
||||
registry = get_approval_registry()
|
||||
previous = registry.get_backend()
|
||||
previous = registry._global_backend # not get_backend(): its ConsoleBackend fallback would be installed globally on restore
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
registry.set_backend(previous)
|
||||
registry._global_backend = previous
|
||||
clear_approval_context()
|
||||
|
||||
|
||||
@@ -154,7 +154,7 @@ def test_code_mode_changes_what_the_tool_can_do():
|
||||
|
||||
_probe_tool_name = "code_mode_probe"
|
||||
get_registry().register(code_mode_probe)
|
||||
previous = get_approval_registry().get_backend()
|
||||
previous = get_approval_registry()._global_backend # not get_backend(): its ConsoleBackend fallback would be installed globally on restore
|
||||
get_approval_registry().set_backend(AutoApproveBackend())
|
||||
try:
|
||||
safe = Agent(
|
||||
@@ -182,7 +182,7 @@ def test_code_mode_changes_what_the_tool_can_do():
|
||||
safe_result = safe_exec(code)
|
||||
unsafe_result = unsafe_exec(code)
|
||||
finally:
|
||||
get_approval_registry().set_backend(previous)
|
||||
get_approval_registry()._global_backend = previous
|
||||
clear_approval_context()
|
||||
# The tool registry is process-global. Leaving these probe tools in it
|
||||
# changed what later tests saw when they enumerated or dispatched
|
||||
@@ -221,7 +221,7 @@ def test_unsafe_code_mode_cannot_reach_tools_the_agent_was_not_granted():
|
||||
|
||||
_probe_tool_name = "ungranted_global_tool"
|
||||
get_registry().register(ungranted_global_tool)
|
||||
previous = get_approval_registry().get_backend()
|
||||
previous = get_approval_registry()._global_backend # not get_backend(): its ConsoleBackend fallback would be installed globally on restore
|
||||
get_approval_registry().set_backend(AutoApproveBackend())
|
||||
try:
|
||||
agent = Agent(
|
||||
@@ -240,7 +240,7 @@ def test_unsafe_code_mode_cannot_reach_tools_the_agent_was_not_granted():
|
||||
)
|
||||
result = execute("print(ungranted_global_tool())")
|
||||
finally:
|
||||
get_approval_registry().set_backend(previous)
|
||||
get_approval_registry()._global_backend = previous
|
||||
clear_approval_context()
|
||||
# The tool registry is process-global. Leaving these probe tools in it
|
||||
# changed what later tests saw when they enumerated or dispatched
|
||||
@@ -279,13 +279,13 @@ def test_code_execution_runs_a_trailing_expression_exactly_once():
|
||||
from praisonaiagents.approval import clear_approval_context
|
||||
|
||||
registry = get_approval_registry()
|
||||
previous = registry.get_backend()
|
||||
previous = registry._global_backend # not get_backend(): its ConsoleBackend fallback would be installed globally on restore
|
||||
registry.set_backend(AutoApproveBackend())
|
||||
try:
|
||||
assert execute_code("print(7)")["stdout"] == "7\n"
|
||||
assert execute_code_with_tools("print(7)")["stdout"] == "7\n"
|
||||
finally:
|
||||
registry.set_backend(previous)
|
||||
registry._global_backend = previous
|
||||
clear_approval_context()
|
||||
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ def test_no_handrolled_async_bridges_outside_helper():
|
||||
for path in root.rglob("*.py"):
|
||||
if path in allowed:
|
||||
continue
|
||||
tree = ast.parse(path.read_text(), filename=str(path))
|
||||
tree = ast.parse(path.read_bytes(), filename=str(path))
|
||||
for func in ast.walk(tree):
|
||||
if not isinstance(func, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
continue
|
||||
@@ -93,6 +93,15 @@ def test_no_handrolled_async_bridges_outside_helper():
|
||||
)
|
||||
|
||||
|
||||
def test_scan_accepts_python_source_encoding_cookie(tmp_path, monkeypatch):
|
||||
"""Scan the same Latin-1 source that Python's parser accepts."""
|
||||
(tmp_path / "example.py").write_bytes(
|
||||
b"# coding: latin-1; caf\xe9\nmessage = 'caf\xe9'\n"
|
||||
)
|
||||
monkeypatch.setitem(globals(), "_package_root", lambda: tmp_path)
|
||||
test_no_handrolled_async_bridges_outside_helper()
|
||||
|
||||
|
||||
def test_tool_timeout_uses_daemon_worker_and_does_not_leak():
|
||||
before = {t.ident for t in threading.enumerate()}
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Regression tests for #6152: async APIs must not block the event loop."""
|
||||
import asyncio
|
||||
import contextvars
|
||||
import time
|
||||
|
||||
from praisonaiagents.escalation.pipeline import EscalationPipeline
|
||||
from praisonaiagents.escalation.types import EscalationStage, StageContext
|
||||
from praisonaiagents.knowledge.query_engine import SubQuestionEngine
|
||||
from praisonaiagents.rag.pipeline import RAG
|
||||
|
||||
BLOCK = 0.3
|
||||
_request_var = contextvars.ContextVar("request_var", default=None)
|
||||
|
||||
|
||||
async def _heartbeat_ticks(coro):
|
||||
ticks = 0
|
||||
done = False
|
||||
|
||||
async def beat():
|
||||
nonlocal ticks
|
||||
while not done:
|
||||
await asyncio.sleep(0.02)
|
||||
ticks += 1
|
||||
|
||||
task = asyncio.create_task(beat())
|
||||
try:
|
||||
result = await coro
|
||||
finally:
|
||||
done = True
|
||||
await task
|
||||
return result, ticks
|
||||
|
||||
|
||||
class _BlockingAgent:
|
||||
def __init__(self):
|
||||
self.tools = ["a", "b", "c"]
|
||||
self.seen_tools = []
|
||||
|
||||
def chat(self, prompt, tools=None):
|
||||
self.seen_tools.append(tools)
|
||||
time.sleep(BLOCK)
|
||||
return "ok"
|
||||
|
||||
|
||||
def test_escalation_does_not_block_loop_or_mutate_agent_tools():
|
||||
agent = _BlockingAgent()
|
||||
pipeline = EscalationPipeline(agent=agent)
|
||||
pipeline._current_context = StageContext(stage=EscalationStage.HEURISTIC, prompt="")
|
||||
|
||||
async def run():
|
||||
return await _heartbeat_ticks(
|
||||
asyncio.gather(
|
||||
pipeline._execute_heuristic("hi"),
|
||||
pipeline._execute_heuristic("there"),
|
||||
)
|
||||
)
|
||||
|
||||
(r1, r2), ticks = asyncio.run(run())
|
||||
assert r1["success"] and r2["success"]
|
||||
assert ticks >= 5
|
||||
assert agent.tools == ["a", "b", "c"]
|
||||
assert all(t is not None for t in agent.seen_tools)
|
||||
|
||||
|
||||
class _BlockingRAG(RAG):
|
||||
llm = None # shadow the lazy LLM property
|
||||
|
||||
def __init__(self): # bypass heavy init
|
||||
from praisonaiagents.rag.models import RAGConfig
|
||||
self.config = RAGConfig()
|
||||
self.seen_var = None
|
||||
|
||||
def _retrieve(self, **kwargs):
|
||||
self.seen_var = _request_var.get()
|
||||
time.sleep(BLOCK)
|
||||
return []
|
||||
|
||||
def _rerank(self, question, results, rerank=None):
|
||||
return results
|
||||
|
||||
def _build_context(self, results):
|
||||
return "", []
|
||||
|
||||
def _build_prompt(self, question, context):
|
||||
return question
|
||||
|
||||
|
||||
def test_rag_astream_offloads_retrieval_and_keeps_contextvars():
|
||||
rag = _BlockingRAG()
|
||||
|
||||
async def consume():
|
||||
_request_var.set("req-1")
|
||||
return [c async for c in rag.astream("q")]
|
||||
|
||||
chunks, ticks = asyncio.run(_heartbeat_ticks(consume()))
|
||||
assert chunks == ["LLM not available."]
|
||||
assert ticks >= 5
|
||||
assert rag.seen_var == "req-1"
|
||||
|
||||
|
||||
def test_subquestion_aquery_offloads():
|
||||
engine = SubQuestionEngine()
|
||||
|
||||
def slow_query(question, context=None, **kwargs):
|
||||
time.sleep(BLOCK)
|
||||
return "done"
|
||||
|
||||
engine.query = slow_query
|
||||
result, ticks = asyncio.run(_heartbeat_ticks(engine.aquery("q")))
|
||||
assert result == "done"
|
||||
assert ticks >= 5
|
||||
|
||||
|
||||
class _SyncStreamLLM:
|
||||
"""Sync-only LLM (no ``astream``) whose ``stream()`` blocks between chunks."""
|
||||
|
||||
def stream(self, messages, **kwargs):
|
||||
for tok in ("a", "b", "c"):
|
||||
time.sleep(BLOCK / 3)
|
||||
yield tok
|
||||
|
||||
|
||||
def test_rag_astream_sync_fallback_offloads_and_retrieves_once():
|
||||
rag = _BlockingRAG()
|
||||
rag.llm = _SyncStreamLLM()
|
||||
calls = []
|
||||
orig_retrieve = rag._retrieve
|
||||
|
||||
def counting_retrieve(**kwargs):
|
||||
calls.append(kwargs)
|
||||
return orig_retrieve(**kwargs)
|
||||
|
||||
rag._retrieve = counting_retrieve
|
||||
|
||||
async def consume():
|
||||
return [c async for c in rag.astream("q")]
|
||||
|
||||
chunks, ticks = asyncio.run(_heartbeat_ticks(consume()))
|
||||
assert chunks == ["a", "b", "c"]
|
||||
assert len(calls) == 1
|
||||
assert ticks >= 10
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Counting rules of benchmarks/execution_benchmark.py, with fake runners.
|
||||
|
||||
No framework is imported and nothing leaves the machine: each runner is a
|
||||
``build()`` that returns the call to time, as the script's real runners do.
|
||||
"""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[2] / "benchmarks" / "execution_benchmark.py"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def bench():
|
||||
spec = importlib.util.spec_from_file_location("execution_benchmark", SCRIPT)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def runner(answers, log, name):
|
||||
"""A build() whose calls return ``answers`` in order (an Exception is raised)."""
|
||||
answers = iter(answers)
|
||||
|
||||
def build():
|
||||
log.append(("build", name))
|
||||
|
||||
def call():
|
||||
log.append(("call", name))
|
||||
answer = next(answers)
|
||||
if isinstance(answer, Exception):
|
||||
raise answer
|
||||
return answer
|
||||
return call
|
||||
return build
|
||||
|
||||
|
||||
def test_warm_up_calls_are_not_timed_and_rounds_interleave(bench):
|
||||
log = []
|
||||
runners = {"A": runner(["w", "a1", "a2"], log, "A"), "B": runner(["w", "b1", "b2"], log, "B")}
|
||||
results = bench.run_benchmark(runners, iterations=2, warmup=1)
|
||||
assert [len(r["times"]) for r in results.values()] == [2, 2]
|
||||
calls = [name for kind, name in log if kind == "call"]
|
||||
assert calls == ["A", "B", "A", "B", "B", "A"] # warm-ups, round 1, round 2 rotated
|
||||
|
||||
|
||||
def test_errors_and_empty_answers_are_failures_not_timings(bench):
|
||||
log = []
|
||||
runners = {"A": runner(["w", RuntimeError("401"), None, "None", " ", "4"], log, "A")}
|
||||
results = bench.run_benchmark(runners, iterations=5, warmup=1)
|
||||
assert len(results["A"]["times"]) == 1
|
||||
assert len(results["A"]["failures"]) == 4
|
||||
assert "RuntimeError: 401" in results["A"]["failures"][0]
|
||||
|
||||
|
||||
def test_a_failed_warm_up_does_not_drop_the_framework(bench):
|
||||
log = []
|
||||
runners = {"A": runner([RuntimeError("timeout"), "a1", "a2"], log, "A")}
|
||||
results = bench.run_benchmark(runners, iterations=2, warmup=1)
|
||||
assert len(results["A"]["times"]) == 2
|
||||
|
||||
|
||||
def test_summary_ranks_by_median_and_skips_frameworks_without_a_success(bench):
|
||||
results = {
|
||||
"slow": {"times": [1.0, 1.1, 9.0], "failures": []},
|
||||
"fast": {"times": [0.5, 0.6, 0.7], "failures": ["x"]},
|
||||
"broken": {"times": [], "failures": ["401"]},
|
||||
}
|
||||
rows = bench.summarise(results)
|
||||
assert [r[0] for r in rows] == ["fast", "slow"]
|
||||
name, median, mean, ok, failed = rows[1]
|
||||
assert (median, ok, failed) == (1.1, 3, 0)
|
||||
assert mean == pytest.approx(11.1 / 3)
|
||||
|
||||
|
||||
def test_only_the_call_is_timed(bench, monkeypatch):
|
||||
clock = iter([10.0, 10.5]) # start / end of the one timed call; build() reads no clock
|
||||
|
||||
def build():
|
||||
return lambda: "4"
|
||||
|
||||
monkeypatch.setattr(bench.time, "perf_counter", lambda: next(clock))
|
||||
results = bench.run_benchmark({"A": build}, iterations=1, warmup=0)
|
||||
assert results["A"]["times"] == [0.5]
|
||||
@@ -328,7 +328,7 @@ class TestMultiChannelGatewayConfig:
|
||||
from praisonaiagents.gateway.config import ChannelRouteConfig, MultiChannelGatewayConfig
|
||||
module = sys.modules["praisonaiagents.gateway.config"]
|
||||
source_file = module.__file__
|
||||
with open(source_file, "r") as f:
|
||||
with open(source_file, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
for dep in ["chromadb", "fastapi", "uvicorn", "pydantic", "litellm"]:
|
||||
assert f"import {dep}" not in content, f"Found heavy dep '{dep}' in config.py"
|
||||
@@ -376,7 +376,7 @@ class TestChatCommandInfo:
|
||||
from praisonaiagents.bots.protocols import BotProtocol
|
||||
module = sys.modules["praisonaiagents.bots.protocols"]
|
||||
source_file = module.__file__
|
||||
with open(source_file, "r") as f:
|
||||
with open(source_file, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
for dep in ["chromadb", "fastapi", "uvicorn", "litellm"]:
|
||||
assert f"import {dep}" not in content, f"Found heavy dep '{dep}' in protocols.py"
|
||||
|
||||
@@ -9,7 +9,7 @@ from praisonaiagents import ImageAgent
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model_kwargs,expected", [
|
||||
({}, {"generate": "dall-e-3", "edit": "dall-e-2", "variation": "dall-e-2"}),
|
||||
({}, {"generate": "gpt-image-2.5-flare", "edit": "gpt-image-2.5-flare", "variation": "dall-e-2"}),
|
||||
({"llm": "openai/gpt-image-1"}, dict.fromkeys(("generate", "edit", "variation"), "openai/gpt-image-1")),
|
||||
({"model": "openai/gpt-image-1"}, dict.fromkeys(("generate", "edit", "variation"), "openai/gpt-image-1")),
|
||||
])
|
||||
@@ -37,3 +37,27 @@ def test_image_model_resolution(monkeypatch, tmp_path, model_kwargs, expected):
|
||||
agent.variation(str(image))
|
||||
|
||||
assert {name: kwargs["model"] for name, kwargs in calls.items()} == expected
|
||||
|
||||
|
||||
IMAGES_26_PARAMS = {"quality": "low", "frame_count": 4, "frame_duration_ms": 100,
|
||||
"complexity": "low", "output_format": "gif"}
|
||||
|
||||
|
||||
def test_generate_forwards_images_26_params(monkeypatch):
|
||||
calls = {}
|
||||
agent = ImageAgent(llm="gpt-image-2.6-flare", verbose=False)
|
||||
monkeypatch.setattr(agent, "_litellm", lambda **kw: calls.update(kw) or {"data": []})
|
||||
monkeypatch.setitem(sys.modules, "litellm", types.SimpleNamespace(
|
||||
get_llm_provider=lambda model: (model, "openai", None, None)))
|
||||
agent.generate_image("x", **IMAGES_26_PARAMS)
|
||||
assert IMAGES_26_PARAMS.items() <= calls.items()
|
||||
|
||||
|
||||
def test_edit_rejects_markup_instead_of_dropping_it(monkeypatch):
|
||||
called = []
|
||||
agent = ImageAgent(llm="gpt-image-2.5-flare", verbose=False)
|
||||
monkeypatch.setitem(sys.modules, "litellm", types.SimpleNamespace(
|
||||
image_edit=lambda **kw: called.append(kw)))
|
||||
with pytest.raises(ValueError, match="markup"):
|
||||
agent.edit("photo.png", "make the circled cup blue", markup="markup.png")
|
||||
assert not called
|
||||
@@ -0,0 +1,158 @@
|
||||
"""ImageAgent results expose .save() while keeping the raw response API (#6260)."""
|
||||
|
||||
import base64
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents import ImageAgent
|
||||
|
||||
GIF = b"GIF89a\x01\x00\x01\x00fake"
|
||||
SVG = b'<svg xmlns="http://www.w3.org/2000/svg"></svg>'
|
||||
|
||||
|
||||
def _response(*payloads):
|
||||
return types.SimpleNamespace(
|
||||
data=[types.SimpleNamespace(b64_json=base64.b64encode(p).decode(), url=None) for p in payloads],
|
||||
usage={"total_tokens": 5},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agent(monkeypatch):
|
||||
agent = ImageAgent(llm="openai/gpt-image-2.6-flare", verbose=False)
|
||||
sent = {}
|
||||
|
||||
def fake(name, payloads):
|
||||
def call(**kwargs):
|
||||
sent[name] = kwargs
|
||||
return _response(*payloads)
|
||||
return call
|
||||
|
||||
monkeypatch.setattr(agent, "_litellm", fake("generate", [GIF]))
|
||||
monkeypatch.setitem(sys.modules, "litellm", types.SimpleNamespace(
|
||||
image_edit=fake("edit", [SVG, GIF]),
|
||||
image_variation=fake("variation", [GIF]),
|
||||
get_llm_provider=lambda model: (model, "openai", None, None),
|
||||
))
|
||||
agent.sent = sent
|
||||
return agent
|
||||
|
||||
|
||||
def test_generate_save_writes_decoded_bytes(agent, tmp_path):
|
||||
out = agent.generate("A cat waving", output_format="gif", frame_count=8).save(str(tmp_path / "cat.gif"))
|
||||
assert open(out, "rb").read() == GIF
|
||||
assert agent.sent["generate"]["output_format"] == "gif"
|
||||
assert agent.sent["generate"]["frame_count"] == 8
|
||||
|
||||
|
||||
def test_edit_save_index_and_raw_access(agent, tmp_path):
|
||||
src = tmp_path / "photo.png"
|
||||
src.write_bytes(b"png")
|
||||
result = agent.edit(str(src), "make the cup blue", n=2)
|
||||
assert agent.sent["edit"]["n"] == 2
|
||||
assert len(result.images) == 2
|
||||
assert result.usage == {"total_tokens": 5}
|
||||
assert base64.b64decode(result.data[1].b64_json) == GIF
|
||||
result.save(str(tmp_path / "a.svg"))
|
||||
assert (tmp_path / "a.svg").read_bytes() == SVG
|
||||
|
||||
|
||||
def test_variation_and_dict_response(agent, tmp_path, monkeypatch):
|
||||
src = tmp_path / "photo.png"
|
||||
src.write_bytes(b"png")
|
||||
assert agent.variation(str(src)).save(str(tmp_path / "v.gif"))
|
||||
monkeypatch.setattr(agent, "_litellm", lambda **kw: {"data": [{"b64_json": base64.b64encode(SVG).decode()}]})
|
||||
result = agent.generate("logo")
|
||||
assert result["data"][0]["b64_json"]
|
||||
result.save(str(tmp_path / "logo.svg"))
|
||||
assert (tmp_path / "logo.svg").read_bytes() == SVG
|
||||
|
||||
|
||||
def test_save_without_image_data_raises(agent, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(agent, "_litellm", lambda **kw: {"data": []})
|
||||
with pytest.raises(ValueError):
|
||||
agent.generate("nothing").save(str(tmp_path / "x.png"))
|
||||
|
||||
|
||||
def test_save_nonzero_index(agent, tmp_path):
|
||||
src = tmp_path / "photo.png"
|
||||
src.write_bytes(b"png")
|
||||
result = agent.edit(str(src), "two takes", n=2)
|
||||
assert result.save(str(tmp_path / "b.gif"), index=1) == str(tmp_path / "b.gif")
|
||||
assert (tmp_path / "b.gif").read_bytes() == GIF
|
||||
|
||||
|
||||
class _FakeHTTP:
|
||||
def __init__(self, body):
|
||||
self.body = body
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def read(self, n=-1):
|
||||
return self.body if n < 0 else self.body[:n]
|
||||
|
||||
|
||||
def _url_response(url):
|
||||
return {"data": [{"b64_json": None, "url": url}]}
|
||||
|
||||
|
||||
def test_save_downloads_url(agent, tmp_path, monkeypatch):
|
||||
import urllib.request
|
||||
|
||||
seen = {}
|
||||
|
||||
def fake_urlopen(url, timeout=None):
|
||||
seen.update(url=url, timeout=timeout)
|
||||
return _FakeHTTP(GIF)
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
|
||||
monkeypatch.setattr(agent, "_litellm", lambda **kw: _url_response("https://example.com/i.gif"))
|
||||
agent.generate("cat").save(str(tmp_path / "u.gif"))
|
||||
assert (tmp_path / "u.gif").read_bytes() == GIF
|
||||
assert seen == {"url": "https://example.com/i.gif", "timeout": 30}
|
||||
|
||||
|
||||
def test_save_rejects_non_http_url(agent, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(agent, "_litellm", lambda **kw: _url_response("file:///etc/passwd"))
|
||||
with pytest.raises(ValueError, match="http"):
|
||||
agent.generate("x").save(str(tmp_path / "x.png"))
|
||||
assert not (tmp_path / "x.png").exists()
|
||||
|
||||
|
||||
def test_save_rejects_oversized_download(agent, tmp_path, monkeypatch):
|
||||
import urllib.request
|
||||
from praisonaiagents.agent import _image_result
|
||||
|
||||
monkeypatch.setattr(_image_result, "_MAX_DOWNLOAD_BYTES", 4)
|
||||
monkeypatch.setattr(urllib.request, "urlopen", lambda url, timeout=None: _FakeHTTP(b"123456"))
|
||||
monkeypatch.setattr(agent, "_litellm", lambda **kw: _url_response("https://example.com/big.png"))
|
||||
with pytest.raises(ValueError, match="maximum"):
|
||||
agent.generate("x").save(str(tmp_path / "big.png"))
|
||||
assert not (tmp_path / "big.png").exists()
|
||||
|
||||
|
||||
def test_container_protocol_passes_through(agent):
|
||||
from litellm.types.utils import ImageResponse
|
||||
|
||||
raw = ImageResponse(data=[{"b64_json": base64.b64encode(GIF).decode()}])
|
||||
agent._litellm = lambda **kw: raw
|
||||
result = agent.generate("cat")
|
||||
assert "data" in result
|
||||
assert dict(result).keys() == dict(raw).keys()
|
||||
|
||||
|
||||
def test_workflow_output_saves_b64_image(agent, tmp_path):
|
||||
from praisonaiagents.workflows.workflows import AgentFlow
|
||||
|
||||
flow = object.__new__(AgentFlow)
|
||||
out = str(tmp_path / "wf.gif")
|
||||
variables = {"output": out}
|
||||
flow._execute_specialized_agent(agent, "ImageAgent", "a cat", None, variables, False)
|
||||
assert (tmp_path / "wf.gif").read_bytes() == GIF
|
||||
assert variables["_last_image_file"] == out
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Regression tests for issue #6171.
|
||||
|
||||
1. Agent.output_file must not overwrite a hardlinked file outside the root.
|
||||
2. Shadow-git / worktree git calls are bounded by a timeout and non-interactive.
|
||||
3. A torn learn-store archive is quarantined and eviction keeps working.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_agent_output_file_hardlink_does_not_touch_outside_inode(tmp_path, monkeypatch):
|
||||
from praisonaiagents import Agent, OutputConfig
|
||||
|
||||
root = tmp_path / "root"
|
||||
root.mkdir()
|
||||
outside = tmp_path / "outside.txt"
|
||||
outside.write_text("SECRET")
|
||||
link = root / "out.md"
|
||||
try:
|
||||
os.link(outside, link)
|
||||
except (OSError, NotImplementedError):
|
||||
pytest.skip("hardlinks unsupported")
|
||||
|
||||
monkeypatch.setenv("PRAISONAI_PROJECT_ROOT", str(root))
|
||||
monkeypatch.chdir(root)
|
||||
agent = Agent(name="writer", output=OutputConfig(output_file="out.md"))
|
||||
assert agent._save_output_to_file("AGENT-OVERWRITE") is True
|
||||
|
||||
assert outside.read_text() == "SECRET"
|
||||
assert link.read_text() == "AGENT-OVERWRITE"
|
||||
|
||||
|
||||
def test_agent_output_file_outside_root_rejected(tmp_path, monkeypatch):
|
||||
from praisonaiagents import Agent, OutputConfig
|
||||
|
||||
root = tmp_path / "root"
|
||||
root.mkdir()
|
||||
monkeypatch.setenv("PRAISONAI_PROJECT_ROOT", str(root))
|
||||
agent = Agent(name="writer", output=OutputConfig(output_file=str(tmp_path / "escape.md")))
|
||||
assert agent._save_output_to_file("x") is False
|
||||
assert not (tmp_path / "escape.md").exists()
|
||||
|
||||
|
||||
def test_snapshot_run_git_is_bounded_and_non_interactive(tmp_path):
|
||||
from praisonaiagents.snapshot import snapshot as snap_mod
|
||||
|
||||
fs = snap_mod.FileSnapshot(str(tmp_path), snapshot_dir=str(tmp_path / "snaps"))
|
||||
with mock.patch.object(
|
||||
snap_mod.subprocess, "run",
|
||||
side_effect=subprocess.TimeoutExpired(["git"], snap_mod._GIT_TIMEOUT),
|
||||
) as run:
|
||||
with pytest.raises(subprocess.TimeoutExpired):
|
||||
fs._run_git("status")
|
||||
kwargs = run.call_args.kwargs
|
||||
assert kwargs["timeout"] == snap_mod._GIT_TIMEOUT
|
||||
assert kwargs["env"]["GIT_TERMINAL_PROMPT"] == "0"
|
||||
|
||||
|
||||
def test_git_worktree_timeout_treated_as_unavailable(tmp_path):
|
||||
from praisonaiagents.workspace.adapters import git_worktree as gw
|
||||
|
||||
with mock.patch(
|
||||
"subprocess.run",
|
||||
side_effect=subprocess.TimeoutExpired(["git"], gw._GIT_TIMEOUT),
|
||||
) as run:
|
||||
adapter = gw.GitWorktreeAdapter(tmp_path)
|
||||
assert adapter.available is False
|
||||
kwargs = run.call_args.kwargs
|
||||
assert kwargs["timeout"] == gw._GIT_TIMEOUT
|
||||
assert kwargs["env"]["GIT_TERMINAL_PROMPT"] == "0"
|
||||
|
||||
|
||||
def test_corrupt_archive_quarantined_and_eviction_continues(tmp_path):
|
||||
from praisonaiagents.memory.learn.stores import InsightStore
|
||||
|
||||
st = InsightStore(store_path=str(tmp_path / "insights.json"), max_entries=3)
|
||||
for i in range(5):
|
||||
st.add(f"insight {i}")
|
||||
archive = st._archive_path()
|
||||
archive.write_text('[{"id": "x", "cont') # torn write
|
||||
|
||||
for i in range(5, 12):
|
||||
st.add(f"insight {i}")
|
||||
|
||||
assert len(st._entries) == 3
|
||||
sidecars = list(tmp_path.glob("insights.archive.json.*.corrupt"))
|
||||
assert sidecars and sidecars[0].read_text() == '[{"id": "x", "cont'
|
||||
import json
|
||||
assert isinstance(json.loads(archive.read_text()), list)
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Regression tests for Issue #6238.
|
||||
|
||||
* Caller-supplied names must never escape the storage directory.
|
||||
* Sync entry points must work from inside a running event loop.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from praisonaiagents.planning.storage import PlanStorage
|
||||
from praisonaiagents.planning.todo import TodoList
|
||||
from praisonaiagents.utils.atomic_io import safe_join
|
||||
|
||||
|
||||
def test_safe_join_keeps_legacy_names_and_confines_others(tmp_path):
|
||||
base = str(tmp_path)
|
||||
assert safe_join(base, "current", ".json") == os.path.join(base, "current.json")
|
||||
for name in ("../../x", "/etc/passwd", "..", "", "a/b", "a\\b", ".hidden"):
|
||||
path = safe_join(base, name, ".json")
|
||||
assert os.path.dirname(path) == base, name
|
||||
# Distinct inputs never collide on one file.
|
||||
assert safe_join(base, "a/b") != safe_join(base, "a_b")
|
||||
|
||||
|
||||
def test_plan_storage_todo_name_cannot_traverse(tmp_path):
|
||||
storage = PlanStorage(base_path=str(tmp_path / "store"))
|
||||
path = storage.save_todo(TodoList(), name="../../escaped")
|
||||
assert os.path.dirname(path) == storage.todos_dir
|
||||
assert not (tmp_path / "escaped.json").exists()
|
||||
assert storage.load_todo("../../escaped") is not None
|
||||
assert storage.delete_todo("../../escaped") is True
|
||||
|
||||
|
||||
def test_generate_title_works_inside_running_loop(monkeypatch):
|
||||
from praisonaiagents.session import title
|
||||
|
||||
async def fake(*args, **kwargs):
|
||||
return "LLM Title"
|
||||
|
||||
monkeypatch.setattr(title, "generate_title_async", fake)
|
||||
|
||||
async def main():
|
||||
return title.generate_title("hello", "world")
|
||||
|
||||
assert asyncio.run(main()) == "LLM Title"
|
||||
|
||||
|
||||
def test_listed_todo_names_round_trip(tmp_path):
|
||||
storage = PlanStorage(base_path=str(tmp_path / "store"))
|
||||
storage.save_todo(TodoList(), name="my tasks")
|
||||
(listed,) = storage.list_todos()
|
||||
assert storage.load_todo(listed) is not None
|
||||
assert storage.load_todo("my tasks") is not None
|
||||
assert storage.delete_todo(listed) is True
|
||||
assert storage.list_todos() == []
|
||||
|
||||
|
||||
def test_legacy_todo_file_still_found(tmp_path):
|
||||
storage = PlanStorage(base_path=str(tmp_path / "store"))
|
||||
legacy = os.path.join(storage.todos_dir, "my tasks.json")
|
||||
with open(legacy, "w", encoding="utf-8") as f:
|
||||
f.write(TodoList().to_json())
|
||||
assert storage.load_todo("my tasks") is not None
|
||||
assert storage.save_todo(TodoList(), name="my tasks") == legacy
|
||||
assert storage.list_todos() == ["my tasks"]
|
||||
assert storage.delete_todo("my tasks") is True
|
||||
|
||||
|
||||
def test_custom_plan_filename_keeps_md_suffix(tmp_path):
|
||||
from praisonaiagents.planning.plan import Plan
|
||||
|
||||
storage = PlanStorage(base_path=str(tmp_path / "store"))
|
||||
plan = Plan(name="demo")
|
||||
path = storage.save_plan(plan, filename="my plan.md")
|
||||
assert path.endswith(".md") and os.path.dirname(path) == storage.plans_dir
|
||||
assert len(storage.list_plans()) == 1
|
||||
|
||||
|
||||
def test_encoded_looking_names_do_not_share_a_file(tmp_path):
|
||||
storage = PlanStorage(base_path=str(tmp_path / "store"))
|
||||
names = ["a/b", "a_b~c14cddc033f6", "a%2Fb", "a b", "a%20b", "~x", ".."]
|
||||
paths = {storage.save_todo(TodoList(name=n), name=n) for n in names}
|
||||
assert len(paths) == len(names)
|
||||
assert all(os.path.dirname(p) == storage.todos_dir for p in paths)
|
||||
assert sorted(storage.list_todos()) == sorted(names)
|
||||
for n in names:
|
||||
assert storage.load_todo(n).name == n
|
||||
assert storage.delete_todo("a/b") is True
|
||||
assert storage.load_todo("a%2Fb") is not None
|
||||
|
||||
|
||||
def test_long_unicode_names_are_bounded_and_round_trip(tmp_path):
|
||||
storage = PlanStorage(base_path=str(tmp_path / "store"))
|
||||
long_a, long_b = "我的任务" * 40, "我的任务" * 40 + "x"
|
||||
pa = storage.save_todo(TodoList(name=long_a), name=long_a)
|
||||
pb = storage.save_todo(TodoList(name=long_b), name=long_b)
|
||||
assert pa != pb
|
||||
for p in (pa, pb):
|
||||
assert os.path.dirname(p) == storage.todos_dir
|
||||
assert len(os.path.basename(p).encode("utf-8")) <= 200
|
||||
# Short non-ASCII names keep their plain (pre-#6238) filename.
|
||||
assert storage.save_todo(TodoList(), name="我的任务").endswith("我的任务.json")
|
||||
for listed in storage.list_todos():
|
||||
assert storage.load_todo(listed) is not None
|
||||
assert storage.delete_todo(listed) is True
|
||||
assert storage.list_todos() == []
|
||||
assert storage.load_todo(long_a) is None
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Regression tests for issue #6257 (beginner harness demo)."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.llm.openai_client import OpenAIClient
|
||||
|
||||
|
||||
def test_guardrail_runs_when_agent_has_no_name():
|
||||
seen = []
|
||||
|
||||
def check(output):
|
||||
seen.append(output.agent)
|
||||
return True, output
|
||||
|
||||
agent = Agent(instructions="test", llm="gpt-4o-mini", guardrails=check)
|
||||
assert agent.name is None or isinstance(agent.name, str)
|
||||
|
||||
ok, result, error = agent._validate_with_guardrail("hello")
|
||||
|
||||
assert ok is True and error is None
|
||||
assert result == "hello"
|
||||
assert seen and isinstance(seen[0], str)
|
||||
|
||||
|
||||
def test_openai_timeout_env_var(monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_TIMEOUT", "45")
|
||||
assert OpenAIClient(api_key="sk-test").timeout == 45.0
|
||||
|
||||
monkeypatch.setenv("OPENAI_TIMEOUT", "not-a-number")
|
||||
assert OpenAIClient(api_key="sk-test").timeout is None
|
||||
|
||||
monkeypatch.delenv("OPENAI_TIMEOUT")
|
||||
assert OpenAIClient(api_key="sk-test").timeout is None
|
||||
|
||||
|
||||
def test_openai_timeout_reaches_sync_client(monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_TIMEOUT", "12")
|
||||
client = OpenAIClient(api_key="sk-test")
|
||||
monkeypatch.setattr(
|
||||
"praisonaiagents.llm.openai_client.check_model_request", lambda *a, **k: None
|
||||
)
|
||||
assert client.sync_client.timeout == 12.0
|
||||
|
||||
|
||||
def test_rules_notice_lists_only_active_rules_once(tmp_path, monkeypatch, caplog):
|
||||
import logging
|
||||
from praisonaiagents.agent import agent as agent_mod
|
||||
|
||||
rules_dir = tmp_path / ".praisonai" / "rules"
|
||||
rules_dir.mkdir(parents=True)
|
||||
(rules_dir / "always_on.md").write_text("---\nactivation: always\n---\nBe brief.\n")
|
||||
(rules_dir / "manual_only.md").write_text("---\nactivation: manual\n---\nOnly on @mention.\n")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
||||
monkeypatch.setattr(agent_mod, "_RULES_NOTICE_SEEN", set())
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
for _ in range(2):
|
||||
a = Agent(instructions="test", llm="gpt-4o-mini")
|
||||
a._init_rules_manager()
|
||||
|
||||
notices = [r.getMessage() for r in caplog.records if "Applied rules" in r.getMessage()]
|
||||
assert len(notices) == 1
|
||||
assert "always_on" in notices[0] and "manual_only" not in notices[0]
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Regression guard (#6167): opt-in modules must not load on ``import Agent``."""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
_SCRIPT = r"""
|
||||
import sys
|
||||
from praisonaiagents import Agent # noqa: F401
|
||||
import praisonaiagents.session as session
|
||||
|
||||
lazy = (
|
||||
"praisonaiagents.agent.server_registry",
|
||||
"praisonaiagents.goal.judge",
|
||||
"praisonaiagents.session.encrypted_store",
|
||||
)
|
||||
loaded = [m for m in lazy if m in sys.modules]
|
||||
assert not loaded, f"eagerly imported: {loaded}"
|
||||
|
||||
# Public encryption exports still resolve lazily.
|
||||
from praisonaiagents.session import ( # noqa: E402
|
||||
EncryptedSessionStore, SessionEncryptionError, generate_session_key,
|
||||
)
|
||||
assert "praisonaiagents.session.encrypted_store" in sys.modules
|
||||
assert session.EncryptedSessionStore is EncryptedSessionStore
|
||||
assert callable(generate_session_key) and issubclass(SessionEncryptionError, Exception)
|
||||
"""
|
||||
|
||||
|
||||
def test_opt_in_modules_not_loaded_on_agent_import():
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", _SCRIPT], capture_output=True, text=True, timeout=60
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Regression test for #6202: capping a tool batch must not leave orphaned tool_calls."""
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("litellm")
|
||||
|
||||
|
||||
def _litellm():
|
||||
# Resolve at test time: other tests may reload/replace ``litellm`` in
|
||||
# sys.modules after collection, and llm.py imports it lazily per call.
|
||||
import litellm
|
||||
return litellm
|
||||
|
||||
|
||||
def ModelResponse(**kw): # noqa: N802
|
||||
return _litellm().ModelResponse(**kw)
|
||||
|
||||
|
||||
def _tc(i):
|
||||
return {"id": f"c{i}", "type": "function",
|
||||
"function": {"name": "ping", "arguments": json.dumps({"n": i})}}
|
||||
|
||||
|
||||
def _ping(n: int) -> str:
|
||||
"ping"
|
||||
return f"pong{n}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["sync", "async"])
|
||||
def test_truncated_batch_has_no_orphaned_tool_calls(monkeypatch, mode):
|
||||
sent = []
|
||||
|
||||
def _respond(kwargs):
|
||||
sent.append(json.loads(json.dumps(kwargs["messages"], default=str)))
|
||||
if len(sent) == 1:
|
||||
return ModelResponse(choices=[{"message": {"role": "assistant", "content": "",
|
||||
"tool_calls": [_tc(i) for i in range(12)]},
|
||||
"finish_reason": "tool_calls", "index": 0}])
|
||||
return ModelResponse(choices=[{"message": {"role": "assistant", "content": "done"},
|
||||
"finish_reason": "stop", "index": 0}])
|
||||
|
||||
async def _afake(*a, **k):
|
||||
return _respond(k)
|
||||
|
||||
monkeypatch.setattr(_litellm(), "completion", lambda *a, **k: _respond(k))
|
||||
monkeypatch.setattr(_litellm(), "acompletion", _afake)
|
||||
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(instructions="x", model="anthropic/claude-3-haiku-20240307",
|
||||
tools=[_ping], output="silent")
|
||||
result = agent.start("go") if mode == "sync" else asyncio.run(agent.astart("go"))
|
||||
|
||||
assert result == "done"
|
||||
follow_up = sent[1]
|
||||
declared = [t["id"] for m in follow_up if m.get("tool_calls") for t in m["tool_calls"]]
|
||||
replied = [m["tool_call_id"] for m in follow_up if m.get("role") == "tool"]
|
||||
assert sorted(declared) == sorted(replied)
|
||||
assert len(declared) == 10 # default max_tool_calls_per_turn
|
||||
|
||||
|
||||
def test_stream_fallback_keeps_text_sent_with_capped_tool_calls(monkeypatch):
|
||||
"""Text returned alongside tool calls must still be yielded once the cap is hit."""
|
||||
sent = []
|
||||
|
||||
def _respond(*a, **k):
|
||||
sent.append(k)
|
||||
n = len(sent)
|
||||
if n <= 2:
|
||||
return ModelResponse(choices=[{"message": {"role": "assistant", "content": f"text{n}",
|
||||
"tool_calls": [_tc(n)]},
|
||||
"finish_reason": "tool_calls", "index": 0}])
|
||||
return ModelResponse(choices=[{"message": {"role": "assistant", "content": "final"},
|
||||
"finish_reason": "stop", "index": 0}])
|
||||
|
||||
monkeypatch.setattr(_litellm(), "completion", _respond)
|
||||
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
llm = LLM(model="anthropic/claude-3-haiku-20240307")
|
||||
monkeypatch.setattr(llm, "_supports_streaming_tools", lambda: False)
|
||||
chunks = list(llm.get_response_stream(
|
||||
"go", tools=[_ping], execute_tool_fn=lambda name, args: _ping(**args),
|
||||
max_tool_calls_per_turn=1))
|
||||
|
||||
assert "text1" in chunks and "text2" in chunks # text2 arrived at the cap
|
||||
@@ -81,6 +81,111 @@ class TestExactMatchBackwardCompat:
|
||||
assert _read(p) == "x = 2\nx = 2\n"
|
||||
|
||||
|
||||
class TestNoMatchSelfCorrection:
|
||||
def test_retried_edit_shows_applied_line_in_hint(self, tools, tmp_path):
|
||||
# A retried edit is never reported as success; the hint shows the
|
||||
# already-edited line so the agent can see the change landed.
|
||||
p = tmp_path / "a.py"
|
||||
_write(p, "def foo():\n return 1\n")
|
||||
assert "Success" in tools.edit_file(str(p), "return 1", "return 2")
|
||||
before = _read(p)
|
||||
result = tools.edit_file(str(p), "return 1", "return 2")
|
||||
assert "String not found" in result and "No change needed" not in result
|
||||
assert " 2| return 2" in result
|
||||
assert _read(p) == before
|
||||
|
||||
def test_tied_candidate_elsewhere_is_not_reported_applied(self, tools, tmp_path):
|
||||
# Merge-gate case: g() already has `return 2` and ties with f()'s
|
||||
# `return 3`; the edit meant for f() must not report success.
|
||||
p = tmp_path / "a.py"
|
||||
src = "def g():\n return 2\n\ndef f():\n return 3\n"
|
||||
_write(p, src)
|
||||
result = tools.edit_file(str(p), "return 1", "return 2")
|
||||
assert "No change needed" not in result
|
||||
assert "String not found" in result
|
||||
assert _read(p) == src
|
||||
|
||||
def test_short_common_new_string_is_not_noop(self, tools, tmp_path):
|
||||
# new_string occurring more than once is not evidence of a prior edit.
|
||||
p = tmp_path / "a.py"
|
||||
_write(p, "x = 1\ny = x\n")
|
||||
result = tools.edit_file(str(p), "z = 1", "x")
|
||||
assert "No change needed" not in result
|
||||
|
||||
def test_new_inside_old_is_not_noop(self, tools, tmp_path):
|
||||
# new_string is a substring of old_string, so its presence proves nothing.
|
||||
p = tmp_path / "a.py"
|
||||
_write(p, "y = compute(a)\n")
|
||||
result = tools.edit_file(str(p), "x = compute(a)", "compute(a)")
|
||||
assert "Error" in result and "No change needed" not in result
|
||||
|
||||
def test_unrelated_new_string_elsewhere_is_not_noop(self, tools, tmp_path):
|
||||
# new_string exists in another function, not where old_string was expected.
|
||||
p = tmp_path / "a.py"
|
||||
_write(p, "def f(x):\n return x * 2\n\ndef g():\n return 0\n")
|
||||
result = tools.edit_file(str(p), "return x * 3", "return 0")
|
||||
assert "No change needed" not in result
|
||||
assert "String not found" in result
|
||||
assert " 2|" in result
|
||||
|
||||
def test_hint_truncates_long_lines(self, tools, tmp_path):
|
||||
p = tmp_path / "a.json"
|
||||
_write(p, '{"key": "' + "a" * 1000 + '"}\n')
|
||||
result = tools.edit_file(str(p), '{"key": "' + "a" * 900 + 'b"}', "x")
|
||||
assert "Closest match" in result and "[truncated]" in result
|
||||
assert len(result) < 1000
|
||||
|
||||
def test_hint_search_is_bounded_on_long_repetitive_lines(self, tools, tmp_path):
|
||||
import time
|
||||
p = tmp_path / "a.json"
|
||||
_write(p, "\n".join('{"k": "' + "ab" * 600 + '"}' for _ in range(500)) + "\n")
|
||||
start = time.monotonic()
|
||||
result = tools.edit_file(str(p), '{"k": "' + "ab" * 550 + 'c"}', "x")
|
||||
assert "String not found" in result
|
||||
assert time.monotonic() - start < 5
|
||||
|
||||
def test_hint_prefilter_is_budgeted(self):
|
||||
# Every window passes real_quick_ratio but fails quick_ratio (a
|
||||
# pure-Python pass); that prefilter work must count against the budget
|
||||
# (unbudgeted this took ~4s on a 20k-line file).
|
||||
import random
|
||||
import string
|
||||
import time
|
||||
rnd = random.Random(0)
|
||||
line = lambda alpha: "".join(rnd.choice(alpha) for _ in range(20))
|
||||
content = "\n".join(line(string.ascii_lowercase) for _ in range(19999))
|
||||
old = "\n".join(line("abcdefghijkl") for _ in range(95))
|
||||
start = time.monotonic()
|
||||
EditTools._closest_region(content, old)
|
||||
assert time.monotonic() - start < 2
|
||||
|
||||
def test_closest_region_hint_with_line_numbers(self, tools, tmp_path):
|
||||
p = tmp_path / "a.py"
|
||||
_write(p, "import os\n\ndef compute(x):\n total = x * 2\n return total\n")
|
||||
result = tools.edit_file(str(p), "def compute(y):\n total = y * 3", "x")
|
||||
assert "String not found" in result
|
||||
assert "Closest match" in result
|
||||
assert "lines 3-4" in result
|
||||
assert " 3| def compute(x):" in result
|
||||
|
||||
def test_no_hint_when_nothing_similar(self, tools, tmp_path):
|
||||
p = tmp_path / "a.py"
|
||||
_write(p, "hello world\n")
|
||||
result = tools.edit_file(str(p), "zzzzqqqq", "x")
|
||||
assert "String not found" in result
|
||||
assert "Closest match" not in result
|
||||
|
||||
def test_apply_patch_hunk_hint(self, tools, tmp_path):
|
||||
p = tmp_path / "a.py"
|
||||
_write(p, "def compute(x):\n return x * 2\n")
|
||||
patch = (f"*** Update File: {p}\n@@\n"
|
||||
"def compute(y):\n return y * 2\n===\n"
|
||||
"def compute(y):\n return y * 3\n")
|
||||
result = tools.apply_patch(patch)
|
||||
assert "Hunk not found" in result
|
||||
assert " 1| def compute(x):" in result
|
||||
|
||||
|
||||
class TestFuzzyMatching:
|
||||
def test_line_trimmed_indentation(self, tools, tmp_path):
|
||||
p = tmp_path / "a.py"
|
||||
|
||||
@@ -64,6 +64,35 @@ def test_grep_glob_filters_by_pattern(sample_tree):
|
||||
assert "src" not in out
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pattern", ["**/*.py", "src/**/*.py", "src/**/**/test_*.py"])
|
||||
def test_grep_recursive_glob_matches_zero_directories(sample_tree, pattern):
|
||||
from praisonaiagents.tools import grep
|
||||
|
||||
direct = sample_tree / "src" / "test_direct.py"
|
||||
direct.write_text("needle\n")
|
||||
nested = sample_tree / "src" / "nested"
|
||||
nested.mkdir()
|
||||
(nested / "test_nested.py").write_text("needle\n")
|
||||
with patch("shutil.which", return_value=None):
|
||||
out = grep("needle", glob=pattern)
|
||||
assert "src/test_direct.py:1:" in out.replace(os.sep, "/")
|
||||
assert "src/nested/test_nested.py:1:" in out.replace(os.sep, "/")
|
||||
if pattern == "**/*.py":
|
||||
assert "a.py:2:" in out
|
||||
else:
|
||||
assert "a.py:2:" not in out
|
||||
if "test_" in pattern:
|
||||
assert "src/d.py:1:" not in out.replace(os.sep, "/")
|
||||
|
||||
|
||||
def test_grep_recursive_glob_single_file(sample_tree):
|
||||
from praisonaiagents.tools import grep
|
||||
|
||||
with patch("shutil.which", return_value=None):
|
||||
out = grep("needle", path="a.py", glob="**/*.py")
|
||||
assert "a.py:2:" in out
|
||||
|
||||
|
||||
def test_grep_case_insensitive(sample_tree):
|
||||
from praisonaiagents.tools import grep
|
||||
with patch("shutil.which", return_value=None):
|
||||
|
||||
@@ -491,7 +491,7 @@ class TestTeamTasks:
|
||||
return ApprovalDecision(approved=True, reason="looks good", approver="ana")
|
||||
|
||||
registry = get_approval_registry()
|
||||
previous = registry.get_backend()
|
||||
previous = registry._global_backend # not get_backend(): its ConsoleBackend fallback would be installed globally on restore
|
||||
registry.set_backend(Reviewer())
|
||||
try:
|
||||
writer = Agent(name="Writer", instructions="write")
|
||||
@@ -500,7 +500,7 @@ class TestTeamTasks:
|
||||
out = TaskOutput(description="Draft", raw="api_key=sk-secret123456 text", agent="Writer")
|
||||
team._apply_human_review(task, 0, out)
|
||||
finally:
|
||||
registry.set_backend(previous)
|
||||
registry._global_backend = previous
|
||||
requested, decided = _of(recorder, "approval_requested"), _of(recorder, "approval_decided")
|
||||
assert len(requested) == len(decided) == 1
|
||||
assert requested[0].data["kind"] == "task_output_review"
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Durable step cache: an interrupted AgentFlow resumes in a new process (#6249)."""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.workflows.step_cache import FileStepCache
|
||||
from praisonaiagents.workflows.workflows import AgentFlow
|
||||
|
||||
|
||||
def _step(name, calls, output=None, fail=False):
|
||||
def handler(ctx):
|
||||
calls.append(name)
|
||||
if fail:
|
||||
raise RuntimeError("interrupted")
|
||||
return output if output is not None else f"{name}-out"
|
||||
handler.__name__ = name
|
||||
return handler
|
||||
|
||||
|
||||
def test_resume_skips_completed_steps_across_instances(tmp_path):
|
||||
path = tmp_path / "run.json"
|
||||
calls = []
|
||||
first = AgentFlow(
|
||||
steps=[_step("a", calls), _step("b", calls, fail=True)],
|
||||
cache=FileStepCache(str(path)),
|
||||
)
|
||||
try:
|
||||
first.run("in", verbose=False)
|
||||
except Exception:
|
||||
pass
|
||||
assert calls[0] == "a" and "b" in calls
|
||||
assert path.exists()
|
||||
|
||||
# A fresh flow and a fresh cache object: simulates a new process.
|
||||
calls.clear()
|
||||
second = AgentFlow(
|
||||
steps=[_step("a", calls), _step("b", calls), _step("c", calls)],
|
||||
cache=FileStepCache(str(path)),
|
||||
)
|
||||
result = second.run("in", verbose=False)
|
||||
assert calls == ["b", "c"]
|
||||
assert result["output"] == "c-out"
|
||||
|
||||
|
||||
def test_changed_input_does_not_serve_stale_results(tmp_path):
|
||||
path = str(tmp_path / "run.json")
|
||||
calls = []
|
||||
AgentFlow(steps=[_step("a", calls)], cache=FileStepCache(path)).run("x", verbose=False)
|
||||
AgentFlow(steps=[_step("a", calls)], cache=FileStepCache(path)).run("y", verbose=False)
|
||||
assert calls == ["a", "a"]
|
||||
|
||||
|
||||
def test_changed_upstream_output_reruns_downstream(tmp_path):
|
||||
path = str(tmp_path / "run.json")
|
||||
calls = []
|
||||
AgentFlow(
|
||||
steps=[_step("a", calls, output="v1"), _step("b", calls)], cache=FileStepCache(path)
|
||||
).run("x", verbose=False)
|
||||
calls.clear()
|
||||
# Step "a" was reconfigured under the SAME name (its closure value
|
||||
# changed), so it and everything after it must re-run.
|
||||
AgentFlow(
|
||||
steps=[_step("a", calls, output="v2"), _step("b", calls)], cache=FileStepCache(path)
|
||||
).run("x", verbose=False)
|
||||
assert calls == ["a", "b"]
|
||||
|
||||
|
||||
def test_unserialisable_value_is_rejected_not_stored(tmp_path):
|
||||
path = tmp_path / "run.json"
|
||||
cache = FileStepCache(str(path))
|
||||
with pytest.raises(TypeError):
|
||||
cache.set("k", {"output": object()})
|
||||
assert cache.get("k") is None and not path.exists()
|
||||
|
||||
|
||||
def test_get_returns_a_copy_and_clear_removes_file(tmp_path):
|
||||
path = tmp_path / "run.json"
|
||||
cache = FileStepCache(str(path))
|
||||
cache.set("k", {"output": "o", "variables": {"x": 1}})
|
||||
cache.get("k")["variables"]["x"] = 2
|
||||
assert FileStepCache(str(path)).get("k") == {"output": "o", "variables": {"x": 1}}
|
||||
assert json.loads(path.read_text())["k"]["output"] == "o"
|
||||
cache.clear()
|
||||
assert len(cache) == 0 and not path.exists()
|
||||
|
||||
|
||||
def test_edited_handler_with_same_name_reruns(tmp_path):
|
||||
path = str(tmp_path / "run.json")
|
||||
calls = []
|
||||
|
||||
def a(ctx):
|
||||
calls.append("v1")
|
||||
return "v1"
|
||||
|
||||
AgentFlow(steps=[a], cache=FileStepCache(path)).run("x", verbose=False)
|
||||
|
||||
def a(ctx): # noqa: F811 - same name, edited body
|
||||
calls.append("v2")
|
||||
return "v2"
|
||||
|
||||
result = AgentFlow(steps=[a], cache=FileStepCache(path)).run("x", verbose=False)
|
||||
assert calls == ["v1", "v2"] and result["output"] == "v2"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [{"variables": {"lookup": {1: "one"}}}, {"output": ("a", "b")}])
|
||||
def test_value_that_changes_shape_in_json_is_rejected(tmp_path, value):
|
||||
cache = FileStepCache(str(tmp_path / "run.json"))
|
||||
with pytest.raises(TypeError):
|
||||
cache.set("k", value)
|
||||
assert cache.get("k") is None
|
||||
|
||||
|
||||
def test_two_writers_on_one_path_keep_both_entries(tmp_path):
|
||||
path = str(tmp_path / "run.json")
|
||||
a, b = FileStepCache(path), FileStepCache(path)
|
||||
a.set("a", {"output": "1"})
|
||||
b.set("b", {"output": "2"})
|
||||
reopened = FileStepCache(path)
|
||||
assert reopened.get("a") == {"output": "1"} and reopened.get("b") == {"output": "2"}
|
||||
|
||||
|
||||
|
||||
def test_set_constants_are_encoded_independent_of_iteration_order():
|
||||
# A set literal compiles to a frozenset constant whose iteration order
|
||||
# depends on PYTHONHASHSEED; the key must not, or resume silently re-runs.
|
||||
from praisonaiagents.workflows.step_cache import _stable, make_step_key
|
||||
|
||||
words = ["apple", "banana", "cherry", "date", "elder"]
|
||||
expected = "{" + ",".join(sorted(repr(w) for w in words)) + "}"
|
||||
assert _stable(frozenset(words), set()) == expected
|
||||
assert _stable(frozenset(reversed(words)), set()) == expected
|
||||
|
||||
def lookup(ctx):
|
||||
return "fruit" if ctx.input in {"apple", "banana", "cherry", "date", "elder"} else "other"
|
||||
|
||||
consts = lookup.__code__.co_consts
|
||||
assert any(isinstance(c, frozenset) for c in consts)
|
||||
# Same constants, frozenset rebuilt: the key must not change.
|
||||
rebuilt = lookup.__code__.replace(
|
||||
co_consts=tuple(frozenset(sorted(c, reverse=True)) if isinstance(c, frozenset) else c for c in consts)
|
||||
)
|
||||
import types
|
||||
twin = types.FunctionType(rebuilt, lookup.__globals__, "lookup")
|
||||
assert make_step_key(lookup, None, "apple", {}) == make_step_key(twin, None, "apple", {})
|
||||
assert make_step_key(lookup, None, "x", {"t": {"b", "a"}}) == make_step_key(lookup, None, "x", {"t": {"a", "b"}})
|
||||
|
||||
def test_edited_helper_or_default_reruns(tmp_path):
|
||||
from praisonaiagents.workflows.step_cache import make_step_key
|
||||
|
||||
def helper_v1():
|
||||
return 1
|
||||
|
||||
def helper_v2():
|
||||
return 2
|
||||
|
||||
def make(default):
|
||||
def step(ctx, mode=default):
|
||||
return mode
|
||||
return step
|
||||
|
||||
assert make_step_key(make("fast"), None, "x", {}) != make_step_key(make("slow"), None, "x", {})
|
||||
assert make_step_key(make("fast"), None, "x", {}) == make_step_key(make("fast"), None, "x", {})
|
||||
|
||||
import types
|
||||
|
||||
def step(ctx):
|
||||
return helper() # noqa: F821 - resolved from the globals given below
|
||||
|
||||
def bind(helper):
|
||||
return types.FunctionType(step.__code__, {"helper": helper}, "step")
|
||||
|
||||
assert make_step_key(bind(helper_v1), None, "x", {}) != make_step_key(bind(helper_v2), None, "x", {})
|
||||
assert make_step_key(bind(helper_v1), None, "x", {}) == make_step_key(bind(helper_v1), None, "x", {})
|
||||
|
||||
|
||||
def test_agent_step_with_same_name_but_new_instructions_misses():
|
||||
from praisonaiagents.workflows.step_cache import make_step_key
|
||||
|
||||
class FakeAgent:
|
||||
def __init__(self, instructions, llm="gpt-4o-mini"):
|
||||
self.name, self.instructions, self.llm, self.tools = "writer", instructions, llm, []
|
||||
|
||||
k = lambda a: make_step_key(a, None, "x", {})
|
||||
assert k(FakeAgent("be brief")) == k(FakeAgent("be brief"))
|
||||
assert k(FakeAgent("be brief")) != k(FakeAgent("be verbose"))
|
||||
assert k(FakeAgent("be brief")) != k(FakeAgent("be brief", llm="gpt-4o"))
|
||||
|
||||
|
||||
def test_agent_wrapped_in_task_keys_on_agent_config():
|
||||
# AgentFlow normalises an Agent step to Task(agent=...) before keying it,
|
||||
# so the wrapped agent's instructions and model must reach the key.
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.workflows.step_cache import make_step_key
|
||||
|
||||
def key(instructions, llm="gpt-4o-mini"):
|
||||
agent = Agent(name="writer", instructions=instructions, llm=llm)
|
||||
task = AgentFlow(steps=[agent])._normalize_single_step(agent, 0)
|
||||
assert task.agent is agent
|
||||
return make_step_key(task, None, "x", {})
|
||||
|
||||
assert key("be brief") == key("be brief")
|
||||
assert key("be brief") != key("be verbose")
|
||||
assert key("be brief") != key("be brief", llm="gpt-4o")
|
||||
@@ -157,11 +157,12 @@ class TestCorrectnessOnAHit:
|
||||
|
||||
def test_legacy_cache_entry_restores_output_variable_and_status(self):
|
||||
cache = InMemoryStepCache()
|
||||
cache.set(make_step_key("upstream", None, "same", {"input": "same"}), {
|
||||
calls = []
|
||||
upstream = _counting_step("upstream", calls)
|
||||
cache.set(make_step_key(upstream, None, "same", {"input": "same"}), {
|
||||
"output": "cached", "variables": {"upstream_output": "cached"},
|
||||
})
|
||||
calls = []
|
||||
flow = AgentFlow(steps=[_counting_step("upstream", calls)], cache=cache)
|
||||
flow = AgentFlow(steps=[upstream], cache=cache)
|
||||
result = flow.run("same", verbose=False)
|
||||
assert calls == []
|
||||
assert result["output"] == "cached"
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""An agent's YAML `execution:` block reaches ExecutionConfig (Issue #6206)."""
|
||||
from praisonaiagents.workflows.yaml_parser import YAMLWorkflowParser
|
||||
|
||||
|
||||
def test_execution_block_passes_budget_and_durable_keys():
|
||||
agent = YAMLWorkflowParser()._create_agent("worker", {
|
||||
"role": "Worker",
|
||||
"execution": {
|
||||
"max_budget": 1.5,
|
||||
"on_budget_exceeded": "warn",
|
||||
"durable": True,
|
||||
"resume_run_id": "run-123",
|
||||
},
|
||||
"max_iter": 7,
|
||||
})
|
||||
assert agent._max_budget == 1.5
|
||||
assert agent._on_budget_exceeded == "warn"
|
||||
assert agent.execution.durable is True
|
||||
assert agent.execution.resume_run_id == "run-123"
|
||||
assert agent.execution.max_iter == 7
|
||||
|
||||
|
||||
def test_flat_max_iter_overrides_execution_block():
|
||||
agent = YAMLWorkflowParser()._create_agent("worker", {
|
||||
"execution": {"max_iter": 3},
|
||||
"max_iter": 9,
|
||||
})
|
||||
assert agent.execution.max_iter == 9
|
||||
|
||||
|
||||
def test_no_execution_block_keeps_defaults():
|
||||
agent = YAMLWorkflowParser()._create_agent("worker", {"role": "Worker"})
|
||||
assert not agent._max_budget
|
||||
|
||||
|
||||
def test_roles_format_keeps_execution_block():
|
||||
"""The agents.yaml `roles:` format must not drop `execution:` in conversion."""
|
||||
parser = YAMLWorkflowParser()
|
||||
parser.parse_string(
|
||||
"framework: praisonai\n"
|
||||
"topic: t\n"
|
||||
"roles:\n"
|
||||
" worker:\n"
|
||||
" role: Worker\n"
|
||||
" goal: g\n"
|
||||
" backstory: b\n"
|
||||
" execution:\n"
|
||||
" max_budget: 2.0\n"
|
||||
" on_budget_exceeded: warn\n"
|
||||
" tasks:\n"
|
||||
" t1:\n"
|
||||
" description: do it\n"
|
||||
" expected_output: done\n"
|
||||
)
|
||||
agent = parser._agents["worker"]
|
||||
assert agent._max_budget == 2.0
|
||||
assert agent._on_budget_exceeded == "warn"
|
||||
Generated
+1
-1
@@ -3162,7 +3162,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.7.20"
|
||||
version = "1.7.22"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.19"
|
||||
__version__ = "0.1.21"
|
||||
@@ -28,7 +28,9 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import itertools
|
||||
import logging
|
||||
import math
|
||||
import sys
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Dict, Optional
|
||||
|
||||
@@ -106,9 +108,14 @@ class AdmissionRejected(Exception):
|
||||
busy acknowledgement to the user instead of an opaque error.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str = "Busy right now — please resend in a moment."):
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "Busy right now — please resend in a moment.",
|
||||
retry_after: Optional[float] = None,
|
||||
):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.retry_after = retry_after
|
||||
|
||||
|
||||
class _TurnSlot:
|
||||
@@ -249,8 +256,16 @@ class AdmissionGate:
|
||||
*,
|
||||
resource_policy: Optional[object] = None,
|
||||
resource_sampler: Optional[object] = None,
|
||||
rate_limit_policy: Optional[object] = None,
|
||||
):
|
||||
self._policy = policy
|
||||
# Issue #6215: optional per-identity request-rate ceiling (a core
|
||||
# ``RateLimitPolicyProtocol``, e.g. ``SlidingWindowRateLimitPolicy``)
|
||||
# checked before a turn is admitted, keyed by the caller's rate key
|
||||
# (defaults to the session id).
|
||||
self._rate_limit_policy = rate_limit_policy
|
||||
self.rate_limited = 0
|
||||
self._rate_prune_after = 0.0
|
||||
# Optional memory/resource-pressure policy (Issue #3445). When set, a
|
||||
# lightweight RSS sample is folded into each admit decision so the gate
|
||||
# queues under soft pressure and sheds under hard pressure before the
|
||||
@@ -303,6 +318,16 @@ class AdmissionGate:
|
||||
# otherwise assume active (it was explicitly supplied).
|
||||
return bool(getattr(pol, "enabled", True))
|
||||
|
||||
def set_rate_limit_policy(self, policy: Optional[object]) -> None:
|
||||
"""Swap the per-identity rate budget in place (config hot-reload)."""
|
||||
self._rate_limit_policy = policy
|
||||
self._rate_prune_after = 0.0
|
||||
|
||||
@property
|
||||
def _rate_limit_enabled(self) -> bool:
|
||||
pol = self._rate_limit_policy
|
||||
return pol is not None and bool(getattr(pol, "enabled", True))
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Whether the gate is active.
|
||||
@@ -313,7 +338,7 @@ class AdmissionGate:
|
||||
"""
|
||||
if self._policy is not None and self._max > 0:
|
||||
return True
|
||||
return self._resource_enabled
|
||||
return self._resource_enabled or self._rate_limit_enabled
|
||||
|
||||
@property
|
||||
def in_flight(self) -> int:
|
||||
@@ -337,6 +362,10 @@ class AdmissionGate:
|
||||
"admitted": self.admitted,
|
||||
"rejected": self.rejected,
|
||||
"shed": self.shed,
|
||||
"per_user_per_minute": int(
|
||||
getattr(self._rate_limit_policy, "max_requests", 0) or 0
|
||||
),
|
||||
"rate_limited": self.rate_limited,
|
||||
"max_rss_mb": float(
|
||||
getattr(self._resource_policy, "hard_rss_mb", 0.0) or 0.0
|
||||
),
|
||||
@@ -362,9 +391,14 @@ class AdmissionGate:
|
||||
return self._sem
|
||||
|
||||
@asynccontextmanager
|
||||
async def admit(self, *, session_id: str = ""):
|
||||
async def admit(self, *, session_id: str = "", rate_key: Optional[str] = None):
|
||||
"""Admit (or shed) an inbound turn, holding a slot for its duration.
|
||||
|
||||
``rate_key`` is the identity charged against the per-user request
|
||||
budget. It defaults to ``session_id``; surfaces where the session id is
|
||||
client-chosen (API headers, WebSocket sessions) pass a stable caller
|
||||
identity instead so a new conversation cannot reset the budget.
|
||||
|
||||
Yields when capacity is available (immediately or after waiting in the
|
||||
bounded queue). Raises :class:`AdmissionRejected` when the policy sheds
|
||||
the turn. A no-op pass-through when the gate is disabled.
|
||||
@@ -374,6 +408,8 @@ class AdmissionGate:
|
||||
yield
|
||||
return
|
||||
|
||||
self._check_rate_limit(rate_key or session_id)
|
||||
|
||||
# Issue #5168: the scope key is the caller-supplied session_id (the
|
||||
# per-user/per-tenant storage key at the call sites). Only tracked when
|
||||
# a per-scope sub-limit is active, so the unscoped path is unchanged.
|
||||
@@ -473,6 +509,54 @@ class AdmissionGate:
|
||||
pass
|
||||
slot.close()
|
||||
|
||||
def _check_rate_limit(self, session_id: str) -> None:
|
||||
"""Shed the turn if *session_id* is over its request-rate budget (#6215)."""
|
||||
if not self._rate_limit_enabled:
|
||||
return
|
||||
pol = self._rate_limit_policy
|
||||
now = time.monotonic()
|
||||
try:
|
||||
decision = pol.check(identity=session_id or "-", scope="ingress", now=now)
|
||||
except Exception as e: # pragma: no cover — never block on policy error
|
||||
logger.warning("AdmissionGate: rate-limit policy failed (%s); admitting", e)
|
||||
return
|
||||
finally:
|
||||
# Bounded cadence: a burst of distinct callers must not trigger a
|
||||
# full scan on every request while their windows are still live.
|
||||
if now >= self._rate_prune_after:
|
||||
self._rate_prune_after = now + _RATE_PRUNE_INTERVAL
|
||||
self._prune_rate_limit_state(pol, now)
|
||||
if decision.allowed:
|
||||
return
|
||||
self.rate_limited += 1
|
||||
retry = decision.retry_after_seconds
|
||||
wait = max(1, math.ceil(retry)) if retry is not None else None
|
||||
logger.info("AdmissionGate: rate-limited session=%s retry_after=%s", session_id or "-", wait)
|
||||
if wait is None:
|
||||
raise AdmissionRejected("Rate limit reached — please try again later.")
|
||||
raise AdmissionRejected(
|
||||
f"Rate limit reached — please try again in {wait}s.", retry_after=float(wait)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _prune_rate_limit_state(pol: object, now: float, cap: int = 4096) -> None:
|
||||
"""Drop expired per-identity entries so an open bot cannot grow them forever.
|
||||
|
||||
``SlidingWindowRateLimitPolicy`` reclaims a key only when that key is
|
||||
checked again and leaves reclamation of an unbounded identity space to
|
||||
the wrapper; inbound users are such a space.
|
||||
"""
|
||||
windows = getattr(pol, "_windows", None)
|
||||
lockouts = getattr(pol, "_lockouts", None)
|
||||
if not isinstance(windows, dict) or len(windows) + len(lockouts or {}) <= cap:
|
||||
return
|
||||
span = float(getattr(pol, "window_seconds", 0) or 0)
|
||||
for key in [k for k, (start, _) in windows.items() if now - start >= span]:
|
||||
windows.pop(key, None)
|
||||
if isinstance(lockouts, dict):
|
||||
for key in [k for k, until in lockouts.items() if now >= until]:
|
||||
lockouts.pop(key, None)
|
||||
|
||||
def _take_slot(self, scope: str) -> None:
|
||||
self._in_flight += 1
|
||||
if scope:
|
||||
@@ -596,6 +680,46 @@ def build_memory_pressure_policy(
|
||||
return MemoryPressurePolicy(soft_rss_mb=soft, hard_rss_mb=hard)
|
||||
|
||||
|
||||
_RATE_PRUNE_INTERVAL = 5.0
|
||||
|
||||
|
||||
def build_rate_limit_policy(per_user_per_minute: object) -> Optional[object]:
|
||||
"""Return a per-user request budget policy, or ``None`` when unlimited.
|
||||
|
||||
Accepts a non-negative whole number (``0``/``None`` = unlimited). Fractional,
|
||||
boolean, negative or non-numeric values raise ``ValueError`` so a typo never
|
||||
silently disables the budget the operator asked for.
|
||||
"""
|
||||
value = per_user_per_minute
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
|
||||
raise ValueError(
|
||||
f"per_user_per_minute must be a non-negative integer, got {value!r}"
|
||||
)
|
||||
try:
|
||||
if isinstance(value, float):
|
||||
if not value.is_integer():
|
||||
raise ValueError
|
||||
value = int(value)
|
||||
else:
|
||||
value = int(value)
|
||||
except (TypeError, ValueError) as err:
|
||||
raise ValueError(
|
||||
f"per_user_per_minute must be a non-negative integer, "
|
||||
f"got {per_user_per_minute!r}"
|
||||
) from err
|
||||
if value < 0:
|
||||
raise ValueError(
|
||||
f"per_user_per_minute must be >= 0, got {per_user_per_minute!r}"
|
||||
)
|
||||
if value == 0:
|
||||
return None
|
||||
from praisonaiagents.gateway import SlidingWindowRateLimitPolicy
|
||||
|
||||
return SlidingWindowRateLimitPolicy(max_requests=value, window_seconds=60.0)
|
||||
|
||||
|
||||
def build_admission_gate(
|
||||
max_concurrent_runs: int = 0,
|
||||
queue_depth: int = 0,
|
||||
@@ -603,6 +727,7 @@ def build_admission_gate(
|
||||
policy: Optional[object] = None,
|
||||
resource_policy: Optional[object] = None,
|
||||
max_concurrent_runs_per_scope: int = 0,
|
||||
per_user_per_minute: int = 0,
|
||||
) -> Optional[AdmissionGate]:
|
||||
"""Construct an :class:`AdmissionGate` from config, or return ``None``.
|
||||
|
||||
@@ -620,7 +745,13 @@ def build_admission_gate(
|
||||
gate so it queues under soft RSS pressure and sheds under hard pressure. A
|
||||
gate is built when *either* a concurrency ceiling *or* a resource policy is
|
||||
configured, so a memory-only deployment still gets backpressure.
|
||||
|
||||
``per_user_per_minute`` (Issue #6215) caps how many turns one identity may
|
||||
start per minute; ``0`` (the default) leaves ingress unlimited. Over the cap
|
||||
the turn is refused with a ``retry_after`` hint before any work starts.
|
||||
"""
|
||||
# Raises ValueError on a negative / non-integer value (fail fast).
|
||||
rate_limit_policy = build_rate_limit_policy(per_user_per_minute)
|
||||
if policy is None:
|
||||
# Only an explicit ``0`` (or ``None``) disables admission control. A
|
||||
# *negative* ceiling is a misconfiguration, not "disabled": fall through
|
||||
@@ -634,8 +765,12 @@ def build_admission_gate(
|
||||
if no_ceiling:
|
||||
# No concurrency ceiling: build a resource-only gate when a
|
||||
# resource policy is supplied, else no gate (legacy behaviour).
|
||||
if resource_policy is not None:
|
||||
return AdmissionGate(None, resource_policy=resource_policy)
|
||||
if resource_policy is not None or rate_limit_policy is not None:
|
||||
return AdmissionGate(
|
||||
None,
|
||||
resource_policy=resource_policy,
|
||||
rate_limit_policy=rate_limit_policy,
|
||||
)
|
||||
return None
|
||||
try:
|
||||
from praisonaiagents.gateway import ConcurrencyLimitPolicy
|
||||
@@ -647,4 +782,6 @@ def build_admission_gate(
|
||||
overflow_policy=overflow_policy,
|
||||
max_concurrent_runs_per_scope=max_concurrent_runs_per_scope,
|
||||
)
|
||||
return AdmissionGate(policy, resource_policy=resource_policy)
|
||||
return AdmissionGate(
|
||||
policy, resource_policy=resource_policy, rate_limit_policy=rate_limit_policy
|
||||
)
|
||||
@@ -873,6 +873,15 @@ class HealthMonitorSchema(BaseModel):
|
||||
breaker_cooldown_s: float = Field(120.0, ge=0)
|
||||
|
||||
|
||||
class OperatorGrantSchema(BaseModel):
|
||||
"""Per-token operator grant (Issue #6253). Unknown keys are rejected so a
|
||||
typo (``agnts``) cannot silently drop an agent confinement."""
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
scopes: List[str] = Field(default_factory=list)
|
||||
agents: Optional[List[str]] = None
|
||||
|
||||
|
||||
class GatewayServerSchema(BaseModel):
|
||||
"""Typed schema for the ``gateway:`` server block (issue #3050).
|
||||
|
||||
@@ -896,7 +905,8 @@ class GatewayServerSchema(BaseModel):
|
||||
allowed_origins: Optional[List[str]] = None
|
||||
auth_token: Optional[str] = None
|
||||
auth: Optional[Dict[str, Any]] = None
|
||||
auth_scopes: Optional[Dict[str, List[str]]] = None
|
||||
# Issue #6253: token -> [scopes] or {scopes: [...], agents: [...]}.
|
||||
auth_scopes: Optional[Dict[str, Union[List[str], OperatorGrantSchema]]] = None
|
||||
max_connections: Optional[int] = Field(None, ge=0)
|
||||
max_sessions_per_agent: Optional[int] = Field(None, ge=0)
|
||||
session_config: Optional[Dict[str, Any]] = None
|
||||
@@ -912,6 +922,8 @@ class GatewayServerSchema(BaseModel):
|
||||
max_concurrent_runs: Optional[int] = Field(None, ge=0)
|
||||
queue_depth: Optional[int] = Field(None, ge=0)
|
||||
overflow_policy: Optional[str] = None
|
||||
# Per-user inbound request budget (#6215). 0 = unlimited (default).
|
||||
per_user_per_minute: Optional[int] = Field(None, ge=0)
|
||||
preauth_max_connections_per_ip: Optional[int] = Field(None, ge=0)
|
||||
max_unauthorized_frames: Optional[int] = Field(None, ge=0)
|
||||
# Graceful-drain windows (#2375 / #2533)
|
||||
|
||||
@@ -23,7 +23,7 @@ wrapper's delivery stack, neither of which belong in the core SDK.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
from praisonaiagents.gateway import (
|
||||
DeliveryResult,
|
||||
@@ -61,6 +61,13 @@ class BotOutboundMessenger:
|
||||
media_policy: Optional :class:`OutboundMediaPolicy` governing which
|
||||
agent-supplied file paths may be uploaded (exfiltration guard).
|
||||
Defaults to a permissive-but-denylisted policy.
|
||||
liveness: Optional zero-arg predicate returning ``True`` while the
|
||||
originating turn is still live (same contract as
|
||||
``ApprovalRequest.liveness``). When it reports stale, every
|
||||
outbound effect (send/react/create_thread/edit/delete) is dropped
|
||||
fail-closed, so a stopped or superseded turn — or a retained
|
||||
handle to its messenger — cannot reach a user channel. ``None``
|
||||
(default) keeps today's unbound behaviour.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -69,10 +76,35 @@ class BotOutboundMessenger:
|
||||
*,
|
||||
origin: Optional[SessionSource] = None,
|
||||
media_policy: Optional[OutboundMediaPolicy] = None,
|
||||
liveness: Optional[Callable[[], bool]] = None,
|
||||
) -> None:
|
||||
self._router = router
|
||||
self._origin = origin
|
||||
self._media_policy = media_policy or OutboundMediaPolicy()
|
||||
self._liveness = liveness
|
||||
|
||||
_STALE_DETAIL = "turn no longer live (stopped or superseded)"
|
||||
|
||||
def _is_stale(self) -> bool:
|
||||
"""Whether the originating turn was stopped/superseded (#6160).
|
||||
|
||||
Mirrors ``ApprovalRegistry._reject_if_stale``: ``None`` liveness means
|
||||
always live, and a probe that raises is treated as live (fail-open on
|
||||
the *probe*, never on the effect) so a buggy predicate cannot wedge
|
||||
delivery.
|
||||
"""
|
||||
live = self._liveness
|
||||
if live is None:
|
||||
return False
|
||||
try:
|
||||
stale = not live()
|
||||
except Exception: # noqa: BLE001 - a liveness probe must never crash
|
||||
return False
|
||||
if stale:
|
||||
logger.warning(
|
||||
"BotOutboundMessenger: dropping outbound effect from a stale turn"
|
||||
)
|
||||
return stale
|
||||
|
||||
async def send(
|
||||
self,
|
||||
@@ -90,6 +122,13 @@ class BotOutboundMessenger:
|
||||
platform adapter. The result truthfully reports how many attachments
|
||||
were delivered, skipped by policy, or unsupported by the transport.
|
||||
"""
|
||||
if self._is_stale():
|
||||
return DeliveryResult(
|
||||
ok=False,
|
||||
target=target,
|
||||
summary=f"Not sent to {target}: {self._STALE_DETAIL}.",
|
||||
detail=self._STALE_DETAIL,
|
||||
)
|
||||
try:
|
||||
platform, channel_id, _thread_id = self._router.resolve(target, self._origin)
|
||||
except ValueError as e:
|
||||
@@ -244,7 +283,18 @@ class BotOutboundMessenger:
|
||||
# Only the first attachment carries the caption to avoid repeating the
|
||||
# full text on every file.
|
||||
first = True
|
||||
for path in media:
|
||||
for index, path in enumerate(media):
|
||||
# Re-check liveness before every upload (#6160): /stop only sets
|
||||
# the controller flag, it does not cancel this coroutine, so a turn
|
||||
# stopped mid-send must not keep uploading the remaining files.
|
||||
if self._is_stale():
|
||||
remaining = len(media) - index
|
||||
skipped += remaining
|
||||
notes.append(
|
||||
f"Stopped: {remaining} attachment(s) not sent, "
|
||||
f"{self._STALE_DETAIL}."
|
||||
)
|
||||
break
|
||||
try:
|
||||
safe = validate_media_delivery_path(
|
||||
path, policy=self._media_policy
|
||||
@@ -294,6 +344,10 @@ class BotOutboundMessenger:
|
||||
message with a single emoji instead of a full text reply. A channel that
|
||||
cannot react returns a typed ``unsupported`` outcome rather than raising.
|
||||
"""
|
||||
if self._is_stale():
|
||||
return ReactionResult(
|
||||
status="failed", target=target, detail=self._STALE_DETAIL
|
||||
)
|
||||
resolved_message_id = message_id
|
||||
if not resolved_message_id and target == "origin" and self._origin is not None:
|
||||
resolved_message_id = self._origin.message_id or ""
|
||||
@@ -335,6 +389,10 @@ class BotOutboundMessenger:
|
||||
target = f"slack:C123:{res.thread_id}" if res.ok else "slack:C123"
|
||||
await messenger.send(target, subtask_output)
|
||||
"""
|
||||
if self._is_stale():
|
||||
return ThreadResult(
|
||||
status="failed", target=target, detail=self._STALE_DETAIL
|
||||
)
|
||||
status, resolved, thread_id = await self._router.create_thread(
|
||||
target,
|
||||
name,
|
||||
@@ -367,6 +425,10 @@ class BotOutboundMessenger:
|
||||
cannot edit returns a typed ``unsupported`` outcome rather than raising,
|
||||
so the agent can fall back to sending a follow-up.
|
||||
"""
|
||||
if self._is_stale():
|
||||
return MessageActionResult(
|
||||
status="failed", target=target, detail=self._STALE_DETAIL
|
||||
)
|
||||
status, resolved = await self._router.edit(
|
||||
target, message_id, text, self._origin
|
||||
)
|
||||
@@ -388,6 +450,10 @@ class BotOutboundMessenger:
|
||||
channel that cannot delete returns a typed ``unsupported`` outcome
|
||||
rather than raising.
|
||||
"""
|
||||
if self._is_stale():
|
||||
return MessageActionResult(
|
||||
status="failed", target=target, detail=self._STALE_DETAIL
|
||||
)
|
||||
status, resolved = await self._router.delete(
|
||||
target, message_id, self._origin
|
||||
)
|
||||
|
||||
@@ -1342,8 +1342,24 @@ class BotSessionManager:
|
||||
thread_id=thread_id,
|
||||
message_id=message_id or None,
|
||||
)
|
||||
# Issue #6160: bind outbound effects to this turn's liveness.
|
||||
# The run-control InterruptController is created fresh per run
|
||||
# and set on /stop or supersede, so a stopped/superseded turn
|
||||
# (or a retained handle to this messenger) is dropped
|
||||
# fail-closed. A normally completed turn never sets it, so
|
||||
# post-turn background deliveries keep working. No controller
|
||||
# (run control off) -> unbound, today's behaviour.
|
||||
from praisonaiagents.agent.interrupt import InterruptController
|
||||
|
||||
_ic = getattr(agent, "interrupt_controller", None)
|
||||
messenger = BotOutboundMessenger(
|
||||
self._delivery_router, origin=origin_source
|
||||
self._delivery_router,
|
||||
origin=origin_source,
|
||||
liveness=(
|
||||
(lambda: not _ic.is_set())
|
||||
if isinstance(_ic, InterruptController)
|
||||
else None
|
||||
),
|
||||
)
|
||||
messenger_token = _register_messenger(messenger)
|
||||
except Exception as e: # pragma: no cover — defensive
|
||||
|
||||
@@ -217,7 +217,7 @@ class StreamingMode(Enum):
|
||||
OFF = "off" # Current behavior (single final message, chunked)
|
||||
DRAFT = "draft" # Send placeholder, edit in place with growing content
|
||||
PROGRESS = "progress" # Show compact status, then replace with final answer
|
||||
AUTO = "auto" # Stream (DRAFT) where the channel can edit, else OFF
|
||||
AUTO = "auto" # DRAFT where the channel can edit, else status text, else OFF
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -401,6 +401,24 @@ class DraftStreamer:
|
||||
if self._edit_rate_limit > 0:
|
||||
self._current_min_interval = max(self._current_min_interval, self._edit_rate_limit)
|
||||
|
||||
# Edit-less channels that declare ``supports_status_text`` (and back it
|
||||
# with ``set_status_text``) get the curated activity line as a textual
|
||||
# status instead of silence: degrade edit -> status-text -> off.
|
||||
pcaps = getattr(adapter, 'platform_capabilities', None)
|
||||
self._status_text_only = bool(
|
||||
not self._can_edit
|
||||
and self._config.mode in (StreamingMode.AUTO, StreamingMode.PROGRESS)
|
||||
and getattr(pcaps, 'supports_status_text', False) is True
|
||||
and callable(getattr(adapter, 'set_status_text', None))
|
||||
)
|
||||
self._last_status_text: Optional[str] = None
|
||||
if self._status_text_only:
|
||||
logger.info(
|
||||
"Channel %s can't edit; routing streaming mode '%s' to status text",
|
||||
channel_id, self._config.mode.value,
|
||||
)
|
||||
self._config = replace(self._config, mode=StreamingMode.PROGRESS)
|
||||
|
||||
# Resolve AUTO to a concrete mode: stream where the channel can edit,
|
||||
# otherwise fall back to OFF. This makes "stream where supported" an
|
||||
# explicit, opt-in choice instead of every platform silently sitting on
|
||||
@@ -419,7 +437,11 @@ class DraftStreamer:
|
||||
|
||||
# Override config if channel doesn't support editing. Logged (not
|
||||
# silent) so operators can see why a channel isn't streaming.
|
||||
if not self._can_edit and self._config.mode != StreamingMode.OFF:
|
||||
if (
|
||||
not self._can_edit
|
||||
and not self._status_text_only
|
||||
and self._config.mode != StreamingMode.OFF
|
||||
):
|
||||
logger.warning(
|
||||
"Channel %s doesn't support live editing; degrading streaming "
|
||||
"mode '%s' to 'off'",
|
||||
@@ -441,6 +463,16 @@ class DraftStreamer:
|
||||
if self._config.mode == StreamingMode.OFF:
|
||||
raise ValueError("Cannot start streaming when mode is OFF")
|
||||
|
||||
if self._status_text_only:
|
||||
# No placeholder message: the status line is the only live surface.
|
||||
self._message_id = ""
|
||||
self._last_edit_time = time.monotonic()
|
||||
try:
|
||||
await self._push_status_text(self._config.placeholder_text)
|
||||
except Exception as e: # noqa: BLE001 - status is advisory; never break the turn
|
||||
self._handle_edit_failure(e)
|
||||
return self._message_id
|
||||
|
||||
if self._rate_limiter:
|
||||
await self._rate_limiter.acquire(self._channel_id)
|
||||
|
||||
@@ -463,11 +495,33 @@ class DraftStreamer:
|
||||
logger.debug("DraftStreamer started, placeholder sent with message_id=%s", self._message_id)
|
||||
return self._message_id
|
||||
|
||||
async def _push_status_text(self, text: str) -> None:
|
||||
"""Push a textual status line; skips unchanged lines (no API cost).
|
||||
|
||||
Rate-limited like edits. Raises on adapter failure so callers apply the
|
||||
shared flood-control backoff (``_handle_edit_failure``).
|
||||
"""
|
||||
text = redact_outbound_text(text, self._adapter)
|
||||
if text == self._last_status_text:
|
||||
return
|
||||
if self._rate_limiter:
|
||||
await self._rate_limiter.acquire(self._channel_id)
|
||||
await self._adapter.set_status_text(self._channel_id, text)
|
||||
self._last_status_text = text
|
||||
|
||||
@property
|
||||
def _active(self) -> bool:
|
||||
"""Whether a live surface (placeholder or status line) is in use."""
|
||||
return self._message_id is not None and (
|
||||
bool(self._message_id) or self._status_text_only
|
||||
)
|
||||
|
||||
@property
|
||||
def _feed_style(self) -> bool:
|
||||
"""Whether the multi-line rolling progress feed is enabled."""
|
||||
return (
|
||||
self._config.mode == StreamingMode.PROGRESS
|
||||
not self._status_text_only
|
||||
and self._config.mode == StreamingMode.PROGRESS
|
||||
and self._config.progress_style == "feed"
|
||||
)
|
||||
|
||||
@@ -485,7 +539,7 @@ class DraftStreamer:
|
||||
|
||||
async def on_event(self, event: "StreamEvent") -> None:
|
||||
"""Stream event callback to handle progressive updates."""
|
||||
if self._is_finalized or not self._message_id:
|
||||
if self._is_finalized or not self._active:
|
||||
return
|
||||
|
||||
# Import here to avoid circular imports
|
||||
@@ -624,6 +678,8 @@ class DraftStreamer:
|
||||
content = self._config.placeholder_text
|
||||
elif self._current_tool:
|
||||
content = self._activity_line(self._current_tool)
|
||||
elif self._status_text_only:
|
||||
content = self._config.placeholder_text
|
||||
elif buffer:
|
||||
content = buffer
|
||||
else:
|
||||
@@ -645,20 +701,24 @@ class DraftStreamer:
|
||||
|
||||
async def _perform_update(self) -> None:
|
||||
"""Perform the actual message edit with flood-control handling."""
|
||||
if not self._message_id or self._is_finalized or not self._progressive:
|
||||
if not self._active or self._is_finalized or not self._progressive:
|
||||
return
|
||||
|
||||
content = self._render_content()
|
||||
if content is None:
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
# Apply rate limiting
|
||||
if self._rate_limiter:
|
||||
await self._rate_limiter.acquire(self._channel_id)
|
||||
|
||||
# Perform edit
|
||||
await self._adapter.edit_message(self._channel_id, self._message_id, content)
|
||||
if self._status_text_only:
|
||||
# Same rate limiting and flood backoff as edits.
|
||||
await self._push_status_text(content)
|
||||
else:
|
||||
# Apply rate limiting
|
||||
if self._rate_limiter:
|
||||
await self._rate_limiter.acquire(self._channel_id)
|
||||
|
||||
# Perform edit
|
||||
await self._adapter.edit_message(self._channel_id, self._message_id, content)
|
||||
|
||||
# Success - reset failure streak and update state
|
||||
self._fail_streak = 0
|
||||
@@ -709,7 +769,7 @@ class DraftStreamer:
|
||||
|
||||
async def finalize(self, final_content: str) -> None:
|
||||
"""Finalize with the complete response content."""
|
||||
if self._is_finalized or not self._message_id:
|
||||
if self._is_finalized or not self._active:
|
||||
return
|
||||
|
||||
self._is_finalized = True
|
||||
@@ -752,11 +812,12 @@ class DraftStreamer:
|
||||
# (to avoid leaving a stale "🤔 Thinking..." draft) and fall back to a
|
||||
# new send below.
|
||||
delivered = False
|
||||
try:
|
||||
await self._adapter.edit_message(self._channel_id, self._message_id, chunks[0])
|
||||
delivered = True
|
||||
except Exception as e: # noqa: BLE001 - adapter boundary; preserve final delivery
|
||||
logger.warning("DraftStreamer final edit failed: %s", e)
|
||||
if not self._status_text_only: # status-text mode has no placeholder to edit
|
||||
try:
|
||||
await self._adapter.edit_message(self._channel_id, self._message_id, chunks[0])
|
||||
delivered = True
|
||||
except Exception as e: # noqa: BLE001 - adapter boundary; preserve final delivery
|
||||
logger.warning("DraftStreamer final edit failed: %s", e)
|
||||
|
||||
# Fallback: if the edit failed (or progressive editing was disabled by
|
||||
# flooding), deliver the full answer as a fresh send so the user ALWAYS
|
||||
@@ -782,6 +843,15 @@ class DraftStreamer:
|
||||
delivered = False
|
||||
logger.warning("DraftStreamer follow-on chunk send failed: %s", chunk_exc)
|
||||
|
||||
# Clear the status line so a finished turn no longer looks busy. Runs
|
||||
# after delivery so it can never delay or block the answer.
|
||||
if self._status_text_only and self._last_status_text:
|
||||
try:
|
||||
await self._adapter.set_status_text(self._channel_id, "")
|
||||
self._last_status_text = ""
|
||||
except Exception as e: # noqa: BLE001 - status is advisory
|
||||
logger.debug("DraftStreamer status clear failed: %s", e)
|
||||
|
||||
logger.debug(
|
||||
"DraftStreamer finalized message %s, final_length=%d, progressive=%s, delivered=%s",
|
||||
self._message_id, len(final_content), self._progressive, delivered
|
||||
@@ -790,7 +860,7 @@ class DraftStreamer:
|
||||
@property
|
||||
def is_streaming(self) -> bool:
|
||||
"""Check if streaming is active."""
|
||||
return bool(self._message_id and not self._is_finalized)
|
||||
return bool(self._active and not self._is_finalized)
|
||||
|
||||
@property
|
||||
def has_content(self) -> bool:
|
||||
|
||||
@@ -106,6 +106,49 @@ def _coerce_drain_timeout(value: Optional[Any]) -> Optional[float]:
|
||||
return timeout
|
||||
|
||||
|
||||
# Issue #6210: keys ``BotOS.from_config`` actually reads. Anything else in a
|
||||
# BotOS YAML used to be dropped silently.
|
||||
_BOTOS_TOP_KEYS = frozenset({
|
||||
"name", "agent", "platforms", "health", "supervision", "gateway",
|
||||
"drain_timeout", "max_concurrent_runs", "queue_depth", "overflow_policy",
|
||||
"max_concurrent_runs_per_scope", "max_rss_mb", "reliability",
|
||||
"per_user_per_minute",
|
||||
})
|
||||
_BOTOS_GATEWAY_KEYS = frozenset({
|
||||
"drain_timeout", "max_concurrent_runs", "queue_depth", "overflow_policy",
|
||||
"max_concurrent_runs_per_scope", "max_rss_mb", "reliability",
|
||||
"per_user_per_minute",
|
||||
})
|
||||
# Sections that only the gateway runtime (``praisonai gateway start``) honours.
|
||||
_GATEWAY_ONLY_KEYS = frozenset({
|
||||
"channels", "agents", "routing", "schedules", "rooms", "hooks",
|
||||
"access_groups", "mcp_servers", "identity",
|
||||
})
|
||||
|
||||
|
||||
def _check_botos_config_shape(raw: Dict[str, Any], path: str) -> None:
|
||||
"""Fail loudly on gateway-only YAML and warn on keys BotOS will not read."""
|
||||
gateway_only = sorted(k for k in raw if k in _GATEWAY_ONLY_KEYS)
|
||||
if gateway_only:
|
||||
raise ValueError(
|
||||
f"{path} is a gateway config (sections: {', '.join(gateway_only)}) "
|
||||
"which BotOS.from_config() cannot honour. Run it the same way as "
|
||||
"`praisonai gateway start --config`: "
|
||||
"asyncio.run(WebSocketGateway().start_with_config(path)) "
|
||||
"(from praisonai_bot import WebSocketGateway)."
|
||||
)
|
||||
ignored = [str(k) for k in raw if k not in _BOTOS_TOP_KEYS]
|
||||
gateway_cfg = raw.get("gateway")
|
||||
if isinstance(gateway_cfg, dict):
|
||||
ignored += [f"gateway.{k}" for k in gateway_cfg if k not in _BOTOS_GATEWAY_KEYS]
|
||||
if ignored:
|
||||
logger.warning(
|
||||
"BotOS.from_config(%s): ignoring unsupported keys %s "
|
||||
"(use WebSocketGateway().start_with_config() for full gateway configs)",
|
||||
path, ", ".join(sorted(ignored)),
|
||||
)
|
||||
|
||||
|
||||
class BotOS:
|
||||
"""Multi-platform bot orchestrator.
|
||||
|
||||
@@ -150,6 +193,7 @@ class BotOS:
|
||||
admission_policy: Optional[Any] = None,
|
||||
max_rss_mb: float = 0.0,
|
||||
reliability: Optional[str] = None,
|
||||
per_user_per_minute: int = 0,
|
||||
turn_lock: Optional[Any] = None,
|
||||
redis_config: Optional[Any] = None,
|
||||
degraded_registry: Optional[Any] = None,
|
||||
@@ -224,6 +268,8 @@ class BotOS:
|
||||
max_concurrent_runs_per_scope=max_concurrent_runs_per_scope,
|
||||
policy=admission_policy,
|
||||
resource_policy=build_memory_pressure_policy(max_rss_mb),
|
||||
# Issue #6215: per-user inbound request budget (0 = unlimited).
|
||||
per_user_per_minute=per_user_per_minute,
|
||||
)
|
||||
self._on_quiesce = None # optional callable(): host-suspend driver
|
||||
# W1: shared identity resolver applied to every managed bot —
|
||||
@@ -1600,11 +1646,22 @@ class BotOS:
|
||||
startup_grace: 60
|
||||
max_restarts_per_hour: 10
|
||||
|
||||
This is the lightweight BotOS dialect. A full ``gateway.yaml`` (with
|
||||
``channels:``/``agents:``/``routing:``/``schedules:`` …) is *not*
|
||||
accepted here — it raises ``ValueError`` instead of being silently
|
||||
degraded. Run such a file exactly like ``praisonai gateway start``::
|
||||
|
||||
from praisonai_bot import WebSocketGateway
|
||||
asyncio.run(WebSocketGateway().start_with_config("gateway.yaml"))
|
||||
|
||||
Args:
|
||||
path: Path to YAML config file.
|
||||
|
||||
Returns:
|
||||
Configured BotOS instance.
|
||||
|
||||
Raises:
|
||||
ValueError: If the file uses gateway-only sections.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
@@ -1620,6 +1677,8 @@ class BotOS:
|
||||
if not raw or not isinstance(raw, dict):
|
||||
raise ValueError(f"Invalid BotOS config: {path}")
|
||||
|
||||
_check_botos_config_shape(raw, path)
|
||||
|
||||
def _resolve_env(val):
|
||||
if isinstance(val, str):
|
||||
return re.sub(
|
||||
@@ -1788,6 +1847,11 @@ class BotOS:
|
||||
# ``reliability`` or ``gateway.reliability``; the preset composes drain
|
||||
# + admission while any explicit field above still overrides it.
|
||||
reliability = raw.get("reliability", gateway_cfg.get("reliability"))
|
||||
# Issue #6215: per-user inbound request budget. Default 0 = unlimited.
|
||||
# Validated (not truncated) by ``build_rate_limit_policy``.
|
||||
per_user_per_minute = raw.get(
|
||||
"per_user_per_minute", gateway_cfg.get("per_user_per_minute", 0)
|
||||
)
|
||||
|
||||
return cls(
|
||||
bots=bots,
|
||||
@@ -1800,6 +1864,7 @@ class BotOS:
|
||||
max_concurrent_runs_per_scope=max_concurrent_runs_per_scope,
|
||||
max_rss_mb=max_rss_mb,
|
||||
reliability=reliability,
|
||||
per_user_per_minute=per_user_per_minute,
|
||||
)
|
||||
|
||||
async def probe_all(self, timeout: float = 15.0) -> Dict[str, Any]:
|
||||
|
||||
@@ -1427,6 +1427,36 @@
|
||||
"title": "HookSchema",
|
||||
"type": "object"
|
||||
},
|
||||
"OperatorGrantSchema": {
|
||||
"additionalProperties": false,
|
||||
"description": "Per-token operator grant (Issue #6253). Unknown keys are rejected so a\ntypo (``agnts``) cannot silently drop an agent confinement.",
|
||||
"properties": {
|
||||
"scopes": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Scopes",
|
||||
"type": "array"
|
||||
},
|
||||
"agents": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Agents"
|
||||
}
|
||||
},
|
||||
"title": "OperatorGrantSchema",
|
||||
"type": "object"
|
||||
},
|
||||
"OutboundHookSchema": {
|
||||
"additionalProperties": false,
|
||||
"description": "``gateway.hooks_outbound[]`` \u2014 a core ``CommandHook`` on lifecycle events (#6140).",
|
||||
@@ -1559,10 +1589,17 @@
|
||||
"anyOf": [
|
||||
{
|
||||
"additionalProperties": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/OperatorGrantSchema"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
@@ -1739,6 +1776,19 @@
|
||||
"default": null,
|
||||
"title": "Overflow Policy"
|
||||
},
|
||||
"per_user_per_minute": {
|
||||
"anyOf": [
|
||||
{
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Per User Per Minute"
|
||||
},
|
||||
"preauth_max_connections_per_ip": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
||||
@@ -16,6 +16,8 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextvars
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
@@ -373,7 +375,13 @@ class GatewayApiEndpoints:
|
||||
if gate is not None and getattr(gate, "enabled", False):
|
||||
from ..bots._admission import AdmissionRejected
|
||||
try:
|
||||
async with gate.admit(session_id=session.session_id):
|
||||
# Issue #6215: charge a stable caller identity, only when a
|
||||
# budget is set (keeps duck-typed gates' signature intact).
|
||||
rk = (
|
||||
{"rate_key": self._rate_key_var.get() or "ip:unknown"}
|
||||
if getattr(gate, "_rate_limit_enabled", False) else {}
|
||||
)
|
||||
async with gate.admit(session_id=session.session_id, **rk):
|
||||
result = await self._gw._dispatch_agent_turn(
|
||||
agent, content, on_complete=_capture, **extra
|
||||
)
|
||||
@@ -554,6 +562,41 @@ class GatewayApiEndpoints:
|
||||
session_id = f"api:{key}:{agent_id}"
|
||||
return self._gw.create_session(agent_id=agent_id, session_id=session_id)
|
||||
|
||||
# Per-request stable identity for the per-user request budget (#6215);
|
||||
# inherited by the turn's task/stream context like ``_stream_turn_var``.
|
||||
_rate_key_var: "contextvars.ContextVar[Optional[str]]" = (
|
||||
contextvars.ContextVar("praisonai_gw_rate_key", default=None)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _rate_key(request: Any, auth_token: Optional[str] = None) -> str:
|
||||
"""Stable caller identity that a client-chosen value cannot reset.
|
||||
|
||||
A bearer token is used only when it *is* the gateway's configured
|
||||
``auth_token`` (so it was validated); any other bearer value is
|
||||
attacker-chosen and would mint a fresh budget per request, so such
|
||||
callers are charged to their peer address. Never returns ``None``, so the
|
||||
budget never falls back to the client-chosen session id.
|
||||
"""
|
||||
auth = request.headers.get("authorization", "")
|
||||
bearer = auth[7:] if auth.startswith("Bearer ") else ""
|
||||
if bearer and auth_token and hmac.compare_digest(
|
||||
bearer.encode("utf-8"), str(auth_token).encode("utf-8")
|
||||
):
|
||||
return "token:" + hashlib.sha256(bearer.encode("utf-8")).hexdigest()[:16]
|
||||
client = getattr(request, "client", None)
|
||||
host = getattr(client, "host", None)
|
||||
return f"ip:{host}" if host else "ip:unknown"
|
||||
|
||||
def _bind_rate_key(self, request: Any) -> None:
|
||||
"""Bind this request's per-user budget key for its turn (#6215).
|
||||
|
||||
Called by the auth-guarded route wrapper after auth has passed.
|
||||
"""
|
||||
cfg = getattr(self._gw, "config", None)
|
||||
token = getattr(cfg, "auth_token", None)
|
||||
self._rate_key_var.set(self._rate_key(request, token))
|
||||
|
||||
@staticmethod
|
||||
def _caller_key(request: Any) -> str:
|
||||
"""Derive a stable per-caller session key from the request.
|
||||
|
||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
|
||||
@@ -86,19 +87,21 @@ def _process_create_time(pid: int) -> Optional[float]:
|
||||
class GatewayPIDLock:
|
||||
"""Manages PID lock file for single-instance gateway enforcement."""
|
||||
|
||||
def __init__(self, lock_dir: Optional[Path] = None, host: str = "127.0.0.1", port: int = 8765):
|
||||
def __init__(self, lock_dir: Optional[Path] = None, host: str = "127.0.0.1", port: int = 8765,
|
||||
lock_name: Optional[str] = None):
|
||||
"""Initialize PID lock manager.
|
||||
|
||||
Args:
|
||||
lock_dir: Directory for lock file (default: ~/.praisonai/)
|
||||
host: Gateway host
|
||||
port: Gateway port
|
||||
lock_name: Override the lock file name (used by the state lock)
|
||||
"""
|
||||
self.lock_dir = lock_dir or Path.home() / ".praisonai"
|
||||
# Make lock file specific to host and port to allow multiple instances
|
||||
safe_host = host.replace(":", "_").replace(".", "_")
|
||||
self.lock_file = self.lock_dir / f"gateway-{safe_host}-{port}.pid"
|
||||
self.lock_dir.mkdir(exist_ok=True)
|
||||
self.lock_file = self.lock_dir / (lock_name or f"gateway-{safe_host}-{port}.pid")
|
||||
self.lock_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def acquire_lock(self, host: str, port: int) -> bool:
|
||||
"""Acquire the PID lock for the gateway.
|
||||
@@ -110,6 +113,27 @@ class GatewayPIDLock:
|
||||
Returns:
|
||||
True if lock acquired successfully, False if conflict
|
||||
"""
|
||||
# Serialise check-then-claim across processes so two simultaneous
|
||||
# starts can never both observe "no live holder" and both win.
|
||||
with self._claim_guard():
|
||||
return self._acquire_locked(host, port)
|
||||
|
||||
@contextmanager
|
||||
def _claim_guard(self):
|
||||
"""Hold an OS advisory lock on a sidecar file for the claim (POSIX)."""
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError: # Windows: exclusive link() below still applies
|
||||
yield
|
||||
return
|
||||
guard = open(self.lock_file.with_name(self.lock_file.name + ".guard"), "a")
|
||||
try:
|
||||
fcntl.flock(guard, fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
guard.close() # closing drops the flock
|
||||
|
||||
def _acquire_locked(self, host: str, port: int) -> bool:
|
||||
current_pid = os.getpid()
|
||||
|
||||
# Check if lock file exists
|
||||
@@ -120,6 +144,9 @@ class GatewayPIDLock:
|
||||
lines = lock_content.split('\n')
|
||||
if lines:
|
||||
existing_pid = int(lines[0])
|
||||
if existing_pid == current_pid:
|
||||
# Re-acquire by the same process (in-process restart).
|
||||
return True
|
||||
existing_create_time = None
|
||||
if len(lines) >= 5 and lines[4]:
|
||||
try:
|
||||
@@ -145,14 +172,29 @@ class GatewayPIDLock:
|
||||
create_time = _process_create_time(current_pid)
|
||||
create_time_line = "" if create_time is None else repr(create_time)
|
||||
lock_content = f"{current_pid}\n{host}\n{port}\n{int(time.time())}\n{create_time_line}\n"
|
||||
# Exclusive claim: write a private temp file, then hard-link it into
|
||||
# place. ``link`` fails if the lock already exists, so a racing claimant
|
||||
# can never overwrite a live holder (unlike rename/replace).
|
||||
temp_lock_file = self.lock_file.with_name(f"{self.lock_file.name}.{current_pid}.tmp")
|
||||
try:
|
||||
# Use a temporary file and atomic rename to prevent race conditions
|
||||
temp_lock_file = self.lock_file.with_suffix(".tmp")
|
||||
temp_lock_file.write_text(lock_content)
|
||||
temp_lock_file.replace(self.lock_file)
|
||||
try:
|
||||
os.link(temp_lock_file, self.lock_file)
|
||||
except FileExistsError:
|
||||
return False
|
||||
except OSError:
|
||||
# Filesystem without hard links: fall back to O_EXCL create.
|
||||
fd = os.open(self.lock_file, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(lock_content)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
try:
|
||||
temp_lock_file.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def release_lock(self) -> None:
|
||||
"""Release the PID lock."""
|
||||
@@ -253,6 +295,43 @@ class GatewayPIDLock:
|
||||
pass
|
||||
|
||||
|
||||
def resolve_state_dir() -> Path:
|
||||
"""Return the gateway's shared mutable state root (``~/.praisonai``).
|
||||
|
||||
The durable outbox, DLQ, pairing and hook stores are hard-wired under
|
||||
``~/.praisonai`` (they do not honour ``PRAISONAI_HOME``), so the home root -
|
||||
not the TCP port or ``PRAISONAI_HOME`` - is what identifies a gateway.
|
||||
"""
|
||||
return Path.home() / ".praisonai"
|
||||
|
||||
|
||||
def state_lock(state_dir: Optional[Path] = None, host: str = "127.0.0.1",
|
||||
port: int = 8765) -> GatewayPIDLock:
|
||||
"""Single-instance lock keyed on the shared state directory, regardless of port.
|
||||
|
||||
Two gateways sharing one state root would poll the same bot tokens and write
|
||||
the same SQLite stores concurrently (split-brain), so only one may own it.
|
||||
"""
|
||||
return GatewayPIDLock(lock_dir=state_dir or resolve_state_dir(), host=host,
|
||||
port=port, lock_name="gateway.state.pid")
|
||||
|
||||
|
||||
def format_state_collision_error(state_dir: Path, lock_info: Optional[dict] = None) -> str:
|
||||
"""Error shown when another live gateway already owns the shared state."""
|
||||
holder = ""
|
||||
if lock_info:
|
||||
holder = f" (PID {lock_info['pid']} on {lock_info['host']}:{lock_info['port']})"
|
||||
return "\n".join([
|
||||
f"Error: another gateway already owns the state at {state_dir}{holder}.",
|
||||
"",
|
||||
" Two gateways sharing state poll the same bot tokens and write the same",
|
||||
" queues, causing 409 Conflict errors and duplicate delivery.",
|
||||
" Stop it: praisonai gateway stop",
|
||||
" Or run an isolated gateway with its own state and bot tokens:",
|
||||
" HOME=/srv/gateway-b praisonai gateway start",
|
||||
])
|
||||
|
||||
|
||||
def format_collision_error(host: str, port: int, lock_info: Optional[dict] = None) -> str:
|
||||
"""Format a user-friendly error message for port collisions.
|
||||
|
||||
@@ -275,10 +354,11 @@ def format_collision_error(host: str, port: int, lock_info: Optional[dict] = Non
|
||||
pid = lock_info['pid']
|
||||
lines.extend([
|
||||
f" Another gateway may be running (PID {pid}).",
|
||||
f" Stop it: praisonai gateway stop",
|
||||
f" Or use a different port: GATEWAY_PORT={port + 1} praisonai gateway start",
|
||||
" Stop it: praisonai gateway stop",
|
||||
"",
|
||||
" Only ONE gateway process should poll each Telegram bot token.",
|
||||
" Only ONE gateway process may poll each bot token. To run a second,",
|
||||
" isolated gateway give it its own state, bot tokens and port:",
|
||||
f" HOME=/srv/gateway-b GATEWAY_PORT={port + 1} praisonai gateway start",
|
||||
])
|
||||
else:
|
||||
lines.extend([
|
||||
|
||||
@@ -1287,6 +1287,9 @@ class WebSocketGateway:
|
||||
# {client_id: SessionSharingRole}). The owner stays ``session.client_id``.
|
||||
self._session_observers: Dict[str, Dict[str, Any]] = {}
|
||||
self._client_scopes: Dict[str, List[str]] = {} # client_id -> operator scopes
|
||||
# Issue #6253: client_id -> agent ids the operator is confined to
|
||||
# (absent/None = every agent).
|
||||
self._client_agents: Dict[str, Optional[List[str]]] = {}
|
||||
# Issue #3467: in-flight turn registry so a running turn can be aborted
|
||||
# (by a WS ``abort`` frame or a portable ``/stop`` chat command) and so
|
||||
# a per-turn timeout can cancel a runaway turn. Maps session_id ->
|
||||
@@ -1412,6 +1415,7 @@ class WebSocketGateway:
|
||||
|
||||
# PID lock for single-instance enforcement
|
||||
self._pid_lock: Optional[Any] = None
|
||||
self._state_lock: Optional[Any] = None
|
||||
|
||||
# Channel supervisor for resilient bot management. Share the gateway's
|
||||
# degraded-capability registry so the fleet crash-loop breaker (Issue
|
||||
@@ -1544,9 +1548,46 @@ class WebSocketGateway:
|
||||
logger.error("Gateway startup failed - another instance is running")
|
||||
raise RuntimeError(error_msg)
|
||||
|
||||
# Store PID lock for cleanup
|
||||
self._pid_lock = pid_lock
|
||||
|
||||
# When ``start_with_config`` already claimed the state root, its channel
|
||||
# bots are still polling if we fail here; only it may release that lock
|
||||
# (after channels stop), or a second gateway could claim the state.
|
||||
owns_state_lock = self._state_lock is None
|
||||
try:
|
||||
self._acquire_state_lock()
|
||||
await self._start_locked()
|
||||
except BaseException:
|
||||
# Any failure after claiming (bad config, bind error, cancel) must
|
||||
# not leave a live process holding the locks and blocking restarts.
|
||||
self._release_locks(state=owns_state_lock)
|
||||
raise
|
||||
|
||||
def _acquire_state_lock(self) -> None:
|
||||
"""Claim the shared state root (idempotent).
|
||||
|
||||
The port lock alone admits a second gateway on another port sharing the
|
||||
same state + bot tokens (split-brain), so the state root is locked too.
|
||||
"""
|
||||
if self._state_lock is not None:
|
||||
return
|
||||
from .port_utils import state_lock, resolve_state_dir, format_state_collision_error
|
||||
state_dir = resolve_state_dir()
|
||||
st_lock = state_lock(state_dir, host=self._host, port=self._port)
|
||||
if not st_lock.acquire_lock(self._host, self._port):
|
||||
logger.error("Gateway startup failed - another instance owns the shared state")
|
||||
raise RuntimeError(format_state_collision_error(state_dir, st_lock.get_lock_info()))
|
||||
self._state_lock = st_lock
|
||||
|
||||
def _release_locks(self, state: bool = True) -> None:
|
||||
"""Release the port lock and (if ``state``) the state lock (idempotent)."""
|
||||
for attr in ("_pid_lock", "_state_lock") if state else ("_pid_lock",):
|
||||
lock = getattr(self, attr, None)
|
||||
if lock:
|
||||
lock.release_lock()
|
||||
setattr(self, attr, None)
|
||||
|
||||
async def _start_locked(self) -> None:
|
||||
"""Body of :meth:`start` once the single-instance locks are held."""
|
||||
# Validate bind-aware auth configuration before starting
|
||||
from .auth import assert_external_bind_safe
|
||||
self.config.host = self._host
|
||||
@@ -2032,6 +2073,10 @@ class WebSocketGateway:
|
||||
self._register_client_conn(client_id, websocket)
|
||||
# Resolve operator scopes for this connection (all scopes if no policy).
|
||||
self._client_scopes[client_id] = self.config.resolve_scopes(operator_token)
|
||||
_resolve_agents = getattr(self.config, "resolve_agents", None) # older core: unconfined
|
||||
self._client_agents[client_id] = (
|
||||
_resolve_agents(operator_token) if _resolve_agents else None
|
||||
)
|
||||
# Issue #2661: stamp the session with the active secret's generation
|
||||
# so a later rotation can force-close it (instant revocation).
|
||||
self._client_auth_generation[client_id] = self._auth_generation()
|
||||
@@ -2082,6 +2127,7 @@ class WebSocketGateway:
|
||||
self._clients.pop(client_id, None)
|
||||
await self._teardown_client_conn(client_id)
|
||||
self._client_scopes.pop(client_id, None)
|
||||
self._client_agents.pop(client_id, None)
|
||||
self._client_auth_generation.pop(client_id, None)
|
||||
self._release_client_session(client_id)
|
||||
|
||||
@@ -2631,6 +2677,8 @@ class WebSocketGateway:
|
||||
auth_err = _check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
# Issue #6215: bind the per-user budget key post-auth.
|
||||
self._api_endpoints._bind_rate_key(request)
|
||||
return await handler(request)
|
||||
return _wrapped
|
||||
|
||||
@@ -2768,14 +2816,8 @@ class WebSocketGateway:
|
||||
self._ledger_heartbeat_loop(), name="gateway-lifecycle-ledger"
|
||||
)
|
||||
try:
|
||||
# Lock cleanup on failure is handled by ``start()``.
|
||||
await self._server.serve()
|
||||
except Exception as e:
|
||||
# Clean up PID lock on any startup failure
|
||||
if hasattr(self, '_pid_lock') and self._pid_lock:
|
||||
self._pid_lock.release_lock()
|
||||
self._pid_lock = None
|
||||
# Re-raise the original exception
|
||||
raise
|
||||
finally:
|
||||
self._disarm_watchdog()
|
||||
await self._cancel_liveness_task()
|
||||
@@ -3958,6 +4000,7 @@ class WebSocketGateway:
|
||||
self._client_conns.clear()
|
||||
self._client_sessions.clear()
|
||||
self._client_scopes.clear()
|
||||
self._client_agents.clear()
|
||||
self._client_auth_generation.clear()
|
||||
|
||||
for session_id in list(self._sessions.keys()):
|
||||
@@ -3993,9 +4036,7 @@ class WebSocketGateway:
|
||||
logger.debug("Failed to shut down pooled MCP client")
|
||||
self._mcp_pool.clear()
|
||||
|
||||
# Release PID lock
|
||||
if hasattr(self, '_pid_lock') and self._pid_lock:
|
||||
self._pid_lock.release_lock()
|
||||
self._release_locks()
|
||||
|
||||
logger.info("Gateway stopped")
|
||||
|
||||
@@ -4072,8 +4113,14 @@ class WebSocketGateway:
|
||||
if msg_type == "hello":
|
||||
agent_id = data.get("agent_id")
|
||||
|
||||
# Check if agent exists
|
||||
if not agent_id or agent_id not in self._agents:
|
||||
# Check if agent exists. An agent outside the operator's grant
|
||||
# (Issue #6253) is reported identically so its existence is not
|
||||
# disclosed to a confined operator.
|
||||
if (
|
||||
not agent_id
|
||||
or agent_id not in self._agents
|
||||
or not self._client_may_reach_agent(client_id, agent_id)
|
||||
):
|
||||
error = HelloError(
|
||||
code=ConnectErrorCode.AGENT_NOT_FOUND,
|
||||
message=f"Agent not found: {agent_id}",
|
||||
@@ -4279,7 +4326,11 @@ class WebSocketGateway:
|
||||
# Keep backward compatibility with old join message
|
||||
elif msg_type == "join":
|
||||
agent_id = data.get("agent_id")
|
||||
if agent_id and agent_id in self._agents:
|
||||
if (
|
||||
agent_id
|
||||
and agent_id in self._agents
|
||||
and self._client_may_reach_agent(client_id, agent_id)
|
||||
):
|
||||
# Protocol version negotiation with validation
|
||||
try:
|
||||
client_min_version = int(data.get("min_version", MIN_PROTOCOL_VERSION))
|
||||
@@ -4341,6 +4392,18 @@ class WebSocketGateway:
|
||||
})
|
||||
return
|
||||
|
||||
# Issue #6253: deny resuming another agent's live session
|
||||
# *before* the resume call touches it.
|
||||
existing = self.get_session(session_id) if isinstance(session_id, str) else None
|
||||
if existing is not None and not self._client_may_reach_agent(
|
||||
client_id, existing.agent_id
|
||||
):
|
||||
await self._send_to_client(client_id, {
|
||||
"type": "error",
|
||||
"message": "Session does not belong to the requested agent",
|
||||
})
|
||||
return
|
||||
|
||||
# Resume or create session
|
||||
session, replay_events = self.resume_or_create_session(
|
||||
session_id=session_id,
|
||||
@@ -4348,7 +4411,17 @@ class WebSocketGateway:
|
||||
client_id=client_id,
|
||||
since_cursor=since_cursor,
|
||||
)
|
||||
|
||||
|
||||
# Issue #6253: a resumed session_id must not smuggle a confined
|
||||
# operator onto another agent's session (covers sessions
|
||||
# rehydrated from the persistent store).
|
||||
if not self._client_may_reach_agent(client_id, session.agent_id):
|
||||
await self._send_to_client(client_id, {
|
||||
"type": "error",
|
||||
"message": "Session does not belong to the requested agent",
|
||||
})
|
||||
return
|
||||
|
||||
# Set negotiated protocol version for the session
|
||||
session._protocol_version = negotiated_version
|
||||
|
||||
@@ -4614,7 +4687,7 @@ class WebSocketGateway:
|
||||
|
||||
elif msg_type == "server_reply":
|
||||
# Issue #5351: a client's correlated answer to a server→client
|
||||
# interactive request (approval / choice / input).
|
||||
# interactive request (approval / choice / input / secret).
|
||||
request_id = data.get("request_id")
|
||||
value = data.get("value")
|
||||
if not request_id or not isinstance(value, str):
|
||||
@@ -4648,7 +4721,7 @@ class WebSocketGateway:
|
||||
return True
|
||||
# Answering an approval on behalf of the turn is an approval decision
|
||||
# and requires the APPROVALS scope (parity with approvals.resolve);
|
||||
# choice/input answers require WRITE like sending a message.
|
||||
# choice/input/secret answers require WRITE like sending a message.
|
||||
required_scope = (
|
||||
OperatorScope.APPROVALS if req_kind == "approval" else OperatorScope.WRITE
|
||||
)
|
||||
@@ -5089,7 +5162,14 @@ class WebSocketGateway:
|
||||
# so enforce the shared gate here too.
|
||||
from ..bots._admission import AdmissionRejected
|
||||
try:
|
||||
async with gate.admit(session_id=session.session_id):
|
||||
# Issue #6215: stable per-user budget key (only
|
||||
# when a budget is set; duck-typed gates unchanged).
|
||||
_rk = (
|
||||
{"rate_key": self._client_rate_key(client_id)}
|
||||
if getattr(gate, "_rate_limit_enabled", False)
|
||||
else {}
|
||||
)
|
||||
async with gate.admit(session_id=session.session_id, **_rk):
|
||||
if getattr(session, "_stop_epoch", 0) != dequeued_stop_epoch:
|
||||
response = _stop_sentinel
|
||||
else:
|
||||
@@ -5333,6 +5413,7 @@ class WebSocketGateway:
|
||||
# Mirror the normal disconnect cleanup so evicted clients do not leave
|
||||
# stale scope/session entries that accumulate until shutdown.
|
||||
self._client_scopes.pop(client_id, None)
|
||||
self._client_agents.pop(client_id, None)
|
||||
self._client_auth_generation.pop(client_id, None)
|
||||
self._release_client_session(client_id)
|
||||
if ws is not None:
|
||||
@@ -5403,6 +5484,7 @@ class WebSocketGateway:
|
||||
ws = self._clients.pop(client_id, None)
|
||||
await self._teardown_client_conn(client_id)
|
||||
self._client_scopes.pop(client_id, None)
|
||||
self._client_agents.pop(client_id, None)
|
||||
self._client_auth_generation.pop(client_id, None)
|
||||
self._release_client_session(client_id)
|
||||
if ws is not None:
|
||||
@@ -5455,6 +5537,7 @@ class WebSocketGateway:
|
||||
ws = self._clients.pop(client_id, None)
|
||||
await self._teardown_client_conn(client_id)
|
||||
self._client_scopes.pop(client_id, None)
|
||||
self._client_agents.pop(client_id, None)
|
||||
self._client_auth_generation.pop(client_id, None)
|
||||
self._release_client_session(client_id)
|
||||
self._reaped_connections += 1
|
||||
@@ -5812,7 +5895,9 @@ class WebSocketGateway:
|
||||
|
||||
* ``approval`` → ``"allow"`` or ``"deny"``.
|
||||
* ``choice`` → one of the offered ``options`` (any value if none given).
|
||||
* ``input`` → any string.
|
||||
* ``input`` / ``secret`` → any string. A ``secret`` value is handed
|
||||
only to the waiting caller — never logged, echoed, or journalled
|
||||
(Issue #6223).
|
||||
"""
|
||||
kind = getattr(req, "kind", "input")
|
||||
if kind == "approval":
|
||||
@@ -5894,6 +5979,18 @@ class WebSocketGateway:
|
||||
|
||||
# ── Client Management (Public API) ────────────────────────────────
|
||||
|
||||
def _client_rate_key(self, client_id: Optional[str]) -> str:
|
||||
"""Stable per-user budget key for a WebSocket turn (#6215).
|
||||
|
||||
Session ids are client-chosen, so charge the peer address instead.
|
||||
With no known peer, callers share one ``ip:unknown`` budget rather
|
||||
than falling back to the client-chosen session id.
|
||||
"""
|
||||
ws = self._clients.get(client_id) if client_id else None
|
||||
peer = getattr(ws, "client", None)
|
||||
host = getattr(peer, "host", None)
|
||||
return f"ip:{host}" if host else "ip:unknown"
|
||||
|
||||
def add_client(self, client_id: str, websocket: Any) -> None:
|
||||
"""Register a client WebSocket connection.
|
||||
|
||||
@@ -6786,6 +6883,9 @@ class WebSocketGateway:
|
||||
role = SessionSharingRole(role)
|
||||
if role is SessionSharingRole.OWNER or client_id == session.client_id:
|
||||
raise ValueError("The owner is bound at session creation, not attached")
|
||||
# Issue #6253: a confined operator must not join another agent's session.
|
||||
if not self._client_may_reach_agent(client_id, session.agent_id):
|
||||
raise PermissionError(f"Client {client_id} may not reach this session's agent")
|
||||
visibility = SessionVisibility(session._visibility)
|
||||
if visibility is SessionVisibility.PRIVATE:
|
||||
raise PermissionError(f"Session {session_id} is private")
|
||||
@@ -6948,6 +7048,15 @@ class WebSocketGateway:
|
||||
return func
|
||||
return decorator
|
||||
|
||||
def _client_may_reach_agent(self, client_id: str, agent_id: Any) -> bool:
|
||||
"""Whether a connected client's operator grant covers ``agent_id``.
|
||||
|
||||
Issue #6253: unconfined clients (no ``agents`` in their grant) reach
|
||||
every agent, preserving prior behaviour.
|
||||
"""
|
||||
allowed = self._client_agents.get(client_id)
|
||||
return allowed is None or agent_id in allowed
|
||||
|
||||
def _client_has_scope(self, client_id: str, scope: OperatorScope) -> bool:
|
||||
"""Whether a connected client holds ``scope`` (ADMIN implies all).
|
||||
|
||||
@@ -6992,6 +7101,11 @@ class WebSocketGateway:
|
||||
|
||||
def _event_visible_to(self, event: GatewayEvent, client_id: str) -> bool:
|
||||
"""Whether ``event`` should be delivered to ``client_id`` given its scopes."""
|
||||
# Issue #6253: an event sourced from an agent outside the client's
|
||||
# operator grant is withheld; non-agent (system) events are unaffected.
|
||||
source = getattr(event, "source", None)
|
||||
if source in self._agents and not self._client_may_reach_agent(client_id, source):
|
||||
return False
|
||||
return self._client_has_scope(client_id, self._event_required_scope(event))
|
||||
|
||||
async def emit(self, event: GatewayEvent) -> None:
|
||||
@@ -11281,6 +11395,29 @@ class WebSocketGateway:
|
||||
self._shell_routed_agents.clear()
|
||||
self._shell_routed_agents.update(candidate.get("shell", {}))
|
||||
|
||||
@staticmethod
|
||||
def _rate_policy_from_config(cfg: Any) -> Any:
|
||||
"""Build the ``gateway.per_user_per_minute`` policy (#6215); may raise."""
|
||||
from ..bots._admission import build_rate_limit_policy
|
||||
|
||||
gw_cfg = (cfg or {}).get("gateway") or {}
|
||||
return build_rate_limit_policy(gw_cfg.get("per_user_per_minute", 0))
|
||||
|
||||
def _apply_rate_policy(self, policy: Any) -> None:
|
||||
"""Install a reloaded per-user budget on the live admission gate (#6215).
|
||||
|
||||
An existing gate is updated in place (bots/API/WS already share it); a
|
||||
budget added to a gateway that had no gate builds a rate-only gate,
|
||||
which channels started afterwards pick up via ``_stamp_admission_gate``.
|
||||
"""
|
||||
gate = getattr(self, "_admission_gate", None)
|
||||
if gate is not None:
|
||||
gate.set_rate_limit_policy(policy)
|
||||
elif policy is not None:
|
||||
from ..bots._admission import AdmissionGate
|
||||
|
||||
self._admission_gate = AdmissionGate(None, rate_limit_policy=policy)
|
||||
|
||||
async def _restore_previous_config(
|
||||
self, error: str, changed_paths: Set[str]
|
||||
) -> None:
|
||||
@@ -11302,6 +11439,10 @@ class WebSocketGateway:
|
||||
)
|
||||
return
|
||||
try:
|
||||
try:
|
||||
self._apply_rate_policy(self._rate_policy_from_config(prev_cfg))
|
||||
except ValueError:
|
||||
pass # previous config was validated when it went live
|
||||
prev_agents_cfg = prev_cfg.get("agents", {})
|
||||
if prev_agents_cfg:
|
||||
provider_cfg = prev_cfg.get("provider", {})
|
||||
@@ -11431,6 +11572,13 @@ class WebSocketGateway:
|
||||
# drain window keep resolving the previous agents until the cutover
|
||||
# commits.
|
||||
report = self._validate_candidate(new_cfg)
|
||||
# Issue #6215: validate the per-user budget with the candidate so a
|
||||
# bad value rejects the reload instead of failing mid-cutover.
|
||||
try:
|
||||
rate_policy = self._rate_policy_from_config(new_cfg)
|
||||
except ValueError as e:
|
||||
report.ok = False
|
||||
report.failures.append(f"gateway.per_user_per_minute: {e}")
|
||||
if not report.ok:
|
||||
error = "; ".join(report.failures) or "candidate validation failed"
|
||||
logger.error(f"Reload rejected — {error}; keeping previous config")
|
||||
@@ -11453,6 +11601,7 @@ class WebSocketGateway:
|
||||
# channel is accepting turns (greptile #1 — never expose the
|
||||
# candidate before the cutover commits).
|
||||
self._activate_candidate(report.candidate)
|
||||
self._apply_rate_policy(rate_policy)
|
||||
|
||||
channels_cfg = new_cfg.get("channels", {})
|
||||
try:
|
||||
@@ -11974,6 +12123,42 @@ class WebSocketGateway:
|
||||
self.config.trusted_proxies = _trusted
|
||||
except Exception: # pragma: no cover - config is always a GatewayConfig
|
||||
pass
|
||||
# Issue #6253: apply the YAML operator policy (scopes and agent
|
||||
# confinement). Only overwrite when the YAML declares one, so a policy
|
||||
# a Python caller set on ``GatewayConfig`` survives.
|
||||
try:
|
||||
from praisonaiagents.gateway.config import _parse_auth_scopes
|
||||
except ImportError: # older core without per-agent grants
|
||||
_parse_auth_scopes = None
|
||||
_a = gw_cfg.get("auth")
|
||||
if gw_cfg.get("auth_scopes") or (isinstance(_a, dict) and _a.get("tokens")):
|
||||
# A declared policy that cannot be enforced must not start
|
||||
# with every operator unconfined (fail closed).
|
||||
raise ValueError(
|
||||
"gateway operator policy is declared but the installed "
|
||||
"praisonaiagents cannot enforce it; upgrade praisonaiagents"
|
||||
)
|
||||
_yaml_scopes = _parse_auth_scopes(gw_cfg) if _parse_auth_scopes else None
|
||||
if _yaml_scopes is not None:
|
||||
_auth = gw_cfg.get("auth")
|
||||
_tokens_form = isinstance(_auth, dict) and isinstance(_auth.get("tokens"), list)
|
||||
# ``load_gateway_config`` resolves ``${VAR}`` in values only, so
|
||||
# ``auth_scopes`` *keys* still hold placeholders here. An unset
|
||||
# var resolves to "" and that entry is dropped (it matches no one).
|
||||
_policy = {}
|
||||
for _tok, _grant in _yaml_scopes.items():
|
||||
_tok = _tok if _tokens_form else self._substitute_env_vars(_tok)
|
||||
if _tok:
|
||||
_policy[_tok] = _grant
|
||||
_declared = _auth.get("tokens") if _tokens_form else gw_cfg.get("auth_scopes")
|
||||
if _declared and not _policy:
|
||||
# An empty policy means "no policy" (every scope, every agent),
|
||||
# so refuse to start rather than fail open.
|
||||
raise ValueError(
|
||||
"gateway operator policy: every token resolved to empty "
|
||||
"(unset ${VAR}?); refusing to start without it"
|
||||
)
|
||||
self.config.auth_scopes = _policy
|
||||
# Issue #3593: the multi-bot CLI builds this gateway with a *default*
|
||||
# session config, so ``__init__`` already picked a store before this
|
||||
# YAML loaded. Re-read the ``session:`` block here and re-select the
|
||||
@@ -12181,6 +12366,8 @@ class WebSocketGateway:
|
||||
queue_depth=_queue_depth,
|
||||
overflow_policy=_overflow,
|
||||
max_concurrent_runs_per_scope=_max_runs_per_scope,
|
||||
# Issue #6215: per-user inbound request budget (0 = unlimited).
|
||||
per_user_per_minute=gw_cfg.get("per_user_per_minute", 0),
|
||||
)
|
||||
if self._admission_gate is not None:
|
||||
logger.info(
|
||||
@@ -12436,6 +12623,9 @@ class WebSocketGateway:
|
||||
except (NotImplementedError, OSError, ValueError):
|
||||
pass
|
||||
|
||||
# Claim the state root BEFORE channels start: channel setup replays the
|
||||
# inbound journal, which a rejected second gateway must never touch.
|
||||
self._acquire_state_lock()
|
||||
try:
|
||||
await _run_all()
|
||||
finally:
|
||||
@@ -12486,3 +12676,4 @@ class WebSocketGateway:
|
||||
logger.warning("Error draining websocket sessions: %s", e)
|
||||
finally:
|
||||
self._cancel_shutdown_deadline()
|
||||
self._release_locks()
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "praisonai-bot"
|
||||
version = "0.1.19"
|
||||
version = "0.1.21"
|
||||
description = "Bots, gateway, and channel CLI for PraisonAI — messaging platforms and WebSocket control plane extracted from the praisonai wrapper."
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
|
||||
Loaded 100 of 220 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user