mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-13 05:01:35 -10:00
Compare commits
23
Commits
No files matched your search
@@ -41,10 +41,6 @@ curl -fsSL https://praison.ai/install.sh | bash
|
||||
<img src=".github/images/dashboard.png" alt="PraisonAI Dashboard" width="800" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/images/agentflow.gif" alt="PraisonAI AgentFlow" width="800" />
|
||||
</p>
|
||||
|
||||
```
|
||||
██████╗ ██████╗ █████╗ ██╗███████╗ ██████╗ ███╗ ██╗ █████╗ ██╗
|
||||
██╔══██╗██╔══██╗██╔══██╗██║██╔════╝██╔═══██╗████╗ ██║ ██╔══██╗██║
|
||||
@@ -56,12 +52,6 @@ curl -fsSL https://praison.ai/install.sh | bash
|
||||
pip install praisonai
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/images/latest_ai_news_and_crawl_each_url_to_find_info.gif" alt="PraisonAI command execution" width="800" />
|
||||
</p>
|
||||
|
||||
\* `export TAVILY_API_KEY=xxxxx`
|
||||
|
||||
<div align="center">
|
||||
<a href="https://docs.praison.ai">
|
||||
<p align="center">
|
||||
@@ -106,6 +96,170 @@ agent.start("Analyze the top 3 tech trends of 2026 and format as a markdown tabl
|
||||
|
||||
---
|
||||
|
||||
## 🧬 The Five-Layer Agent Stack
|
||||
|
||||
Most frameworks hand you one or two layers and leave the rest as homework. PraisonAI covers **all five** — plus the outer layer that decides *where* your agent actually runs.
|
||||
|
||||
Each layer wraps the one inside it. When an agent misbehaves, the layer tells you where to look.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ⬡ MANAGED AGENTS — Where does it actually run? │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 5 · GRAPH — Who runs when, and who checks whom? │ │
|
||||
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ 4 · LOOP — When do we stop? │ │ │
|
||||
│ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │
|
||||
│ │ │ │ 3 · HARNESS — Can it act, and be checked? │ │ │ │
|
||||
│ │ │ │ ┌─────────────────────────────────────────────────┐ │ │ │ │
|
||||
│ │ │ │ │ 2 · CONTEXT — Is the right thing in the window? │ │ │ │ │
|
||||
│ │ │ │ │ ┌─────────────────────────────────────────────┐ │ │ │ │ │
|
||||
│ │ │ │ │ │ 1 · PROMPT — Did I say it clearly? │ │ │ │ │ │
|
||||
│ │ │ │ │ └─────────────────────────────────────────────┘ │ │ │ │ │
|
||||
│ │ │ │ └─────────────────────────────────────────────────┘ │ │ │ │
|
||||
│ │ │ └─────────────────────────────────────────────────────┘ │ │ │
|
||||
│ │ └─────────────────────────────────────────────────────────┘ │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| Layer | The question it answers | PraisonAI |
|
||||
|:--|:--|:--|
|
||||
| **1 · Prompt** | Did I say it clearly? | `instructions=`, `role`/`goal`/`backstory`, `output=`, `templates=` |
|
||||
| **2 · Context** | Is the right thing in the window? | `memory=`, `knowledge=`, `context=`, handoff `ContextPolicy` |
|
||||
| **3 · Harness** | Can it act, and be checked? | `tools=`, `MCP()`, `guardrails=`, `approval=`, `hooks=`, `sandbox=` |
|
||||
| **4 · Loop** | When do we stop? | `execution=ExecutionConfig(...)`, `reflection=`, `autonomy=`, doom-loop detection |
|
||||
| **5 · Graph** | Who runs when, and who checks whom? | `AgentFlow`, `route()`, `parallel()`, `loop()`, `repeat()` |
|
||||
| **⬡ Managed** | *Where does it actually run?* | `backend=ManagedAgent(compute="e2b")` — remote sandbox, or a fully hosted loop |
|
||||
|
||||
### Layer 1 · Prompt — *Did I say it clearly?*
|
||||
|
||||
Role, instructions, examples, output format.
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
role="Senior Data Analyst",
|
||||
goal="Turn raw numbers into decisions",
|
||||
output="verbose", # markdown-formatted output
|
||||
)
|
||||
agent.start("Summarise Q3 revenue trends")
|
||||
```
|
||||
|
||||
### Layer 2 · Context — *Is the right thing in the window?*
|
||||
|
||||
Write, select, compress, isolate — the four context operations, one parameter each.
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
instructions="You are a support engineer.",
|
||||
memory={"user_id": "u-42"}, # write — persists across runs (needs a user_id)
|
||||
knowledge=["docs/"], # select — retrieves only what's relevant
|
||||
context="summarize", # compress — auto-compacts before the limit
|
||||
)
|
||||
```
|
||||
|
||||
> **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)
|
||||
|
||||
### Layer 3 · Harness — *Can it act, and be checked?*
|
||||
|
||||
*Agent = Model + Harness.* Tool dispatch, plus the guides that steer before acting and the sensors that observe after.
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent, MCP, tool
|
||||
|
||||
@tool
|
||||
def deploy(env: str) -> str:
|
||||
"""Deploy the current build to an environment."""
|
||||
return f"Deployed to {env}"
|
||||
|
||||
agent = Agent(
|
||||
name="ReleaseEngineer",
|
||||
instructions="You are a release engineer.",
|
||||
tools=[deploy, MCP("npx -y @modelcontextprotocol/server-filesystem /tmp")],
|
||||
approval=True, # guide — human gate before risky tools run
|
||||
)
|
||||
agent.start("Deploy to staging, then list the files you can read")
|
||||
```
|
||||
|
||||
### Layer 4 · Loop — *When do we stop?*
|
||||
|
||||
Hard iteration caps, budget ceilings, no-progress detection and completion checks — every brake is explicit.
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent, ExecutionConfig
|
||||
|
||||
agent = Agent(
|
||||
instructions="Fix the failing tests.",
|
||||
execution=ExecutionConfig(max_iter=30, max_budget=0.50, on_budget_exceeded="stop"),
|
||||
reflection=True, # completion check — the agent grades its own answer
|
||||
autonomy=True, # required to drive the loop with run_autonomous()
|
||||
)
|
||||
result = agent.run_autonomous("Refactor the auth module", max_iterations=5)
|
||||
|
||||
print(result.completion_reason)
|
||||
# goal | no_tool_calls | max_iterations | timeout | doom_loop | needs_help | error
|
||||
# (with on_budget_exceeded="stop", hitting the cap raises BudgetExceededError,
|
||||
# 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)
|
||||
|
||||
### Layer 5 · Graph — *Who runs when, and who checks whom?*
|
||||
|
||||
Topology as a versionable artifact: prompt chaining, routing, parallelisation, orchestrator-worker.
|
||||
|
||||
```python
|
||||
from praisonaiagents import AgentFlow
|
||||
from praisonaiagents.workflows import route, parallel, repeat
|
||||
|
||||
flow = AgentFlow(steps=[
|
||||
classifier,
|
||||
route({"bug": [bug_agent], "feature": [feature_agent], "default": [triage]}),
|
||||
parallel([reviewer, tester]), # fan out, join automatically
|
||||
repeat(editor, until=lambda ctx: "approved" in ctx.previous_result.lower(),
|
||||
max_iterations=3), # evaluator–optimizer
|
||||
])
|
||||
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)
|
||||
|
||||
### ⬡ Outside the stack: Managed Agents — *Where does it actually run?*
|
||||
|
||||
The harness is commoditising; **where** the agent executes is the next multiplier. Rather than burning your laptop's CPU, hand an agent a short-lived cloud sandbox — repo, tools and tests run there.
|
||||
|
||||
```bash
|
||||
pip install praisonai
|
||||
```
|
||||
|
||||
```python
|
||||
from praisonai import Agent, ManagedAgent, LocalManagedConfig
|
||||
|
||||
# A. Tools run in a remote sandbox; the agent loop stays local
|
||||
sandboxed = ManagedAgent(
|
||||
provider="local", compute="e2b", # or modal | daytona | flyio | docker | tenki
|
||||
config=LocalManagedConfig(model="gpt-4o-mini", name="RemoteTools"),
|
||||
)
|
||||
agent = Agent(name="builder", backend=sandboxed)
|
||||
|
||||
# B. The entire agent loop runs in the cloud (needs ANTHROPIC_API_KEY;
|
||||
# with no key set, ManagedAgent() falls back to a local loop)
|
||||
agent = Agent(name="teacher", backend=ManagedAgent())
|
||||
agent.start("Write a Python script that prints the first 10 primes, then run it")
|
||||
```
|
||||
|
||||
Sandboxes shut themselves down when idle (`auto_shutdown`, `idle_timeout_s`), and a post-setup snapshot is reused so the next run skips the image pull and dependency install. Commit a `.praisonai/environment.yaml` and the environment travels with the repo.
|
||||
|
||||
> 📖 [20 runnable examples](examples/python/managed-agents/) · manage sessions with `praisonai managed sessions list <agent-id>` or `praisonai managed sessions resume <session-id> "<prompt>"`
|
||||
|
||||
<sub>Stack framing adapted from [The Five-Layer Agent Stack](https://mer.vin/2026/07/five-layer-agent-stack-match-bug-to-right-layer/) and [Agent Harnesses vs Orbs](https://mer.vin/2026/08/agent-harnesses-vs-orbs-why-remote-sandboxes-beat-local-agent-loops/).</sub>
|
||||
|
||||
---
|
||||
|
||||
## 🌌 The PraisonAI Ecosystem
|
||||
|
||||
Start simple with the core SDK, or expand to full visual builders and dashboards when you're ready.
|
||||
@@ -200,9 +354,9 @@ Powered by 100+ LLMs (OpenAI, Anthropic, Gemini & local models).
|
||||
| 🧠 | **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) |
|
||||
| 🔄 | **Agent Handoffs** — seamless conversation passing | `handoff=True` |
|
||||
| 🔄 | **Agent Handoffs** — seamless conversation passing | `handoffs=[other_agent]` |
|
||||
| 🛡️ | **Guardrails** — input/output validation | [Docs](https://docs.praison.ai/docs/concepts/guardrails) |
|
||||
| | **Web Search + Fetch** — native browsing | `web_search=True` |
|
||||
| | **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) |
|
||||
| 🧠 | **Memory (zero deps)** — works out of the box | `memory=True` |
|
||||
@@ -212,9 +366,9 @@ Powered by 100+ LLMs (OpenAI, Anthropic, Gemini & local models).
|
||||
|
||||
| | Feature | How |
|
||||
|--|---------|-----|
|
||||
| 💡 | **Prompt Caching** — reduce latency + cost | `prompt_caching=True` |
|
||||
| 💡 | **Prompt Caching** — reduce latency + cost | `caching=True` |
|
||||
| 💾 | **Sessions + Auto-Save** — persistent state across restarts | `auto_save="my-project"` |
|
||||
| 💭 | **Thinking Budgets** — control reasoning depth | `thinking_budget=1024` |
|
||||
| 💭 | **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) |
|
||||
@@ -341,8 +495,10 @@ from praisonaiagents import Agent, db
|
||||
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
db=db(database_url="postgresql://localhost/mydb"),
|
||||
session_id="my-session"
|
||||
memory={
|
||||
"db": db(database_url="postgresql://localhost/mydb"),
|
||||
"session_id": "my-session",
|
||||
},
|
||||
)
|
||||
agent.chat("Hello!") # Auto-persists messages, runs, traces
|
||||
```
|
||||
@@ -788,6 +944,16 @@ PraisonAI is built for speed, with agent instantiation in around 14μs. This red
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/images/agentflow.gif" alt="PraisonAI AgentFlow" width="800" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/images/latest_ai_news_and_crawl_each_url_to_find_info.gif" alt="PraisonAI command execution" width="800" />
|
||||
</p>
|
||||
|
||||
\* `export TAVILY_API_KEY=xxxxx`
|
||||
|
||||
## 🔍 Langfuse Tracing
|
||||
|
||||
```bash
|
||||
@@ -891,8 +1057,10 @@ from praisonaiagents import Agent, db
|
||||
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
db=db(database_url="postgresql://localhost/mydb"),
|
||||
session_id="my-session"
|
||||
memory={
|
||||
"db": db(database_url="postgresql://localhost/mydb"),
|
||||
"session_id": "my-session",
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
@@ -908,8 +1076,8 @@ from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
memory=True, # Enables file-based memory (no extra deps!)
|
||||
user_id="user123"
|
||||
# Enables file-based memory (no extra deps!)
|
||||
memory={"user_id": "user123"},
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
@@ -13,15 +13,14 @@ Usage:
|
||||
-n 4
|
||||
|
||||
Architecture:
|
||||
Harbor Container → `praisonai code "TASK" --dangerously-skip-approval` (headless)
|
||||
Harbor Container → `praisonai code -p --output json "TASK" --dangerously-skip-approval`
|
||||
|
||||
Notes:
|
||||
- `--dangerously-skip-approval` sets PRAISON_APPROVAL_MODE=auto +
|
||||
PRAISONAI_TOOL_SAFETY=off so the assistant runs fully autonomously in the
|
||||
container (no approval hang in a non-TTY session).
|
||||
- `praisonai code` normally exits 0 and Harbor grades by task verification,
|
||||
so a benchmark miss does not fail the run — but the real exit status is
|
||||
propagated so genuine install/auth/startup failures still surface.
|
||||
- The JSON envelope provides token, cost, session, and status metadata while
|
||||
the real exit status still surfaces install/auth/startup failures.
|
||||
- The base `praisonai` package is sufficient; heavy `code` extras are not
|
||||
required (ACP tools degrade gracefully).
|
||||
|
||||
@@ -29,6 +28,7 @@ Dependencies:
|
||||
pip install harbor praisonai praisonaiagents
|
||||
"""
|
||||
|
||||
import json
|
||||
import shlex
|
||||
|
||||
try:
|
||||
@@ -81,16 +81,21 @@ class PraisonAICodeAgent(BaseInstalledAgent):
|
||||
model = self.model_name or "openai/gpt-4o-mini"
|
||||
|
||||
command = (
|
||||
f"praisonai code {shlex.quote(instruction)} "
|
||||
# Private per-run log (mktemp, 0600) instead of a predictable
|
||||
# /tmp/praisonai_code.log path another process could pre-create as a
|
||||
# symlink to redirect/clobber a file writable by the agent user.
|
||||
"log=$(mktemp /tmp/praisonai_code.XXXXXX.log); "
|
||||
f"praisonai code -p --output json {shlex.quote(instruction)} "
|
||||
f"--dangerously-skip-approval "
|
||||
f"--model {shlex.quote(model)} "
|
||||
"> /tmp/praisonai_code.log 2>&1; "
|
||||
'> "$log" 2>&1; '
|
||||
# Capture the real exit status so install/auth/startup failures still
|
||||
# surface instead of being silently masked (Harbor otherwise records a
|
||||
# completed attempt for a crashed agent).
|
||||
"status=$?; "
|
||||
# Tee the log into a stable path so populate_context_post_run can read it.
|
||||
"cp /tmp/praisonai_code.log /tmp/praisonai_code_run.log 2>/dev/null || true; "
|
||||
# Echo the envelope so Harbor captures it for post-run accounting,
|
||||
# then remove the private log.
|
||||
'cat "$log"; rm -f "$log"; '
|
||||
# `praisonai code` normally exits 0 (Harbor grades by task
|
||||
# verification), so a genuine benchmark miss won't fail here — but a
|
||||
# nonzero status means the assistant itself failed to run.
|
||||
@@ -100,23 +105,35 @@ class PraisonAICodeAgent(BaseInstalledAgent):
|
||||
# `--ae` / job YAML env vars arrive via BaseAgent.extra_env and are wired
|
||||
# into the container exec context by Harbor Trial, so no per-exec `env=`
|
||||
# is needed here.
|
||||
await self.exec_as_agent(
|
||||
result = await self.exec_as_agent(
|
||||
environment,
|
||||
command=command,
|
||||
)
|
||||
self._last_stdout = getattr(result, "stdout", "") or ""
|
||||
|
||||
def populate_context_post_run(self, context: AgentContext) -> None:
|
||||
"""`praisonai code` exposes no machine-readable metrics headlessly.
|
||||
"""Parse the headless JSON envelope into Harbor's accounting fields."""
|
||||
envelope = None
|
||||
for line in reversed(getattr(self, "_last_stdout", "").splitlines()):
|
||||
try:
|
||||
candidate = json.loads(line)
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
continue
|
||||
if isinstance(candidate, dict) and "status" in candidate:
|
||||
envelope = candidate
|
||||
break
|
||||
|
||||
Record identifying metadata; token/cost accounting is not available from
|
||||
the CLI's mixed stdout, so we leave those fields unset rather than guess.
|
||||
"""
|
||||
usage = envelope.get("usage", {}) if envelope else {}
|
||||
context.n_input_tokens = usage.get("in")
|
||||
context.n_output_tokens = usage.get("out")
|
||||
context.cost_usd = usage.get("cost")
|
||||
context.metadata = {
|
||||
"framework": "praisonai",
|
||||
"agent_type": "code-cli",
|
||||
"agent_name": self.name(),
|
||||
"model": self.model_name,
|
||||
"log_path": "/tmp/praisonai_code.log",
|
||||
"session_id": envelope.get("session_id") if envelope else None,
|
||||
"status": envelope.get("status") if envelope else "unknown",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
| Category | Features | Docs | Lines |
|
||||
|----------|----------|------|-------|
|
||||
| ✅ AGUI | 1 | 2 | 420 |
|
||||
| ✅ Agent | 22 | 50 | 15804 |
|
||||
| ✅ Agent | 22 | 50 | 15809 |
|
||||
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2996 |
|
||||
| ✅ Agent-to-User (A2U) | 1 | 3 | 689 |
|
||||
| ✅ Approval | 1 | 5 | 2537 |
|
||||
@@ -27,32 +27,32 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
| ✅ Autonomy | 3 | 7 | 2744 |
|
||||
| ✅ Bots | 7 | 31 | 13314 |
|
||||
| ✅ Budget | 1 | 1 | 287 |
|
||||
| ✅ CLI | 5 | 121 | 35970 |
|
||||
| ✅ CLI | 5 | 121 | 36021 |
|
||||
| ✅ Chunking | 2 | 2 | 385 |
|
||||
| ✅ Citations | 2 | 1 | 202 |
|
||||
| ✅ Code Execution | 2 | 14 | 4583 |
|
||||
| ✅ Conditions | 1 | 3 | 1179 |
|
||||
| ✅ Configuration | 3 | 8 | 3047 |
|
||||
| ✅ Context Management | 16 | 34 | 13388 |
|
||||
| ✅ Context Management | 16 | 34 | 13396 |
|
||||
| ✅ Deep Research | 8 | 2 | 587 |
|
||||
| ✅ Display | 6 | 3 | 741 |
|
||||
| ✅ Embeddings | 6 | 23 | 2049 |
|
||||
| ✅ Evaluation | 1 | 7 | 2905 |
|
||||
| ✅ Events | 1 | 2 | 749 |
|
||||
| ✅ Execution | 3 | 4 | 1560 |
|
||||
| ✅ Execution | 3 | 4 | 1562 |
|
||||
| ✅ Failover | 2 | 1 | 432 |
|
||||
| ✅ Files | 2 | 7 | 2273 |
|
||||
| ✅ Flow | 1 | 3 | 788 |
|
||||
| ✅ Gateway | 7 | 72 | 26391 |
|
||||
| ✅ Guardrails | 4 | 4 | 1950 |
|
||||
| ✅ Guardrails | 4 | 4 | 2017 |
|
||||
| ✅ Handoffs | 11 | 6 | 2563 |
|
||||
| ✅ Hooks | 2 | 9 | 4309 |
|
||||
| ✅ Hooks | 2 | 9 | 4317 |
|
||||
| ✅ Image | 1 | 11 | 1255 |
|
||||
| ✅ Knowledge | 4 | 13 | 4245 |
|
||||
| ✅ LLM | 3 | 15 | 5140 |
|
||||
| ✅ Knowledge | 4 | 13 | 4333 |
|
||||
| ✅ LLM | 3 | 15 | 5192 |
|
||||
| ✅ Loops | 4 | 5 | 1422 |
|
||||
| ✅ MCP | 1 | 61 | 13847 |
|
||||
| ✅ Memory | 6 | 18 | 7067 |
|
||||
| ✅ Memory | 6 | 18 | 7070 |
|
||||
| ✅ OCR | 2 | 1 | 237 |
|
||||
| ✅ Observability | 2 | 23 | 2814 |
|
||||
| ✅ Optimizer | 1 | 2 | 792 |
|
||||
@@ -63,21 +63,21 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
| ✅ Prompts | 2 | 9 | 1965 |
|
||||
| ✅ Providers | 1 | 56 | 7735 |
|
||||
| ✅ Query | 1 | 2 | 736 |
|
||||
| ✅ RAG | 5 | 15 | 2936 |
|
||||
| ✅ RAG | 5 | 15 | 3003 |
|
||||
| ✅ Realtime | 2 | 5 | 668 |
|
||||
| ✅ Reflection | 3 | 3 | 680 |
|
||||
| ✅ Retrieval | 2 | 5 | 1044 |
|
||||
| ✅ Retrieval | 2 | 5 | 1054 |
|
||||
| ✅ Routing | 1 | 2 | 430 |
|
||||
| ✅ Sandbox | 5 | 6 | 2918 |
|
||||
| ✅ Security | 1 | 3 | 3206 |
|
||||
| ✅ Sessions | 4 | 17 | 6960 |
|
||||
| ✅ Sessions | 4 | 17 | 6963 |
|
||||
| ✅ Skills | 6 | 15 | 5638 |
|
||||
| ✅ Tasks | 2 | 6 | 2819 |
|
||||
| ✅ Telemetry | 1 | 2 | 604 |
|
||||
| ✅ Templates | 1 | 8 | 1663 |
|
||||
| ✅ Tools | 12 | 141 | 41237 |
|
||||
| ✅ Tools | 12 | 141 | 41242 |
|
||||
| ✅ Tracing | 3 | 2 | 139 |
|
||||
| ✅ Vector Store | 1 | 12 | 1207 |
|
||||
| ✅ Vector Store | 1 | 12 | 1211 |
|
||||
| ✅ Video | 2 | 6 | 649 |
|
||||
| ✅ Vision | 2 | 1 | 329 |
|
||||
| ✅ Web | 3 | 9 | 2467 |
|
||||
@@ -87,7 +87,7 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
|
||||
These docs exist but don't match any implemented feature category:
|
||||
|
||||
- ℹ️ Database (42 docs, 6703 lines)
|
||||
- ℹ️ Database (42 docs, 6713 lines)
|
||||
- ℹ️ Documents (1 docs, 787 lines)
|
||||
|
||||
---
|
||||
|
||||
@@ -1413,6 +1413,13 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
|
||||
'rerank': _knowledge_config.rerank,
|
||||
'rerank_model': _knowledge_config.rerank_model,
|
||||
}
|
||||
# Forward embedder settings so they reach Knowledge (mem0).
|
||||
# embedder is a provider shorthand (e.g. "gemini"); embedder_config
|
||||
# is the full mem0 embedder dict. Only override when explicitly set.
|
||||
if _knowledge_config.embedder and _knowledge_config.embedder != "openai":
|
||||
retrieval_config.setdefault('embedder_provider', _knowledge_config.embedder)
|
||||
if _knowledge_config.embedder_config:
|
||||
retrieval_config.setdefault('embedder_config', _knowledge_config.embedder_config)
|
||||
knowledge = _knowledge_config.sources if _knowledge_config.sources else None
|
||||
elif isinstance(_knowledge_config, list):
|
||||
knowledge = _knowledge_config
|
||||
|
||||
@@ -660,6 +660,13 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
reflection: Optional[Any] = None, # Union[bool, ReflectionConfig] - self-reflection
|
||||
caching: Optional[Any] = None, # Union[bool, CachingConfig] - caching
|
||||
learn: Optional[Any] = None, # Union[bool, LearnConfig] - continuous learning
|
||||
# Union[str, ComputeProviderProtocol] - run every agent's shell/file
|
||||
# tools in ONE shared remote sandbox ("docker", "e2b", "modal",
|
||||
# "daytona", "flyio", "tenki", "local"). Agents share /workspace, so
|
||||
# files written by one agent are visible to the others. Orchestration
|
||||
# stays local. Pass a configured provider instance instead of a name to
|
||||
# customise image/resources.
|
||||
compute: Optional[Any] = None,
|
||||
):
|
||||
"""
|
||||
Initialize AgentManager with consolidated feature parameters.
|
||||
@@ -890,7 +897,9 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
self.process = process
|
||||
self.stream = _stream
|
||||
self.name = name
|
||||
|
||||
# Remote execution: one shared sandbox for every agent on this team.
|
||||
self.compute = compute
|
||||
|
||||
# Callbacks for workflow execution
|
||||
self.on_task_start = _on_task_start
|
||||
self.on_task_complete = _on_task_complete
|
||||
@@ -1752,6 +1761,21 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
result = agents.start(output="silent")
|
||||
```
|
||||
"""
|
||||
# Remote execution: provision ONE sandbox shared by every agent on the
|
||||
# team, and tear it down even if execution raises. Re-enters start()
|
||||
# with compute cleared so the wrapper applies exactly once.
|
||||
if getattr(self, "compute", None) is not None:
|
||||
from ..managed.shared_compute import SharedCompute
|
||||
|
||||
compute, self.compute = self.compute, None
|
||||
try:
|
||||
with SharedCompute(compute) as shared:
|
||||
shared.attach(list(self.agents or []))
|
||||
return self.start(content=content, return_dict=return_dict,
|
||||
output=output, **kwargs)
|
||||
finally:
|
||||
self.compute = compute
|
||||
|
||||
# Track execution via telemetry
|
||||
if hasattr(self, '_telemetry') and self._telemetry:
|
||||
self._telemetry.track_agent_execution(self.name, success=True)
|
||||
|
||||
@@ -6,20 +6,28 @@ Implementations are provided by the wrapper layer (praisonai.db).
|
||||
|
||||
Usage (simplest - recommended):
|
||||
from praisonaiagents import Agent, db
|
||||
|
||||
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
db=db(database_url="postgresql://localhost/mydb"), # db(...) shortcut
|
||||
session_id="my-session" # optional: defaults to per-hour ID
|
||||
memory=db(database_url="postgresql://localhost/mydb"), # db(...) shortcut
|
||||
)
|
||||
agent.chat("Hello!") # auto-persists messages, runs, traces, tool calls
|
||||
|
||||
Alternative (explicit backend):
|
||||
db=db.DB(database_url="...") # Short name (recommended)
|
||||
db=db.PraisonAIDB(database_url="...") # Auto-detect backend
|
||||
db=db.PostgresDB(host="localhost") # PostgreSQL
|
||||
db=db.SQLiteDB(path="data.db") # SQLite
|
||||
db=db.RedisDB(host="localhost") # Redis (state only)
|
||||
With an explicit session_id (use MemoryConfig):
|
||||
from praisonaiagents import Agent, db, MemoryConfig
|
||||
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
memory=MemoryConfig(db=db(database_url="postgresql://localhost/mydb"),
|
||||
session_id="my-session"), # optional: defaults to per-hour ID
|
||||
)
|
||||
|
||||
Alternative (explicit backend, passed via memory=):
|
||||
memory=db.DB(database_url="...") # Short name (recommended)
|
||||
memory=db.PraisonAIDB(database_url="...") # Auto-detect backend
|
||||
memory=db.PostgresDB(host="localhost") # PostgreSQL
|
||||
memory=db.SQLiteDB(path="data.db") # SQLite
|
||||
memory=db.RedisDB(host="localhost") # Redis (state only)
|
||||
"""
|
||||
|
||||
from .protocol import (
|
||||
|
||||
@@ -1468,22 +1468,6 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
"""
|
||||
return function_name == "memory" and self._supports_claude_memory()
|
||||
|
||||
def _execute_memory_tool_call(self, arguments: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Execute a Claude Memory Tool call.
|
||||
|
||||
Args:
|
||||
arguments: The arguments from Claude's tool call
|
||||
|
||||
Returns:
|
||||
Result string from the memory operation
|
||||
"""
|
||||
memory_tool = self._get_claude_memory_tool()
|
||||
if not memory_tool:
|
||||
return "Error: Memory tool not initialized"
|
||||
|
||||
return memory_tool.process_tool_call(arguments)
|
||||
|
||||
def _generate_ollama_tool_summary(self, tool_results: List[Any], response_text: str) -> Optional[str]:
|
||||
"""
|
||||
Generate a summary from tool results for Ollama to prevent infinite loops.
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Lazy access to compute providers from praisonaiagents.
|
||||
|
||||
Concrete ``ComputeProviderProtocol`` implementations live in the ``praisonai``
|
||||
wrapper package (``praisonai.integrations.compute``). The core SDK only ships
|
||||
protocols, so this module resolves a provider by name at call time -- mirroring
|
||||
``praisonaiagents.sandbox._sandbox_bridge``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from typing import Any, Optional
|
||||
|
||||
_INSTALL_HINT = "Remote compute requires the wrapper package: pip install praisonai"
|
||||
|
||||
# name -> (module, attribute). Kept in sync with
|
||||
# praisonai.integrations.managed_local._resolve_compute
|
||||
_PROVIDERS = {
|
||||
"local": ("praisonai.integrations.compute.local", "LocalCompute"),
|
||||
"docker": ("praisonai.integrations.compute.docker", "DockerCompute"),
|
||||
"e2b": ("praisonai.integrations.compute.e2b", "E2BCompute"),
|
||||
"modal": ("praisonai.integrations.compute.modal_compute", "ModalCompute"),
|
||||
"daytona": ("praisonai.integrations.compute.daytona", "DaytonaCompute"),
|
||||
"flyio": ("praisonai.integrations.compute.flyio", "FlyioCompute"),
|
||||
"tenki": ("praisonai.integrations.compute.tenki", "TenkiCompute"),
|
||||
}
|
||||
|
||||
_EXTRA_HINTS = {
|
||||
"e2b": "pip install praisonai[e2b]",
|
||||
"modal": "pip install praisonai[modal]",
|
||||
"daytona": "pip install praisonai[daytona]",
|
||||
}
|
||||
|
||||
|
||||
def available_providers() -> list:
|
||||
"""Return the compute provider names this bridge can resolve."""
|
||||
return sorted(_PROVIDERS)
|
||||
|
||||
|
||||
def compute_package_available() -> bool:
|
||||
"""True when the wrapper package supplying compute providers is importable."""
|
||||
import importlib.util
|
||||
|
||||
return importlib.util.find_spec("praisonai") is not None
|
||||
|
||||
|
||||
def resolve_compute(compute: Optional[Any]) -> Optional[Any]:
|
||||
"""Resolve ``compute`` into a ``ComputeProviderProtocol`` instance.
|
||||
|
||||
Accepts ``None`` (no remote compute), a provider name such as ``"docker"``,
|
||||
or an already-instantiated provider, which is returned unchanged so callers
|
||||
can inject custom or pre-configured backends.
|
||||
"""
|
||||
if compute is None:
|
||||
return None
|
||||
|
||||
# Already a provider instance (duck-typed against ComputeProviderProtocol).
|
||||
if not isinstance(compute, str):
|
||||
if hasattr(compute, "provision") and hasattr(compute, "execute"):
|
||||
return compute
|
||||
raise TypeError(
|
||||
f"compute= expects a provider name or a ComputeProviderProtocol "
|
||||
f"instance, got {type(compute).__name__}"
|
||||
)
|
||||
|
||||
name = compute.lower().strip()
|
||||
if name not in _PROVIDERS:
|
||||
raise ValueError(
|
||||
f"Unknown compute provider {name!r}. "
|
||||
f"Available: {', '.join(available_providers())}"
|
||||
)
|
||||
|
||||
module_name, attr = _PROVIDERS[name]
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
except ImportError as exc:
|
||||
hint = _EXTRA_HINTS.get(name, _INSTALL_HINT)
|
||||
raise ImportError(f"compute={name!r} requires {module_name}. {hint}") from exc
|
||||
|
||||
return getattr(module, attr)()
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Share one remote sandbox across every agent in a team or workflow.
|
||||
|
||||
``LocalAgent(compute=...)`` gives a *single* agent remote tools, and each agent
|
||||
provisions its own isolated instance. A workflow therefore ends up with N
|
||||
sandboxes and no shared filesystem, so agents cannot hand files to each other.
|
||||
|
||||
``SharedCompute`` provisions **one** instance for a whole run and binds every
|
||||
participating agent's shell/file tools to it, so step 2 can read what step 1
|
||||
wrote. Orchestration (routing, parallel, repeat) still runs locally.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import logging
|
||||
import shlex
|
||||
import threading
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ._compute_bridge import resolve_compute
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Tool names that mean "touch the machine" and so must follow the sandbox.
|
||||
BRIDGED_TOOL_NAMES = ("execute_command", "read_file", "write_file", "list_files")
|
||||
|
||||
# Models routinely refuse to call bridged tools because nothing in the prompt
|
||||
# says remote execution is available -- the sandbox spins up and is never used.
|
||||
CAPABILITY_PROMPT = (
|
||||
"You have tools that run on a REMOTE Linux sandbox, not on the user's machine: "
|
||||
"execute_command(command), read_file(path), write_file(path, content), list_files(path). "
|
||||
"Use them for any shell, file, or code-execution request. Never claim you are unable "
|
||||
"to run commands. All agents in this run share the same sandbox and working directory, "
|
||||
"so files written by an earlier step are visible to later steps."
|
||||
)
|
||||
|
||||
|
||||
def _run_sync(coro):
|
||||
"""Run ``coro`` to completion from sync code, even inside a running loop."""
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return asyncio.run(coro)
|
||||
# Already inside a loop (e.g. called from async workflow code): hand the
|
||||
# coroutine to a private loop on another thread so we never re-enter.
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||
return pool.submit(asyncio.run, coro).result()
|
||||
|
||||
|
||||
class SharedCompute:
|
||||
"""One provisioned sandbox instance shared by many agents.
|
||||
|
||||
Provisioning is lazy: nothing is created until a tool is actually called, so
|
||||
a workflow that never touches the sandbox costs nothing.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
compute: Any,
|
||||
*,
|
||||
config: Optional[Any] = None,
|
||||
working_dir: str = "/workspace",
|
||||
auto_shutdown: bool = True,
|
||||
idle_timeout_s: int = 300,
|
||||
):
|
||||
self.provider = resolve_compute(compute)
|
||||
self.working_dir = working_dir
|
||||
self.instance_id: Optional[str] = None
|
||||
self._config = config
|
||||
self._auto_shutdown = auto_shutdown
|
||||
self._idle_timeout_s = idle_timeout_s
|
||||
self._patched: List[Any] = []
|
||||
# Serialises lazy provisioning: parallel steps can make their first tool
|
||||
# call concurrently, and without this two threads would each provision
|
||||
# their own sandbox -- splitting the run and leaking one instance.
|
||||
self._provision_lock = threading.Lock()
|
||||
|
||||
# ── lifecycle ────────────────────────────────────────────────────────────
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
return getattr(self.provider, "provider_name", type(self.provider).__name__)
|
||||
|
||||
def ensure_provisioned(self) -> str:
|
||||
"""Provision on first use and return the instance id.
|
||||
|
||||
Guarded by a lock so concurrent first-use calls (e.g. from a parallel
|
||||
step) share one sandbox instead of racing and provisioning several.
|
||||
"""
|
||||
if self.instance_id is not None:
|
||||
return self.instance_id
|
||||
|
||||
with self._provision_lock:
|
||||
# Re-check inside the lock: another thread may have provisioned
|
||||
# while we were waiting.
|
||||
if self.instance_id is not None:
|
||||
return self.instance_id
|
||||
|
||||
config = self._config
|
||||
if config is None:
|
||||
from .protocols import ComputeConfig
|
||||
|
||||
config = ComputeConfig(
|
||||
working_dir=self.working_dir,
|
||||
auto_shutdown=self._auto_shutdown,
|
||||
idle_timeout_s=self._idle_timeout_s,
|
||||
)
|
||||
|
||||
info = _run_sync(self.provider.provision(config))
|
||||
self.instance_id = getattr(info, "instance_id", info)
|
||||
logger.info(
|
||||
"[shared_compute] provisioned %s instance %s",
|
||||
self.provider_name,
|
||||
self.instance_id,
|
||||
)
|
||||
return self.instance_id
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Tear down the instance and restore any agents we patched."""
|
||||
for agent, original_tools, original_backstory in self._patched:
|
||||
try:
|
||||
agent.tools = original_tools
|
||||
agent.backstory = original_backstory
|
||||
cache = getattr(agent, "_system_prompt_cache", None)
|
||||
if cache is not None and hasattr(cache, "clear"):
|
||||
cache.clear()
|
||||
except Exception: # pragma: no cover - restoration is best-effort
|
||||
logger.debug("[shared_compute] could not restore agent %r", agent)
|
||||
self._patched.clear()
|
||||
|
||||
if self.instance_id is None:
|
||||
return
|
||||
try:
|
||||
_run_sync(self.provider.shutdown(self.instance_id))
|
||||
logger.info("[shared_compute] shut down instance %s", self.instance_id)
|
||||
except Exception as exc: # pragma: no cover - teardown must not mask errors
|
||||
logger.warning("[shared_compute] shutdown failed for %s: %s", self.instance_id, exc)
|
||||
finally:
|
||||
self.instance_id = None
|
||||
|
||||
def __enter__(self) -> "SharedCompute":
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc_info) -> None:
|
||||
self.shutdown()
|
||||
|
||||
# ── remote primitives ────────────────────────────────────────────────────
|
||||
def run_command(self, command: str, timeout: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""Execute ``command`` inside the shared sandbox."""
|
||||
instance_id = self.ensure_provisioned()
|
||||
result = _run_sync(self.provider.execute(instance_id, command, timeout))
|
||||
if isinstance(result, dict):
|
||||
return result
|
||||
return {"stdout": str(result), "stderr": "", "exit_code": 0}
|
||||
|
||||
@staticmethod
|
||||
def _format(result: Dict[str, Any]) -> str:
|
||||
stdout = (result.get("stdout") or "").rstrip("\n")
|
||||
stderr = (result.get("stderr") or "").rstrip("\n")
|
||||
if result.get("exit_code"):
|
||||
return f"Error (exit {result['exit_code']}): {stderr or stdout or 'command failed'}"
|
||||
if stdout:
|
||||
return stdout
|
||||
if stderr:
|
||||
return stderr
|
||||
# Never return "" -- an empty tool result reads as "no progress" to the
|
||||
# loop guard and the model retries until it trips the stuck detector.
|
||||
return "(command succeeded with no output)"
|
||||
|
||||
# ── tools handed to agents ───────────────────────────────────────────────
|
||||
def build_tools(self) -> List[Any]:
|
||||
"""Return shell/file tools bound to this shared instance."""
|
||||
|
||||
def execute_command(command: str) -> str:
|
||||
"""Run a shell command on the shared remote sandbox."""
|
||||
return self._format(self.run_command(command))
|
||||
|
||||
def read_file(path: str) -> str:
|
||||
"""Read a file from the shared remote sandbox."""
|
||||
return self._format(self.run_command(f"cat {shlex.quote(path)}"))
|
||||
|
||||
def write_file(path: str, content: str) -> str:
|
||||
"""Write a file to the shared remote sandbox."""
|
||||
quoted = shlex.quote(path)
|
||||
# Random per-write delimiter so file content can never terminate the
|
||||
# heredoc early (which would truncate the file and run the remainder
|
||||
# as shell commands). Guard against the astronomically-unlikely case
|
||||
# where the token still appears in the body.
|
||||
delimiter = f"__PRAISON_EOF_{uuid.uuid4().hex}__"
|
||||
while delimiter in content:
|
||||
delimiter = f"__PRAISON_EOF_{uuid.uuid4().hex}__"
|
||||
heredoc = (
|
||||
f"mkdir -p $(dirname {quoted}) && "
|
||||
f"cat > {quoted} <<'{delimiter}'\n{content}\n{delimiter}"
|
||||
)
|
||||
result = self.run_command(heredoc)
|
||||
if result.get("exit_code"):
|
||||
return self._format(result)
|
||||
return f"Wrote {path}"
|
||||
|
||||
def list_files(path: str = ".") -> str:
|
||||
"""List files in a directory on the shared remote sandbox."""
|
||||
return self._format(self.run_command(f"ls -la {shlex.quote(path)}"))
|
||||
|
||||
return [execute_command, read_file, write_file, list_files]
|
||||
|
||||
def attach(self, agents: List[Any]) -> None:
|
||||
"""Give each agent the shared tools and tell it they exist.
|
||||
|
||||
Agents that already carry their own managed ``backend`` are skipped --
|
||||
they have deliberately been pointed at their own runtime.
|
||||
"""
|
||||
tools = self.build_tools()
|
||||
by_name = {t.__name__: t for t in tools}
|
||||
|
||||
for agent in agents:
|
||||
if agent is None or getattr(agent, "backend", None) is not None:
|
||||
continue
|
||||
|
||||
original_tools = list(getattr(agent, "tools", None) or [])
|
||||
# Agent.__init__ copies `instructions` into role/goal/backstory and
|
||||
# builds the system prompt from THOSE, so mutating `.instructions`
|
||||
# here would be silently ignored. Patch `backstory`, which leads the
|
||||
# system prompt.
|
||||
original_backstory = getattr(agent, "backstory", None)
|
||||
|
||||
# Replace same-named local tools; keep everything else untouched.
|
||||
merged = [
|
||||
t for t in original_tools
|
||||
if getattr(t, "__name__", getattr(t, "name", None)) not in by_name
|
||||
]
|
||||
merged.extend(tools)
|
||||
|
||||
try:
|
||||
agent.tools = merged
|
||||
agent.backstory = (
|
||||
f"{original_backstory}\n\n{CAPABILITY_PROMPT}"
|
||||
if original_backstory
|
||||
else CAPABILITY_PROMPT
|
||||
)
|
||||
# The system prompt is memoised per (role, goal, tools); drop the
|
||||
# cache so the capability text is actually rendered.
|
||||
cache = getattr(agent, "_system_prompt_cache", None)
|
||||
if cache is not None and hasattr(cache, "clear"):
|
||||
cache.clear()
|
||||
except Exception as exc: # pragma: no cover - never break a run over this
|
||||
logger.warning("[shared_compute] could not attach tools to %r: %s", agent, exc)
|
||||
continue
|
||||
|
||||
self._patched.append((agent, original_tools, original_backstory))
|
||||
@@ -6,19 +6,18 @@ Implementations are provided by the wrapper layer (praisonai_tools.observability
|
||||
|
||||
Usage (simplest - recommended):
|
||||
from praisonaiagents import Agent, obs
|
||||
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
observability=obs.auto(), # Auto-detect from env vars
|
||||
)
|
||||
|
||||
obs.auto() # Auto-detect from env vars; instruments the LLM client globally
|
||||
|
||||
agent = Agent(name="Assistant")
|
||||
agent.chat("Hello!") # Auto-traces to configured provider
|
||||
|
||||
Alternative (explicit provider):
|
||||
observability=obs.langfuse() # Langfuse
|
||||
observability=obs.langsmith() # LangSmith
|
||||
observability=obs.agentops() # AgentOps
|
||||
observability=obs.arize() # Arize Phoenix
|
||||
observability=obs.datadog() # Datadog
|
||||
Alternative (explicit provider - call before creating the agent):
|
||||
obs.langfuse() # Langfuse
|
||||
obs.langsmith() # LangSmith
|
||||
obs.agentops() # AgentOps
|
||||
obs.arize() # Arize Phoenix
|
||||
obs.datadog() # Datadog
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
|
||||
|
||||
@@ -97,6 +97,7 @@ class RetrievalConfig:
|
||||
# Embedding configuration
|
||||
embedder_provider: Optional[str] = None
|
||||
embedder_model: Optional[str] = None
|
||||
embedder_config: Optional[Dict[str, Any]] = None # Full mem0 embedder dict: {"provider": ..., "config": {...}}
|
||||
|
||||
# Auto mode heuristics
|
||||
auto_keywords: frozenset = field(default_factory=lambda: frozenset({
|
||||
@@ -193,6 +194,7 @@ class RetrievalConfig:
|
||||
"persist_path": self.persist_path,
|
||||
"embedder_provider": self.embedder_provider,
|
||||
"embedder_model": self.embedder_model,
|
||||
"embedder_config": self.embedder_config,
|
||||
"auto_min_length": self.auto_min_length,
|
||||
"context_template": self.context_template,
|
||||
"system_separation": self.system_separation,
|
||||
@@ -241,6 +243,7 @@ class RetrievalConfig:
|
||||
persist_path=data.get("persist_path"),
|
||||
embedder_provider=data.get("embedder_provider"),
|
||||
embedder_model=data.get("embedder_model"),
|
||||
embedder_config=data.get("embedder_config"),
|
||||
auto_keywords=auto_keywords,
|
||||
auto_min_length=data.get("auto_min_length", 10),
|
||||
context_template=data.get("context_template", cls.__dataclass_fields__["context_template"].default),
|
||||
@@ -261,7 +264,15 @@ class RetrievalConfig:
|
||||
if self.collection_name:
|
||||
config["vector_store"]["config"]["collection_name"] = self.collection_name
|
||||
|
||||
if self.embedder_provider or self.embedder_model:
|
||||
# Normalise embedder into mem0's {"provider": ..., "config": {...}} shape.
|
||||
# A full embedder_config dict (from KnowledgeConfig.embedder_config) takes
|
||||
# precedence; embedder_provider acts as a shorthand for the provider.
|
||||
if self.embedder_config:
|
||||
embedder = dict(self.embedder_config)
|
||||
if "provider" not in embedder and self.embedder_provider:
|
||||
embedder["provider"] = self.embedder_provider
|
||||
config["embedder"] = embedder
|
||||
elif self.embedder_provider or self.embedder_model:
|
||||
config["embedder"] = {}
|
||||
if self.embedder_provider:
|
||||
config["embedder"]["provider"] = self.embedder_provider
|
||||
|
||||
@@ -13,20 +13,24 @@ Zero Performance Impact:
|
||||
- No overhead when not in use
|
||||
|
||||
Usage:
|
||||
from praisonaiagents.thinking import ThinkingBudget, ThinkingConfig
|
||||
|
||||
# Create a thinking budget
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.thinking import ThinkingBudget
|
||||
|
||||
# Create a thinking budget (helper for computing per-task token limits)
|
||||
budget = ThinkingBudget(
|
||||
max_tokens=16000,
|
||||
max_time_seconds=60,
|
||||
adaptive=True
|
||||
)
|
||||
|
||||
# Apply to agent
|
||||
agent = Agent(
|
||||
instructions="...",
|
||||
thinking_budget=budget
|
||||
)
|
||||
|
||||
# Compute an adaptive token budget for a given task complexity (0.0-1.0)
|
||||
tokens = budget.get_tokens_for_complexity(0.8)
|
||||
|
||||
# Note: `agent.thinking_budget` stores this object on the agent as a hint,
|
||||
# but the core Agent request pipeline does not yet apply it automatically.
|
||||
# To influence step-by-step reasoning today, use the built-in
|
||||
# `reasoning_steps` option instead:
|
||||
agent = Agent(instructions="...", reasoning_steps=True)
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
|
||||
@@ -627,7 +627,17 @@ class AgentFlow:
|
||||
execution: Optional[Any] = None # Union[str, ExecutionConfig] - execution control
|
||||
caching: Optional[Any] = None # Union[bool, CachingConfig] - caching
|
||||
learn: Optional[Any] = None # Union[bool, LearnConfig] - continuous learning
|
||||
|
||||
|
||||
# ============================================================
|
||||
# REMOTE EXECUTION
|
||||
# ============================================================
|
||||
# Union[str, ComputeProviderProtocol] - run every step's shell/file tools in
|
||||
# ONE shared remote sandbox ("docker", "e2b", "modal", "daytona", "flyio",
|
||||
# "tenki", "local"). Agents share /workspace, so a file written by one step
|
||||
# is visible to later steps. Orchestration stays local. Pass a configured
|
||||
# provider instance instead of a name to customise image/resources.
|
||||
compute: Optional[Any] = None
|
||||
|
||||
# ============================================================
|
||||
# ROBUSTNESS PARAMS (debugging & audit trail)
|
||||
# ============================================================
|
||||
@@ -1105,10 +1115,74 @@ class AgentFlow:
|
||||
"separate Workflow instance per concurrent run."
|
||||
)
|
||||
try:
|
||||
return self._run_impl(input, llm, verbose, stream)
|
||||
if self.compute is None:
|
||||
return self._run_impl(input, llm, verbose, stream)
|
||||
# One sandbox for the whole run; torn down even if a step raises.
|
||||
with self._shared_compute() as shared:
|
||||
shared.attach(self._collect_agents())
|
||||
return self._run_impl(input, llm, verbose, stream)
|
||||
finally:
|
||||
self._execution_lock.release()
|
||||
|
||||
def _shared_compute(self):
|
||||
"""Build the SharedCompute for this run (see the ``compute=`` field)."""
|
||||
from ..managed.shared_compute import SharedCompute
|
||||
|
||||
return SharedCompute(self.compute)
|
||||
|
||||
def _collect_agents(self) -> List[Any]:
|
||||
"""Walk steps (including nested route/parallel/loop/repeat/if) for Agents.
|
||||
|
||||
Returns each Agent once, in first-seen order. Anything that is not an
|
||||
Agent -- plain functions, Tasks without an agent -- is ignored.
|
||||
"""
|
||||
found: List[Any] = []
|
||||
seen = set()
|
||||
|
||||
def visit(step: Any) -> None:
|
||||
if step is None:
|
||||
return
|
||||
if isinstance(step, (list, tuple)):
|
||||
for item in step:
|
||||
visit(item)
|
||||
return
|
||||
|
||||
# Nested step containers.
|
||||
if isinstance(step, Route):
|
||||
for branch in step.routes.values():
|
||||
visit(branch)
|
||||
visit(step.default)
|
||||
return
|
||||
if isinstance(step, Parallel):
|
||||
visit(step.steps)
|
||||
return
|
||||
if isinstance(step, Loop):
|
||||
visit(step.step)
|
||||
visit(step.steps)
|
||||
return
|
||||
if isinstance(step, Repeat):
|
||||
visit(step.step)
|
||||
return
|
||||
if isinstance(step, If):
|
||||
visit(step.then_steps)
|
||||
visit(step.else_steps)
|
||||
return
|
||||
|
||||
# A Task carries the agent that will execute it.
|
||||
agent = getattr(step, "agent", None)
|
||||
candidate = agent if agent is not None else step
|
||||
|
||||
# Duck-type an Agent: has chat() and instructions/role.
|
||||
if hasattr(candidate, "chat") and (
|
||||
hasattr(candidate, "instructions") or hasattr(candidate, "role")
|
||||
):
|
||||
if id(candidate) not in seen:
|
||||
seen.add(id(candidate))
|
||||
found.append(candidate)
|
||||
|
||||
visit(self.steps)
|
||||
return found
|
||||
|
||||
def _run_impl(
|
||||
self,
|
||||
input: str = "",
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Tests for the shared-sandbox execution added to AgentFlow / AgentTeam.
|
||||
|
||||
These use a fake ComputeProvider so the suite needs no Docker or cloud
|
||||
credentials. The Docker path is exercised manually; see the PR description.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.managed._compute_bridge import available_providers, resolve_compute
|
||||
from praisonaiagents.managed.shared_compute import (
|
||||
CAPABILITY_PROMPT,
|
||||
SharedCompute,
|
||||
)
|
||||
|
||||
|
||||
class FakeCompute:
|
||||
"""Minimal ComputeProviderProtocol stand-in that records what it ran."""
|
||||
|
||||
provider_name = "fake"
|
||||
|
||||
def __init__(self):
|
||||
self.commands = []
|
||||
self.provisioned = 0
|
||||
self.shutdowns = []
|
||||
self.files = {}
|
||||
|
||||
async def provision(self, config):
|
||||
self.provisioned += 1
|
||||
self.last_config = config
|
||||
|
||||
class _Info:
|
||||
instance_id = f"fake_{self.provisioned}"
|
||||
|
||||
return _Info()
|
||||
|
||||
async def execute(self, instance_id, command, timeout=None):
|
||||
self.commands.append((instance_id, command))
|
||||
if command.startswith("cat ") and "<<" not in command:
|
||||
path = command.split(" ", 1)[1].strip("'\"")
|
||||
if path in self.files:
|
||||
return {"stdout": self.files[path], "stderr": "", "exit_code": 0}
|
||||
return {"stdout": "", "stderr": f"cat: {path}: No such file", "exit_code": 1}
|
||||
if "cat > " in command and " <<'" in command:
|
||||
# Real form: mkdir -p ... && cat > 'PATH' <<'DELIM'\nBODY\nDELIM
|
||||
# DELIM is randomised per write, so parse it out of the command.
|
||||
path = command.split("cat > ", 1)[1].split(" <<", 1)[0].strip("'\"")
|
||||
delimiter = command.split(" <<'", 1)[1].split("'\n", 1)[0]
|
||||
body = command.split(f"<<'{delimiter}'\n", 1)[1]
|
||||
body = body.rsplit(f"\n{delimiter}", 1)[0]
|
||||
self.files[path] = body
|
||||
return {"stdout": "", "stderr": "", "exit_code": 0}
|
||||
return {"stdout": "ok", "stderr": "", "exit_code": 0}
|
||||
|
||||
async def shutdown(self, instance_id):
|
||||
self.shutdowns.append(instance_id)
|
||||
|
||||
|
||||
class FakeAgent:
|
||||
"""Duck-typed Agent: what _collect_agents and attach() look for."""
|
||||
|
||||
def __init__(self, name, tools=None, backend=None):
|
||||
self.name = name
|
||||
self.instructions = f"You are {name}."
|
||||
self.backstory = f"You are {name}."
|
||||
self.role = name
|
||||
self.tools = list(tools or [])
|
||||
self.backend = backend
|
||||
|
||||
def chat(self, *a, **k): # pragma: no cover - presence is what matters
|
||||
return ""
|
||||
|
||||
|
||||
# ── bridge ───────────────────────────────────────────────────────────────────
|
||||
def test_resolve_compute_passthrough_and_errors():
|
||||
fake = FakeCompute()
|
||||
assert resolve_compute(fake) is fake # instance passes through
|
||||
assert resolve_compute(None) is None
|
||||
with pytest.raises(ValueError):
|
||||
resolve_compute("not-a-provider")
|
||||
with pytest.raises(TypeError):
|
||||
resolve_compute(object()) # not a provider, not a name
|
||||
|
||||
|
||||
def test_known_provider_names_listed():
|
||||
for name in ("docker", "e2b", "modal", "daytona", "flyio", "local"):
|
||||
assert name in available_providers()
|
||||
|
||||
|
||||
# ── SharedCompute ────────────────────────────────────────────────────────────
|
||||
def test_provisioning_is_lazy_and_single():
|
||||
fake = FakeCompute()
|
||||
sc = SharedCompute(fake)
|
||||
assert fake.provisioned == 0, "must not provision until a tool is used"
|
||||
|
||||
ec, _, _, _ = sc.build_tools()
|
||||
ec("echo one")
|
||||
ec("echo two")
|
||||
assert fake.provisioned == 1, "one instance shared across calls"
|
||||
assert len({inst for inst, _ in fake.commands}) == 1
|
||||
|
||||
|
||||
def test_shutdown_tears_down_and_is_idempotent():
|
||||
fake = FakeCompute()
|
||||
sc = SharedCompute(fake)
|
||||
sc.build_tools()[0]("echo hi")
|
||||
instance = sc.instance_id
|
||||
|
||||
sc.shutdown()
|
||||
assert fake.shutdowns == [instance]
|
||||
assert sc.instance_id is None
|
||||
sc.shutdown() # second call must not raise or double-shutdown
|
||||
assert fake.shutdowns == [instance]
|
||||
|
||||
|
||||
def test_context_manager_tears_down_on_exception():
|
||||
fake = FakeCompute()
|
||||
with pytest.raises(RuntimeError):
|
||||
with SharedCompute(fake) as sc:
|
||||
sc.build_tools()[0]("echo hi")
|
||||
raise RuntimeError("step blew up")
|
||||
assert fake.shutdowns, "sandbox must be released even when a step raises"
|
||||
|
||||
|
||||
def test_write_then_read_shares_state():
|
||||
fake = FakeCompute()
|
||||
with SharedCompute(fake) as sc:
|
||||
_, read_file, write_file, _ = sc.build_tools()
|
||||
write_file("/workspace/a.txt", "SECRET-42")
|
||||
assert read_file("/workspace/a.txt") == "SECRET-42"
|
||||
|
||||
|
||||
def test_concurrent_first_use_provisions_once():
|
||||
"""Parallel first-use calls must share one sandbox, not race into several."""
|
||||
import threading
|
||||
|
||||
class SlowCompute(FakeCompute):
|
||||
async def provision(self, config):
|
||||
import time
|
||||
time.sleep(0.05) # widen the race window
|
||||
return await super().provision(config)
|
||||
|
||||
fake = SlowCompute()
|
||||
sc = SharedCompute(fake)
|
||||
ec, _, _, _ = sc.build_tools()
|
||||
|
||||
barrier = threading.Barrier(8)
|
||||
|
||||
def hit():
|
||||
barrier.wait()
|
||||
ec("echo hi")
|
||||
|
||||
threads = [threading.Thread(target=hit) for _ in range(8)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert fake.provisioned == 1, "concurrent first use must provision exactly one sandbox"
|
||||
assert len({inst for inst, _ in fake.commands}) == 1
|
||||
|
||||
|
||||
def test_write_file_content_containing_delimiter_token_is_preserved():
|
||||
"""Content that looks like the heredoc terminator must not truncate/execute."""
|
||||
fake = FakeCompute()
|
||||
with SharedCompute(fake) as sc:
|
||||
_, read_file, write_file, _ = sc.build_tools()
|
||||
payload = "line1\n__PRAISON_EOF__\nrm -rf /\nline4"
|
||||
write_file("/workspace/danger.txt", payload)
|
||||
assert read_file("/workspace/danger.txt") == payload
|
||||
|
||||
|
||||
def test_result_is_never_empty_string():
|
||||
"""An empty tool result reads as 'no progress' and trips the loop guard."""
|
||||
fake = FakeCompute()
|
||||
sc = SharedCompute(fake)
|
||||
assert sc._format({"stdout": "", "stderr": "", "exit_code": 0}) != ""
|
||||
assert "Error" in sc._format({"stdout": "", "stderr": "boom", "exit_code": 1})
|
||||
|
||||
|
||||
# ── attach ───────────────────────────────────────────────────────────────────
|
||||
def test_attach_adds_tools_and_capability_prompt():
|
||||
fake = FakeCompute()
|
||||
agent = FakeAgent("Writer")
|
||||
with SharedCompute(fake) as sc:
|
||||
sc.attach([agent])
|
||||
assert [t.__name__ for t in agent.tools] == [
|
||||
"execute_command", "read_file", "write_file", "list_files",
|
||||
]
|
||||
# Patched on backstory, not instructions: Agent builds its system
|
||||
# prompt from role/goal/backstory, so .instructions would be ignored.
|
||||
assert CAPABILITY_PROMPT in agent.backstory
|
||||
assert CAPABILITY_PROMPT not in agent.backstory, "must restore on shutdown"
|
||||
assert agent.tools == []
|
||||
|
||||
|
||||
def test_attach_skips_agents_with_their_own_backend():
|
||||
fake = FakeCompute()
|
||||
managed = FakeAgent("HasBackend", backend=object())
|
||||
with SharedCompute(fake) as sc:
|
||||
sc.attach([managed])
|
||||
assert managed.tools == [], "agent with its own backend must be left alone"
|
||||
|
||||
|
||||
def test_attach_replaces_same_named_tools_but_keeps_others():
|
||||
def execute_command(command: str) -> str:
|
||||
return "LOCAL"
|
||||
|
||||
def custom_tool(x: str) -> str:
|
||||
return x
|
||||
|
||||
fake = FakeCompute()
|
||||
agent = FakeAgent("A", tools=[execute_command, custom_tool])
|
||||
with SharedCompute(fake) as sc:
|
||||
sc.attach([agent])
|
||||
names = [t.__name__ for t in agent.tools]
|
||||
assert names.count("execute_command") == 1
|
||||
assert "custom_tool" in names, "unrelated tools must survive"
|
||||
ec = [t for t in agent.tools if t.__name__ == "execute_command"][0]
|
||||
assert ec("hostname") != "LOCAL", "must be the remote-bound tool"
|
||||
|
||||
|
||||
# ── AgentFlow wiring ─────────────────────────────────────────────────────────
|
||||
def test_collect_agents_walks_nested_steps_and_dedupes():
|
||||
from praisonaiagents import AgentFlow
|
||||
from praisonaiagents.workflows import parallel, repeat, route, when
|
||||
|
||||
a, b = FakeAgent("A"), FakeAgent("B")
|
||||
flow = AgentFlow(steps=[
|
||||
a,
|
||||
route({"x": [b], "default": [a]}),
|
||||
parallel([a, b]),
|
||||
repeat(b, until=lambda ctx: True),
|
||||
when("{{x}} > 1", [a], [b]),
|
||||
lambda ctx: "plain function is not an agent",
|
||||
])
|
||||
collected = flow._collect_agents()
|
||||
assert [x.name for x in collected] == ["A", "B"], "each agent exactly once, in order"
|
||||
|
||||
|
||||
def test_flow_without_compute_does_not_provision():
|
||||
from praisonaiagents import AgentFlow
|
||||
|
||||
flow = AgentFlow(steps=[FakeAgent("A")])
|
||||
assert flow.compute is None
|
||||
assert flow._collect_agents()[0].tools == []
|
||||
@@ -152,6 +152,36 @@ class TestConfigConversion:
|
||||
assert "reranker" in knowledge_config
|
||||
assert knowledge_config["reranker"]["enabled"] is True
|
||||
|
||||
def test_to_knowledge_config_with_embedder_config(self):
|
||||
"""Full embedder_config dict is forwarded to mem0 (issue #3941)."""
|
||||
config = RetrievalConfig(
|
||||
embedder_config={
|
||||
"provider": "gemini",
|
||||
"config": {"model": "models/text-embedding-004"},
|
||||
}
|
||||
)
|
||||
|
||||
knowledge_config = config.to_knowledge_config()
|
||||
|
||||
assert knowledge_config["embedder"]["provider"] == "gemini"
|
||||
assert knowledge_config["embedder"]["config"]["model"] == "models/text-embedding-004"
|
||||
|
||||
def test_to_knowledge_config_embedder_provider_shorthand(self):
|
||||
"""embedder_provider acts as a provider shorthand (issue #3941)."""
|
||||
config = RetrievalConfig(embedder_provider="gemini")
|
||||
|
||||
knowledge_config = config.to_knowledge_config()
|
||||
|
||||
assert knowledge_config["embedder"]["provider"] == "gemini"
|
||||
|
||||
def test_to_knowledge_config_no_embedder_by_default(self):
|
||||
"""Default config emits no embedder override (mem0 default is used)."""
|
||||
config = RetrievalConfig()
|
||||
|
||||
knowledge_config = config.to_knowledge_config()
|
||||
|
||||
assert "embedder" not in knowledge_config
|
||||
|
||||
def test_to_rag_config(self):
|
||||
"""Test conversion to RAG pipeline config."""
|
||||
config = RetrievalConfig(
|
||||
@@ -258,6 +288,60 @@ class TestLegacyConfigMigration:
|
||||
assert config.citations_mode == CitationsMode.INLINE
|
||||
|
||||
|
||||
class TestAgentEmbedderForwarding:
|
||||
"""Test KnowledgeConfig embedder reaches Knowledge via the Agent (issue #3941)."""
|
||||
|
||||
def _knowledge_config(self, agent):
|
||||
assert agent._retrieval_config is not None
|
||||
return agent._retrieval_config.to_knowledge_config()
|
||||
|
||||
def test_agent_forwards_embedder_config(self):
|
||||
"""Full embedder_config dict on KnowledgeConfig reaches Knowledge config."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.config.feature_configs import KnowledgeConfig
|
||||
|
||||
agent = Agent(
|
||||
instructions="test",
|
||||
knowledge=KnowledgeConfig(
|
||||
sources=["doc.pdf"],
|
||||
embedder_config={
|
||||
"provider": "gemini",
|
||||
"config": {"model": "models/text-embedding-004"},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
knowledge_config = self._knowledge_config(agent)
|
||||
assert knowledge_config["embedder"]["provider"] == "gemini"
|
||||
assert knowledge_config["embedder"]["config"]["model"] == "models/text-embedding-004"
|
||||
|
||||
def test_agent_forwards_embedder_provider_shorthand(self):
|
||||
"""Provider shorthand on KnowledgeConfig reaches Knowledge config."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.config.feature_configs import KnowledgeConfig
|
||||
|
||||
agent = Agent(
|
||||
instructions="test",
|
||||
knowledge=KnowledgeConfig(sources=["doc.pdf"], embedder="gemini"),
|
||||
)
|
||||
|
||||
knowledge_config = self._knowledge_config(agent)
|
||||
assert knowledge_config["embedder"]["provider"] == "gemini"
|
||||
|
||||
def test_agent_default_embedder_no_override(self):
|
||||
"""Default embedder ("openai") emits no override so mem0 default is kept."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.config.feature_configs import KnowledgeConfig
|
||||
|
||||
agent = Agent(
|
||||
instructions="test",
|
||||
knowledge=KnowledgeConfig(sources=["doc.pdf"]),
|
||||
)
|
||||
|
||||
knowledge_config = self._knowledge_config(agent)
|
||||
assert "embedder" not in knowledge_config
|
||||
|
||||
|
||||
class TestSerialization:
|
||||
"""Test serialization and deserialization."""
|
||||
|
||||
|
||||
@@ -462,6 +462,7 @@ def _run_print_code(
|
||||
scripts/CI/benchmarks a reliable signal — parity with ``run --output json``.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
|
||||
try:
|
||||
from praisonaiagents import Agent
|
||||
@@ -490,35 +491,51 @@ def _run_print_code(
|
||||
except Exception:
|
||||
resolved_session = None
|
||||
|
||||
agent_config = {
|
||||
"name": "CodeAgent",
|
||||
"role": "Code Assistant",
|
||||
"goal": "Help with coding tasks",
|
||||
"verbose": verbose,
|
||||
# A minimal output preset keeps the agent from printing its own
|
||||
# decorations so stdout carries only our envelope.
|
||||
"output": "minimal",
|
||||
}
|
||||
if model:
|
||||
agent_config["llm"] = model
|
||||
|
||||
memory_cfg = None
|
||||
if resolved_session:
|
||||
try:
|
||||
from ..state.project_sessions import build_cli_memory_config
|
||||
|
||||
memory_cfg = build_cli_memory_config(
|
||||
session_id=resolved_session, auto_save=resolved_session
|
||||
)
|
||||
except Exception:
|
||||
memory_cfg = None
|
||||
if memory_cfg is not None:
|
||||
agent_config["memory"] = memory_cfg
|
||||
|
||||
status = "ok"
|
||||
result = None
|
||||
error_message = None
|
||||
try:
|
||||
# Resolve the workspace and load the coding tools *inside* the protected
|
||||
# lifecycle: tool loading spins up the ACP/LSP runtime, which can raise.
|
||||
# Keeping it here guarantees a failure still emits the JSON error
|
||||
# envelope and always runs cleanup_runtime() via the finally block,
|
||||
# instead of aborting before either.
|
||||
workspace = os.environ.get("PRAISONAI_WORKSPACE") or os.getcwd()
|
||||
tool_groups = ["basic", "acp", "edit", "search", "lsp"]
|
||||
code_tools = _get_headless_code_tools(
|
||||
groups=tool_groups,
|
||||
workspace=workspace,
|
||||
)
|
||||
|
||||
agent_config = {
|
||||
"name": "CodeAgent",
|
||||
"role": "Code Assistant",
|
||||
"goal": "Help with coding tasks",
|
||||
"instructions": (
|
||||
"You are a terminal coding assistant. Inspect the workspace and "
|
||||
"use the provided tools for coding tasks. Keep all file "
|
||||
"operations inside the configured workspace."
|
||||
),
|
||||
# A minimal output preset keeps the agent from printing its own
|
||||
# decorations so stdout carries only our envelope.
|
||||
"output": "minimal",
|
||||
"tools": code_tools,
|
||||
}
|
||||
if model:
|
||||
agent_config["llm"] = model
|
||||
|
||||
if resolved_session:
|
||||
try:
|
||||
from ..state.project_sessions import build_cli_memory_config
|
||||
|
||||
memory_cfg = build_cli_memory_config(
|
||||
session_id=resolved_session, auto_save=resolved_session
|
||||
)
|
||||
except Exception:
|
||||
memory_cfg = None
|
||||
if memory_cfg is not None:
|
||||
agent_config["memory"] = memory_cfg
|
||||
|
||||
agent = Agent(**agent_config)
|
||||
if thinking_budget is not None:
|
||||
agent.thinking_budget = thinking_budget
|
||||
@@ -535,6 +552,13 @@ def _run_print_code(
|
||||
except Exception as exc: # noqa: BLE001 - surface any failure via exit code
|
||||
status = "error"
|
||||
error_message = str(exc)
|
||||
finally:
|
||||
try:
|
||||
from praisonai_code.cli.features.interactive_tools import cleanup_runtime
|
||||
|
||||
cleanup_runtime()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if status != "error" and not _print_result_succeeded(result):
|
||||
status = "failed"
|
||||
@@ -571,6 +595,13 @@ def _run_print_code(
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
def _get_headless_code_tools(*, groups: List[str], workspace: str):
|
||||
"""Load the same workspace-bound coding tool groups used by the TUI."""
|
||||
from praisonai_code.cli.features.interactive_tools import get_interactive_tools
|
||||
|
||||
return get_interactive_tools(groups=groups, workspace=workspace)
|
||||
|
||||
|
||||
def _run_profiled_code(
|
||||
prompt: str,
|
||||
model: Optional[str] = None,
|
||||
|
||||
@@ -189,34 +189,99 @@ def resolve_tool_groups(
|
||||
return tool_names
|
||||
|
||||
|
||||
def _load_basic_tools() -> Dict[str, Callable]:
|
||||
"""Lazy load basic tools from praisonaiagents."""
|
||||
def _bind_basic_file_shell_tools(config: ToolConfig) -> Optional[Dict[str, Callable]]:
|
||||
"""Bind the basic file/shell tools to the configured workspace.
|
||||
|
||||
The module-level ``read_file``/``write_file``/``list_files`` helpers and the
|
||||
raw ``execute_command`` are unbound: their fallback path only rejects ``..``
|
||||
traversal and still accepts absolute paths (e.g. ``/etc/hosts``) resolved
|
||||
against the *process* working directory, and ``execute_command`` honours any
|
||||
caller-supplied ``cwd``. Under auto-approval (``--dangerously-skip-approval``)
|
||||
that lets the agent read/write/execute outside ``--workspace``.
|
||||
|
||||
Mirror the fail-closed containment already used by the ``edit`` group: bind
|
||||
``FileTools`` to a ``Workspace`` so relative and absolute paths resolve
|
||||
*inside* the workspace, and force ``execute_command`` to run with its ``cwd``
|
||||
pinned to the workspace root. Returns ``None`` when containment is
|
||||
unavailable so the caller can fall back to the unbound tools (manual
|
||||
approval still guards them).
|
||||
"""
|
||||
try:
|
||||
from pathlib import Path
|
||||
from praisonaiagents.workspace import Workspace # type: ignore
|
||||
from praisonaiagents.tools.file_tools import FileTools
|
||||
from praisonaiagents.tools.shell_tools import ShellTools
|
||||
except ImportError as e:
|
||||
logger.debug(f"Workspace-bound basic tools unavailable: {e}")
|
||||
return None
|
||||
|
||||
try:
|
||||
workspace = Workspace(root=Path(config.workspace))
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Basic tools not workspace-bound: containment unavailable: {e}"
|
||||
)
|
||||
return None
|
||||
|
||||
file_tools = FileTools(workspace=workspace)
|
||||
shell_tools = ShellTools()
|
||||
workspace_root = str(Path(config.workspace).resolve())
|
||||
|
||||
bound: Dict[str, Callable] = {
|
||||
"read_file": file_tools.read_file,
|
||||
"write_file": file_tools.write_file,
|
||||
"list_files": file_tools.list_files,
|
||||
}
|
||||
|
||||
raw_execute = shell_tools.execute_command
|
||||
|
||||
def execute_command(command: str, timeout: int = 30, **kwargs):
|
||||
"""Execute a shell command with its working directory pinned to the
|
||||
configured workspace so it cannot escape ``--workspace``."""
|
||||
kwargs.pop("cwd", None)
|
||||
return raw_execute(command, cwd=workspace_root, timeout=timeout, **kwargs)
|
||||
|
||||
execute_command.__name__ = "execute_command"
|
||||
bound["execute_command"] = execute_command
|
||||
return bound
|
||||
|
||||
|
||||
def _load_basic_tools(config: Optional[ToolConfig] = None) -> Dict[str, Callable]:
|
||||
"""Lazy load basic tools from praisonaiagents.
|
||||
|
||||
When ``config`` carries a workspace, the file and shell tools are bound to
|
||||
it (fail-closed) so an auto-approved headless run stays inside ``--workspace``.
|
||||
"""
|
||||
tools = {}
|
||||
|
||||
try:
|
||||
from praisonaiagents.tools import read_file
|
||||
tools["read_file"] = read_file
|
||||
except ImportError:
|
||||
logger.debug("read_file not available")
|
||||
|
||||
try:
|
||||
from praisonaiagents.tools import write_file
|
||||
tools["write_file"] = write_file
|
||||
except ImportError:
|
||||
logger.debug("write_file not available")
|
||||
|
||||
try:
|
||||
from praisonaiagents.tools import list_files
|
||||
tools["list_files"] = list_files
|
||||
except ImportError:
|
||||
logger.debug("list_files not available")
|
||||
|
||||
try:
|
||||
from praisonaiagents.tools import execute_command
|
||||
tools["execute_command"] = execute_command
|
||||
except ImportError:
|
||||
logger.debug("execute_command not available")
|
||||
|
||||
|
||||
bound = _bind_basic_file_shell_tools(config) if config is not None else None
|
||||
if bound is not None:
|
||||
tools.update(bound)
|
||||
else:
|
||||
try:
|
||||
from praisonaiagents.tools import read_file
|
||||
tools["read_file"] = read_file
|
||||
except ImportError:
|
||||
logger.debug("read_file not available")
|
||||
|
||||
try:
|
||||
from praisonaiagents.tools import write_file
|
||||
tools["write_file"] = write_file
|
||||
except ImportError:
|
||||
logger.debug("write_file not available")
|
||||
|
||||
try:
|
||||
from praisonaiagents.tools import list_files
|
||||
tools["list_files"] = list_files
|
||||
except ImportError:
|
||||
logger.debug("list_files not available")
|
||||
|
||||
try:
|
||||
from praisonaiagents.tools import execute_command
|
||||
tools["execute_command"] = execute_command
|
||||
except ImportError:
|
||||
logger.debug("execute_command not available")
|
||||
|
||||
try:
|
||||
from praisonaiagents.tools import internet_search
|
||||
tools["internet_search"] = internet_search
|
||||
@@ -550,9 +615,10 @@ def get_interactive_tools(
|
||||
# Load tools by group
|
||||
all_tools: Dict[str, Callable] = {}
|
||||
|
||||
# Load basic tools (always available, no runtime needed)
|
||||
# Load basic tools (always available, no runtime needed). Pass the config so
|
||||
# the file/shell tools bind to the configured workspace (fail-closed).
|
||||
if config.enable_basic and not (disable and "basic" in disable):
|
||||
basic_tools = _load_basic_tools()
|
||||
basic_tools = _load_basic_tools(config)
|
||||
all_tools.update(basic_tools)
|
||||
|
||||
# Load fast search tools (always available, no runtime needed)
|
||||
|
||||
@@ -14,6 +14,7 @@ scripts, CI, and benchmark harnesses. These tests assert the new `-p/--print` +
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
@@ -95,6 +96,111 @@ def test_code_print_defaults_to_json(monkeypatch):
|
||||
assert payload["result"] == "ok result"
|
||||
|
||||
|
||||
def test_code_print_uses_supported_agent_kwargs_and_workspace_tools(monkeypatch, tmp_path):
|
||||
"""The real constructor contract and coding-tool wiring must stay aligned."""
|
||||
import inspect
|
||||
import praisonaiagents
|
||||
from praisonaiagents.agent.agent import Agent as RealAgent
|
||||
|
||||
captured = {}
|
||||
|
||||
class _SpyAgent:
|
||||
def __init__(self, **kwargs):
|
||||
captured.update(kwargs)
|
||||
|
||||
def start(self, *_args, **_kwargs):
|
||||
return "ok"
|
||||
|
||||
def fake_tools(*, groups, workspace):
|
||||
captured["tool_groups"] = groups
|
||||
captured["workspace"] = workspace
|
||||
return [lambda: None]
|
||||
|
||||
monkeypatch.setattr(praisonaiagents, "Agent", _SpyAgent, raising=False)
|
||||
monkeypatch.setattr(code_module, "_get_headless_code_tools", fake_tools)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
app,
|
||||
["-p", "--workspace", str(tmp_path), "inspect the workspace"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
real_params = set(inspect.signature(RealAgent.__init__).parameters)
|
||||
agent_keys = set(captured) - {"tool_groups", "workspace"}
|
||||
assert agent_keys <= real_params
|
||||
assert "verbose" not in captured
|
||||
assert captured["output"] == "minimal"
|
||||
assert captured["tools"]
|
||||
assert captured["workspace"] == str(tmp_path)
|
||||
assert captured["tool_groups"] == ["basic", "acp", "edit", "search", "lsp"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("RUN_REAL_KEY_TESTS", "").lower() not in {"1", "true", "yes"}
|
||||
or not os.environ.get("OPENAI_API_KEY"),
|
||||
reason="Real OpenAI test disabled",
|
||||
)
|
||||
def test_code_print_real_agent_writes_workspace(tmp_path):
|
||||
"""Run the real headless coding agent and exercise a workspace write."""
|
||||
target = tmp_path / "headless_probe.txt"
|
||||
|
||||
result = CliRunner().invoke(
|
||||
app,
|
||||
[
|
||||
"-p",
|
||||
"--output",
|
||||
"json",
|
||||
"--model",
|
||||
"gpt-4o-mini",
|
||||
"--workspace",
|
||||
str(tmp_path),
|
||||
# CliRunner has no interactive approval path, so skip the prompt or
|
||||
# the write tool would hang/deny and never create the probe file.
|
||||
"--dangerously-skip-approval",
|
||||
f"Create {target.name} containing exactly HEADLESS_OK.",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.stdout.strip())
|
||||
assert payload["status"] == "ok"
|
||||
assert target.read_text(encoding="utf-8").strip() == "HEADLESS_OK"
|
||||
|
||||
|
||||
def test_headless_basic_tools_are_workspace_bound(tmp_path):
|
||||
"""The headless `basic` group must stay inside `--workspace`.
|
||||
|
||||
Under `--dangerously-skip-approval` these tools run unguarded, so an
|
||||
absolute path outside the workspace must be rejected rather than written —
|
||||
matching the fail-closed containment used by the `edit` group.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ["PRAISON_APPROVAL_MODE"] = "auto"
|
||||
os.environ["PRAISONAI_TOOL_SAFETY"] = "off"
|
||||
from praisonaiagents.approval import add_yaml_approved_tools
|
||||
|
||||
add_yaml_approved_tools(["write_file", "read_file", "list_files"])
|
||||
|
||||
from praisonai_code.cli.features.interactive_tools import (
|
||||
ToolConfig,
|
||||
_load_basic_tools,
|
||||
)
|
||||
|
||||
tools = _load_basic_tools(ToolConfig(workspace=str(tmp_path)))
|
||||
write_file = tools["write_file"]
|
||||
|
||||
assert write_file("inside.txt", "hello") is True
|
||||
assert (tmp_path / "inside.txt").read_text() == "hello"
|
||||
|
||||
escape = tmp_path.parent / "escape_probe.txt"
|
||||
if escape.exists():
|
||||
escape.unlink()
|
||||
assert write_file(str(escape), "escaped") is False
|
||||
assert not escape.exists()
|
||||
assert tools["execute_command"].__name__ == "execute_command"
|
||||
|
||||
|
||||
def test_code_print_text_mode_clean_stdout(monkeypatch):
|
||||
"""`-p --output text` prints just the result text, no envelope or decorations."""
|
||||
import praisonaiagents
|
||||
|
||||
@@ -71,6 +71,16 @@ from dataclasses import dataclass, field, asdict
|
||||
from typing import Dict, List, Optional, Callable, Any
|
||||
from contextlib import contextmanager, asynccontextmanager
|
||||
|
||||
# Reuse the shared profiling primitives owned by the core SDK
|
||||
# (praisonaiagents.profiling) so there is a single source of truth for
|
||||
# TimingRecord / StreamingRecord / StreamingTracker. The wrapper extends them
|
||||
# below with its superset fields/behaviour instead of re-declaring them.
|
||||
from praisonaiagents.profiling import (
|
||||
TimingRecord as _CoreTimingRecord,
|
||||
StreamingRecord as _CoreStreamingRecord,
|
||||
StreamingTracker as _CoreStreamingTracker,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Configuration
|
||||
@@ -129,14 +139,17 @@ def _tracemalloc_release(owned: bool) -> None:
|
||||
# ============================================================================
|
||||
|
||||
@dataclass
|
||||
class TimingRecord:
|
||||
"""Record of a single timing measurement."""
|
||||
name: str
|
||||
duration_ms: float
|
||||
class TimingRecord(_CoreTimingRecord):
|
||||
"""Record of a single timing measurement.
|
||||
|
||||
Extends the core SDK's ``TimingRecord`` (single owner in
|
||||
``praisonaiagents.profiling``) with the wrapper-only ``file``/``line``
|
||||
fields and a ``function`` default category. Defaults keep both existing
|
||||
test suites and consumers green.
|
||||
"""
|
||||
category: str = "function"
|
||||
file: str = ""
|
||||
line: int = 0
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -152,14 +165,14 @@ class APICallRecord:
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamingRecord:
|
||||
"""Record of streaming operation (LLM responses)."""
|
||||
name: str
|
||||
ttft_ms: float # Time to first token
|
||||
total_ms: float
|
||||
chunk_count: int = 0
|
||||
class StreamingRecord(_CoreStreamingRecord):
|
||||
"""Record of streaming operation (LLM responses).
|
||||
|
||||
Extends the core SDK's ``StreamingRecord`` with the wrapper-only
|
||||
``total_tokens`` field; the shared TTFT schema lives in
|
||||
``praisonaiagents.profiling``.
|
||||
"""
|
||||
total_tokens: int = 0
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -195,10 +208,14 @@ class FlowRecord:
|
||||
# Streaming Tracker
|
||||
# ============================================================================
|
||||
|
||||
class StreamingTracker:
|
||||
class StreamingTracker(_CoreStreamingTracker):
|
||||
"""
|
||||
Track streaming operations (LLM responses).
|
||||
|
||||
|
||||
Extends the core SDK's ``StreamingTracker`` (single owner of the TTFT
|
||||
start/first_token/chunk/end logic) with wrapper-only ``total_tokens``
|
||||
support and records to the wrapper's context-aware ``Profiler``.
|
||||
|
||||
Usage:
|
||||
tracker = StreamingTracker("chat")
|
||||
tracker.start()
|
||||
@@ -207,40 +224,32 @@ class StreamingTracker:
|
||||
tracker.chunk()
|
||||
tracker.end(total_tokens=100)
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
self._start_time: Optional[float] = None
|
||||
self._first_token_time: Optional[float] = None
|
||||
self._end_time: Optional[float] = None
|
||||
self._chunk_count: int = 0
|
||||
super().__init__(name)
|
||||
self._total_tokens: int = 0
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start tracking."""
|
||||
self._start_time = time.perf_counter()
|
||||
|
||||
def first_token(self) -> None:
|
||||
"""Mark time to first token."""
|
||||
if self._first_token_time is None:
|
||||
self._first_token_time = time.perf_counter()
|
||||
|
||||
def chunk(self) -> None:
|
||||
"""Record a chunk received."""
|
||||
self._chunk_count += 1
|
||||
|
||||
self._recorded: bool = False
|
||||
|
||||
def end(self, total_tokens: int = 0) -> None:
|
||||
"""End tracking and record to Profiler."""
|
||||
"""End tracking and record to the wrapper Profiler.
|
||||
|
||||
Idempotent: safe to call explicitly inside a
|
||||
``with Profiler.streaming(...)`` block (whose ``finally`` also calls
|
||||
``end()``); only the first invocation records a StreamingRecord.
|
||||
"""
|
||||
if self._recorded:
|
||||
return
|
||||
self._recorded = True
|
||||
self._end_time = time.perf_counter()
|
||||
self._total_tokens = total_tokens
|
||||
|
||||
|
||||
if self._start_time is not None:
|
||||
ttft_ms = 0.0
|
||||
if self._first_token_time is not None:
|
||||
ttft_ms = (self._first_token_time - self._start_time) * 1000
|
||||
|
||||
|
||||
total_ms = (self._end_time - self._start_time) * 1000
|
||||
|
||||
|
||||
Profiler.record_streaming(
|
||||
name=self.name,
|
||||
ttft_ms=ttft_ms,
|
||||
@@ -248,21 +257,6 @@ class StreamingTracker:
|
||||
chunk_count=self._chunk_count,
|
||||
total_tokens=self._total_tokens
|
||||
)
|
||||
|
||||
@property
|
||||
def ttft_ms(self) -> float:
|
||||
"""Get time to first token in ms."""
|
||||
if self._start_time and self._first_token_time:
|
||||
return (self._first_token_time - self._start_time) * 1000
|
||||
return 0.0
|
||||
|
||||
@property
|
||||
def elapsed_ms(self) -> float:
|
||||
"""Get elapsed time in ms."""
|
||||
if self._start_time:
|
||||
end = self._end_time or time.perf_counter()
|
||||
return (end - self._start_time) * 1000
|
||||
return 0.0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
Reference in new issue
Block a user