Compare commits

...
Author SHA1 Message Date
praisonai-triage-agent[bot]andMervin Praison eb73d528a8 fix(code): bind headless basic tools to workspace, harden lifecycle
- Bind basic file/shell tools (read/write/list/execute) to --workspace via
  Workspace containment (fail-closed), matching the edit group, so an
  auto-approved headless run cannot escape the configured workspace.
- Move workspace resolution + tool loading inside the protected try/finally in
  _run_print_code so init failures still emit the JSON error envelope and always
  run cleanup_runtime().
- Use a private mktemp log in the terminal-bench example instead of a
  predictable /tmp path; drop the stale log_path metadata.
- Add --dangerously-skip-approval to the opt-in real-agent write test and a
  fast regression test for basic-group workspace containment.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Mervin Praison <[email protected]>
2026-08-14 19:52:54 +00:00
dajiaohuang b54289d0d2 fix(code): restore headless coding agent execution 2026-08-15 03:41:54 +08:00
praisonai-triage-agent[bot] 2fb47fbcff Merge pull request #3953 from MervinPraison/claude/issue-3952-20260814-1720
fix: remove dead code _execute_memory_tool_call in llm.py
2026-08-14 18:07:27 +00:00
praisonai-triage-agent[bot] e73e360e9e Merge pull request #3951 from MervinPraison/claude/issue-3950-20260814-1618
fix: wrapper profiler builds on core praisonaiagents.profiling primitives
2026-08-14 17:22:54 +00:00
praisonai-triage-agent[bot]andMervinPraison a5c1ef561c fix: remove dead code _execute_memory_tool_call in llm.py (fixes #3952)
Co-authored-by: MervinPraison <[email protected]>
2026-08-14 17:21:39 +00:00
praisonai-triage-agent[bot]andMervin Praison bf64318bbd fix: make wrapper StreamingTracker.end() idempotent to avoid double-recorded streaming records
Explicitly calling tracker.end(total_tokens=...) inside a
`with Profiler.streaming(...)` block previously recorded twice because the
context manager's finally also calls end(). Guard with a _recorded flag so
only the first invocation records a StreamingRecord.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-14 16:28:51 +00:00
praisonai-triage-agent[bot]andMervinPraison d03e120fca fix: wrapper profiler builds on core praisonaiagents.profiling primitives (fixes #3950)
Import TimingRecord/StreamingRecord/StreamingTracker from
praisonaiagents.profiling and subclass them for the wrapper's superset
fields (file/line, total_tokens) instead of re-declaring them. Core is now
the single owner of the shared primitives; wrapper public API and --profile
flags are unchanged.

Co-authored-by: MervinPraison <[email protected]>
2026-08-14 16:22:27 +00:00
MervinPraison cf47c8f54d docs: auto-update documentation parity trackers [skip ci] 2026-08-14 16:00:38 +00:00
praisonai-triage-agent[bot] 1e0059a30e Merge pull request #3949 from MervinPraison/research/managed-agents-remote-execution
feat(managed): share one remote sandbox across a whole team or workflow
2026-08-14 16:00:06 +00:00
praisonai-triage-agent[bot] 3778469955 Merge pull request #3946 from MervinPraison/claude/issue-3941-20260814-1431
fix: forward KnowledgeConfig embedder into Knowledge (fixes #3941)
2026-08-14 15:37:08 +00:00
MervinPraison db9e59c228 docs: auto-update documentation parity trackers [skip ci] 2026-08-14 15:32:31 +00:00
praisonai-triage-agent[bot] c4fdb39561 Merge pull request #3945 from MervinPraison/claude/issue-3942-20260814-1432
docs: fix module docstrings advertising invalid Agent kwargs
2026-08-14 15:31:57 +00:00
praisonai-triage-agent[bot]andMervin Praison 799e07a520 fix(managed): serialize sandbox provisioning and randomize heredoc delimiter
Fixes two P1 issues from PR review:
- Concurrent first-use tool calls (e.g. parallel steps) could each pass the
  unguarded instance_id check and provision separate sandboxes, splitting the
  run and leaking one instance. ensure_provisioned is now guarded by a lock
  with double-checked locking.
- write_file used a fixed __PRAISON_EOF__ heredoc delimiter, so file content
  containing that line would terminate the heredoc early, truncate the file,
  and execute the remainder as shell commands. The delimiter is now a random
  per-write token with a collision guard.

Adds regression tests for concurrent provisioning and delimiter-bearing
content, and updates the fake provider to parse the randomized delimiter.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-14 15:18:32 +00:00
MervinPraisonandClaude Opus 5 ba793302ca feat(managed): share one remote sandbox across a whole team or workflow
Managed execution was per-Agent only: `backend=` exists on Agent but not on
AgentTeam or AgentFlow, and each LocalAgent(compute=...) provisions its own
isolated instance. A workflow therefore got N sandboxes with no shared
filesystem, so agents could not hand files to each other -- and YAML users
had no path to remote execution at all.

Adds a single `compute=` param to AgentFlow (and its Workflow/Pipeline
aliases) and AgentTeam. One sandbox is provisioned per run and every
participating agent's shell/file tools are bound to it, so a file written by
step 1 is visible to step 2. Orchestration stays local.

  flow = AgentFlow(compute="docker", steps=[writer, reader])
  team = AgentTeam(agents=[w, r], tasks=[...], compute="docker")

Details:
- praisonaiagents/managed/_compute_bridge.py resolves provider names lazily
  from the praisonai wrapper, mirroring sandbox/_sandbox_bridge.py.
- Provisioning is lazy (a run that never touches the sandbox costs nothing)
  and teardown is guaranteed via context manager, even when a step raises.
- Agents that already carry their own `backend=` are left untouched.
- Tools are bound onto `backstory`, not `instructions`: Agent.__init__ copies
  instructions into role/goal/backstory and builds the system prompt from
  those, so patching `.instructions` would be silently ignored.
- The capability prompt is required, not cosmetic: without it models
  routinely refuse to call bridged tools ("I can't execute shell commands"),
  so the sandbox spins up and is never used.
- Tool results are never the empty string; an empty result reads as "no
  progress" and trips the loop guard into a stuck-agent error.

Verified end-to-end against local Docker: writer wrote /workspace/data.txt
and reported container hostname; reader read the same file and reported the
same hostname, with zero leftover containers after teardown.

Adds 12 tests using a fake compute provider (no Docker/cloud needed).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-14 16:12:00 +01:00
Mervin Praison dbfbb7f848 Merge pull request #3948 from MervinPraison/docs/readme-move-demo-gifs
docs(readme): move demo GIFs down above the Langfuse section
2026-08-14 15:48:35 +01:00
MervinPraisonandClaude Opus 5 1567eedd22 docs(readme): move demo GIFs down above the Langfuse section
Moves agentflow.gif and latest_ai_news_and_crawl_each_url_to_find_info.gif
out of the hero and down to the bottom, directly above the Langfuse
Tracing section. The "* export TAVILY_API_KEY=xxxxx" footnote travels
with the news GIF it annotates.

The hero is now: install -> Elon badge -> five-layer stack -> dashboard
screenshot -> ASCII art -> docs badge.

Pure move - no content change (identical line multiset).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-14 15:47:16 +01:00
praisonai-triage-agent[bot]andMervin Praison 77062e34fb test: add Agent-level regression tests for KnowledgeConfig embedder forwarding
Covers the agent.py forwarding boundary (KnowledgeConfig -> retrieval_config
-> Knowledge) that the unit tests previously bypassed. Addresses Greptile
review feedback on #3946.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-14 14:42:44 +00:00
praisonai-triage-agent[bot]andMervin Praison 86d37d9915 docs: make thinking docstring honest about thinking_budget no-op
The previous example advertised assigning `agent.thinking_budget = budget`,
but `_thinking_budget` is stored and never read by the Agent execution
pipeline (verified: no consumer in agent.py or the LLM call path). Point
users to the working `reasoning_steps=True` option and document the
`ThinkingBudget.get_tokens_for_complexity` helper instead.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-14 14:39:51 +00:00
Mervin Praison 69803c4115 Merge pull request #3938 from MervinPraison/docs/five-layer-agent-stack-readme
docs(readme): lead with the five-layer agent stack
2026-08-14 15:39:07 +01:00
praisonai-triage-agent[bot]andMervinPraison 32cefe236b fix: forward KnowledgeConfig embedder into Knowledge (fixes #3941)
KnowledgeConfig.embedder / embedder_config were dropped between Agent
resolution and Knowledge construction, so non-OpenAI embedders silently
fell back to text-embedding-3-small.

Forward them through RetrievalConfig and normalise into mem0's
embedder = {provider, config} shape (embedder acts as provider shorthand).

Co-authored-by: MervinPraison <[email protected]>
2026-08-14 14:35:48 +00:00
praisonai-triage-agent[bot]andMervinPraison 65d470747a docs: fix module docstrings advertising invalid Agent kwargs (fixes #3942)
Agent.__init__ has no **kwargs, so db=/session_id=, thinking_budget=,
and observability= raised TypeError immediately. Update the db, thinking,
and obs module docstrings to the working config-object / property forms.

Co-authored-by: MervinPraison <[email protected]>
2026-08-14 14:35:08 +00:00
praisonai-triage-agent[bot]andMervin Praison cbac721605 docs(readme): correct budget reason, handoff, hosted-loop & session-cmd claims
Fixes four inaccuracies flagged in review of the five-layer stack section:
- Budget stop via ExecutionConfig(on_budget_exceeded="stop") surfaces as
  completion_reason="error" (BudgetExceededError), not "budget_exhausted"
- Default handoff SUMMARY policy truncates to the last few messages verbatim;
  it does not summarise
- ManagedAgent() with no ANTHROPIC_API_KEY falls back to a local loop, not cloud
- `sessions list`/`resume` require agent-id and session-id+prompt arguments

Co-authored-by: Mervin Praison <[email protected]>
2026-08-14 14:27:17 +00:00
MervinPraisonandClaude Opus 5 5ba9c1433e docs(readme): lead with the five-layer agent stack
Add a "Five-Layer Agent Stack" section after "Meet your first Agent",
showing that PraisonAI covers every layer of the agent stack rather than
just multi-agent orchestration:

  1 Prompt   - instructions/role/goal, output=
  2 Context  - memory=, knowledge=, context=
  3 Harness  - tools=, MCP(), approval=
  4 Loop     - execution=ExecutionConfig(...), reflection=, autonomy=
  5 Graph    - AgentFlow + route()/parallel()/repeat()
  + Managed Agents - remote sandbox or fully hosted loop

Includes a concentric-ring diagram (each layer wraps the one inside it),
a summary table, and a short runnable example per layer. Every snippet
was executed against the local source, not just constructed.

Also corrects API claims elsewhere in the README that raise TypeError on
copy-paste, since Agent.__init__ takes no **kwargs:

  handoff=            -> handoffs=[other_agent]
  web_search=         -> web=
  prompt_caching=     -> caching=
  thinking_budget=    -> agent.thinking_budget (property, not a kwarg)
  db=/session_id=     -> memory={"db": ..., "session_id": ...}
  user_id=            -> memory={"user_id": ...}

No existing content removed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-14 15:20:23 +01:00
19 changed files with 1358 additions and 205 deletions

No files matched your search

+188 -20
View File
@@ -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"},
)
```
+31 -14
View File
@@ -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",
}
+15 -15
View File
@@ -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
+47 -53
View File
@@ -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
# ============================================================================