mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-07 12:47:55 -10:00
Compare commits
76
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97aaee119b | ||
|
|
e41a23e040 | ||
|
|
c6284268f6 | ||
|
|
a72f170a17 | ||
|
|
b146d40b62 | ||
|
|
c8d0dd3251 | ||
|
|
68c12d15e7 | ||
|
|
15d4dd5e7b | ||
|
|
1072ab2fd5 | ||
|
|
3fcc3628ac | ||
|
|
749c6d868a | ||
|
|
58ccedae91 | ||
|
|
de017b1fbf | ||
|
|
8d807c9b8d | ||
|
|
bf78a76ffd | ||
|
|
25d3b289ee | ||
|
|
9a5824e0b9 | ||
|
|
63b836c4c2 | ||
|
|
2efc491479 | ||
|
|
86c96fc217 | ||
|
|
3e2881c1bb | ||
|
|
6bed06cdd1 | ||
|
|
f6294923d0 | ||
|
|
a0f908ac1f | ||
|
|
68435ca03a | ||
|
|
063b7c6d0b | ||
|
|
c83b735fba | ||
|
|
20d023a864 | ||
|
|
b05518baed | ||
|
|
740a887ade | ||
|
|
6980ec13e2 | ||
|
|
a362afc899 | ||
|
|
4d9b514106 | ||
|
|
d3ecf23533 | ||
|
|
85922f349d | ||
|
|
50a0949063 | ||
|
|
8da80d1890 | ||
|
|
861584bab4 | ||
|
|
79669e7a7d | ||
|
|
25f8d93a4c | ||
|
|
ffa3068203 | ||
|
|
82d066299b | ||
|
|
87bf746dba | ||
|
|
2b78ce8804 | ||
|
|
c244c4db7e | ||
|
|
42e0695bbe | ||
|
|
f024f6b3a1 | ||
|
|
7d26272fa0 | ||
|
|
85c01743c8 | ||
|
|
8420d1f93f | ||
|
|
4c59b795d5 | ||
|
|
790878b092 | ||
|
|
9a84e99ecf | ||
|
|
fa738028d1 | ||
|
|
71de7217fd | ||
|
|
3d7ed12a3c | ||
|
|
45019589cf | ||
|
|
e3074e5ac1 | ||
|
|
09c443a71f | ||
|
|
a0ad968af0 | ||
|
|
70e3b7cc70 | ||
|
|
1b3f576e00 | ||
|
|
69ca4f9808 | ||
|
|
8d9b32de91 | ||
|
|
318e15054c | ||
|
|
6984acc862 | ||
|
|
a563e08bf1 | ||
|
|
636586d7df | ||
|
|
5baea8dc57 | ||
|
|
26b93f9201 | ||
|
|
d6b8d4ff11 | ||
|
|
534b6e3535 | ||
|
|
2e2081b38c | ||
|
|
0c5f2998f0 | ||
|
|
eca66b1efc | ||
|
|
b284b67011 |
No files matched your search
@@ -158,6 +158,8 @@ jobs:
|
||||
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()
|
||||
|
||||
@@ -18,34 +18,37 @@
|
||||
|
||||
**Private AI that earns trust one task at a time.**
|
||||
|
||||
PraisonAI helps banks, insurers and hospitals run AI agents on their own computers, even fully offline, so their private data never leaves the building.
|
||||
PraisonAI runs AI agents on your own computers, even fully offline, so private data never has to leave the building.
|
||||
|
||||
2M+ downloads · 9.3K stars · 600+ releases in the last year · 80+ contributors · Python
|
||||
- **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.
|
||||
|
||||
[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
|
||||
pip install praisonaiagents
|
||||
pip install praisonai
|
||||
```
|
||||
|
||||
For the `praisonai` CLI, bots, dashboard and YAML agents, also run `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://docs.praison.ai/docs/concepts/agents) · [Handoffs](https://docs.praison.ai/docs/concepts/handoffs)
|
||||
- **Workflows:** [AgentFlow: route, parallel, loop, repeat](https://docs.praison.ai/docs/concepts/agentflow)
|
||||
- **Memory and knowledge:** [Memory](https://docs.praison.ai/docs/concepts/memory) · [Knowledge](https://docs.praison.ai/docs/concepts/knowledge) · [RAG](https://docs.praison.ai/docs/concepts/rag)
|
||||
- **Tools and MCP:** [Tools](https://docs.praison.ai/docs/tools/tools) · [MCP](https://docs.praison.ai/docs/mcp/transports)
|
||||
- **Bots (Telegram, Discord, Slack, WhatsApp):** [Bot CLI](https://docs.praison.ai/docs/cli/bot) · [Bot gateway](https://docs.praison.ai/docs/features/bot-gateway)
|
||||
- **Terminal CLI:** [`praisonai code`](https://docs.praison.ai/docs/cli/code) · [`praisonai run`](https://docs.praison.ai/docs/cli/run) · [`praisonai chat`](https://docs.praison.ai/docs/cli/chat) · [CLI reference](https://docs.praison.ai/docs/cli/cli-reference)
|
||||
- **Training and fine-tuning:** [PraisonAI Train](https://docs.praison.ai/docs/train)
|
||||
- **Browser automation:** [Browser agent](https://docs.praison.ai/docs/features/browser-agent) · [Browser CLI](https://docs.praison.ai/docs/cli/browser)
|
||||
- **Sandboxes:** [Sandboxed code execution](https://docs.praison.ai/docs/features/sandbox)
|
||||
- **Deploy:** [Deploy guide](https://docs.praison.ai/docs/deploy)
|
||||
- **Dashboard and UI:** [Claw dashboard](https://docs.praison.ai/docs/concepts/claw) · [Chat UI](https://docs.praison.ai/docs/ui)
|
||||
- **YAML, no code:** [YAML agents](#-using-yaml-no-code) · [YAML reference](https://docs.praison.ai/docs/features/yaml-configuration-reference)
|
||||
- **JavaScript and TypeScript:** [JavaScript SDK](https://docs.praison.ai/docs/js)
|
||||
- **Desktop app:** [PraisonAI Desktop](https://docs.praison.ai/docs/features/desktop/index) · [Downloads](#desktop-app)
|
||||
- **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
|
||||
@@ -67,10 +70,9 @@ print(agent.start("In two sentences, why do some companies keep their data on th
|
||||
|
||||
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.
|
||||
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
|
||||
pip install "praisonaiagents[llm]" # adds the local-model support
|
||||
# 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)
|
||||
@@ -89,28 +91,32 @@ If Ollama runs on another computer on your network, pass `base_url` (or set `OLL
|
||||
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://docs.praison.ai/docs/models/ollama) and [Models docs](https://docs.praison.ai/docs/models).
|
||||
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:** turns that real work into tests.
|
||||
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.
|
||||
|
||||
The open-source framework is live today; the handover is in early access with design partners. To take part, see [praison.ai/enterprise](https://praison.ai/enterprise/).
|
||||
**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://docs.praison.ai/docs/concepts/agents) · [Handoffs](https://docs.praison.ai/docs/concepts/handoffs) · [Workflows](https://docs.praison.ai/docs/concepts/agentflow)
|
||||
- **Memory:** [Memory](https://docs.praison.ai/docs/concepts/memory) · [Knowledge and RAG](https://docs.praison.ai/docs/concepts/rag) · [Sessions](https://docs.praison.ai/docs/concepts/session-management)
|
||||
- **Tools and MCP:** [Tools](https://docs.praison.ai/docs/tools/tools) · [MCP](https://docs.praison.ai/docs/mcp/transports)
|
||||
- **Guardrails and approval:** [Guardrails](https://docs.praison.ai/docs/concepts/guardrails) · [Human approval](https://docs.praison.ai/docs/concepts/approval) · [Policy engine](https://docs.praison.ai/docs/features/policy-engine)
|
||||
- **Tracing and evaluation:** [Tracing](https://docs.praison.ai/docs/observability/overview) · [Telemetry](https://docs.praison.ai/docs/features/telemetry) · [Langfuse](https://docs.praison.ai/docs/observability/langfuse) · [Evaluation](https://docs.praison.ai/docs/concepts/evaluation)
|
||||
- **Your choice of model:** [Models](https://docs.praison.ai/docs/models) · [Model router](https://docs.praison.ai/docs/features/model-router)
|
||||
- **Fine-tuning:** [PraisonAI Train](https://docs.praison.ai/docs/train)
|
||||
- **Deploy:** [Deploy guide](https://docs.praison.ai/docs/deploy)
|
||||
- **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
|
||||
|
||||
@@ -118,11 +124,20 @@ The open-source framework is live today; the handover is in early access with de
|
||||
npm install praisonai
|
||||
```
|
||||
|
||||
The TypeScript and JavaScript SDK has its own guide at [docs.praison.ai/docs/js](https://docs.praison.ai/docs/js). A short example is in [Using JavaScript Code](#-using-javascript-code) below.
|
||||
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:** [docs.praison.ai](https://docs.praison.ai)
|
||||
- **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)
|
||||
@@ -183,9 +198,9 @@ The full `praisonai` package, with the CLI, bots, dashboard and integrations:
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<a href="https://docs.praison.ai">
|
||||
<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>
|
||||
@@ -295,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?*
|
||||
|
||||
@@ -338,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?*
|
||||
|
||||
@@ -358,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?*
|
||||
|
||||
@@ -554,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>
|
||||
@@ -571,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>
|
||||
|
||||
@@ -641,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
|
||||
|
||||
@@ -693,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)
|
||||
|
||||
@@ -710,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)
|
||||
|
||||
@@ -732,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)
|
||||
|
||||
@@ -745,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)
|
||||
|
||||
@@ -856,7 +871,7 @@ 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)
|
||||
|
||||
---
|
||||
|
||||
@@ -870,12 +885,12 @@ praisonai agents.yaml
|
||||
|
||||
| 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>
|
||||
|
||||
@@ -884,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>
|
||||
|
||||
@@ -902,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>
|
||||
|
||||
@@ -917,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>
|
||||
|
||||
@@ -937,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>
|
||||
|
||||
@@ -951,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>
|
||||
|
||||
@@ -965,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>
|
||||
|
||||
@@ -982,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>
|
||||
|
||||
@@ -994,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>
|
||||
|
||||
@@ -1008,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>
|
||||
|
||||
@@ -1019,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>
|
||||
|
||||
@@ -1037,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>
|
||||
|
||||
@@ -1060,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>
|
||||
|
||||
@@ -1076,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>
|
||||
|
||||
@@ -1098,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>
|
||||
|
||||
@@ -1110,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>
|
||||
|
||||
@@ -1120,7 +1135,7 @@ 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>
|
||||
|
||||
@@ -1151,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)
|
||||
|
||||
---
|
||||
|
||||
@@ -1161,7 +1176,7 @@ Methodology, comparisons and how to reproduce: [benchmarks/BENCHMARK_RESULTS.md]
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
[](https://docs.praison.ai)
|
||||
[](https://praison.ai/docs)
|
||||
|
||||
---
|
||||
|
||||
@@ -1253,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>
|
||||
|
||||
@@ -1294,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>
|
||||
|
||||
@@ -1314,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>
|
||||
|
||||
@@ -1331,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>
|
||||
|
||||
@@ -1347,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>
|
||||
|
||||
@@ -1362,7 +1377,7 @@ 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>
|
||||
|
||||
@@ -1370,7 +1385,7 @@ See [MCP docs](https://docs.praison.ai/docs/mcp/transports) for all transport op
|
||||
|
||||
### 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)
|
||||
|
||||
@@ -1379,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.23" \
|
||||
"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.23" \
|
||||
"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.23" \
|
||||
"praisonai>=4.7.24" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6336,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:
|
||||
@@ -7345,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)
|
||||
|
||||
@@ -548,9 +548,14 @@ class ChatMixin:
|
||||
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, tuple)):
|
||||
# Nested tool lists are flattened by _format_tools_for_completion.
|
||||
if self._offers_edit_tool(tool):
|
||||
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):
|
||||
@@ -564,7 +569,9 @@ class ChatMixin:
|
||||
name = getattr(tool, "name", None)
|
||||
if not isinstance(name, str):
|
||||
name = getattr(tool, "__name__", None)
|
||||
if name in self._EDIT_TOOL_NAMES:
|
||||
# _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
|
||||
|
||||
@@ -1239,7 +1246,8 @@ Your Role: {self.role}"""
|
||||
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
|
||||
@@ -1359,20 +1367,19 @@ Your Role: {self.role}"""
|
||||
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.
|
||||
@@ -3068,6 +3075,23 @@ Your Role: {self.role}"""
|
||||
# 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,
|
||||
@@ -3122,6 +3146,7 @@ Your Role: {self.role}"""
|
||||
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)
|
||||
@@ -3171,6 +3196,7 @@ Your Role: {self.role}"""
|
||||
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)
|
||||
@@ -3322,6 +3348,7 @@ Your Role: {self.role}"""
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -7846,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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -192,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
|
||||
@@ -249,6 +255,9 @@ __all__ = [
|
||||
"clear_session_context",
|
||||
"workspace_id",
|
||||
"build_handoff_prompt",
|
||||
"FileSessionManifest",
|
||||
"ManifestEntry",
|
||||
"SessionManifestProtocol",
|
||||
"MessageOrigin",
|
||||
"wrap_inter_agent",
|
||||
# Encryption at rest
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "1.7.21"
|
||||
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"
|
||||
|
||||
@@ -108,10 +108,24 @@ def test_a_tool_renamed_to_edit_file_counts(make):
|
||||
|
||||
|
||||
def test_nested_tool_list_offers_the_edit_hint(make):
|
||||
agent = make(llm="gpt-4o-mini", tools=[["edit_file"]])
|
||||
# _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": {}}}}
|
||||
@@ -125,7 +139,9 @@ def _system_sent(agent, monkeypatch, run):
|
||||
|
||||
def fake(messages=None, temperature=None, tools=None, *a, **k):
|
||||
sent.append(messages if messages is not None else k.get("messages"))
|
||||
sent_tools.append([t.get("function", {}).get("name") for t in tools or []])
|
||||
# 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),
|
||||
@@ -147,6 +163,51 @@ def test_native_chat_follows_per_call_edit_tools(make, monkeypatch):
|
||||
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")
|
||||
|
||||
@@ -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,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
|
||||
@@ -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()}
|
||||
|
||||
|
||||
@@ -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,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]
|
||||
@@ -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"
|
||||
|
||||
Generated
+1
-1
@@ -3162,7 +3162,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.7.21"
|
||||
version = "1.7.22"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.20"
|
||||
__version__ = "0.1.21"
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 ->
|
||||
@@ -2070,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()
|
||||
@@ -2120,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)
|
||||
|
||||
@@ -3992,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()):
|
||||
@@ -4104,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}",
|
||||
@@ -4311,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))
|
||||
@@ -4373,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,
|
||||
@@ -4380,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
|
||||
|
||||
@@ -5372,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:
|
||||
@@ -5442,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:
|
||||
@@ -5494,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
|
||||
@@ -6839,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")
|
||||
@@ -7001,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).
|
||||
|
||||
@@ -7045,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:
|
||||
@@ -12062,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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "praisonai-bot"
|
||||
version = "0.1.20"
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Operator agent confinement on the gateway (Issue #6253).
|
||||
|
||||
``auth_scopes`` may carry ``{"scopes": [...], "agents": [...]}`` per token so
|
||||
an authenticated operator is confined to specific agents; the plain
|
||||
``token -> [scopes]`` form keeps today's gateway-wide behaviour.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.gateway import GatewayConfig
|
||||
from praisonaiagents.gateway.protocols import EventType, GatewayEvent
|
||||
from praisonai_bot.gateway.server import WebSocketGateway
|
||||
|
||||
|
||||
class _WS:
|
||||
def __init__(self):
|
||||
self.sent = []
|
||||
|
||||
async def send_json(self, data):
|
||||
self.sent.append(data)
|
||||
|
||||
async def close(self, code=None, reason=None):
|
||||
pass
|
||||
|
||||
|
||||
POLICY = {
|
||||
"tok_support": {"scopes": ["read", "write"], "agents": ["support"]},
|
||||
"tok_legacy": ["read", "write"],
|
||||
}
|
||||
|
||||
|
||||
def test_resolve_agents_contract():
|
||||
cfg = GatewayConfig(auth_token="t", auth_scopes=POLICY)
|
||||
assert cfg.resolve_scopes("tok_support") == ["read", "write"]
|
||||
assert cfg.resolve_agents("tok_support") == ["support"]
|
||||
# Plain list form and no-policy are unconfined (backward compatible).
|
||||
assert cfg.resolve_agents("tok_legacy") is None
|
||||
assert GatewayConfig().resolve_agents("anything") is None
|
||||
# Unknown token still holds no scopes.
|
||||
assert cfg.resolve_scopes("nope") == []
|
||||
|
||||
|
||||
def test_yaml_both_shapes_parse_agents():
|
||||
from praisonaiagents.gateway.config import MultiChannelGatewayConfig
|
||||
|
||||
flat = {"gateway": {"auth_scopes": {
|
||||
"tok_support": {"scopes": ["read", "write"], "agents": ["support"]},
|
||||
"tok_legacy": ["read"],
|
||||
}}}
|
||||
gw = MultiChannelGatewayConfig.from_dict(flat).gateway
|
||||
assert gw.resolve_agents("tok_support") == ["support"]
|
||||
assert gw.resolve_scopes("tok_support") == ["read", "write"]
|
||||
assert gw.resolve_agents("tok_legacy") is None
|
||||
|
||||
tokens = {"gateway": {"auth": {"tokens": [
|
||||
{"token": "tok_fin", "scopes": ["read"], "agents": ["finance"]},
|
||||
]}}}
|
||||
gw = MultiChannelGatewayConfig.from_dict(tokens).gateway
|
||||
assert gw.resolve_agents("tok_fin") == ["finance"]
|
||||
assert gw.resolve_scopes("tok_fin") == ["read"]
|
||||
|
||||
|
||||
def _gateway_with_client(agents):
|
||||
gw = WebSocketGateway(config=GatewayConfig(auth_token="tmp"))
|
||||
gw._agents["support"] = object()
|
||||
gw._agents["finance"] = object()
|
||||
ws = _WS()
|
||||
gw._clients["c1"] = ws
|
||||
gw._client_scopes["c1"] = ["read", "write"]
|
||||
gw._client_agents["c1"] = agents
|
||||
return gw, ws
|
||||
|
||||
|
||||
@pytest.mark.parametrize("frame", ["hello", "join"])
|
||||
def test_confined_operator_cannot_bind_other_agent(frame):
|
||||
gw, ws = _gateway_with_client(["support"])
|
||||
asyncio.run(gw._handle_client_message("c1", {"type": frame, "agent_id": "finance"}))
|
||||
assert "c1" not in gw._client_sessions
|
||||
assert "not found" in str(ws.sent[-1]).lower()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("frame", ["hello", "join"])
|
||||
def test_confined_operator_binds_allowed_agent(frame):
|
||||
gw, _ = _gateway_with_client(["support"])
|
||||
asyncio.run(gw._handle_client_message("c1", {"type": frame, "agent_id": "support"}))
|
||||
assert gw._sessions[gw._client_sessions["c1"]].agent_id == "support"
|
||||
|
||||
|
||||
def test_join_cannot_resume_foreign_agent_session():
|
||||
gw, ws = _gateway_with_client(["support"])
|
||||
foreign = gw.create_session("finance", client_id="other")
|
||||
asyncio.run(gw._handle_client_message(
|
||||
"c1",
|
||||
{"type": "join", "agent_id": "support", "session_id": foreign.session_id},
|
||||
))
|
||||
assert "c1" not in gw._client_sessions
|
||||
assert ws.sent[-1]["type"] == "error"
|
||||
|
||||
|
||||
def test_join_denies_foreign_session_before_resume(monkeypatch):
|
||||
# The denial must happen before resume touches the foreign session.
|
||||
gw, ws = _gateway_with_client(["support"])
|
||||
foreign = gw.create_session("finance", client_id="other")
|
||||
calls = []
|
||||
monkeypatch.setattr(gw, "resume_or_create_session", lambda **kw: calls.append(kw))
|
||||
asyncio.run(gw._handle_client_message(
|
||||
"c1",
|
||||
{"type": "join", "agent_id": "support", "session_id": foreign.session_id, "since": 0},
|
||||
))
|
||||
assert calls == []
|
||||
assert ws.sent[-1]["type"] == "error"
|
||||
assert foreign.client_id == "other"
|
||||
|
||||
|
||||
def test_attach_refuses_confined_client_on_foreign_agent_session():
|
||||
gw, _ = _gateway_with_client(["support"])
|
||||
foreign = gw.create_session("finance", client_id="other", visibility="shared")
|
||||
with pytest.raises(PermissionError):
|
||||
gw.attach(foreign.session_id, "c1", role="member")
|
||||
assert "c1" not in gw._client_sessions
|
||||
assert "c1" not in gw._session_observers.get(foreign.session_id, {})
|
||||
own = gw.create_session("support", client_id="other", visibility="shared")
|
||||
gw.attach(own.session_id, "c1", role="member")
|
||||
assert gw._client_sessions["c1"] == own.session_id
|
||||
|
||||
|
||||
def test_unconfined_operator_reaches_every_agent():
|
||||
gw, _ = _gateway_with_client(None)
|
||||
asyncio.run(gw._handle_client_message("c1", {"type": "hello", "agent_id": "finance"}))
|
||||
assert gw._sessions[gw._client_sessions["c1"]].agent_id == "finance"
|
||||
|
||||
|
||||
def test_broadcast_withholds_other_agents_events():
|
||||
gw, ws = _gateway_with_client(["support"])
|
||||
|
||||
async def _run():
|
||||
await gw.broadcast(GatewayEvent(type=EventType.MESSAGE, data={}, source="finance"))
|
||||
await gw.broadcast(GatewayEvent(type=EventType.MESSAGE, data={}, source="support"))
|
||||
await gw.broadcast(GatewayEvent(type=EventType.MESSAGE, data={}, source="system"))
|
||||
|
||||
asyncio.run(_run())
|
||||
assert [f.get("source") for f in ws.sent] == ["support", "system"]
|
||||
|
||||
|
||||
def test_misspelled_grant_key_fails_closed():
|
||||
# A typo must not silently drop the confinement (it would reach every agent).
|
||||
from praisonaiagents.gateway.config import (
|
||||
ConfigValidationError, MultiChannelGatewayConfig,
|
||||
)
|
||||
from praisonai_bot.bots._config_schema import GatewayServerSchema
|
||||
|
||||
bad = {"tok": {"scopes": ["read"], "agnts": ["support"]}}
|
||||
with pytest.raises(ConfigValidationError):
|
||||
MultiChannelGatewayConfig.from_dict({"gateway": {"auth_scopes": bad}})
|
||||
with pytest.raises(Exception):
|
||||
GatewayServerSchema(auth_scopes=bad)
|
||||
GatewayServerSchema(auth_scopes=POLICY) # valid shapes still accepted
|
||||
|
||||
# Same for the ``auth.tokens[]`` form.
|
||||
bad_tokens = {"tokens": [{"token": "tok", "scopes": ["read"], "agnts": ["support"]}]}
|
||||
with pytest.raises(ConfigValidationError):
|
||||
MultiChannelGatewayConfig.from_dict({"gateway": {"auth": bad_tokens}})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gw", [
|
||||
{"auth_scopes": {"tok": {"scopes": ["read"], "agents": "support"}}},
|
||||
{"auth": {"tokens": [{"token": "tok", "scopes": ["read"], "agents": "support"}]}},
|
||||
])
|
||||
def test_scalar_agents_value_rejected(gw):
|
||||
# A scalar must not be split into single-character agent ids.
|
||||
from praisonaiagents.gateway.config import MultiChannelGatewayConfig
|
||||
|
||||
with pytest.raises(ValueError, match="agents"):
|
||||
MultiChannelGatewayConfig.from_dict({"gateway": gw})
|
||||
|
||||
|
||||
class _StopStartup(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gw_yaml", [
|
||||
{"auth_scopes": {"tok_support": {"scopes": ["read"], "agents": ["support"]}}},
|
||||
{"auth": {"tokens": [{"token": "tok_support", "scopes": ["read"], "agents": ["support"]}]}},
|
||||
])
|
||||
def test_start_with_config_applies_yaml_policy(gw_yaml, monkeypatch):
|
||||
# The YAML startup path must stamp the policy onto self.config, otherwise
|
||||
# resolve_agents() returns None and confinement is silently off.
|
||||
gw = WebSocketGateway(config=GatewayConfig(auth_token="tmp"))
|
||||
cfg = {"gateway": dict(gw_yaml, session={"persist": False})}
|
||||
monkeypatch.setattr(gw, "load_gateway_config", lambda path: cfg)
|
||||
monkeypatch.setattr(gw, "_session_store_explicit", False, raising=False)
|
||||
|
||||
def _stop(*a, **k):
|
||||
raise _StopStartup
|
||||
|
||||
monkeypatch.setattr(gw, "_build_session_store", _stop)
|
||||
with pytest.raises(_StopStartup):
|
||||
asyncio.run(gw.start_with_config("gateway.yaml"))
|
||||
assert gw.config.resolve_agents("tok_support") == ["support"]
|
||||
assert gw.config.resolve_scopes("tok_support") == ["read"]
|
||||
|
||||
|
||||
def test_start_with_config_keeps_python_policy_without_yaml_policy(monkeypatch):
|
||||
gw = WebSocketGateway(config=GatewayConfig(auth_token="tmp", auth_scopes=POLICY))
|
||||
monkeypatch.setattr(gw, "load_gateway_config", lambda path: {"gateway": {"session": {}}})
|
||||
monkeypatch.setattr(gw, "_session_store_explicit", False, raising=False)
|
||||
monkeypatch.setattr(gw, "_build_session_store", lambda *a, **k: (_ for _ in ()).throw(_StopStartup()))
|
||||
with pytest.raises(_StopStartup):
|
||||
asyncio.run(gw.start_with_config("gateway.yaml"))
|
||||
assert gw.config.auth_scopes == POLICY
|
||||
|
||||
|
||||
def _start(gw, cfg, monkeypatch):
|
||||
cfg["gateway"].setdefault("session", {"persist": False})
|
||||
monkeypatch.setattr(gw, "load_gateway_config", lambda path: cfg)
|
||||
monkeypatch.setattr(gw, "_session_store_explicit", False, raising=False)
|
||||
monkeypatch.setattr(gw, "_build_session_store", lambda *a, **k: (_ for _ in ()).throw(_StopStartup()))
|
||||
asyncio.run(gw.start_with_config("gateway.yaml"))
|
||||
|
||||
|
||||
def test_start_with_config_expands_env_placeholder_keys(monkeypatch):
|
||||
# The loader resolves ${VAR} in values only; auth_scopes keys must be
|
||||
# expanded too, and the literal placeholder must not be a grant key.
|
||||
monkeypatch.setenv("SUPPORT_TOKEN", "real-support-token")
|
||||
gw = WebSocketGateway(config=GatewayConfig(auth_token="tmp"))
|
||||
cfg = {"gateway": {"auth_scopes": {
|
||||
"${SUPPORT_TOKEN}": {"scopes": ["read"], "agents": ["support"]},
|
||||
"${UNSET_TOKEN_6253}": ["read", "write"],
|
||||
}}}
|
||||
with pytest.raises(_StopStartup):
|
||||
_start(gw, cfg, monkeypatch)
|
||||
assert gw.config.auth_scopes == {
|
||||
"real-support-token": {"scopes": ["read"], "agents": ["support"]},
|
||||
}
|
||||
assert gw.config.resolve_agents("real-support-token") == ["support"]
|
||||
assert gw.config.resolve_scopes("${SUPPORT_TOKEN}") == []
|
||||
assert gw.config.resolve_scopes("") == []
|
||||
|
||||
|
||||
def test_start_with_config_refuses_policy_with_only_unset_tokens(monkeypatch):
|
||||
# An all-empty policy would mean "no policy" (fail open), so startup stops.
|
||||
monkeypatch.delenv("UNSET_TOKEN_6253", raising=False)
|
||||
gw = WebSocketGateway(config=GatewayConfig(auth_token="tmp"))
|
||||
cfg = {"gateway": {"auth_scopes": {
|
||||
"${UNSET_TOKEN_6253}": {"scopes": ["read"], "agents": ["support"]},
|
||||
}}}
|
||||
with pytest.raises(ValueError, match="resolved to empty"):
|
||||
_start(gw, cfg, monkeypatch)
|
||||
# Tokens form: the loader already turned the unset value into "".
|
||||
cfg = {"gateway": {"auth": {"tokens": [
|
||||
{"token": "", "scopes": ["read"], "agents": ["support"]},
|
||||
]}}}
|
||||
with pytest.raises(ValueError, match="resolved to empty"):
|
||||
_start(gw, cfg, monkeypatch)
|
||||
|
||||
|
||||
def test_start_with_config_tolerates_core_without_policy_parser(monkeypatch):
|
||||
# An older praisonaiagents lacks _parse_auth_scopes / resolve_agents; with
|
||||
# no YAML policy the gateway must still start instead of ImportError.
|
||||
import praisonaiagents.gateway.config as core_cfg
|
||||
|
||||
monkeypatch.delattr(core_cfg, "_parse_auth_scopes")
|
||||
gw = WebSocketGateway(config=GatewayConfig(auth_token="tmp", auth_scopes=POLICY))
|
||||
with pytest.raises(_StopStartup):
|
||||
_start(gw, {"gateway": {}}, monkeypatch)
|
||||
assert gw.config.auth_scopes == POLICY
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gw_yaml", [
|
||||
{"auth_scopes": {"support-token": {"scopes": ["read"], "agents": ["support"]}}},
|
||||
{"auth": {"tokens": [{"token": "t", "scopes": ["read"], "agents": ["support"]}]}},
|
||||
])
|
||||
def test_start_with_config_refuses_unenforceable_policy_on_older_core(gw_yaml, monkeypatch):
|
||||
# A declared policy that the installed core cannot parse must fail closed,
|
||||
# not start with every operator unconfined.
|
||||
import praisonaiagents.gateway.config as core_cfg
|
||||
|
||||
monkeypatch.delattr(core_cfg, "_parse_auth_scopes")
|
||||
gw = WebSocketGateway(config=GatewayConfig(auth_token="tmp"))
|
||||
with pytest.raises(ValueError, match="cannot enforce"):
|
||||
_start(gw, {"gateway": dict(gw_yaml)}, monkeypatch)
|
||||
assert not gw.config.auth_scopes
|
||||
Generated
+2
-2
@@ -1428,7 +1428,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonai-bot"
|
||||
version = "0.1.20"
|
||||
version = "0.1.21"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -1510,7 +1510,7 @@ provides-extras = ["gateway", "bot", "bot-whatsapp-web", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.7.21"
|
||||
version = "1.7.22"
|
||||
source = { directory = "../praisonai-agents" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.20"
|
||||
__version__ = "0.1.21"
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "praisonai-browser"
|
||||
version = "0.1.20"
|
||||
version = "0.1.21"
|
||||
description = "Browser automation for PraisonAI — Chrome extension bridge, CDP, and Playwright agents extracted from the praisonai wrapper."
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
Generated
+2
-2
@@ -932,7 +932,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonai-browser"
|
||||
version = "0.1.20"
|
||||
version = "0.1.21"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -976,7 +976,7 @@ provides-extras = ["server", "playwright", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.7.21"
|
||||
version = "1.7.22"
|
||||
source = { directory = "../praisonai-agents" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -12,6 +12,6 @@ the remaining terminal-agent modules, with PEP 562 shims left behind at the
|
||||
old ``praisonai.*`` paths for backward compatibility.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.20"
|
||||
__version__ = "0.1.21"
|
||||
|
||||
__all__ = ["__version__"]
|
||||
@@ -1712,6 +1712,7 @@ def run_main(
|
||||
# execution path does not discover and execute the same definitions twice.
|
||||
preloaded_agent_config = None
|
||||
custom_agent_model = None
|
||||
custom_agent_base_url = None
|
||||
if target or agent or command: # Any execution surface may need model/budget resolution
|
||||
import sys
|
||||
from praisonai_code.llm.credentials import ensure_configured_or_onboard
|
||||
@@ -1731,6 +1732,10 @@ def run_main(
|
||||
_preview_llm = (_preview_config or {}).get("llm")
|
||||
if isinstance(_preview_llm, dict):
|
||||
custom_agent_model = _preview_llm.get("model")
|
||||
# Gate an Ollama agent on the server it will really call.
|
||||
_base = _preview_llm.get("base_url")
|
||||
if isinstance(_base, str) and _base.strip():
|
||||
custom_agent_base_url = _base.strip()
|
||||
else:
|
||||
custom_agent_model = _preview_llm
|
||||
if isinstance(custom_agent_model, str):
|
||||
@@ -1747,7 +1752,9 @@ def run_main(
|
||||
# endpoint, credentials, sampling, and retry options.
|
||||
credential_model = model or custom_agent_model
|
||||
resolved_model = ensure_configured_or_onboard(
|
||||
model=credential_model, interactive=not _headless
|
||||
model=credential_model,
|
||||
interactive=not _headless,
|
||||
base_url=None if model else custom_agent_base_url,
|
||||
)
|
||||
if not (agent and model is None and custom_agent_model):
|
||||
model = resolved_model
|
||||
@@ -2386,6 +2393,8 @@ def _run_prompt(
|
||||
if allow_local_tools:
|
||||
_os.environ[_ALLOW_LOCAL_TOOLS_ENV] = "true"
|
||||
|
||||
_manifest_session = None
|
||||
|
||||
try:
|
||||
# Handle session continuity first (before any execution mode)
|
||||
from praisonai_code.cli.main import PraisonAI
|
||||
@@ -2435,6 +2444,13 @@ def _run_prompt(
|
||||
import uuid
|
||||
auto_save_name = session_id or "session-" + str(uuid.uuid4())[:8]
|
||||
|
||||
# Write-ahead "run in flight" marker on the session manifest, released
|
||||
# in ``finally`` below; a run that dies before then is reported as
|
||||
# interrupted on the next --continue/--session (Issue #6248).
|
||||
_manifest_session = _begin_manifest_run(
|
||||
session_id or auto_save_name, prompt, output, quiet=_json_stdout
|
||||
)
|
||||
|
||||
# Detect-and-attach: if a warm runtime is running, forward the plain
|
||||
# prompt to it (skipping per-invocation cold-start) and fall back to
|
||||
# in-process execution otherwise. Only the simple text path attaches;
|
||||
@@ -2767,6 +2783,7 @@ def _run_prompt(
|
||||
output.print_error(str(e))
|
||||
raise typer.Exit(1)
|
||||
finally:
|
||||
_end_manifest_run(_manifest_session)
|
||||
# Restore the prior opt-in state so the grant is strictly per-invocation.
|
||||
if _prev_allow_local_tools is None:
|
||||
_os.environ.pop(_ALLOW_LOCAL_TOOLS_ENV, None)
|
||||
@@ -2778,6 +2795,57 @@ def _run_prompt(
|
||||
output.mode = _prev_mode
|
||||
|
||||
|
||||
def _begin_manifest_run(session_key, prompt, output, *, quiet: bool = False):
|
||||
"""Commit the session manifest's in-flight marker for this run.
|
||||
|
||||
If the previous run on this session never released its marker it was
|
||||
interrupted; say so, so the user can re-issue it. Returns the session key
|
||||
a ``(manifest, session_key, run_id)`` handle to release later, or ``None``
|
||||
when there is no session or the manifest is unavailable (manifest problems
|
||||
never block a run).
|
||||
"""
|
||||
if not session_key:
|
||||
return None
|
||||
try:
|
||||
import uuid
|
||||
from praisonaiagents.session.manifest import FileSessionManifest
|
||||
from ..utils.project import get_project_sessions_dir
|
||||
|
||||
# Scope manifests like the project session store, so equal session
|
||||
# names in different projects never share a run marker.
|
||||
manifest = FileSessionManifest(str(get_project_sessions_dir() / "manifests"))
|
||||
stale = manifest.interrupted(session_key)
|
||||
if stale is not None and not quiet:
|
||||
# The prompt itself is not stored (only a digest), so it is not echoed.
|
||||
output.print_warning(
|
||||
f"The previous run in this session was interrupted "
|
||||
f"(attempt {stale.attempt}); re-issue it to retry."
|
||||
)
|
||||
run_id = uuid.uuid4().hex[:12]
|
||||
manifest.begin_run(session_key, run_id, run_input=prompt)
|
||||
return manifest, session_key, run_id
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _end_manifest_run(handle) -> None:
|
||||
"""Release this run's own in-flight marker unless it was interrupted (Ctrl+C)."""
|
||||
if not handle:
|
||||
return
|
||||
import sys
|
||||
|
||||
exc = sys.exc_info()[1]
|
||||
if isinstance(exc, KeyboardInterrupt):
|
||||
return
|
||||
manifest, session_key, run_id = handle
|
||||
try:
|
||||
manifest.end_run(
|
||||
session_key, "completed" if exc is None else "ended", run_id=run_id
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _record_session_usage(session_id, model, output, *, suppress_info: bool = False) -> None:
|
||||
"""Accumulate this run's token/cost usage into the active session and show
|
||||
a compact running total footer (Issue #2421).
|
||||
|
||||
@@ -123,6 +123,43 @@ def detect_local_endpoint():
|
||||
return None
|
||||
|
||||
|
||||
_OLLAMA_PREFIXES = ("ollama/", "ollama_chat/")
|
||||
|
||||
|
||||
def _ollama_host_for_model(
|
||||
model: Optional[str], base_url: Optional[str] = None
|
||||
) -> Optional[str]:
|
||||
"""Return the Ollama host to probe for an ``ollama/`` model, else ``None``.
|
||||
|
||||
Ollama needs no key, so an explicit Ollama model is gated on the server
|
||||
being reachable rather than on a credential. The endpoint the caller will
|
||||
actually use (e.g. an agent's ``llm.base_url``) wins, then
|
||||
``OLLAMA_API_BASE`` (what the runtime honours), then ``OLLAMA_HOST``, then
|
||||
the default port.
|
||||
"""
|
||||
import os
|
||||
|
||||
if not model or not model.lower().startswith(_OLLAMA_PREFIXES):
|
||||
return None
|
||||
from praisonai_code.llm.local_detect import _DEFAULT_OLLAMA_HOST
|
||||
|
||||
return (
|
||||
base_url
|
||||
or os.environ.get("OLLAMA_API_BASE")
|
||||
or os.environ.get("OLLAMA_HOST")
|
||||
or _DEFAULT_OLLAMA_HOST
|
||||
)
|
||||
|
||||
|
||||
def _ollama_reachable(host: str) -> bool:
|
||||
try:
|
||||
from praisonai_code.llm.local_detect import ollama_reachable
|
||||
|
||||
return ollama_reachable(host)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _provider_key_vars_for_model(model: str) -> tuple[str, ...]:
|
||||
"""Map a model id to the environment variable(s) for its provider.
|
||||
|
||||
@@ -258,6 +295,10 @@ def is_configured(model: Optional[str] = None) -> bool:
|
||||
if model is None:
|
||||
model = default_model_for_available_provider()
|
||||
|
||||
ollama_host = _ollama_host_for_model(model) if explicit_model else None
|
||||
if ollama_host is not None:
|
||||
return _ollama_reachable(ollama_host)
|
||||
|
||||
required_vars = _provider_key_vars_for_model(model)
|
||||
|
||||
if required_vars:
|
||||
@@ -303,6 +344,7 @@ def ensure_configured_or_onboard(
|
||||
*,
|
||||
model: Optional[str] = None,
|
||||
interactive: bool = True,
|
||||
base_url: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Shared first-run credential gate for LLM-bound interactive commands.
|
||||
|
||||
@@ -331,6 +373,9 @@ def ensure_configured_or_onboard(
|
||||
keyless local-first fallback (its own provider gate applies).
|
||||
interactive: Whether a wizard prompt may be shown. Callers pass ``False``
|
||||
for headless (``-p``, piped, ``--output json``) paths.
|
||||
base_url: The endpoint the caller will actually use (e.g. a custom
|
||||
agent's ``llm.base_url``). For an explicit Ollama model this is the
|
||||
server probed, instead of ``OLLAMA_API_BASE``/``OLLAMA_HOST``.
|
||||
|
||||
Returns:
|
||||
The model id to proceed with. May be a detected local model id when the
|
||||
@@ -343,6 +388,21 @@ def ensure_configured_or_onboard(
|
||||
import typer
|
||||
|
||||
inject_credentials_into_env()
|
||||
|
||||
# An explicit Ollama model needs a running server, not a key: probe the
|
||||
# endpoint the caller will really use and, if it is down, say so instead
|
||||
# of sending the user to an API-key wizard.
|
||||
ollama_host = _ollama_host_for_model(model, base_url)
|
||||
if ollama_host is not None:
|
||||
if _ollama_reachable(ollama_host):
|
||||
return model
|
||||
typer.echo(
|
||||
f"Error: Ollama isn't reachable at {ollama_host} — start it with "
|
||||
"`ollama serve` (or set OLLAMA_API_BASE).",
|
||||
err=True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
if is_configured(model):
|
||||
return model
|
||||
|
||||
@@ -360,11 +420,16 @@ def ensure_configured_or_onboard(
|
||||
return local.model
|
||||
|
||||
if not interactive:
|
||||
# Only mention auto-detection when it actually ran and found nothing.
|
||||
hint = f" for model {model}" if model else ""
|
||||
tail = (
|
||||
"" if model else
|
||||
"\n(no running local endpoint such as Ollama was found; one "
|
||||
"would be used automatically)"
|
||||
)
|
||||
typer.echo(
|
||||
"Error: No API key configured. Run: praisonai setup\n"
|
||||
"or set environment variables like OPENAI_API_KEY\n"
|
||||
"(a running local endpoint such as Ollama would be used "
|
||||
"automatically)",
|
||||
f"Error: No API key configured{hint}. Run: praisonai setup\n"
|
||||
f"or set environment variables like OPENAI_API_KEY{tail}",
|
||||
err=True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
@@ -19,9 +19,10 @@ from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
# Total time budget for a probe so the credential/first-run hot path is never
|
||||
# stalled when nothing is listening. Kept small deliberately.
|
||||
_PROBE_TIMEOUT_S = 0.15
|
||||
# Per-request probe timeout. Only paid on the keyless path; a closed port is
|
||||
# refused instantly, while a running Ollama can take ~0.35 s on its first call,
|
||||
# so a tighter budget made detection flaky (issue #6228).
|
||||
_PROBE_TIMEOUT_S = 1.0
|
||||
|
||||
# How long a negative probe is remembered so repeated invocations in the same
|
||||
# process (e.g. bare TUI then run) don't re-pay the connection latency.
|
||||
@@ -187,6 +188,11 @@ def _probe_ollama_tags(host: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def ollama_reachable(host: str) -> bool:
|
||||
"""Return ``True`` if an Ollama server answers ``/api/tags`` at ``host``."""
|
||||
return _get_json(_root_host(_with_scheme(host)) + "/api/tags") is not None
|
||||
|
||||
|
||||
def detect_local_model(*, use_cache: bool = True) -> Optional[LocalModel]:
|
||||
"""Detect a reachable local OpenAI-compatible endpoint.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "praisonai-code"
|
||||
version = "0.1.20"
|
||||
version = "0.1.21"
|
||||
description = "Agentic terminal CLI for PraisonAI — the terminal-native agent (run, chat, code, runtime, cli backends) extracted from the praisonai wrapper."
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Tests for issue #6228 — explicit ``ollama/`` models need no API key.
|
||||
|
||||
An explicit Ollama model is gated on the server being reachable, not on
|
||||
``OLLAMA_HOST`` being set; the probe tolerates a slow first response; and the
|
||||
error text for an explicit model does not claim auto-detection would kick in.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
|
||||
import pytest
|
||||
import typer
|
||||
|
||||
from praisonai_code.llm import credentials, local_detect
|
||||
|
||||
|
||||
_ENV = (
|
||||
"OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY",
|
||||
"GROQ_API_KEY", "COHERE_API_KEY", "OPENROUTER_API_KEY", "OLLAMA_HOST",
|
||||
"OLLAMA_API_BASE", "OPENAI_BASE_URL", "MODEL_NAME", "OPENAI_MODEL_NAME",
|
||||
"PRAISONAI_LOCAL_ENDPOINTS",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clean_env(monkeypatch):
|
||||
for var in _ENV:
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
monkeypatch.setattr(credentials, "inject_credentials_into_env", lambda: False)
|
||||
local_detect.reset_cache()
|
||||
yield monkeypatch
|
||||
local_detect.reset_cache()
|
||||
|
||||
|
||||
class _Resp(io.BytesIO):
|
||||
status = 200
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
|
||||
def _fake_urlopen(seen, delay=0.0):
|
||||
def _urlopen(url, timeout):
|
||||
seen.append((url, timeout))
|
||||
if delay > timeout:
|
||||
raise TimeoutError("probe timed out")
|
||||
return _Resp(json.dumps({"models": [{"name": "llama3.2:latest"}]}).encode())
|
||||
return _urlopen
|
||||
|
||||
|
||||
def _refuse(url, timeout):
|
||||
raise ConnectionRefusedError()
|
||||
|
||||
|
||||
def test_explicit_ollama_configured_when_reachable(clean_env):
|
||||
seen = []
|
||||
clean_env.setattr(local_detect.urllib.request, "urlopen", _fake_urlopen(seen))
|
||||
assert credentials.is_configured("ollama/llama3.2") is True
|
||||
assert seen[0][0] == "http://127.0.0.1:11434/api/tags"
|
||||
|
||||
|
||||
def test_explicit_ollama_honours_ollama_api_base(clean_env):
|
||||
seen = []
|
||||
clean_env.setenv("OLLAMA_API_BASE", "http://gpu-box:11434")
|
||||
clean_env.setattr(local_detect.urllib.request, "urlopen", _fake_urlopen(seen))
|
||||
assert credentials.is_configured("ollama_chat/llama3.2") is True
|
||||
assert seen[0][0] == "http://gpu-box:11434/api/tags"
|
||||
|
||||
|
||||
def test_explicit_ollama_not_configured_when_unreachable(clean_env):
|
||||
clean_env.setattr(local_detect.urllib.request, "urlopen", _refuse)
|
||||
assert credentials.is_configured("ollama/llama3.2") is False
|
||||
|
||||
|
||||
def test_unreachable_ollama_error_is_actionable(clean_env, capsys):
|
||||
clean_env.setattr(local_detect.urllib.request, "urlopen", _refuse)
|
||||
with pytest.raises(typer.Exit):
|
||||
credentials.ensure_configured_or_onboard(
|
||||
model="ollama/llama3.2", interactive=False
|
||||
)
|
||||
err = capsys.readouterr().err
|
||||
assert "Ollama isn't reachable at http://127.0.0.1:11434" in err
|
||||
assert "ollama serve" in err
|
||||
assert "API key" not in err
|
||||
|
||||
|
||||
def test_explicit_ollama_passes_gate_when_reachable(clean_env):
|
||||
clean_env.setattr(local_detect.urllib.request, "urlopen", _fake_urlopen([]))
|
||||
assert credentials.ensure_configured_or_onboard(
|
||||
model="ollama/llama3.2", interactive=False
|
||||
) == "ollama/llama3.2"
|
||||
|
||||
|
||||
def test_probe_tolerates_slow_first_response(clean_env):
|
||||
"""A ~0.4 s first /api/tags response must still be detected."""
|
||||
clean_env.setattr(
|
||||
local_detect.urllib.request, "urlopen", _fake_urlopen([], delay=0.4)
|
||||
)
|
||||
result = local_detect.detect_local_model(use_cache=False)
|
||||
assert result is not None
|
||||
assert result.model == "ollama/llama3.2:latest"
|
||||
|
||||
|
||||
def test_explicit_cloud_model_error_omits_auto_detect_hint(clean_env, capsys):
|
||||
clean_env.setattr(credentials, "detect_local_endpoint", lambda: None)
|
||||
clean_env.setattr(credentials, "is_configured", lambda model=None: False)
|
||||
with pytest.raises(typer.Exit):
|
||||
credentials.ensure_configured_or_onboard(
|
||||
model="gpt-4o-mini", interactive=False
|
||||
)
|
||||
err = capsys.readouterr().err
|
||||
assert "for model gpt-4o-mini" in err
|
||||
assert "automatically" not in err
|
||||
|
||||
|
||||
def test_keyless_no_model_error_mentions_auto_detect(clean_env, capsys):
|
||||
clean_env.setattr(credentials, "detect_local_endpoint", lambda: None)
|
||||
clean_env.setattr(credentials, "is_configured", lambda model=None: False)
|
||||
with pytest.raises(typer.Exit):
|
||||
credentials.ensure_configured_or_onboard(model=None, interactive=False)
|
||||
assert "automatically" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_gate_probes_agent_base_url_not_env_host(clean_env):
|
||||
"""A custom agent's ``llm.base_url`` is the server gated, not OLLAMA_HOST."""
|
||||
seen = []
|
||||
clean_env.setenv("OLLAMA_HOST", "http://down-box:11434")
|
||||
|
||||
def _urlopen(url, timeout):
|
||||
seen.append(url)
|
||||
if "down-box" in url:
|
||||
raise ConnectionRefusedError()
|
||||
return _Resp(json.dumps({"models": []}).encode())
|
||||
|
||||
clean_env.setattr(local_detect.urllib.request, "urlopen", _urlopen)
|
||||
assert credentials.ensure_configured_or_onboard(
|
||||
model="ollama/llama3.2",
|
||||
interactive=False,
|
||||
base_url="http://gpu-box:11434/v1",
|
||||
) == "ollama/llama3.2"
|
||||
assert seen == ["http://gpu-box:11434/api/tags"]
|
||||
Generated
+2
-2
@@ -1370,7 +1370,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonai-code"
|
||||
version = "0.1.20"
|
||||
version = "0.1.21"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -1403,7 +1403,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.7.21"
|
||||
version = "1.7.22"
|
||||
source = { directory = "../praisonai-agents" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.20"
|
||||
__version__ = "0.1.21"
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "praisonai-deploy"
|
||||
version = "0.1.20"
|
||||
version = "0.1.21"
|
||||
description = "Deployment for PraisonAI — API, Docker, and cloud (AWS, Azure, GCP) deployments extracted from the praisonai wrapper."
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
Generated
+2
-2
@@ -977,7 +977,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonai-deploy"
|
||||
version = "0.1.20"
|
||||
version = "0.1.21"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -1014,7 +1014,7 @@ provides-extras = ["api", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.7.21"
|
||||
version = "1.7.22"
|
||||
source = { directory = "../praisonai-agents" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.22"
|
||||
__version__ = "0.1.23"
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "praisonai-mcp"
|
||||
version = "0.1.22"
|
||||
version = "0.1.23"
|
||||
description = "MCP server hosting for PraisonAI — expose agents and tools to Cursor, Claude Desktop, and other MCP clients."
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
Generated
+2
-2
@@ -1044,7 +1044,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonai-mcp"
|
||||
version = "0.1.22"
|
||||
version = "0.1.23"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -1084,7 +1084,7 @@ provides-extras = ["server", "auth", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.7.21"
|
||||
version = "1.7.22"
|
||||
source = { directory = "../praisonai-agents" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.20"
|
||||
__version__ = "0.1.21"
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "praisonai-sandbox"
|
||||
version = "0.1.20"
|
||||
version = "0.1.21"
|
||||
description = "Where PraisonAI agents run code — Docker, E2B, Modal, Daytona, Fly.io, Tenki, Novita, Sandlock, SSH and subprocess, as both single sandboxes and multi-instance compute providers."
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
Generated
+2
-2
@@ -1679,7 +1679,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonai-sandbox"
|
||||
version = "0.1.20"
|
||||
version = "0.1.21"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -1744,7 +1744,7 @@ provides-extras = ["docker", "e2b", "sandlock", "ssh", "tenki", "flyio", "modal"
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.7.21"
|
||||
version = "1.7.22"
|
||||
source = { directory = "../praisonai-agents" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.20"
|
||||
__version__ = "0.1.21"
|
||||
@@ -717,6 +717,9 @@ def train_export(
|
||||
mtp_draft: bool = typer.Option(
|
||||
False, "--mtp-draft/--no-mtp-draft",
|
||||
help="Also download the stock MTP drafter (Gemma-4 only) for fast inference"),
|
||||
push: Optional[bool] = typer.Option(
|
||||
None, "--push/--no-push",
|
||||
help="ollama: push to ollama.com after `ollama create` (--no-push keeps it local)"),
|
||||
):
|
||||
"""
|
||||
Export / publish an ALREADY-trained model without re-running training.
|
||||
@@ -726,6 +729,7 @@ def train_export(
|
||||
praisonai-train export gguf --model-dir lora_model --quant q4_k_m # local .gguf
|
||||
praisonai-train export gguf --model-dir lora_model --hf me/my-model # local + push
|
||||
praisonai-train export ollama --model-dir lora_model --ollama me/my-model --quant q4_k_m
|
||||
praisonai-train export ollama --model-dir lora_model --ollama my-model --no-push # local only
|
||||
"""
|
||||
from ..output.console import get_output_controller
|
||||
|
||||
@@ -765,6 +769,9 @@ def train_export(
|
||||
cfg["hf_model_name"] = hf
|
||||
if ollama:
|
||||
cfg["ollama_model"] = ollama
|
||||
# Either explicit flag overrides the config; unset keeps config/default (push).
|
||||
if isinstance(push, bool):
|
||||
cfg["ollama_push"] = push
|
||||
|
||||
# Recover the model name (drives chat-template selection) from --base-model or
|
||||
# the trained model's config.json (_name_or_path), falling back to the dir name.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
ollama_save: "true"
|
||||
ollama_push: "true" # set "false" to keep the Ollama model local (no upload to ollama.com)
|
||||
huggingface_save: "true"
|
||||
train: "true"
|
||||
|
||||
|
||||
@@ -220,8 +220,9 @@ def create_and_push_ollama_model(
|
||||
model_parameters: str,
|
||||
modelfile_content: str,
|
||||
quantization: Optional[str] = None,
|
||||
push: bool = True,
|
||||
) -> None:
|
||||
"""Create and push an Ollama model with proper daemon management.
|
||||
"""Create (and by default push) an Ollama model with proper daemon management.
|
||||
|
||||
Args:
|
||||
ollama_model: Name of the Ollama model
|
||||
@@ -229,6 +230,8 @@ def create_and_push_ollama_model(
|
||||
modelfile_content: Content for the Modelfile
|
||||
quantization: Optional quantization (e.g. "q4_k_m") passed to
|
||||
``ollama create --quantize`` so the model isn't stored as huge f16.
|
||||
push: When False, stop after a successful ``ollama create`` and keep the
|
||||
model local-only (nothing is uploaded to ollama.com).
|
||||
|
||||
Raises:
|
||||
RuntimeError: If ollama create/push operations fail (with actionable guidance)
|
||||
@@ -259,6 +262,10 @@ def create_and_push_ollama_model(
|
||||
f"{(create.stderr or create.stdout or '').strip()}"
|
||||
)
|
||||
|
||||
if not push:
|
||||
print(f"Created local Ollama model `{tag}` (not pushed). Run it with: ollama run {tag}")
|
||||
return
|
||||
|
||||
# Push with captured output so a 401/403 becomes an actionable message rather
|
||||
# than a raw non-zero exit.
|
||||
result = subprocess.run(["ollama", "push", tag], capture_output=True, text=True)
|
||||
|
||||
@@ -545,6 +545,7 @@ class TrainModel:
|
||||
"optim", "weight_decay", "lr_scheduler_type", "seed", "output_dir",
|
||||
"assistant_only_loss", "train_on_responses_only", "save_steps",
|
||||
"train", "huggingface_save", "huggingface_save_gguf", "ollama_save",
|
||||
"ollama_push",
|
||||
"method", "beta", "max_prompt_length", "desirable_weight", "undesirable_weight",
|
||||
"reward_funcs", "num_generations", "max_completion_length",
|
||||
"embedding_learning_rate",
|
||||
@@ -951,6 +952,10 @@ class TrainModel:
|
||||
if self._flag(dataset_info.get("shuffle"), default=False):
|
||||
dataset = dataset.shuffle(
|
||||
seed=int(dataset_info.get("seed", self.config.get("seed", 3407))))
|
||||
# OpenAI-style chat rows ({"messages": [...]}, as written by
|
||||
# praisonaiagents.eval.export_sft) are the ShareGPT shape under another name.
|
||||
if "messages" in dataset.column_names and "conversations" not in dataset.column_names:
|
||||
dataset = dataset.rename_column("messages", "conversations")
|
||||
if "conversations" in dataset.column_names:
|
||||
print("DEBUG: Standardizing dataset (ShareGPT style)...")
|
||||
dataset = standardize_sharegpt(dataset)
|
||||
@@ -970,8 +975,8 @@ class TrainModel:
|
||||
if len(dataset) == 0:
|
||||
raise ValueError(
|
||||
"All examples formatted to empty text. The dataset columns are "
|
||||
f"{columns_before}; this trainer understands ShareGPT "
|
||||
"('conversations'), Alpaca ('instruction'/'input'/'output') and "
|
||||
f"{columns_before}; this trainer understands chat "
|
||||
"('conversations' or 'messages'), Alpaca ('instruction'/'input'/'output') and "
|
||||
"raw text ('text'). Rename your columns to one of those shapes, "
|
||||
"or set chat_template to match your data.")
|
||||
return dataset
|
||||
@@ -1784,6 +1789,7 @@ class TrainModel:
|
||||
self.config.get('model_parameters', 'latest'),
|
||||
modelfile_content,
|
||||
quantization=self.config.get('quantization_method'),
|
||||
push=self._flag(self.config.get('ollama_push'), default=True),
|
||||
)
|
||||
|
||||
def run(self):
|
||||
|
||||
@@ -313,7 +313,8 @@ PARAMETER top_p 0.9
|
||||
create_and_push_ollama_model(
|
||||
self.config['ollama_model'],
|
||||
self.config['model_parameters'],
|
||||
modelfile_content
|
||||
modelfile_content,
|
||||
push=self._flag(self.config.get('ollama_push'), default=True),
|
||||
)
|
||||
|
||||
def run(self):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "praisonai-train"
|
||||
version = "0.1.20"
|
||||
version = "0.1.21"
|
||||
description = "Training for PraisonAI — LLM fine-tuning (Unsloth) and iterative agent training extracted from the praisonai wrapper."
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""An OpenAI-style `messages` chat dataset formatted to empty text.
|
||||
|
||||
`praisonai-train validate` and `praisonaiagents.eval.export_sft` both use the
|
||||
`{"messages": [...]}` shape, but `process_dataset` only recognised
|
||||
`conversations`, so every row fell into the Alpaca branch, formatted to "",
|
||||
and the run died after the base model had already been loaded.
|
||||
"""
|
||||
|
||||
from praisonai_train.train.llm import trainer as trainer_mod
|
||||
|
||||
|
||||
class _FakeDataset:
|
||||
def __init__(self, rows):
|
||||
self.rows = rows
|
||||
|
||||
@property
|
||||
def column_names(self):
|
||||
return list(self.rows[0].keys()) if self.rows else []
|
||||
|
||||
def __len__(self):
|
||||
return len(self.rows)
|
||||
|
||||
def rename_column(self, src, dst):
|
||||
return _FakeDataset([{(dst if k == src else k): v for k, v in r.items()}
|
||||
for r in self.rows])
|
||||
|
||||
def map(self, func, batched, remove_columns):
|
||||
batch = {c: [r[c] for r in self.rows] for c in self.column_names}
|
||||
out = func(batch)
|
||||
return _FakeDataset([{"text": t} for t in out["text"]])
|
||||
|
||||
def filter(self, pred):
|
||||
return _FakeDataset([r for r in self.rows if pred(r)])
|
||||
|
||||
|
||||
class _Tokenizer:
|
||||
def apply_chat_template(self, convo, tokenize, add_generation_prompt):
|
||||
return "".join(f"<{m['role']}>{m['content']}" for m in convo)
|
||||
|
||||
|
||||
def test_messages_column_is_treated_as_conversations(monkeypatch):
|
||||
rows = [{"messages": [
|
||||
{"role": "system", "content": "s"},
|
||||
{"role": "user", "content": f"q{i}"},
|
||||
{"role": "assistant", "content": f"a{i}"},
|
||||
]} for i in range(3)]
|
||||
monkeypatch.setattr(trainer_mod, "load_dataset",
|
||||
lambda *a, **k: _FakeDataset(rows), raising=False)
|
||||
monkeypatch.setattr(trainer_mod, "standardize_sharegpt", lambda d: d,
|
||||
raising=False)
|
||||
|
||||
tm = trainer_mod.TrainModel.__new__(trainer_mod.TrainModel)
|
||||
tm.config = {"method": "sft"}
|
||||
tm.chat_tokenizer = _Tokenizer()
|
||||
|
||||
out = tm.process_dataset({"name": "hub/some-dataset"})
|
||||
assert len(out) == 3
|
||||
assert out.rows[0]["text"] == "<system>s<user>q0<assistant>a0"
|
||||
@@ -412,3 +412,88 @@ def test_export_command_bad_target_exits(monkeypatch, tmp_path):
|
||||
quant=None,
|
||||
base_model=None,
|
||||
)
|
||||
|
||||
|
||||
def test_ollama_no_push_skips_push(monkeypatch, tmp_path):
|
||||
"""push=False stops after `ollama create`; nothing is sent to ollama.com."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setattr(ollama_mod, "_check_ollama_disk", lambda *_a, **_k: None)
|
||||
monkeypatch.setattr(ollama_mod, "ensure_ollama_running", lambda *_a, **_k: None)
|
||||
calls = []
|
||||
|
||||
def _fake_run(cmd, *a, **k):
|
||||
calls.append(cmd)
|
||||
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(ollama_mod.subprocess, "run", _fake_run)
|
||||
ollama_mod.create_and_push_ollama_model(
|
||||
"my-model", "latest", "FROM ./model\n", push=False)
|
||||
assert [c[1] for c in calls] == ["create"]
|
||||
assert not any("push" in c for c in calls)
|
||||
|
||||
|
||||
def _ollama_export_cfg(monkeypatch, tmp_path, push, config_push=None):
|
||||
"""Run `export ollama` with a fake trainer; return the config it received."""
|
||||
import praisonai_train.cli.commands.train as cli
|
||||
|
||||
seen = {}
|
||||
|
||||
class _FakeTrainer:
|
||||
@classmethod
|
||||
def for_export(cls, cfg):
|
||||
obj = cls()
|
||||
obj.config = cfg
|
||||
obj.model = None
|
||||
obj.hf_tokenizer = None
|
||||
return obj
|
||||
|
||||
def load_model(self):
|
||||
return ("MODEL", "TOK")
|
||||
|
||||
def create_and_push_ollama_model(self):
|
||||
seen.update(self.config)
|
||||
|
||||
monkeypatch.setattr(trainer_mod, "TrainModel", _FakeTrainer)
|
||||
config = None
|
||||
if config_push is not None:
|
||||
config = tmp_path / "cfg.yaml"
|
||||
config.write_text(f"ollama_push: {config_push}\n")
|
||||
cli.train_export(
|
||||
target="ollama", model_dir=str(tmp_path), config=str(config) if config else None,
|
||||
ollama="my-model", hf=None, quant=None, base_model="unsloth/gemma-2-2b",
|
||||
mtp_draft=False, push=push,
|
||||
)
|
||||
return seen
|
||||
|
||||
|
||||
def test_export_ollama_no_push_flag_sets_config(monkeypatch, tmp_path):
|
||||
assert _ollama_export_cfg(monkeypatch, tmp_path, push=False)["ollama_push"] is False
|
||||
|
||||
|
||||
def test_export_ollama_explicit_push_overrides_config(monkeypatch, tmp_path):
|
||||
cfg = _ollama_export_cfg(monkeypatch, tmp_path, push=True, config_push="false")
|
||||
assert cfg["ollama_push"] is True
|
||||
|
||||
|
||||
def test_export_ollama_unset_push_keeps_config(monkeypatch, tmp_path):
|
||||
cfg = _ollama_export_cfg(monkeypatch, tmp_path, push=None, config_push="false")
|
||||
assert cfg["ollama_push"] is False # YAML bool preserved, not overridden
|
||||
assert "ollama_push" not in _ollama_export_cfg(monkeypatch, tmp_path, push=None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value, expected", [
|
||||
(False, False), ("false", False), (True, True), ("true", True), (None, True),
|
||||
])
|
||||
def test_llm_trainer_forwards_ollama_push(monkeypatch, value, expected):
|
||||
"""TrainModel forwards `ollama_push` (bool or string) to the helper."""
|
||||
captured = {}
|
||||
monkeypatch.setattr(
|
||||
ollama_mod, "create_and_push_ollama_model",
|
||||
lambda *a, **k: captured.update(k))
|
||||
fake = types.SimpleNamespace(
|
||||
config={"ollama_model": "m", "ollama_push": value},
|
||||
prepare_modelfile_content=lambda: "FROM ./model\n",
|
||||
_flag=trainer_mod.TrainModel._flag,
|
||||
)
|
||||
trainer_mod.TrainModel.create_and_push_ollama_model(fake)
|
||||
assert captured["push"] is expected
|
||||
Generated
+2
-2
@@ -2606,7 +2606,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonai-train"
|
||||
version = "0.1.20"
|
||||
version = "0.1.21"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -2696,7 +2696,7 @@ provides-extras = ["llm", "vision", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.7.21"
|
||||
version = "1.7.22"
|
||||
source = { directory = "../praisonai-agents" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# PraisonAI TypeScript Node.js AI Agents Framework
|
||||
|
||||
PraisonAI helps banks, insurers and hospitals run AI agents on their own computers, even fully offline, so their private data never leaves the building.
|
||||
PraisonAI runs AI agents on your own computers, even fully offline, so private data never has to leave the building. It is built for highly regulated teams, and free and open source for everyone.
|
||||
|
||||
This package is the TypeScript and JavaScript SDK of the open-source PraisonAI agent framework. Build single agents, multi-agent workflows, tools and memory in Node.js, on hosted models or on local models such as Ollama. Data stays on your own machines only when the model runs locally too; with a hosted model such as the default OpenAI one, prompts are sent to that provider. The Python framework and the full docs are at [github.com/MervinPraison/PraisonAI](https://github.com/MervinPraison/PraisonAI) and [docs.praison.ai](https://docs.praison.ai).
|
||||
This package is the TypeScript and JavaScript SDK of the open-source PraisonAI agent framework. Build single agents, multi-agent workflows, tools and memory in Node.js, on hosted models or on local models such as Ollama. Data stays on your own machines only when the model runs locally too; with a hosted model such as the default OpenAI one, prompts are sent to that provider. The Python framework and the full docs are at [github.com/MervinPraison/PraisonAI](https://github.com/MervinPraison/PraisonAI) and [praison.ai/docs](https://praison.ai/docs).
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ This complements `PARITY.md`, which only tracks whether an export exists.
|
||||
|
||||
### `Agent.__init__`
|
||||
|
||||
- Python: `src/praisonai-agents/praisonaiagents/agent/agent.py:631`
|
||||
- Python: `src/praisonai-agents/praisonaiagents/agent/agent.py:632`
|
||||
- TypeScript: `src/praisonai-ts/src/agent/simple.ts:197` (ctor `src/praisonai-ts/src/agent/simple.ts:1019`)
|
||||
- Counts: 42 python params: 29 exact, 8 camelCase, 2 alias, 3 flattened, 0 missing; 8 mismatches; 8 waived; 14 TS-only of 60
|
||||
|
||||
@@ -219,7 +219,7 @@ TS-only members: `previousResult`?, `onToken`?, `signal`?, `onEvent`?, `options`
|
||||
|
||||
### `Agent.chat`
|
||||
|
||||
- Python: `src/praisonai-agents/praisonaiagents/agent/chat_mixin.py:3707`
|
||||
- Python: `src/praisonai-agents/praisonaiagents/agent/chat_mixin.py:3734`
|
||||
- TypeScript: `src/praisonai-ts/src/agent/simple.ts:3592`
|
||||
- Counts: 17 python params: 7 exact, 9 camelCase, 1 alias, 0 flattened, 0 missing; 0 mismatches; 0 waived; 2 TS-only of 19
|
||||
|
||||
@@ -316,7 +316,7 @@ TS-only members: `config`
|
||||
### `Session.__init__`
|
||||
|
||||
- Python: `src/praisonai-agents/praisonaiagents/session/api.py:67`
|
||||
- TypeScript: `src/praisonai-ts/src/session/session.ts:29` (ctor `src/praisonai-ts/src/session/session.ts:132`)
|
||||
- TypeScript: `src/praisonai-ts/src/session/session.ts:29` (ctor `src/praisonai-ts/src/session/session.ts:133`)
|
||||
- Counts: 7 python params: 1 exact, 6 camelCase, 0 alias, 0 flattened, 0 missing; 1 mismatches; 1 waived; 5 TS-only of 12
|
||||
|
||||
| Python param | Kind | Py default | Py type | Match | TS name | TS default | TS type | Status |
|
||||
|
||||
@@ -1185,7 +1185,7 @@
|
||||
}
|
||||
],
|
||||
"python": {
|
||||
"location": "src/praisonai-agents/praisonaiagents/agent/agent.py:631",
|
||||
"location": "src/praisonai-agents/praisonaiagents/agent/agent.py:632",
|
||||
"resolved_class": "Agent"
|
||||
},
|
||||
"ts_only": [
|
||||
@@ -1797,7 +1797,7 @@
|
||||
}
|
||||
],
|
||||
"python": {
|
||||
"location": "src/praisonai-agents/praisonaiagents/agent/chat_mixin.py:3707",
|
||||
"location": "src/praisonai-agents/praisonaiagents/agent/chat_mixin.py:3734",
|
||||
"resolved_class": "ChatMixin"
|
||||
},
|
||||
"ts_only": [
|
||||
@@ -5142,7 +5142,7 @@
|
||||
],
|
||||
"ts_only_required": [],
|
||||
"typescript": {
|
||||
"ctor_location": "src/praisonai-ts/src/session/session.ts:132",
|
||||
"ctor_location": "src/praisonai-ts/src/session/session.ts:133",
|
||||
"location": "src/praisonai-ts/src/session/session.ts:29",
|
||||
"options_parameters": [
|
||||
"config"
|
||||
|
||||
Vendored
+20
-5
@@ -72,7 +72,7 @@ export class MemoryCache extends BaseCache {
|
||||
if (this.maxSize && this.cache.size >= this.maxSize && !this.cache.has(key)) {
|
||||
// Remove oldest entry
|
||||
const firstKey = this.cache.keys().next().value;
|
||||
if (firstKey) this.cache.delete(firstKey);
|
||||
if (firstKey !== undefined) this.cache.delete(firstKey);
|
||||
}
|
||||
|
||||
const effectiveTtl = ttl ?? this.ttl;
|
||||
@@ -137,12 +137,15 @@ export class MemoryCache extends BaseCache {
|
||||
*/
|
||||
export class FileCache extends BaseCache {
|
||||
private cacheDir: string;
|
||||
private ttl?: number;
|
||||
private fs: any;
|
||||
private path: any;
|
||||
private listQueues: Map<string, Promise<void>> = new Map();
|
||||
|
||||
constructor(config: CacheConfig & { cacheDir?: string } = {}) {
|
||||
super(config);
|
||||
this.cacheDir = config.cacheDir || '.cache';
|
||||
this.ttl = config.ttl;
|
||||
}
|
||||
|
||||
private async ensureDir(): Promise<void> {
|
||||
@@ -177,10 +180,11 @@ export class FileCache extends BaseCache {
|
||||
|
||||
async set<T = any>(key: string, value: T, ttl?: number): Promise<void> {
|
||||
await this.ensureDir();
|
||||
const effectiveTtl = ttl ?? this.ttl;
|
||||
const entry: CacheEntry<T> = {
|
||||
value,
|
||||
createdAt: Date.now(),
|
||||
expiresAt: ttl ? Date.now() + ttl : undefined
|
||||
expiresAt: effectiveTtl ? Date.now() + effectiveTtl : undefined
|
||||
};
|
||||
await this.fs.writeFile(this.getFilePath(key), JSON.stringify(entry));
|
||||
}
|
||||
@@ -227,9 +231,20 @@ export class FileCache extends BaseCache {
|
||||
}
|
||||
|
||||
async listPush<T = any>(key: string, value: T): Promise<void> {
|
||||
const list = (await this.get<T[]>(`list_${key}`)) || [];
|
||||
list.push(value);
|
||||
await this.set(`list_${key}`, list);
|
||||
// Serialize the full read/modify/write operation for this instance and key.
|
||||
// A failed append must not block subsequent appends.
|
||||
const previous = this.listQueues.get(key) ?? Promise.resolve();
|
||||
const next = previous.catch(() => {}).then(async () => {
|
||||
const list = (await this.get<T[]>(`list_${key}`)) || [];
|
||||
list.push(value);
|
||||
await this.set(`list_${key}`, list);
|
||||
});
|
||||
this.listQueues.set(key, next);
|
||||
try {
|
||||
await next;
|
||||
} finally {
|
||||
if (this.listQueues.get(key) === next) this.listQueues.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async listLength(key: string): Promise<number> {
|
||||
|
||||
@@ -231,12 +231,17 @@ export class HooksManager {
|
||||
return promise;
|
||||
}
|
||||
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`Hook timeout after ${timeout}ms`)), timeout)
|
||||
),
|
||||
]);
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(`Hook timeout after ${timeout}ms`)), timeout);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -251,7 +256,7 @@ export class HooksManager {
|
||||
* Get all registered hooks for an event
|
||||
*/
|
||||
getHooks(event: HookEvent): readonly HookConfig[] {
|
||||
return Object.freeze(this.hooks.get(event) ?? []);
|
||||
return Object.freeze([...(this.hooks.get(event) ?? [])]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -82,16 +82,20 @@ export class Chunking {
|
||||
* Chunk by sentences
|
||||
*/
|
||||
chunkBySentence(text: string): Chunk[] {
|
||||
const sentences = text.match(/[^.!?]+[.!?]+/g) || [text];
|
||||
return sentences.map((content, index) => {
|
||||
const startOffset = text.indexOf(content);
|
||||
return {
|
||||
content: content.trim(),
|
||||
index,
|
||||
const chunks: Chunk[] = [];
|
||||
// Include the unterminated tail and punctuation-only spans as well.
|
||||
for (const match of text.matchAll(/[^.!?]+[.!?]*|[.!?]+/g)) {
|
||||
const content = match[0].trim();
|
||||
if (!content) continue;
|
||||
const startOffset = match.index + match[0].indexOf(content);
|
||||
chunks.push({
|
||||
content,
|
||||
index: chunks.length,
|
||||
startOffset,
|
||||
endOffset: startOffset + content.length
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -60,7 +60,23 @@ export class GraphStore {
|
||||
* Add an edge to the graph
|
||||
*/
|
||||
addEdge(edge: GraphEdge): void {
|
||||
const previous = this.edges.get(edge.id);
|
||||
this.edges.set(edge.id, edge);
|
||||
|
||||
if (previous && (previous.source !== edge.source || previous.target !== edge.target)) {
|
||||
// Another edge may still connect the old endpoints.
|
||||
let hasParallelEdge = false;
|
||||
for (const candidate of this.edges.values()) {
|
||||
if (candidate.source === previous.source && candidate.target === previous.target) {
|
||||
hasParallelEdge = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!hasParallelEdge) {
|
||||
this.adjacencyList.get(previous.source)?.delete(previous.target);
|
||||
this.reverseAdjacencyList.get(previous.target)?.delete(previous.source);
|
||||
}
|
||||
}
|
||||
|
||||
// Update adjacency lists
|
||||
if (!this.adjacencyList.has(edge.source)) {
|
||||
|
||||
@@ -70,17 +70,19 @@ export class WebSocketTransport implements MCPTransport {
|
||||
}
|
||||
const WebSocket = wsModule.default;
|
||||
|
||||
this.ws = new WebSocket(this.url);
|
||||
const socket = new WebSocket(this.url);
|
||||
this.ws = socket;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.ws.on('open', () => {
|
||||
socket.on('open', () => {
|
||||
this.connected = true;
|
||||
resolve();
|
||||
});
|
||||
this.ws.on('error', (err: Error) => reject(err));
|
||||
socket.on('error', (err: Error) => reject(err));
|
||||
});
|
||||
|
||||
this.ws.on('message', (data: Buffer | string) => {
|
||||
socket.on('message', (data: Buffer | string) => {
|
||||
if (this.ws !== socket) return;
|
||||
try {
|
||||
const message = JSON.parse(data.toString()) as TransportMessage;
|
||||
|
||||
@@ -102,16 +104,29 @@ export class WebSocketTransport implements MCPTransport {
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.on('error', (err: Error) => this.errorHandler?.(err));
|
||||
this.ws.on('close', () => { this.connected = false; });
|
||||
socket.on('error', (err: Error) => {
|
||||
if (this.ws === socket) this.errorHandler?.(err);
|
||||
});
|
||||
socket.on('close', () => {
|
||||
if (this.ws !== socket) return;
|
||||
this.connected = false;
|
||||
this.ws = null;
|
||||
this.rejectPendingRequests();
|
||||
});
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
this.connected = false;
|
||||
}
|
||||
const socket = this.ws;
|
||||
this.ws = null;
|
||||
this.connected = false;
|
||||
this.rejectPendingRequests();
|
||||
socket?.close();
|
||||
}
|
||||
|
||||
private rejectPendingRequests(): void {
|
||||
const error = new Error('WebSocket connection closed');
|
||||
for (const { reject } of this.pendingRequests.values()) reject(error);
|
||||
this.pendingRequests.clear();
|
||||
}
|
||||
|
||||
async send(message: TransportMessage): Promise<TransportMessage> {
|
||||
|
||||
@@ -111,6 +111,8 @@ export class FileSessionStore implements ISessionStore {
|
||||
private autoSaveInterval?: number;
|
||||
private saveTimer?: NodeJS.Timeout;
|
||||
private dirty: boolean = false;
|
||||
private saveQueue: Promise<void> = Promise.resolve();
|
||||
private pendingSaves: number = 0;
|
||||
|
||||
constructor(filePath: string, config?: { defaultTTL?: number; autoSaveInterval?: number }) {
|
||||
this.filePath = filePath;
|
||||
@@ -120,7 +122,7 @@ export class FileSessionStore implements ISessionStore {
|
||||
|
||||
if (this.autoSaveInterval) {
|
||||
this.saveTimer = setInterval(() => {
|
||||
if (this.dirty) this.save();
|
||||
if (this.dirty && this.pendingSaves === 0) this.save();
|
||||
}, this.autoSaveInterval);
|
||||
}
|
||||
}
|
||||
@@ -146,10 +148,26 @@ export class FileSessionStore implements ISessionStore {
|
||||
}
|
||||
|
||||
async save(): Promise<void> {
|
||||
const fs = await import('fs').then(m => m.promises);
|
||||
const sessions = Array.from(this.sessions.values());
|
||||
await fs.writeFile(this.filePath, JSON.stringify(sessions, null, 2));
|
||||
this.dirty = false;
|
||||
this.pendingSaves++;
|
||||
const saving = this.saveQueue.then(async () => {
|
||||
const fs = await import('fs').then(m => m.promises);
|
||||
const data = JSON.stringify(Array.from(this.sessions.values()), null, 2);
|
||||
// Only this snapshot is clean; mutations during the write stay dirty.
|
||||
this.dirty = false;
|
||||
try {
|
||||
await fs.writeFile(this.filePath, data);
|
||||
} catch (error) {
|
||||
this.dirty = true;
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
// Keep the queue usable after a failure, while this caller still sees it.
|
||||
this.saveQueue = saving.catch(() => {});
|
||||
try {
|
||||
await saving;
|
||||
} finally {
|
||||
this.pendingSaves--;
|
||||
}
|
||||
}
|
||||
|
||||
async get(id: string): Promise<StoredSession | null> {
|
||||
@@ -205,6 +223,7 @@ export class FileSessionStore implements ISessionStore {
|
||||
if (this.saveTimer) {
|
||||
clearInterval(this.saveTimer);
|
||||
}
|
||||
await this.saveQueue;
|
||||
if (this.dirty) {
|
||||
await this.save();
|
||||
}
|
||||
|
||||
@@ -420,7 +420,21 @@ export class FunctionTool<TParams = any, TResult = any> {
|
||||
}
|
||||
const delay = retryDelayMs(policy, attempt - 1);
|
||||
if (delay > 0) {
|
||||
await new Promise<void>(resolve => setTimeout(resolve, delay));
|
||||
const signal = context?.signal;
|
||||
const completed = await new Promise<boolean>(resolve => {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const finish = (value: boolean) => {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
resolve(value);
|
||||
};
|
||||
const onAbort = () => finish(false);
|
||||
timer = setTimeout(() => finish(true), delay);
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
if (signal?.aborted) finish(false);
|
||||
});
|
||||
// Preserve the existing last-error behavior when cancellation stops retries.
|
||||
if (!completed || signal?.aborted) throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +147,9 @@ export function createRetryMiddleware(
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
if (context.signal?.aborted) {
|
||||
throw context.signal.reason ?? new Error('The operation was aborted');
|
||||
}
|
||||
try {
|
||||
return await next();
|
||||
} catch (error) {
|
||||
@@ -212,7 +215,9 @@ export function createValidationMiddleware(): ToolMiddleware {
|
||||
const limits = context.limits;
|
||||
|
||||
if (limits?.maxPayloadBytes) {
|
||||
const size = JSON.stringify(input).length;
|
||||
const serialized = JSON.stringify(input);
|
||||
if (serialized === undefined) throw new TypeError('Input payload is not JSON serializable');
|
||||
const size = new TextEncoder().encode(serialized).byteLength;
|
||||
if (size > limits.maxPayloadBytes) {
|
||||
throw new Error(`Input payload size (${size} bytes) exceeds limit (${limits.maxPayloadBytes} bytes)`);
|
||||
}
|
||||
@@ -227,17 +232,16 @@ export function createValidationMiddleware(): ToolMiddleware {
|
||||
*/
|
||||
export function composeMiddleware(...middlewares: ToolMiddleware[]): ToolMiddleware {
|
||||
return async (input, context, next) => {
|
||||
let index = 0;
|
||||
|
||||
const composedNext = async (): Promise<unknown> => {
|
||||
// Each continuation keeps its own downstream position so retry middleware
|
||||
// can invoke next() again without skipping the remaining layers.
|
||||
const dispatch = async (index: number): Promise<unknown> => {
|
||||
if (index < middlewares.length) {
|
||||
const mw = middlewares[index++];
|
||||
return mw(input, context, composedNext);
|
||||
return middlewares[index](input, context, () => dispatch(index + 1));
|
||||
}
|
||||
return next();
|
||||
};
|
||||
|
||||
return composedNext();
|
||||
return dispatch(0);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* Supports lazy loading, middleware, and multi-agent safety.
|
||||
*/
|
||||
|
||||
import { composeMiddleware } from './middleware';
|
||||
|
||||
import type {
|
||||
ToolMetadata,
|
||||
ToolFactory,
|
||||
@@ -61,6 +63,7 @@ export class ToolsRegistry {
|
||||
*/
|
||||
register(metadata: ToolMetadata, factory: ToolFactory): this {
|
||||
this.tools.set(metadata.id, { metadata, factory });
|
||||
this.installCache.delete(metadata.id);
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -68,6 +71,7 @@ export class ToolsRegistry {
|
||||
* Unregister a tool
|
||||
*/
|
||||
unregister(id: string): boolean {
|
||||
this.installCache.delete(id);
|
||||
return this.tools.delete(id);
|
||||
}
|
||||
|
||||
@@ -158,16 +162,7 @@ export class ToolsRegistry {
|
||||
if (this.middleware.length === 0) {
|
||||
result = await executeWithMiddleware();
|
||||
} else {
|
||||
// Chain middleware
|
||||
let index = 0;
|
||||
const next = async (): Promise<unknown> => {
|
||||
if (index < this.middleware.length) {
|
||||
const mw = this.middleware[index++];
|
||||
return mw(input, ctx, next);
|
||||
}
|
||||
return executeWithMiddleware();
|
||||
};
|
||||
result = await next();
|
||||
result = await composeMiddleware(...this.middleware)(input, ctx, executeWithMiddleware);
|
||||
}
|
||||
|
||||
// Call afterToolCall hook
|
||||
@@ -222,27 +217,25 @@ export class ToolsRegistry {
|
||||
* Check if a tool's optional dependency is installed
|
||||
*/
|
||||
async checkInstalled(id: string): Promise<boolean> {
|
||||
// Check cache first
|
||||
const registered = this.tools.get(id);
|
||||
if (!registered) return false;
|
||||
|
||||
if (this.installCache.has(id)) {
|
||||
return this.installCache.get(id)!;
|
||||
}
|
||||
|
||||
const registered = this.tools.get(id);
|
||||
if (!registered) {
|
||||
return false;
|
||||
const { packageName } = registered.metadata;
|
||||
let installed: boolean;
|
||||
try {
|
||||
await import(packageName);
|
||||
installed = true;
|
||||
} catch {
|
||||
installed = false;
|
||||
}
|
||||
|
||||
const { packageName } = registered.metadata;
|
||||
|
||||
try {
|
||||
// Try to resolve the package
|
||||
await import(packageName);
|
||||
this.installCache.set(id, true);
|
||||
return true;
|
||||
} catch {
|
||||
this.installCache.set(id, false);
|
||||
return false;
|
||||
}
|
||||
// An earlier import must not repopulate the cache after replacement/removal.
|
||||
if (this.tools.get(id) === registered) this.installCache.set(id, installed);
|
||||
return installed;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+86
@@ -3,6 +3,9 @@
|
||||
*/
|
||||
|
||||
import { MemoryCache, FileCache, createMemoryCache, createFileCache } from '../../../src/cache';
|
||||
import { mkdtemp, rm } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
describe('MemoryCache', () => {
|
||||
let cache: MemoryCache;
|
||||
@@ -158,3 +161,86 @@ describe('Factory functions', () => {
|
||||
expect(cache).toBeInstanceOf(FileCache);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FileCache default TTL', () => {
|
||||
let cacheDir: string;
|
||||
let now: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
cacheDir = await mkdtemp(join(tmpdir(), 'praisonai-file-cache-ttl-'));
|
||||
now = 1000;
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
jest.restoreAllMocks();
|
||||
await rm(cacheDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it.each(['get', 'has'] as const)('honors the configured TTL through %s after reopening', async (method) => {
|
||||
const cache = createFileCache({ cacheDir, ttl: 50 });
|
||||
await cache.set('answer', 'old answer');
|
||||
expect(await cache.get('answer')).toBe('old answer');
|
||||
|
||||
const reopened = new FileCache({ cacheDir });
|
||||
now += 51;
|
||||
if (method === 'get') {
|
||||
expect(await reopened.get('answer')).toBeUndefined();
|
||||
} else {
|
||||
expect(await reopened.has('answer')).toBe(false);
|
||||
}
|
||||
expect(await reopened.keys()).not.toContain('answer');
|
||||
});
|
||||
|
||||
it('allows a per-entry TTL to override the configured TTL', async () => {
|
||||
const cache = new FileCache({ cacheDir, ttl: 50 });
|
||||
await cache.set('answer', 'value', 100);
|
||||
now += 51;
|
||||
expect(await cache.get('answer')).toBe('value');
|
||||
now += 50;
|
||||
expect(await cache.get('answer')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('allows an explicit zero TTL to disable the configured expiry', async () => {
|
||||
const cache = new FileCache({ cacheDir, ttl: 50 });
|
||||
await cache.set('answer', 'value', 0);
|
||||
now += 1000;
|
||||
expect(await cache.get('answer')).toBe('value');
|
||||
});
|
||||
|
||||
it('keeps entries without a default or explicit TTL', async () => {
|
||||
const cache = new FileCache({ cacheDir });
|
||||
await cache.set('answer', 'value');
|
||||
now += 1000;
|
||||
expect(await cache.get('answer')).toBe('value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MemoryCache capacity with empty keys', () => {
|
||||
it.each([1, 2])('keeps capacity %i bounded when the oldest key is empty', async (maxSize) => {
|
||||
const cache = new MemoryCache({ maxSize });
|
||||
await cache.set('', 'oldest');
|
||||
for (let i = 0; i <= maxSize; i++) {
|
||||
await cache.set(`key-${i}`, i);
|
||||
}
|
||||
|
||||
expect(await cache.size()).toBe(maxSize);
|
||||
expect(await cache.has('')).toBe(false);
|
||||
expect(await cache.keys()).toEqual(
|
||||
Array.from({ length: maxSize }, (_, i) => `key-${i + 1}`)
|
||||
);
|
||||
expect(await cache.get(`key-${maxSize}`)).toBe(maxSize);
|
||||
});
|
||||
|
||||
it('updates an existing empty key without evicting another entry', async () => {
|
||||
const cache = new MemoryCache({ maxSize: 2 });
|
||||
await cache.set('', 'old value');
|
||||
await cache.set('other', 'retained');
|
||||
await cache.set('', 'new value');
|
||||
|
||||
expect(await cache.size()).toBe(2);
|
||||
expect(await cache.keys()).toEqual(['', 'other']);
|
||||
expect(await cache.get('')).toBe('new value');
|
||||
expect(await cache.get('other')).toBe('retained');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';
|
||||
import { mkdtemp, mkdir, rm } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { createFileCache } from '../../../src/cache';
|
||||
|
||||
describe('FileCache concurrent list appends', () => {
|
||||
let directory: string;
|
||||
beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'praison-list-')); });
|
||||
afterEach(async () => { await rm(directory, { recursive: true, force: true }); });
|
||||
|
||||
it('persists every successful concurrent append in call order', async () => {
|
||||
const cache = createFileCache({ cacheDir: directory });
|
||||
await cache.listPush('events', 'existing');
|
||||
const values = Array.from({ length: 25 }, (_, index) => `event-${index}`);
|
||||
await Promise.all(values.map(value => cache.listPush('events', value)));
|
||||
const reopened = createFileCache({ cacheDir: directory });
|
||||
expect(await reopened.listRange('events', 0)).toEqual(['existing', ...values]);
|
||||
expect(await reopened.listLength('events')).toBe(26);
|
||||
});
|
||||
|
||||
it('retains each list when different keys receive concurrent appends', async () => {
|
||||
const cache = createFileCache({ cacheDir: directory });
|
||||
await cache.keys();
|
||||
const values = Array.from({ length: 12 }, (_, index) => index + 1);
|
||||
await Promise.all(values.flatMap(value => [cache.listPush('left', value), cache.listPush('right', -value)]));
|
||||
const reopened = createFileCache({ cacheDir: directory });
|
||||
expect(await reopened.listRange('left', 0)).toEqual(values);
|
||||
expect(await reopened.listRange('right', 0)).toEqual(values.map(value => -value));
|
||||
});
|
||||
|
||||
it('recovers after a failed append without persisting that value', async () => {
|
||||
const cache = createFileCache({ cacheDir: directory });
|
||||
const blockedFile = join(directory, 'list_events.json');
|
||||
await mkdir(blockedFile);
|
||||
await expect(cache.listPush('events', 'failed')).rejects.toThrow();
|
||||
await rm(blockedFile, { recursive: true });
|
||||
await cache.listPush('events', 'retry');
|
||||
expect(await cache.listRange('events', 0)).toEqual(['retry']);
|
||||
});
|
||||
|
||||
it('continues an already queued append after a transient write failure', async () => {
|
||||
const cache = createFileCache({ cacheDir: directory });
|
||||
let releaseFailure!: () => void;
|
||||
let signalWrite!: () => void;
|
||||
const blocked = new Promise<void>(resolve => { releaseFailure = resolve; });
|
||||
const writing = new Promise<void>(resolve => { signalWrite = resolve; });
|
||||
// Inject one failed public write; the queued retry uses the real filesystem.
|
||||
jest.spyOn(cache, 'set').mockImplementationOnce(async () => {
|
||||
signalWrite();
|
||||
await blocked;
|
||||
throw new Error('transient write failure');
|
||||
});
|
||||
const failed = expect(cache.listPush('events', 'failed')).rejects.toThrow('transient write failure');
|
||||
const retry = cache.listPush('events', 'retry');
|
||||
await writing;
|
||||
releaseFailure();
|
||||
await failed;
|
||||
await retry;
|
||||
const reopened = createFileCache({ cacheDir: directory });
|
||||
expect(await reopened.listRange('events', 0)).toEqual(['retry']);
|
||||
});
|
||||
|
||||
it('preserves sequential appends and list slicing', async () => {
|
||||
const cache = createFileCache({ cacheDir: directory });
|
||||
for (const value of [1, 2, 3]) await cache.listPush('events', value);
|
||||
expect(await cache.listRange('events', 1, 2)).toEqual([2]);
|
||||
expect(await cache.listRange('events', -1)).toEqual([3]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from '@jest/globals';
|
||||
import { HooksManager } from '../../src/hooks/manager';
|
||||
|
||||
describe('HooksManager inspection', () => {
|
||||
it('allows registering and executing hooks after inspection', async () => {
|
||||
const manager = new HooksManager();
|
||||
const called: string[] = [];
|
||||
const first = manager.register('pre_tool_call', () => { called.push('first'); });
|
||||
const snapshot = manager.getHooks('pre_tool_call');
|
||||
const second = manager.register('pre_tool_call', () => { called.push('second'); }, { priority: 1 });
|
||||
expect(snapshot.map(hook => hook.id)).toEqual([first]);
|
||||
expect(manager.getHooks('pre_tool_call').map(hook => hook.id)).toEqual([second, first]);
|
||||
expect((await manager.execute('pre_tool_call', {})).success).toBe(true);
|
||||
expect(called).toEqual(['second', 'first']);
|
||||
});
|
||||
|
||||
it('allows unregistering a hook after inspection', async () => {
|
||||
const manager = new HooksManager();
|
||||
const called: string[] = [];
|
||||
const first = manager.register('pre_tool_call', () => { called.push('first'); });
|
||||
const second = manager.register('pre_tool_call', () => { called.push('second'); });
|
||||
const snapshot = manager.getHooks('pre_tool_call');
|
||||
expect(manager.unregister(first)).toBe(true);
|
||||
expect(snapshot.map(hook => hook.id)).toEqual([first, second]);
|
||||
expect(manager.getStats().totalHooks).toBe(1);
|
||||
await manager.execute('pre_tool_call', {});
|
||||
expect(called).toEqual(['second']);
|
||||
});
|
||||
|
||||
it('keeps returned arrays frozen against caller mutations', () => {
|
||||
const manager = new HooksManager();
|
||||
const id = manager.register('pre_tool_call', () => {});
|
||||
const snapshot = manager.getHooks('pre_tool_call');
|
||||
expect(Object.isFrozen(snapshot)).toBe(true);
|
||||
expect(() => (snapshot as unknown[]).pop()).toThrow(TypeError);
|
||||
expect(manager.getHooks('pre_tool_call').map(hook => hook.id)).toEqual([id]);
|
||||
});
|
||||
|
||||
it('allows first registration after inspecting an empty event', () => {
|
||||
const manager = new HooksManager();
|
||||
const snapshot = manager.getHooks('pre_tool_call');
|
||||
const id = manager.register('pre_tool_call', () => {});
|
||||
expect(snapshot).toEqual([]);
|
||||
expect(Object.isFrozen(snapshot)).toBe(true);
|
||||
expect(manager.getHooks('pre_tool_call').map(hook => hook.id)).toEqual([id]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { resolve } from 'path';
|
||||
import { HooksManager } from '../../src/hooks/manager';
|
||||
|
||||
describe('HooksManager timeout lifecycle', () => {
|
||||
beforeEach(() => { jest.useFakeTimers(); });
|
||||
afterEach(() => { jest.clearAllTimers(); jest.useRealTimers(); });
|
||||
|
||||
it('releases the deadline after a successful asynchronous hook', async () => {
|
||||
const hooks = new HooksManager({ timeout: 30000 });
|
||||
hooks.register('pre_tool_call', async (context) => ({ ...context, checked: true }));
|
||||
const result = await hooks.execute('pre_tool_call', { input: 'test' });
|
||||
expect(result.modifiedContext).toEqual({ input: 'test', checked: true });
|
||||
expect(result.blocked).toBe(false);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('releases the deadline after an asynchronous hook rejects', async () => {
|
||||
const hooks = new HooksManager({ timeout: 30000 });
|
||||
hooks.register('pre_tool_call', async () => { throw new Error('validation failed'); }, { blockOnFailure: true });
|
||||
const result = await hooks.execute('pre_tool_call', {});
|
||||
expect(result).toMatchObject({ success: false, blocked: true, error: 'validation failed' });
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('still times out a pending hook and preserves blocking policy', async () => {
|
||||
const hooks = new HooksManager({ timeout: 10 });
|
||||
hooks.register('pre_tool_call', () => new Promise(() => {}), { blockOnFailure: true });
|
||||
const execution = hooks.execute('pre_tool_call', {});
|
||||
expect(jest.getTimerCount()).toBe(1);
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
expect(await execution).toMatchObject({ success: false, blocked: true, error: 'Hook timeout after 10ms' });
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('does not allocate a deadline for a synchronous hook', async () => {
|
||||
const hooks = new HooksManager();
|
||||
hooks.register('pre_tool_call', () => ({ checked: true }));
|
||||
expect((await hooks.execute('pre_tool_call', {})).modifiedContext).toEqual({ checked: true });
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('lets a real process exit naturally after its async hook settles', () => {
|
||||
const script = `
|
||||
const { HooksManager } = require(${JSON.stringify(resolve(__dirname, '../../src/hooks/manager'))});
|
||||
const hooks = new HooksManager({ timeout: 30000 });
|
||||
hooks.register('pre_tool_call', async context => context);
|
||||
hooks.execute('pre_tool_call', {}).then(() => console.log('hook settled')).catch(error => { console.error(error); process.exitCode = 1; });
|
||||
`;
|
||||
const result = spawnSync(process.execPath, ['-r', 'ts-node/register/transpile-only', '-e', script], {
|
||||
timeout: 10000,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
expect(result.stdout).toContain('hook settled');
|
||||
expect(result.error).toBeUndefined();
|
||||
expect({ status: result.status, stderr: result.stderr }).toEqual({ status: 0, stderr: '' });
|
||||
}, 15000);
|
||||
});
|
||||
@@ -30,6 +30,34 @@ describe('Chunking', () => {
|
||||
expect(chunks.length).toBe(3);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'First sentence. Final important fact without punctuation',
|
||||
'Echo.Echo.Echo.',
|
||||
' \nFirst. Second! \nTail \t',
|
||||
'... Leading punctuation. Tail',
|
||||
'No punctuation here',
|
||||
'Really?! Next.',
|
||||
'你好。完整段落',
|
||||
])('preserves sentence content and its source span: %j', async text => {
|
||||
const { Chunking } = await import('../../../src/knowledge/chunking');
|
||||
const chunks = new Chunking({ strategy: 'sentence' }).chunk(text);
|
||||
let cursor = 0;
|
||||
for (const [index, chunk] of chunks.entries()) {
|
||||
expect(chunk.index).toBe(index);
|
||||
expect(chunk.content.length).toBeGreaterThan(0);
|
||||
expect(chunk.startOffset).toBeGreaterThanOrEqual(cursor);
|
||||
expect(text.slice(cursor, chunk.startOffset).trim()).toBe('');
|
||||
expect(text.slice(chunk.startOffset, chunk.endOffset)).toBe(chunk.content);
|
||||
cursor = chunk.endOffset;
|
||||
}
|
||||
expect(text.slice(cursor).trim()).toBe('');
|
||||
});
|
||||
|
||||
it.each(['', ' \n\t '])('does not emit empty sentence chunks for %j', async text => {
|
||||
const { Chunking } = await import('../../../src/knowledge/chunking');
|
||||
expect(new Chunking({ strategy: 'sentence' }).chunk(text)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should chunk by paragraphs', async () => {
|
||||
const { Chunking } = await import('../../../src/knowledge/chunking');
|
||||
const chunker = new Chunking({ strategy: 'paragraph' });
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from '@jest/globals';
|
||||
import { GraphStore } from '../../../src/knowledge/graph-rag';
|
||||
|
||||
function graph(): GraphStore {
|
||||
const store = new GraphStore();
|
||||
for (const id of ['a', 'b', 'c', 'd']) store.addNode({ id, type: 'document', content: id });
|
||||
return store;
|
||||
}
|
||||
|
||||
describe('GraphStore edge replacement', () => {
|
||||
it.each([
|
||||
['a', 'c'], ['c', 'b'], ['c', 'd'], ['a', 'a'],
|
||||
])('removes obsolete adjacency when an edge moves to %s -> %s', (source, target) => {
|
||||
const store = graph();
|
||||
store.addEdge({ id: 'edge', source: 'a', target: 'b', type: 'link' });
|
||||
store.addEdge({ id: 'edge', source, target, type: 'link' });
|
||||
expect(store.getNeighbors('a', 'outgoing').map(n => n.id)).not.toContain('b');
|
||||
expect(store.getNeighbors('b', 'incoming').map(n => n.id)).not.toContain('a');
|
||||
expect(store.getEdgesBetween('a', 'b')).toEqual([]);
|
||||
expect(store.findPaths('a', 'b')).toEqual([]);
|
||||
expect(store.getNeighbors(source, 'outgoing').map(n => n.id)).toContain(target);
|
||||
expect(store.getNeighbors(target, 'incoming').map(n => n.id)).toContain(source);
|
||||
expect(store.getStats().edgeCount).toBe(1);
|
||||
});
|
||||
|
||||
it('retains adjacency backed by a parallel edge', () => {
|
||||
const store = graph();
|
||||
store.addEdge({ id: 'one', source: 'a', target: 'b', type: 'one' });
|
||||
store.addEdge({ id: 'two', source: 'a', target: 'b', type: 'two' });
|
||||
store.addEdge({ id: 'one', source: 'c', target: 'd', type: 'one' });
|
||||
expect(store.getNeighbors('a', 'outgoing').map(n => n.id)).toEqual(['b']);
|
||||
expect(store.getNeighbors('b', 'incoming').map(n => n.id)).toEqual(['a']);
|
||||
expect(store.getEdgesBetween('a', 'b').map(e => e.id)).toEqual(['two']);
|
||||
store.addEdge({ id: 'two', source: 'c', target: 'd', type: 'two' });
|
||||
expect(store.getNeighbors('a', 'outgoing')).toEqual([]);
|
||||
expect(store.getNeighbors('b', 'incoming')).toEqual([]);
|
||||
expect(store.getStats().edgeCount).toBe(2);
|
||||
});
|
||||
|
||||
it('preserves neighbors when only edge metadata changes', () => {
|
||||
const store = graph();
|
||||
store.addEdge({ id: 'edge', source: 'a', target: 'b', type: 'link', weight: 1 });
|
||||
store.addEdge({ id: 'edge', source: 'a', target: 'b', type: 'link', weight: 2 });
|
||||
expect(store.getNeighbors('a', 'outgoing').map(n => n.id)).toEqual(['b']);
|
||||
expect(store.getEdgesBetween('a', 'b')[0].weight).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import { WebSocketTransport } from '../../src/mcp/transports';
|
||||
|
||||
class MockWebSocket extends EventEmitter {
|
||||
static last: MockWebSocket;
|
||||
send = jest.fn();
|
||||
// Real close handshakes finish asynchronously; tests emit close explicitly.
|
||||
close = jest.fn();
|
||||
|
||||
constructor(_url: string) {
|
||||
super();
|
||||
MockWebSocket.last = this;
|
||||
queueMicrotask(() => this.emit('open'));
|
||||
}
|
||||
}
|
||||
|
||||
jest.mock('ws', () => ({ __esModule: true, default: MockWebSocket }), { virtual: true });
|
||||
|
||||
async function boundedOutcome(pending: Promise<unknown>): Promise<unknown> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
pending.then(() => 'resolved', error => error),
|
||||
new Promise(resolve => { timer = setTimeout(() => resolve('pending'), 100); }),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
const response = (source: string) => JSON.stringify({ jsonrpc: '2.0', id: 1, result: { source } });
|
||||
|
||||
describe('WebSocketTransport connection lifecycle', () => {
|
||||
let transport: WebSocketTransport;
|
||||
|
||||
beforeEach(async () => {
|
||||
transport = new WebSocketTransport('ws://localhost');
|
||||
await transport.connect();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await transport.disconnect();
|
||||
});
|
||||
|
||||
it.each(['remote', 'local'])('rejects every pending request on %s close', async (mode) => {
|
||||
const outcomes = [1, 2].map(id => boundedOutcome(
|
||||
transport.send({ jsonrpc: '2.0', id, method: 'tools/list' })
|
||||
));
|
||||
if (mode === 'remote') MockWebSocket.last.emit('close');
|
||||
else await transport.disconnect();
|
||||
|
||||
expect(transport.isConnected()).toBe(false);
|
||||
for (const outcome of await Promise.all(outcomes)) {
|
||||
expect(outcome).toBeInstanceOf(Error);
|
||||
expect(outcome).toEqual(expect.objectContaining({
|
||||
message: expect.stringMatching(/closed|disconnect/i),
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a replacement connection active when the old close event arrives', async () => {
|
||||
const old = MockWebSocket.last;
|
||||
await transport.disconnect();
|
||||
await transport.connect();
|
||||
old.emit('close');
|
||||
expect(transport.isConnected()).toBe(true);
|
||||
|
||||
const pending = transport.send({ jsonrpc: '2.0', id: 1, method: 'tools/list' });
|
||||
MockWebSocket.last.emit('message', response('new'));
|
||||
await expect(pending).resolves.toMatchObject({ result: { source: 'new' } });
|
||||
});
|
||||
|
||||
it('ignores a closing connection response when a new request reuses its ID', async () => {
|
||||
const old = MockWebSocket.last;
|
||||
await transport.disconnect();
|
||||
await transport.connect();
|
||||
const received = jest.fn();
|
||||
transport.onMessage(received);
|
||||
const pending = transport.send({ jsonrpc: '2.0', id: 1, method: 'tools/list' });
|
||||
old.emit('message', response('old'));
|
||||
MockWebSocket.last.emit('message', response('new'));
|
||||
|
||||
await expect(pending).resolves.toMatchObject({ result: { source: 'new' } });
|
||||
expect(received).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps a completed response available after its connection closes', async () => {
|
||||
const pending = transport.send({ jsonrpc: '2.0', id: 1, method: 'tools/list' });
|
||||
MockWebSocket.last.emit('message', response('complete'));
|
||||
MockWebSocket.last.emit('close');
|
||||
await expect(pending).resolves.toMatchObject({ result: { source: 'complete' } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';
|
||||
import { promises as fs } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { FileSessionStore, createStoredSession } from '../../../src/session/store';
|
||||
|
||||
function gate(): { promise: Promise<void>; release: () => void } {
|
||||
let release!: () => void;
|
||||
const promise = new Promise<void>(resolve => { release = resolve; });
|
||||
return { promise, release };
|
||||
}
|
||||
|
||||
describe('FileSessionStore save ordering', () => {
|
||||
let directory: string;
|
||||
let filePath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
directory = await fs.mkdtemp(join(tmpdir(), 'praison-session-save-'));
|
||||
filePath = join(directory, 'sessions.json');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
jest.restoreAllMocks();
|
||||
jest.useRealTimers();
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function pauseFirstWrite() {
|
||||
const entered = gate();
|
||||
const blocked = gate();
|
||||
const writeFile = fs.writeFile.bind(fs);
|
||||
let writes = 0;
|
||||
jest.spyOn(fs, 'writeFile').mockImplementation(async (...args: Parameters<typeof fs.writeFile>) => {
|
||||
if (++writes === 1) {
|
||||
entered.release();
|
||||
await blocked.promise;
|
||||
}
|
||||
return writeFile(...args);
|
||||
});
|
||||
return { entered: entered.promise, release: blocked.release, writes: () => writes };
|
||||
}
|
||||
|
||||
it.each(['set', 'delete', 'clear'] as const)('persists %s during an in-flight save on close', async operation => {
|
||||
const store = new FileSessionStore(filePath);
|
||||
const first = createStoredSession({ turn: 1 });
|
||||
const second = createStoredSession({ turn: 2 });
|
||||
await store.set(first);
|
||||
const paused = pauseFirstWrite();
|
||||
const saving = store.save();
|
||||
await paused.entered;
|
||||
if (operation === 'set') await store.set(second);
|
||||
else if (operation === 'delete') await store.delete(first.id);
|
||||
else await store.clear();
|
||||
paused.release();
|
||||
await saving;
|
||||
await store.close();
|
||||
|
||||
const reloaded = new FileSessionStore(filePath);
|
||||
await reloaded.load();
|
||||
expect((await reloaded.list()).map(session => session.id)).toEqual(operation === 'set' ? [first.id, second.id] : []);
|
||||
});
|
||||
|
||||
it('serializes saves so an older snapshot cannot overwrite a newer one', async () => {
|
||||
const store = new FileSessionStore(filePath);
|
||||
const session = createStoredSession({ turn: 1 });
|
||||
await store.set(session);
|
||||
const paused = pauseFirstWrite();
|
||||
const firstSave = store.save();
|
||||
await paused.entered;
|
||||
await store.set({ ...session, data: { turn: 2 } });
|
||||
const secondSave = store.save();
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
const writesBeforeRelease = paused.writes();
|
||||
paused.release();
|
||||
await Promise.all([firstSave, secondSave]);
|
||||
await store.close();
|
||||
|
||||
const reloaded = new FileSessionStore(filePath);
|
||||
await reloaded.load();
|
||||
expect(writesBeforeRelease).toBe(1);
|
||||
expect((await reloaded.get(session.id))?.data).toEqual({ turn: 2 });
|
||||
});
|
||||
|
||||
it('waits for an outstanding save before closing', async () => {
|
||||
const store = new FileSessionStore(filePath);
|
||||
await store.set(createStoredSession());
|
||||
const paused = pauseFirstWrite();
|
||||
const saving = store.save();
|
||||
await paused.entered;
|
||||
let closed = false;
|
||||
const closing = store.close().then(() => { closed = true; });
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
const writesBeforeRelease = paused.writes();
|
||||
const closedBeforeRelease = closed;
|
||||
paused.release();
|
||||
await Promise.all([saving, closing]);
|
||||
expect(writesBeforeRelease).toBe(1);
|
||||
expect(closedBeforeRelease).toBe(false);
|
||||
});
|
||||
|
||||
it('does not queue repeated autosaves behind a blocked write', async () => {
|
||||
jest.useFakeTimers();
|
||||
const store = new FileSessionStore(filePath, { autoSaveInterval: 10 });
|
||||
const save = jest.spyOn(store, 'save');
|
||||
const first = createStoredSession({ turn: 1 });
|
||||
const second = createStoredSession({ turn: 2 });
|
||||
await store.set(first);
|
||||
const paused = pauseFirstWrite();
|
||||
const saving = store.save();
|
||||
await paused.entered;
|
||||
await store.set(second);
|
||||
jest.advanceTimersByTime(100);
|
||||
const saveCallsBeforeRelease = save.mock.calls.length;
|
||||
paused.release();
|
||||
await saving;
|
||||
jest.advanceTimersByTime(10);
|
||||
const saveCallsAfterRelease = save.mock.calls.length;
|
||||
await store.close();
|
||||
|
||||
const reloaded = new FileSessionStore(filePath);
|
||||
await reloaded.load();
|
||||
expect(saveCallsBeforeRelease).toBe(1);
|
||||
expect(saveCallsAfterRelease).toBe(2);
|
||||
expect((await reloaded.list()).map(session => session.id)).toEqual([first.id, second.id]);
|
||||
});
|
||||
|
||||
it('retries a failed save on close without poisoning later saves', async () => {
|
||||
const store = new FileSessionStore(filePath);
|
||||
const session = createStoredSession({ turn: 1 });
|
||||
await store.set(session);
|
||||
jest.spyOn(fs, 'writeFile').mockRejectedValueOnce(new Error('disk unavailable'));
|
||||
await expect(store.save()).rejects.toThrow('disk unavailable');
|
||||
await store.close();
|
||||
const reloaded = new FileSessionStore(filePath);
|
||||
await reloaded.load();
|
||||
expect((await reloaded.get(session.id))?.data).toEqual({ turn: 1 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from '@jest/globals';
|
||||
import { ToolsRegistry } from '../../../src/tools/registry/registry';
|
||||
import type { ToolMetadata } from '../../../src/tools/registry/types';
|
||||
|
||||
const missingPackage = '@praisonai-test/not-installed-registry-fixture';
|
||||
function register(registry: ToolsRegistry, packageName: string) {
|
||||
const metadata: ToolMetadata = {
|
||||
id: 'dependency-test', displayName: 'Dependency test', description: 'Local status test',
|
||||
tags: [], requiredEnv: [], optionalEnv: [], docsSlug: 'tools/dependency-test',
|
||||
install: { npm: `npm install ${packageName}`, pnpm: '', yarn: '', bun: '' },
|
||||
capabilities: {}, packageName,
|
||||
};
|
||||
registry.register(metadata, () => ({
|
||||
name: 'dependency-test', description: 'Local status test',
|
||||
parameters: { type: 'object', properties: {} }, execute: async () => 'ok',
|
||||
}));
|
||||
}
|
||||
|
||||
describe('ToolsRegistry installation cache lifecycle', () => {
|
||||
it('does not report a removed cached tool as installed', async () => {
|
||||
const registry = new ToolsRegistry();
|
||||
register(registry, 'node:path');
|
||||
expect(await registry.checkInstalled('dependency-test')).toBe(true);
|
||||
expect(registry.unregister('dependency-test')).toBe(true);
|
||||
expect(await registry.checkInstalled('dependency-test')).toBe(false);
|
||||
expect(await registry.getInstallStatus('dependency-test')).toBeNull();
|
||||
});
|
||||
|
||||
it('rechecks an installed dependency replaced by a missing one', async () => {
|
||||
const registry = new ToolsRegistry();
|
||||
register(registry, 'node:path');
|
||||
expect(await registry.checkInstalled('dependency-test')).toBe(true);
|
||||
register(registry, missingPackage);
|
||||
expect(await registry.getInstallStatus('dependency-test')).toMatchObject({
|
||||
installed: false, installCommand: `npm install ${missingPackage}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('rechecks a missing dependency replaced by an installed one', async () => {
|
||||
const registry = new ToolsRegistry();
|
||||
register(registry, missingPackage);
|
||||
expect(await registry.checkInstalled('dependency-test')).toBe(false);
|
||||
register(registry, 'node:path');
|
||||
expect(await registry.getInstallStatus('dependency-test')).toMatchObject({ installed: true, installCommand: undefined });
|
||||
});
|
||||
|
||||
it('does not cache an old successful import after replacement', async () => {
|
||||
const registry = new ToolsRegistry();
|
||||
register(registry, 'node:path');
|
||||
const oldCheck = registry.checkInstalled('dependency-test');
|
||||
register(registry, missingPackage);
|
||||
expect(await oldCheck).toBe(true);
|
||||
expect(await registry.checkInstalled('dependency-test')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not cache an old failed import after replacement', async () => {
|
||||
const registry = new ToolsRegistry();
|
||||
register(registry, missingPackage);
|
||||
const oldCheck = registry.checkInstalled('dependency-test');
|
||||
register(registry, 'node:path');
|
||||
expect(await oldCheck).toBe(false);
|
||||
expect(await registry.checkInstalled('dependency-test')).toBe(true);
|
||||
});
|
||||
|
||||
it('reports an unknown tool as unavailable', async () => {
|
||||
const registry = new ToolsRegistry();
|
||||
expect(await registry.checkInstalled('unknown')).toBe(false);
|
||||
expect(await registry.getInstallStatus('unknown')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps repeated installed checks consistent', async () => {
|
||||
const registry = new ToolsRegistry();
|
||||
register(registry, 'node:path');
|
||||
expect(await registry.checkInstalled('dependency-test')).toBe(true);
|
||||
expect(await registry.checkInstalled('dependency-test')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps repeated missing checks consistent', async () => {
|
||||
const registry = new ToolsRegistry();
|
||||
register(registry, missingPackage);
|
||||
expect(await registry.checkInstalled('dependency-test')).toBe(false);
|
||||
expect(await registry.checkInstalled('dependency-test')).toBe(false);
|
||||
});
|
||||
});
|
||||
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user