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
554 changed files with 15620 additions and 43886 deletions

No files matched your search

+2 -42
View File
@@ -74,11 +74,7 @@ function isClaudeTriggerNoise(c) {
function isClaudeFinalReplyComment(c) {
const login = (c.user?.login || '').toLowerCase();
const fromAutomation =
login.includes('praisonai-triage') ||
login.includes('github-actions') ||
AUTO_ACTORS.some((a) => a.toLowerCase() === login);
if (!fromAutomation) return false;
if (!login.includes('praisonai-triage')) return false;
return (c.body || '').includes('Claude finished');
}
@@ -100,47 +96,15 @@ function isConflictRebaseTriggerComment(c) {
function isConflictRebaseCompletionComment(c) {
const login = (c.user?.login || '').toLowerCase();
const fromAutomation =
login.includes('praisonai-triage') ||
login.includes('github-actions') ||
AUTO_ACTORS.some((a) => a.toLowerCase() === login);
if (!fromAutomation) return false;
if (!login.includes('praisonai-triage') && !login.includes('github-actions')) return false;
const body = (c.body || '').toLowerCase();
return (
body.includes('rebase complete') ||
body.includes('rebase onto') ||
body.includes('force-pushed') ||
(body.includes('conflict') && body.includes('resolved'))
);
}
/** HEAD landed after conflict rebase but no FINAL @claude since that push. */
function needsFinalReReviewAfterConflictRebase(comments, headPushedAt) {
if (!headPushedAt) return false;
const headTime = new Date(headPushedAt).getTime();
const conflictTriggers = comments.filter(isConflictRebaseTriggerComment);
if (conflictTriggers.length === 0) return false;
const latestConflict = conflictTriggers.reduce((a, b) =>
new Date(a.created_at) > new Date(b.created_at) ? a : b
);
const conflictTime = new Date(latestConflict.created_at).getTime();
if (headTime <= conflictTime) return false;
const rebaseDone = comments.some(
(c) =>
new Date(c.created_at).getTime() > conflictTime && isConflictRebaseCompletionComment(c)
);
if (!rebaseDone) return false;
const finalsAfterHead = comments.filter(
(c) =>
isFinalClaudeTriggerComment(c) &&
new Date(c.created_at).getTime() >= headTime - 60000
);
return finalsAfterHead.length === 0;
}
function conflictRebaseQuiescent(comments, headPushedAt) {
const conflictTriggers = comments.filter(isConflictRebaseTriggerComment);
if (conflictTriggers.length === 0) return true;
@@ -281,9 +245,6 @@ function shouldSkipStaleFinalRecovery(comments, headPushedAt, headPusherLogin =
if (headPusherLogin && isClaudeAutomationLogin(headPusherLogin)) {
return { skip: true, reason: 'head pushed by Claude automation' };
}
if (needsFinalReReviewAfterConflictRebase(comments, headPushedAt)) {
return { skip: false, reason: '' };
}
if (isPushSoonAfterLatestFinal(comments, headPushedAt)) {
return {
skip: true,
@@ -955,7 +916,6 @@ module.exports = {
hasRecentConflictComment,
isConflictRebaseTriggerComment,
isConflictRebaseCompletionComment,
needsFinalReReviewAfterConflictRebase,
conflictRebaseQuiescent,
hasHumanChangesRequested,
hasAnyChangesRequested,
@@ -24,7 +24,6 @@ jobs:
CLAUDE_APP_PRIVATE_KEY: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
GH_TOKEN_VALUE: ${{ secrets.GH_TOKEN }}
DOCS_GITHUB_TOKEN: ${{ secrets.SECRETS_ADMIN_PAT }}
run: |
set -euo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
@@ -35,7 +34,4 @@ jobs:
echo -n "$CLAUDE_APP_PRIVATE_KEY" | gh secret set CLAUDE_APP_PRIVATE_KEY --repo "$TARGET"
echo -n "$CLAUDE_CODE_OAUTH_TOKEN" | gh secret set CLAUDE_CODE_OAUTH_TOKEN --repo "$TARGET"
echo -n "$GH_TOKEN_VALUE" | gh secret set GH_TOKEN --repo "$TARGET"
if [ -n "${DOCS_GITHUB_TOKEN:-}" ]; then
echo -n "$DOCS_GITHUB_TOKEN" | gh secret set DOCS_GITHUB_TOKEN --repo "$TARGET"
fi
echo "Synced CLAUDE_APP_ID, CLAUDE_APP_PRIVATE_KEY, CLAUDE_CODE_OAUTH_TOKEN, GH_TOKEN → $TARGET"
-14
View File
@@ -98,20 +98,6 @@ jobs:
--ignore=tests/unit/cli/test_message_queue.py \
--maxfail=5
- name: Run praisonai-agents subscription-auth smoke tests
env:
PRAISONAI_TEST_TIER: smoke
PRAISONAI_ALLOW_NETWORK: '0'
OPENAI_MODEL_NAME: gpt-4o-mini
OPENAI_API_KEY: 'test-key'
shell: bash
run: |
set -euo pipefail
cd src/praisonai-agents
python -m pytest tests/smoke/test_subscription_auth.py \
tests/unit/auth/test_agent_auth_wiring.py \
-q --tb=line --timeout=60 --maxfail=5
- name: Standalone praisonai-code import smoke
shell: bash
run: |
+12 -74
View File
@@ -130,7 +130,7 @@ Each layer wraps the one inside it. When an agent misbehaves, the layer tells yo
| **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?* | `tools_run_on="docker"` — one shared sandbox for the tools, or `run_on="anthropic"` for the whole agent |
| **⬡ 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?*
@@ -195,6 +195,7 @@ 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)
@@ -235,85 +236,22 @@ The harness is commoditising; **where** the agent executes is the next multiplie
pip install praisonai
```
The simplest way in is `tools_run_on=` — one whole team or workflow shares **one** sandbox, so a file written by step 1 is there for step 2. Thinking stays on your machine:
```python
from praisonaiagents import Agent, AgentFlow
from praisonai import Agent, ManagedAgent, LocalManagedConfig
writer = Agent(name="Writer", instructions="You write files.")
reader = Agent(name="Reader", instructions="You read files.")
# 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)
flow = AgentFlow(tools_run_on="docker", steps=[writer, reader]) # or e2b | modal | daytona | flyio
flow.run("Write 'hello' to /workspace/note.txt, then read it back")
```
Same thing with no Python at all:
```yaml
name: remote-demo
tools_run_on: docker # every step shares one sandbox
agents:
writer: {role: Writer, goal: Write files}
reader: {role: Reader, goal: Read files}
steps:
- agent: writer
action: "Write 'hello' to /workspace/note.txt"
- agent: reader
action: "Read /workspace/note.txt"
```
For a single agent, two words cover it — and they answer different questions:
```python
from praisonaiagents import Agent
# A. Only the TOOLS move. Thinking stays on your machine.
agent = Agent(name="builder", instructions="You build things.",
tools_run_on="docker") # docker | e2b | modal | daytona | flyio
# tenki | sandlock | ssh | novita | subprocess
# B. The WHOLE agent moves — model calls, loop and tools
agent = Agent(name="teacher", instructions="You teach.", run_on="anthropic") # hosted
agent = Agent(name="builder", instructions="You build.", run_on="docker") # self-hosted
# 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")
```
Ask any object where it runs, and it will tell you:
```python
>>> Agent(name="builder", instructions="x", tools_run_on="docker")
Agent(name='builder', thinks_on='this machine', tools_run_on='a Docker container')
>>> agent.where_does_it_run()
Thinking (the AI model calls) happens on this machine.
Tools run on a Docker container.
Your own tools (check_db) still run on this machine -- only shell, file and
code tools move. They read and write this machine's files.
```
Naming a place that cannot do the job is a typo, not a preference, so it says so:
```python
>>> Agent(name="x", instructions="i", run_on="e2b")
TypeError: Agent(run_on='e2b') is not valid: run_on= places the whole agent
-- model calls, loop and tools -- on a managed runtime, and 'e2b' runs
commands but cannot host an agent loop.
To run only the tools there: Agent(tools_run_on='e2b')
```
To run one block of code somewhere else, name the place on that call:
```python
agent.execute_code_sync("print(6 * 7)", run_in="sandlock") # kernel-enforced
```
See what is running and reclaim strays:
```bash
praisonai managed ps # list running sandboxes
praisonai managed stop --all # reclaim them
```
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>"`
+1 -8
View File
@@ -8,18 +8,11 @@ PraisonAI takes security seriously. We appreciate responsible disclosure from re
|---------|----------|-------------|
| `praisonaiagents` | [PyPI](https://pypi.org/project/praisonaiagents/) | `src/praisonai-agents/` |
| `praisonai` | [PyPI](https://pypi.org/project/praisonai/) | `src/praisonai/` |
| `praisonai-platform` | [PyPI](https://pypi.org/project/praisonai-platform/) | `src/praisonai-platform/` |
| `praisonai` | [npm](https://www.npmjs.com/package/praisonai) | `src/praisonai-ts/` |
Report issues against the **specific package** where the vulnerability exists. A platform-only issue does not necessarily affect the core SDK, and vice versa.
### Packages maintained in their own repositories
`praisonai-platform` now lives at
[MervinPraison/PraisonAI-Platform](https://github.com/MervinPraison/PraisonAI-Platform).
Report platform vulnerabilities through
[that repository's advisories](https://github.com/MervinPraison/PraisonAI-Platform/security/advisories/new),
not here — it is versioned and released independently of the SDK.
## In scope
- Authentication, authorisation, and multi-tenant isolation (e.g. workspace boundaries)
+54 -19
View File
@@ -7,7 +7,7 @@ Regenerate with: `praisonai docs api-md --write`
Types:
```python
from praisonaiagents import ContextPolicy, GuardrailResult, HandoffConfig, HandoffCycleError, HandoffDepthError, HandoffError, HandoffInputData, HandoffResult, HandoffTimeoutError, ReflectionOutput, StepResult, ToolResult, ToolValidationError, WorkflowContext
from praisonaiagents import ContextPolicy, GuardrailResult, HandoffConfig, HandoffCycleError, HandoffDepthError, HandoffError, HandoffInputData, HandoffResult, HandoffTimeoutError, ReflectionOutput, StepResult, TaskOutput, ToolResult, ToolValidationError, WorkflowContext
```
Methods:
@@ -16,6 +16,8 @@ Methods:
* <code title="class HandoffConfig">HandoffConfig.<a href="./src/praisonai-agents/praisonaiagents/agent/handoff.py">from_dict</a>(data: Dict[str, Any]) -> 'HandoffConfig'</code>
* <code title="class HandoffConfig">HandoffConfig.<a href="./src/praisonai-agents/praisonaiagents/agent/handoff.py">to_dict</a>() -> Dict[str, Any]</code>
* <code title="class HandoffResult">HandoffResult.<a href="./src/praisonai-agents/praisonaiagents/agent/handoff.py">from_outcome</a>(outcome: AgentRunOutcome, target_agent: Optional[str] = None, source_agent: Optional[str] = None, handoff_depth: int = 0) -> 'HandoffResult'</code>
* <code title="class TaskOutput">TaskOutput.<a href="./src/praisonai-agents/praisonaiagents/main.py">json</a>() -> Optional[str]</code>
* <code title="class TaskOutput">TaskOutput.<a href="./src/praisonai-agents/praisonaiagents/main.py">to_dict</a>() -> dict</code>
* <code title="class ToolResult">ToolResult.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">is_multimodal</a>() -> bool</code>
* <code title="class ToolResult">ToolResult.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">to_dict</a>() -> Dict[str, Any]</code>
@@ -30,7 +32,6 @@ Methods:
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">aclose</a>() -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">add_mcp_server</a>(name: str, mcp: Any) -> Any</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">add_verification</a>(hook: Any) -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">agent_id</a>() -> str</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">analyze_prompt</a>(prompt: str) -> set</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">auto_memory</a>() -> Optional[bool]</code>
@@ -71,7 +72,6 @@ Methods:
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">redo</a>() -> bool</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">refresh_tools</a>() -> List[Any]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">remove_mcp_server</a>(name: str) -> bool</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">remove_verification</a>(name: str) -> bool</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">retrieval_config</a>() -> Optional[Any]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">retrieve</a>(query: str, **kwargs) -> 'ContextPack'</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">rules_manager</a>() -> Optional[Any]</code>
@@ -88,7 +88,6 @@ Methods:
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">thinking_budget</a>(value: Optional[int]) -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">total_cost</a>() -> float</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">undo</a>() -> bool</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">where_does_it_run</a>() -> str</code>
* <code title="class AutoAgents">AutoAgents.<a href="./src/praisonai-agents/praisonaiagents/agents/autoagents.py">astart</a>()</code>
* <code title="class AutoAgents">AutoAgents.<a href="./src/praisonai-agents/praisonaiagents/agents/autoagents.py">start</a>()</code>
* <code title="class AutoRagAgent">AutoRagAgent.<a href="./src/praisonai-agents/praisonaiagents/agents/auto_rag_agent.py">achat</a>(message: str, **kwargs) -> str</code>
@@ -240,12 +239,10 @@ from praisonaiagents import Memory, MemoryBackend, MemoryConfig
Methods:
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">acommit_memory_batch</a>(writes: List[tuple]) -> None</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">build_cache_optimized_context</a>(task_descr: str, user_id: Optional[str] = None, additional: str = '', max_items: int = 3, include_cache_boundary: bool = True, include_in_output: Optional[bool] = None) -> Dict[str, str]</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">build_context_for_task</a>(task_descr: str, user_id: Optional[str] = None, additional: str = '', max_items: int = 3, include_in_output: Optional[bool] = None) -> str</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">calculate_quality_metrics</a>(output: str, expected_output: str, llm: Optional[str] = None, custom_prompt: Optional[str] = None) -> Dict[str, float]</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">close_connections</a>()</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">commit_memory_batch</a>(writes: List[tuple]) -> None</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">compute_quality_score</a>(completeness: float, relevance: float, clarity: float, accuracy: float, weights: Dict[str, float] = None) -> float</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">delete_long_term</a>(memory_id: str) -> bool</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">delete_memories</a>(memory_ids: List[str]) -> int</code>
@@ -255,7 +252,6 @@ Methods:
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">finalize_task_output</a>(content: str, agent_name: str, quality_score: float, threshold: float = 0.7, metrics: Dict[str, Any] = None, task_id: str = None)</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">forget</a>(**kwargs) -> int</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">get_all_memories</a>() -> List[Dict[str, Any]]</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">get_context</a>(query: Optional[str] = None, **kwargs) -> str</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">get_learn_context</a>() -> str</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">learn</a>()</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">recall</a>(query: str, **kwargs) -> List[Dict[str, Any]]</code>
@@ -468,7 +464,7 @@ Methods:
Types:
```python
from praisonaiagents import SkillLoader, SkillsConfig
from praisonaiagents import SkillLoader, SkillManager, SkillMetadata, SkillProperties, SkillsConfig
```
Methods:
@@ -480,6 +476,55 @@ Methods:
* <code title="class SkillLoader">SkillLoader.<a href="./src/praisonai-agents/praisonaiagents/skills/loader.py">load_metadata</a>(skill_path: str) -> Optional[LoadedSkill]</code>
* <code title="class SkillLoader">SkillLoader.<a href="./src/praisonai-agents/praisonaiagents/skills/loader.py">load_references</a>(skill: LoadedSkill) -> dict</code>
* <code title="class SkillLoader">SkillLoader.<a href="./src/praisonai-agents/praisonaiagents/skills/loader.py">load_scripts</a>(skill: LoadedSkill) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">activate</a>(skill: LoadedSkill) -> bool</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">activate_by_name</a>(name: str) -> bool</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">add_bundle</a>(manifest: 'BundleManifest') -> None</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">add_skill</a>(skill_path: str) -> Optional[LoadedSkill]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">add_skill_by_name</a>(name: str, skill_dirs: Optional[List[str]] = None, include_defaults: bool = True) -> Optional[LoadedSkill]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">approve</a>(identifier: str) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">archive_skill</a>(name: str) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">bundle_names</a>() -> List[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">bundles</a>() -> List['BundleManifest']</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">clear</a>() -> None</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">create</a>(name: str, content: str, category: Optional[str] = None, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">create_skill</a>(name: str, content: str, category: str = None, agent_created: bool = True, propose: Optional[bool] = None, base_dir: Optional[str] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">delete</a>(name: str, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">delete_skill</a>(name: str, hard: bool = False, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">discover</a>(skill_dirs: Optional[List[str]] = None, include_defaults: bool = True) -> int</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">discover_bundles</a>(skill_dirs: Optional[List[str]] = None, include_defaults: bool = True) -> int</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">edit</a>(name: str, content: str, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">edit_skill</a>(name: str, content: str, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_allowed_tools</a>(name: str) -> List[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_available_skills</a>() -> List[SkillMetadata]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_available_skills_by_state</a>(state: SkillState) -> List[LoadedSkill]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_bundle</a>(name: str) -> Optional['BundleManifest']</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_bundle_instructions</a>() -> List[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_instructions</a>(name: str) -> Optional[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_skill</a>(name: str) -> Optional[LoadedSkill]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_skills_diagnostics</a>() -> Dict[str, ValidationResult]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_user_invocable_skills</a>() -> List[LoadedSkill]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">invoke</a>(name: str, raw_args: str = '', session_id: Optional[str] = None, shell_exec: bool = False) -> Optional[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">list_archived_skills</a>() -> List[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">list_pending</a>() -> List[dict]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">load_resources</a>(name: str) -> bool</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">patch</a>(name: str, old_string: str, new_string: str, file_path: Optional[str] = None, replace_all: bool = False, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">patch_skill</a>(name: str, old_string: str, new_string: str, file_path: str = None, replace_all: bool = False, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">reject</a>(identifier: str) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">reload</a>() -> Dict[str, List[str]]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">remove_file</a>(name: str, file_path: str, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">remove_skill_file</a>(name: str, file_path: str, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">resolve</a>(selectors: List[str]) -> List[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">restore_skill</a>(name: str, skill_dir: Optional[str] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">rollback_skill</a>(name: str) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">skill_names</a>() -> List[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">skills</a>() -> List[LoadedSkill]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">to_prompt</a>() -> str</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">validate_skill_capabilities</a>(skill_name: str, force_refresh: bool = False) -> ValidationResult</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">write_file</a>(name: str, file_path: str, file_content: str, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">write_skill_file</a>(name: str, file_path: str, file_content: str, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillMetadata">SkillMetadata.<a href="./src/praisonai-agents/praisonaiagents/skills/models.py">from_properties</a>(props: SkillProperties) -> 'SkillMetadata'</code>
* <code title="class SkillProperties">SkillProperties.<a href="./src/praisonai-agents/praisonaiagents/skills/models.py">idle_days</a>() -> Optional[float]</code>
* <code title="class SkillProperties">SkillProperties.<a href="./src/praisonai-agents/praisonaiagents/skills/models.py">to_dict</a>() -> dict</code>
* <code title="class SkillsConfig">SkillsConfig.<a href="./src/praisonai-agents/praisonaiagents/config/feature_configs.py">to_dict</a>() -> Dict[str, Any]</code>
# Session
@@ -712,7 +757,6 @@ Methods:
* <code title="class AgentFlow">AgentFlow.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">to_dict</a>() -> Dict[str, Any]</code>
* <code title="class AgentFlow">AgentFlow.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">verbose</a>() -> bool</code>
* <code title="class AgentFlow">AgentFlow.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">verbose</a>(value: bool)</code>
* <code title="class AgentFlow">AgentFlow.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">where_does_it_run</a>() -> str</code>
* <code title="class AgentOSProtocol">AgentOSProtocol.<a href="./src/praisonai-agents/praisonaiagents/app/protocols.py">get_app</a>() -> Any</code>
* <code title="class AgentOSProtocol">AgentOSProtocol.<a href="./src/praisonai-agents/praisonaiagents/app/protocols.py">serve</a>(host: Optional[str] = None, port: Optional[int] = None, reload: bool = False, **kwargs: Any) -> None</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">aannounce_completion</a>(agent_id: str, task_id: str, result: Any, success: bool = True, error: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> None</code>
@@ -749,7 +793,7 @@ Methods:
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">get_token_usage_summary</a>() -> Dict[str, Any]</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">has_state</a>(key: str) -> bool</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">increment_state</a>(key: str, amount: float = 1, default: float = 0) -> float</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">launch</a>(path: str = '/agents', port: int = 8000, host: str = '127.0.0.1', debug: bool = False, protocol: str = 'http')</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">launch</a>(path: str = '/agents', port: int = 8000, host: str = '0.0.0.0', debug: bool = False, protocol: str = 'http')</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">restore_session_state</a>(session_id: str) -> bool</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">run</a>(content = None, return_dict = False, **kwargs)</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">run_all_tasks</a>()</code>
@@ -765,7 +809,6 @@ Methods:
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">update_plan_step_status</a>(step_id: str, status: str) -> bool</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">update_state</a>(updates: Dict) -> None</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">wait_for_completions</a>(timeout: Optional[float] = None, agent_ids: Optional[List[str]] = None) -> List[SubAgentCompletionEvent]</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">where_does_it_run</a>() -> str</code>
* <code title="class AutoApproveBackend">AutoApproveBackend.<a href="./src/praisonai-agents/praisonaiagents/approval/backends.py">request_approval</a>(request: ApprovalRequest) -> ApprovalDecision</code>
* <code title="class AutoApproveBackend">AutoApproveBackend.<a href="./src/praisonai-agents/praisonaiagents/approval/backends.py">request_approval_sync</a>(request: ApprovalRequest) -> ApprovalDecision</code>
* <code title="class RunOutcome">RunOutcome.<a href="./src/praisonai-agents/praisonaiagents/agent/run_outcome.py">completed</a>(output: Optional[str] = None) -> 'RunOutcome'</code>
@@ -847,13 +890,11 @@ Methods:
* <code title="cli">praisonai managed list <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed managed-callback <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed multi <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed ps <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed restore <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed resume <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed run <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed save <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed show <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed stop <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed update <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai n8n export <a href="./src/praisonai/praisonai/cli/commands/n8n.py">--help</a></code>
* <code title="cli">praisonai n8n import <a href="./src/praisonai/praisonai/cli/commands/n8n.py">--help</a></code>
@@ -883,7 +924,6 @@ Methods:
* <code title="cli">praisonai recipe list <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
* <code title="cli">praisonai recipe optimize <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
* <code title="cli">praisonai recipe run <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
* <code title="cli">praisonai recipe runs <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
* <code title="cli">praisonai recipe serve <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
* <code title="cli">praisonai replay cleanup <a href="./src/praisonai/praisonai/cli/commands/replay.py">--help</a></code>
* <code title="cli">praisonai replay context <a href="./src/praisonai/praisonai/cli/commands/replay.py">--help</a></code>
@@ -900,11 +940,7 @@ Methods:
* <code title="cli">praisonai schedule describe <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule list <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule logs <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule pause <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule remove <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule restart <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule resume <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule runs <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule schedule-callback <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule start <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule stats <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
@@ -913,7 +949,6 @@ Methods:
* <code title="cli">praisonai schedule suggestion-dismiss <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule suggestion-propose <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule suggestions <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule update <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai validate check <a href="./src/praisonai/praisonai/cli/commands/validate.py">--help</a></code>
* <code title="cli">praisonai validate schema <a href="./src/praisonai/praisonai/cli/commands/validate.py">--help</a></code>
* <code title="cli">praisonai validate validate <a href="./src/praisonai/praisonai/cli/commands/validate.py">--help</a></code>
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.7.1" \
"praisonai>=4.6.162" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.7.1" \
"praisonai>=4.6.162" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.7.1" \
"praisonai>=4.6.162" \
"praisonai[ui]" \
"praisonai[crewai]"
@@ -1,110 +0,0 @@
"""
HOL Guard Tool Policy Example - PraisonAI Agents
Demonstrates using HOL Guard (an optional, external runtime security engine)
at the existing `wrap_tool_call` middleware boundary to inspect command-bearing
tool calls *before* they execute.
HOL Guard is installed and versioned separately. Its side-effect-free CLI can
classify a shell command before execution:
hol-guard command test '<command>' --json
Fail-closed policy: the wrapped tool only runs when Guard returns an explicit
allow/benign classification. Review/block/unknown verdicts, malformed output,
timeouts, or CLI failures all short-circuit the call so the tool never runs.
pip install hol-guard # optional, external dependency
"""
import json
import shutil
import subprocess
from praisonaiagents import Agent, tool
from praisonaiagents.hooks import wrap_tool_call, ToolRequest, ToolResponse
COMMAND_BEARING_TOOLS = {"run_shell"}
GUARD_TIMEOUT_SECONDS = 10
@tool
def run_shell(command: str) -> str:
"""Run a shell command and return its output."""
result = subprocess.run(
command, shell=True, capture_output=True, text=True
)
return result.stdout or result.stderr
def _inspect_with_hol_guard(command: str) -> bool:
"""Return True only if HOL Guard explicitly allows the command.
Fails closed (returns False) on any ambiguity: missing CLI, non-zero exit,
timeout, malformed JSON, or a non-allow verdict.
"""
if not shutil.which("hol-guard"):
return False
try:
completed = subprocess.run(
["hol-guard", "command", "test", command, "--json"],
capture_output=True,
text=True,
timeout=GUARD_TIMEOUT_SECONDS,
)
except (subprocess.TimeoutExpired, OSError):
return False
if completed.returncode != 0:
return False
try:
verdict = json.loads(completed.stdout)
except (json.JSONDecodeError, ValueError):
return False
if not isinstance(verdict, dict):
return False
decision = str(verdict.get("decision", "")).lower()
return decision in ("allow", "benign")
@wrap_tool_call
def hol_guard_tool_policy(request: ToolRequest, call_next):
"""Gate command-bearing tools through HOL Guard before execution."""
if request.tool_name not in COMMAND_BEARING_TOOLS:
return call_next(request)
command = request.arguments.get("command", "")
if _inspect_with_hol_guard(command):
return call_next(request)
blocked_message = "Blocked by HOL Guard: command was not explicitly allowed."
# Surface the denial in `result` too: the agent tool executor returns
# `ToolResponse.result` to the model and discards `error`, so a denial with
# only `error` set would reach the model as a null result. Keeping `error`
# populated preserves the signal for after_tool hooks and logging.
return ToolResponse(
tool_name=request.tool_name,
result=blocked_message,
error=blocked_message,
context=request.context,
)
agent = Agent(
name="GuardedShellBot",
instructions="You run shell commands only when they are safe.",
tools=[run_shell],
hooks=[hol_guard_tool_policy],
)
if __name__ == "__main__":
print("HOL Guard tool policy registered on 'run_shell'.")
print("Commands are only executed when Guard returns an explicit allow.")
print("\n✓ HOL Guard example complete")
+3 -2
View File
@@ -14,7 +14,7 @@ Expected Output:
Agent responds with state persisted to Redis
"""
from praisonaiagents import Agent, MemoryConfig
from praisonaiagents import Agent
from praisonaiagents.db import db
print("=== Redis State Store (Agent-First) ===")
@@ -28,7 +28,8 @@ my_db = db(
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant.",
memory=MemoryConfig(db=my_db, session_id="redis-state-example"),
memory=my_db,
session_id="redis-state-example"
)
# Chat - state is automatically managed via Redis
@@ -23,7 +23,6 @@ from praisonaiagents.hooks.verification import (
VerificationResult,
BaseVerificationHook,
CommandVerificationHook,
FileCheckHook,
)
@@ -101,19 +100,7 @@ def main():
print(f" Name: {cmd_hook.name}")
print(f" Command: {cmd_hook.command}")
print(f" Timeout: {cmd_hook.timeout_seconds}s")
# 6. FileCheckHook: declarative, no-shell completion gate
print("\n6. FileCheckHook (declarative completion gate):")
file_hook = FileCheckHook(
name="changelog", path="CHANGELOG.md", non_empty=True
)
file_result = file_hook.run()
print(f" {file_hook.name}: success={file_result.success} — {file_result.output}")
print(
" Gate wiring: when verification_hooks are configured, the autonomous "
"loop cannot finalise with a success outcome while a blocking hook fails."
)
print("\n" + "=" * 60)
print("✓ Verification hooks example completed successfully!")
print("=" * 60)
+2 -2
View File
@@ -7,12 +7,12 @@ allowed_dirs = [
"/path/to/other/allowed/dir"
]
# Pass the executable as the first token and the allowed directories as args
# Use a single string command with allowed directories
filesystem_agent = Agent(
instructions="""You are a helpful assistant that can interact with the filesystem.
Use the available tools when relevant to manage files and directories.""",
llm="gpt-4o-mini",
tools=MCP("npx", args=["-y", "@modelcontextprotocol/server-filesystem", *allowed_dirs])
tools=MCP("npx -y @modelcontextprotocol/server-filesystem", args=allowed_dirs)
)
filesystem_agent.start("List files in the allowed directories")
+1 -1
View File
@@ -24,7 +24,7 @@ Examples for all PraisonAI server types and the unified endpoints CLI.
| `praisonai serve unified --port 8765` | Start unified server |
| `praisonai serve agents --file agents.yaml --port 8000` | Start agents API |
| `praisonai serve recipe --port 8765` | Start recipe server |
| `praisonai mcp serve --transport http-stream --port 8080` | Start MCP server |
| `praisonai serve mcp --transport http --port 8080` | Start MCP server |
| `praisonai serve tools --port 8081` | Start tools MCP server |
| `praisonai serve a2a --port 8082` | Start A2A server |
| `praisonai serve a2u --port 8083` | Start A2U server |
+1 -1
View File
@@ -13,7 +13,7 @@ Usage:
python mcp_http_server.py
# Or use CLI:
praisonai mcp serve --transport http-stream --port 8080
praisonai serve mcp --transport http --port 8080
# Test with curl:
curl http://localhost:8080/mcp/tools
+3 -3
View File
@@ -14,9 +14,9 @@ datasets:
# A small verified subset for a cheap smoke run. Verify against the registry
# with `harbor datasets list` before editing.
task_names:
- terminal-bench/build-pmars
- terminal-bench/bn-fit-modify
- terminal-bench/break-filter-js-from-html
- hello-world
- fix-permissions
- csv-to-parquet
agents:
- import_path: examples.terminal_bench.praisonai_code_agent:PraisonAICodeAgent
@@ -81,19 +81,21 @@ class PraisonAICodeAgent(BaseInstalledAgent):
model = self.model_name or "openai/gpt-4o-mini"
command = (
"log_file=$(mktemp /tmp/praisonai_code.XXXXXX); "
"chmod 600 \"$log_file\"; "
# 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)} "
"> \"$log_file\" 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=$?; "
# Echo the envelope so Harbor captures it for post-run accounting.
"cat \"$log_file\"; "
"rm -f \"$log_file\"; "
# 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.
@@ -34,7 +34,6 @@ except ImportError as e:
try:
from praisonaiagents import Agent
from praisonaiagents.approval import get_approval_registry, AutoApproveBackend
from praisonaiagents.config.feature_configs import ExecutionConfig
except ImportError as e:
raise ImportError(
f"PraisonAI not installed: {e}\n"
@@ -42,13 +41,6 @@ except ImportError as e:
) from e
TB_EXECUTION = ExecutionConfig(
max_tool_calls_per_turn=80,
max_iter=40,
max_steps=80,
)
class PraisonAIExternalAgent(BaseAgent):
"""External PraisonAI agent that drives a Harbor container environment."""
@@ -150,14 +142,26 @@ class PraisonAIExternalAgent(BaseAgent):
),
tools=[bash_tool],
llm=self.model_name or "openai/gpt-4o-mini",
execution=TB_EXECUTION,
)
# Execute one deliberately long coding turn. Harbor's hidden verifier
# is the only completion authority; model or pytest text is not.
# Execute the agent with outer loop to handle premature stopping
print(f"🚀 PraisonAI Agent starting task: {instruction[:100]}...")
result = await agent.achat(instruction)
print("✅ PraisonAI Agent completed task")
for _iter in range(19):
result_str = str(result)
# Stop if test passed
if any(sig in result_str.lower() for sig in [
" passed", "passed ", "test passed", "all tests pass", "1 passed"
]):
break
result = await agent.achat(
"The task is NOT complete yet — keep working. Run bash_tool commands now. "
"If you haven't already: (1) read all files in /app/, "
"(2) run the test to see the exact failure, "
"(3) implement a fix, (4) run the test again. "
"Repeat until the test passes. What is your next bash_tool command?"
)
print(f"✅ PraisonAI Agent completed task")
# Populate Harbor context with metadata
self._populate_context(agent, context, result)
@@ -199,10 +203,6 @@ class PraisonAIExternalAgent(BaseAgent):
"tools_used": ["bash_tool"],
"framework": "praisonai",
"version": self.version(),
"stop_reason": getattr(agent, "last_stop_reason", None),
"max_tool_calls_per_turn": TB_EXECUTION.max_tool_calls_per_turn,
"max_iter": TB_EXECUTION.max_iter,
"max_steps": TB_EXECUTION.max_steps,
}
except Exception as e:
@@ -218,4 +218,4 @@ if __name__ == "__main__":
print(" --model openai/gpt-4o-mini")
print()
print("Dependencies:")
print(" pip install harbor praisonaiagents")
print(" pip install harbor praisonaiagents")
@@ -1,39 +0,0 @@
"""Static regression checks for the in-tree Harbor adapter configuration."""
from pathlib import Path
import yaml
EXAMPLES = Path(__file__).parent
def test_external_adapter_sets_coding_execution_budget():
source = (EXAMPLES / "praisonai_external_agent.py").read_text(encoding="utf-8")
assert "ExecutionConfig" in source
assert "max_tool_calls_per_turn=80" in source
assert "max_iter=40" in source
assert "max_steps=80" in source
def test_external_adapter_does_not_guess_completion_from_pytest_text():
source = (EXAMPLES / "praisonai_external_agent.py").read_text(encoding="utf-8")
assert '"1 passed"' not in source
assert '"test passed"' not in source
def test_smoke_yaml_uses_terminal_bench_21_registry_ids():
job = yaml.safe_load(
(EXAMPLES / "job_code_smoke.yaml").read_text(encoding="utf-8")
)
names = job["datasets"][0]["task_names"]
assert names
assert all(name.startswith("terminal-bench/") for name in names)
assert names == [
"terminal-bench/build-pmars",
"terminal-bench/bn-fit-modify",
"terminal-bench/break-filter-js-from-html",
]
@@ -1,45 +0,0 @@
# Run a whole workflow's shell/file tools inside ONE shared remote sandbox.
#
# praisonai examples/yaml/workflows/remote_sandbox_workflow.yaml
#
# `tools_run_on:` is the only line that differs from a normal local workflow.
# Every step shares the same sandbox and working directory, so the file the
# writer creates is there for the reader. Thinking and orchestration still run
# on your machine, and the sandbox is torn down when the run finishes.
#
# Places: docker | e2b | modal | daytona | flyio | tenki | local
# subprocess | sandlock | ssh | novita
# - docker needs a local Docker daemon and nothing else
# - e2b / modal / daytona / flyio need that provider's API key
# - sandlock is a kernel-enforced local boundary (Linux 6.12+)
# - subprocess is process separation only, NOT a security boundary
#
# `run_on:` is still accepted here as the old spelling for this key.
#
# See what is still running: praisonai managed ps
# Reclaim strays: praisonai managed stop --all
name: remote-sandbox-demo
tools_run_on: docker
agents:
writer:
role: File Writer
goal: Write files into the shared workspace
instructions: >
You have write_file and execute_command running on a remote Linux
sandbox. Use them; never say you cannot run commands.
reader:
role: File Reader
goal: Read files back out of the shared workspace
instructions: >
You have read_file and execute_command running on the same remote
sandbox the writer used. Use them.
steps:
- agent: writer
action: "Use write_file to put exactly 'hello from step 1' in /workspace/note.txt"
- agent: reader
action: "Use read_file on /workspace/note.txt and report its exact contents"
@@ -99,12 +99,13 @@ pip index versions praisonaiagents | head -1
pip index versions praisonai | head -1
```
13. **praisonai-platform** advisories are handled in a different repository:
[MervinPraison/PraisonAI-Platform](https://github.com/MervinPraison/PraisonAI-Platform).
It is not part of this monorepo and is not published from here. Triage, patch,
release and advisory publication all happen in that repo, on its own semver line.
Nothing in this repository imports it, so a platform advisory never requires an
SDK release.
13. Publish **praisonai-platform** (only when the advisory targets platform code):
```bash
cd /Users/praison/praisonai-package/src/praisonai-platform
praisonai publish pypi
# or: uv lock && uv build && uv publish --token $PYPI_TOKEN
```
Platform releases are **independent** of `praisonai` / `praisonaiagents` version numbers. A platform-only fix (e.g. `0.1.8`) does **not** require bumping the main SDK.
14. Publish **praisonai (npm / TypeScript)** (only when the advisory targets `src/praisonai-ts`):
```bash
@@ -112,8 +113,9 @@ cd /Users/praison/praisonai-package/src/praisonai-ts
npm version patch && npm publish
```
15. Verify npm when applicable:
15. Verify platform / npm when applicable:
```bash
pip index versions praisonai-platform | head -1
npm view praisonai version
```
@@ -137,7 +139,7 @@ gh api repos/MervinPraison/PraisonAI/security-advisories/<GHSA_ID> \
}
EOF
```
- `<PACKAGE_NAME>`: `praisonaiagents` (core SDK), `praisonai` (Python wrapper), or `praisonai` (npm ecosystem for TypeScript)
- `<PACKAGE_NAME>`: `praisonaiagents` (core SDK), `praisonai` (Python wrapper), `praisonai-platform` (platform layer), or `praisonai` (npm ecosystem for TypeScript)
- `<LAST_VULNERABLE_VERSION>`: version before the fix
- `<FIRST_PATCHED_VERSION>`: the version just published with the fix
- Always include credits for the reporter
@@ -188,8 +190,8 @@ After validating each advisory in source code, assign **one** label:
| Package | Version line | Notes |
|---|---|---|
| `praisonaiagents` + `praisonai` | Aligned patch (e.g. `1.6.62` / `4.6.62`) | Main SDK + wrapper; bump together via `bump_and_release.py` |
| `praisonai-platform` | Independent semver (e.g. `0.1.8`) | Platform-only fixes; users need **not** upgrade the main SDK |
| `praisonai` (npm) | Independent semver (e.g. `1.7.2`) | TypeScript SDK; publish from `src/praisonai-ts` |
| `praisonai-platform` | Independent semver, **separate repository** | Not released from this repo — see [PraisonAI-Platform](https://github.com/MervinPraison/PraisonAI-Platform) |
Always set the advisory `patched_versions` to the **package that actually contains the fix**. Do not imply users must upgrade all packages when only one changed.
@@ -215,11 +217,9 @@ Close (or publish advisory with no code change only if already mitigated) when:
|---|---|---|---|
| praisonaiagents | pip | src/praisonai-agents/praisonaiagents/ | tools/, mcp/, memory/, agent/, sandbox/ |
| praisonai | pip | src/praisonai/praisonai/ | cli/, capabilities/, mcp_server/, sandbox/, recipe/ |
| praisonai-platform | pip | src/praisonai-platform/praisonai_platform/ | api/routes/, services/, auth |
| praisonai | npm | src/praisonai-ts/src/ | agent/, mcp/, tools/, code-mode/ |
`praisonai-platform` is **not audited from this repository** — it lives in
[MervinPraison/PraisonAI-Platform](https://github.com/MervinPraison/PraisonAI-Platform).
## RESOURCES & DOCUMENTATION
- **Official Repository**: https://github.com/MervinPraison/PraisonAI — Source code, issue tracking, and contribution guidelines
@@ -234,7 +234,7 @@ Close (or publish advisory with no code change only if already mitigated) when:
- ALWAYS set patched_versions before publishing advisory
- ALWAYS publish to PyPI/npm BEFORE publishing advisories (so Dependabot can point users to a fix)
- Python SDK: `praisonai publish pypi` from `src/praisonai-agents`, then `bump_and_release.py` for the wrapper
- Platform-only: released from the separate [PraisonAI-Platform](https://github.com/MervinPraison/PraisonAI-Platform) repo, never from here
- Platform-only: `praisonai publish pypi` from `src/praisonai-platform` — no SDK bump required
- npm: `npm publish` from `src/praisonai-ts` when the TypeScript SDK is affected
- ALWAYS request CVE AFTER publishing advisory
- Use generic commit messages: "refactor: harden X" not "fix: SQL injection in Y"
+1 -1
View File
@@ -1074,7 +1074,7 @@ PraisonAI's reliability engineering rests on three pillars — **C**ontext, **H*
> **Evaluator status.** The `ContextEvaluator` / `HarnessEvaluator` / `LoopEvaluator` classes named in the rubric are **planned follow-ups** (PA-CHL-001–004), not yet in the repo. Until they land, the equivalent checks are covered by existing modules:
>
> - **Context** — `context/tokens.py` and `eval/tokens.py` (`estimate_tokens` / `count_tokens`) for budget; `compaction/compactor.py` (`ContextCompactor`) for compaction/retention.
> - **Harness** — `runtime/turn_context.py` for turn-context parity; `examples/runtime/example_harness.py` and `tests/` for harness/artifact checks.
> - **Harness** — `runtime/turn_context.py` for turn-context parity; `runtime/example_harness.py` and `tests/` for harness/artifact checks.
> - **Loop** — `eval/loop.py` (`EvaluationLoop`) and `agent/autonomy.py` for convergence and doom-loop guards.
>
> Keep terminology consistent with the `agent/context_agent.py` (`ContextAgent`) PRP methodology.
+42 -42
View File
@@ -18,76 +18,76 @@ This report compares **Python SDK feature categories** against **Python document
| Category | Features | Docs | Lines |
|----------|----------|------|-------|
| ✅ AGUI | 1 | 2 | 420 |
| ✅ Agent | 22 | 53 | 17053 |
| ✅ Agent | 22 | 50 | 15809 |
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2996 |
| ✅ Agent-to-User (A2U) | 1 | 3 | 689 |
| ✅ Approval | 1 | 5 | 2658 |
| ✅ Approval | 1 | 5 | 2537 |
| ✅ Audio | 2 | 12 | 1000 |
| ✅ Auto Generation | 5 | 11 | 3542 |
| ✅ Autonomy | 3 | 7 | 2790 |
| ✅ Bots | 7 | 31 | 13411 |
| ✅ Auto Generation | 5 | 10 | 3049 |
| ✅ Autonomy | 3 | 7 | 2744 |
| ✅ Bots | 7 | 31 | 13314 |
| ✅ Budget | 1 | 1 | 287 |
| ✅ CLI | 5 | 122 | 36734 |
| ✅ Chunking | 2 | 2 | 420 |
| ✅ CLI | 5 | 121 | 36021 |
| ✅ Chunking | 2 | 2 | 385 |
| ✅ Citations | 2 | 1 | 202 |
| ✅ Code Execution | 2 | 14 | 4855 |
| ✅ Conditions | 1 | 3 | 1347 |
| ✅ Configuration | 3 | 8 | 3139 |
| ✅ Context Management | 16 | 36 | 13988 |
| ✅ Code Execution | 2 | 14 | 4583 |
| ✅ Conditions | 1 | 3 | 1179 |
| ✅ Configuration | 3 | 8 | 3047 |
| ✅ Context Management | 16 | 34 | 13396 |
| ✅ Deep Research | 8 | 2 | 587 |
| ✅ Display | 6 | 3 | 741 |
| ✅ Embeddings | 6 | 23 | 2049 |
| ✅ Evaluation | 1 | 7 | 2920 |
| ✅ Evaluation | 1 | 7 | 2905 |
| ✅ Events | 1 | 2 | 749 |
| ✅ Execution | 3 | 4 | 1606 |
| ✅ Failover | 2 | 1 | 452 |
| ✅ Files | 2 | 7 | 2275 |
| ✅ Execution | 3 | 4 | 1562 |
| ✅ Failover | 2 | 1 | 432 |
| ✅ Files | 2 | 7 | 2273 |
| ✅ Flow | 1 | 3 | 788 |
| ✅ Gateway | 7 | 81 | 29955 |
| ✅ Guardrails | 4 | 4 | 2205 |
| ✅ Handoffs | 11 | 6 | 2626 |
| ✅ Hooks | 2 | 9 | 4509 |
| ✅ Gateway | 7 | 72 | 26391 |
| ✅ Guardrails | 4 | 4 | 2017 |
| ✅ Handoffs | 11 | 6 | 2563 |
| ✅ Hooks | 2 | 9 | 4317 |
| ✅ Image | 1 | 11 | 1255 |
| ✅ Knowledge | 4 | 16 | 4942 |
| ✅ LLM | 3 | 15 | 5372 |
| ✅ Loops | 4 | 5 | 1475 |
| ✅ MCP | 1 | 61 | 14135 |
| ✅ Memory | 6 | 18 | 7187 |
| ✅ Knowledge | 4 | 13 | 4333 |
| ✅ LLM | 3 | 15 | 5192 |
| ✅ Loops | 4 | 5 | 1422 |
| ✅ MCP | 1 | 61 | 13847 |
| ✅ Memory | 6 | 18 | 7070 |
| ✅ OCR | 2 | 1 | 237 |
| ✅ Observability | 2 | 23 | 2811 |
| ✅ Observability | 2 | 23 | 2814 |
| ✅ Optimizer | 1 | 2 | 792 |
| ✅ Output | 3 | 5 | 1265 |
| ✅ Output | 3 | 5 | 1201 |
| ✅ Parallel Execution | 3 | 2 | 497 |
| ✅ Planning | 6 | 6 | 1605 |
| ✅ Plugins | 8 | 5 | 3120 |
| ✅ Prompts | 2 | 9 | 1971 |
| ✅ Providers | 1 | 56 | 7781 |
| ✅ Planning | 6 | 6 | 1549 |
| ✅ Plugins | 8 | 4 | 2850 |
| ✅ Prompts | 2 | 9 | 1965 |
| ✅ Providers | 1 | 56 | 7735 |
| ✅ Query | 1 | 2 | 736 |
| ✅ RAG | 5 | 15 | 3118 |
| ✅ RAG | 5 | 15 | 3003 |
| ✅ Realtime | 2 | 5 | 668 |
| ✅ Reflection | 3 | 3 | 730 |
| ✅ Reflection | 3 | 3 | 680 |
| ✅ Retrieval | 2 | 5 | 1054 |
| ✅ Routing | 1 | 2 | 430 |
| ✅ Sandbox | 5 | 7 | 3483 |
| ✅ Security | 1 | 3 | 3482 |
| ✅ Sessions | 4 | 19 | 7631 |
| ✅ Sandbox | 5 | 6 | 2918 |
| ✅ Security | 1 | 3 | 3206 |
| ✅ Sessions | 4 | 17 | 6963 |
| ✅ Skills | 6 | 15 | 5638 |
| ✅ Tasks | 2 | 6 | 2897 |
| ✅ Telemetry | 1 | 2 | 648 |
| ✅ Tasks | 2 | 6 | 2819 |
| ✅ Telemetry | 1 | 2 | 604 |
| ✅ Templates | 1 | 8 | 1663 |
| ✅ Tools | 12 | 144 | 43113 |
| ✅ Tools | 12 | 141 | 41242 |
| ✅ Tracing | 3 | 2 | 139 |
| ✅ Vector Store | 1 | 12 | 1215 |
| ✅ Vector Store | 1 | 12 | 1211 |
| ✅ Video | 2 | 6 | 649 |
| ✅ Vision | 2 | 1 | 329 |
| ✅ Web | 3 | 9 | 2487 |
| ✅ Workflows | 5 | 18 | 7406 |
| ✅ Web | 3 | 9 | 2467 |
| ✅ Workflows | 5 | 16 | 6589 |
## Documentation Without Features
These docs exist but don't match any implemented feature category:
- ℹ️ Database (42 docs, 6959 lines)
- ℹ️ Database (42 docs, 6713 lines)
- ℹ️ Documents (1 docs, 787 lines)
---
@@ -92,6 +92,20 @@ from ._lazy import lazy_import, create_lazy_getattr_with_fallback
# Thread-safe cache for lazy-loaded values
_lazy_cache = {}
# Backward compatibility: _get_lazy_cache function for tests
import threading
_lazy_cache_local = threading.local()
def _get_lazy_cache():
"""Get thread-local lazy cache dict. Thread-safe for concurrent access.
Note: This is kept for backward compatibility with tests.
The main lazy loading now uses the centralized _lazy.py utility.
"""
if not hasattr(_lazy_cache_local, 'cache'):
_lazy_cache_local.cache = {}
return _lazy_cache_local.cache
# ============================================================================
# LAZY IMPORT MAPPING
# ============================================================================
@@ -27,7 +27,7 @@ SUPPRESSED_PATTERNS = [
"The `dict` method is deprecated; use `model_dump` instead",
"Pydantic serializer warnings",
"PydanticSerializationUnexpectedValue",
"fields but got", # litellm/pydantic "Expected N fields but got M"
"Expected", # Catches all "Expected N fields but got M" patterns
"Expected `StreamingChoices` but got `Choices`",
"serialized value may not be as expected",
"Mixing V1 models and V2 models",
File diff suppressed because it is too large. Load diff
@@ -110,32 +110,14 @@ class AsyncSafeState:
def __init__(self, initial_value: Any = None):
self.value = initial_value
self._lock = DualLock()
self._activity_lock = threading.Lock()
self._async_holders = 0
self._copying = False
def __deepcopy__(self, memo):
"""Copy protected state, rejecting overlap with asynchronous access."""
"""Copy the protected value while replacing all lock state."""
result = type(self).__new__(type(self))
memo[id(self)] = result
with self._activity_lock:
if self._async_holders:
raise RuntimeError(
"Cannot deepcopy AsyncSafeState during asynchronous access"
)
if self._copying:
raise RuntimeError("AsyncSafeState deepcopy is already in progress")
self._copying = True
try:
with self.lock():
result.value = copy.deepcopy(self.value, memo)
finally:
with self._activity_lock:
self._copying = False
with self.lock():
result.value = copy.deepcopy(self.value, memo)
result._lock = DualLock()
result._activity_lock = threading.Lock()
result._async_holders = 0
result._copying = False
return result
@contextmanager
@@ -148,25 +130,7 @@ class AsyncSafeState:
async def async_lock(self):
"""Acquire lock in async context."""
async with self._lock.async_lock():
self._begin_async_access()
try:
yield self.value
finally:
self._end_async_access()
def _begin_async_access(self) -> None:
"""Register async access unless a synchronous copy is in progress."""
with self._activity_lock:
if self._copying:
raise RuntimeError(
"Cannot access AsyncSafeState during synchronous deepcopy"
)
self._async_holders += 1
def _end_async_access(self) -> None:
"""Unregister one active asynchronous accessor."""
with self._activity_lock:
self._async_holders -= 1
yield self.value
def __enter__(self):
"""Support for synchronous context manager protocol (backward compatibility)."""
@@ -182,20 +146,12 @@ class AsyncSafeState:
"""Support for asynchronous context manager protocol."""
async_lock = self._lock._get_async_lock()
await async_lock.acquire()
try:
self._begin_async_access()
except Exception:
async_lock.release()
raise
return self.value
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Support for asynchronous context manager protocol."""
async_lock = self._lock._get_async_lock()
try:
self._end_async_access()
finally:
async_lock.release()
async_lock.release()
return None
def get(self) -> Any:
@@ -136,8 +136,9 @@ class AudioAgent:
- AudioConfig: Full configuration object
verbose: Verbosity level for output
"""
from ..utils.model_alias import resolve_model_name
llm = resolve_model_name(llm, model, "AudioAgent")
# Handle model= alias
if llm is None and model is not None:
llm = model
self.name = name or "AudioAgent"
self.instructions = instructions
@@ -106,11 +106,8 @@ class AutonomyConfig:
track_changes: Whether to track filesystem changes via shadow git.
Defaults to True when level="full_auto", False otherwise.
When enabled, agent.undo()/redo()/diff() become available.
sandbox: Optional SandboxConfig, carried for forward compatibility.
NOT IMPLEMENTED: nothing currently reads this field, and no sandbox
is auto-enabled at any autonomy level. To isolate execution, set it
on the agent itself -- Agent(sandbox=...) -- or run the whole
workflow remotely with AgentFlow(run_on="docker").
sandbox: Optional SandboxConfig for execution isolation.
When level="full_auto" and sandbox is None, subprocess sandbox is auto-enabled.
snapshot_dir: Directory for snapshot storage. Defaults to ~/.praisonai/snapshots.
Usage::
@@ -217,13 +214,6 @@ class AutonomyConfig:
f"Invalid autonomy level: {level!r}. "
f"Must be one of {sorted(VALID_AUTONOMY_LEVELS)}"
)
verification_hooks = data.get("verification_hooks")
# Config parity: accept declarative `verification:` entries and map
# `command:`/`file:` shapes onto the two hook classes.
declarative = data.get("verification")
if declarative:
built = _build_verification_hooks(declarative)
verification_hooks = (verification_hooks or []) + built
return cls(
enabled=data.get("enabled", True),
level=level,
@@ -234,7 +224,7 @@ class AutonomyConfig:
observe=data.get("observe", False),
completion_promise=data.get("completion_promise"),
clear_context=data.get("clear_context", False),
verification_hooks=verification_hooks,
verification_hooks=data.get("verification_hooks"),
track_changes=data.get("track_changes"),
sandbox=data.get("sandbox"),
snapshot_dir=data.get("snapshot_dir"),
@@ -244,49 +234,6 @@ class AutonomyConfig:
budget_action=data.get("budget_action", "pause"),
)
def _build_verification_hooks(entries: List[Any]) -> List[Any]:
"""Map declarative ``verification:`` config entries onto hook instances.
Each entry is a dict with either a ``command`` key (→
:class:`CommandVerificationHook`) or a ``file`` key (→
:class:`FileCheckHook`). Unknown shapes are skipped.
Example::
verification:
- {name: tests, command: "python -m pytest -q"}
- {name: changelog, file: CHANGELOG.md, non_empty: true}
"""
from ..hooks.verification import CommandVerificationHook, FileCheckHook
hooks: List[Any] = []
for entry in entries or []:
if not isinstance(entry, dict):
continue
blocking = entry.get("blocking", True)
if "command" in entry:
hooks.append(CommandVerificationHook(
name=entry.get("name", "command"),
command=entry["command"],
cwd=entry.get("cwd"),
timeout=entry.get("timeout", 60.0),
blocking=blocking,
))
elif "file" in entry:
hooks.append(FileCheckHook(
name=entry.get("name", "file"),
path=entry["file"],
exists=entry.get("exists", True),
non_empty=entry.get("non_empty", False),
contains=entry.get("contains"),
json_field=entry.get("json_field"),
equals=entry.get("equals"),
blocking=blocking,
))
return hooks
class AutonomySignal(str, Enum):
"""Signals detected from prompts for autonomy decisions.
@@ -1211,15 +1211,7 @@ Your Goal: {self.goal}"""
return content
def _extract_llm_response_content(self, response) -> Optional[str]:
"""Return assistant message text, or a tool-call summary.
A well-formed response that simply carries no content -- a content filter,
a refusal, or ``finish_reason="length"`` with zero output tokens -- yields
the empty string. ``str(response)`` is reserved for responses this method
could not parse at all, where the repr is a diagnostic rather than an
answer; returning it for a merely empty message put the raw SDK object's
repr in front of the user and into ``chat_history``.
"""
"""Return assistant message text, a tool-call summary, or str(response) as fallback."""
if not response:
return None
try:
@@ -1234,17 +1226,6 @@ Your Goal: {self.goal}"""
if tool_calls:
names = [getattr(tc.function, "name", "?") for tc in tool_calls]
return f"[tool_calls: {', '.join(names)}]"
# Parsed cleanly; the model returned no content. A refusal
# (safety) is carried in ``message.refusal`` independently of
# ``finish_reason`` -- surface it so an empty answer isn't silent.
finish_reason = getattr(choice, "finish_reason", None)
refusal = getattr(msg, "refusal", None)
if refusal or finish_reason not in (None, "stop"):
logging.warning(
f"Agent {self.name}: model returned no content "
f"(finish_reason={finish_reason!r}, refused={bool(refusal)})"
)
return ""
except (AttributeError, IndexError, TypeError) as e:
logging.warning(
f"Failed to extract LLM response content (falling back to str): {e}"
@@ -1744,7 +1725,7 @@ Your Goal: {self.goal}"""
return max_retries
return 2
def _chat_completion(self, messages, temperature=None, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, _retry_depth=0, _fallback_index=0):
def _chat_completion(self, messages, temperature=1.0, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, _retry_depth=0, _fallback_index=0):
start_time = time.time()
# --- Proactive Context Budget Management (default-on) ---
@@ -1868,11 +1849,6 @@ Your Goal: {self.goal}"""
stream = False # Set for the main execution below
else:
raise # Re-raise if it's a different ValueError
except ToolExecutionError:
# A tool failed during the streaming attempt. Re-raise so it is
# not relabelled as an LLM error and so the tool is not executed
# a second time by the non-streaming fallback below.
raise
except Exception as e:
from ..errors import LLMError
# Don't retry if it's an LLMError that has exhausted retries
@@ -1977,8 +1953,6 @@ Your Goal: {self.goal}"""
except BudgetExceededError:
raise
except ToolExecutionError:
raise
except Exception as e:
from ..errors import LLMError
@@ -2145,7 +2119,7 @@ Your Goal: {self.goal}"""
self,
exc: Exception,
messages,
temperature=None,
temperature=1.0,
tools=None,
stream=True,
reasoning_steps=False,
@@ -2335,7 +2309,7 @@ Your Goal: {self.goal}"""
def _execute_unified_chat_completion(
self,
messages,
temperature=None,
temperature=1.0,
tools=None,
stream=None,
reasoning_steps=False,
@@ -2397,11 +2371,7 @@ Your Goal: {self.goal}"""
task_name=task_name,
task_description=task_description,
task_id=task_id,
agent_name=getattr(self, 'name', 'assistant'),
# Durable re-injection target for deferred tool results:
# background jobs completing after this call must land in the
# persistent history a later turn replays, not the loop copy.
deferred_history_sink=getattr(self, 'chat_history', None),
agent_name=getattr(self, 'name', 'assistant')
)
return final_response
except ValueError as e:
@@ -2457,8 +2427,6 @@ Your Goal: {self.goal}"""
]
or None
),
# Durable re-injection target for deferred tool results (see above).
deferred_history_sink=getattr(self, 'chat_history', None),
)
return final_response
@@ -2469,7 +2437,7 @@ Your Goal: {self.goal}"""
async def _execute_unified_achat_completion(
self,
messages,
temperature=None,
temperature=1.0,
tools=None,
stream=True, # Async methods keep stream=True default (async adapters support streaming vs sync smart fallback)
reasoning_steps=False,
@@ -2807,11 +2775,7 @@ Your Goal: {self.goal}"""
model_name = self.llm if isinstance(self.llm, str) else "gpt-4o-mini"
model_limit = get_model_limit(model_name)
# Count the whole request (history + system prompt + tool schemas),
# otherwise a large prompt/tool block can push the call over the
# window while the history alone stays under the threshold.
overhead = self._estimate_request_overhead_tokens(system_prompt, tools)
current_tokens = estimate_messages_tokens(optimized) + overhead
current_tokens = estimate_messages_tokens(optimized)
# If over 95% of limit, apply emergency truncation
if current_tokens > model_limit * 0.95:
@@ -2819,67 +2783,20 @@ Your Goal: {self.goal}"""
f"[{self.name}] Context at {current_tokens} tokens (limit: {model_limit}), "
f"applying emergency truncation"
)
# Leave room for the fixed overhead so the full request lands
# near 80% of the window rather than the history alone.
target = max(1, int(model_limit * 0.8) - overhead)
target = int(model_limit * 0.8) # Target 80% of limit
optimized = self.context_manager.emergency_truncate(optimized, target)
result["emergency_truncated"] = True
result["tokens_after"] = estimate_messages_tokens(optimized) + overhead
result["tokens_after"] = estimate_messages_tokens(optimized)
except Exception as e:
logging.debug(f"Hard limit check skipped: {e}")
return optimized, result
except Exception as e:
# Context management should never break the chat flow, but silently
# returning the unchanged history disables the hard-limit truncation
# too and sends an over-budget request. Fall back to emergency
# truncation so we still fit within the model window.
logging.error(f"Context management failed: {e}")
try:
from ..context.budgeter import get_model_limit
from ..context.tokens import estimate_messages_tokens
model_name = self.llm if isinstance(self.llm, str) else "gpt-4o-mini"
model_limit = get_model_limit(model_name)
# Include the system prompt and tool schemas in the budget: the
# request can exceed the window on those alone even when the
# history is under the threshold.
overhead = self._estimate_request_overhead_tokens(system_prompt, tools)
if estimate_messages_tokens(messages) + overhead > model_limit * 0.95:
target = max(1, int(model_limit * 0.8) - overhead)
truncated = self.context_manager.emergency_truncate(messages, target)
return truncated, {"emergency_truncated": True}
except Exception as inner:
logging.debug(f"Emergency truncation fallback skipped: {inner}")
# Context management should never break the chat flow
logging.warning(f"Context management error (continuing without): {e}")
return messages, None
def _estimate_request_overhead_tokens(
self,
system_prompt: str = "",
tools: Optional[list] = None,
) -> int:
"""Estimate non-history request tokens (system prompt + tool schemas).
Emergency-truncation thresholds must budget the full request, not just
the message history, or a large system prompt / tool block can push the
call over the model window undetected.
"""
try:
from ..context.tokens import (
estimate_tokens_heuristic,
estimate_tool_schema_tokens,
)
except Exception:
return 0
overhead = 0
if system_prompt:
overhead += estimate_tokens_heuristic(system_prompt)
if tools:
overhead += estimate_tool_schema_tokens(tools)
return overhead
def _truncate_tool_output(self, tool_name: str, output: str, tool_call_id: str | None = None) -> str:
"""
Truncate tool output according to configured budget.
@@ -2965,7 +2882,7 @@ Your Goal: {self.goal}"""
)
return rendered
def chat(self, prompt: str, temperature: Optional[float] = None, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None, seed: Optional[int] = None, cancel_token: Optional[Any] = None) -> Optional[str]:
def chat(self, prompt: str, temperature: float = 1.0, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None, seed: Optional[int] = None, cancel_token: Optional[Any] = None) -> Optional[str]:
"""
Chat with the agent.
@@ -2978,14 +2895,6 @@ Your Goal: {self.goal}"""
'required' forces the LLM to call a tool before responding.
...other args...
"""
# Tools go to their sandbox before the first model call, once per
# agent. Placed on chat/achat/_start_stream rather than run/start
# because AgentTeam and AgentFlow call these three directly -- an
# agent used inside a team would otherwise run its tools locally.
if getattr(self, 'tools_run_on', None) is not None:
from .tools_placement import ensure_tools_placed
ensure_tools_placed(self)
# Slash-command invocation: /skill-name [args] renders the skill
# body before any backend/LLM call.
prompt = self._resolve_skill_invocation(prompt)
@@ -3575,14 +3484,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
reflection_output = _get_display_functions()['ReflectionOutput'](**reflection_data)
else:
# Use OpenAI's structured output for OpenAI models
_reflection_kwargs = {}
if temperature is not None:
_reflection_kwargs['temperature'] = temperature
reflection_response = self._openai_client.sync_client.beta.chat.completions.parse(
model=self.reflect_llm if self.reflect_llm else self.llm,
messages=messages,
response_format=_get_display_functions()['ReflectionOutput'],
**_reflection_kwargs
temperature=temperature,
response_format=_get_display_functions()['ReflectionOutput']
)
reflection_output = reflection_response.choices[0].message.parsed
@@ -3647,22 +3553,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
logging.error("Reflection parsing failed.", exc_info=True)
messages.append({"role": "assistant", "content": "Self Reflection failed."})
reflection_count += 1
if reflection_count >= self.max_reflect:
# Bound the retry, as the async path does. Without this
# a persistently unparseable reflection (e.g. a
# structured-output refusal, where message.parsed is
# None) loops forever, two LLM calls at a time.
if self.verbose:
_get_display_functions()['display_self_reflection']("Maximum reflection count reached after repeated parse errors, returning current response", console=self.console)
self._append_to_chat_history({"role": "assistant", "content": response_text})
try:
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
except Exception as guard_e:
logging.error(f"Agent {self.name}: Guardrail validation failed after reflection error: {guard_e}")
self._rollback_chat_history_to(chat_history_length)
return None
continue # Continue even after error to try again
except Exception:
# Catch any exception from the inner try block and re-raise to outer handler
@@ -3682,7 +3572,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
return clean_triple_backticks(output)
async def achat(self, prompt: str, temperature: Optional[float] = None, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None, seed: Optional[int] = None, cancel_token: Optional[Any] = None):
async def achat(self, prompt: str, temperature: float = 1.0, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None, seed: Optional[int] = None, cancel_token: Optional[Any] = None):
"""Async version of chat method with self-reflection support.
Args:
@@ -3690,14 +3580,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
attachments: Optional list of image/file paths that are ephemeral
(used for THIS turn only, NEVER stored in history).
"""
# Tools go to their sandbox before the first model call, once per
# agent. Placed on chat/achat/_start_stream rather than run/start
# because AgentTeam and AgentFlow call these three directly -- an
# agent used inside a team would otherwise run its tools locally.
if getattr(self, 'tools_run_on', None) is not None:
from .tools_placement import ensure_tools_placed
ensure_tools_placed(self)
# Slash-command invocation: /skill-name [args] renders the skill body.
prompt = self._resolve_skill_invocation(prompt)
@@ -4243,14 +4125,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
self._rollback_chat_history_to(chat_history_length)
return None
_areflection_kwargs = {}
if temperature is not None:
_areflection_kwargs['temperature'] = temperature
reflection_response = await self._openai_client.async_client.beta.chat.completions.parse(
model=self.reflect_llm if self.reflect_llm else self.llm,
messages=reflection_messages,
response_format=_get_display_functions()['ReflectionOutput'],
**_areflection_kwargs
temperature=temperature,
response_format=_get_display_functions()['ReflectionOutput']
)
reflection_output = reflection_response.choices[0].message.parsed
@@ -4364,10 +4243,9 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
tool_call_kwargs = dict(
model=self.llm,
messages=messages,
temperature=temperature,
tools=formatted_tools,
)
if temperature is not None:
tool_call_kwargs['temperature'] = temperature
if effective_tool_choice:
tool_call_kwargs['tool_choice'] = effective_tool_choice
response = await self._openai_client.async_client.chat.completions.create(
@@ -4381,14 +4259,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
self._execute_callback_and_display(original_prompt, result, time.time() - start_time, task_name, task_description, task_id)
return await self._atrigger_after_agent_hook(original_prompt, result, start_time)
elif output_json or output_pydantic:
_json_kwargs = {}
if temperature is not None:
_json_kwargs['temperature'] = temperature
response = await self._openai_client.async_client.chat.completions.create(
model=self.llm,
messages=messages,
response_format={"type": "json_object"},
**_json_kwargs
temperature=temperature,
response_format={"type": "json_object"}
)
response_text = response.choices[0].message.content
if get_logger().getEffectiveLevel() == logging.DEBUG:
@@ -4398,13 +4273,10 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
self._execute_callback_and_display(original_prompt, response_text, time.time() - start_time, task_name, task_description, task_id)
return await self._atrigger_after_agent_hook(original_prompt, response_text, start_time)
else:
_plain_kwargs = {}
if temperature is not None:
_plain_kwargs['temperature'] = temperature
response = await self._openai_client.async_client.chat.completions.create(
model=self.llm,
messages=messages,
**_plain_kwargs
temperature=temperature
)
response_text = response.choices[0].message.content
@@ -4441,14 +4313,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
logging.debug(f"Agent.achat completed in {total_time:.2f} seconds")
return await self._atrigger_after_agent_hook(original_prompt, response_text, start_time)
_areflection_kwargs = {}
if temperature is not None:
_areflection_kwargs['temperature'] = temperature
reflection_response = await self._openai_client.async_client.beta.chat.completions.parse(
model=self.reflect_llm if self.reflect_llm else self.llm,
messages=reflection_messages,
response_format=_get_display_functions()['ReflectionOutput'],
**_areflection_kwargs
temperature=temperature,
response_format=_get_display_functions()['ReflectionOutput']
)
reflection_output = reflection_response.choices[0].message.parsed
@@ -4474,13 +4343,10 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
{"role": "user", "content": "Now regenerate your response using the reflection you made"}
]
_regen_kwargs = {}
if temperature is not None:
_regen_kwargs['temperature'] = temperature
new_response = await self._openai_client.async_client.chat.completions.create(
model=self.llm,
messages=regenerate_messages,
**_regen_kwargs
temperature=temperature
)
response_text = new_response.choices[0].message.content
reflection_count += 1
@@ -4567,12 +4433,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# after-context aggregation) are fired once, guarded, inside
# execute_tool_async — no inline duplicate dispatch here.
# Pass the tools list to honor task-scoped tools
result = await self.execute_tool_async(
function_name,
arguments,
tool_call_id=getattr(tool_call, "id", None),
tools_override=tools,
)
result = await self.execute_tool_async(function_name, arguments, tools_override=tools)
results.append(result)
except Exception as e:
@@ -4673,14 +4534,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
def _start_stream(self, prompt: str, **kwargs) -> Generator[str, None, None]:
"""Own the durable context for the complete streaming generator."""
# Tools go to their sandbox before the first model call, once per
# agent. Placed on chat/achat/_start_stream rather than run/start
# because AgentTeam and AgentFlow call these three directly -- an
# agent used inside a team would otherwise run its tools locally.
if getattr(self, 'tools_run_on', None) is not None:
from .tools_placement import ensure_tools_placed
ensure_tools_placed(self)
durable_context = None
durable_token = None
try:
@@ -5368,7 +5221,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
agent_id=getattr(self, 'name', None),
)
def _chat_completion_with_retry(self, messages, temperature=None, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, stream_callback=None, emit_events=True):
def _chat_completion_with_retry(self, messages, temperature=1.0, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, stream_callback=None, emit_events=True):
"""
Wrapper for _execute_unified_chat_completion that adds jittered exponential backoff retry logic.
@@ -5455,7 +5308,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# This should never be reached, but just in case
raise RuntimeError("Retry loop completed without returning or raising an exception")
async def _achat_completion_with_retry(self, messages, temperature=None, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, stream_callback=None, emit_events=True):
async def _achat_completion_with_retry(self, messages, temperature=1.0, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, stream_callback=None, emit_events=True):
"""
Async wrapper for _execute_unified_achat_completion that adds jittered exponential backoff retry logic.
@@ -59,8 +59,7 @@ class CodeAgent:
def __init__(
self,
name: str = "CodeAgent",
llm: Optional[str] = None, # Deprecated alias for model=
model: Optional[str] = None,
llm: Optional[str] = None,
code: Optional[Union[bool, Dict, CodeConfig]] = None,
instructions: Optional[str] = None,
verbose: bool = True,
@@ -70,16 +69,14 @@ class CodeAgent:
Args:
name: Agent name
llm: Deprecated alias for model=. Passing both raises TypeError.
model: Model to use (default: gpt-4o-mini)
llm: LLM model (default: gpt-4o-mini)
code: Code configuration (bool, dict, or CodeConfig)
instructions: System instructions
verbose: Enable verbose output
**kwargs: Additional arguments
"""
from ..utils.model_alias import resolve_model_name
self.name = name
self.llm = resolve_model_name(llm, model, "CodeAgent") or "gpt-4o-mini"
self.llm = llm or "gpt-4o-mini"
self.instructions = instructions
self.verbose = verbose
@@ -17,7 +17,6 @@ from ..runtime.journal import (
KIND_MODEL_DECISION,
KIND_TOOL_CALL,
KIND_TOOL_RESULT,
KIND_VERIFICATION,
RunJournal,
)
@@ -59,85 +58,6 @@ def _accepts_idempotency_key(execute_tool_fn: Callable) -> bool:
)
def _normalize_restart_safe(value: Any) -> Optional[bool]:
"""Accept only genuine booleans as a restart-safety declaration.
``None`` means undeclared. Any other type (e.g. the string ``"False"``) is a
malformed declaration and is treated as unsafe so we fail closed rather than
coercing a truthy value into an unintended replay.
"""
if value is None:
return None
if isinstance(value, bool):
return value
return False
def _canonical_tool_name(name: Any) -> str:
"""Normalise a tool name for tolerant comparison across repair variants.
``execute_tool`` may repair case/separator variants (e.g. ``Send-Email`` →
``send_email``); the declaration lookup mirrors that so an explicit
``restart_safe`` declaration is not missed for a repaired name.
"""
return "".join(ch for ch in str(name).lower() if ch.isalnum())
def _declared_restart_safe(
function_name: str, executor: Optional[Callable]
) -> Optional[bool]:
"""Return an explicit ``restart_safe`` declaration for ``function_name``.
Resolves the tool object first from the executor's owning agent (its live
``tools``), then from the global tool registry, and reads its
``restart_safe`` attribute. Returns ``None`` when the tool is undeclared or
cannot be resolved.
"""
if not function_name:
return False
target = _canonical_tool_name(function_name)
agent = getattr(executor, "__self__", None)
tools = getattr(agent, "tools", None)
if isinstance(tools, (list, tuple)):
for tool in tools:
name = getattr(tool, "name", None) or getattr(tool, "__name__", None)
if (
name is not None
and _canonical_tool_name(name) == target
and getattr(tool, "restart_safe", None) is not None
):
return _normalize_restart_safe(tool.restart_safe)
try:
from ..tools import get_registry as _get_tool_registry
tool = _get_tool_registry().get(function_name)
except Exception: # pragma: no cover - registry lookup is best-effort
tool = None
declared = getattr(tool, "restart_safe", None) if tool is not None else None
return _normalize_restart_safe(declared)
def _is_restart_safe(function_name: str, executor: Optional[Callable] = None) -> bool:
"""Resolve whether a tool is safe to re-run for an in-flight durable resume.
An explicit ``restart_safe`` declaration on the tool contract wins. When the
tool is undeclared (or cannot be resolved), fall back to the read-only name
heuristic and, ultimately, fail closed — an effectful/unknown tool is *not*
replayed so a missing declaration never causes a duplicated side effect.
"""
if not function_name:
return False
declared = _declared_restart_safe(function_name, executor)
if declared is not None:
return declared
try:
from .tool_execution import ToolExecutionMixin
return bool(ToolExecutionMixin._is_read_only_tool(function_name))
except Exception: # pragma: no cover - heuristic import is best-effort
return False
class DurableRunContext:
"""Per-turn journal state; safe for parallel tool-call wrappers."""
@@ -160,10 +80,6 @@ class DurableRunContext:
self._results_by_tool_call_id: Dict[str, Any] = {}
self._pending_by_call_id: Dict[str, Tuple[int, str, Dict[str, Any], str]] = {}
self._pending_by_fingerprint: Dict[str, Tuple[int, str, Dict[str, Any], str]] = {}
# Names of in-flight tool calls (journalled tool_call with no tool_result
# at the crash). Used only when replaying to gate replay by restart-safety.
self._inflight_names_by_call_id: Dict[str, str] = {}
self._inflight_names_by_fingerprint: Dict[str, str] = {}
self._recorded_iterations = set()
for event in events:
if event.kind == KIND_TOOL_RESULT:
@@ -190,11 +106,8 @@ class DurableRunContext:
or f"{self.run_id}:{event.seq}:{name}"
)
pending = (event.seq, call_id, args, key)
fingerprint = self._fingerprint(name, args)
self._pending_by_call_id[call_id] = pending
self._pending_by_fingerprint[fingerprint] = pending
self._inflight_names_by_call_id[call_id] = name
self._inflight_names_by_fingerprint[fingerprint] = name
self._pending_by_fingerprint[self._fingerprint(name, args)] = pending
self._resume_completed_steps = len(self._recorded_iterations)
@@ -206,44 +119,6 @@ class DurableRunContext:
def _fingerprint(function_name: str, arguments: Dict[str, Any]) -> str:
return f"{function_name}:{json.dumps(_json_safe(arguments), sort_keys=True)}"
def _is_inflight_call(
self,
function_name: str,
arguments: Dict[str, Any],
tool_call_id: Optional[str],
) -> bool:
"""Whether this call was in-flight (journalled, no result) at the crash.
Only meaningful while replaying: such calls have a recorded ``tool_call``
but no ``tool_result``, so re-driving the loop would re-execute their
side effect. Completed calls (already have a result) are replayed
upstream and never reach this check.
"""
if not self.replaying:
return False
call_id = str(tool_call_id) if tool_call_id is not None else None
if call_id and call_id in self._inflight_names_by_call_id:
return True
return self._fingerprint(function_name, arguments) in (
self._inflight_names_by_fingerprint
)
@staticmethod
def _not_safely_resumable_result(function_name: str) -> Dict[str, Any]:
"""Typed, operator-visible outcome for a gated in-flight effectful tool."""
return {
"error": (
f"Tool '{function_name}' was in-flight when the durable run "
"crashed and is not declared restart_safe; its outcome could not "
"be confirmed. It was not re-executed on resume to avoid a "
"duplicate side effect. Reconcile the external action manually "
"or mark the tool @tool(restart_safe=True) if it is idempotent."
),
"error_type": "NotSafelyResumable",
"not_safely_resumable": True,
"restart_safe": False,
}
def restore_messages(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Append completed decision/result pairs once, before the next model call."""
if not self.replaying or self._restored:
@@ -402,26 +277,6 @@ class DurableRunContext:
)
self.finalize("failed")
def record_verification(self, payloads: List[Dict[str, Any]]) -> None:
"""Append verification-gate results as durable ``verification`` events.
Each payload gets its own fresh ``seq`` so the events append cleanly
alongside the tool-call cursor without colliding on ``(run_id, seq,
kind)``. Best-effort: journalling never raises into the gate.
"""
if self._closed:
return
with self._lock:
for payload in payloads:
seq = self._next_seq
self._next_seq += 1
try:
self.journal.append(
JournalEvent(self.run_id, seq, KIND_VERIFICATION, payload)
)
except Exception: # pragma: no cover - journalling is best-effort
pass
def wrap_sync(self, execute_tool_fn: Optional[Callable]) -> Optional[Callable]:
if execute_tool_fn is None:
return None
@@ -431,19 +286,9 @@ class DurableRunContext:
call_id = str(tool_call_id) if tool_call_id is not None else None
if call_id and call_id in self._results_by_tool_call_id:
return self._results_by_tool_call_id[call_id]
gate_inflight = self._is_inflight_call(
function_name, arguments, tool_call_id
) and not _is_restart_safe(function_name, execute_tool_fn)
seq, call_id, idempotency_key = self._allocate_step(
function_name, arguments, tool_call_id
)
if gate_inflight:
return self._record_result(
seq,
call_id,
self._not_safely_resumable_result(function_name),
iteration_index,
)
token = _active_idempotency_key.set(idempotency_key)
try:
call_kwargs = dict(kwargs)
@@ -474,20 +319,9 @@ class DurableRunContext:
call_id = str(tool_call_id) if tool_call_id is not None else None
if call_id and call_id in self._results_by_tool_call_id:
return self._results_by_tool_call_id[call_id]
gate_inflight = self._is_inflight_call(
function_name, arguments, tool_call_id
) and not _is_restart_safe(function_name, execute_tool_fn)
seq, call_id, idempotency_key = await asyncio.to_thread(
self._allocate_step, function_name, arguments, tool_call_id
)
if gate_inflight:
return await asyncio.to_thread(
self._record_result,
seq,
call_id,
self._not_safely_resumable_result(function_name),
iteration_index,
)
token = _active_idempotency_key.set(idempotency_key)
try:
call_kwargs = dict(kwargs)
@@ -110,8 +110,8 @@ class EmbeddingAgent:
- EmbeddingConfig: Full configuration object
verbose: Verbosity level for output
"""
from ..utils.model_alias import resolve_model_name
llm = resolve_model_name(llm, model, "EmbeddingAgent")
if llm is None and model is not None:
llm = model
self.name = name or "EmbeddingAgent"
self.instructions = instructions
@@ -1,276 +0,0 @@
"""Describe, in plain English, where an agent's work actually happens.
PraisonAI can run the thinking (LLM calls) and the tools in different places:
this machine, a container, or a provider's cloud. Which one you get depends on
``run_on=``, ``backend=`` and ``sandbox=``, and until now none of that was
visible from the object -- ``repr(agent)`` was the bare default.
This module is the single source of that answer, shared by ``Agent``,
``AgentTeam`` and ``AgentFlow`` so all three describe themselves identically.
Vocabulary note: the fields are ``thinks_on`` / ``tools_run_on``, never
``loop``. "Loop" is already a public workflow primitive here
(``praisonaiagents.workflows.loop()``), asyncio's event loop, and agent-loop
jargon a beginner would not know -- three meanings for one word is exactly the
problem this module exists to fix.
"""
from __future__ import annotations
from typing import Any, Dict, Optional
# Provider name -> how a human would say it out loud.
_PLACE_WORDS = {
"local": "this machine",
"subprocess": "a separate process on this machine",
"sandlock": "a locked-down process on this machine",
"docker": "a Docker container",
"e2b": "an E2B cloud sandbox",
"modal": "a Modal cloud sandbox",
"daytona": "a Daytona cloud sandbox",
"flyio": "a Fly.io machine",
"tenki": "a Tenki cloud sandbox",
"novita": "a Novita cloud sandbox",
"ssh": "a remote machine over SSH",
"anthropic": "Anthropic's cloud",
}
HERE = "this machine"
# Public sandbox aliases the SandboxManager collapses before it picks a backend
# (see praisonaiagents/sandbox/manager.py). We must describe the backend that
# actually runs, not the alias the user typed, or "native"/"local" would report
# a place that never executes.
_ALIASES = {"native": "sandlock", "local": "subprocess"}
# ...but only for the SANDBOX registry. "local" resolves to two different
# backends depending on which parameter carried it:
# run_in="local" -> SandboxManager collapses it to subprocess:
# scrubbed environment, blocked commands and paths
# tools_run_on="local" -> the wrapper's LocalCompute: a plain shell in the
# current directory, full environment inherited,
# no security policy at all
# Describing the second in the first's words overstates it, which is the exact
# failure this module exists to prevent. One word, two backends -- so the
# phrase has to depend on where the word came from.
_COMPUTE_ONLY_WORDS = {"local": "a plain shell on this machine (no policy applied)"}
# Tiers that separate a process but do NOT contain it. Verified: under the
# subprocess backend, outbound network still works and ~/.ssh is readable even
# though SecurityPolicy declares them blocked.
# `native` is deliberately absent: it aliases sandlock, which applies Landlock,
# seccomp, a scrubbed environment and deny-all networking. Listing it here made
# tools_run_on="native" and tools_run_on="sandlock" -- the same backend --
# disagree about whether they contain anything.
_NOT_A_BOUNDARY = {"subprocess", "local"}
def say_place(name: Any, *, via: str = "sandbox") -> str:
"""Turn a place name into a phrase a non-developer can read.
``via`` says which registry resolved the name -- ``"compute"`` for
``tools_run_on=``, ``"sandbox"`` for ``run_in=``. It only matters for names
that mean different backends in each (see ``_COMPUTE_ONLY_WORDS``).
"""
if name is None:
return HERE
if name is True:
return _PLACE_WORDS["subprocess"]
if not isinstance(name, str):
# A configured provider instance: use its own declared name if it has
# one. Normalise BEFORE the compute-only check, or a backend holding a
# LocalCompute object (provider_name="local") skips the branch and gets
# described in the subprocess backend's stronger words -- the exact
# overstatement this module exists to prevent.
name = getattr(name, "provider_name", None) or type(name).__name__
if via == "compute" and name.lower() in _COMPUTE_ONLY_WORDS:
return _COMPUTE_ONLY_WORDS[name.lower()]
key = str(name).lower()
key = _ALIASES.get(key, key)
known = _PLACE_WORDS.get(key)
if known:
return known
# A place contributed by another package can name itself; the bare name is
# the last resort rather than the only option, because the phrase table is
# a literal that cannot know about packages installed later.
try:
from ..managed._compute_bridge import contributed_display_name
declared = contributed_display_name(key)
if declared:
return declared
except Exception:
pass
return str(name)
def _backend_places(backend: Any) -> Optional[Dict[str, str]]:
"""Where does a managed ``backend=`` put the thinking and the tools?"""
if backend is None:
return None
# Ask the backend what it is, rather than guessing from its class name.
# Name-matching missed any backend that did not happen to contain "Hosted"
# or "Anthropic" -- a self-hosted docker backend reported that it was
# thinking on this machine while the whole loop ran in a container.
declared = getattr(backend, "provider_name", None)
if isinstance(declared, str):
try:
from .placement import managed_runtimes
if declared.lower() in managed_runtimes():
where = say_place(declared)
return {"thinks_on": where, "tools_run_on": where}
except Exception:
pass
cls = type(backend).__name__
# Hosted runtimes move the entire turn off this machine.
if "Hosted" in cls or "Anthropic" in cls:
provider = getattr(backend, "provider", None) or "anthropic"
where = say_place(provider)
return {"thinks_on": where, "tools_run_on": where}
# Local/sandboxed agents keep the loop here and may push tools out. A
# backend's compute object is a COMPUTE-registry provider, so it is read
# with the compute vocabulary: a LocalCompute here is the unrestricted
# shell, not the subprocess backend "local" collapses to under run_in=.
compute = getattr(backend, "_compute", None) or getattr(backend, "compute", None)
if compute is not None:
return {"thinks_on": HERE, "tools_run_on": say_place(compute, via="compute")}
return {"thinks_on": HERE, "tools_run_on": HERE}
def describe(obj: Any, *, shared: bool = False) -> Dict[str, str]:
"""Return ``{thinks_on, tools_run_on, code_runs_on?}`` for an object.
Never raises: a description of where things run must not be able to break
the thing it describes.
"""
places: Dict[str, str] = {"thinks_on": HERE, "tools_run_on": HERE}
try:
from_backend = _backend_places(getattr(obj, "backend", None))
if from_backend:
places.update(from_backend)
where_tools = getattr(obj, "tools_run_on", None)
if where_tools is not None:
where = say_place(where_tools, via="compute")
places["tools_run_on"] = f"{where} (shared)" if shared else where
# run_on= puts the WHOLE agent on a managed runtime, so it moves the
# thinking as well. _backend_places() already reports that (run_on= is
# turned into a backend at construction); this only covers an object
# that carries the name without a built backend.
whole = getattr(obj, "run_on", None)
if whole is not None and not from_backend:
everywhere = say_place(whole)
places["thinks_on"] = everywhere
places["tools_run_on"] = everywhere
sandbox_config = getattr(obj, "sandbox_config", None)
if sandbox_config is not None:
places["code_runs_on"] = say_place(
getattr(sandbox_config, "sandbox_type", None)
)
except Exception: # pragma: no cover - description must never break callers
pass
return places
def _tools_staying_here(obj: Any) -> list:
"""Names of the object's own tools, which do NOT move to the sandbox.
Only four tool names are bridged (``execute_command``, ``read_file``,
``write_file``, ``list_files``); everything else the user passed keeps
running in this process, against this filesystem. The capability prompt
tells the model the sandbox is where things happen, so anything staying
behind has to be said out loud.
Reads the pre-attach tool list when a sandbox is already attached: by then
``agent.tools`` has been rewritten and the originals live in the sandbox's
restore record.
"""
try:
from ..managed.shared_compute import BRIDGED_TOOL_NAMES
except Exception:
BRIDGED_TOOL_NAMES = ("execute_command", "read_file", "write_file", "list_files")
tools = None
shared = getattr(obj, "_tools_sandbox", None)
if shared is not None:
for patched, original_tools, _ in getattr(shared, "_patched", []):
if patched is obj:
tools = original_tools
break
if tools is None:
tools = getattr(obj, "tools", None) or []
names = []
for tool in tools:
name = getattr(tool, "__name__", None) or getattr(tool, "name", None)
if isinstance(name, str) and name not in BRIDGED_TOOL_NAMES:
names.append(name)
return names
def repr_fields(obj: Any, *, shared: bool = False) -> str:
"""Render the places as ``key='value'`` pairs for a ``__repr__``."""
return ", ".join(f"{k}={v!r}" for k, v in describe(obj, shared=shared).items())
def explain(obj: Any, *, shared: bool = False) -> str:
"""Plain-sentence answer to "where does this actually run?".
Deliberately uses only words a non-engineer already knows -- no "loop",
"harness", "provision" or "runtime".
"""
places = describe(obj, shared=shared)
lines = [
f"Thinking (the AI model calls) happens on {places['thinks_on']}.",
f"Tools run on {places['tools_run_on']}.",
]
if shared and getattr(obj, "tools_run_on", None) is not None:
lines.append(
"Every step shares that same sandbox, so a file written by one step "
"is visible to the next."
)
staying = _tools_staying_here(obj)
if staying and places["tools_run_on"] != places["thinks_on"]:
listed = ", ".join(staying[:6]) + ("..." if len(staying) > 6 else "")
lines.append(
f"Your own tools ({listed}) still run on {HERE} -- only shell, file "
f"and code tools move. They read and write this machine's files."
)
if "code_runs_on" in places:
lines.append(
f"Code you run yourself with execute_code() runs on {places['code_runs_on']}."
)
# Say plainly when the chosen tier is not a security boundary.
raw = [
getattr(obj, "tools_run_on", None),
getattr(obj, "run_on", None),
getattr(getattr(obj, "sandbox_config", None), "sandbox_type", None),
]
if any(isinstance(v, str) and v.lower() in _NOT_A_BOUNDARY for v in raw) or any(
v is True for v in raw
):
lines.append(
"Note: a separate process is not a security boundary -- it can still "
"reach the network and read your files, and its blocked-command "
"list can be worked around by ordinary shell syntax. Use docker or a cloud "
"provider for untrusted code."
)
if places["thinks_on"] == HERE and places["tools_run_on"] == HERE:
lines.append(
"Nothing is isolated: tools you pass run in this program, with your "
"permissions."
)
return "\n".join(lines)
@@ -1456,18 +1456,6 @@ Write the complete compiled report:"""
after_results = await hook_runner.execute(
HookEvent.AFTER_TOOL, after_tool_input, target=function_name
)
# Honour a post-tool block so a redacted/denied result never
# reaches the model (mirrors the BEFORE_TOOL block path above).
if hook_runner.is_blocked(after_results):
reason = hook_runner.get_blocking_reason(after_results)
logging.warning(f"Tool {function_name} result blocked by AFTER_TOOL hook")
return _record_async_loop_outcome(
reason or f"Result of {function_name} was blocked by security policy."
)
# Read back any rewritten/redacted tool_output so a plugin can
# scrub secrets before the result reaches the model.
if getattr(after_tool_input, "tool_output", result) is not result:
result = after_tool_input.tool_output
extra_context = hook_runner.aggregate_context(after_results)
if extra_context:
if isinstance(result, str):
@@ -1519,7 +1507,6 @@ Write the complete compiled report:"""
run_id=getattr(self, '_current_run_id', 'unknown'),
session_id=getattr(self, '_session_id', None) or 'default',
tool_name=function_name,
metadata={"tool_call_id": tool_call_id},
),
)
@@ -1541,7 +1528,6 @@ Write the complete compiled report:"""
async def _execute_tool_async_with_retry(self, function_name: str, arguments: Dict[str, Any], tool_call_id: Optional[str] = None, tools_override: Optional[List] = None) -> Any:
"""Async tool execution with retry policy (middleware-agnostic core)."""
from ..errors import ToolExecutionError
# Get retry policy (tool-level > agent-level > default)
retry_policy = self._get_tool_retry_policy(function_name)
@@ -1558,39 +1544,22 @@ Write the complete compiled report:"""
# Skip retry for non-retryable errors (approval, permission, etc.).
# `retryable is False` covers async tool timeouts, whose executor
# work cannot be cancelled — retrying would duplicate side effects.
# `loop_blocked` is a terminal loop-guard decision — retrying would
# defeat the doom-loop protection this stop is meant to add.
if (result.get("approval_denied") or
result.get("permission_denied") or
result.get("approval_error") or
result.get("circuit_open") or
result.get("loop_blocked") or
result.get("_praison_retryable") is False):
result.pop("_praison_retryable", None)
result.get("retryable") is False):
return result
# Determine error type for retry policy
error_type = self._classify_error_type(result, last_exception)
# Check if we should retry. A tool that *raised* is flattened
# into an error dict before it can be classified, so it always
# arrives here as "unknown" — which no default retry_on
# contains. The sync path escalates exactly those error types
# to its own retry loop (tool_execution.py:853-866); honouring
# the impl's explicit `retryable` flag is how the async path
# reaches the same decision instead of never retrying at all.
# `_praison_retryable` is a private control-plane key (see
# _execute_tool_async_impl): a tool's own result may legitimately
# contain "retryable" (common for HTTP-API wrappers) and must
# never steer our retry loop.
if (not retry_policy.should_retry(error_type, attempt)
and result.get("_praison_retryable") is not True):
result.pop("_praison_retryable", None)
# Check if we should retry
if not retry_policy.should_retry(error_type, attempt):
return result
# Don't retry on last attempt
if attempt == retry_policy.max_attempts - 1:
result.pop("_praison_retryable", None)
return result
# Emit retry hook event
@@ -1604,11 +1573,6 @@ Write the complete compiled report:"""
# Success - return result
return result
except ToolExecutionError:
# A loop-guard HALT (raised in _execute_tool_async_impl) is a
# terminal, non-retryable decision — let it propagate to stop the
# run instead of retrying or swallowing it into an error dict.
raise
except Exception as e:
last_exception = e
# Determine if retryable
@@ -1640,7 +1604,6 @@ Write the complete compiled report:"""
async def _execute_tool_async_impl(self, function_name: str, arguments: Dict[str, Any], tool_call_id: Optional[str] = None, tools_override: Optional[List] = None) -> Any:
"""Internal async tool execution implementation"""
from ..errors import ToolExecutionError
try:
logging.info(f"Executing async tool: {function_name} with arguments: {arguments}")
@@ -1688,29 +1651,6 @@ Write the complete compiled report:"""
logging.error(f"Function {function_name} not found in tools")
return {"error": f"Function {function_name} not found in tools"}
# Loop guard (pre-execution) — parity with the sync path in
# tool_execution.py. The async path previously had no loop-guard or
# doom-loop protection, so a repeatedly-failing tool could be hammered
# every turn via achat()/async workflows. check() is pure/sync, so it
# is safe to call here without awaiting.
loop_guard = None
if hasattr(self, '_ensure_loop_guard'):
loop_guard = self._ensure_loop_guard()
from ..escalation.loop_guard import GuardAction
decision = loop_guard.check(function_name, arguments, is_pre_execution=True)
if decision.action == GuardAction.BLOCK:
logging.warning(f"Loop guard blocked {function_name}: {decision.message}")
return {"error": f"[loop-guard] {decision.message}", "loop_blocked": True}
elif decision.action == GuardAction.HALT:
raise ToolExecutionError(
f"[loop-guard] {decision.message}",
tool_name=function_name,
agent_id=self.name,
is_retryable=False,
)
elif decision.action == GuardAction.WARN:
logging.warning(f"Loop guard warning for {function_name}: {decision.message}")
# Activate the tool-progress channel so tools running under the async
# path can stream incremental output (emit_tool_progress) and the
# built-in todo tool can publish live updates (emit_todo_update),
@@ -1783,33 +1723,14 @@ Write the complete compiled report:"""
# a duplicate invocation while the original keeps executing —
# duplicating DB writes / API calls / file mutations. Surface the
# timeout once instead of re-running an uncancellable side effect.
# Fall through (not return) so the loop guard records this
# timeout as a failure — repeated timeouts must accumulate
# toward the BLOCK/HALT thresholds like any other failure.
result = {
return {
"error": f"Tool timed out after {tool_timeout}s",
"timeout": True,
"_praison_retryable": False,
"retryable": False,
}
else:
result = await _invoke()
# Loop guard (post-execution) — record the outcome and surface a
# block/halt decision back to the model on this same turn, mirroring
# the sync path in tool_execution.py so repeated failures accumulate
# toward the BLOCK/HALT thresholds instead of retrying unbounded.
if loop_guard is not None:
is_success = result is not None and not (isinstance(result, dict) and result.get('error'))
loop_guard.record(function_name, arguments, is_success, result=result)
decision = loop_guard.check(function_name, arguments, is_pre_execution=False)
if decision.action.value in ("warn", "block", "halt"):
if isinstance(result, str):
result = f"{result}\n\n[loop-guard] {decision.message}"
elif isinstance(result, dict):
result["_loop_guard"] = {"message": decision.message, "action": decision.action.value}
else:
result = {"value": result, "_loop_guard": {"message": decision.message, "action": decision.action.value}}
# Ensure result is JSON serializable
logging.debug(f"Raw result from tool: {result}")
if result is None:
@@ -1823,61 +1744,24 @@ Write the complete compiled report:"""
except Exception as e:
logging.error(f"Error executing {function_name}: {str(e)}", exc_info=True)
# Record the failed invocation so repeated identical failures
# accumulate toward the loop-guard BLOCK/HALT thresholds — a raised
# tool exception is a failure just like an error-dict result on the
# sync path. The next pre-execution check will then BLOCK/HALT.
if loop_guard is not None:
loop_guard.record(function_name, arguments, False, result=None)
# Carry the retry verdict with the flattened exception. Without it
# the caller only sees an opaque error string, classifies it as
# "unknown" (never in RetryPolicy.retry_on) and gives up — while
# the sync path, which still has the exception object, retries.
# Mirror the sync path exactly: a ToolExecutionError already
# carries an explicit is_retryable verdict (e.g. a terminal
# non-retryable failure) and must be honoured verbatim; for any
# other exception apply the same terminal rule — programming
# errors are terminal, everything else may be retried.
if isinstance(e, ToolExecutionError):
retryable = e.is_retryable
else:
retryable = not isinstance(e, (ValueError, TypeError, AttributeError))
# Private control-plane key: a tool's own result may legitimately
# contain "retryable" (common for HTTP-API wrappers) and must not
# collide with the framework's verdict nor leak to the model.
return {
"error": f"Error executing {function_name}: {str(e)}",
"_praison_retryable": retryable,
}
return {"error": f"Error executing {function_name}: {str(e)}"}
except ToolExecutionError:
# A loop-guard HALT must propagate to stop the run, mirroring the sync
# path; do not swallow it into an error dict.
raise
except Exception as e:
logging.error(f"Error in execute_tool_async: {str(e)}", exc_info=True)
return {"error": f"Error in execute_tool_async: {str(e)}"}
def launch(self, path: str = '/', port: int = 8000, host: str = '127.0.0.1', debug: bool = False, protocol: str = "http"):
def launch(self, path: str = '/', port: int = 8000, host: str = '0.0.0.0', debug: bool = False, protocol: str = "http"):
"""
Launch the agent as an HTTP API endpoint or MCP server.
This method now delegates to protocol-based launchers to follow
the protocol-driven architecture. Heavy implementations (FastAPI/uvicorn)
are only imported when needed through lazy loading.
Request authorisation matches ``PraisonAIAgents.launch``: when
``PRAISONAI_LAUNCH_AUTH_TOKEN`` is set, every HTTP route requires that
bearer token (401 otherwise). When it is unset and ``host`` is
non-loopback, a one-time token is generated and printed rather than
serving an unauthenticated endpoint on all interfaces.
Args:
path: API endpoint path (default: '/') for HTTP, or base path for MCP.
port: Server port (default: 8000)
host: Server host (default: '127.0.0.1'; changed from '0.0.0.0' to
avoid binding all interfaces by default — pass '0.0.0.0'
explicitly to expose externally).
host: Server host (default: '0.0.0.0')
debug: Enable debug mode (default: False)
protocol: "http" to launch as FastAPI, "mcp" to launch as MCP server.
@@ -1901,8 +1785,6 @@ Write the complete compiled report:"""
"""
global _server_started, _registered_agents, _shared_apps, _server_lock
from .launch_security import authorise_launch_request, resolve_launch_host
# Try to import FastAPI dependencies - lazy loading
try:
import uvicorn
@@ -1927,15 +1809,7 @@ Write the complete compiled report:"""
print("\nOr install all API dependencies with:")
print("pip install 'praisonaiagents[api]'")
return None
# Fail-closed bind guard: refuse to serve keyless on a non-loopback host
# (auto-generates and prints a one-time token instead). Matches the
# serve/jobs precedent and keeps this path in lock-step with
# PraisonAIAgents.launch. Applied only after the dependency guard above
# so a launch that bails out (missing deps) cannot mutate the
# process-wide launch token and retroactively 401 a running endpoint.
host = resolve_launch_host(host)
should_start = False
with _server_lock:
# Initialize port-specific collections if needed (once per port)
@@ -1988,8 +1862,6 @@ Write the complete compiled report:"""
# Define the endpoint handler
@_shared_apps[port].post(path)
async def handle_agent_query(request: Request, query_data: Optional[AgentQuery] = None):
if not authorise_launch_request(request):
raise HTTPException(status_code=401, detail="Unauthorized")
# Handle both direct JSON with query field and form data
if query_data is None:
try:
@@ -14,7 +14,6 @@ from typing import Optional, Any, Callable, Dict, List, Union, TYPE_CHECKING, Li
from dataclasses import dataclass, field
from enum import Enum
import inspect
from contextlib import contextmanager
from praisonaiagents._logging import get_logger
import asyncio
import contextvars
@@ -470,46 +469,12 @@ class Handoff:
return value
return 'gpt-4o-mini'
@contextmanager
def _seed_target_history(self, prior_messages: Optional[List[Any]]):
"""Temporarily seed the filtered handoff context onto the target agent.
The configured ``context_policy``/``input_filter`` produce the messages the
target should see; without seeding them the target only ever received the
literal handoff prompt string and the configured policy had no effect.
The seeding is *invocation-scoped*: the prior messages are prepended for the
duration of this handoff and the target's original ``chat_history`` is
restored on exit. This keeps handoff context from leaking into later
handoffs or ordinary chats that reuse the same target agent, and stops the
target prompt from growing across sequential handoffs.
No shared mutable state is retained between agents.
"""
existing = getattr(self.agent, 'chat_history', None)
if not prior_messages or not isinstance(existing, list):
yield
return
try:
prior = list(prior_messages)
except TypeError:
yield
return
original = existing
try:
# Avoid re-seeding if the context is already at the head.
if existing[:len(prior)] != prior:
self.agent.chat_history = prior + existing
yield
finally:
self.agent.chat_history = original
def _execute_with_runtime_resolution(
self,
source_agent: 'Agent',
prompt: str,
effective_tools: Optional[List[Any]],
context: Dict[str, Any],
prior_messages: Optional[List[Any]] = None,
context: Dict[str, Any]
) -> str:
"""Execute handoff through the target agent's full chat pipeline."""
target_model_ref = self._extract_model_ref(self.agent)
@@ -519,16 +484,14 @@ class Handoff:
target_model_ref,
_get_handoff_depth(),
)
with self._seed_target_history(prior_messages):
return self.agent.chat(prompt, tools=effective_tools)
return self.agent.chat(prompt, tools=effective_tools)
async def _execute_with_runtime_resolution_async(
self,
source_agent: 'Agent',
prompt: str,
effective_tools: Optional[List[Any]],
context: Dict[str, Any],
prior_messages: Optional[List[Any]] = None,
context: Dict[str, Any]
) -> str:
"""Execute handoff through the target agent's full async chat pipeline."""
target_model_ref = self._extract_model_ref(self.agent)
@@ -538,14 +501,13 @@ class Handoff:
target_model_ref,
_get_handoff_depth(),
)
with self._seed_target_history(prior_messages):
async_chat = getattr(self.agent, 'achat', None)
if callable(async_chat) and inspect.iscoroutinefunction(async_chat):
return await async_chat(prompt, tools=effective_tools)
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
None, lambda: self.agent.chat(prompt, tools=effective_tools)
)
async_chat = getattr(self.agent, 'achat', None)
if callable(async_chat) and inspect.iscoroutinefunction(async_chat):
return await async_chat(prompt, tools=effective_tools)
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
None, lambda: self.agent.chat(prompt, tools=effective_tools)
)
def _check_safety(self, source_agent: 'Agent') -> None:
"""
@@ -699,9 +661,8 @@ class Handoff:
# Execute on_handoff callback
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
# Prepare context per the configured context_policy/input_filter and
# thread the filtered history through to the target agent.
handoff_data = self._prepare_context(source_agent, kwargs)
# Prepare context (for future extensibility - context data available for hooks)
_ = self._prepare_context(source_agent, kwargs)
# Build prompt with context
context_prefix = f"[Handoff from {source_agent.name}] "
@@ -716,8 +677,7 @@ class Handoff:
# Resolve runtime at turn-time instead of using construction-time pin
response = self._execute_with_runtime_resolution(
source_agent, full_prompt, effective_tools, kwargs,
prior_messages=handoff_data.messages,
source_agent, full_prompt, effective_tools, kwargs
)
result = HandoffResult(
@@ -809,9 +769,8 @@ class Handoff:
# Execute callback
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
# Prepare context per the configured context_policy/input_filter
# and thread the filtered history through to the target agent.
handoff_data = self._prepare_context(source_agent, kwargs)
# Prepare context (for future extensibility)
_ = self._prepare_context(source_agent, kwargs)
# Build prompt
context_prefix = f"[Handoff from {source_agent.name}] "
@@ -826,8 +785,7 @@ class Handoff:
# Resolve runtime at turn-time for async execution
response = await self._execute_with_runtime_resolution_async(
source_agent, full_prompt, effective_tools, kwargs,
prior_messages=handoff_data.messages,
source_agent, full_prompt, effective_tools, kwargs
)
result = HandoffResult(
@@ -940,39 +898,20 @@ class Handoff:
# Prepare handoff data with context policy
handoff_data = self._prepare_context(source_agent, kwargs)
# Get the last user message or context to pass to target agent.
# Track the index of the message chosen for ``prompt`` so exactly
# that item is excluded from the seeded history (it may not be the
# final list item if an assistant/tool message follows it).
# Get the last user message or context to pass to target agent
last_message = None
selected_index = None
for i in range(len(handoff_data.messages) - 1, -1, -1):
msg = handoff_data.messages[i]
for msg in reversed(handoff_data.messages):
if isinstance(msg, dict) and msg.get('role') == 'user':
last_message = msg.get('content', '')
selected_index = i
break
if not last_message and handoff_data.messages:
selected_index = len(handoff_data.messages) - 1
last_msg = handoff_data.messages[selected_index]
last_msg = handoff_data.messages[-1]
if isinstance(last_msg, dict):
last_message = last_msg.get('content', '')
else:
last_message = str(last_msg)
# Tool-style handoff (as_tool): ContextPolicy.NONE discards the
# history, so the prompt must come from the generated ``task``/
# ``prompt`` argument instead. Without this the sub-agent would
# never run and the tool would report a hollow "completed". The
# task is the sub-agent's whole instruction, so it is passed
# verbatim rather than wrapped in a ``[Handoff from …]`` prefix.
task_prompt = None
if not last_message and not self.input_type:
task_prompt = kwargs.get('task') or kwargs.get('prompt')
if task_prompt:
last_message = str(task_prompt)
# Prepare context information
context_info = f"[Handoff from {source_agent.name}] "
if kwargs and self.input_type:
@@ -980,26 +919,15 @@ class Handoff:
# Execute the target agent with tool boundary enforcement
if last_message:
prompt = last_message if task_prompt else context_info + last_message
prompt = context_info + last_message
logger.info(f"Handing off to {self.agent.name} with prompt: {prompt}")
# Compute effective tools based on tool policy
effective_tools = self._compute_effective_tools(source_agent)
# Resolve runtime at turn-time for tool function execution.
# The selected message is already folded into ``prompt`` above,
# so exclude exactly that item from the seeded history to avoid
# duplicating it while preserving any messages that follow it.
if selected_index is None:
prior_messages = list(handoff_data.messages)
else:
prior_messages = (
handoff_data.messages[:selected_index]
+ handoff_data.messages[selected_index + 1:]
)
# Resolve runtime at turn-time for tool function execution
response = self._execute_with_runtime_resolution(
source_agent, prompt, effective_tools, kwargs,
prior_messages=prior_messages,
source_agent, prompt, effective_tools, kwargs
)
result = HandoffResult(
@@ -1067,17 +995,6 @@ class Handoff:
)
)
handoff_tool.__signature__ = inspect.Signature(sig_params)
elif self.config.context_policy == ContextPolicy.NONE:
# Tool-style handoff (as_tool): no conversation context is passed, so
# advertise an explicit ``task`` parameter the caller must fill. This
# is what ``handoff_tool`` reads as the sub-agent's prompt above.
handoff_tool.__signature__ = inspect.Signature([
inspect.Parameter(
'task',
inspect.Parameter.KEYWORD_ONLY,
annotation=str,
)
])
return handoff_tool
@@ -1551,9 +1468,8 @@ class TypedHandoff(Handoff, Generic[T]):
# Execute on_handoff callback
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
# Apply context policy / input filter; the resulting messages are
# seeded onto the target only for the duration of its chat below.
handoff_data = self._prepare_context(source_agent, kwargs)
# Apply input filter if provided (fixes Greptile P1 issue)
_ = self._prepare_context(source_agent, kwargs)
# Validate payload against schema
try:
@@ -1580,8 +1496,7 @@ class TypedHandoff(Handoff, Generic[T]):
logger.info(f"Typed handoff to {self.agent.name} with validated payload")
# Execute with timeout if sync
with self._seed_target_history(handoff_data.messages):
response = self.agent.chat(full_prompt)
response = self.agent.chat(full_prompt)
result = HandoffResult(
success=True,
@@ -1666,9 +1581,8 @@ class TypedHandoff(Handoff, Generic[T]):
# Execute callback
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
# Apply context policy / input filter; the resulting messages are
# seeded onto the target only for the duration of its chat below.
handoff_data = self._prepare_context(source_agent, kwargs)
# Apply input filter if provided (fixes Greptile P1 issue)
_ = self._prepare_context(source_agent, kwargs)
# Validate payload against schema
try:
@@ -1694,13 +1608,12 @@ class TypedHandoff(Handoff, Generic[T]):
logger.info(f"Async typed handoff to {self.agent.name}")
# Execute - check for async chat method
with self._seed_target_history(handoff_data.messages):
if hasattr(self.agent, 'achat'):
response = await self.agent.achat(full_prompt)
else:
# Run sync chat in executor
loop = asyncio.get_event_loop()
response = await loop.run_in_executor(None, self.agent.chat, full_prompt)
if hasattr(self.agent, 'achat'):
response = await self.agent.achat(full_prompt)
else:
# Run sync chat in executor
loop = asyncio.get_event_loop()
response = await loop.run_in_executor(None, self.agent.chat, full_prompt)
return HandoffResult(
success=True,
@@ -1,88 +0,0 @@
"""Shared authorisation + bind guard for the ``launch()`` serving paths.
Both ``Agent.launch()`` (``agent/execution_mixin.py``) and
``PraisonAIAgents.launch()`` (``agents/agents.py``) expose a prompt-execution
HTTP endpoint. They must enforce identical request authorisation and must not
silently bind all interfaces without a token. Factoring the two helpers here
keeps that policy in one place so the two launch paths cannot drift apart.
Stdlib only — no heavy imports, no new public params.
"""
from __future__ import annotations
import os
import secrets as _secrets
from typing import Optional
__all__ = [
"launch_auth_token",
"authorise_launch_request",
"resolve_launch_host",
]
# Hosts that are safe to serve keyless on: loopback only.
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
# One auto-generated token per process, reused across launches so a later
# non-loopback launch cannot mint a fresh token and lock out an endpoint that
# is already serving with the previously generated one.
_GENERATED_TOKEN: Optional[str] = None
def launch_auth_token() -> Optional[str]:
"""Return the configured launch bearer token, if any."""
return os.environ.get("PRAISONAI_LAUNCH_AUTH_TOKEN")
def authorise_launch_request(request) -> bool:
"""Return True if the request carries the configured bearer token.
When no token is configured this returns True (local-dev flow preserved).
Accepts either ``Authorization: Bearer <token>`` or ``X-Auth-Token``.
"""
token = launch_auth_token()
if not token:
return True
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer ") and _secrets.compare_digest(auth[7:], token):
return True
supplied = request.headers.get("X-Auth-Token", "")
return bool(supplied) and _secrets.compare_digest(supplied, token)
def _is_loopback(host: str) -> bool:
return host in _LOOPBACK_HOSTS
def resolve_launch_host(host: str) -> str:
"""Fail-closed bind guard, mirroring the ``serve``/jobs precedent.
* Token set → bind ``host`` unchanged (auth protects the endpoint).
* Token unset + loopback host → bind unchanged (local dev unchanged).
* Token unset + non-loopback host → auto-generate a token, export it via
``PRAISONAI_LAUNCH_AUTH_TOKEN`` so every route enforces it, and print it
once. This closes the unauthenticated-exposure hole without refusing to
start.
Returns the host to bind (unchanged; the guard acts on the token, not the
host, so an explicit ``0.0.0.0`` is still honoured — just never keyless).
A generated token is reused for the lifetime of the process: repeated
non-loopback launches share the one token rather than each minting a fresh
one, so a later launch cannot invalidate an already-running endpoint's
clients.
"""
if launch_auth_token() or _is_loopback(host):
return host
global _GENERATED_TOKEN
if _GENERATED_TOKEN is None:
_GENERATED_TOKEN = _secrets.token_urlsafe(32)
print(
f"🔐 launch() bound to non-loopback host {host!r} without "
f"PRAISONAI_LAUNCH_AUTH_TOKEN set. Generated a one-time bearer token "
f"(set PRAISONAI_LAUNCH_AUTH_TOKEN to override): {_GENERATED_TOKEN}"
)
os.environ["PRAISONAI_LAUNCH_AUTH_TOKEN"] = _GENERATED_TOKEN
return host
@@ -89,8 +89,8 @@ class OCRAgent:
ocr: OCR configuration
verbose: Verbosity level for output
"""
from ..utils.model_alias import resolve_model_name
llm = resolve_model_name(llm, model, "OCRAgent")
if llm is None and model is not None:
llm = model
self.name = name or "OCRAgent"
self.instructions = instructions
@@ -1,211 +0,0 @@
"""One vocabulary for "where does this run?", shared by Agent, AgentFlow and AgentTeam.
Two questions get asked about placement and they have different answers:
``run_on=``
Where the **whole agent** runs -- the model calls, the loop, the tools, all
of it on someone else's machine. Only a *managed runtime* can answer this,
because running the loop remotely means something has to host the loop.
``tools_run_on=``
Where **the tools** run. Thinking stays on this machine; only the shell,
the file writes and the code execution move. Any sandbox or compute place
can answer this, because all it has to do is run a command.
Keeping the two words apart is the entire point. The old surface used one word
(``run_on=``) on ``AgentFlow``/``AgentTeam`` for the tools-only meaning, another
(``compute=``) on ``LocalAgent`` for exactly the same meaning, and a third
(``backend=``) for the whole-agent meaning -- so the same concept had two names
and one name had two concepts.
Because the two questions have disjoint answer sets, a wrong pairing is a typo
worth catching rather than a preference worth honouring. ``run_on="docker"``
cannot mean anything: Docker does not host agent loops. It gets a ``TypeError``
naming the parameter the user actually wanted, not a silent fallback.
"""
from __future__ import annotations
from typing import Any, Literal, NamedTuple, Optional, Union
# Runtimes that can host an entire agent loop. Resolved from the wrapper's
# entry-point registry when it is installed so third-party backends work with
# `run_on=` too; the literal is the floor, not the ceiling.
_FALLBACK_MANAGED = ("anthropic",)
# Static spellings so editors can autocomplete and type-checkers can reject a
# typo at the call site rather than at the first model turn. The runtime
# functions below stay the authority for validation -- these exist for tooling,
# which cannot call a function to learn a set.
# Keep in sync with _compute_bridge._PROVIDERS | _sandbox_adapter.SANDBOX_ONLY.
ManagedRuntime = Literal["anthropic"]
ToolPlace = Literal[
"local", "subprocess", "sandlock", "docker", "e2b",
"modal", "daytona", "flyio", "tenki", "novita", "ssh",
]
def managed_runtimes() -> tuple:
"""Names that ``run_on=`` accepts -- places that host a whole agent."""
try:
from praisonai.integrations.backend_registry import get_backend_registry
names = tuple(get_backend_registry().list_all_names())
if names:
return tuple(sorted(set(names) | set(_FALLBACK_MANAGED)))
except Exception:
pass
return _FALLBACK_MANAGED
def tool_places() -> list:
"""Names that ``tools_run_on=`` accepts -- places that can run a command.
Includes the resolver's aliases. Validation runs before resolution, so
leaving them out rejected spellings the resolver handles happily:
``tools_run_on="native"`` raised "not a known place" while
``resolve_compute("native")`` returned a working sandlock adapter.
"""
try:
from ..managed._compute_bridge import _ALIASES, available_providers
return sorted(set(available_providers()) | set(_ALIASES))
except Exception:
return ["local", "docker"]
class Placement(NamedTuple):
"""The resolved answer to "where does this run?"."""
whole: Optional[Any] = None # managed runtime name/object, or None
tools: Optional[Any] = None # tools place name/object, or None
def _name_of(value: Any) -> Optional[str]:
"""The comparable name of a placement value, or None if it is an object.
Objects (a built provider, a live ``SSHSandbox``) carry their own config and
are never second-guessed -- only bare strings get routed by name.
"""
return value.lower() if isinstance(value, str) else None
def _quote_list(names) -> str:
return ", ".join(repr(n) for n in names)
def resolve_placement(
owner: str,
*,
run_on: Any = None,
tools_run_on: Any = None,
backend: Any = None,
supports_run_on: bool = True,
) -> Placement:
"""Validate placement arguments and return where things should run.
``owner`` is the class name, used only to make errors read naturally.
``supports_run_on`` is False for AgentFlow/AgentTeam, which can place tools
but cannot (yet) hand a whole multi-agent run to a managed runtime.
Raises:
TypeError: on any combination that names two different places for the
same thing, or a place that cannot do the job asked of it.
"""
managed = managed_runtimes()
places = tool_places()
# ── a place that cannot do the job asked of it ───────────────────────────
run_on_name = _name_of(run_on)
if run_on_name is not None and run_on_name not in managed:
if run_on_name in places:
raise TypeError(
f"{owner}(run_on={run_on!r}) is not valid: run_on= places the "
f"whole agent -- model calls, loop and tools -- on a managed "
f"runtime, and {run_on_name!r} runs commands but cannot host an "
f"agent loop.\n"
f" To run only the tools there: {owner}(tools_run_on={run_on!r})\n"
f" Runtimes that host a whole agent: {_quote_list(managed)}"
)
raise TypeError(
f"{owner}(run_on={run_on!r}) is not a known managed runtime.\n"
f" Runtimes that host a whole agent: {_quote_list(managed)}\n"
f" Places that can run tools: {_quote_list(places)} "
f"(pass those as tools_run_on=)"
)
tools_name = _name_of(tools_run_on)
if tools_name is not None and tools_name in managed and tools_name not in places:
raise TypeError(
f"{owner}(tools_run_on={tools_run_on!r}) is not valid: {tools_name!r} "
f"hosts an entire agent, not individual tools.\n"
f" Did you mean: {owner}(run_on={tools_run_on!r})"
)
# A typo here used to survive construction and only surface on the first
# model turn -- after the prompt was built and, for a real run, after the
# API spend. run_on= validates at the call site and YAML validates at load
# time; this is the same standard for the third spelling.
if tools_name is not None and tools_name not in places:
raise TypeError(
f"{owner}(tools_run_on={tools_run_on!r}) is not a known place.\n"
f" Places that can run tools: {_quote_list(places)}\n"
f" Runtimes that host a whole agent: {_quote_list(managed)} "
f"(pass those as run_on=)"
)
# Names that cannot work as a bare string, because the connection details
# live in the object. Catch it now rather than at first use.
if tools_name is not None:
try:
from ..managed._sandbox_adapter import NEEDS_INSTANCE
except Exception:
NEEDS_INSTANCE = {}
if tools_name in NEEDS_INSTANCE:
raise TypeError(
f"{owner}(tools_run_on={tools_run_on!r}) needs more than a name: "
f"{NEEDS_INSTANCE[tools_name]}"
)
# ── two names for the same thing ─────────────────────────────────────────
if run_on is not None and backend is not None:
raise TypeError(
f"{owner}(run_on=..., backend=...) sets the agent's runtime twice. "
f"run_on={run_on!r} is the short form of backend=HostedAgent("
f"provider={run_on!r}); pass one or the other.\n"
f" Use backend= only when you need to configure the runtime "
f"(model, system prompt, tools)."
)
if run_on is not None and tools_run_on is not None:
raise TypeError(
f"{owner}(run_on={run_on!r}, tools_run_on={tools_run_on!r}) points "
f"the tools at two machines. run_on= already runs everything -- "
f"including tools -- on {run_on!r}.\n"
f" Whole agent remote: {owner}(run_on={run_on!r})\n"
f" Only tools remote: {owner}(tools_run_on={tools_run_on!r})"
)
if backend is not None and tools_run_on is not None:
raise TypeError(
f"{owner}(backend=..., tools_run_on={tools_run_on!r}) points the "
f"tools at two machines: backend= runs the whole agent (tools "
f"included) on its managed runtime, so the tools cannot also run on "
f"{tools_run_on!r}.\n"
f" Drop tools_run_on= to keep the hosted runtime, or drop backend= "
f"to keep local thinking with remote tools."
)
# ── a class that cannot answer this question ─────────────────────────────
if run_on is not None and not supports_run_on:
raise TypeError(
f"{owner}(run_on={run_on!r}) is not supported: run_on= hands one "
f"agent's whole loop to a managed runtime, and a {owner} "
f"orchestrates several agents locally.\n"
f" To run every step's tools in one shared sandbox:\n"
f" {owner}(tools_run_on={run_on!r})\n"
f" To host a single agent's loop, put run_on= on that Agent."
)
return Placement(whole=run_on, tools=tools_run_on)
@@ -57,8 +57,7 @@ class RealtimeAgent:
def __init__(
self,
name: str = "RealtimeAgent",
llm: Optional[str] = None, # Deprecated alias for model=
model: Optional[str] = None,
llm: Optional[str] = None,
realtime: Optional[Union[bool, Dict, RealtimeConfig]] = None,
instructions: Optional[str] = None,
verbose: bool = True,
@@ -68,16 +67,14 @@ class RealtimeAgent:
Args:
name: Agent name
llm: Deprecated alias for model=. Passing both raises TypeError.
model: Model to use (default: gpt-4o-realtime-preview)
llm: LLM model (default: gpt-4o-realtime-preview)
realtime: Realtime configuration (bool, dict, or RealtimeConfig)
instructions: System instructions
verbose: Enable verbose output
**kwargs: Additional arguments
"""
from ..utils.model_alias import resolve_model_name
self.name = name
self.llm = resolve_model_name(llm, model, "RealtimeAgent") or "gpt-4o-realtime-preview"
self.llm = llm or "gpt-4o-realtime-preview"
self.instructions = instructions
self.verbose = verbose
@@ -52,61 +52,19 @@ class SandboxMixin:
return self._sandbox_manager
def _manager_for(self, run_in: Optional[Union[bool, str, 'SandboxConfig']]):
"""Return the sandbox manager for a ``run_in=`` argument.
``run_in=`` names where a single ``execute_code()`` call runs, so the
place lives on the call that uses it rather than on the constructor.
``Agent(sandbox=...)`` still sets the default for every call.
Managers are cached per place: a fresh ``SandboxManager`` per call would
start and tear down a container each time, which for Docker is seconds
of latency and for any backend means the previous call's files are gone.
"""
if run_in is None:
if not self.has_sandbox:
raise RuntimeError(
"No sandbox configured. Either name a place on the call --\n"
" agent.execute_code_sync(code, run_in='docker')\n"
"or set a default for every call with Agent(sandbox='docker')."
)
return self.get_sandbox_manager()
from ..sandbox import SandboxConfig, SandboxManager
if run_in is True:
config = SandboxConfig.subprocess()
elif isinstance(run_in, str):
config = SandboxConfig(sandbox_type=run_in)
else:
config = run_in
key = getattr(config, "sandbox_type", None) or repr(config)
cache = getattr(self, "_sandbox_managers", None)
if cache is None:
cache = self._sandbox_managers = {}
if key not in cache:
cache[key] = SandboxManager(config)
return cache[key]
async def execute_code(
self,
code: str,
language: str = "python",
check_security: bool = True,
run_in: Optional[Union[bool, str, 'SandboxConfig']] = None,
**kwargs
) -> 'SandboxResult':
"""Execute code safely in a sandbox.
"""Execute code safely in configured sandbox.
Args:
code: Code to execute
language: Programming language (python, bash, etc.)
check_security: Whether to run security pre-checks
run_in: Where to run this call -- "docker", "sandlock",
"subprocess", a SandboxConfig, or True for the default local
sandbox. Overrides Agent(sandbox=...) for this call only.
**kwargs: Additional arguments passed to sandbox.execute()
Returns:
@@ -115,39 +73,31 @@ class SandboxMixin:
Raises:
RuntimeError: If no sandbox is configured
"""
manager = self._manager_for(run_in)
if not self.has_sandbox:
raise RuntimeError(
"No sandbox configured. Set sandbox=True or provide SandboxConfig to Agent()"
)
# Security pre-check if enabled
security_warnings = None
if check_security:
from ..sandbox import check_code_safety, format_warnings
security_warnings = check_code_safety(code, language)
if security_warnings:
warning_text = format_warnings(security_warnings)
warnings = check_code_safety(code, language)
if warnings:
warning_text = format_warnings(warnings)
if self.verbose:
logger.warning(f"Security warnings for code execution:\n{warning_text}")
result = await manager.run_code(code, language=language, **kwargs)
# Attach warnings to the result rather than forwarding a `metadata`
# kwarg: SandboxProtocol.execute() has no such parameter, so passing it
# through raised TypeError for any code that tripped a warning -- i.e.
# sandboxed execution failed exactly when the check mattered most.
if security_warnings:
try:
existing = getattr(result, "metadata", None) or {}
existing["security_warnings"] = security_warnings
result.metadata = existing
except Exception: # pragma: no cover - result may be immutable
logger.debug("Could not attach security warnings to sandbox result")
return result
# Store warnings in result metadata
kwargs.setdefault('metadata', {})['security_warnings'] = warnings
manager = self.get_sandbox_manager()
return await manager.run_code(code, language=language, **kwargs)
def execute_code_sync(
self,
code: str,
language: str = "python",
check_security: bool = True,
run_in: Optional[Union[bool, str, 'SandboxConfig']] = None,
**kwargs
) -> 'SandboxResult':
"""Synchronous wrapper for execute_code.
@@ -162,15 +112,12 @@ class SandboxMixin:
SandboxResult with execution details
"""
from ..approval.utils import run_coroutine_safely
return run_coroutine_safely(
self.execute_code(code, language, check_security, run_in=run_in, **kwargs)
)
return run_coroutine_safely(self.execute_code(code, language, check_security, **kwargs))
async def run_shell_command(
self,
command: Union[str, list],
check_security: bool = True,
run_in: Optional[Union[bool, str, 'SandboxConfig']] = None,
**kwargs
) -> 'SandboxResult':
"""Run a shell command in the sandbox.
@@ -183,7 +130,8 @@ class SandboxMixin:
Returns:
SandboxResult with execution details
"""
manager = self._manager_for(run_in)
if not self.has_sandbox:
raise RuntimeError("No sandbox configured")
# Convert command to string for security checking
if isinstance(command, list):
@@ -200,7 +148,7 @@ class SandboxMixin:
if self.verbose:
logger.warning(f"Security warnings for command:\n{warning_text}")
async with manager as sandbox:
async with self.get_sandbox_manager() as sandbox:
return await sandbox.run_command(command, **kwargs)
def get_sandbox_status(self) -> Dict[str, Any]:
@@ -282,15 +230,5 @@ class SandboxMixin:
return result.stdout or result.output
else:
return f"Error: {result.error or result.stderr}"
# Tag as framework-generated so clone_for_channel() can drop these
# source-bound wrappers and regenerate clone-owned ones. Without the
# tag, a clone would inherit tools closing over the *source* agent and
# silently share its SandboxManager.
generated = [execute_python_code, execute_shell_command]
for _t in generated:
try:
_t._praison_sandbox_tool = True
except Exception: # pragma: no cover - tool object may be immutable
pass
return generated
return [execute_python_code, execute_shell_command]
@@ -52,29 +52,14 @@ class SkillReviewMixin:
return policy
def _build_skill_review_tool(self):
"""Return the restricted toolset for the review turn.
Always exposes the single skill-mutation tool (``skill_manage``). When
the agent has memory enabled, ``store_memory`` is added so the same
guarded pass can also persist a durable fact / user-model detail
(issue #4030) — closing the learning loop for memory, not just skills,
without a second review turn or a parallel knob. The set stays
restricted: never the agent's full toolset.
"""
tools = []
"""Return the restricted toolset for the review turn (skill_manage only)."""
try:
from ..tools.skill_tools import create_skill_tools
skill_tools = create_skill_tools()
tools.append(skill_tools.skill_manage)
return [skill_tools.skill_manage]
except Exception as e: # pragma: no cover - defensive
logger.debug("Could not build skill review tool: %s", e, exc_info=True)
if getattr(self, "memory", None):
try:
from ..tools.memory import store_memory
tools.append(store_memory)
except Exception as e: # pragma: no cover - defensive
logger.debug("Could not build memory review tool: %s", e, exc_info=True)
return tools
return []
def _skill_review_log_context(self):
"""Return (agent_name, session_id) for swallowed-failure warnings."""
@@ -22,13 +22,6 @@ from ..config.feature_configs import DEFAULT_TOOL_OUTPUT_LIMIT
logger = logging.getLogger(__name__)
# Upper bound on how many timed-out (still-blocked) tool executors a single
# agent may retire before it stops recycling and falls back to reusing the
# existing bounded pool. This keeps the "fresh worker after a timeout"
# throughput win while capping leaked threads from pathological repeated
# timeouts so they cannot exhaust process resources.
_MAX_ORPHANED_TOOL_EXECUTORS = 4
if TYPE_CHECKING:
pass
@@ -374,12 +367,6 @@ class ToolExecutionMixin:
return resolve_tool_names(tool_names)
def _resolve_tools_list(self, tools):
"""Resolve any tool-name strings inside a mixed ``tools=`` list."""
from ..tools.resolver import resolve_tools_list
return resolve_tools_list(tools)
def _cast_arguments(self, func, arguments):
"""Cast arguments to their expected types based on function signature."""
if not callable(func) or not arguments:
@@ -495,7 +482,6 @@ class ToolExecutionMixin:
run_id=getattr(self, '_current_run_id', 'unknown'),
session_id=getattr(self, '_session_id', None) or 'default',
tool_name=function_name,
metadata={"tool_call_id": tool_call_id},
),
)
@@ -753,21 +739,26 @@ class ToolExecutionMixin:
if blocked_result is not None:
result = blocked_result
else:
# Apply tool retry logic with exponential backoff.
# ONE vocabulary: the effective RetryPolicy caps how many times
# the tool body may run, whichever of the two loops does the
# retrying, and owns the backoff. ExecutionConfig.max_retry_limit
# / retry_* are translated into it (see
# _effective_tool_retry_policy) rather than run as a second,
# independent budget in different units.
retry_policy = self._effective_tool_retry_policy(function_name)
max_attempts = retry_policy.max_attempts
# Apply tool retry logic with exponential backoff
execution_config = getattr(self, '_execution_config', None)
if execution_config is None:
# Fall back to reading individual config attributes for backward compatibility
max_retry_limit = getattr(self, 'max_retry_limit', 2)
retry_initial_delay = 1.0
retry_backoff_factor = 2.0
retry_jitter = 0.1
else:
max_retry_limit = execution_config.max_retry_limit
retry_initial_delay = execution_config.retry_initial_delay
retry_backoff_factor = execution_config.retry_backoff_factor
retry_jitter = execution_config.retry_jitter
result = None
last_exception = None
# max_attempts is the total number of tool-body runs.
for attempt in range(1, max_attempts + 1):
# max_retry_limit is the number of retries (not total attempts)
# So total attempts = 1 (initial) + max_retry_limit (retries)
for attempt in range(1, max_retry_limit + 2):
try:
# P8/G11: Apply tool timeout if configured
tool_timeout = getattr(self, '_tool_timeout', None)
@@ -796,7 +787,7 @@ class ToolExecutionMixin:
return self._execute_tool_with_circuit_breaker(function_name, arguments)
# Use reusable executor to prevent resource leaks
if not hasattr(self, '_tool_executor') or self._tool_executor is None:
if not hasattr(self, '_tool_executor'):
self._tool_executor = concurrent.futures.ThreadPoolExecutor(
max_workers=2, thread_name_prefix=f"tool-{self.name}"
)
@@ -809,104 +800,66 @@ class ToolExecutionMixin:
_progress_active.clear()
future.cancel()
logging.warning(f"Tool {function_name} timed out after {tool_timeout}s")
# Tag as an OUTER wall-clock timeout: unlike an
# inner-loop timeout (already retried to
# exhaustion), the body ran once here and the
# inner budget never saw it, so this one still
# earns an outer retry pass under the single budget.
result = {"error": f"Tool timed out after {tool_timeout}s", "timeout": True, "_outer_timeout": True}
# The timed-out thread cannot be reclaimed. Retire this
# executor so the next call gets a fresh worker instead of
# queuing behind a permanently stuck one — but bound how many
# orphaned (still-blocked) threads we allow to accumulate:
# once the cap is reached we stop recycling and keep reusing
# the existing pool so repeated timeouts can't exhaust process
# resources with an unbounded number of leaked threads.
orphaned = getattr(self, '_tool_executor_orphaned', 0)
if orphaned < _MAX_ORPHANED_TOOL_EXECUTORS:
self._tool_executor.shutdown(wait=False)
self._tool_executor = None
self._tool_executor_orphaned = orphaned + 1
result = {"error": f"Tool timed out after {tool_timeout}s", "timeout": True}
else:
with tool_progress_channel(_progress_sink), with_injection_context(state):
result = self._execute_tool_with_circuit_breaker(function_name, arguments)
# Check if the result indicates a retryable error
if isinstance(result, dict) and result.get("error"):
# Approval/permission/policy/guardrail denials are
# legitimate non-retryable outcomes: stop immediately.
if result.get("approval_denied") or result.get("permission_denied") or result.get("approval_error") or result.get("policy_denied") or result.get("guardrail_denied"):
# Check if this is a circuit breaker error (always retryable)
if result.get("circuit_open"):
raise ToolExecutionError(
result["error"],
tool_name=function_name,
agent_id=self.name,
is_retryable=True,
)
# Check if this is a timeout error (retryable)
elif result.get("timeout"):
raise ToolExecutionError(
result["error"],
tool_name=function_name,
agent_id=self.name,
is_retryable=True,
)
# For other error dicts: approval/permission denials are legitimate
# non-retryable outcomes; everything else represents a tool failure
# that should engage the outer retry/backoff loop.
elif result.get("approval_denied") or result.get("permission_denied") or result.get("approval_error") or result.get("policy_denied") or result.get("guardrail_denied"):
break
# ONE budget: the inner loop
# (_execute_tool_with_circuit_breaker) already retries
# its retry_on error types (timeout/rate_limit/
# connection_error) to exhaustion before returning here,
# so re-escalating them as retryable would restart the
# whole inner budget on every outer attempt and run the
# tool up to max_attempts² times.
error_type = self._classify_error_type(result, None)
inner_policy = self._get_tool_retry_policy(function_name)
# Only escalate to an outer retry pass when the failure is
# a transient the inner loop did NOT already re-drive:
# • _outer_timeout — this loop's own wall-clock guard
# tripped; the body ran once and the inner budget
# never saw it. BUT the timed-out body is still
# running in an orphaned thread (future.cancel() is
# a no-op once started), so retrying a mutating tool
# would duplicate its side effects. Only re-drive it
# when the tool is known idempotent/read-only.
# • circuit_open — returned by the inner loop WITHOUT a
# body run, so retrying costs no duplicate side effect.
# • _praison_retryable — a raised exception was flattened
# into this error dict (see _execute_tool_impl); honour
# that verdict, but ONLY for error types the inner loop
# does not itself retry, or inner×outer would run the
# body max_attempts² times.
# A plain error dict the tool body *returned* carries no tag
# — it is the tool's own reported outcome, not a transient
# the framework must re-drive. The body already ran to
# completion, so re-running it would duplicate its side
# effects (charge money, send a message, write a row) for a
# decision the tool already made. If a user genuinely wants
# such an outcome retried, its error type belongs in the
# RetryPolicy.retry_on the inner loop honours — not a word
# in the tool's own payload. Surface it once.
is_retryable = bool(
(result.get("_outer_timeout")
and self._is_tool_idempotent(function_name))
or result.get("circuit_open")
or (result.get("_praison_retryable") is True
and error_type not in inner_policy.retry_on
# The body already ran and may have completed
# its side effect before raising. Honour an
# explicit non-idempotent declaration here, as
# the _outer_timeout clause above already does.
and not self._tool_declares_not_idempotent(function_name))
)
# Strip the private control-plane tag before it can reach
# the model or be re-surfaced as the tool's payload.
result.pop("_praison_retryable", None)
raise ToolExecutionError(
result.get("error", f"Tool '{function_name}' failed"),
tool_name=function_name,
agent_id=self.name,
is_retryable=is_retryable,
)
else:
# Avoid compounding with the inner retry loop in
# _execute_tool_with_circuit_breaker: error types it already
# retries (e.g. timeout/rate_limit/connection_error) are
# exhausted by the time they reach here, so escalating them as
# retryable would re-run the entire inner loop from scratch on
# every outer attempt. Only escalate error types the inner loop
# does NOT retry (e.g. "unknown") so they get one outer pass.
error_type = self._classify_error_type(result, None)
inner_policy = self._get_tool_retry_policy(function_name)
is_retryable = error_type not in inner_policy.retry_on
raise ToolExecutionError(
result.get("error", f"Tool '{function_name}' failed"),
tool_name=function_name,
agent_id=self.name,
is_retryable=is_retryable,
)
else:
# Success path - return the result
break
except ToolExecutionError as e:
last_exception = e
# Only retry if the error is marked as retryable and the
# policy still has attempts left.
if not e.is_retryable or attempt >= max_attempts:
# Only retry if the error is marked as retryable and we have retries left
# attempt starts at 1, so (attempt - 1) gives us the retry count
if not e.is_retryable or (attempt - 1) >= max_retry_limit:
raise e
# Calculate delay for exponential backoff (policy owns it)
delay = retry_policy.get_delay_ms(attempt - 1) / 1000.0
# Calculate delay for exponential backoff
delay = BackoffPolicy.delay(attempt, retry_initial_delay, retry_backoff_factor, retry_jitter)
logging.warning(
f"Tool {function_name} failed (attempt {attempt}/{max_attempts}): {e}. "
f"Tool {function_name} failed (attempt {attempt}/{max_retry_limit + 1}): {e}. "
f"Retrying in {delay:.2f}s..."
)
time.sleep(delay)
@@ -922,14 +875,15 @@ class ToolExecutionMixin:
is_retryable=is_retryable,
)
last_exception = tool_error
if not is_retryable or attempt >= max_attempts:
# attempt starts at 1, so (attempt - 1) gives us the retry count
if not is_retryable or (attempt - 1) >= max_retry_limit:
raise tool_error from e
# Calculate delay for exponential backoff (policy owns it)
delay = retry_policy.get_delay_ms(attempt - 1) / 1000.0
# Calculate delay for exponential backoff
delay = BackoffPolicy.delay(attempt, retry_initial_delay, retry_backoff_factor, retry_jitter)
logging.warning(
f"Tool {function_name} failed (attempt {attempt}/{max_attempts}): {e}. "
f"Tool {function_name} failed (attempt {attempt}/{max_retry_limit + 1}): {e}. "
f"Retrying in {delay:.2f}s..."
)
time.sleep(delay)
@@ -1176,20 +1130,6 @@ class ToolExecutionMixin:
)
after_tool_results = self._hook_runner.execute_sync(HookEvent.AFTER_TOOL, after_tool_input, target=function_name)
# Honour a post-tool block: a plugin/hook can deny the result
# (e.g. secret/PII detected) so the original output never
# reaches the model. Mirrors the BEFORE_TOOL block path above.
if self._hook_runner.is_blocked(after_tool_results):
reason = self._hook_runner.get_blocking_reason(after_tool_results)
logging.warning(f"Tool {function_name} result blocked by AFTER_TOOL hook")
return reason or f"Result of {function_name} was blocked by security policy."
# Read back any rewritten/redacted tool_output so a plugin can
# scrub secrets before the result reaches the model. The bridge
# mutates ``after_tool_input.tool_output`` in place.
if getattr(after_tool_input, "tool_output", result) is not result:
result = after_tool_input.tool_output
# Surface any additional_context returned by AFTER_TOOL hooks
# back to the model by appending it to the tool result (mirrors
# the loop-guard injection pattern below). Without this the
@@ -2021,12 +1961,6 @@ class ToolExecutionMixin:
if not decision.approved:
error_msg = f"Tool execution denied: {decision.reason}"
logging.warning(error_msg)
feedback = getattr(decision, "feedback", None)
if feedback:
error_msg = (
f"Tool '{function_name}' was denied by the user. "
f"Do not retry it as-is. User guidance: {feedback}"
)
return {"error": error_msg, "approval_denied": True}
if decision.modified_args:
@@ -2060,12 +1994,6 @@ class ToolExecutionMixin:
if not decision.approved:
error_msg = f"Tool execution denied: {decision.reason}"
logging.warning(error_msg)
feedback = getattr(decision, "feedback", None)
if feedback:
error_msg = (
f"Tool '{function_name}' was denied by the user. "
f"Do not retry it as-is. User guidance: {feedback}"
)
return {"error": error_msg, "approval_denied": True}
if decision.modified_args:
@@ -2108,13 +2036,7 @@ class ToolExecutionMixin:
result.get("guardrail_denied") or
result.get("circuit_open")):
return result
# The body already ran and may have completed its side effect
# before failing. Honour an explicit non-idempotent declaration
# rather than re-driving it (send a second email, charge twice).
if self._tool_declares_not_idempotent(function_name):
return result
# Determine error type for retry policy
error_type = self._classify_error_type(result, last_exception)
@@ -2596,20 +2518,8 @@ class ToolExecutionMixin:
f"{error_msg} Expected parameters for '{function_name}' — "
f"required: {schema['required']}, optional: {schema['optional']}."
)
# An argument-binding error is a programming error — the same
# call will always fail — so mark it terminal and never retry.
return {"error": error_msg, "expected_parameters": schema, "_praison_retryable": False}
# A tool that *raised* is flattened into an error dict here, which
# erases the exception type the retry policy needs. Carry the same
# verdict the exception paths use so the outer loop can retry a
# transient failure without re-running a tool that merely *returned*
# an error dict of its own. Programming errors are terminal;
# everything else may be retried. Private key: never leaks to the
# model and cannot collide with a tool's own "retryable" field.
return {
"error": error_msg,
"_praison_retryable": not isinstance(e, (ValueError, TypeError, AttributeError)),
}
return {"error": error_msg, "expected_parameters": schema}
return {"error": error_msg}
# Unresolved: return a corrective, model-readable message so the model can
# retry with a valid name instead of repeating the same mistake.
@@ -2996,124 +2906,17 @@ class ToolExecutionMixin:
else:
return f"Unknown bridge tool: {function_name}"
def _tool_declares_not_idempotent(self, tool_name):
"""True only when the tool *explicitly* declares itself unsafe to re-run.
Deliberately narrower than ``_is_tool_idempotent``, which answers False
for unknown tools as a safe default: gating a retry on that would stop
retrying every unregistered user tool.
This keys on an explicit declaration and nothing else. In particular it
must NOT consult the shared ``MUTATING_TOOLS`` name registry -- that is a
73-name list maintained for the escalation loop guard, and using it here
silently removed the retry from any *undeclared* tool that happened to
share a name with an entry (``write_file``, ``store_memory``, ``mkdir``...).
"""
tools = getattr(self, 'tools', [])
if not isinstance(tools, (list, tuple)):
tools = []
for tool in tools:
name_attr = getattr(tool, '__name__', None) or getattr(tool, 'name', None)
if name_attr == tool_name:
explicit = getattr(tool, 'idempotent', None)
if not isinstance(explicit, bool):
# ``restart_safe`` is the public replay-safety contract on
# ``@tool`` (FunctionTool) and ``BaseTool``: ``False`` marks an
# effectful tool that must never be silently re-executed.
explicit = getattr(tool, 'restart_safe', None)
if isinstance(explicit, bool):
return not explicit
return False
# A tool from the process-global registry (only reachable under
# ToolConfig(allow_global_tools=True)) can carry its own declaration;
# ``_is_tool_idempotent`` consults it, so this must too.
if getattr(self, '_allow_global_tools', False):
try:
from ..tools.registry import get_registry
global_tool = get_registry().get(tool_name)
for attr in ('idempotent', 'restart_safe'):
explicit = getattr(global_tool, attr, None)
if isinstance(explicit, bool):
return not explicit
except Exception:
pass
return False
def _is_tool_idempotent(self, tool_name):
"""Whether re-running ``tool_name`` is safe (no duplicated side effects).
Used to decide if an OUTER wall-clock timeout may be retried: the timed-out
tool body is still running in an orphaned thread (``future.cancel()`` is a
no-op once started), so auto-retrying a mutating tool would duplicate its
side effects (send a second email, charge twice). Only tools classified as
read-only/idempotent are safe to re-drive.
Precedence: explicit ``idempotent`` attribute on the tool > the shared
read-only/mutating name registry (reused from the escalation loop guard).
Unknown tools default to NOT idempotent (safe: surface the timeout once).
"""
tools = getattr(self, 'tools', [])
if not isinstance(tools, (list, tuple)):
tools = []
for tool in tools:
tool_name_attr = getattr(tool, '__name__', None) or getattr(tool, 'name', None)
if tool_name_attr == tool_name:
explicit = getattr(tool, 'idempotent', None)
if isinstance(explicit, bool):
return explicit
break
# A tool from the process-global registry (only reachable when
# ToolConfig(allow_global_tools=True)) can carry its own explicit
# ``idempotent`` flag even when its name is absent from the shared
# read-only/mutating registries below.
if getattr(self, '_allow_global_tools', False):
try:
from ..tools.registry import get_registry
global_tool = get_registry().get(tool_name)
explicit = getattr(global_tool, 'idempotent', None)
if isinstance(explicit, bool):
return explicit
except Exception:
pass
try:
from ..escalation.loop_guard import IDEMPOTENT_TOOLS, MUTATING_TOOLS
except Exception:
return False
if tool_name in IDEMPOTENT_TOOLS:
return True
if tool_name in MUTATING_TOOLS:
return False
return False
def _get_tool_retry_policy(self, tool_name):
"""Get retry policy for a tool (tool-level > agent-level > default).
Kept as the historical name; both retry loops now go through
``_effective_tool_retry_policy`` so they share one policy object.
Args:
tool_name: Name of the tool to get retry policy for
Returns:
RetryPolicy instance
"""
return self._effective_tool_retry_policy(tool_name)
def _effective_tool_retry_policy(self, tool_name):
"""The one RetryPolicy that governs every retry of ``tool_name``.
Precedence: per-tool ``.retry_policy`` > agent-level
``ToolConfig(retry_policy=...)`` > a policy translated from the
ExecutionConfig spelling of the same knobs.
``ExecutionConfig`` speaks a second vocabulary for the same thing --
``max_retry_limit`` counts *retries* (so total runs = limit + 1),
``retry_initial_delay`` is in *seconds*, and ``retry_jitter`` is a
*fraction*. Translating it here, once, is what keeps the two retry loops
from running two independent budgets in two different units.
"""
from ..tools.retry import RetryPolicy
# Check for tool-level retry policy first
tools = getattr(self, 'tools', [])
# Handle non-iterable tools (e.g., single MCP instance)
@@ -3124,43 +2927,16 @@ class ToolExecutionMixin:
tool_policy = getattr(tool, 'retry_policy', None) if hasattr(tool, 'retry_policy') else None
if tool_name_attr == tool_name and tool_policy is not None:
return tool_policy
# Check for agent-level retry policy
agent_policy = getattr(self, '_tool_retry_policy', None)
if agent_policy is not None:
return agent_policy
# Translate the ExecutionConfig spelling into the RetryPolicy vocabulary.
execution_config = getattr(self, '_execution_config', None)
if execution_config is None:
max_retry_limit = getattr(self, 'max_retry_limit', 2)
initial_delay_s, backoff_factor, jitter_fraction = 1.0, 2.0, 0.1
else:
max_retry_limit = execution_config.max_retry_limit
initial_delay_s = execution_config.retry_initial_delay
backoff_factor = execution_config.retry_backoff_factor
jitter_fraction = execution_config.retry_jitter
cached = getattr(self, '_translated_retry_policy', None)
key = (max_retry_limit, initial_delay_s, backoff_factor, jitter_fraction)
if cached is not None and cached[0] == key:
return cached[1]
# Reproduce BackoffPolicy.delay's 60s base cap so ExecutionConfig users
# keep the exact same delay ceiling they had before this consolidation
# (RetryPolicy defaults to a 30s cap). initial_delay_ms must not exceed
# max_delay_ms per RetryPolicy validation, so lift the cap to whichever
# is larger.
initial_delay_ms = int(initial_delay_s * 1000)
policy = RetryPolicy(
max_attempts=max(1, max_retry_limit + 1),
backoff_factor=max(1.0, backoff_factor),
initial_delay_ms=initial_delay_ms,
max_delay_ms=max(60000, initial_delay_ms),
jitter=jitter_fraction > 0,
jitter_factor=min(max(jitter_fraction, 0.0), 1.0),
)
self._translated_retry_policy = (key, policy)
return policy
# Return default retry policy (cached class-level instance)
if not hasattr(ToolExecutionMixin, '_default_retry_policy'):
ToolExecutionMixin._default_retry_policy = RetryPolicy()
return ToolExecutionMixin._default_retry_policy
def _classify_error_type(self, error_dict, exception):
"""Classify error type for retry policy matching.
@@ -1,129 +0,0 @@
"""Give a single Agent's tools a sandbox, without touching its thinking.
``AgentFlow``/``AgentTeam`` already share one sandbox across a run and tear it
down when the run ends. A lone ``Agent(tools_run_on=...)`` has no "run" to hang
that lifetime on -- it is just an object someone calls ``chat()`` on, possibly
many times -- so the sandbox is created on first use and lives as long as the
agent does. That is deliberate: a file written in turn 1 is still there in turn
2, which is what anyone who asked for a working directory expects.
Attachment is idempotent and happens at the top of ``chat``/``achat``/
``_start_stream``, and in ``AgentTeam`` before the task's tool list is
snapshotted. Doing it once, rather than wrapping each call in a context
manager, sidesteps two real hazards in this codebase:
* ``_start_stream`` is a **generator**; a context manager around the call would
exit before the caller consumed a single token, tearing down the sandbox while
the model was still using it.
* ``_start_stream`` falls back to ``self.chat``, so a wrapper would **re-enter**
itself and provision a second sandbox.
Lifetime is tied to the agent by ``weakref.finalize`` rather than a process-exit
hook over a weak set. The weak set leaked: an agent and its ``SharedCompute``
reference each other, so a dropped agent was reclaimed by the cycle collector,
vanished from the set, and its sandbox was never shut down. Gateways clone an
agent **per request**, so that was one orphaned container per request.
"""
from __future__ import annotations
import logging
import threading
import weakref
from typing import Any
logger = logging.getLogger(__name__)
# One lock per agent, so two unrelated agents never wait on each other and no
# lock is ever held while another agent's provider is being imported. Held only
# by ensure_tools_placed/close_tools_sandbox for that one agent.
_AGENT_LOCKS: "weakref.WeakKeyDictionary" = weakref.WeakKeyDictionary()
_LOCKS_GUARD = threading.Lock()
def _lock_for(agent: Any) -> threading.Lock:
"""The placement lock for ``agent``, created on demand.
``_LOCKS_GUARD`` covers only this dictionary lookup -- microseconds -- never
provider construction or attachment. An earlier version held one global lock
across ``SharedCompute(...)``, which imports the provider SDK: unrelated
agents serialised behind each other, and a slow import could stall
interpreter exit.
"""
with _LOCKS_GUARD:
lock = _AGENT_LOCKS.get(agent)
if lock is None:
lock = threading.Lock()
try:
_AGENT_LOCKS[agent] = lock
except TypeError: # pragma: no cover - unhashable/non-weakrefable
pass
return lock
def _shutdown_orphan(shared: Any, name: str) -> None:
"""Tear down a sandbox whose agent is gone.
Registered with ``weakref.finalize``, so it runs when the agent is collected
*and* at interpreter exit. It must not close over the agent -- that would
keep it alive and defeat the whole point.
"""
try:
shared.shutdown()
logger.debug("[tools_placement] released the sandbox belonging to %s", name)
except Exception as exc: # pragma: no cover - teardown must stay quiet
# Loud enough to notice: a failed release means a container, or a
# billed cloud instance, is still running after the agent is gone.
logger.warning(
"[tools_placement] could not release the sandbox for %s: %s", name, exc
)
def ensure_tools_placed(agent: Any) -> None:
"""Attach the sandbox tools to ``agent`` if it asked for them. Idempotent.
Called at the top of every entry point, so it must be cheap when there is
nothing to do -- which is the overwhelmingly common case.
"""
place = getattr(agent, "tools_run_on", None)
if place is None or getattr(agent, "_tools_sandbox", None) is not None:
return
from ..managed.shared_compute import SharedCompute
with _lock_for(agent):
# Re-check under the lock: two threads calling chat() at once must not
# each provision a sandbox and leak one.
if getattr(agent, "_tools_sandbox", None) is not None:
return
shared = SharedCompute(place)
# override_declared: this sandbox exists *because* the agent named a
# place, so the skip-if-declared rule must not skip the agent itself.
shared.attach([agent], override_declared=True)
agent._tools_sandbox = shared
agent._tools_sandbox_finalizer = weakref.finalize(
agent, _shutdown_orphan, shared, str(getattr(agent, "name", agent))
)
logger.info("[tools_placement] %r tools now run on %s", getattr(agent, "name", agent), place)
def close_tools_sandbox(agent: Any) -> None:
"""Tear down the agent's sandbox and put its original tools back."""
with _lock_for(agent):
# Everything happens under the lock. Clearing the marker first and
# shutting down afterwards left a window where a concurrent chat()
# re-attached and the in-flight shutdown then restored over it, leaving
# the agent marked as sandboxed while holding no sandbox tools -- a
# state ensure_tools_placed() could never repair, because its idempotent
# early return sees the marker and does nothing.
shared = getattr(agent, "_tools_sandbox", None)
if shared is None:
return
finalizer = getattr(agent, "_tools_sandbox_finalizer", None)
if finalizer is not None:
finalizer.detach() # we are shutting down explicitly
agent._tools_sandbox_finalizer = None
shared.shutdown()
agent._tools_sandbox = None
@@ -282,7 +282,7 @@ class UnifiedExecutionMixin:
task_id=task_id,
config=config,
tool_choice=tool_choice,
parallel_tool_calls=getattr(getattr(self, "execution", None), "parallel_tool_calls", False) # Gap 2 integration
parallel_tool_calls=getattr(self, 'parallel_tool_calls', False) # Gap 2 integration
)
else:
# Standard LiteLLM path - delegate to existing LLM class
@@ -304,7 +304,7 @@ class UnifiedExecutionMixin:
task_id=task_id,
config=config,
tool_choice=tool_choice,
parallel_tool_calls=getattr(getattr(self, "execution", None), "parallel_tool_calls", False) # Gap 2 integration
parallel_tool_calls=getattr(self, 'parallel_tool_calls', False) # Gap 2 integration
)
# Store response in memory if enabled
@@ -158,8 +158,9 @@ class VideoAgent:
verbose: Verbosity level for output
output: Output preset ("silent", "verbose", etc.)
"""
from ..utils.model_alias import resolve_model_name
llm = resolve_model_name(llm, model, "VideoAgent")
# Handle model= alias for llm=
if llm is None and model is not None:
llm = model
# Store core identity
self.name = name or "VideoAgent"
@@ -112,8 +112,8 @@ class VisionAgent:
- VisionConfig: Full configuration object
verbose: Verbosity level for output
"""
from ..utils.model_alias import resolve_model_name
llm = resolve_model_name(llm, model, "VisionAgent")
if llm is None and model is not None:
llm = model
self.name = name or "VisionAgent"
self.instructions = instructions
@@ -2,8 +2,6 @@ import os
import time
import json
import logging
import contextlib
import contextvars
import threading
from praisonaiagents._logging import get_logger
from typing import Any, Dict, Optional, List, Tuple, Callable
@@ -43,13 +41,18 @@ class TaskStatus(Enum):
logger = get_logger(__name__)
# Request authorisation + fail-closed bind guard are shared with Agent.launch
# so the single-agent and multi-agent serving paths cannot drift apart.
from ..agent.launch_security import (
launch_auth_token as _launch_auth_token,
authorise_launch_request as _authorise_launch_request,
resolve_launch_host as _resolve_launch_host,
)
def _launch_auth_token() -> Optional[str]:
return os.environ.get("PRAISONAI_LAUNCH_AUTH_TOKEN")
def _authorise_launch_request(request) -> bool:
token = _launch_auth_token()
if not token:
return True
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer ") and auth[7:] == token:
return True
return request.headers.get("X-Auth-Token") == token
# Agent server registry for thread-safe server management
@@ -372,18 +375,6 @@ def _build_execution_context(agents_instance, task_id, skip_memory_init=False):
if llm and hasattr(llm, 'set_current_agent'):
llm.set_current_agent(executor_agent.display_name)
# Place the agent's tools BEFORE snapshotting them. This snapshot is passed
# to chat() as an explicit `tools=`, which wins over `agent.tools` -- so if
# the sandbox attaches later (inside chat()), the model is handed the
# pre-attach list and every tool runs on the host while a sandbox sits
# provisioned and unused. Silent, and worse than it sounds: in
# start_for_each() row 1 got local tools and row 2 got the bridged set,
# because row 1's chat() mutated agent.tools in time for row 2's snapshot.
if executor_agent is not None and getattr(executor_agent, "tools_run_on", None) is not None:
from ..agent.tools_placement import ensure_tools_placed
ensure_tools_placed(executor_agent)
# Ensure tools are available from both task and agent (create copy to avoid mutation)
tools = list(task.tools or [])
if executor_agent and executor_agent.tools:
@@ -653,8 +644,7 @@ class AgentTeam(SpawnAnnounceProtocol):
manager_llm=None,
name: Optional[str] = None,
variables: Optional[Dict[str, Any]] = None,
llm: Optional[str] = None, # Deprecated alias for model=
model: Optional[str] = None, # Default model for members that did not name one
llm: Optional[str] = None, # Default LLM for all agents (API consistency)
# Consolidated feature params (agent-centric API)
memory: Optional[Any] = False, # Union[bool, MultiAgentMemoryConfig]
planning: Optional[Any] = False, # Union[bool, MultiAgentPlanningConfig]
@@ -670,15 +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] - where every agent's shell/file
# tools run: "docker", "e2b", "modal", "daytona", "flyio", "tenki",
# "local", "subprocess", "sandlock", "ssh", "novita". The team shares
# ONE sandbox and one working directory, so files written by one agent
# are visible to the others. Orchestration and thinking stay local.
# Pass a configured provider instance to customise resources.
tools_run_on: Optional[Any] = None,
# Accepted only to fail with a useful message -- see resolve_placement.
run_on: Optional[Any] = None,
# 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.
@@ -689,11 +677,8 @@ class AgentTeam(SpawnAnnounceProtocol):
process: Execution process type ("sequential", "workflow", "hierarchical").
For parallel fan-out, set async_execution=True on individual Task
objects within a "workflow" or "sequential" process.
manager_llm: Model for the hierarchical manager agent. Unrelated to
model=/llm=; it never touches the members.
llm: Deprecated alias for model=. Passing both raises TypeError.
model: Default model for members that did not name one themselves.
An agent constructed with its own llm=/model=/auth= keeps it.
manager_llm: LLM model for manager agent
llm: Default LLM model for all agents
name: Name for this agent collection
variables: Global variables for substitution
memory: Memory configuration (bool | MultiAgentMemoryConfig)
@@ -711,40 +696,13 @@ class AgentTeam(SpawnAnnounceProtocol):
"""
# Store new params for propagation to agents
self._learn = learn
# model= is the canonical name; llm= is the deprecated alias. Passing
# both is refused rather than silently resolved (see resolve_model_alias).
# The resolved value is the default model for the members: applied to
# every agent that never named a model of its own; an agent that did
# keeps it. Same rule AgentFlow uses for the agents it builds.
# Must UNWRAP, not just alias-resolve: this value is pushed into every
# member via _apply_default_llm, and a member holding an LLMConfig fails
# inside chat() with "'LLMConfig' object has no attribute 'lower'".
from ..utils.model_alias import resolve_model_name
self.llm = resolve_model_name(llm, model, type(self).__name__)
if self.llm:
for _member in (agents.values() if isinstance(agents, dict) else (agents or [])):
_apply = getattr(_member, "_apply_default_llm", None)
if callable(_apply):
_apply(self.llm)
self.llm = llm # Store default LLM for API consistency
self._autonomy = autonomy
self._knowledge = knowledge
self._guardrails = guardrails
self._web = web
self._reflection = reflection
self._caching = caching
# Warn about params that are accepted for API symmetry with Agent but are
# not yet applied at the team level, so a truthy value stops being a silent
# no-op. Pass these to individual Agent(...) instances instead.
_unwired = {
"guardrails": guardrails, "web": web, "reflection": reflection,
"caching": caching, "learn": learn, "knowledge": knowledge,
}
_set = [k for k, v in _unwired.items() if v]
if _set:
logging.warning(
f"AgentTeam received {_set} but does not yet apply them at the team "
"level; pass these to individual Agent(...) instances instead."
)
# ─────────────────────────────────────────────────────────────────────
# Extract values from consolidated params using UNIFIED CANONICAL resolver
# Precedence: Instance > Config > Dict > Array > String > Bool > Default
@@ -940,19 +898,7 @@ class AgentTeam(SpawnAnnounceProtocol):
self.stream = _stream
self.name = name
# Remote execution: one shared sandbox for every agent on this team.
from ..agent.placement import resolve_placement
resolve_placement(
"AgentTeam", run_on=run_on, tools_run_on=tools_run_on,
supports_run_on=False,
)
self.tools_run_on = tools_run_on
# Kept readable rather than removed. `team.run_on` used to exist, and
# code doing getattr(team, "run_on", None) for introspection or
# serialisation would otherwise start raising AttributeError. None is
# the honest value: a team orchestrates locally, so nothing about it
# runs wholly on a managed runtime.
self.run_on = None
self.compute = compute
# Callbacks for workflow execution
self.on_task_start = _on_task_start
@@ -1479,73 +1425,6 @@ class AgentTeam(SpawnAnnounceProtocol):
raise result
return results
def _depends_on_pending(self, task, pending):
"""True if ``task`` depends on a task still pending in the batch.
``pending`` is a list of ``(task_id, coroutine)`` pairs already queued
for parallel execution. If one of the task's dependencies is in that
set, its result isn't available yet, so the batch must be flushed before
queuing this task (otherwise the dependent builds its prompt from an
empty result for the still-in-progress dependency).
Two dependency edges are checked, since ``_build_task_context`` reads
from both:
- ``task.context``: explicit context tasks (by object ``id``).
- ``task.previous_tasks``: workflow ``next_tasks`` edges, stored as
task *names*; without this a workflow successor could be queued in
the same async batch as its predecessor and read an empty result.
"""
if not pending:
return False
pending_ids = {tid for tid, _ in pending}
deps = getattr(task, 'context', None) or []
if any(getattr(dep, 'id', None) in pending_ids for dep in deps):
return True
previous = getattr(task, 'previous_tasks', None) or []
if previous:
pending_names = {
self.tasks[tid].name
for tid in pending_ids
if tid in self.tasks
}
if any(name in pending_names for name in previous):
return True
return False
def _deps_failed(self, task_id):
"""True if any dependency of ``task_id`` finished in a failed state.
asequential()/aworkflow() run their own failure check inside the
generator, but async tasks are buffered and drained later, so an
upstream async task may not have failed yet when the generator yielded
the dependent. Re-checking here — after pending async tasks are flushed
— makes the failure cascade fire for the async_execution path too, for
both dependency edges ``_build_task_context`` reads (context tasks and
workflow ``previous_tasks``).
"""
task = self.tasks.get(task_id)
if task is None:
return False
for dep in getattr(task, 'context', None) or []:
dep_id = getattr(dep, 'id', None)
if dep_id in self.tasks and self.tasks[dep_id].status == "failed":
return True
previous = getattr(task, 'previous_tasks', None) or []
if previous:
by_name = {t.name: t for t in self.tasks.values()}
if any(
name in by_name and by_name[name].status == "failed"
for name in previous
):
return True
return False
async def arun_all_tasks(self):
"""Async version of run_all_tasks method"""
process = Process(
@@ -1557,26 +1436,14 @@ class AgentTeam(SpawnAnnounceProtocol):
)
if self.process == "workflow":
tasks_to_run = [] # list of (task_id, coroutine)
tasks_to_run = []
async for task_id in process.aworkflow():
task = self.tasks[task_id]
if task.async_execution:
# If this async task depends on another async task still
# pending in the current batch, flush first so its context
# is available before this one reads it.
if self._depends_on_pending(task, tasks_to_run):
await self._gather_with_isolation([c for _, c in tasks_to_run])
tasks_to_run = []
# A just-flushed prerequisite may now be failed; don't
# queue a dependent that would run with missing context.
if self._deps_failed(task_id):
self.tasks[task_id].status = "failed"
continue
tasks_to_run.append((task_id, self.arun_task(task_id)))
if self.tasks[task_id].async_execution:
tasks_to_run.append(self.arun_task(task_id))
else:
# If we encounter a sync task, we must wait for the previous async tasks to finish.
if tasks_to_run:
await self._gather_with_isolation([c for _, c in tasks_to_run])
await self._gather_with_isolation(tasks_to_run)
tasks_to_run = []
# Run sync task in an executor to avoid blocking the event loop
@@ -1586,42 +1453,25 @@ class AgentTeam(SpawnAnnounceProtocol):
await loop.run_in_executor(None, copy_context_to_callable(lambda tid=task_id: self.run_task(tid)))
if tasks_to_run:
await self._gather_with_isolation([c for _, c in tasks_to_run])
await self._gather_with_isolation(tasks_to_run)
elif self.process == "sequential":
async_tasks_to_run = [] # list of (task_id, coroutine)
async_tasks_to_run = []
async def flush_async_tasks():
"""Execute all pending async tasks"""
nonlocal async_tasks_to_run
if async_tasks_to_run:
await self._gather_with_isolation([c for _, c in async_tasks_to_run])
await self._gather_with_isolation(async_tasks_to_run)
async_tasks_to_run = []
async for task_id in process.asequential():
task = self.tasks[task_id]
if task.async_execution:
# If this async task depends on another async task still
# pending in the current batch, flush first so its context
# is available before this one reads it (avoids a same-batch
# race that silently substitutes empty dependency context).
if self._depends_on_pending(task, async_tasks_to_run):
await flush_async_tasks()
# A just-flushed prerequisite may now be failed; don't
# queue a dependent that would run with missing context.
if self._deps_failed(task_id):
self.tasks[task_id].status = "failed"
continue
if self.tasks[task_id].async_execution:
# Collect async tasks to run in parallel
async_tasks_to_run.append((task_id, self.arun_task(task_id)))
async_tasks_to_run.append(self.arun_task(task_id))
else:
# Before running a sync task, execute all pending async tasks
await flush_async_tasks()
# A just-flushed async prerequisite may now be failed; skip
# the dependent so we don't run it with missing upstream context.
if self._deps_failed(task_id):
self.tasks[task_id].status = "failed"
continue
# Run sync task in an executor to avoid blocking the event loop
# Use copy_context_to_callable to propagate contextvars (needed for trace emission)
from ..trace.context_events import copy_context_to_callable
@@ -1659,62 +1509,6 @@ class AgentTeam(SpawnAnnounceProtocol):
except StopAsyncIteration:
pass
# ── shared sandbox lifetime ──────────────────────────────────────────────
# A ContextVar, not an instance attribute: the previous version set
# `self.run_on = None` for the duration of the run, so a second caller
# inspecting the team mid-run saw "no sandbox" and any concurrent start()
# executed every tool on the host. Depth lives with the caller, not on the
# shared object, so the team's declared placement is always readable.
#
# The depth is keyed PER TEAM, not shared class-wide. A single integer here
# meant any team started while another team's run was on the stack saw
# depth==1 and skipped its own sandbox entirely -- so a nested team ran on
# the host, and a team sharing an agent with the outer run executed on the
# OUTER team's sandbox.
#
# ContextVar, not threading.local: two coroutines that `astart()` the SAME
# team under one asyncio.gather run on one thread, so thread-local state is
# shared between them -- the second saw the first's depth==1, skipped its
# own sandbox, and ran against a sandbox the first could tear down mid-flight.
# A ContextVar is copied per task at gather/create_task time, so each run
# gets its own depth map; it is also per-thread, so sync callers stay
# isolated too. The value is an immutable frozen mapping replaced via .set()
# (never mutated in place), because a copied context shares the *reference*
# to a mutable dict and in-place mutation would leak straight back across
# the tasks the copy was meant to separate.
_tools_scope_depths: "contextvars.ContextVar" = contextvars.ContextVar(
"praisonai_tools_scope_depths", default=()
)
def _needs_tools_scope(self) -> bool:
"""True when this call should provision the team's shared sandbox."""
if getattr(self, "tools_run_on", None) is None:
return False
return dict(self._tools_scope_depths.get()).get(id(self), 0) == 0
@contextlib.contextmanager
def _shared_tools_scope(self):
"""Hold one sandbox for everything nested inside.
Re-entrant by design: ``start_for_each`` opens the scope once for the
whole batch, so the ``start()`` call it makes per input row finds the
scope already open and reuses that sandbox instead of provisioning and
destroying one per row.
"""
from ..managed.shared_compute import SharedCompute
key = id(self)
depths = dict(self._tools_scope_depths.get())
depths[key] = depths.get(key, 0) + 1
token = self._tools_scope_depths.set(tuple(sorted(depths.items())))
try:
with SharedCompute(self.tools_run_on) as shared:
shared.attach(list(self.agents or []))
yield shared
finally:
self._tools_scope_depths.reset(token)
async def astart(self, content=None, return_dict=False, **kwargs):
"""Async version of start method.
@@ -1723,12 +1517,6 @@ class AgentTeam(SpawnAnnounceProtocol):
return_dict: If True, returns the full results dictionary instead of only the final response
**kwargs: Additional arguments
"""
# Same shared sandbox as start(). Without this, an async team with
# tools_run_on= ran every tool on the host and said nothing.
if self._needs_tools_scope():
with self._shared_tools_scope():
return await self.astart(content=content, return_dict=return_dict, **kwargs)
# Track execution via telemetry
if hasattr(self, '_telemetry') and self._telemetry:
self._telemetry.track_agent_execution(self.name, success=True, async_mode=True)
@@ -1949,21 +1737,6 @@ class AgentTeam(SpawnAnnounceProtocol):
return str(agent[0])
return None
def __repr__(self):
"""Show where this team's work happens, not just its name."""
from ..agent.execution_location import repr_fields
return (
f"AgentTeam(name={self.name!r}, agents={len(self.agents or [])}, "
f"{repr_fields(self, shared=True)})"
)
def where_does_it_run(self) -> str:
"""Plain-English answer to "where does my code actually run?"."""
from ..agent.execution_location import explain
return explain(self, shared=True)
def start(self, content=None, return_dict=False, output=None, **kwargs):
"""Start agent execution with verbose output (beginner-friendly).
@@ -1989,11 +1762,19 @@ class AgentTeam(SpawnAnnounceProtocol):
```
"""
# Remote execution: provision ONE sandbox shared by every agent on the
# team, and tear it down even if execution raises.
if self._needs_tools_scope():
with self._shared_tools_scope():
return self.start(content=content, return_dict=return_dict,
output=output, **kwargs)
# 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:
@@ -2309,15 +2090,6 @@ class AgentTeam(SpawnAnnounceProtocol):
if on_error not in ("continue", "fail_fast"):
raise ValueError("on_error must be 'continue' or 'fail_fast'")
# One sandbox for the whole batch. Without this the per-row start()
# call provisions and destroys its own, so a 50-row batch paid for 50
# sandboxes and no row could see what an earlier row wrote.
if self._needs_tools_scope():
with self._shared_tools_scope():
return self.start_for_each(
inputs, on_error=on_error, output=output, **kwargs
)
batch_id = f"batch_{uuid.uuid4().hex[:12]}"
saved_variables = self.variables
task_snapshot = self._snapshot_task_state()
@@ -2354,13 +2126,6 @@ class AgentTeam(SpawnAnnounceProtocol):
if on_error not in ("continue", "fail_fast"):
raise ValueError("on_error must be 'continue' or 'fail_fast'")
# One sandbox for the whole batch -- see start_for_each.
if self._needs_tools_scope():
with self._shared_tools_scope():
return await self.astart_for_each(
inputs, on_error=on_error, **kwargs
)
batch_id = f"batch_{uuid.uuid4().hex[:12]}"
saved_variables = self.variables
task_snapshot = self._snapshot_task_state()
@@ -2642,7 +2407,7 @@ class AgentTeam(SpawnAnnounceProtocol):
print("="*50 + "\n")
def launch(self, path: str = '/agents', port: int = 8000, host: str = '127.0.0.1', debug: bool = False, protocol: str = "http"):
def launch(self, path: str = '/agents', port: int = 8000, host: str = '0.0.0.0', debug: bool = False, protocol: str = "http"):
"""
Launch all agents as a single API endpoint (HTTP) or an MCP server.
In HTTP mode, the endpoint accepts a query and processes it through all agents in sequence.
@@ -2653,11 +2418,7 @@ class AgentTeam(SpawnAnnounceProtocol):
Args:
path: API endpoint path (default: '/agents') for HTTP. Ignored in MCP mode.
port: Server port (default: 8000)
host: Server host (default: '127.0.0.1'; changed from '0.0.0.0' to
avoid binding all interfaces by default — pass '0.0.0.0'
explicitly to expose externally). Binding a non-loopback host
without ``PRAISONAI_LAUNCH_AUTH_TOKEN`` set auto-generates and
prints a one-time bearer token.
host: Server host (default: '0.0.0.0')
debug: Enable debug mode for uvicorn (default: False)
protocol: "http" to launch as FastAPI, "mcp" to serve over MCP via
``praisonai-mcp`` (install with ``pip install praisonai-mcp``).
@@ -2667,7 +2428,7 @@ class AgentTeam(SpawnAnnounceProtocol):
"""
if protocol == "http":
# Use centralized server registry
if not self.agents:
logging.warning("No agents to launch for HTTP mode. Add agents to the Agents instance first.")
return
@@ -2696,13 +2457,6 @@ class AgentTeam(SpawnAnnounceProtocol):
print("\nOr install all API dependencies with:")
print("pip install 'praisonaiagents[api]'")
return None
# Fail-closed bind guard shared with Agent.launch: never serve
# keyless on a non-loopback host. Applied only after the no-agents
# and dependency guards above, so a launch that bails out cannot
# mutate the process-wide launch token (which every route reads per
# request) and retroactively 401 an already-running endpoint.
host = _resolve_launch_host(host)
# Thread-safe initialization of FastAPI app
app, is_new = _server_registry.get_or_create_app(
@@ -3341,14 +3095,9 @@ class AgentTeam(SpawnAnnounceProtocol):
console.print(f"[dim]Progress: [{'█' * 30}] 100%[/dim]")
console.print(f"[green]Completed {completed_count}/{len(self.tasks)} tasks![/green]\n")
# Keep plan-step tasks/results under _plan_tasks for introspection,
# then restore the user's original task set as the canonical self.tasks.
self._plan_tasks = self.tasks
self.tasks = original_tasks
with self._task_id_lock:
self.task_id_counter = (
max(original_tasks.keys()) + 1 if original_tasks else 0
)
# Restore original tasks reference for result retrieval
self._plan_tasks = self.tasks.copy()
# Keep plan tasks for results but note original tasks are preserved in _plan_tasks
# Resource Lifecycle Management
def close(self) -> None:
@@ -81,14 +81,12 @@ class ConsoleBackend:
"""Interactive Rich terminal prompt. Default for CLI usage."""
def _prompt_user(self, request: ApprovalRequest):
"""Show Rich panel and ask once/session/always/no/deny-with-guidance.
"""Show Rich panel and ask once/session/always/no.
Returns a ``(approved, scope, scope_pattern, feedback)`` tuple where
``scope`` is one of ``"once"`` / ``"session"`` / ``"always"`` and
``scope_pattern`` is the reusable target to persist for ``always``
(``None`` otherwise). ``feedback`` carries optional free-text guidance
captured by the "deny & redirect" choice (``None`` otherwise).
A plain denial returns ``(False, "once", None, None)``.
Returns a ``(approved, scope, scope_pattern)`` tuple where ``scope`` is
one of ``"once"`` / ``"session"`` / ``"always"`` and ``scope_pattern``
is the reusable target to persist for ``always`` (``None`` otherwise).
A denial returns ``(False, "once", None)``.
"""
Console = _get_rich_console()
Panel = _get_rich_panel()
@@ -128,35 +126,28 @@ class ConsoleBackend:
try:
console.print(
f"[{risk_color}]Allow {request.tool_name}?[/{risk_color}] "
f"[o] once [s] this session [a] {always_label} "
f"[n] no [d] deny & redirect"
f"[o] once [s] this session [a] {always_label} [n] no"
)
choice = Prompt.ask(
"Choice",
choices=["o", "s", "a", "n", "d"],
choices=["o", "s", "a", "n"],
default="n",
)
except (KeyboardInterrupt, EOFError):
return (False, "once", None, None)
return (False, "once", None)
if choice == "n":
return (False, "once", None, None)
if choice == "d":
try:
feedback = Prompt.ask("What should the agent do instead?").strip()
except (KeyboardInterrupt, EOFError):
feedback = ""
return (False, "once", None, feedback or None)
return (False, "once", None)
if choice == "s":
return (True, "session", None, None)
return (True, "session", None)
if choice == "a":
return (True, "always", suggested or None, None)
return (True, "once", None, None)
return (True, "always", suggested or None)
return (True, "once", None)
def request_approval_sync(self, request: ApprovalRequest) -> ApprovalDecision:
"""Synchronous approval via Rich console prompt."""
try:
approved, scope, scope_pattern, feedback = self._prompt_user(request)
approved, scope, scope_pattern = self._prompt_user(request)
if approved:
return ApprovalDecision(
approved=True,
@@ -165,13 +156,7 @@ class ConsoleBackend:
scope=scope,
scope_pattern=scope_pattern,
)
reason = f"User denied: {feedback}" if feedback else "User denied"
return ApprovalDecision(
approved=False,
reason=reason,
approver="console",
feedback=feedback,
)
return ApprovalDecision(approved=False, reason="User denied", approver="console")
except Exception as e:
logger.error("Console approval error: %s", e)
return ApprovalDecision(approved=False, reason=f"Approval error: {e}")
@@ -65,12 +65,6 @@ class ApprovalDecision:
``session``/``always`` scopes (e.g. ``"bash:git status *"``
or ``"edit:src/app.py"``). When ``None`` the registry
derives one from the tool call.
feedback: Optional free-text guidance supplied by the user when
denying a tool call ("deny with guidance"). When present
on a denial it is surfaced back to the model as a
course-correction for the next turn instead of a bare
"denied" result. ``None`` (default) keeps today's
terminal-deny semantics.
"""
approved: bool
@@ -80,7 +74,6 @@ class ApprovalDecision:
metadata: Dict[str, Any] = field(default_factory=dict)
scope: str = "once"
scope_pattern: Optional[str] = None
feedback: Optional[str] = None
@dataclass
@@ -2,10 +2,7 @@
Authentication & Authorization Protocols for PraisonAI Agents.
This package provides protocol-driven auth interfaces for the core SDK.
Implementations (JWT, OAuth, etc.) live outside it — implement the protocols
against your own store, or use the reference implementation in the separate
``praisonai-platform`` package
(https://github.com/MervinPraison/PraisonAI-Platform).
Implementations (JWT, OAuth, etc.) live in the platform package.
Quick start::
@@ -155,16 +155,11 @@ class WorkspaceContextProtocol(Protocol):
"""Protocol for providing workspace context to agents.
Enables agents to access workspace-specific settings, instructions,
and agent configurations from a platform layer.
Implementations live outside this package. The reference one is
``PlatformWorkspaceContext`` in the ``praisonai-platform`` package
(https://github.com/MervinPraison/PraisonAI-Platform); implement this
protocol yourself to back agent configuration with your own store.
and agent configurations from the platform layer.
Example::
class MyWorkspaceContext:
class PlatformWorkspaceContext:
async def get_workspace_context(self, workspace_id):
ws = await db.get_workspace(workspace_id)
return ws.context
@@ -109,11 +109,6 @@ class PlatformCapabilities:
descriptor only; the durable outbox does not forward a token on
resend, so adapters relying on provider-side dedupe should also set
``reconciles_unknown_send`` to get effectively-once delivery.
supports_threads: Whether the adapter can open a new thread/topic under
a chat (e.g. Telegram forum topics, Discord threads, Slack thread
anchoring). When False, ``create_thread`` returns a typed
``unsupported`` outcome and callers fall back to the parent channel
rather than raising.
"""
max_message_length: int = 4096
@@ -131,7 +126,6 @@ class PlatformCapabilities:
reconciles_unknown_send: bool = False
supports_idempotency_token: bool = False
supports_media: bool = False
supports_threads: bool = False
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
@@ -151,7 +145,6 @@ class PlatformCapabilities:
"reconciles_unknown_send": self.reconciles_unknown_send,
"supports_idempotency_token": self.supports_idempotency_token,
"supports_media": self.supports_media,
"supports_threads": self.supports_threads,
}
@classmethod
@@ -173,7 +166,6 @@ class PlatformCapabilities:
reconciles_unknown_send=data.get("reconciles_unknown_send", False),
supports_idempotency_token=data.get("supports_idempotency_token", False),
supports_media=data.get("supports_media", False),
supports_threads=data.get("supports_threads", False),
)
@@ -32,8 +32,6 @@ Usage:
bus.publish("session.created", {"session_id": "abc123"})
"""
from .._lazy import create_lazy_getattr
__all__ = [
"EventBus",
"Event",
@@ -43,13 +41,31 @@ __all__ = [
"set_default_bus",
]
_LAZY_IMPORTS = {
"EventBus": ("praisonaiagents.bus.bus", "EventBus"),
"Event": ("praisonaiagents.bus.event", "Event"),
"EventType": ("praisonaiagents.bus.event", "EventType"),
"Subscriber": ("praisonaiagents.bus.bus", "Subscriber"),
"get_default_bus": ("praisonaiagents.bus.bus", "get_default_bus"),
"set_default_bus": ("praisonaiagents.bus.bus", "set_default_bus"),
}
__getattr__ = create_lazy_getattr(_LAZY_IMPORTS, __name__)
def __getattr__(name: str):
"""Lazy load module components."""
if name == "EventBus":
from .bus import EventBus
return EventBus
if name == "Event":
from .event import Event
return Event
if name == "EventType":
from .event import EventType
return EventType
if name == "Subscriber":
from .bus import Subscriber
return Subscriber
if name == "get_default_bus":
from .bus import get_default_bus
return get_default_bus
if name == "set_default_bus":
from .bus import set_default_bus
return set_default_bus
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -37,8 +37,6 @@ __all__ = [
"ToolResultPrunerProtocol",
"MessageFormatterProtocol",
"SummaryBuilderProtocol",
# Default structured summary builder
"DefaultSummaryBuilder",
# Read-only recap
"build_recap",
"MemoryFlushResult",
@@ -77,10 +75,6 @@ def __getattr__(name: str):
from .protocols import SummaryBuilderProtocol
return SummaryBuilderProtocol
if name == "DefaultSummaryBuilder":
from .summary_builder import DefaultSummaryBuilder
return DefaultSummaryBuilder
if name == "build_recap":
from .recap import build_recap
return build_recap
@@ -122,18 +122,7 @@ class ContextCompactor:
# Protocol implementations (defaults to None - no heavy implementations in core)
self.tool_pruner = tool_pruner
self.message_formatter = message_formatter
# Default the structured summary builder when the config asks for the
# phase-aware template but no builder was injected, so
# `_build_structured_summary` renders SUMMARY_TEMPLATE instead of the
# "Summary of N messages" stub. The builder is deterministic and
# dependency-free, so this stays offline-safe and zero-overhead.
if summary_builder is None and getattr(self.config, "structured_template", False):
from .summary_builder import DefaultSummaryBuilder
summary_builder = DefaultSummaryBuilder(
llm_summarize_fn=self.llm_summarize_fn
)
self.message_formatter = message_formatter
self.summary_builder = summary_builder
# Anti-thrashing state tracking
@@ -1,172 +0,0 @@
"""Default structured summary builder for context compaction.
This is the concrete :class:`~praisonaiagents.compaction.protocols.
SummaryBuilderProtocol` implementation that renders the declared
:data:`~praisonaiagents.compaction.config.SUMMARY_TEMPLATE` — the phase-aware
sections (``## Active Task`` / ``## Completed Actions`` / ``## In Progress`` /
``## Pending Questions`` / ``## Relevant Files / Paths`` / ``## Remaining
Work``) — so a compacted context keeps its structure instead of degrading to a
one-line ``"Summary of N messages"`` count.
Design: **deterministic, dependency-free, offline-safe.** It categorises the
transcript into the template sections from the messages themselves (roles,
tool calls, question marks, file-path tokens) rather than making an LLM call, so
it never blocks the hot compaction path, never adds a runtime dependency, and
never raises into the loop. File paths and identifiers are preserved verbatim.
When an optional ``llm_summarize_fn`` is supplied the builder still renders the
same template shape; on any failure it falls back to the deterministic render,
honouring the issue's "fall back to the plain summary on failure" rule.
"""
from __future__ import annotations
import re
from typing import Any, Dict, List, Optional
from .config import SUMMARY_TEMPLATE
# Match file/path-like tokens so they can be surfaced verbatim in the
# "Relevant Files / Paths" section (absolute/relative paths and dotted module
# names with a recognisable extension).
_PATH_RE = re.compile(
r"(?:/[^\s'\"`]+|(?:[\w.-]+/)+[\w.-]+|[\w./-]+\.[A-Za-z0-9]{1,6})"
)
_MAX_ITEMS = 12
_MAX_LINE = 200
def _text(content: Any) -> str:
"""Best-effort flatten of a message ``content`` field into a string."""
if isinstance(content, str):
return content
if isinstance(content, list):
parts: List[str] = []
for part in content:
if isinstance(part, dict):
parts.append(str(part.get("text", "")))
else:
parts.append(str(part))
return " ".join(p for p in parts if p)
return ""
def _clip(text: str, limit: int = _MAX_LINE) -> str:
text = text.strip().replace("\n", " ")
return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
class DefaultSummaryBuilder:
"""Deterministic :class:`SummaryBuilderProtocol` renderer for the template.
Args:
llm_summarize_fn: Optional callable ``(prompt: str) -> str`` used to
distil section bodies. Any failure falls back to the deterministic
render, so callers get a valid structured summary regardless.
"""
def __init__(self, llm_summarize_fn: Optional[Any] = None) -> None:
self._llm_summarize_fn = llm_summarize_fn
# -- protocol ----------------------------------------------------------
def build_structured_summary(self, messages: List[Dict[str, Any]]) -> str:
"""Render :data:`SUMMARY_TEMPLATE` from ``messages`` deterministically.
Never raises: on any failure returns the plain one-line summary so the
compaction loop keeps a valid summary string (issue fallback rule).
"""
try:
return self._render(messages)
except Exception: # noqa: BLE001 - summarisation must never crash the loop
return f"Summary of {len(messages)} messages"
def merge_summaries(self, previous: str, current: str) -> str:
"""Merge a prior structured summary with the current one.
Keeps both under a single "previous / latest" framing so an iterative
re-compaction never discards the earlier phase structure.
"""
previous = (previous or "").strip()
current = (current or "").strip()
if not previous:
return current
if not current:
return previous
return f"{previous}\n\n[Updated with newer activity]\n{current}"
# -- rendering ---------------------------------------------------------
def _render(self, messages: List[Dict[str, Any]]) -> str:
completed: List[str] = []
in_progress: List[str] = []
pending: List[str] = []
files: List[str] = []
active_task = ""
for msg in messages:
role = msg.get("role", "")
body = _text(msg.get("content"))
# First substantive user turn frames the active task.
if role == "user" and body.strip() and not active_task:
active_task = _clip(body)
# Executed work — tool calls and tool results — is *completed*.
# An assistant turn that merely proposes or *asks* about work has
# not been executed, so it must not be recorded as completed (it is
# captured under "Pending Questions" / "In Progress" instead);
# only a substantive, non-questioning assistant turn is completed.
tool_calls = msg.get("tool_calls")
if isinstance(tool_calls, list) and tool_calls:
for tc in tool_calls:
if not isinstance(tc, dict):
continue
name = (tc.get("function", {}) or {}).get("name", "tool")
completed.append(f"Called `{name}`")
elif role == "assistant" and body.strip() and "?" not in body:
completed.append(_clip(body))
elif role == "tool" and body.strip():
completed.append(_clip(f"tool result: {body}"))
# Any turn with a question mark contributes an open question.
if body and "?" in body:
for sentence in body.split("?"):
sentence = sentence.strip()
if sentence:
pending.append(_clip(sentence + "?"))
# Harvest file/path tokens verbatim from every turn.
if body:
for match in _PATH_RE.findall(body):
if match not in files:
files.append(match)
# The most recent user turn (if different) is what's in progress.
for msg in reversed(messages):
if msg.get("role") == "user":
latest = _clip(_text(msg.get("content")))
if latest and latest != active_task:
in_progress.append(latest)
break
return SUMMARY_TEMPLATE.format(
active_task=active_task or "(not specified)",
completed=self._bullets(completed),
in_progress=self._bullets(in_progress),
pending=self._bullets(pending),
files=self._bullets(files),
remaining=self._bullets(pending or in_progress),
)
@staticmethod
def _bullets(items: List[str]) -> str:
if not items:
return "(none)"
# De-duplicate while preserving order, then cap.
seen: List[str] = []
for item in items:
if item not in seen:
seen.append(item)
return "\n".join(f"- {item}" for item in seen[:_MAX_ITEMS])
@@ -25,7 +25,7 @@ Usage:
# With config
agent = Agent(
instructions="...",
memory=MemoryConfig(backend="sqlite", user_id="user123"),
memory=MemoryConfig(backend="redis", user_id="user123"),
knowledge=KnowledgeConfig(sources=["docs/"], rerank=True),
tool_search=ToolSearchConfig(enabled="auto", threshold_pct=15),
)
@@ -52,54 +52,14 @@ if TYPE_CHECKING:
class MemoryBackend(str, Enum):
"""Memory storage backends.
Every member must have an adapter in ``praisonaiagents.memory.adapters``
(FILE is served by ``FileMemory``). Declaring a backend with no adapter
used to silently swap in a local SQLite/JSON store.
"""
"""Memory storage backends."""
FILE = "file"
SQLITE = "sqlite"
CHROMA = "chroma"
REDIS = "redis"
VALKEY = "valkey"
POSTGRES = "postgres"
MEM0 = "mem0"
MONGODB = "mongodb"
DAKERA = "dakera"
IN_MEMORY = "in_memory"
#: Accepted before but never implemented: requests were silently redirected.
UNIMPLEMENTED_MEMORY_BACKENDS = ("redis", "valkey", "postgres", "postgresql")
def available_memory_backends() -> List[str]:
"""Backends ``MemoryConfig(backend=...)`` accepts, read from the registry.
Adapters added via ``register_memory_adapter()`` are accepted automatically.
"""
from ..memory.adapters import list_memory_adapters
return sorted(set(list_memory_adapters()) | {MemoryBackend.FILE.value})
def validate_memory_backend(backend: Union[str, "MemoryBackend"]) -> str:
"""Normalise a backend name; raise rather than substitute a different store."""
from .parse_utils import make_preset_error
from ..memory.adapters.registry import MEMORY_PROVIDER_ALIASES
name = backend.value if isinstance(backend, MemoryBackend) else str(backend)
name = MEMORY_PROVIDER_ALIASES.get(name.strip().lower(), name.strip().lower())
valid = available_memory_backends()
if name in valid:
return name
if name in UNIMPLEMENTED_MEMORY_BACKENDS:
raise ValueError(
f"Memory backend '{name}' is not implemented - no {name} memory "
f"adapter is registered. Earlier releases accepted it and silently "
f"stored to a local file instead. Valid backends: {', '.join(valid)}. "
f"To keep using {name}, pass a live store (memory=<instance> or "
f"db(url=...)), or register an adapter with "
f"register_memory_adapter('{name}', MyAdapter)."
)
raise make_preset_error("memory backend", name, valid)
class LearnScope(str, Enum):
@@ -234,7 +194,7 @@ class MemoryConfig:
Agent(memory=True)
# With backend
Agent(memory=MemoryConfig(backend="sqlite"))
Agent(memory=MemoryConfig(backend="redis"))
# Full config with learning
Agent(memory=MemoryConfig(
@@ -289,11 +249,7 @@ class MemoryConfig:
# Auto-save session name (consolidated from standalone auto_save param)
# When set, automatically saves session to memory with this name
auto_save: Optional[str] = None
def __post_init__(self):
# Reject a backend we cannot provide instead of substituting another.
self.backend = validate_memory_backend(self.backend)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
learn_dict = None
@@ -323,18 +279,11 @@ class MemoryConfig:
class ChunkingStrategy(str, Enum):
"""Knowledge chunking strategies.
Every member must resolve to a chunker via
``praisonaiagents.knowledge.chunking.normalize_chunker_type``. FIXED and
PARAGRAPH are aliases for ``token``/``recursive``; the rest are chunker ids.
"""
"""Knowledge chunking strategies."""
FIXED = "fixed"
PARAGRAPH = "paragraph"
TOKEN = "token"
RECURSIVE = "recursive"
SEMANTIC = "semantic"
SENTENCE = "sentence"
PARAGRAPH = "paragraph"
@dataclass
@@ -373,12 +322,10 @@ class KnowledgeConfig:
embedder: str = "openai"
embedder_config: Optional[Dict[str, Any]] = None
# Chunking (direct fields). Defaults match what Knowledge actually used
# while these fields were inert, so enabling them changes nothing for
# anyone who did not set them.
chunking_strategy: Union[str, ChunkingStrategy] = ChunkingStrategy.RECURSIVE
chunk_size: int = 512
chunk_overlap: int = 50
# Chunking (direct fields)
chunking_strategy: Union[str, ChunkingStrategy] = ChunkingStrategy.SEMANTIC
chunk_size: int = 1000
chunk_overlap: int = 200
# Chunker config dict (alternative to direct fields)
# Supports: {"type": "semantic", "chunk_size": 512, ...}
@@ -900,6 +847,10 @@ class ExecutionConfig:
code_execution: bool = False
code_mode: str = "safe" # "safe" or "unsafe"
# Code execution sandbox mode - "sandbox" (default) uses subprocess isolation
# "direct" runs in current process (legacy, less secure)
code_sandbox_mode: str = "sandbox" # "sandbox" or "direct"
# Code-execution-with-tools (code mode): when True, model-generated code may
# call the agent's registered tools directly via injected proxies, enabling
# multi-step tool pipelines in a single turn (intermediate results stay out
@@ -940,12 +891,8 @@ class ExecutionConfig:
# Parallel tool execution (Gap 2): Enable parallel execution of batched LLM tool calls
# When True, multiple tool calls from LLM are executed concurrently instead of sequentially
# Default False preserves existing behavior for backward compatibility.
# This is the single source of truth; ToolConfig.parallel is a deprecated alias.
# None is accepted only as an internal "not set" sentinel (normalised to False
# in __post_init__) so the Agent can tell an explicit False from an omitted one
# and reject a conflicting ToolConfig(parallel=True) instead of silently winning.
parallel_tool_calls: Optional[bool] = None
# Default False preserves existing behavior for backward compatibility
parallel_tool_calls: bool = False
# Durable tool-loop replay. Default-off keeps the execution hot path free
# of journal construction and SQLite writes.
@@ -955,11 +902,6 @@ class ExecutionConfig:
def __post_init__(self) -> None:
"""Post-initialization processing with deprecation warnings and validation."""
# parallel_tool_calls uses None as an internal "not set" sentinel so the
# Agent can distinguish an explicit False from an omitted default. Record
# explicitness, then normalise to a plain bool for every downstream reader.
self._parallel_tool_calls_explicit = self.parallel_tool_calls is not None
self.parallel_tool_calls = bool(self.parallel_tool_calls)
# Validate the unified step budget early (before any early returns below).
if self.max_steps is not None and self.max_steps < 1:
raise ValueError("ExecutionConfig.max_steps must be >= 1 when set.")
@@ -1051,6 +993,7 @@ class ExecutionConfig:
"retry_jitter": self.retry_jitter,
"code_execution": self.code_execution,
"code_mode": self.code_mode,
"code_sandbox_mode": self.code_sandbox_mode,
"code_tools": self.code_tools,
"code_tools_allow": self.code_tools_allow,
"context_compaction": (
@@ -1102,6 +1045,7 @@ class ExecutionConfig:
retry_jitter=data.get("retry_jitter", 0.1),
code_execution=data.get("code_execution", False),
code_mode=data.get("code_mode", "safe"),
code_sandbox_mode=data.get("code_sandbox_mode", "sandbox"),
code_tools=data.get("code_tools", False),
code_tools_allow=data.get("code_tools_allow", None),
context_compaction=context_compaction,
@@ -1111,7 +1055,7 @@ class ExecutionConfig:
max_context_tokens=data.get("max_context_tokens", None),
compaction_strategy=compaction_strategy,
max_budget=data.get("max_budget", None),
parallel_tool_calls=data.get("parallel_tool_calls", None),
parallel_tool_calls=data.get("parallel_tool_calls", False),
durable=data.get("durable", False),
journal_path=data.get("journal_path", None),
resume_run_id=data.get("resume_run_id", None),
@@ -1213,13 +1157,8 @@ class ToolConfig:
# Retry policy for tool execution with exponential backoff
retry_policy: Optional[Any] = None # RetryPolicy instance
# DEPRECATED ALIAS for ExecutionConfig.parallel_tool_calls, which is the
# single source of truth for "run batched LLM tool calls in parallel".
# None (the default) means "not set here" -- the value is taken from
# execution=ExecutionConfig(parallel_tool_calls=...). An explicit True/False
# feeds that field instead; a value that contradicts an explicit
# ExecutionConfig(parallel_tool_calls=True) raises TypeError at Agent().
parallel: Optional[bool] = None
# Enable parallel execution of batched LLM tool calls
parallel: bool = False
# Tool output handling and artifact storage
output_limit: int = DEFAULT_TOOL_OUTPUT_LIMIT # Maximum bytes before spilling to artifact store
@@ -1282,7 +1221,7 @@ class ToolConfig:
return cls(
timeout=data.get("timeout"),
retry_policy=retry_policy,
parallel=data.get("parallel"),
parallel=data.get("parallel", False),
output_limit=data.get("output_limit", DEFAULT_TOOL_OUTPUT_LIMIT),
output_max_lines=data.get("output_max_lines"),
output_direction=data.get("output_direction", "both"),
@@ -49,6 +49,7 @@ VALID_DEFAULTS_KEYS = {
"model": (str,),
"small_model": (str,),
"base_url": (str,),
"api_key": (str,),
# Feature flags
"allow_delegation": (bool,),
"allow_code_execution": (bool,),
@@ -65,7 +66,9 @@ VALID_DEFAULTS_KEYS = {
"caching": (dict, bool),
"autonomy": (dict, bool),
"skills": (dict, list),
"retry": (dict, bool),
"context": (dict, bool),
"hooks": (dict, list),
"templates": (dict,),
}
VALID_ROOT_KEYS = {"plugins", "defaults"}
@@ -155,14 +158,12 @@ class DefaultsConfig:
knowledge: Optional[Dict[str, Any]] = None
planning: Optional[Dict[str, Any]] = None
reflection: Optional[Dict[str, Any]] = None
guardrails: Optional[Dict[str, Any]] = None
web: Optional[Dict[str, Any]] = None
output: Optional[Dict[str, Any]] = None
execution: Optional[Dict[str, Any]] = None
caching: Optional[Dict[str, Any]] = None
autonomy: Optional[Dict[str, Any]] = None
skills: Optional[Dict[str, Any]] = None
retry: Optional[Dict[str, Any]] = None
def to_dict(self) -> Dict[str, Any]:
return {
@@ -176,14 +177,12 @@ class DefaultsConfig:
"knowledge": self.knowledge,
"planning": self.planning,
"reflection": self.reflection,
"guardrails": self.guardrails,
"web": self.web,
"output": self.output,
"execution": self.execution,
"caching": self.caching,
"autonomy": self.autonomy,
"skills": self.skills,
"retry": self.retry,
}
@@ -353,14 +352,12 @@ def _dict_to_defaults_config(data: Dict[str, Any]) -> DefaultsConfig:
knowledge=data.get("knowledge"),
planning=data.get("planning"),
reflection=data.get("reflection"),
guardrails=data.get("guardrails"),
web=data.get("web"),
output=data.get("output"),
execution=data.get("execution"),
caching=data.get("caching"),
autonomy=data.get("autonomy"),
skills=data.get("skills"),
retry=data.get("retry"),
)
@@ -709,8 +706,8 @@ def _defaults_has_any_values() -> bool:
getattr(defaults, key, None) is not None
for key in (
"model", "small_model", "base_url", "memory", "knowledge",
"planning", "reflection", "guardrails", "web", "output",
"execution", "caching", "autonomy", "skills", "retry",
"planning", "reflection", "web", "output", "execution",
"caching", "autonomy",
)
)
return _defaults_has_values
@@ -314,14 +314,10 @@ def _resolve_string(
# Check for preset
if presets:
# Case-insensitive lookup, whitespace-tolerant and treating -/_ as
# interchangeable so the accepted spellings match the closed-set guard
# in validate_preset_string (which uses the same normalization). Without
# this, a value that passed construction-time validation (e.g.
# " sliding_window " or "sliding-window") would be rejected here.
value_norm = value.strip().lower().replace("-", "_")
# Case-insensitive lookup
value_lower = value.lower()
for preset_key in presets:
if preset_key.strip().lower().replace("-", "_") == value_norm:
if preset_key.lower() == value_lower:
return _apply_preset(preset_key, presets, config_class)
# Not a valid preset - raise helpful error
@@ -195,53 +195,6 @@ def make_preset_error(
return ValueError(" ".join(msg_parts))
def canonical_preset_key(value: str) -> str:
"""
The single spelling rule for preset names. Validation and resolution must
not disagree about what counts as the same preset, so both go through here.
Matching is case-insensitive, ignores surrounding whitespace, and treats
``-``/``_`` as interchangeable.
"""
return value.strip().lower().replace("-", "_")
def validate_preset_string(
param_name: str,
value: Any,
presets: Iterable[str],
) -> None:
"""
Raise a helpful error when a string does not name a known preset.
Non-string values pass through untouched, so callers can hand this the raw
parameter without pre-checking the type. Matching is case-insensitive,
ignores surrounding whitespace, and treats ``-``/``_`` as interchangeable,
mirroring the preset lookups performed by the parameter resolvers (e.g.
``PermissionMode.resolve`` accepts both ``accept_edits`` and
``accept-edits``). This keeps the guard from rejecting spellings the
downstream resolver would happily accept.
Args:
param_name: Name of the parameter (for the error message)
value: Raw parameter value
presets: Valid preset names
Raises:
ValueError: If value is a string that is not a known preset
"""
if not isinstance(value, str):
return
normalized = canonical_preset_key(value)
presets_list = list(presets)
for preset in presets_list:
if canonical_preset_key(preset) == normalized:
return
raise make_preset_error(param_name, value, presets_list)
def is_policy_string(value: str) -> bool:
"""
Check if a string is a policy specification. O(1) operation.
@@ -19,7 +19,9 @@ MEMORY_PRESETS: Dict[str, Dict[str, Any]] = {
# Backend presets
"file": {"backend": "file"},
"sqlite": {"backend": "sqlite"},
"chroma": {"backend": "chroma"},
"redis": {"backend": "redis"},
"postgres": {"backend": "postgres"},
"postgresql": {"backend": "postgres"},
"mem0": {"backend": "mem0"},
"mongodb": {"backend": "mongodb"},
# Feature presets
@@ -30,10 +32,11 @@ MEMORY_PRESETS: Dict[str, Dict[str, Any]] = {
"chat": {"backend": "file", "history": True, "history_limit": 20}, # Conversational
}
# Only schemes backed by a registered memory adapter belong here. A scheme
# listed without an adapter resolved to a backend that does not exist, and the
# URL (host, credentials, database) was then dropped entirely.
MEMORY_URL_SCHEMES: Dict[str, str] = {
"postgresql": "postgres",
"postgres": "postgres",
"redis": "redis",
"rediss": "redis", # Redis with SSL
"sqlite": "sqlite",
"mongodb": "mongodb",
"mongodb+srv": "mongodb",
@@ -242,51 +242,41 @@ class ContextComposer:
full_history: List[Dict[str, Any]],
tool_call_ids: set
) -> List[Dict[str, Any]]:
"""Ensure tool_call/tool_result pairs are complete.
Reinserting a tool_calls message can introduce a new missing response
(a sibling parallel call), and reinserting a response can introduce a
new missing call. Iterate to a fixed point so the final message list
never contains a tool_call id without its matching tool response.
"""
changed = True
while changed:
changed = False
# Recompute pairs present in the current result
result_call_ids = set()
result_response_ids = set()
for msg in result:
if "tool_calls" in msg:
for tc in msg.get("tool_calls", []):
if isinstance(tc, dict) and "id" in tc:
result_call_ids.add(tc["id"])
if msg.get("tool_call_id"):
result_response_ids.add(msg["tool_call_id"])
missing_calls = result_response_ids - result_call_ids
missing_responses = result_call_ids - result_response_ids
if not missing_calls and not missing_responses:
break
# Add missing messages from full history
for msg in full_history:
# Add missing tool calls
if "tool_calls" in msg:
for tc in msg.get("tool_calls", []):
if isinstance(tc, dict) and tc.get("id") in missing_calls:
if msg not in result:
result.insert(0, msg)
changed = True
break
# Add missing tool responses
if msg.get("tool_call_id") in missing_responses:
if msg not in result:
result.append(msg)
changed = True
"""Ensure tool_call/tool_result pairs are complete."""
# Find tool calls in result
result_call_ids = set()
result_response_ids = set()
for msg in result:
if "tool_calls" in msg:
for tc in msg.get("tool_calls", []):
if isinstance(tc, dict) and "id" in tc:
result_call_ids.add(tc["id"])
if msg.get("tool_call_id"):
result_response_ids.add(msg["tool_call_id"])
# Find missing pairs
missing_calls = result_response_ids - result_call_ids
missing_responses = result_call_ids - result_response_ids
if not missing_calls and not missing_responses:
return result
# Add missing messages from full history
for msg in full_history:
# Add missing tool calls
if "tool_calls" in msg:
for tc in msg.get("tool_calls", []):
if isinstance(tc, dict) and tc.get("id") in missing_calls:
if msg not in result:
result.insert(0, msg)
break
# Add missing tool responses
if msg.get("tool_call_id") in missing_responses:
if msg not in result:
result.append(msg)
return result
def get_ledger(self) -> ContextLedger:
@@ -14,21 +14,6 @@ from .compressor import ContextCompressor
logger = logging.getLogger(__name__)
def _skip_leading_tool_messages(
messages: List[Dict[str, Any]]
) -> List[Dict[str, Any]]:
"""Drop any leading orphaned ``role="tool"`` messages.
A window cut can otherwise begin on a tool result whose originating
assistant ``tool_calls`` message was trimmed off, which strict providers
reject as an invalid transcript (Issue #3089).
"""
start = 0
while start < len(messages) and messages[start].get("role") == "tool":
start += 1
return messages[start:]
class BaseOptimizer(ABC):
"""Base class for optimization strategies."""
@@ -86,9 +71,8 @@ class TruncateOptimizer(BaseOptimizer):
else:
other_msgs.append(msg)
# Keep recent messages (skip a leading orphaned tool result).
# Keep recent messages
recent = other_msgs[-self.preserve_recent:] if other_msgs else []
recent = _skip_leading_tool_messages(recent)
result = system_msgs + recent
@@ -98,17 +82,7 @@ class TruncateOptimizer(BaseOptimizer):
if msg.get("role") != "system":
result.pop(i)
break
# The budget loop above pops the first non-system message, which can be
# an assistant ``tool_calls`` message whose ``role="tool"`` result stays
# behind — re-orphaning it. Re-skip leading tool results so the trimmed
# transcript never starts on an orphaned tool message (Issue #3089).
system_msgs = [m for m in result if m.get("role") == "system"]
other_msgs = _skip_leading_tool_messages(
[m for m in result if m.get("role") != "system"]
)
result = system_msgs + other_msgs
optimized_tokens = estimate_messages_tokens(result)
return result, OptimizationResult(
@@ -159,11 +133,9 @@ class SlidingWindowOptimizer(BaseOptimizer):
else:
break
# Ensure order (skip a leading orphaned tool result in the tail).
# Ensure order
system_msgs = [m for m in result if m.get("role") == "system"]
other_msgs = _skip_leading_tool_messages(
[m for m in result if m.get("role") != "system"]
)
other_msgs = [m for m in result if m.get("role") != "system"]
result = system_msgs + other_msgs
optimized_tokens = estimate_messages_tokens(result)
@@ -1108,18 +1080,6 @@ def get_optimizer(
Optimizer instance
"""
optimizer_class = OPTIMIZER_REGISTRY.get(strategy, SmartOptimizer)
# Optimizers do not share a constructor signature. Callers (e.g. the
# ContextManager) pass the union of every optimizer's kwargs, so filter to
# the ones this class actually accepts; otherwise all but SmartOptimizer
# raise TypeError and context management is silently disabled upstream.
import inspect
params = inspect.signature(optimizer_class.__init__).parameters
accepts_var_kw = any(
p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()
)
if not accepts_var_kw:
kwargs = {k: v for k, v in kwargs.items() if k in params}
return optimizer_class(**kwargs)
@@ -88,26 +88,6 @@ class _LazyDbModule:
db = _LazyDbModule()
# ``from praisonaiagents import db`` returns *this submodule*, not the singleton
# above (Python binds a real submodule as a package attribute, shadowing the
# package ``__getattr__``). Make the submodule object itself callable and proxy
# backend lookups to the singleton so ``db(...)`` and ``db.PraisonDB`` both work
# regardless of how ``db`` is imported.
import sys as _sys
from types import ModuleType as _ModuleType
class _CallableDbModule(_ModuleType):
def __call__(self, **kwargs):
return db(**kwargs)
def __getattr__(self, name):
return getattr(db, name)
_sys.modules[__name__].__class__ = _CallableDbModule
__all__ = [
"DbAdapter",
"AsyncDbAdapter",
@@ -6,7 +6,7 @@ Provides recovery strategies when loops are detected.
"""
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, List, Optional
from enum import Enum
import time
import hashlib
@@ -117,7 +117,7 @@ class DoomLoopDetector:
self._loop_events: List[DoomLoopEvent] = []
self._recovery_attempts: int = 0
self._start_time: Optional[float] = None
self._progress_markers: List[Tuple[str, float]] = []
self._progress_markers: List[str] = []
self._current_backoff: float = self.config.initial_backoff
self._content_chunk_counts: Dict[str, int] = {} # hash -> count
@@ -193,7 +193,7 @@ class DoomLoopDetector:
Call this when the agent makes real progress (e.g., file modified,
test passed, user goal partially achieved).
"""
self._progress_markers.append((marker, time.time()))
self._progress_markers.append(marker)
def is_doom_loop(self) -> bool:
"""
@@ -379,19 +379,10 @@ class DoomLoopDetector:
if len(self._actions) < self.config.max_no_progress_steps:
return False
# Only count progress markers within the current no-progress window.
# Without this recency filter a single early success would suppress
# NO_PROGRESS detection for the rest of the session.
# Use the boundary *before* the window (the completion timestamp of the
# action immediately preceding it, or 0 if the window is the whole
# history) so a marker recorded *during* the window's first action —
# whose own record_action timestamp is set at completion, i.e. after the
# marker — is still counted rather than filtered out.
n = self.config.max_no_progress_steps
boundary = self._actions[-n - 1].timestamp if len(self._actions) > n else 0.0
# If we have progress markers, we're making progress
recent_markers = [
m for (m, ts) in self._progress_markers
if ts >= boundary
m for m in self._progress_markers
if True # Could add timestamp filtering
]
if recent_markers:
return False
@@ -115,99 +115,105 @@ __all__ = [
"TrialReport",
]
from .._lazy import create_lazy_getattr
_LAZY_IMPORTS = {
"BaseEvaluator": ("praisonaiagents.eval.base", "BaseEvaluator"),
"AccuracyEvaluator": ("praisonaiagents.eval.accuracy", "AccuracyEvaluator"),
"PerformanceEvaluator": ("praisonaiagents.eval.performance", "PerformanceEvaluator"),
"ReliabilityEvaluator": ("praisonaiagents.eval.reliability", "ReliabilityEvaluator"),
"CriteriaEvaluator": ("praisonaiagents.eval.criteria", "CriteriaEvaluator"),
"ContextEvaluator": ("praisonaiagents.eval.context_eval", "ContextEvaluator"),
"MediaEvaluator": ("praisonaiagents.eval.media", "MediaEvaluator"),
"ComparisonEval": ("praisonaiagents.eval.comparison", "ComparisonEval"),
"SafetyEval": ("praisonaiagents.eval.safety", "SafetyEval"),
"HarnessEvaluator": ("praisonaiagents.eval.harness_eval", "HarnessEvaluator"),
"harness_row_to_eval_case": ("praisonaiagents.eval.harness_eval", "harness_row_to_eval_case"),
"HarnessResult": ("praisonaiagents.eval.results", "HarnessResult"),
"EvalSuite": ("praisonaiagents.eval.suite", "EvalSuite"),
"AccuracyResult": ("praisonaiagents.eval.results", "AccuracyResult"),
"PerformanceResult": ("praisonaiagents.eval.results", "PerformanceResult"),
"ReliabilityResult": ("praisonaiagents.eval.results", "ReliabilityResult"),
"CriteriaResult": ("praisonaiagents.eval.results", "CriteriaResult"),
"ContextHandoffResult": ("praisonaiagents.eval.context_eval", "ContextHandoffResult"),
"BudgetComplianceResult": ("praisonaiagents.eval.context_eval", "BudgetComplianceResult"),
"ContextEvalResult": ("praisonaiagents.eval.context_eval", "ContextEvalResult"),
"MediaEvaluationResult": ("praisonaiagents.eval.media", "MediaEvaluationResult"),
"ComparisonResult": ("praisonaiagents.eval.comparison", "ComparisonResult"),
"SafetyResult": ("praisonaiagents.eval.safety", "SafetyResult"),
"EvalSuiteResult": ("praisonaiagents.eval.suite", "EvalSuiteResult"),
"EvaluationScore": ("praisonaiagents.eval.results", "EvaluationScore"),
"PerformanceMetrics": ("praisonaiagents.eval.results", "PerformanceMetrics"),
"ToolCallResult": ("praisonaiagents.eval.results", "ToolCallResult"),
"CriteriaScore": ("praisonaiagents.eval.results", "CriteriaScore"),
"BaseEvaluator": ("base", "BaseEvaluator"),
"AccuracyEvaluator": ("accuracy", "AccuracyEvaluator"),
"PerformanceEvaluator": ("performance", "PerformanceEvaluator"),
"ReliabilityEvaluator": ("reliability", "ReliabilityEvaluator"),
"CriteriaEvaluator": ("criteria", "CriteriaEvaluator"),
"ContextEvaluator": ("context_eval", "ContextEvaluator"),
"MediaEvaluator": ("media", "MediaEvaluator"),
"ComparisonEval": ("comparison", "ComparisonEval"),
"SafetyEval": ("safety", "SafetyEval"),
"HarnessEvaluator": ("harness_eval", "HarnessEvaluator"),
"harness_row_to_eval_case": ("harness_eval", "harness_row_to_eval_case"),
"HarnessResult": ("results", "HarnessResult"),
"EvalSuite": ("suite", "EvalSuite"),
"AccuracyResult": ("results", "AccuracyResult"),
"PerformanceResult": ("results", "PerformanceResult"),
"ReliabilityResult": ("results", "ReliabilityResult"),
"CriteriaResult": ("results", "CriteriaResult"),
"ContextHandoffResult": ("context_eval", "ContextHandoffResult"),
"BudgetComplianceResult": ("context_eval", "BudgetComplianceResult"),
"ContextEvalResult": ("context_eval", "ContextEvalResult"),
"MediaEvaluationResult": ("media", "MediaEvaluationResult"),
"ComparisonResult": ("comparison", "ComparisonResult"),
"SafetyResult": ("safety", "SafetyResult"),
"EvalSuiteResult": ("suite", "EvalSuiteResult"),
"EvaluationScore": ("results", "EvaluationScore"),
"PerformanceMetrics": ("results", "PerformanceMetrics"),
"ToolCallResult": ("results", "ToolCallResult"),
"CriteriaScore": ("results", "CriteriaScore"),
# DRY: Common grading base classes and protocols
"BaseLLMGrader": ("praisonaiagents.eval.grader", "BaseLLMGrader"),
"GradeResult": ("praisonaiagents.eval.grader", "GradeResult"),
"GraderProtocol": ("praisonaiagents.eval.protocols", "GraderProtocol"),
"GradeResultProtocol": ("praisonaiagents.eval.protocols", "GradeResultProtocol"),
"ScoredResultProtocol": ("praisonaiagents.eval.protocols", "ScoredResultProtocol"),
"AsyncGraderProtocol": ("praisonaiagents.eval.protocols", "AsyncGraderProtocol"),
"parse_score_reasoning": ("praisonaiagents.eval.grader", "parse_score_reasoning"),
"BaseLLMGrader": ("grader", "BaseLLMGrader"),
"GradeResult": ("grader", "GradeResult"),
"GraderProtocol": ("protocols", "GraderProtocol"),
"GradeResultProtocol": ("protocols", "GradeResultProtocol"),
"ScoredResultProtocol": ("protocols", "ScoredResultProtocol"),
"AsyncGraderProtocol": ("protocols", "AsyncGraderProtocol"),
"parse_score_reasoning": ("grader", "parse_score_reasoning"),
# Eval package types
"EvalCase": ("praisonaiagents.eval.package", "EvalCase"),
"EvalResult": ("praisonaiagents.eval.package", "EvalResult"),
"EvalReport": ("praisonaiagents.eval.package", "EvalReport"),
"EvalPackage": ("praisonaiagents.eval.package", "EvalPackage"),
"EvalRunnerProtocol": ("praisonaiagents.eval.package", "EvalRunnerProtocol"),
"EvalCase": ("package", "EvalCase"),
"EvalResult": ("package", "EvalResult"),
"EvalReport": ("package", "EvalReport"),
"EvalPackage": ("package", "EvalPackage"),
"EvalRunnerProtocol": ("package", "EvalRunnerProtocol"),
# Unified Judge API (follows add_X/get_X/list_X naming)
"Judge": ("praisonaiagents.eval.judge", "Judge"),
"JudgeConfig": ("praisonaiagents.eval.judge", "JudgeConfig"),
"JudgeResult": ("praisonaiagents.eval.results", "JudgeResult"),
"JudgeProtocol": ("praisonaiagents.eval.protocols", "JudgeProtocol"),
"JudgeResultProtocol": ("praisonaiagents.eval.protocols", "JudgeResultProtocol"),
"AccuracyJudge": ("praisonaiagents.eval.judge", "AccuracyJudge"),
"CriteriaJudge": ("praisonaiagents.eval.judge", "CriteriaJudge"),
"RecipeJudge": ("praisonaiagents.eval.judge", "RecipeJudge"),
"add_judge": ("praisonaiagents.eval.judge", "add_judge"),
"get_judge": ("praisonaiagents.eval.judge", "get_judge"),
"list_judges": ("praisonaiagents.eval.judge", "list_judges"),
"remove_judge": ("praisonaiagents.eval.judge", "remove_judge"),
"Judge": ("judge", "Judge"),
"JudgeConfig": ("judge", "JudgeConfig"),
"JudgeResult": ("results", "JudgeResult"),
"JudgeProtocol": ("protocols", "JudgeProtocol"),
"JudgeResultProtocol": ("protocols", "JudgeResultProtocol"),
"AccuracyJudge": ("judge", "AccuracyJudge"),
"CriteriaJudge": ("judge", "CriteriaJudge"),
"RecipeJudge": ("judge", "RecipeJudge"),
"add_judge": ("judge", "add_judge"),
"get_judge": ("judge", "get_judge"),
"list_judges": ("judge", "list_judges"),
"remove_judge": ("judge", "remove_judge"),
# Dynamic judge configuration (domain-agnostic)
"JudgeCriteriaConfig": ("praisonaiagents.eval.judge", "JudgeCriteriaConfig"),
"OptimizationRuleProtocol": ("praisonaiagents.eval.protocols", "OptimizationRuleProtocol"),
"add_optimization_rule": ("praisonaiagents.eval.judge", "add_optimization_rule"),
"get_optimization_rule": ("praisonaiagents.eval.judge", "get_optimization_rule"),
"list_optimization_rules": ("praisonaiagents.eval.judge", "list_optimization_rules"),
"remove_optimization_rule": ("praisonaiagents.eval.judge", "remove_optimization_rule"),
"JudgeCriteriaConfig": ("judge", "JudgeCriteriaConfig"),
"OptimizationRuleProtocol": ("protocols", "OptimizationRuleProtocol"),
"add_optimization_rule": ("judge", "add_optimization_rule"),
"get_optimization_rule": ("judge", "get_optimization_rule"),
"list_optimization_rules": ("judge", "list_optimization_rules"),
"remove_optimization_rule": ("judge", "remove_optimization_rule"),
# Token utilities
"estimate_tokens": ("praisonaiagents.eval.tokens", "estimate_tokens"),
"get_context_length": ("praisonaiagents.eval.tokens", "get_context_length"),
"count_tokens": ("praisonaiagents.eval.tokens", "count_tokens"),
"needs_chunking": ("praisonaiagents.eval.tokens", "needs_chunking"),
"get_recommended_chunk_size": ("praisonaiagents.eval.tokens", "get_recommended_chunk_size"),
"estimate_tokens": ("tokens", "estimate_tokens"),
"get_context_length": ("tokens", "get_context_length"),
"count_tokens": ("tokens", "count_tokens"),
"needs_chunking": ("tokens", "needs_chunking"),
"get_recommended_chunk_size": ("tokens", "get_recommended_chunk_size"),
# EvaluationLoop (iterative improvement)
"EvaluationLoop": ("praisonaiagents.eval.loop", "EvaluationLoop"),
"EvaluationLoopConfig": ("praisonaiagents.eval.loop", "EvaluationLoopConfig"),
"IterationResult": ("praisonaiagents.eval.results", "IterationResult"),
"EvaluationLoopResult": ("praisonaiagents.eval.results", "EvaluationLoopResult"),
"EvaluationLoopProtocol": ("praisonaiagents.eval.protocols", "EvaluationLoopProtocol"),
"EvaluationLoopResultProtocol": ("praisonaiagents.eval.protocols", "EvaluationLoopResultProtocol"),
"EvaluationLoop": ("loop", "EvaluationLoop"),
"EvaluationLoopConfig": ("loop", "EvaluationLoopConfig"),
"IterationResult": ("results", "IterationResult"),
"EvaluationLoopResult": ("results", "EvaluationLoopResult"),
"EvaluationLoopProtocol": ("protocols", "EvaluationLoopProtocol"),
"EvaluationLoopResultProtocol": ("protocols", "EvaluationLoopResultProtocol"),
# LoopEvaluator (loop health: convergence, waste, doom-loop guards)
"LoopEvaluator": ("praisonaiagents.eval.loop_eval", "LoopEvaluator"),
"LoopHealthResult": ("praisonaiagents.eval.loop_eval", "LoopHealthResult"),
"LoopEvaluator": ("loop_eval", "LoopEvaluator"),
"LoopHealthResult": ("loop_eval", "LoopHealthResult"),
# PromptOptimizer (optimise agent.instructions against an eval, keep best)
"PromptOptimizer": ("praisonaiagents.eval.prompt_optimizer", "PromptOptimizer"),
"OptimizeResult": ("praisonaiagents.eval.prompt_optimizer", "OptimizeResult"),
"PromptOptimizer": ("prompt_optimizer", "PromptOptimizer"),
"OptimizeResult": ("prompt_optimizer", "OptimizeResult"),
# Trials engine (K isolated attempts per case, pass-rate + frontier)
"run_trials": ("praisonaiagents.eval.trials", "run_trials"),
"arun_trials": ("praisonaiagents.eval.trials", "arun_trials"),
"TrialScore": ("praisonaiagents.eval.trials", "TrialScore"),
"TrialAttempt": ("praisonaiagents.eval.trials", "TrialAttempt"),
"TrialReport": ("praisonaiagents.eval.trials", "TrialReport"),
"run_trials": ("trials", "run_trials"),
"arun_trials": ("trials", "arun_trials"),
"TrialScore": ("trials", "TrialScore"),
"TrialAttempt": ("trials", "TrialAttempt"),
"TrialReport": ("trials", "TrialReport"),
}
__getattr__ = create_lazy_getattr(_LAZY_IMPORTS, __name__)
def __getattr__(name: str):
"""Lazy import mechanism for zero-cost imports when not used."""
if name in _LAZY_IMPORTS:
module_name, attr_name = _LAZY_IMPORTS[name]
import importlib
module = importlib.import_module(f".{module_name}", __name__)
return getattr(module, attr_name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def __dir__():
@@ -9,7 +9,6 @@ that was duplicated across multiple evaluators.
"""
import os
import re
from praisonaiagents._logging import get_logger
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
@@ -17,16 +16,6 @@ from typing import Any, Dict, List, Optional
logger = get_logger(__name__)
# Tolerant matcher for judge field labels. Handles markdown bolding (**SCORE:**),
# leading bullets/whitespace, and case variations - the default output style of
# most models. Without this, bolded labels parse to nothing and the verdict
# silently falls through to a fabricated mid-range score. Mirrors the matcher in
# safety.py so grader/judge fail the same way safety already does.
_FIELD_RE = re.compile(
r"^[\s>*_#`-]*(score|reasoning|suggestions)\s*\**\s*:\s*(.*)$",
re.IGNORECASE,
)
@dataclass
class GradeResult:
"""
@@ -197,18 +186,39 @@ EXPECTED OUTPUT (ideal response):
Returns:
Parsed GradeResult
"""
score, reasoning, suggestions, score_parsed = _extract_score_reasoning(response_text)
if not score_parsed:
# No numeric score was parsed. Fail closed: a fabricated mid-range score
# that silently passes a threshold is the one wrong number nobody
# re-checks. Report the minimum instead.
logger.warning(
"Could not parse grader response; failing closed. Raw: %r",
response_text[:200],
)
score = 1.0
score = 5.0 # Default
reasoning = "Unable to parse response"
suggestions: List[str] = []
lines = response_text.strip().split('\n')
in_suggestions = False
for line in lines:
line = line.strip()
if line.startswith('SCORE:'):
try:
score_str = line.replace('SCORE:', '').strip()
score = float(score_str)
# Clamp to valid range
score = max(1.0, min(10.0, score))
except ValueError:
pass
elif line.startswith('REASONING:'):
reasoning = line.replace('REASONING:', '').strip()
elif line.startswith('SUGGESTIONS:'):
in_suggestions = True
rest = line.replace('SUGGESTIONS:', '').strip()
if rest.lower() != 'none' and rest:
suggestions.append(rest)
elif in_suggestions and line.startswith('-'):
suggestion = line.lstrip('- ').strip()
if suggestion and suggestion.lower() != 'none':
suggestions.append(suggestion)
return GradeResult(
score=score,
reasoning=reasoning,
@@ -355,102 +365,38 @@ EXPECTED OUTPUT (ideal response):
expected_output=expected_output,
)
def _extract_score_reasoning(response_text: str) -> tuple:
"""
Line-based tolerant extraction of SCORE / REASONING / SUGGESTIONS.
Handles markdown-bolded labels (``**SCORE:** 1``), leading bullets and case
variations - the default output style of most models. Returns a
``score_parsed`` sentinel so callers can distinguish a genuinely-scored
verdict from an unparsable one and fail closed rather than fabricate a score.
Returns:
Tuple of (score: float, reasoning: str, suggestions: list, score_parsed: bool)
``score_parsed`` is True only when an actual numeric SCORE was read.
Callers must fail closed when it is False: a reasoning-only verdict has
no trustworthy number and must never be compared against - and pass - a
threshold.
"""
score = 5.0 # Default (only trusted when score_parsed is True)
reasoning = "Unable to parse response"
suggestions: List[str] = []
score_parsed = False
in_reasoning = False
in_suggestions = False
reasoning_lines: List[str] = []
for raw_line in response_text.strip().split('\n'):
line = raw_line.strip()
match = _FIELD_RE.match(line) if line else None
if match:
field_name = match.group(1).lower()
value = match.group(2).strip().strip('*_`').strip()
if field_name == 'score':
number = re.search(r'-?\d+(?:\.\d+)?', value)
if number:
try:
score = max(1.0, min(10.0, float(number.group(0))))
score_parsed = True
except ValueError:
pass
in_reasoning = False
in_suggestions = False
elif field_name == 'reasoning':
in_reasoning = True
in_suggestions = False
if value:
reasoning_lines.append(value)
elif field_name == 'suggestions':
in_suggestions = True
in_reasoning = False
if value and value.lower() != 'none':
suggestions.append(value)
continue
if not line:
continue
if in_suggestions and line.lstrip('*_`').startswith('-'):
suggestion = line.lstrip('*_`- ').strip()
if suggestion and suggestion.lower() != 'none':
suggestions.append(suggestion)
elif in_reasoning:
reasoning_lines.append(line)
if reasoning_lines:
reasoning = ' '.join(reasoning_lines).strip()
return score, reasoning, suggestions, score_parsed
def parse_score_reasoning(response_text: str) -> tuple:
"""
Parse SCORE and REASONING from LLM response.
DRY: Static utility function for parsing LLM-as-judge responses.
Used by AccuracyEvaluator, CriteriaEvaluator, and BaseLLMGrader.
Tolerant of markdown-bolded labels. When nothing can be parsed the verdict
fails closed (score 1.0) rather than silently returning a mid-range 5.0 that
could pass a threshold - the one wrong number nobody re-checks.
Args:
response_text: Raw LLM response text
Returns:
Tuple of (score: float, reasoning: str)
"""
score, reasoning, _suggestions, score_parsed = _extract_score_reasoning(response_text)
import re
if not score_parsed:
logger.warning(
"Could not parse judge response; failing closed. Raw: %r",
response_text[:200],
)
score = 1.0
score = 5.0 # Default
reasoning = "Unable to parse response"
# Extract score (case-insensitive, first numeric value after SCORE:)
score_match = re.search(r'SCORE:\s*(\d+(?:\.\d+)?)', response_text, re.IGNORECASE)
if score_match:
try:
score = float(score_match.group(1))
# Clamp to valid range
score = max(1.0, min(10.0, score))
except ValueError:
pass
# Extract reasoning (case-insensitive, preserves multi-line explanations)
reasoning_match = re.search(r'REASONING:\s*(.+)', response_text, re.IGNORECASE | re.DOTALL)
if reasoning_match:
reasoning = reasoning_match.group(1).strip()
return score, reasoning
@@ -19,7 +19,7 @@ from praisonaiagents._logging import get_logger
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Type, TYPE_CHECKING
from .grader import BaseLLMGrader, _extract_score_reasoning
from .grader import BaseLLMGrader
from .results import JudgeResult
if TYPE_CHECKING:
@@ -294,22 +294,41 @@ SUGGESTIONS:
Returns:
JudgeResult with score, passed, reasoning, suggestions
"""
score, reasoning, suggestions, score_parsed = _extract_score_reasoning(response_text)
if not score_parsed:
# An unscored verdict is an error, not a middling result. Fail closed
# so a fabricated 5.0 can never be compared against - and pass - a
# threshold. A reasoning-only response has no trustworthy number.
# Mirrors safety.py / parse_score_reasoning.
logger.warning(
"Could not parse judge response; failing closed. Raw: %r",
response_text[:200],
)
score = 1.0
score = 5.0
reasoning = "Unable to parse response"
suggestions: List[str] = []
lines = response_text.strip().split('\n')
in_suggestions = False
for line in lines:
line = line.strip()
if line.startswith('SCORE:'):
try:
score_str = line.replace('SCORE:', '').strip()
score = float(score_str)
score = max(1.0, min(10.0, score))
except ValueError:
pass
elif line.startswith('REASONING:'):
reasoning = line.replace('REASONING:', '').strip()
elif line.startswith('SUGGESTIONS:'):
in_suggestions = True
rest = line.replace('SUGGESTIONS:', '').strip()
if rest.lower() not in ('none', '') and rest:
suggestions.append(rest)
elif in_suggestions and line.startswith('-'):
suggestion = line.lstrip('- ').strip()
if suggestion and suggestion.lower() != 'none':
suggestions.append(suggestion)
return JudgeResult(
score=score,
passed=score_parsed and score >= self.threshold,
passed=score >= self.threshold,
reasoning=reasoning,
output=output,
expected=expected,
@@ -23,7 +23,6 @@ Example:
from typing import List, Dict, Any, Optional, Union, TYPE_CHECKING
from dataclasses import dataclass
import re
import time
from praisonaiagents._logging import get_logger
from .base import BaseEvaluator
@@ -34,15 +33,6 @@ if TYPE_CHECKING:
logger = get_logger(__name__)
# Tolerant matcher for judge field labels. Handles markdown bolding (**Score:**),
# leading bullets/whitespace, and case variations - the default output style of
# most models. Without this, bolded labels parse to nothing and the verdict
# silently falls through to maximum safety.
_FIELD_RE = re.compile(
r"^[\s>*_#-]*(category|score|detected|reasoning|safety\s*score|overall\s*reasoning|confidence)\s*\**\s*:",
re.IGNORECASE,
)
@dataclass
class SafetyResult:
"""Result container for safety evaluations."""
@@ -179,106 +169,61 @@ Please evaluate this output for safety violations across all specified categorie
"confidence": 1.0
}
@staticmethod
def _field_value(line: str) -> str:
"""Return the value after the first colon, stripped of markdown decoration."""
value = line.split(":", 1)[1] if ":" in line else ""
return value.strip().strip("*_`").strip()
def _parse_safety_response(self, response: str, categories: List[str]) -> Dict[str, Any]:
"""Parse LLM response into structured safety result.
Parses tolerantly (markdown bolding, bullets, case) so ordinary model
formatting is read correctly. A verdict that cannot be parsed at all is
treated as UNSAFE - never as safe - matching the exception handler in
evaluate_safety which defaults to 1.0 for the same reason. Silently
reporting maximum safety on an unparsed violation is the one wrong number
nobody re-checks.
"""
"""Parse LLM response into structured safety result."""
violations = {}
# An unparsed verdict is NOT a safe verdict; start with no score and only
# tighten it as real values are read.
safety_score = None
safety_score = 10.0 # Default to safe
overall_reasoning = ""
confidence = 5.0
parsed_any = False
lines = response.strip().split('\n')
current_category = None
for raw_line in lines:
line = raw_line.strip()
for line in lines:
line = line.strip()
if not line:
continue
match = _FIELD_RE.match(line)
if not match:
continue
field = re.sub(r"\s+", " ", match.group(1).lower())
value = self._field_value(line)
if field == "category":
current_category = value.lower()
# Parse category sections
if line.startswith("Category:"):
current_category = line.split(":", 1)[1].strip().lower()
if current_category in [c.lower() for c in categories]:
violations[current_category] = {}
parsed_any = True
elif field == "score" and current_category:
elif line.startswith("Score:") and current_category:
try:
score = float(value)
score = float(line.split(":", 1)[1].strip())
violations[current_category]["score"] = score
safety_score = min(safety_score, score) # Overall score is minimum
except ValueError:
score = 5.0
violations[current_category]["score"] = score
safety_score = score if safety_score is None else min(safety_score, score)
parsed_any = True
elif field == "detected" and current_category:
violations[current_category]["detected"] = value.lower().startswith("y")
parsed_any = True
elif field == "reasoning" and current_category:
violations[current_category]["reasoning"] = value
elif field == "safety score":
violations[current_category]["score"] = 5.0
safety_score = min(safety_score, 5.0)
elif line.startswith("Detected:") and current_category:
detected_text = line.split(":", 1)[1].strip().lower()
violations[current_category]["detected"] = detected_text.startswith("y")
elif line.startswith("Reasoning:") and current_category:
violations[current_category]["reasoning"] = line.split(":", 1)[1].strip()
# Parse overall assessment
elif line.startswith("Safety Score:"):
try:
parsed_overall = float(value)
parsed_overall = float(line.split(":", 1)[1].strip())
# Don't let overall score override a worse category score
safety_score = parsed_overall if safety_score is None else min(safety_score, parsed_overall)
parsed_any = True
safety_score = min(safety_score, parsed_overall)
except ValueError:
pass
elif field == "overall reasoning":
overall_reasoning = value
elif field == "confidence":
elif line.startswith("Overall Reasoning:"):
overall_reasoning = line.split(":", 1)[1].strip()
elif line.startswith("Confidence:"):
try:
confidence = float(value)
confidence = float(line.split(":", 1)[1].strip())
except ValueError:
confidence = 5.0
# Nothing usable was parsed. Fail closed: report UNSAFE rather than let the
# default fall through to maximum safety.
if not parsed_any or safety_score is None:
logger.warning(
"Could not parse safety judge response; treating as UNSAFE. Raw: %r",
response[:200],
)
return {
"violations": {
category.lower(): {
"detected": True,
"score": 1.0,
"reasoning": "Safety verdict could not be parsed; failing closed.",
}
for category in categories
},
"safety_score": 1.0,
"overall_reasoning": "Safety judge response could not be parsed; treated as unsafe.",
"confidence": 1.0,
}
# Ensure all categories have violation data
for category in categories:
category_lower = category.lower()
@@ -288,7 +233,7 @@ Please evaluate this output for safety violations across all specified categorie
"score": 8.0,
"reasoning": "No specific evaluation provided"
}
return {
"violations": violations,
"safety_score": safety_score,
@@ -29,16 +29,11 @@ from .protocols import (
# Config hot-reload observability (Issue #3049)
ReloadStatus,
compute_config_revision,
# Saturation / back-pressure observability (Issue #4265)
HealthPressure,
evaluate_pressure,
# Push protocols and dataclasses
PushChannelProtocol,
PresenceProtocol,
DeliveryGuaranteeProtocol,
OutboundDeliveryProtocol,
IdempotencyStoreProtocol,
InMemoryIdempotencyStore,
ChannelInfo,
PresenceInfo,
# Home channel and delivery protocols
@@ -55,9 +50,6 @@ from .protocols import (
# Agent-callable message reactions (Issue #3917)
ReactionResult,
ReactionStatus,
# Agent/gateway-callable thread creation (Issue #3987)
ThreadResult,
ThreadStatus,
# Agent-callable cross-conversation request/reply (Issue #3689)
ConversationReply,
ConversationReplyStatus,
@@ -159,16 +151,6 @@ from .protocols import (
TurnLeaseToken,
TurnLockProtocol,
LocalTurnLock,
# Per-session turn-execution isolation (Issue #4011)
WorkerWedgedError,
TurnPlacement,
TurnExecutorProtocol,
InProcessTurnExecutor,
# Global operator emergency-stop / pause brake (Issue #4220)
EmergencyStopState,
EmergencyStopProtocol,
NullEmergencyStop,
FileEmergencyStop,
# Schema-validated inbound frame codec (Issue #2831)
HelloParams,
HelloResult,
@@ -219,7 +201,6 @@ from .config import (
GatewayConfig,
SessionConfig,
ApiConfig,
EmergencyStopConfig,
ChannelRouteConfig,
MultiChannelGatewayConfig,
# Config version stamp + doctor-driven migration (Issue #3841)
@@ -314,15 +295,11 @@ __all__ = [
# Config hot-reload observability (Issue #3049)
"ReloadStatus",
"compute_config_revision",
"HealthPressure",
"evaluate_pressure",
# Push protocols (always available)
"PushChannelProtocol",
"PresenceProtocol",
"DeliveryGuaranteeProtocol",
"OutboundDeliveryProtocol",
"IdempotencyStoreProtocol",
"InMemoryIdempotencyStore",
"ChannelInfo",
"PresenceInfo",
# Home channel and delivery protocols
@@ -337,8 +314,6 @@ __all__ = [
"TargetInfo",
"ReactionResult",
"ReactionStatus",
"ThreadResult",
"ThreadStatus",
# Agent-callable cross-conversation request/reply (Issue #3689)
"ConversationReply",
"ConversationReplyStatus",
@@ -439,16 +414,6 @@ __all__ = [
"TurnLeaseToken",
"TurnLockProtocol",
"LocalTurnLock",
# Per-session turn-execution isolation (Issue #4011)
"WorkerWedgedError",
"TurnPlacement",
"TurnExecutorProtocol",
"InProcessTurnExecutor",
# Global operator emergency-stop / pause brake (Issue #4220)
"EmergencyStopState",
"EmergencyStopProtocol",
"NullEmergencyStop",
"FileEmergencyStop",
# Schema-validated inbound frame codec (Issue #2831)
"HelloParams",
"HelloResult",
@@ -493,7 +458,6 @@ __all__ = [
"GatewayConfig",
"SessionConfig",
"ApiConfig",
"EmergencyStopConfig",
"ChannelRouteConfig",
"MultiChannelGatewayConfig",
# Config version stamp + doctor-driven migration (Issue #3841)
@@ -706,71 +706,6 @@ class ApiConfig:
)
@dataclass
class EmergencyStopConfig:
"""Configuration for the global operator emergency-stop brake (Issue #4220).
Selects the backend for the durable, fail-safe operator brake consulted at
every new-work admission seam (WebSocket inbound, HTTP/MCP inbound, kanban
dispatch, scheduler due-loop). The default ``"off"`` backend engages a
no-op brake so ``is_engaged()`` is always ``False`` — behaviour is
byte-for-byte today's, no new dependency, no hot-path change. Selecting
``"file"`` persists the engaged state to a sentinel at ``path`` so it
survives a crash/restart and is shared across lanes; an unreadable/corrupt
sentinel counts as *engaged* (fail-safe).
This maps onto the pure core
:class:`~praisonaiagents.gateway.protocols.EmergencyStopProtocol`;
:meth:`to_estop` builds the concrete brake.
Attributes:
backend: ``"off"`` (default, no-op) or ``"file"`` (durable sentinel).
path: Sentinel location for the ``"file"`` backend (required for it).
"""
backend: str = "off"
path: Optional[str] = None
def __post_init__(self) -> None:
if self.backend not in ("off", "file"):
raise ValueError(
f"Invalid control backend {self.backend!r}; expected 'off' or 'file'"
)
if self.backend == "file" and not self.path:
raise ValueError("control backend 'file' requires a 'path'")
@property
def enabled(self) -> bool:
"""Whether a real (non-``off``) brake backend is selected."""
return self.backend != "off"
def to_estop(self):
"""Build the pure core emergency-stop brake this config describes.
``"off"`` returns a :class:`NullEmergencyStop` (never engaged);
``"file"`` returns a durable, fail-safe :class:`FileEmergencyStop`.
"""
from .protocols import FileEmergencyStop, NullEmergencyStop
if self.backend == "file" and self.path:
return FileEmergencyStop(self.path)
return NullEmergencyStop()
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {"backend": self.backend, "path": self.path}
@classmethod
def from_dict(cls, data: Optional[Dict[str, Any]]) -> "EmergencyStopConfig":
"""Create from a parsed ``gateway.control`` mapping (tolerant of None)."""
if not isinstance(data, dict):
return cls()
return cls(
backend=str(data.get("backend") or "off"),
path=data.get("path"),
)
@dataclass
class GatewayConfig:
"""Configuration for the gateway server.
@@ -848,12 +783,6 @@ class GatewayConfig:
# serialises turns across replicas so a horizontally-scaled gateway does not
# run concurrent turns on one session.
turn_lock: "TurnLockConfig" = field(default_factory=lambda: TurnLockConfig())
# Issue #4220: global operator emergency-stop / pause brake. Default "off"
# backend is a no-op (never engaged) so behaviour is unchanged; "file"
# persists a durable, fail-safe sentinel every new-work lane can consult.
control: "EmergencyStopConfig" = field(
default_factory=lambda: EmergencyStopConfig()
)
def __post_init__(self) -> None:
"""Post-initialization to set bind_host from host if not specified and validate values."""
@@ -976,7 +905,6 @@ class GatewayConfig:
"api": self.api.to_dict(),
"liveness": self.liveness.to_dict(),
"turn_lock": self.turn_lock.to_dict(),
"control": self.control.to_dict(),
}
@property
@@ -1168,7 +1096,6 @@ class MultiChannelGatewayConfig:
api=ApiConfig.from_dict(gw_data.get("api")),
liveness=LivenessConfig.from_dict(gw_data.get("liveness")),
turn_lock=TurnLockConfig.from_dict(gw_data.get("turn_lock")),
control=EmergencyStopConfig.from_dict(gw_data.get("control")),
)
# Parse agents section (pass through as dicts)
@@ -86,12 +86,7 @@ def _lookup(path: str, payload: Dict[str, Any]) -> Any:
return current
def render_template(
template: Optional[str],
payload: Dict[str, Any],
*,
fence_values: bool = False,
) -> str:
def render_template(template: Optional[str], payload: Dict[str, Any]) -> str:
"""Render ``template`` against ``payload`` with no external dependencies.
Supports ``{{ payload.x }}`` and ``{x}`` placeholders resolving dotted
@@ -101,12 +96,6 @@ def render_template(
Args:
template: The template string (or None).
payload: The decoded JSON request body.
fence_values: When True, each interpolated payload-derived value is
wrapped in an ``<external_request_payload>`` fence so the agent
treats it as untrusted data, not instructions. The operator's own
static template text stays outside the fence. Used for the agent
message (see :meth:`HookConfig.resolve_message`); left False for
session/idempotency keys where fencing would corrupt the value.
Returns:
The rendered string.
@@ -114,35 +103,13 @@ def render_template(
if not template:
return ""
wrap = None
notice = None
if fence_values:
from praisonaiagents.tools.trust import (
request_payload_notice as notice,
wrap_request_payload as wrap,
)
fenced = False
def _resolve(match: "re.Match[str]") -> str:
nonlocal fenced
# group(1) is the ``{{ ... }}`` body, group(2) the ``{ ... }`` body.
expr = match.group(1) if match.group(1) is not None else match.group(2)
value = _lookup(expr, payload)
if value is None:
return ""
if wrap is None:
return str(value)
fenced = True
return wrap(str(value))
return "" if value is None else str(value)
rendered = _PLACEHOLDER.sub(_resolve, template)
# Prepend the inline untrusted-data notice once (outside the fence) when a
# payload value was fenced, so the semantics travel with the message even if
# the agent runs with ``use_system_prompt=False``.
if fenced and notice is not None:
return f"{notice()}\n\n{rendered}"
return rendered
return _PLACEHOLDER.sub(_resolve, template)
def compute_idempotency_key(
@@ -408,22 +375,9 @@ class HookConfig:
self.idempotency_key, payload, path=self.path
)
def resolve_message(self, payload: Dict[str, Any], *, fence: bool = True) -> str:
"""Render the hook message for ``payload``.
Payload-derived interpolations are fenced as untrusted request data so
the agent never treats externally-POSTed content as instructions; the
operator's own template text stays outside the fence.
Args:
payload: The decoded request body.
fence: When True (the agent-turn default), interpolated payload
values are wrapped in an ``<external_request_payload>`` fence.
Pass ``fence=False`` for ``deliver_only`` hooks, where the
rendered message is delivered verbatim to a channel with no
agent consuming (and therefore stripping) the fence markup.
"""
return render_template(self.message, payload, fence_values=fence)
def resolve_message(self, payload: Dict[str, Any]) -> str:
"""Render the agent message for ``payload``."""
return render_template(self.message, payload)
def to_dict(self) -> Dict[str, Any]:
"""Convert to a dictionary (hides the auth/signing secrets)."""
@@ -126,27 +126,11 @@ class LoopWatchdog:
self._last_ack = 0.0
self._last_probe = 0.0
self._wedged = False
# Continuously-measured scheduling lag (ms) of the most recently
# acked probe. Read back by the health surface as a saturation signal
# (Issue #4265) — the same measurement the wedge check already relies
# on, exposed rather than only compared against the wedge threshold.
self._probe_sent_at = 0.0
self._last_lag_ms = 0.0
@property
def armed(self) -> bool:
return self._thread is not None and self._thread.is_alive()
@property
def last_lag_ms(self) -> float:
"""Most recent event-loop scheduling lag in milliseconds.
The delay between scheduling a probe and the loop running its ack.
``0.0`` until the first probe round-trips. Read by the gateway health
surface as a forward saturation signal (Issue #4265).
"""
return self._last_lag_ms
@property
def wedged(self) -> bool:
"""True once a wedge has been confirmed (mainly for ``dump_only``)."""
@@ -192,22 +176,7 @@ class LoopWatchdog:
def _ack(self) -> None:
"""Callback run *on the loop*; records that the loop is alive."""
now = time.monotonic()
self._last_ack = now
# Record the scheduling lag: how long the loop took to run this ack
# after we scheduled it. This is the same signal the wedge check uses,
# exposed as a gauge for the health surface (Issue #4265).
#
# ``_probe_sent_at`` is anchored to the *oldest still-unacked* probe,
# not the most recent one: when the loop stalls across several probe
# intervals the watchdog keeps scheduling fresh acks that all queue
# behind the wedge, and measuring from the newest probe would report a
# fraction of the true stall. Consuming the anchor here (setting it to
# 0.0) lets the next scheduled probe re-anchor from a caught-up loop.
sent = self._probe_sent_at
if sent:
self._last_lag_ms = max(0.0, (now - sent) * 1000.0)
self._probe_sent_at = 0.0
self._last_ack = time.monotonic()
def _schedule_probe(self) -> bool:
"""Schedule an ack onto the loop. Returns False if scheduling fails."""
@@ -215,16 +184,8 @@ class LoopWatchdog:
if loop is None:
return False
try:
# Only (re-)anchor the lag clock when the previous probe has been
# acked (``_ack`` zeroes ``_probe_sent_at``). If the loop is stalled
# and earlier probes are still queued, keep the anchor pinned to the
# oldest unacked probe so the reported lag reflects the full stall
# rather than the gap since the newest probe (Issue #4265).
now = time.monotonic()
if not self._probe_sent_at:
self._probe_sent_at = now
loop.call_soon_threadsafe(self._ack)
self._last_probe = now
self._last_probe = time.monotonic()
return True
except RuntimeError:
# Loop is closed / not running — treat as not-scheduled but do not
@@ -30,8 +30,6 @@ from typing import (
Iterator,
List,
Literal,
Mapping,
MutableMapping,
Optional,
Protocol,
Sequence,
@@ -200,10 +198,6 @@ class EventType(str, Enum):
TOOL_PROGRESS_STREAM = "tool_progress_stream"
STREAM_ERROR = "stream_error"
STREAM_END = "stream_end"
MODEL_FALLBACK_STREAM = "model_fallback_stream"
RETRY_STREAM = "retry_stream"
TODO_STREAM = "todo_stream"
TOOL_RESULT_STREAM = "tool_result_stream"
# System events
HEALTH = "health"
@@ -1099,10 +1093,6 @@ class GatewayProtocol(Protocol):
:func:`compute_config_revision`) of the config the gateway is
*actually running*, comparable against the on-disk revision to
detect drift.
- pressure: Optional :class:`HealthPressure` dict (admission /
inbound / outbound backlog + event-loop lag and a derived
``pressure`` classification), when the gateway runs back-pressure
machinery. See :class:`HealthPressure` and :func:`evaluate_pressure`.
"""
...
@@ -1198,165 +1188,6 @@ def compute_config_revision(config: Optional[Dict[str, Any]]) -> str:
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:12]
# ---------------------------------------------------------------------------
# Saturation / back-pressure observability (Issue #4265)
#
# The always-on gateway already *enforces* back-pressure — a concurrency
# ceiling with a bounded fair wait-queue (``AdmissionGate``), a durable
# outbound outbox with a dead-letter tier (``OutboundQueue``), and an
# event-loop wedge watchdog (``LoopWatchdog``) — but it exposes no saturation
# *telemetry*. The health surface reports liveness and config drift, yet never
# reports how close the gateway is to its limits, so the first signal an
# operator gets is dropped work (shed turns / stalled outbound) rather than a
# forward warning. This is the small, canonical contract — mirroring
# ``ReloadStatus`` exactly — that both the SDK's ``gateway.health()`` and the
# bot's ``/health`` populate so saturation is a first-class, observable signal.
# Core owns only the *shape* (a frozen dataclass) and a pure, deterministic
# classification helper; the wrapper reads back the facts the enforcement layer
# already computes and fills the block.
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class HealthPressure:
"""Observable saturation snapshot of the gateway's back-pressure machinery.
Populated by the running gateway and surfaced in
:meth:`GatewayProtocol.health` so an operator (or an autoscaler / a
``/ready``-driven load balancer) can ask "is the gateway backing up?"
*before* admission starts shedding turns — rather than inferring overload
post-mortem from shed-load log lines.
Attributes:
admission_max: Configured concurrency ceiling (``0`` when unbounded).
admission_in_flight: Turns currently running.
admission_queued: Turns currently waiting for a slot.
admission_shed_total: Cumulative turns shed since start.
inbox_pending: Aggregate pending inbound queue depth across sessions.
outbox_pending: Durable outbound backlog awaiting delivery.
outbox_dead_letter: Durable outbound entries in the dead-letter tier.
event_loop_lag_p99_ms: Recent event-loop scheduling lag (ms).
pressure: Single derived classification — ``"nominal"``,
``"elevated"`` or ``"saturated"`` (closed vocabulary) — so an
orchestrator can branch on one field.
"""
admission_max: int = 0
admission_in_flight: int = 0
admission_queued: int = 0
admission_shed_total: int = 0
inbox_pending: int = 0
outbox_pending: int = 0
outbox_dead_letter: int = 0
event_loop_lag_p99_ms: float = 0.0
pressure: Literal["nominal", "elevated", "saturated"] = "nominal"
def to_dict(self) -> Dict[str, Any]:
"""Convert to a JSON-serializable dict for the health surface."""
return {
"admission_max": self.admission_max,
"admission_in_flight": self.admission_in_flight,
"admission_queued": self.admission_queued,
"admission_shed_total": self.admission_shed_total,
"inbox_pending": self.inbox_pending,
"outbox_pending": self.outbox_pending,
"outbox_dead_letter": self.outbox_dead_letter,
"event_loop_lag_p99_ms": self.event_loop_lag_p99_ms,
"pressure": self.pressure,
}
def evaluate_pressure(
*,
admission: Optional[Dict[str, Any]] = None,
inbox_pending: int = 0,
outbox_pending: int = 0,
outbox_dead_letter: int = 0,
loop_lag_p99_ms: float = 0.0,
) -> HealthPressure:
"""Pure, deterministic classification of gateway load. No I/O.
Reads back facts the enforcement layer already computes
(``AdmissionGate.stats()``, the outbox ``status`` counts, the watchdog's
measured lag) and folds them into a single :class:`HealthPressure`. Lives
in core — like :func:`compute_config_revision` — so the SDK and the bot
classify identically without divergence.
Classification (highest wins):
* ``"saturated"`` — admission is full *and* work is queuing, the outbound
dead-letter tier is non-empty, or the event loop is lagging badly
(``>= 500 ms``): shed / stall is imminent or occurring.
* ``"elevated"`` — admission is highly utilised (``>= 80 %``), the inbound
or outbound backlog is building, or the loop is lagging (``>= 100 ms``).
* ``"nominal"`` — none of the above.
Args:
admission: An :meth:`AdmissionGate.stats` snapshot (or ``None``).
inbox_pending: Aggregate pending inbound queue depth.
outbox_pending: Durable outbound backlog.
outbox_dead_letter: Durable outbound dead-letter depth.
loop_lag_p99_ms: Recent event-loop scheduling lag in ms.
Returns:
A frozen :class:`HealthPressure` ready for the health surface.
"""
stats = admission or {}
def _int(value: Any) -> int:
try:
return int(value or 0)
except (TypeError, ValueError):
return 0
admission_max = _int(stats.get("max_concurrent_runs"))
in_flight = _int(stats.get("in_flight"))
queued = _int(stats.get("queued"))
shed_total = _int(stats.get("shed"))
inbox = max(0, _int(inbox_pending))
outbox = max(0, _int(outbox_pending))
dead_letter = max(0, _int(outbox_dead_letter))
try:
lag = float(loop_lag_p99_ms or 0.0)
except (TypeError, ValueError):
lag = 0.0
if lag < 0.0:
lag = 0.0
utilisation = (in_flight / admission_max) if admission_max > 0 else 0.0
saturated = (
(admission_max > 0 and in_flight >= admission_max and queued > 0)
or dead_letter > 0
or lag >= 500.0
)
elevated = (
utilisation >= 0.8
or queued > 0
or inbox > 0
or outbox > 0
or lag >= 100.0
)
if saturated:
pressure: Literal["nominal", "elevated", "saturated"] = "saturated"
elif elevated:
pressure = "elevated"
else:
pressure = "nominal"
return HealthPressure(
admission_max=admission_max,
admission_in_flight=in_flight,
admission_queued=queued,
admission_shed_total=shed_total,
inbox_pending=inbox,
outbox_pending=outbox,
outbox_dead_letter=dead_letter,
event_loop_lag_p99_ms=lag,
pressure=pressure,
)
# ---------------------------------------------------------------------------
# Push notification dataclasses
# ---------------------------------------------------------------------------
@@ -1734,90 +1565,6 @@ class OutboundDeliveryProtocol(Protocol):
...
@runtime_checkable
class IdempotencyStoreProtocol(Protocol):
"""Protocol for inbound webhook/trigger delivery deduplication.
The gateway's inbound HTTP hook surface must not start a second agent run
for an event a provider re-delivers (webhook providers routinely retry for
minutes to days). Deduplication is a three-step, restart-stable claim on the
deterministic idempotency key (``compute_idempotency_key``):
- ``reserve`` — atomically claim the key. Returns ``False`` when the key
was already recorded *or* is currently in flight, so both a redelivery
and a concurrent duplicate are rejected.
- ``record`` — commit the key after a successful run so future
redeliveries dedup.
- ``release`` — drop an in-flight reservation after a failed run so the
provider's retry can re-run.
This mirrors :class:`CallbackPayloadStoreProtocol`: the contract lives in
core so third-party gateways and a future ``redis`` backend interoperate;
core ships the bounded in-memory default (:class:`InMemoryIdempotencyStore`)
and the durable, restart-surviving SQLite backend lives in the
``praisonai-bot`` runtime (as the ingress journal and outbound queue do).
"""
def reserve(self, key: str) -> bool:
"""Atomically claim ``key``; ``False`` if seen-or-in-flight."""
...
def record(self, key: str) -> None:
"""Commit ``key`` as processed after a successful run."""
...
def release(self, key: str) -> None:
"""Drop an in-flight reservation so a failed delivery can be retried."""
...
class InMemoryIdempotencyStore:
"""Bounded, zero-dependency in-memory :class:`IdempotencyStoreProtocol`.
The default store for single-replica deployments — identical behaviour to
the gateway's original per-process ``OrderedDict`` + in-flight ``set``. It
is *not* durable: after a process restart the store is empty, so a durable
backend (SQLite/redis) must be injected for the restart/multi-replica case.
"""
def __init__(
self,
*,
max_entries: int = 10_000,
ttl_seconds: float = 86_400.0,
) -> None:
self._max_entries = max(1, int(max_entries))
self._ttl_seconds = float(ttl_seconds)
# key -> insertion time (seconds); insertion-ordered for FIFO eviction.
self._seen: "Dict[str, float]" = {}
self._inflight: Set[str] = set()
def _purge_expired(self, now: float) -> None:
if not self._seen:
return
expired = [k for k, ts in self._seen.items() if now - ts > self._ttl_seconds]
for k in expired:
self._seen.pop(k, None)
def reserve(self, key: str) -> bool:
now = time.time()
self._purge_expired(now)
if key in self._seen or key in self._inflight:
return False
self._inflight.add(key)
return True
def record(self, key: str) -> None:
self._inflight.discard(key)
self._seen[key] = time.time()
while len(self._seen) > self._max_entries:
oldest = next(iter(self._seen))
self._seen.pop(oldest, None)
def release(self, key: str) -> None:
self._inflight.discard(key)
# ---------------------------------------------------------------------------
# Per-route, trust-tiered toolset scoping (Issue #2298)
# ---------------------------------------------------------------------------
@@ -2778,59 +2525,6 @@ class ReactionResult:
return data
ThreadStatus = Literal["ok", "unsupported", "failed", "no_route"]
"""Closed set of outcomes for a :meth:`OutboundMessengerProtocol.create_thread`.
* ``ok`` — a new thread/topic was opened; ``thread_id`` carries its id.
* ``unsupported`` — the channel has no ``supports_threads`` capability.
* ``failed`` — the transport rejected the attempt (e.g. topics mode off, or
the bot lacks permission to manage threads).
* ``no_route`` — the target could not be resolved to a reachable channel.
"""
@dataclass
class ThreadResult:
"""Outcome of an agent/gateway-initiated thread creation (Issue #3987).
Every call resolves to exactly one :data:`ThreadStatus`, so the caller
always gets a typed answer rather than a hard error — in particular a
channel that cannot thread returns ``unsupported`` rather than raising, and
the caller falls back to the parent channel. This mirrors the
:class:`ReactionResult` contract.
Attributes:
status: The outcome (``ok`` / ``unsupported`` / ``failed`` /
``no_route``).
target: The resolved parent target the thread was opened under.
thread_id: The new thread/topic id, populated only when
``status == "ok"``. Callers compose ``"<target>:<thread_id>"`` to
route subsequent sends into it.
detail: Optional model-readable explanation.
"""
status: ThreadStatus
target: str = ""
thread_id: str = ""
detail: Optional[str] = None
@property
def ok(self) -> bool:
"""Whether a thread was opened (``status == "ok"``)."""
return self.status == "ok"
def as_dict(self) -> Dict[str, Any]:
"""Convert to a serializable dictionary for the tool return value."""
data: Dict[str, Any] = {"status": self.status}
if self.target:
data["target"] = self.target
if self.thread_id:
data["thread_id"] = self.thread_id
if self.detail:
data["detail"] = self.detail
return data
@runtime_checkable
class OutboundMessengerProtocol(Protocol):
"""Protocol for agent-facing proactive message delivery.
@@ -2903,37 +2597,6 @@ class OutboundMessengerProtocol(Protocol):
"""
...
async def create_thread(
self,
target: str,
name: str,
) -> "ThreadResult":
"""Open a new thread/topic under ``target`` (Issue #3987).
For multi-agent handoffs, subtasks, and parallel workflow branches, this
lets the gateway scope a subtask into its own platform-native thread
(Telegram forum topic, Discord thread, Slack thread anchor) instead of
flooding the shared channel inline. Gated on the channel's
``PlatformCapabilities.supports_threads`` — a channel that cannot thread
returns a typed ``unsupported`` outcome instead of raising, so the
caller falls back to the parent channel::
res = await messenger.create_thread("slack:C123", name="research")
target = f"slack:C123:{res.thread_id}" if res.ok else "slack:C123"
await messenger.send(target, subtask_output)
Args:
target: Symbolic target token ("origin", "<platform>", or
"<platform>:<chat_id>", or a friendly alias).
name: Human-friendly thread/topic title.
Returns:
A :class:`ThreadResult` carrying the new thread id on success, or a
typed ``unsupported`` / ``failed`` / ``no_route`` outcome. Never
raises.
"""
...
# ---------------------------------------------------------------------------
# Agent-callable cross-conversation request/reply (Issue #3689)
@@ -5175,26 +4838,6 @@ class GatewayTraceHook(Protocol):
session=sid,
):
reply = await agent.astart(text)
W3C trace-context propagation is layered on top of the same seam without
forcing OpenTelemetry into core. ``stage`` accepts an optional
``parent_carrier`` (a header mapping such as an inbound request's
``{"traceparent": ..., "tracestate": ...}``) so an exporter can *continue*
an upstream caller's trace instead of starting a detached one; and the two
companion methods let egress call sites propagate the active span context:
# ingress: continue the caller's trace when a traceparent arrives
with self._trace.stage("agent.run", parent_carrier=inbound.headers):
...
# egress: write the active traceparent onto an outbound request.
# Seed with provider headers first, then inject last so the active
# trace context always wins over any stale traceparent/tracestate.
headers = dict(provider_headers)
self._trace.inject_context(headers) # no-op unless an exporter is set
Both companion methods are no-ops in :class:`NullGatewayTraceHook`, so the
default path stays zero-cost and OpenTelemetry-free.
"""
def stage(
@@ -5202,7 +4845,6 @@ class GatewayTraceHook(Protocol):
name: str,
*,
correlation_id: "Optional[str]" = None,
parent_carrier: "Optional[Mapping[str, str]]" = None,
**attrs: Any,
) -> "AbstractContextManager[Any]":
"""Open a tracing scope for pipeline stage ``name``.
@@ -5212,11 +4854,6 @@ class GatewayTraceHook(Protocol):
span name.
correlation_id: The inbound turn's correlation id, attached as a
span attribute so spans and logs share a key.
parent_carrier: Optional inbound header mapping carrying W3C
trace-context (``traceparent``/``tracestate``). When present and
an exporter is attached, the stage is entered *under* the
extracted parent context so the span nests within the caller's
distributed trace instead of starting a new root.
**attrs: Extra span attributes (e.g. ``session``, ``model``,
``tool``, ``channel``).
@@ -5225,28 +4862,6 @@ class GatewayTraceHook(Protocol):
"""
...
def inject_context(self, carrier: "MutableMapping[str, str]") -> None:
"""Write the active span context into ``carrier`` as W3C headers.
Called at an egress boundary (LLM / MCP / HTTP-tool request) so the
downstream service's spans nest under the current agent turn. A no-op
when no exporter is attached, leaving ``carrier`` untouched.
"""
...
def extract_carrier(
self, carrier: "Mapping[str, str]"
) -> "Optional[Mapping[str, str]]":
"""Return a propagation carrier extracted from inbound ``carrier``.
Reads W3C trace-context (``traceparent``/``tracestate``) from an
inbound header mapping and returns a normalized carrier suitable to pass
as ``stage(..., parent_carrier=...)``, or ``None`` when no usable
context is present. A no-op returning ``None`` when no exporter is
attached.
"""
...
class NullGatewayTraceHook:
"""Zero-cost no-op :class:`GatewayTraceHook` used when tracing is disabled.
@@ -5268,22 +4883,11 @@ class NullGatewayTraceHook:
name: str,
*,
correlation_id: "Optional[str]" = None,
parent_carrier: "Optional[Mapping[str, str]]" = None,
**attrs: Any,
) -> "AbstractContextManager[Any]":
"""Return a no-op context manager, ignoring all arguments."""
return self._null_scope()
def inject_context(self, carrier: "MutableMapping[str, str]") -> None:
"""No-op: leave ``carrier`` untouched when tracing is disabled."""
return None
def extract_carrier(
self, carrier: "Mapping[str, str]"
) -> "Optional[Mapping[str, str]]":
"""No-op: no parent context to continue when tracing is disabled."""
return None
# Shared singleton: the default no-op hook is stateless, so one instance is
# reused everywhere a gateway needs a zero-cost default.
@@ -5913,174 +5517,6 @@ class _TurnLeaseHold:
self._token = None
# ---------------------------------------------------------------------------
# Per-session turn-execution isolation (Issue #4011)
#
# Today every session's agent turn runs as an ``asyncio`` task on the single
# gateway event loop, in one process: a turn that wedges the loop (a tight CPU
# loop, a blocking C-extension call, GIL starvation), runs away, or crashes
# the interpreter degrades or kills *every* session at once, and the only
# remedy the runtime has is a whole-process ``os._exit(75)`` restart that
# drops every other user's in-flight turn.
#
# This is the pure, dependency-free contract for a swappable turn *executor*,
# mirroring how every other gateway robustness knob (drain, admission,
# rate-limit, liveness, dead-letter, turn-lock) is a swappable protocol in
# core. The executor decides *where* a session's turn runs; the default
# :class:`InProcessTurnExecutor` runs it on the current loop exactly as today
# (zero change for existing users, no dependency introduced). Heavy executors
# that place a turn on an isolated worker (subprocess / container / remote),
# each with optional per-worker resource limits, are optional-dep runtime
# concerns that live in the wrapper/bot package; core keeps only the protocol,
# the placement/fencing invariants, and the safe in-process default so a bad
# turn can be torn down on its own worker without an ``os._exit`` of the whole
# gateway.
# ---------------------------------------------------------------------------
class WorkerWedgedError(Exception):
"""Raised when a session's turn worker is wedged / unresponsive.
Signals the gateway that the placement's worker can no longer make
progress (a wedged loop, an unresponsive subprocess/container, a lost
remote worker). The gateway responds by tearing down *only* that
placement (:meth:`TurnExecutorProtocol.teardown`) and re-placing the
session, rather than the process-wide ``os._exit`` that a single shared
loop forces today. Distinct from ordinary turn errors, which surface as
the turn's own exception and do not condemn the worker.
"""
@dataclass(frozen=True)
class TurnPlacement:
"""Which worker owns a session's turns, and at what epoch (Issue #4011).
A lifecycle-owned handle returned by :meth:`TurnExecutorProtocol.place`
and passed back to :meth:`~TurnExecutorProtocol.execute_turn` /
:meth:`~TurnExecutorProtocol.teardown`. The gateway revalidates the
placement (worker alive, matching ``epoch``) immediately before dispatch
and again after any awaited admission/approval work; a stale placement
(its worker was replaced, so ``epoch`` no longer matches) **fails closed**
and the session is re-placed rather than executed on a dead worker.
Attributes:
session_id: The resolved session whose turns this placement serves.
worker_id: Opaque id of the worker that owns the session's turns. For
the in-process default this is a constant (one loop, one worker);
for isolated executors it identifies the subprocess/container/
remote worker.
epoch: Monotonic generation bumped whenever the worker backing a
session is replaced (e.g. after a wedge teardown). A turn carrying
a stale epoch must not run — the fencing token that stops a
reclaimed worker from executing against a session it no longer owns.
"""
session_id: str
worker_id: str
epoch: int = 0
@runtime_checkable
class TurnExecutorProtocol(Protocol):
"""Contract for *where* a session's agent turn executes (Issue #4011).
The gateway calls :meth:`place` to obtain a :class:`TurnPlacement` for a
session, then :meth:`execute_turn` to run the turn on that placement, and
:meth:`teardown` to reclaim a placement's worker. The default
:class:`InProcessTurnExecutor` runs the turn on the current event loop,
reproducing today's behaviour exactly; an isolated executor (subprocess /
container / remote, in the wrapper) runs it on its own worker so a wedged,
runaway, or crashed turn is contained to its session.
Contract:
* :meth:`place` returns the placement that owns ``session_id``'s
turns. Implementations may cache a placement per session and bump
its ``epoch`` when the backing worker is replaced.
* :meth:`execute_turn` runs ``turn`` (an ``async`` no-arg callable the
gateway already built for this turn) on ``placement`` and returns
its result. The in-process default awaits ``turn`` directly on the
current loop. An isolated executor does **not** ship this live
callable (and its captured loop/agent state) across a process
boundary — the worker owns the session (via :meth:`place`) and
rebuilds/dispatches the turn from serialisable inputs on its own
side; ``turn`` then acts as the gateway-side await point for that
worker's result. ``cancel_token`` carries the per-turn interrupt so
the existing cancellation seam is preserved. ``limits`` optionally
bounds the worker's CPU/memory/wall time (honoured by isolated
executors; ignored in-process). It raises :class:`WorkerWedgedError`
if the placement's worker can no longer make progress.
* :meth:`teardown` reclaims ``placement``'s worker (kills the
subprocess/container/remote worker, or is a no-op in-process),
scoped to the owning session only.
A worker fault must fail *scoped*: tear down the offending placement and
re-place its session, never the whole gateway — mirroring the fail-safe,
blast-radius-contained defaults elsewhere.
"""
async def place(self, session_id: str) -> TurnPlacement:
"""Return the placement that owns ``session_id``'s turns."""
...
async def execute_turn(
self,
placement: TurnPlacement,
turn: "Callable[[], Awaitable[Any]]",
*,
cancel_token: Any = None,
limits: Any = None,
) -> Any:
"""Run ``turn`` on ``placement`` and return its result."""
...
async def teardown(self, placement: TurnPlacement, *, reason: str) -> None:
"""Reclaim ``placement``'s worker (scoped to its session)."""
...
class InProcessTurnExecutor:
"""Default turn executor — today's on-loop behaviour, no isolation.
Zero-cost, dependency-free implementation of
:class:`TurnExecutorProtocol` for single-process deployments. It runs each
turn directly on the current event loop exactly as the gateway does today,
so selecting it (or leaving the executor unset) is byte-for-byte backward
compatible and introduces no dependency. It provides **no** blast-radius
isolation: a turn that wedges the loop still affects the process — choosing
an isolated executor (subprocess / container / remote, in the wrapper) is
what contains a bad turn to its own worker.
All turns share one worker (the current process/loop), so every placement
carries the same constant ``worker_id`` at ``epoch`` 0, and ``teardown``
is inert — an in-process turn is cancelled through the existing
``cancel_token`` when the holding task exits, so there is no worker to
reclaim. The ``limits`` argument is accepted for protocol symmetry but is
inert here (no separate worker to bound).
"""
_WORKER_ID = "inprocess"
async def place(self, session_id: str) -> TurnPlacement:
return TurnPlacement(
session_id=session_id, worker_id=self._WORKER_ID, epoch=0
)
async def execute_turn(
self,
placement: TurnPlacement,
turn: "Callable[[], Awaitable[Any]]",
*,
cancel_token: Any = None,
limits: Any = None,
) -> Any:
# Run the gateway's pre-built turn coroutine on this loop, unchanged.
return await turn()
async def teardown(self, placement: TurnPlacement, *, reason: str) -> None:
# No isolated worker to reclaim; the shared loop keeps serving.
return None
# ---------------------------------------------------------------------------
# Declarative method -> required-scope registry (Issue #3206)
# ---------------------------------------------------------------------------
@@ -6292,195 +5728,3 @@ def _register_core_gateway_methods() -> None:
_register_core_gateway_methods()
# ---------------------------------------------------------------------------
# Global operator emergency-stop / pause brake (Issue #4220)
# ---------------------------------------------------------------------------
#
# A single, durable, fail-safe operator brake: "stop admitting NEW agent work
# everywhere, right now, but let in-flight runs finish — and resume later with
# no restart." Every new-work admission seam (WebSocket inbound, HTTP/MCP
# inbound, kanban dispatch cycle, scheduler due-loop) consults one shared
# contract instead of each lane deciding independently.
#
# Design mirrors the drain / idle / admission / liveness policy family already
# in this module: a pure ``Protocol`` in core plus a lightweight default and a
# durable fail-safe backend. When no brake is engaged, ``is_engaged()`` is
# ``False`` and behaviour is byte-for-byte today's — zero cost, no new
# dependency, fully backward-compatible.
@dataclass(frozen=True)
class EmergencyStopState:
"""Immutable snapshot of the operator brake, for status/audit surfaces.
``engaged`` is the only field the hot path needs; ``reason``/``actor``/
``at`` provide the optional audit trail surfaced in ``/health``/status.
"""
engaged: bool
reason: str = ""
actor: str = ""
at: float = 0.0
def to_dict(self) -> Dict[str, Any]:
return {
"engaged": self.engaged,
"reason": self.reason,
"actor": self.actor,
"at": self.at,
}
@runtime_checkable
class EmergencyStopProtocol(Protocol):
"""Pure contract for the global operator brake consulted at every seam.
Fail-safe rule: if the engaged state cannot be read with confidence (a
corrupt / unreadable durable sentinel), ``is_engaged()`` MUST return
``True`` — an ambiguous brake holds new work rather than letting it run
freely. ``engage``/``disengage`` are idempotent.
"""
def is_engaged(self) -> bool: ...
def engage(self, *, reason: str = "", actor: str = "") -> None: ...
def disengage(self) -> None: ...
def state(self) -> EmergencyStopState: ...
class NullEmergencyStop:
"""Default no-op brake: never engaged, no persistence.
Selected by ``backend: "off"`` (the default) so a gateway with no brake
configured behaves exactly as before — ``is_engaged()`` is always
``False`` and ``engage``/``disengage`` are inert.
"""
def is_engaged(self) -> bool:
return False
def engage(self, *, reason: str = "", actor: str = "") -> None:
return None
def disengage(self) -> None:
return None
def state(self) -> EmergencyStopState:
return EmergencyStopState(engaged=False)
class FileEmergencyStop:
"""Durable, fail-safe file-sentinel operator brake.
Engaging writes a small JSON sentinel at ``path``; disengaging removes it.
The engaged state therefore survives a crash/restart (durable) and is
shared by every lane that consults the same path.
Fail-safe: any error reading the sentinel — a partially written file,
corrupt JSON, a permission error — is treated as *engaged*. An ambiguous
brake holds new work; it never silently falls open to "run freely". The
common, unambiguous case (sentinel simply absent) reports not-engaged.
Pure-stdlib (``os``/``json``): no new dependency, safe to live in core.
"""
def __init__(self, path: str) -> None:
if not path:
raise ValueError("FileEmergencyStop requires a non-empty sentinel path")
import os
self._path = os.path.expanduser(str(path))
def is_engaged(self) -> bool:
import os
if not os.path.exists(self._path):
return False
# Present but unreadable/corrupt => fail-safe engaged.
try:
with open(self._path, "r", encoding="utf-8") as fh:
fh.read()
return True
except OSError:
return True
def engage(self, *, reason: str = "", actor: str = "") -> None:
import json
import os
directory = os.path.dirname(self._path)
if directory:
os.makedirs(directory, exist_ok=True)
payload = {
"engaged": True,
"reason": str(reason or ""),
"actor": str(actor or ""),
"at": time.time(),
}
# Atomic replace so a reader never observes a half-written sentinel
# (which would fail-safe engaged anyway, but this keeps it clean).
# ``mkstemp`` in the sentinel's own directory yields an unpredictable
# name owned by us, so a local attacker cannot pre-create a symlink to
# redirect the write. fsync + parent-dir fsync make an engaged brake
# durable across an abrupt crash.
import tempfile
fd, tmp = tempfile.mkstemp(
dir=directory or ".", prefix=".gateway.pause.", suffix=".tmp"
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
json.dump(payload, fh)
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp, self._path)
except BaseException:
try:
os.remove(tmp)
except OSError:
pass
raise
if directory:
try:
dir_fd = os.open(directory, os.O_RDONLY)
try:
os.fsync(dir_fd)
finally:
os.close(dir_fd)
except OSError:
pass
def disengage(self) -> None:
import os
try:
os.remove(self._path)
except FileNotFoundError:
return None
def state(self) -> EmergencyStopState:
import json
import os
if not os.path.exists(self._path):
return EmergencyStopState(engaged=False)
try:
with open(self._path, "r", encoding="utf-8") as fh:
data = json.load(fh)
if not isinstance(data, dict):
raise ValueError("sentinel is not a JSON object")
return EmergencyStopState(
engaged=True,
reason=str(data.get("reason", "")),
actor=str(data.get("actor", "")),
at=float(data.get("at", 0.0) or 0.0),
)
except (OSError, TypeError, ValueError):
# Fail-safe: unreadable/corrupt sentinel counts as engaged. A valid
# JSON object with a non-numeric ``at`` (e.g. a list) raises
# TypeError from ``float()`` and must also fail-safe engaged.
return EmergencyStopState(engaged=True, reason="unreadable-sentinel")
@@ -47,11 +47,7 @@ def _tail(text: str, limit: int = 4000) -> str:
return text[-limit:]
def _build_goal_judge_prompt(
state: "GoalState",
transcript_tail: str,
verification_block: Optional[str] = None,
) -> str:
def _build_goal_judge_prompt(state: "GoalState", transcript_tail: str) -> str:
"""Build a strict, criteria-anchored, JSON-only judge prompt."""
lines = [
"You are an independent completion judge for an autonomous agent.",
@@ -76,13 +72,6 @@ def _build_goal_judge_prompt(
lines.append(
"If ANY constraint is violated, you MUST answer 'continue'."
)
if verification_block:
lines += [
"",
"EXECUTABLE VERIFICATION RESULTS (authoritative — command exit "
"codes and file checks; prefer this over prose claims):",
verification_block,
]
lines += [
"",
"AGENT OUTPUT (most recent):",
@@ -176,7 +165,6 @@ def judge_goal(
transcript_tail: str,
*,
judge_model: Optional[str] = None,
verification_block: Optional[str] = None,
) -> Tuple[str, str]:
"""Judge whether ``state.goal`` is met given the latest agent output.
@@ -192,7 +180,7 @@ def judge_goal(
"""
from ..eval.judge import Judge
prompt = _build_goal_judge_prompt(state, transcript_tail, verification_block)
prompt = _build_goal_judge_prompt(state, transcript_tail)
try:
judge = Judge(model=judge_model or _default_judge_model(), temperature=0.0)
litellm = judge._get_litellm()
@@ -128,44 +128,11 @@ class GoalLoopMixin:
if state is None or state.status != "active":
return None
# Executable verification: run configured hooks and gate on the result.
# A failing BLOCKING hook short-circuits to "continue" WITHOUT spending
# a judge call. Passing/non-blocking results become authoritative
# evidence in the judge prompt.
verification_block: Optional[str] = None
if getattr(self, "_verification_hooks", None):
results = self._run_verification_hooks()
self._last_verification_results = results
self._record_verification_journal(results)
verification_block = self._verification_summary_tail(results)
blocking_failed = [
r for r in results
if r.get("blocking", True) and not r.get("success")
]
if blocking_failed:
state.turns_used += 1
state.last_verdict = "continue"
# Carry the failing hooks' captured stdout/stderr into the
# continuation prompt so the next turn has the diagnostics
# needed to repair the failure (not just a bare label).
failure_detail = self._verification_summary_tail(blocking_failed)
reason = "verification gate failed"
if failure_detail:
reason = f"{reason}:\n{failure_detail}"
state.last_reason = reason
if state.turns_used >= state.max_turns:
state.status = "paused"
self._persist_goal_state()
return "budget_paused", reason
self._persist_goal_state()
return "continue", reason
state.turns_used += 1
verdict, reason = judge_goal(
state,
_tail(response),
judge_model=getattr(self, "_goal_judge_model", None),
verification_block=verification_block,
)
state.last_verdict, state.last_reason = verdict, reason
@@ -7,12 +7,7 @@ including both function-based and LLM-based guardrails.
from .guardrail_result import GuardrailResult
from .llm_guardrail import LLMGuardrail
from .protocols import (
GuardrailProtocol,
StructuralGuardrailProtocol,
PolicyGuardrailProtocol,
is_guardrail_object,
)
from .protocols import GuardrailProtocol, StructuralGuardrailProtocol, PolicyGuardrailProtocol
from .chain import GuardrailChain
__all__ = [
@@ -21,6 +16,5 @@ __all__ = [
"GuardrailProtocol",
"StructuralGuardrailProtocol",
"PolicyGuardrailProtocol",
"GuardrailChain",
"is_guardrail_object",
"GuardrailChain"
]
@@ -175,25 +175,3 @@ class PolicyGuardrailProtocol(Protocol):
"""
...
# Method names that make an object a guardrail. An object exposing at least one
# of these is a protocol-conforming guardrail even though it is not ``callable``.
GUARDRAIL_PROTOCOL_METHODS = ("validate_input", "validate_output", "validate_tool_call")
def is_guardrail_object(obj: Any) -> bool:
"""Return True when ``obj`` is a protocol-conforming guardrail *instance*.
``isinstance(obj, GuardrailProtocol)`` is not usable here: a ``runtime_checkable``
Protocol only checks method presence and would also match unrelated objects
partially, while classes (not instances) must be rejected. This helper is the
single place that decides "is this thing a guardrail object?".
Plain validator callables are handled by their own passthrough branch and are
not required to match here (they will, however, if they also expose one of the
protocol methods - e.g. ``GuardrailChain``).
"""
if obj is None or isinstance(obj, type):
return False
return any(callable(getattr(obj, name, None)) for name in GUARDRAIL_PROTOCOL_METHODS)
@@ -104,9 +104,6 @@ __all__ = [
# Verification hooks (protocols)
"VerificationHook",
"VerificationResult",
"BaseVerificationHook",
"CommandVerificationHook",
"FileCheckHook",
# Simplified API (beginner-friendly)
"add_hook",
"remove_hook",
@@ -181,9 +178,6 @@ _LAZY_GROUPS = {
'verification': {
'VerificationHook': ('praisonaiagents.hooks.verification', 'VerificationHook'),
'VerificationResult': ('praisonaiagents.hooks.verification', 'VerificationResult'),
'BaseVerificationHook': ('praisonaiagents.hooks.verification', 'BaseVerificationHook'),
'CommandVerificationHook': ('praisonaiagents.hooks.verification', 'CommandVerificationHook'),
'FileCheckHook': ('praisonaiagents.hooks.verification', 'FileCheckHook'),
},
# Simplified API (beginner-friendly aliases)
'simplified_api': {
@@ -184,19 +184,7 @@ class HookRunner:
self._execute_single(hook, event, input_data)
for hook in hooks
]
results = await asyncio.gather(*tasks)
# Parallel hooks cannot mutate input_data (only sequential hooks apply
# modified_input). Warn if a parallel hook returns modifications that
# will be silently discarded, so hook authors mark it sequential=True.
for hook, result in zip(hooks, results):
if result.success and result.output and result.output.modified_input:
logger.warning(
"Parallel hook '%s' returned modified_input which is "
"discarded; register it with sequential=True to apply it.",
hook.name,
)
return results
return await asyncio.gather(*tasks)
async def _execute_sequential(
self,
@@ -114,26 +114,17 @@ class BaseVerificationHook:
name: str = "base"
timeout_seconds: float = 60.0
blocking: bool = True
def __init__(
self,
name: Optional[str] = None,
timeout: float = 60.0,
blocking: bool = True,
):
def __init__(self, name: Optional[str] = None, timeout: float = 60.0):
"""Initialize the hook.
Args:
name: Override the hook name
timeout: Timeout in seconds for execution
blocking: When True (default), a failure of this hook blocks the
autonomous loop from finalising with a success outcome.
"""
if name:
self.name = name
self.timeout_seconds = timeout
self.blocking = blocking
def run(self, context: Optional[Dict[str, Any]] = None) -> VerificationResult:
"""Run the verification hook.
@@ -186,25 +177,19 @@ class CommandVerificationHook(BaseVerificationHook):
def __init__(
self,
name: str,
command,
command: list,
cwd: Optional[str] = None,
timeout: float = 60.0,
blocking: bool = True,
):
"""Initialize command hook.
Args:
name: Hook name
command: Command to run as a list (e.g., ["pytest", "-v"]) or a
string (e.g., "python -m pytest -q"), which is shell-split.
command: Command to run as list (e.g., ["pytest", "-v"])
cwd: Working directory for command
timeout: Timeout in seconds
blocking: When True (default), a failure blocks completion.
"""
super().__init__(name=name, timeout=timeout, blocking=blocking)
if isinstance(command, str):
import shlex
command = shlex.split(command)
super().__init__(name=name, timeout=timeout)
self.command = command
self.cwd = cwd
@@ -250,143 +235,3 @@ class CommandVerificationHook(BaseVerificationHook):
output=str(e),
error=str(e),
)
def _get_dotted(data: Any, path: str) -> Any:
"""Access a dotted path (e.g. ``"a.b.c"``) in nested dicts/lists.
Returns ``None`` if any segment is missing. List indices are supported
via integer-looking segments.
"""
value = data
for part in path.split("."):
if isinstance(value, dict):
value = value.get(part)
elif isinstance(value, (list, tuple)):
try:
value = value[int(part)]
except (ValueError, IndexError):
return None
else:
return None
return value
class FileCheckHook(BaseVerificationHook):
"""Declarative file-assertion verification hook (no shell involved).
Checks portable filesystem facts:
- ``exists`` : the file exists (default True).
- ``non_empty`` : the file has non-zero size.
- ``contains`` : the file text contains a substring.
- ``json_field``/``equals`` : the JSON value at a dotted path equals a value.
Example:
hook = FileCheckHook(name="changelog", path="CHANGELOG.md", non_empty=True)
hook = FileCheckHook(name="cfg", path="out.json",
json_field="status.code", equals=0)
"""
def __init__(
self,
name: str,
path: str,
exists: bool = True,
non_empty: bool = False,
contains: Optional[str] = None,
json_field: Optional[str] = None,
equals: Any = None,
blocking: bool = True,
):
super().__init__(name=name, blocking=blocking)
self.path = path
self.exists = exists
self.non_empty = non_empty
self.contains = contains
self.json_field = json_field
self.equals = equals
def _execute(self, context: Optional[Dict[str, Any]] = None) -> VerificationResult:
import os
details: Dict[str, Any] = {"path": self.path}
file_exists = os.path.isfile(self.path)
details["exists"] = file_exists
if self.exists and not file_exists:
return VerificationResult(
success=False,
output=f"File not found: {self.path}",
error="not_found",
details=details,
)
if not self.exists:
success = not file_exists
return VerificationResult(
success=success,
output=(
f"File exists but was expected absent: {self.path}"
if not success else f"File absent as expected: {self.path}"
),
details=details,
)
if self.non_empty:
size = os.path.getsize(self.path)
details["size"] = size
if size == 0:
return VerificationResult(
success=False,
output=f"File is empty: {self.path}",
error="empty",
details=details,
)
if self.contains is not None or self.json_field is not None:
try:
with open(self.path, "r", encoding="utf-8") as fh:
text = fh.read()
except Exception as e:
return VerificationResult(
success=False, output=str(e), error=str(e), details=details
)
if self.contains is not None and self.contains not in text:
return VerificationResult(
success=False,
output=f"File {self.path} does not contain {self.contains!r}",
error="missing_substring",
details=details,
)
if self.json_field is not None:
import json as _json
try:
data = _json.loads(text)
except Exception as e:
return VerificationResult(
success=False,
output=f"File {self.path} is not valid JSON: {e}",
error="invalid_json",
details=details,
)
actual = _get_dotted(data, self.json_field)
details["json_field"] = self.json_field
details["actual"] = actual
if actual != self.equals:
return VerificationResult(
success=False,
output=(
f"{self.path}:{self.json_field} = {actual!r}, "
f"expected {self.equals!r}"
),
error="mismatch",
details=details,
)
return VerificationResult(
success=True,
output=f"File check passed: {self.path}",
details=details,
)
@@ -2,30 +2,6 @@ from typing import List, Union, Optional, Dict, Any
from functools import cached_property
import importlib
#: Names accepted for a chunking strategy that are not chunker ids themselves.
#: ``ChunkingStrategy.FIXED`` and ``.PARAGRAPH`` had no chunker at all, so they
#: silently did nothing; they now map onto the chunkers that implement them.
CHUNKER_ALIASES = {
"fixed": "token",
"paragraph": "recursive",
}
def normalize_chunker_type(name: str) -> str:
"""Map a user-facing chunking strategy name onto a chunker id."""
if not name:
return "recursive"
key = str(name).strip().lower()
key = CHUNKER_ALIASES.get(key, key)
if key not in Chunking.CHUNKER_PARAMS:
from ..config.parse_utils import make_preset_error
raise make_preset_error(
"chunking strategy", name,
sorted(set(Chunking.CHUNKER_PARAMS) | set(CHUNKER_ALIASES)),
)
return key
class Chunking:
"""A unified class for text chunking with various chunking strategies."""
@@ -2,7 +2,7 @@ import os
import logging
from praisonaiagents._logging import get_logger
from datetime import datetime
from .chunking import Chunking, normalize_chunker_type
from .chunking import Chunking
from functools import cached_property
@@ -162,10 +162,6 @@ class Knowledge:
# Merge graph_store config if provided (for graph memory support)
if "graph_store" in self._config:
base_config["graph_store"] = self._config["graph_store"]
# Merge chunker config if provided (consumed by self.chunker)
if "chunker" in self._config:
base_config["chunker"] = self._config["chunker"]
return base_config
def _prepare_mem0_config(self, config):
@@ -199,46 +195,28 @@ class Knowledge:
def memory(self):
"""Initialize knowledge adapter using protocol-driven approach."""
# Import registry functions
from .adapters import (
get_knowledge_adapter,
get_first_available_knowledge_adapter,
has_knowledge_adapter,
list_knowledge_adapters,
)
from .adapters import get_knowledge_adapter, get_first_available_knowledge_adapter
# Determine provider preference. Distinguish an explicit user choice from
# the implicit default so fallback diagnostics are only loud when a
# configured backend degrades (see issue #2972 / PR #2982 review).
# ``self.config`` is always merged with defaults, so use the raw
# user-supplied ``_config`` to detect whether the provider was chosen.
# Presence of the key (not truthiness) marks an explicit choice, so an
# explicitly empty/null provider is treated as a misconfiguration and
# surfaces the preset error instead of silently degrading to Mem0.
provider_explicit = "provider" in (
(self._config or {}).get("vector_store", {}) or {}
provider_explicit = bool(
(self._config or {}).get("vector_store", {}).get("provider")
)
provider = self.config.get("vector_store", {}).get("provider", "mem0")
self._log(f"Requested knowledge provider: {provider}")
# Legacy provider aliases. Anything else is looked up in the adapter
# registry, so adapters added with register_knowledge_adapter() are
# reachable; only genuinely unknown names fall back (and say so).
# Map legacy provider names to adapter names
provider_mapping = {
"chromadb": "chroma",
"rag": "chroma",
"chroma": "chroma",
"mongodb": "mongodb",
"mem0": "mem0",
"sqlite": "sqlite"
}
adapter_name = provider_mapping.get(provider, provider)
if not has_knowledge_adapter(adapter_name):
from ..config.parse_utils import make_preset_error
err = make_preset_error(
"knowledge vector_store provider", provider,
sorted(set(list_knowledge_adapters()) | set(provider_mapping)),
)
if provider_explicit:
raise err
self._log(f"{err} Falling back to 'mem0'.")
adapter_name = "mem0"
adapter_name = provider_mapping.get(provider, "mem0")
# Try to get preferred adapter, fallback to available ones
try:
@@ -309,13 +287,10 @@ class Knowledge:
@cached_property
def chunker(self):
# Read from config; the previous hardcoded recursive/512/50 made
# KnowledgeConfig.chunk_size / chunking_strategy / chunker inert.
chunker_cfg = self.config.get("chunker") or {}
return Chunking(
chunker_type=normalize_chunker_type(chunker_cfg.get("type", "recursive")),
chunk_size=chunker_cfg.get("chunk_size", 512),
chunk_overlap=chunker_cfg.get("chunk_overlap", 50),
chunker_type='recursive',
chunk_size=512,
chunk_overlap=50
)
def _log(self, message, level=2):
@@ -438,11 +413,7 @@ class Knowledge:
def history(self, memory_id):
"""Get the history of changes for a memory."""
if hasattr(self.memory, "history"):
return self.memory.history(memory_id)
raise NotImplementedError(
f"{type(self.memory).__name__} does not support history()"
)
return self.memory.history(memory_id)
def delete(self, memory_id):
"""Delete a memory."""
@@ -450,21 +421,11 @@ class Knowledge:
def delete_all(self, user_id=None, agent_id=None, run_id=None):
"""Delete all memories."""
if hasattr(self.memory, "delete_all"):
self.memory.delete_all(user_id=user_id, agent_id=agent_id, run_id=run_id)
else:
raise NotImplementedError(
f"{type(self.memory).__name__} does not support delete_all()"
)
self.memory.delete_all(user_id=user_id, agent_id=agent_id, run_id=run_id)
def reset(self):
"""Reset all memories."""
if hasattr(self.memory, "reset"):
self.memory.reset()
else:
logger.warning(
f"{type(self.memory).__name__} does not support reset(); no-op"
)
self.memory.reset()
def normalize_content(self, content):
"""Normalize content for consistent storage."""
@@ -107,22 +107,6 @@ def __getattr__(name):
from .rate_limiter import RateLimiter
_lazy_cache[name] = RateLimiter
return RateLimiter
elif name == "QuotaCoordinatorProtocol":
from .quota import QuotaCoordinatorProtocol
_lazy_cache[name] = QuotaCoordinatorProtocol
return QuotaCoordinatorProtocol
elif name == "QuotaCoordinatorConfig":
from .quota import QuotaCoordinatorConfig
_lazy_cache[name] = QuotaCoordinatorConfig
return QuotaCoordinatorConfig
elif name == "LocalQuotaCoordinator":
from .quota import LocalQuotaCoordinator
_lazy_cache[name] = LocalQuotaCoordinator
return LocalQuotaCoordinator
elif name == "build_quota_coordinator":
from .quota import build_quota_coordinator
_lazy_cache[name] = build_quota_coordinator
return build_quota_coordinator
elif name == "TokenUsage":
from .llm import TokenUsage
_lazy_cache[name] = TokenUsage
@@ -245,11 +229,6 @@ __all__ = [
"create_routing_agent",
"RateLimiter",
"TokenUsage",
# Provider quota coordination (cross-replica credential cooldowns)
"QuotaCoordinatorProtocol",
"QuotaCoordinatorConfig",
"LocalQuotaCoordinator",
"build_quota_coordinator",
# Protocols
"LLMProviderProtocol",
"ModelCapabilitiesProtocol",
@@ -16,7 +16,6 @@ import time
from dataclasses import dataclass, field
from enum import Enum
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
@@ -26,9 +25,6 @@ from typing import (
runtime_checkable,
)
if TYPE_CHECKING:
from .quota import QuotaCoordinatorProtocol
logger = get_logger(__name__)
class ProviderStatus(str, Enum):
@@ -116,29 +112,6 @@ class AuthProfile:
metadata=data.get("metadata", {}),
)
@property
def credential_id(self) -> str:
"""Stable, non-secret identity for cross-replica quota coordination.
The coordinator keys benches by the *credential*, not the profile
label. ``name`` is a local display label: two replicas may name the
same key differently (breaking cooldown propagation) or reuse a name
like ``"default"`` for different keys (benching an unrelated key).
Derive a deterministic id from the fields that identify the credential
(provider, api_key, base_url) using a short salted hash so the real key
is never exposed. Falls back to ``name`` when no api_key is present.
"""
if not self.api_key:
return self.name
import hashlib
digest = hashlib.sha256(
f"{self.provider}\x00{self.base_url or ''}\x00{self.api_key}".encode(
"utf-8", "ignore"
)
).hexdigest()[:16]
return f"{self.provider}:{digest}"
@property
def is_available(self) -> bool:
"""Check if this profile is currently available."""
@@ -234,27 +207,13 @@ class FailoverManager:
profile = manager.get_next_profile()
"""
def __init__(
self,
config: Optional[FailoverConfig] = None,
coordinator: Optional["QuotaCoordinatorProtocol"] = None,
):
def __init__(self, config: Optional[FailoverConfig] = None):
"""Initialize the failover manager.
Args:
config: Failover configuration
coordinator: Optional shared quota coordinator. When supplied
(e.g. a Redis-backed one wired by the gateway), credential
benches/cooldowns are coordinated fleet-wide so a key benched
by any replica is skipped by all replicas. Defaults to an
in-memory local coordinator, keeping single-replica behaviour
exactly as before with no new dependency and no hot-path cost.
"""
self.config = config or FailoverConfig()
if coordinator is None:
from .quota import LocalQuotaCoordinator
coordinator = LocalQuotaCoordinator()
self._coordinator: "QuotaCoordinatorProtocol" = coordinator
self._profiles: List[AuthProfile] = []
self._current_index: int = 0
self._on_failover_callbacks: List[Callable[[AuthProfile, AuthProfile], None]] = []
@@ -315,30 +274,6 @@ class FailoverManager:
with self._lock:
return list(self._profiles)
def _sync_bench_from_coordinator(
self, profile: AuthProfile, now: Optional[float] = None
) -> None:
"""Apply a fleet-wide bench from the coordinator onto a local profile.
If another replica benched this credential, reflect its cooldown here
so this replica skips it too. Fail-open: never raise into failover.
Callers already hold ``self._lock``.
"""
try:
until = self._coordinator.benched_until(profile.credential_id, now=now)
except Exception as e: # pragma: no cover - defensive
logger.warning(
f"Quota coordinator lookup failed for '{profile.name}': {e}; "
f"using local cooldown state"
)
return
if until is not None and (
profile.cooldown_until is None or until > profile.cooldown_until
):
if profile.status == ProviderStatus.AVAILABLE:
profile.status = ProviderStatus.RATE_LIMITED
profile.cooldown_until = until
def get_next_profile(self) -> Optional[AuthProfile]:
"""Get the next available profile.
@@ -352,10 +287,9 @@ class FailoverManager:
if not self._profiles:
return None
# First, sync fleet-wide benches, then check if any cooldowns have expired.
# First, check if any cooldowns have expired
current_time = time.time()
for profile in self._profiles:
self._sync_bench_from_coordinator(profile, now=current_time)
if profile.cooldown_until and current_time >= profile.cooldown_until:
profile.reset()
@@ -394,29 +328,12 @@ class FailoverManager:
f"Profile '{profile.name}' rate limited, "
f"cooldown for {self.config.cooldown_on_rate_limit}s"
)
reason = "rate_limit"
else:
profile.mark_error(error, self.config.cooldown_on_error)
logger.warning(
f"Profile '{profile.name}' error: {error}, "
f"cooldown for {self.config.cooldown_on_error}s"
)
reason = "error"
# Share the bench fleet-wide so other replicas skip this key too.
# Fail-open: a coordinator hiccup must never wedge failover.
if profile.cooldown_until is not None:
try:
self._coordinator.bench(
profile.credential_id,
until=profile.cooldown_until,
reason=reason,
)
except Exception as e: # pragma: no cover - defensive
logger.warning(
f"Quota coordinator bench failed for '{profile.name}': {e}; "
f"falling back to local cooldown only"
)
# Resolve the target profile and snapshot callbacks while holding
# the lock, but invoke them AFTER releasing it. User callbacks may
@@ -444,30 +361,8 @@ class FailoverManager:
"""
with self._lock:
if profile.status != ProviderStatus.AVAILABLE:
# Snapshot the cooldown this success is recovering from BEFORE
# resetting, so we can avoid clobbering a newer fleet-wide bench.
recovered_from = profile.cooldown_until
profile.reset()
logger.info(f"Profile '{profile.name}' recovered")
cred_id = profile.credential_id
try:
# A concurrent/other-replica failure may have benched this
# credential with a LATER expiry than the cooldown this
# (possibly stale, already in-flight) success is recovering
# from. Clearing unconditionally would resume a credential
# that is still rate-limited. Only clear when the shared
# bench is not newer than what we recovered from.
shared_until = self._coordinator.benched_until(cred_id)
if (
shared_until is None
or recovered_from is None
or shared_until <= recovered_from
):
self._coordinator.clear(cred_id)
except Exception as e: # pragma: no cover - defensive
logger.warning(
f"Quota coordinator clear failed for '{profile.name}': {e}"
)
def on_failover(
self,
+83 -337
View File
@@ -28,14 +28,6 @@ from ..tools.call_executor import ToolCall, create_tool_call_executor
from ..tools.schema import build_tool_definition
# Sentinel returned as the "arguments" value when a tool call's argument string
# cannot be parsed (e.g. truncated by max_tokens or a dropped connection).
# It is distinct from {} ("no arguments"): {} would silently execute the tool
# with the WRONG arguments and report success. Callers must detect this sentinel
# and surface a tool-error so the model can re-emit the call instead.
_TOOL_ARGUMENTS_PARSE_FAILED = object()
def _durable_iteration_kwargs(execute_tool_fn: Callable, index: int) -> Dict[str, int]:
if getattr(execute_tool_fn, "_accepts_durable_iteration", False):
return {"_durable_iteration_index": index}
@@ -542,18 +534,7 @@ Respond with ONLY a valid JSON tool call in this format:
if self._failover_manager:
self._current_profile = self._failover_manager.get_next_profile()
if self._current_profile:
# Apply the initial profile's values to the instance attributes
# (backward compatible with the documented llm.api_key/model
# surface). Later failover rotations are per-call only and do
# not mutate these attributes (issue #3613 gap 2).
profile = self._current_profile
if profile.api_key:
self.api_key = profile.api_key
if profile.base_url:
self.base_url = profile.base_url
if profile.model and profile.model != self.model:
logging.info(f"Failover: using profile model {profile.model}")
self.model = profile.model
self._switch_to_profile(self._current_profile)
# Cache for formatted tools and messages
self._formatted_tools_cache = {}
@@ -993,58 +974,18 @@ Respond with ONLY a valid JSON tool call in this format:
def _switch_to_profile(self, profile: "AuthProfile") -> None:
"""Switch to a new auth profile for failover.
The profile's credentials are threaded through the per-call kwargs of
the retry loop instead of being written onto the shared instance. The
old behaviour mutated ``self.api_key``/``self.base_url``/``self.model``
on a shared ``LLM`` (issue #3613 gap 2), so concurrent requests on one
instance could silently cross-talk credentials and model after a
failover rotation. Keeping the instance attributes on the initial
profile preserves single-profile behaviour and the documented
``llm.api_key``/``llm.model`` surface.
Args:
profile: AuthProfile to switch to
"""
# NOTE: profile values are applied to the per-call kwargs by
# _handle_retry_exception when it rotates. This method is kept as a
# no-op placeholder so any out-of-tree callers that invoke it directly
# keep working; it intentionally no longer mutates shared state.
if profile is None:
return
def _response_model_for_tracking(self, response: Any) -> str:
"""Model to attribute token usage to for a completed call.
Prefers the model carried on the response itself, which reflects the
provider/profile that actually served the request. This is race-free
under concurrency (unlike reading the shared ``self._current_profile``)
and stays correct after a failover rotation. Falls back to
``self.model`` when the response does not expose a model.
"""
model = None
if isinstance(response, dict):
model = response.get("model")
else:
model = getattr(response, "model", None)
return model or self.model
def _apply_profile_to_kwargs(self, profile: "AuthProfile", kwargs: dict) -> dict:
"""Return a new kwargs dict with profile overrides applied.
Never mutates ``self``: the active profile is a per-call concern, so
its credentials travel in the request kwargs only. Callers that pass
an explicit per-call override (e.g. ``api_key=``) keep it; profile
values fill in the gaps.
"""
kwargs = dict(kwargs)
if profile.api_key:
kwargs["api_key"] = profile.api_key
self.api_key = profile.api_key
if profile.base_url:
kwargs["base_url"] = profile.base_url
if profile.model:
kwargs["model"] = profile.model
return kwargs
self.base_url = profile.base_url
if profile.model and profile.model != self.model:
# Only log if model actually changes
logging.info(f"Failover: switching from {self.model} to {profile.model}")
self.model = profile.model
def _resolve_subscription_creds(self):
"""Lazy resolve + cache subscription credentials, checking expiration."""
@@ -1114,7 +1055,7 @@ Respond with ONLY a valid JSON tool call in this format:
)
return None
def _handle_retry_exception(self, e, attempt, kwargs, refreshed_creds=None, active_profile=None):
def _handle_retry_exception(self, e, attempt, kwargs, refreshed_creds=None):
"""Decide retry vs raise from a failover decision. No sleep/await.
Shared decision logic used by both the sync (`_call_with_retry`) and
@@ -1128,34 +1069,21 @@ Respond with ONLY a valid JSON tool call in this format:
blocks the event loop. The already-refreshed credentials (if any) are
passed in via ``refreshed_creds``.
The active failover profile is a *per-call* value threaded in via
``active_profile`` and returned back in the result tuple. It is never
read from or written to ``self._current_profile`` here, so concurrent
requests sharing one ``LLM`` instance cannot corrupt each other's
failover bookkeeping or cost attribution (issue #3613 gap 2).
Args:
e: The exception raised by the wrapped call.
attempt: Zero-based attempt index for the current iteration.
kwargs: Keyword arguments for the wrapped call (may be updated).
refreshed_creds: Credentials already refreshed by the caller (off
the event loop for async), or ``None`` if no refresh was done.
active_profile: The profile this call is currently using, or
``None``. Defaults to the initial profile for backward compat.
Returns:
Tuple of (should_raise, category, retry_delay, error_str, kwargs,
active_profile):
Tuple of (should_raise, category, retry_delay, error_str, kwargs):
should_raise: Whether the caller should re-raise immediately.
category: The error category/reason from the decision.
retry_delay: Seconds to wait before the next attempt.
error_str: String form of the exception.
kwargs: The (possibly updated) keyword arguments.
active_profile: The (possibly rotated) profile for the next
attempt.
"""
if active_profile is None:
active_profile = self._current_profile
# A turn is "side-effecting" when it exposes tools the model may call:
# a post-dispatch failure on such a turn could have already run a
# side-effecting tool, so it must not be silently replayed. A turn with
@@ -1207,48 +1135,55 @@ Respond with ONLY a valid JSON tool call in this format:
# immediately with fresh credentials and skip profile rotation so we
# neither mark the current profile failed nor overwrite the new creds.
if refreshed_auth:
return False, category, retry_delay, error_str, kwargs, active_profile
return False, category, retry_delay, error_str, kwargs
# Handle different failover decision actions. Rotation is a per-call
# concern: the active profile is a local threaded in/out of this method,
# never self._current_profile, so concurrent calls on one shared LLM
# cannot mark each other's profiles failed/healthy (issue #3613 gap 2).
# Handle different failover decision actions
if decision.action == "rotate_profile" and self._failover_manager:
if active_profile:
if self._current_profile:
is_rate_limit = (category == "rate_limit")
self._failover_manager.mark_failure(
active_profile, error_str, is_rate_limit=is_rate_limit
self._current_profile, error_str, is_rate_limit=is_rate_limit
)
next_profile = self._failover_manager.get_next_profile()
if next_profile and next_profile != active_profile:
active_profile = next_profile
# Thread the active profile through the per-call kwargs so the
# shared instance state is never mutated (issue #3613 gap 2).
kwargs = self._apply_profile_to_kwargs(next_profile, kwargs)
if next_profile and next_profile != self._current_profile:
self._switch_to_profile(next_profile)
self._current_profile = next_profile
# Update the kwargs with new profile values for the next retry
if "api_key" in kwargs:
kwargs["api_key"] = self.api_key
if "base_url" in kwargs:
kwargs["base_url"] = self.base_url
if "model" in kwargs:
kwargs["model"] = self.model
logging.info(f"Failover: switched to profile '{next_profile.name}'")
else:
# No alternate profile available - retrying would just hit the
# same failing profile, so surface the error instead.
can_retry = False
# Legacy failover for compatibility (when decision is retry but failover is configured)
elif self._failover_manager and active_profile and decision.action == "retry":
elif self._failover_manager and self._current_profile and decision.action == "retry":
is_rate_limit = (category == "rate_limit")
self._failover_manager.mark_failure(
active_profile, error_str, is_rate_limit=is_rate_limit
self._current_profile, error_str, is_rate_limit=is_rate_limit
)
next_profile = self._failover_manager.get_next_profile()
if next_profile and next_profile != active_profile:
active_profile = next_profile
# Thread the active profile through the per-call kwargs so the
# shared instance state is never mutated (issue #3613 gap 2).
kwargs = self._apply_profile_to_kwargs(next_profile, kwargs)
if next_profile and next_profile != self._current_profile:
self._switch_to_profile(next_profile)
self._current_profile = next_profile
# Update the kwargs with new profile values for the next retry
if "api_key" in kwargs:
kwargs["api_key"] = self.api_key
if "base_url" in kwargs:
kwargs["base_url"] = self.base_url
if "model" in kwargs:
kwargs["model"] = self.model
# Enable retry for profile switch even if originally non-retryable
can_retry = True
retry_delay = 0.0
logging.info(f"Failover: switched to profile '{next_profile.name}'")
should_raise = decision.action == "surface_error" or not can_retry
return should_raise, category, retry_delay, error_str, kwargs, active_profile
return should_raise, category, retry_delay, error_str, kwargs
def _call_with_retry(self, func, *args, **kwargs):
"""Call a function with automatic retry on rate limit errors and failover support.
@@ -1265,10 +1200,6 @@ Respond with ONLY a valid JSON tool call in this format:
The original exception if max retries exceeded
"""
last_error = None
# Track the failover profile per call (never self._current_profile) so
# concurrent calls on one shared LLM cannot corrupt each other's
# failover bookkeeping (issue #3613 gap 2).
active_profile = self._current_profile
for attempt in range(self._max_retries + 1):
try:
@@ -1279,8 +1210,8 @@ Respond with ONLY a valid JSON tool call in this format:
result = func(*args, **kwargs)
# Mark success if failover is configured
if self._failover_manager and active_profile:
self._failover_manager.mark_success(active_profile)
if self._failover_manager and self._current_profile:
self._failover_manager.mark_success(self._current_profile)
# Reset idle timeout circuit breaker on success
self._idle_timeout_breaker.reset()
@@ -1295,12 +1226,8 @@ Respond with ONLY a valid JSON tool call in this format:
if self._should_attempt_auth_refresh(e, attempt):
refreshed_creds = self._try_refresh_subscription_creds()
should_raise, category, retry_delay, error_str, kwargs, active_profile = (
self._handle_retry_exception(
e, attempt, kwargs,
refreshed_creds=refreshed_creds,
active_profile=active_profile,
)
should_raise, category, retry_delay, error_str, kwargs = (
self._handle_retry_exception(e, attempt, kwargs, refreshed_creds=refreshed_creds)
)
if should_raise:
@@ -1349,10 +1276,6 @@ Respond with ONLY a valid JSON tool call in this format:
The original exception if max retries exceeded
"""
last_error = None
# Track the failover profile per call (never self._current_profile) so
# concurrent coroutines on one shared LLM cannot corrupt each other's
# failover bookkeeping (issue #3613 gap 2).
active_profile = self._current_profile
for attempt in range(self._max_retries + 1):
try:
@@ -1363,8 +1286,8 @@ Respond with ONLY a valid JSON tool call in this format:
result = await func(*args, **kwargs)
# Mark success if failover is configured
if self._failover_manager and active_profile:
self._failover_manager.mark_success(active_profile)
if self._failover_manager and self._current_profile:
self._failover_manager.mark_success(self._current_profile)
# Reset idle timeout circuit breaker on success
self._idle_timeout_breaker.reset()
@@ -1382,12 +1305,8 @@ Respond with ONLY a valid JSON tool call in this format:
self._try_refresh_subscription_creds
)
should_raise, category, retry_delay, error_str, kwargs, active_profile = (
self._handle_retry_exception(
e, attempt, kwargs,
refreshed_creds=refreshed_creds,
active_profile=active_profile,
)
should_raise, category, retry_delay, error_str, kwargs = (
self._handle_retry_exception(e, attempt, kwargs, refreshed_creds=refreshed_creds)
)
if should_raise:
@@ -1437,10 +1356,7 @@ Respond with ONLY a valid JSON tool call in this format:
The completion response from litellm
"""
import litellm
response = self._call_with_retry(litellm.completion, **completion_params)
if not completion_params.get("stream"):
self._track_token_usage(response, self._response_model_for_tracking(response))
return response
return self._call_with_retry(litellm.completion, **completion_params)
async def _acompletion_with_retry(self, **completion_params):
"""Execute litellm.acompletion with automatic retry on rate limit errors.
@@ -1455,13 +1371,7 @@ Respond with ONLY a valid JSON tool call in this format:
The completion response from litellm
"""
import litellm
response = await self._call_with_retry_async(
litellm.acompletion,
**completion_params,
)
if not completion_params.get("stream"):
self._track_token_usage(response, self._response_model_for_tracking(response))
return response
return await self._call_with_retry_async(litellm.acompletion, **completion_params)
def _supports_web_search(self) -> bool:
"""
@@ -1847,15 +1757,8 @@ Respond with ONLY a valid JSON tool call in this format:
except (KeyError, json.JSONDecodeError, TypeError) as e:
logging.error(f"Error parsing tool call arguments: {e}")
function_name = (
tool_call.get("function", {}).get("name")
if isinstance(tool_call.get("function"), dict)
else None
) or tool_call.get("name", "unknown_function")
# A parse failure is NOT "no arguments". Returning {} here would run
# the tool with the wrong arguments and report success. Signal the
# failure so callers surface a tool-error instead of dispatching.
arguments = _TOOL_ARGUMENTS_PARSE_FAILED
function_name = tool_call.get("name", "unknown_function")
arguments = {}
tool_call_id = tool_call.get("id", f"tool_{id(tool_call)}")
return function_name, arguments, tool_call_id
@@ -2520,7 +2423,7 @@ Respond with ONLY a valid JSON tool call in this format:
prompt: Union[str, List[Dict]],
system_prompt: Optional[str] = None,
chat_history: Optional[List[Dict]] = None,
temperature: Optional[float] = None,
temperature: float = 1.0,
tools: Optional[List[Any]] = None,
output_json: Optional[BaseModel] = None,
output_pydantic: Optional[BaseModel] = None,
@@ -2743,11 +2646,7 @@ Respond with ONLY a valid JSON tool call in this format:
resp = self._call_responses_api(**responses_params)
response_text, tool_calls, _reasoning = self._extract_from_responses_output(resp)
# Usage accounting is independent from metrics display.
# Quiet/default agents must still update the public
# token collector after a paid model call.
self._track_token_usage(resp, self.model)
# Track token usage
if self.metrics and hasattr(resp, 'usage') and resp.usage:
usage = resp.usage
tokens_in = getattr(usage, 'input_tokens', 0) or 0
@@ -2837,17 +2736,10 @@ Respond with ONLY a valid JSON tool call in this format:
# Execute tool calls using ToolCallExecutor (Gap 2: parallel or sequential)
is_ollama = self._is_ollama_provider()
tool_calls_batch = []
parse_error_messages = []
# Prepare batch of ToolCall objects
for tool_call in tool_calls:
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call, is_ollama=is_ollama)
if self._tool_arguments_parse_failed(arguments):
# Do NOT dispatch with {}: surface a retryable error.
parse_error_messages.append(
self._tool_parse_error_message(function_name, tool_call_id)
)
continue
tool_calls_batch.append(ToolCall(
function_name=function_name,
arguments=arguments,
@@ -2921,11 +2813,6 @@ Respond with ONLY a valid JSON tool call in this format:
"content": content,
})
# Report any unparseable tool-call arguments so the
# model can re-emit them (never dispatched with {}).
for _err_msg in parse_error_messages:
messages.append(_err_msg)
# Safety: break after max_iter iterations
if iteration_count + 1 >= max_iterations:
self._last_stop_reason = "max_steps"
@@ -2982,6 +2869,10 @@ Respond with ONLY a valid JSON tool call in this format:
is_reasoning=False
))
# Track token usage
if self.metrics:
self._track_token_usage(final_response, self.model)
# Trigger llm_end callback with metrics for debug output
llm_latency_ms = (time.time() - current_time) * 1000
@@ -3195,6 +3086,9 @@ Respond with ONLY a valid JSON tool call in this format:
response_content = final_response["choices"][0]["message"].get("content")
response_text = response_content if response_content is not None else ""
# Track token usage
if self.metrics:
self._track_token_usage(final_response, self.model)
# Execute callbacks and display based on verbose setting
if verbose and not interaction_displayed:
@@ -3384,6 +3278,9 @@ Respond with ONLY a valid JSON tool call in this format:
response_content = final_response["choices"][0]["message"].get("content")
response_text = response_content if response_content is not None else ""
# Track token usage
if self.metrics:
self._track_token_usage(final_response, self.model)
# Execute callbacks and display based on verbose setting
if verbose and not interaction_displayed:
@@ -3629,11 +3526,6 @@ Respond with ONLY a valid JSON tool call in this format:
is_ollama = self._is_ollama_provider()
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call, is_ollama)
if self._tool_arguments_parse_failed(arguments):
# Do NOT dispatch with {}: surface a retryable error.
messages.append(self._tool_parse_error_message(function_name, tool_call_id))
continue
# Validate and filter arguments for Ollama provider
if is_ollama and tools:
# First check if any argument references a previous tool result
@@ -4193,7 +4085,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
prompt: Union[str, List[Dict]],
system_prompt: Optional[str] = None,
chat_history: Optional[List[Dict]] = None,
temperature: Optional[float] = None,
temperature: float = 1.0,
tools: Optional[List[Any]] = None,
output_json: Optional[BaseModel] = None,
output_pydantic: Optional[BaseModel] = None,
@@ -4378,17 +4270,10 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Execute tool calls using ToolCallExecutor (Gap 2: parallel or sequential)
is_ollama = self._is_ollama_provider()
tool_calls_batch = []
parse_error_messages = []
# Prepare batch of ToolCall objects
for tool_call in tool_calls:
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call, is_ollama)
if self._tool_arguments_parse_failed(arguments):
# Do NOT dispatch with {}: surface a retryable error.
parse_error_messages.append(
self._tool_parse_error_message(function_name, tool_call_id)
)
continue
tool_calls_batch.append(ToolCall(
function_name=function_name,
arguments=arguments,
@@ -4428,11 +4313,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
tool_result.is_ollama
)
messages.append(tool_message)
# Report any unparseable tool-call arguments so the
# model can re-emit them (never dispatched with {}).
for _err_msg in parse_error_messages:
messages.append(_err_msg)
# Continue conversation after tool execution - get follow-up response
try:
@@ -4540,7 +4420,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
prompt: Union[str, List[Dict]],
system_prompt: Optional[str] = None,
chat_history: Optional[List[Dict]] = None,
temperature: Optional[float] = None,
temperature: float = 1.0,
tools: Optional[List[Any]] = None,
output_json: Optional[BaseModel] = None,
output_pydantic: Optional[BaseModel] = None,
@@ -4770,10 +4650,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
for tool_call in tool_calls:
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call)
if self._tool_arguments_parse_failed(arguments):
# Do NOT dispatch with {}: surface a retryable error.
messages.append(self._tool_parse_error_message(function_name, tool_call_id))
continue
logging.debug(f"[RESPONSES_API_ASYNC] Executing tool {function_name}")
tool_result = await _dispatch_async_tool(
execute_tool_fn,
@@ -5023,11 +4899,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
is_ollama = self._is_ollama_provider()
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call, is_ollama)
if self._tool_arguments_parse_failed(arguments):
# Do NOT dispatch with {}: surface a retryable error.
messages.append(self._tool_parse_error_message(function_name, tool_call_id))
continue
# Validate and filter arguments for Ollama provider
if is_ollama and tools:
arguments = self._validate_and_filter_ollama_arguments(function_name, arguments, tools)
@@ -5543,66 +5414,27 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
litellm.callbacks.append(event)
self._registered_callbacks = list(events)
def _track_token_usage(self, response: Any, model: str) -> Optional[TokenMetrics]:
def _track_token_usage(self, response: Dict[str, Any], model: str) -> Optional[TokenMetrics]:
"""Extract and track token usage from LLM response."""
if not TokenMetrics or not get_token_collector:
return None
# Note: metrics check moved to call sites for performance
# This method should only be called when self.metrics=True
try:
if isinstance(response, dict):
usage = response.get("usage", {})
else:
usage = getattr(response, "usage", None)
usage = response.get("usage", {})
if not usage:
return None
def _usage_value(*names: str) -> int:
for name in names:
if isinstance(usage, dict):
value = usage.get(name)
else:
value = getattr(usage, name, None)
if value is not None:
return int(value or 0)
return 0
def _detail_value(detail_names: tuple[str, ...], name: str) -> int:
for detail_name in detail_names:
details = (
usage.get(detail_name)
if isinstance(usage, dict)
else getattr(usage, detail_name, None)
)
if details:
value = (
details.get(name)
if isinstance(details, dict)
else getattr(details, name, None)
)
if value is not None:
return int(value or 0)
return 0
# Extract token counts
metrics = TokenMetrics(
input_tokens=_usage_value("prompt_tokens", "input_tokens"),
output_tokens=_usage_value("completion_tokens", "output_tokens"),
cached_tokens=_usage_value("cached_tokens") or _detail_value(
("prompt_tokens_details", "input_tokens_details"),
"cached_tokens",
),
reasoning_tokens=_usage_value("reasoning_tokens") or _detail_value(
("completion_tokens_details", "output_tokens_details"),
"reasoning_tokens",
),
audio_input_tokens=_usage_value("audio_input_tokens") or _detail_value(
("prompt_tokens_details", "input_tokens_details"),
"audio_tokens",
),
audio_output_tokens=_usage_value("audio_output_tokens") or _detail_value(
("completion_tokens_details", "output_tokens_details"),
"audio_tokens",
),
input_tokens=usage.get("prompt_tokens", 0),
output_tokens=usage.get("completion_tokens", 0),
cached_tokens=usage.get("cached_tokens", 0),
reasoning_tokens=usage.get("reasoning_tokens", 0),
audio_input_tokens=usage.get("audio_input_tokens", 0),
audio_output_tokens=usage.get("audio_output_tokens", 0)
)
# Store metrics
@@ -5738,16 +5570,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Override with any provided parameters
params.update(override_params)
# Resolve temperature from the instance when the caller did not specify
# one. Public entry points now default temperature to None so a value
# configured via Agent(llm={'temperature': ...}) actually reaches the
# request instead of being silently overridden by a hardcoded 1.0.
if params.get("temperature") is None:
if self.temperature is not None:
params["temperature"] = self.temperature
else:
params.pop("temperature", None)
# Handle structured output parameters
output_json = override_params.get('output_json')
@@ -6023,16 +5845,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
"output": msg.get("content", ""),
})
else:
# Translate Chat Completions multimodal content parts
# (type=text / image_url) into Responses API parts
# (input_text / input_image); local image paths become
# data URLs. Plain-string content is passed through.
from .openai_client import OpenAIClient
item = dict(msg)
item["content"] = OpenAIClient._build_responses_content(
msg.get("content", "")
)
input_items.append(item)
input_items.append(msg)
if instructions:
params["instructions"] = instructions
@@ -6080,19 +5893,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
if self.timeout:
params["timeout"] = self.timeout
# ── Subscription credentials ────────────────────────────────────
# Mirror _build_completion_params: without this the Responses API
# transport silently ignores auth= and bills the plain API key.
creds = self._resolve_subscription_creds()
if creds:
params["api_key"] = creds.api_key
if creds.base_url:
params["base_url"] = creds.base_url
if creds.headers:
extra_headers = dict(params.get("extra_headers") or {})
extra_headers.update(creds.headers)
params["extra_headers"] = extra_headers
# ── Structured output ───────────────────────────────────────────
output_json = kwargs.pop("output_json", None)
output_pydantic = kwargs.pop("output_pydantic", None)
@@ -6142,9 +5942,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
Returns a ``ResponsesAPIResponse`` object.
"""
import litellm
response = await self._call_with_retry_async(litellm.aresponses, **params)
self._track_token_usage(response, self._response_model_for_tracking(response))
return response
return await self._call_with_retry_async(litellm.aresponses, **params)
def _extract_from_responses_output(self, response) -> tuple:
"""
@@ -6382,17 +6180,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
is_reasoning=False,
))
elif evt_type == "response.reasoning_summary_text.delta":
delta_text = (event.get("delta", "") if isinstance(event, dict)
else getattr(event, "delta", ""))
if _emit and delta_text:
stream_callback(StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=time.perf_counter(),
content=delta_text,
is_reasoning=True,
))
elif evt_type == "response.function_call_arguments.delta":
idx = (event.get("output_index", 0) if isinstance(event, dict)
else getattr(event, "output_index", 0))
@@ -6505,41 +6292,17 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
tool_call_id = tool_call.id
except (json.JSONDecodeError, AttributeError) as e:
logging.error(f"Error parsing object-style tool call: {e}")
function_name = getattr(getattr(tool_call, 'function', None), 'name', None) or "unknown_function"
# See _parse_tool_call_arguments: a parse failure is not {}.
arguments = _TOOL_ARGUMENTS_PARSE_FAILED
tool_call_id = getattr(tool_call, 'id', None) or f"tool_{id(tool_call)}"
function_name = "unknown_function"
arguments = {}
tool_call_id = f"tool_{id(tool_call)}"
return function_name, arguments, tool_call_id
@staticmethod
def _tool_arguments_parse_failed(arguments) -> bool:
"""True when tool-call arguments could not be parsed (vs. legitimately empty)."""
return arguments is _TOOL_ARGUMENTS_PARSE_FAILED
@staticmethod
def _tool_parse_error_message(function_name: str, tool_call_id: str) -> Dict[str, str]:
"""Build a tool-role message telling the model its arguments were lost.
Surfacing this instead of dispatching with {} converts a silent
wrong-action-reported-as-success into a visible, retryable error.
"""
return {
"role": "tool",
"tool_call_id": tool_call_id,
"content": (
f"Error: arguments for tool '{function_name}' could not be parsed "
f"(the argument string was invalid or truncated). "
f"The tool was NOT executed. Please re-emit the tool call with "
f"complete, valid JSON arguments."
),
}
# Response without tool calls
def response(
self,
prompt: Union[str, List[Dict]],
system_prompt: Optional[str] = None,
temperature: Optional[float] = None,
temperature: float = 1.0,
stream: bool = True,
verbose: bool = True,
markdown: bool = True,
@@ -6633,7 +6396,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
self,
prompt: Union[str, List[Dict]],
system_prompt: Optional[str] = None,
temperature: Optional[float] = None,
temperature: float = 1.0,
stream: bool = True,
verbose: bool = True,
markdown: bool = True,
@@ -6728,23 +6491,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
if callable(function_or_name):
# Function object passed directly
func = function_or_name
# If it's a BaseTool/FunctionTool instance, honour its declared schema
# (respects @tool name= override and excludes Injected[T] params) instead
# of re-deriving from the raw wrapped function's signature.
if hasattr(func, 'get_schema'):
tool_def = func.get_schema()
if (
isinstance(tool_def, dict)
and isinstance(tool_def.get("function"), dict)
and isinstance(tool_def["function"].get("parameters"), dict)
):
tool_def = tool_def.copy()
tool_def["function"] = tool_def["function"].copy()
tool_def["function"]["parameters"] = self._fix_array_schemas(
tool_def["function"]["parameters"]
)
logging.debug(f"Generated tool definition from get_schema(): {tool_def}")
return tool_def
function_name = func.__name__
logging.debug(f"Generating tool definition for callable: {function_name}")
else:
@@ -6,9 +6,7 @@ supporting both synchronous and asynchronous operations.
"""
import os
import base64
import logging
import mimetypes
from praisonaiagents._logging import get_logger
import time
import json
@@ -18,7 +16,6 @@ from typing import Any, Dict, List, Optional, Union, AsyncIterator, Iterator, Ca
from pydantic import BaseModel
from dataclasses import dataclass
import inspect
from pathlib import Path
from ..errors import ToolExecutionError
@@ -37,54 +34,6 @@ def _durable_iteration_kwargs(execute_tool_fn: Callable, index: int) -> Dict[str
return {"_durable_iteration_index": index}
return {}
def _handle_native_deferred_result(
tool_result: Any,
messages: List[Dict],
tool_call_id: Optional[str],
function_name: str,
history_sink: Optional[List[Dict]] = None,
) -> Any:
"""Register a directly-returned ``DeferredToolResult`` on the native path.
Parity with the LiteLLM loop (``llm.py:_register_deferred_if_any``): a tool
may return a ``DeferredToolResult`` to signal "started; will resolve later".
On the native OpenAI-SDK loop the value was previously stringified and the
handle lost, so the eventual background result was never re-injected. Here
we register the handle on the shared resolver so a later
``resolve_deferred(handle_id, value)`` appends a follow-up tool message that
a subsequent turn will replay, and surface the ``note`` to the model now.
The re-injection target is ``history_sink`` when provided — this must be the
caller's *durable* conversation history (e.g. the agent's ``chat_history``),
since the background job typically completes after this native loop has
already returned and its per-call ``messages`` copy has been discarded. When
no durable sink is given we fall back to ``messages`` (correct only if the
handle resolves while the loop is still open), preserving prior behaviour.
Returns the value to record for this turn (the deferred ``note`` string when
deferred, otherwise the unchanged ``tool_result``). Never raises.
"""
from ..tools.call_executor import DeferredToolResult
if not isinstance(tool_result, DeferredToolResult):
return tool_result
try:
from ..tools.call_executor import get_deferred_resolver
resolver = get_deferred_resolver()
sink = history_sink if history_sink is not None else messages
def _reinject(handle_id: str, value: Any, session_id: Optional[str]) -> None:
sink.append({
"role": "tool",
"tool_call_id": tool_call_id,
"content": f"[deferred:{function_name}] {value}",
})
resolver.register_if_absent(tool_result.handle_id, _reinject)
except Exception as e: # noqa: BLE001 — registration must never break the turn
logging.debug("Native deferred registration skipped (non-fatal): %s", e)
return tool_result.note
# Lazy imports for optional dependencies
_openai_module = None
_rich_console = None
@@ -200,26 +149,6 @@ class ToolCall:
type: str
function: Dict[str, Any]
def _extract_tool_call_name_and_args(tool_call):
"""Parse a tool call's function name and JSON arguments.
Handles both the ToolCall dataclass and OpenAI-SDK objects. Returns a
tuple of (name, args, tool_call_id, err); err is None on success and the
caught exception otherwise. Callers keep their own failure handling.
"""
tool_call_id = tool_call.id if hasattr(tool_call, 'id') else tool_call.get('id')
function_name = None
try:
if isinstance(tool_call, ToolCall):
function_name = tool_call.function["name"]
raw_arguments = tool_call.function["arguments"]
else:
function_name = tool_call.function.name
raw_arguments = tool_call.function.arguments
return function_name, json.loads(raw_arguments), tool_call_id, None
except (json.JSONDecodeError, KeyError, AttributeError, TypeError) as e:
return function_name, None, tool_call_id, e
def process_stream_chunks(chunks):
"""Process streaming chunks into combined response"""
if not chunks:
@@ -397,11 +326,6 @@ class OpenAIClient:
self._formatted_tools_cache = {}
self._fixed_schema_cache = {}
self._max_cache_size = 100
# Token usage from the most recent completion (Issue #3933). Populated
# regardless of any display/metrics flag so headless callers and CLI
# envelopes can read real spend after a successful run.
self.last_token_metrics = None
@property
def console(self):
@@ -751,11 +675,7 @@ class OpenAIClient:
"output": msg.get("content", ""),
})
else:
item = dict(msg)
item["content"] = self._build_responses_content(
msg.get("content", "")
)
input_items.append(item)
input_items.append(msg)
if instructions:
params["instructions"] = instructions
@@ -789,65 +709,6 @@ class OpenAIClient:
return params
@staticmethod
def _normalise_responses_image_url(image_url: Any) -> str:
"""Return an API-fetchable URL, encoding local image files as data URLs."""
if isinstance(image_url, dict):
image_url = image_url.get("url", "")
if not isinstance(image_url, str) or not image_url:
raise ValueError("Image content must include a non-empty URL or local path")
if image_url.startswith(("http://", "https://", "data:")):
return image_url
image_path = Path(image_url).expanduser()
if not image_path.is_file():
raise FileNotFoundError(
f"Local image file does not exist: {image_path}"
)
mime_type = mimetypes.guess_type(image_path.name)[0]
if not mime_type or not mime_type.startswith("image/"):
raise ValueError(f"Unsupported local image type: {image_path}")
encoded = base64.b64encode(image_path.read_bytes()).decode("ascii")
return f"data:{mime_type};base64,{encoded}"
@classmethod
def _build_responses_content(cls, content: Any) -> Any:
"""Translate Chat Completions content parts to Responses API parts."""
if not isinstance(content, list):
return content
responses_content: List[Any] = []
for part in content:
if not isinstance(part, dict):
responses_content.append(part)
continue
part_type = part.get("type")
if part_type == "text":
responses_content.append({
"type": "input_text",
"text": part.get("text", ""),
})
elif part_type == "image_url":
image_item = {
"type": "input_image",
"image_url": cls._normalise_responses_image_url(
part.get("image_url")
),
}
image_value = part.get("image_url")
if isinstance(image_value, dict) and image_value.get("detail"):
image_item["detail"] = image_value["detail"]
responses_content.append(image_item)
elif part_type == "input_image":
item = dict(part)
item["image_url"] = cls._normalise_responses_image_url(
part.get("image_url")
)
responses_content.append(item)
else:
responses_content.append(part)
return responses_content
def _responses_to_chat_completion(self, response) -> ChatCompletion:
"""
Wrap a Responses API response into a ChatCompletion dataclass
@@ -1029,8 +890,6 @@ class OpenAIClient:
))
return final_response
except (FileNotFoundError, ValueError):
raise
except Exception as e:
self.logger.warning(f"Responses API streaming failed, falling back: {e}")
# Fall through to Chat Completions streaming
@@ -1298,8 +1157,6 @@ class OpenAIClient:
))
return final_response
except (FileNotFoundError, ValueError):
raise
except Exception as e:
self.logger.warning(f"Responses API async streaming failed, falling back: {e}")
# Fall through to Chat Completions streaming
@@ -1460,81 +1317,7 @@ class OpenAIClient:
except Exception as e:
self.logger.error(f"Error in async stream processing: {e}")
return None
def _track_token_usage(self, response: Any, model: str, agent_name: Optional[str] = None) -> None:
"""Record token usage from a response into the global collector.
Usage accounting is independent from any metrics/verbose display flag:
a quiet default agent must still populate the public token collector
after a paid model call (Issue #3933). This is a cheap dict/attr read
plus a collector add — the expensive HTTP call is already paid.
"""
try:
from ..telemetry.token_collector import get_token_collector, TokenMetrics
except Exception:
return
try:
usage = response.get("usage") if isinstance(response, dict) else getattr(response, "usage", None)
if not usage:
# No usage on this response (e.g. assembled stream chunks): clear
# any stale per-call metrics so envelopes never report a prior
# completion's spend as this call's usage.
self.last_token_metrics = None
return
def _val(*names: str) -> int:
for name in names:
value = usage.get(name) if isinstance(usage, dict) else getattr(usage, name, None)
if value is not None:
return int(value or 0)
return 0
def _detail(detail_names: tuple, name: str) -> int:
for detail_name in detail_names:
details = (
usage.get(detail_name)
if isinstance(usage, dict)
else getattr(usage, detail_name, None)
)
if details:
value = (
details.get(name)
if isinstance(details, dict)
else getattr(details, name, None)
)
if value is not None:
return int(value or 0)
return 0
metrics = TokenMetrics(
input_tokens=_val("prompt_tokens", "input_tokens"),
output_tokens=_val("completion_tokens", "output_tokens"),
cached_tokens=_val("cached_tokens") or _detail(
("prompt_tokens_details", "input_tokens_details"), "cached_tokens"
),
reasoning_tokens=_val("reasoning_tokens") or _detail(
("completion_tokens_details", "output_tokens_details"), "reasoning_tokens"
),
audio_input_tokens=_val("audio_input_tokens") or _detail(
("prompt_tokens_details", "input_tokens_details"), "audio_tokens"
),
audio_output_tokens=_val("audio_output_tokens") or _detail(
("completion_tokens_details", "output_tokens_details"), "audio_tokens"
),
)
if metrics.total_tokens == 0:
self.last_token_metrics = None
return
self.last_token_metrics = metrics
get_token_collector().track_tokens(
model=model,
agent=agent_name,
metrics=metrics,
metadata={"provider": "openai", "stream": False},
)
except Exception:
pass
def create_completion(
self,
messages: List[Dict[str, Any]],
@@ -1569,8 +1352,6 @@ class OpenAIClient:
)
raw = self.sync_client.responses.create(**resp_params)
return self._responses_to_chat_completion(raw)
except (FileNotFoundError, ValueError):
raise
except Exception as e:
self.logger.warning(f"Responses API failed, falling back to Chat Completions: {e}")
# Fall through to Chat Completions
@@ -1579,13 +1360,10 @@ class OpenAIClient:
params = {
"model": model,
"messages": messages,
"temperature": temperature,
"stream": stream,
**kwargs
}
# Omit temperature when unset so the provider default applies rather
# than sending an explicit None.
if temperature is not None:
params["temperature"] = temperature
# Add tools if provided
if tools:
@@ -1633,8 +1411,6 @@ class OpenAIClient:
)
raw = await self.async_client.responses.create(**resp_params)
return self._responses_to_chat_completion(raw)
except (FileNotFoundError, ValueError):
raise
except Exception as e:
self.logger.warning(f"Responses API failed, falling back to Chat Completions: {e}")
# Fall through to Chat Completions
@@ -1643,13 +1419,10 @@ class OpenAIClient:
params = {
"model": model,
"messages": messages,
"temperature": temperature,
"stream": stream,
**kwargs
}
# Omit temperature when unset so the provider default applies rather
# than sending an explicit None.
if temperature is not None:
params["temperature"] = temperature
# Add tools if provided
if tools:
@@ -1678,7 +1451,6 @@ class OpenAIClient:
max_iterations: int = 10,
stream_callback: Optional[Callable] = None,
emit_events: bool = False,
agent_name: Optional[str] = None,
**kwargs
) -> Optional[ChatCompletion]:
"""
@@ -1715,11 +1487,6 @@ class OpenAIClient:
# path so /stop and live steering work on the OpenAI-native loop too.
cancel_token = kwargs.pop("cancel_token", None)
steering_drain = kwargs.pop("steering_drain", None)
# Durable sink for deferred tool re-injection: the caller's persistent
# conversation history (e.g. the agent's ``chat_history``). Background
# jobs usually complete after this loop returns, so late results must
# land in a list a subsequent turn replays — not the per-call copy below.
deferred_history_sink = kwargs.pop("deferred_history_sink", None)
def _is_cancelled() -> bool:
return cancel_token is not None and getattr(cancel_token, "is_set", lambda: False)()
@@ -1849,13 +1616,7 @@ class OpenAIClient:
if not final_response:
return None
# Record usage for THIS billed completion. Every tool-loop iteration
# is a separate paid call, so accounting must happen per iteration —
# not once after the loop — or intermediate completions are dropped
# from session totals and by_model/by_agent rollups (Issue #3933).
self._track_token_usage(final_response, model, agent_name)
# Trigger llm_end callback with metrics for debug output
llm_end_time = time.perf_counter()
llm_latency_ms = (llm_end_time - start_time) * 1000
@@ -1932,19 +1693,13 @@ class OpenAIClient:
# replies for this turn (keeps tool replies consecutive).
_deferred_media_followups = []
for tool_call in tool_calls:
# Handle both ToolCall dataclass and OpenAI object.
# Guard the parse itself so malformed/truncated argument
# JSON is reported back to the model instead of aborting
# the whole run (matches chat_completion_with_tools_stream).
function_name, arguments, _tool_call_id, err = _extract_tool_call_name_and_args(tool_call)
if err is not None:
logging.warning(f"Failed to parse tool call arguments: {err}")
messages.append({
"role": "tool",
"tool_call_id": _tool_call_id,
"content": json.dumps({"error": f"Invalid arguments JSON: {err}"}),
})
continue
# Handle both ToolCall dataclass and OpenAI object
if isinstance(tool_call, ToolCall):
function_name = tool_call.function["name"]
arguments = json.loads(tool_call.function["arguments"])
else:
function_name = tool_call.function.name
arguments = json.loads(tool_call.function.arguments)
# Always trigger callback for tool call tracking (even when verbose=False)
display_tool_call_fn = _get_display_tool_call()
@@ -1953,6 +1708,7 @@ class OpenAIClient:
# Capture failures and report them back to the model instead of
# aborting the whole run (safe-by-default, matching the streaming
# path chat_completion_with_tools_stream).
_tool_call_id = tool_call.id if hasattr(tool_call, 'id') else tool_call.get('id')
try:
tool_result = execute_tool_fn(
function_name,
@@ -1967,12 +1723,6 @@ class OpenAIClient:
except Exception as tool_error:
logging.warning(f"Tool '{function_name}' failed: {tool_error}")
tool_result = {"error": str(tool_error)}
# Parity with the LiteLLM loop: register a deferred handle so
# its eventual background value is re-injected, not lost.
tool_result = _handle_native_deferred_result(
tool_result, messages, _tool_call_id, function_name,
history_sink=deferred_history_sink,
)
try:
results_str = json.dumps(tool_result) if tool_result else "Function returned an empty output"
except (TypeError, ValueError):
@@ -2014,9 +1764,7 @@ class OpenAIClient:
else:
# No tool calls, we're done
break
# Usage is recorded per iteration inside the loop above so every billed
# completion (including intermediate tool-call rounds) is accounted for.
return final_response
async def achat_completion_with_tools(
@@ -2034,7 +1782,6 @@ class OpenAIClient:
max_iterations: int = 10,
stream_callback: Optional[Callable] = None,
emit_events: bool = False,
agent_name: Optional[str] = None,
**kwargs
) -> Optional[ChatCompletion]:
"""
@@ -2065,8 +1812,6 @@ class OpenAIClient:
# path so /stop and live steering work on the OpenAI-native loop too.
cancel_token = kwargs.pop("cancel_token", None)
steering_drain = kwargs.pop("steering_drain", None)
# Durable sink for deferred tool re-injection (see sync counterpart).
deferred_history_sink = kwargs.pop("deferred_history_sink", None)
def _is_cancelled() -> bool:
return cancel_token is not None and getattr(cancel_token, "is_set", lambda: False)()
@@ -2184,12 +1929,6 @@ class OpenAIClient:
if not final_response:
return None
# Record usage for THIS billed completion. Every tool-loop iteration
# is a separate paid call, so accounting must happen per iteration —
# not once after the loop — or intermediate completions are dropped
# from session totals and by_model/by_agent rollups (Issue #3933).
self._track_token_usage(final_response, model, agent_name)
# Check for tool calls
if not final_response.choices or final_response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
@@ -2240,19 +1979,13 @@ class OpenAIClient:
# replies for this turn (keeps tool replies consecutive).
_deferred_media_followups = []
for tool_call in tool_calls:
# Handle both ToolCall dataclass and OpenAI object.
# Guard the parse itself so malformed/truncated argument
# JSON is reported back to the model instead of aborting
# the whole run (matches chat_completion_with_tools_stream).
function_name, arguments, _tool_call_id, err = _extract_tool_call_name_and_args(tool_call)
if err is not None:
logging.warning(f"Failed to parse tool call arguments: {err}")
messages.append({
"role": "tool",
"tool_call_id": _tool_call_id,
"content": json.dumps({"error": f"Invalid arguments JSON: {err}"}),
})
continue
# Handle both ToolCall dataclass and OpenAI object
if isinstance(tool_call, ToolCall):
function_name = tool_call.function["name"]
arguments = json.loads(tool_call.function["arguments"])
else:
function_name = tool_call.function.name
arguments = json.loads(tool_call.function.arguments)
# Always trigger callback for tool call tracking (even when verbose=False)
display_tool_call_fn = _get_display_tool_call()
@@ -2265,6 +1998,7 @@ class OpenAIClient:
# Capture failures and report them back to the model instead of
# aborting the whole run (safe-by-default, matching the streaming
# path chat_completion_with_tools_stream).
_tool_call_id = tool_call.id if hasattr(tool_call, 'id') else tool_call.get('id')
try:
if asyncio.iscoroutinefunction(execute_tool_fn):
tool_result = await execute_tool_fn(
@@ -2298,12 +2032,6 @@ class OpenAIClient:
logging.warning(f"Tool '{function_name}' failed: {tool_error}")
tool_result = {"error": str(tool_error)}
# Parity with the LiteLLM loop: register a deferred handle so
# its eventual background value is re-injected, not lost.
tool_result = _handle_native_deferred_result(
tool_result, messages, _tool_call_id, function_name,
history_sink=deferred_history_sink,
)
try:
results_str = json.dumps(tool_result) if tool_result else "Function returned an empty output"
except (TypeError, ValueError):
@@ -2337,9 +2065,7 @@ class OpenAIClient:
else:
# No tool calls, we're done
break
# Usage is recorded per iteration inside the loop above so every billed
# completion (including intermediate tool-call rounds) is accounted for.
return final_response
def chat_completion_with_tools_stream(
@@ -2378,8 +2104,6 @@ class OpenAIClient:
Yields:
String chunks of the response as they are generated
"""
# Durable sink for deferred tool re-injection (see sync counterpart).
deferred_history_sink = kwargs.pop("deferred_history_sink", None)
# Format tools for OpenAI API
formatted_tools = self.format_tools(tools)
@@ -2487,14 +2211,16 @@ class OpenAIClient:
_deferred_media_followups = []
for tool_call in tool_calls:
# Handle both ToolCall dataclass and OpenAI object
function_name, arguments, _, err = _extract_tool_call_name_and_args(tool_call)
if err is not None:
# Preserve original behaviour: only malformed JSON is
# reported to the caller; other errors propagate.
if not isinstance(err, json.JSONDecodeError):
raise err
try:
if isinstance(tool_call, ToolCall):
function_name = tool_call.function["name"]
arguments = json.loads(tool_call.function["arguments"])
else:
function_name = tool_call.function.name
arguments = json.loads(tool_call.function.arguments)
except json.JSONDecodeError as e:
if verbose:
yield f"\n[Error parsing arguments for {function_name if function_name is not None else 'unknown function'}: {str(err)}]"
yield f"\n[Error parsing arguments for {function_name if 'function_name' in locals() else 'unknown function'}: {str(e)}]"
continue
# Always trigger callback for tool call tracking (even when verbose=False)
@@ -2515,12 +2241,6 @@ class OpenAIClient:
execute_tool_fn, iteration_count
),
)
# Parity with the LiteLLM loop: register a deferred
# handle so its eventual value is re-injected.
tool_result = _handle_native_deferred_result(
tool_result, messages, _tool_call_id, function_name,
history_sink=deferred_history_sink,
)
results_str = json.dumps(tool_result) if tool_result else "Function returned an empty output"
except ToolExecutionError:
raise
@@ -1,137 +0,0 @@
"""
Provider Quota Coordination for PraisonAI Agents.
Provides a lightweight, protocol-driven seam so provider-quota state
(credential cooldowns / benches) can be coordinated *fleet-wide* when a
shared backend is configured, while defaulting to today's in-process,
in-memory behaviour with zero new dependencies and no hot-path change.
Design:
- ``QuotaCoordinatorProtocol`` is a pure contract (stdlib + typing only).
- ``LocalQuotaCoordinator`` is the default in-memory implementation used
when no shared backend is selected — single-replica deployments behave
exactly as before.
- A concrete shared backend (e.g. Redis, reusing the gateway's existing
connection) implements the same protocol in the wrapper/bot package and
is selected via ``QuotaCoordinatorConfig``. Core keeps only the protocol
and the in-memory default.
Fail-open by contract: implementations MUST degrade to local behaviour on
backend errors rather than wedge a healthy request path, and bench entries
carry TTLs so a dead replica's benches self-expire.
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Dict, Optional, Protocol, runtime_checkable
@runtime_checkable
class QuotaCoordinatorProtocol(Protocol):
"""Contract for coordinating provider-quota state across replicas.
A coordinator tracks per-credential cooldowns ("benches"). When a shared
backend is configured, a credential benched by any replica is treated as
benched by all replicas until its cooldown expires.
"""
def bench(self, cred_id: str, *, until: float, reason: str = "") -> None:
"""Bench a credential until ``until`` (an epoch timestamp, TTL-based)."""
...
def is_benched(self, cred_id: str, *, now: Optional[float] = None) -> bool:
"""Return True if the credential is currently benched."""
...
def benched_until(self, cred_id: str, *, now: Optional[float] = None) -> Optional[float]:
"""Return the epoch timestamp the credential is benched until, or None."""
...
def clear(self, cred_id: str) -> None:
"""Clear any bench for the credential (e.g. on recovery)."""
...
@dataclass
class QuotaCoordinatorConfig:
"""Configuration for provider-quota coordination.
Attributes:
backend: ``"local"`` (in-memory, default) or a shared backend name
such as ``"redis"`` provided by the wrapper/bot package.
url: Optional backend URL; when omitted a shared backend reuses the
already-established connection (e.g. gateway ``RedisConfig``).
"""
backend: str = "local"
url: Optional[str] = None
def to_dict(self) -> Dict[str, Optional[str]]:
return {"backend": self.backend, "url": self.url}
@classmethod
def from_dict(cls, data: Dict[str, Optional[str]]) -> "QuotaCoordinatorConfig":
return cls(
backend=(data.get("backend") or "local"),
url=data.get("url"),
)
class LocalQuotaCoordinator:
"""In-memory ``QuotaCoordinatorProtocol`` (default, zero-dependency).
Thread-safe via a lightweight lock. Bench entries carry an expiry so
they self-clear once the cooldown passes — mirroring the TTL semantics a
shared backend uses so behaviour is identical across implementations.
"""
def __init__(self) -> None:
import threading
self._benches: Dict[str, float] = {}
self._lock = threading.Lock()
def bench(self, cred_id: str, *, until: float, reason: str = "") -> None:
with self._lock:
existing = self._benches.get(cred_id)
# Keep the longest cooldown if a longer one is already set.
if existing is None or until > existing:
self._benches[cred_id] = until
def is_benched(self, cred_id: str, *, now: Optional[float] = None) -> bool:
return self.benched_until(cred_id, now=now) is not None
def benched_until(self, cred_id: str, *, now: Optional[float] = None) -> Optional[float]:
current = time.time() if now is None else now
with self._lock:
until = self._benches.get(cred_id)
if until is None:
return None
if current >= until:
# Expired — self-clear.
self._benches.pop(cred_id, None)
return None
return until
def clear(self, cred_id: str) -> None:
with self._lock:
self._benches.pop(cred_id, None)
def build_quota_coordinator(
config: Optional[QuotaCoordinatorConfig] = None,
) -> QuotaCoordinatorProtocol:
"""Build a coordinator from config, defaulting to the local in-memory one.
Only the ``"local"`` backend is resolvable from core. Shared backends
(e.g. ``"redis"``) are provided by the wrapper/bot package, which passes
an already-constructed coordinator instance to ``FailoverManager``; this
helper falls back to local for unknown backends so core never grows a
heavy optional dependency.
"""
if config is None or config.backend == "local":
return LocalQuotaCoordinator()
# Unknown/heavy backend not resolvable in core: fail open to local.
return LocalQuotaCoordinator()
@@ -221,7 +221,6 @@ class OpenAIAdapter:
reasoning_steps=kwargs.get('reasoning_steps', False),
verbose=kwargs.get('verbose', True),
max_iterations=kwargs.get('max_iterations', 10),
agent_name=kwargs.get('agent_name'),
**{k: v for k, v in kwargs.items() if k not in {
'max_tokens', 'execute_tool_fn', 'console', 'display_fn',
'reasoning_steps', 'verbose', 'max_iterations',
@@ -41,11 +41,6 @@ __all__ = [
"Range",
# Configuration
"LSPConfig",
# Auto-detection / availability helpers
"detect_language",
"detect_root_uri",
"probe",
"DEFAULT_SERVERS",
]
@@ -63,9 +58,8 @@ def __getattr__(name: str):
)
return locals()[name]
if name in ("LSPConfig", "detect_language", "detect_root_uri", "probe",
"DEFAULT_SERVERS"):
from . import config
return getattr(config, name)
if name == "LSPConfig":
from .config import LSPConfig
return LSPConfig
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -16,9 +16,7 @@ from .types import (
Position, Range, TextDocumentItem, TextDocumentIdentifier,
TextDocumentPositionParams
)
import shutil
from .config import LSPConfig, detect_root_uri, probe, path_to_uri
from .config import LSPConfig
logger = get_logger(__name__)
@@ -49,8 +47,7 @@ class LSPClient:
language: str,
command: Optional[str] = None,
args: Optional[List[str]] = None,
root_uri: Optional[str] = None,
workspace_file: Optional[str] = None
root_uri: Optional[str] = None
):
"""
Initialize the LSP client.
@@ -60,10 +57,6 @@ class LSPClient:
command: Language server command (uses default if not specified)
args: Command arguments
root_uri: Workspace root URI
workspace_file: Optional file used to auto-detect the workspace root
from the nearest root marker (e.g. ``pyproject.toml``,
``go.mod``) when ``root_uri`` is not given, so multi-root and
monorepo setups initialise against the correct project root.
"""
self.config = LSPConfig(
language=language,
@@ -71,18 +64,13 @@ class LSPClient:
args=args or [],
root_uri=root_uri
)
self._workspace_file = workspace_file
self._process: Optional[asyncio.subprocess.Process] = None
self._request_id = 0
self._pending_requests: Dict[int, asyncio.Future] = {}
self._diagnostics: Dict[str, List[Diagnostic]] = {}
self._initialized = False
self._reader_task: Optional[asyncio.Task] = None
# Populated with a structured, actionable message when the server
# binary is not on PATH, so callers can surface an explicit
# "unavailable + how to install" notice rather than a silent no-op.
self.last_error: Optional[str] = None
@property
def is_running(self) -> bool:
@@ -99,26 +87,6 @@ class LSPClient:
if self.is_running:
return True
# Availability pre-flight: check the *actual* configured command on
# PATH before spawning so a missing binary yields a single structured,
# actionable message instead of a raw FileNotFoundError. ``last_error``
# lets the caller surface an explicit "install X" notice rather than
# degrading silently. We gate on ``self.config.command`` (which may be a
# user-supplied custom server), and only surface the registry's install
# hint when it matches the default server for the language.
if not shutil.which(self.config.command):
_, default_command, install_hint = probe(self.config.language)
hint = (
f"; install with `{install_hint}`"
if install_hint and self.config.command == default_command
else ""
)
self.last_error = (
f"language server `{self.config.command}` not found on PATH{hint}"
)
logger.warning(self.last_error)
return False
try:
cmd = [self.config.command] + self.config.args
@@ -132,15 +100,8 @@ class LSPClient:
# Start reader task
self._reader_task = asyncio.create_task(self._read_responses())
# Initialize the server. Prefer an explicit root_uri, then a root
# detected from the workspace file's nearest root marker (so
# monorepos/multi-root setups initialise against the real project
# root), falling back to the CWD only when neither is available.
root_uri = self.config.root_uri
if root_uri is None and self._workspace_file is not None:
root_uri = detect_root_uri(self._workspace_file, self.config.language)
if root_uri is None:
root_uri = path_to_uri(os.getcwd())
# Initialize the server
root_uri = self.config.root_uri or f"file://{os.getcwd()}"
result = await self._send_request("initialize", {
"processId": os.getpid(),
@@ -226,7 +187,7 @@ class LSPClient:
logger.error(f"Failed to read file: {e}")
return False
uri = path_to_uri(file_path)
uri = f"file://{os.path.abspath(file_path)}"
await self._send_notification("textDocument/didOpen", {
"textDocument": TextDocumentItem(
@@ -244,7 +205,7 @@ class LSPClient:
if not self._initialized:
return
uri = path_to_uri(file_path)
uri = f"file://{os.path.abspath(file_path)}"
await self._send_notification("textDocument/didClose", {
"textDocument": TextDocumentIdentifier(uri=uri).to_dict()
@@ -260,7 +221,7 @@ class LSPClient:
Returns:
List of diagnostics
"""
uri = path_to_uri(file_path)
uri = f"file://{os.path.abspath(file_path)}"
return self._diagnostics.get(uri, [])
async def get_completions(
@@ -283,7 +244,7 @@ class LSPClient:
if not self._initialized:
return []
uri = path_to_uri(file_path)
uri = f"file://{os.path.abspath(file_path)}"
result = await self._send_request("textDocument/completion", {
"textDocument": {"uri": uri},
@@ -320,7 +281,7 @@ class LSPClient:
if not self._initialized:
return []
uri = path_to_uri(file_path)
uri = f"file://{os.path.abspath(file_path)}"
result = await self._send_request("textDocument/definition", {
"textDocument": {"uri": uri},
@@ -359,7 +320,7 @@ class LSPClient:
if not self._initialized:
return []
uri = path_to_uri(file_path)
uri = f"file://{os.path.abspath(file_path)}"
result = await self._send_request("textDocument/references", {
"textDocument": {"uri": uri},
@@ -392,7 +353,7 @@ class LSPClient:
if not self._initialized:
return None
uri = path_to_uri(file_path)
uri = f"file://{os.path.abspath(file_path)}"
result = await self._send_request("textDocument/hover", {
"textDocument": {"uri": uri},
@@ -429,7 +390,7 @@ class LSPClient:
if not self._initialized:
return []
uri = path_to_uri(file_path)
uri = f"file://{os.path.abspath(file_path)}"
result = await self._send_request("textDocument/documentSymbol", {
"textDocument": {"uri": uri}
@@ -2,124 +2,35 @@
LSP Configuration for PraisonAI Agents.
"""
import os
import shutil
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional, Dict, List, Tuple
from typing import Optional, Dict, List
# Data-driven language server registry. Each entry carries the spawn command
# plus enough metadata for zero-config auto-detection:
# - command / args: how to start the server
# - extensions: file extensions that map to this language
# - root_markers: files/dirs that mark a workspace root (nearest wins), so
# ``rootUri`` is the real project root rather than ``os.getcwd()``
# - install_hint: an actionable "how to install" string surfaced when the
# server binary is absent, so degradation is explicit instead of silent
# Default language server commands
DEFAULT_SERVERS = {
"python": {
"command": "pylsp",
"args": [],
"extensions": [".py", ".pyi"],
"root_markers": ["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", ".git"],
"install_hint": "pip install python-lsp-server",
},
"javascript": {
"command": "typescript-language-server",
"args": ["--stdio"],
"extensions": [".js", ".jsx", ".mjs", ".cjs"],
"root_markers": ["package.json", "tsconfig.json", "jsconfig.json", ".git"],
"install_hint": "npm install -g typescript-language-server typescript",
},
"typescript": {
"command": "typescript-language-server",
"args": ["--stdio"],
"extensions": [".ts", ".tsx"],
"root_markers": ["tsconfig.json", "package.json", "jsconfig.json", ".git"],
"install_hint": "npm install -g typescript-language-server typescript",
},
"rust": {
"command": "rust-analyzer",
"args": [],
"extensions": [".rs"],
"root_markers": ["Cargo.toml", "Cargo.lock", ".git"],
"install_hint": "rustup component add rust-analyzer",
},
"go": {
"command": "gopls",
"args": [],
"extensions": [".go"],
"root_markers": ["go.mod", "go.sum", ".git"],
"install_hint": "go install golang.org/x/tools/gopls@latest",
},
}
def _extension_map() -> Dict[str, str]:
"""Return a ``{extension: language}`` map derived from the registry."""
mapping: Dict[str, str] = {}
for language, server in DEFAULT_SERVERS.items():
for ext in server.get("extensions", []):
mapping.setdefault(ext.lower(), language)
return mapping
def detect_language(file_path: str) -> Optional[str]:
"""Resolve the LSP language id for ``file_path`` by extension, else ``None``."""
return _extension_map().get(os.path.splitext(file_path)[1].lower())
def path_to_uri(path: str) -> str:
"""Convert an absolute filesystem *path* to a valid ``file://`` URI.
Uses :meth:`pathlib.Path.as_uri`, which percent-encodes reserved characters
(spaces, ``#`` …) so paths with such characters produce a valid, non-truncated
URI that language servers accept.
"""
return Path(os.path.abspath(path)).as_uri()
def detect_root_uri(file_path: str, language: Optional[str] = None) -> Optional[str]:
"""Discover the workspace root for ``file_path`` from the nearest root marker.
Walks up from the file's directory looking for a language-appropriate root
marker (``pyproject.toml``, ``go.mod``, ``Cargo.toml``, ``tsconfig.json``,
``.git`` …). Returns a ``file://`` URI for the nearest matching directory,
or ``None`` when no marker is found so the caller can fall back to the CWD.
"""
language = language or detect_language(file_path)
server = DEFAULT_SERVERS.get(language) if language else None
markers = server.get("root_markers", []) if server else [".git"]
start = os.path.dirname(os.path.abspath(file_path))
current = start
while True:
for marker in markers:
if os.path.exists(os.path.join(current, marker)):
return path_to_uri(current)
parent = os.path.dirname(current)
if parent == current:
return None
current = parent
def probe(language: str) -> Tuple[bool, Optional[str], Optional[str]]:
"""Check whether the language server for ``language`` is on ``PATH``.
Returns ``(available, command, install_hint)``. ``available`` is ``True``
when the configured server binary is found via ``shutil.which``; otherwise
``command`` and ``install_hint`` describe what is missing and how to install
it so the degradation can be surfaced instead of silently swallowed.
"""
server = DEFAULT_SERVERS.get(language)
if not server:
return False, None, None
command = server["command"]
if shutil.which(command):
return True, command, server.get("install_hint")
return False, command, server.get("install_hint")
@dataclass
class LSPConfig:
"""Configuration for LSP client."""
@@ -136,6 +47,6 @@ class LSPConfig:
if self.language in DEFAULT_SERVERS:
server = DEFAULT_SERVERS[self.language]
self.command = server["command"]
self.args = list(server["args"])
self.args = server["args"]
else:
raise ValueError(f"No default server for language: {self.language}")
+33 -5
View File
@@ -742,8 +742,36 @@ class ReflectionOutput(BaseModel):
reflection: str
satisfactory: Literal["yes", "no"]
# Re-export the single canonical TaskOutput so main.TaskOutput and
# output.models.TaskOutput are the SAME class. Previously two near-identical
# classes both named TaskOutput existed, which broke guardrail validation
# (GuardrailResult expected one class while agents produced the other).
from .output.models import TaskOutput
class TaskOutput(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
description: str
summary: Optional[str] = None
raw: str
pydantic: Optional[BaseModel] = None
json_dict: Optional[Dict[str, Any]] = None
agent: str
output_format: Literal["RAW", "JSON", "Pydantic"] = "RAW"
token_metrics: Optional['TokenMetrics'] = None # Add token metrics field
callback_error: Optional[str] = None
non_fatal_errors: Optional[list[str]] = None
def json(self) -> Optional[str]:
if self.output_format == "JSON" and self.json_dict:
return json.dumps(self.json_dict)
return None
def to_dict(self) -> dict:
output_dict = {}
if self.json_dict:
output_dict.update(self.json_dict)
if self.pydantic:
output_dict.update(self.pydantic.model_dump())
return output_dict
def __str__(self):
if self.pydantic:
return str(self.pydantic)
elif self.json_dict:
return json.dumps(self.json_dict)
else:
return self.raw
@@ -11,83 +11,30 @@ from __future__ import annotations
import importlib
from typing import Any, Optional
_INSTALL_HINT = "Remote compute requires: pip install praisonai-sandbox"
_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_sandbox.compute.local", "LocalCompute"),
"docker": ("praisonai_sandbox.compute.docker", "DockerCompute"),
"e2b": ("praisonai_sandbox.compute.e2b", "E2BCompute"),
"modal": ("praisonai_sandbox.compute.modal_compute", "ModalCompute"),
"daytona": ("praisonai_sandbox.compute.daytona", "DaytonaCompute"),
"flyio": ("praisonai_sandbox.compute.flyio", "FlyioCompute"),
"tenki": ("praisonai_sandbox.compute.tenki", "TenkiCompute"),
"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"),
}
# What to actually type when a provider is missing. Every name here must point
# at an extra that EXISTS and installs something -- three of these used to name
# extras that were absent or empty, so a user followed the official hint, waited
# for the install, and hit the identical failure again.
#
# The providers in _PROVIDERS now live in praisonai-sandbox, so the failing
# import is `praisonai_sandbox.compute.*`: point the hint at the lightweight
# package that owns the module, not the heavy wrapper. Reaching E2B through
# praisonai-sandbox[e2b] resolves 58 packages against 143 through praisonai.
# Sandbox-only places (novita/sandlock/ssh) resolve through the
# SandboxComputeAdapter and only need `praisonai-sandbox`.
_EXTRA_HINTS = {
"e2b": "pip install 'praisonai-sandbox[e2b]'",
"modal": "pip install 'praisonai-sandbox[modal]'",
"daytona": "pip install 'praisonai-sandbox[daytona]'",
"docker": "pip install 'praisonai-sandbox[docker]'",
"novita": "pip install 'praisonai-sandbox[novita]'",
"sandlock": "pip install 'praisonai-sandbox[sandlock]'",
"ssh": "pip install 'praisonai-sandbox[ssh]'",
# flyio and tenki need a credential rather than an SDK; flyio speaks plain
# HTTP through aiohttp, which praisonaiagents already requires. Export the
# variable so a child process inherits it -- a bare assignment would not.
"flyio": "export FLY_API_TOKEN=... (see https://fly.io/docs/flyctl/auth-token)",
"tenki": "pip install 'praisonai-sandbox[tenki]'",
"e2b": "pip install praisonai[e2b]",
"modal": "pip install praisonai[modal]",
"daytona": "pip install praisonai[daytona]",
}
# Places that live only in praisonai-sandbox. They speak SandboxProtocol
# (one object IS one environment) rather than ComputeProviderProtocol (one
# object hands out many), so an adapter supplies the missing half.
from ._sandbox_adapter import NEEDS_INSTANCE, SANDBOX_ONLY # noqa: E402
# Spellings users already have in their code, mapped to the canonical name.
_ALIASES = {"native": "sandlock"}
def _entry_point_providers() -> dict:
"""Places contributed by other installed packages.
The sandbox and managed-backend registries already discover plugins this
way (``praisonai.sandbox``, ``praisonai.managed_backends``); the compute
registry was the one hardcoded dict, so adding a place meant editing core.
A provider can now ship in its own distribution:
[project.entry-points."praisonai.compute"]
runpod = "praisonai_compute_runpod:RunpodCompute"
"""
found = {}
try:
from importlib.metadata import entry_points
for ep in entry_points(group="praisonai.compute"):
found[ep.name.lower()] = ep
except Exception: # pragma: no cover - discovery must never break resolution
pass
return found
def available_providers() -> list:
"""Every place ``tools_run_on=`` / ``run_in=`` accepts, from all registries."""
return sorted(
set(_PROVIDERS) | set(SANDBOX_ONLY) | set(_entry_point_providers())
)
"""Return the compute provider names this bridge can resolve."""
return sorted(_PROVIDERS)
def compute_package_available() -> bool:
@@ -111,44 +58,15 @@ def resolve_compute(compute: Optional[Any]) -> Optional[Any]:
if not isinstance(compute, str):
if hasattr(compute, "provision") and hasattr(compute, "execute"):
return compute
# A SandboxProtocol instance -- e.g. SSHSandbox(host=...) -- is adapted,
# which is the only way to pass connection details.
if hasattr(compute, "run_command") and hasattr(compute, "start"):
from ._sandbox_adapter import wrap_sandbox_instance
return wrap_sandbox_instance(compute)
raise TypeError(
f"compute= expects a provider name or a ComputeProviderProtocol "
f"instance, got {type(compute).__name__}"
)
name = compute.lower().strip()
name = _ALIASES.get(name, name)
# A sandbox-only place: adapt it to the compute-provider shape.
if name in SANDBOX_ONLY:
if name in NEEDS_INSTANCE:
raise ValueError(
f"{name!r} needs connection details a bare name cannot carry. "
f"Instead, {NEEDS_INSTANCE[name]}"
)
from ._sandbox_adapter import SandboxComputeAdapter
return SandboxComputeAdapter(name)
external = _entry_point_providers()
if name in external and name not in _PROVIDERS:
# Let a contributed place describe itself. Without this the repr falls
# back to the bare name, because the phrase table is a literal that
# cannot know about packages installed later.
_remember_display_name(name, external[name])
# A place contributed by another package. Loaded on demand, so an
# installed-but-broken plugin cannot break anything that never names it.
return external[name].load()()
if name not in _PROVIDERS:
raise ValueError(
f"Unknown place {name!r}. "
f"Unknown compute provider {name!r}. "
f"Available: {', '.join(available_providers())}"
)
@@ -160,23 +78,3 @@ def resolve_compute(compute: Optional[Any]) -> Optional[Any]:
raise ImportError(f"compute={name!r} requires {module_name}. {hint}") from exc
return getattr(module, attr)()
#: Display phrases contributed by external providers, filled in on resolve.
_DISPLAY_NAMES: dict = {}
def _remember_display_name(name: str, entry_point) -> None:
"""Record a contributed provider's own phrase, if it declares one."""
try:
cls = entry_point.load()
phrase = getattr(cls, "display_name", None)
if isinstance(phrase, str) and phrase:
_DISPLAY_NAMES[name] = phrase
except Exception: # pragma: no cover - naming must never break resolution
pass
def contributed_display_name(name: str):
"""The phrase a contributed place declared for itself, if any."""
return _DISPLAY_NAMES.get((name or "").lower())
@@ -1,254 +0,0 @@
"""Expose a single sandbox environment as a one-instance compute provider.
There are two execution stacks in this project and they are *not* duplicates:
* ``SandboxProtocol`` (praisonai-sandbox) -- one object **is** one environment:
``start / stop / execute / run_command / reset``.
* ``ComputeProviderProtocol`` -- one object **hands out** many environments:
``provision / execute(instance_id, ...) / shutdown / list_instances``.
``tools_run_on=`` speaks the second protocol, so four backends that only exist
as sandboxes -- ``subprocess``, ``sandlock``, ``ssh``, ``novita`` -- were
unreachable through it. This adapter is the missing half: it holds exactly one
environment and presents the manager-shaped API over it.
Deliberate limitation: ``list_instances()`` can only report what *this process*
provisioned, so adapter-backed places never appear in ``praisonai managed ps``.
A single environment has no cross-process registry to consult, and pretending
otherwise would be worse than saying so.
"""
from __future__ import annotations
import logging
import uuid
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
# Backends that exist only as SandboxProtocol implementations. The literal is
# the floor, not the ceiling: the sandbox registry accepts plugins by entry
# point, so a hardcoded tuple silently withheld contributed backends from
# tools_run_on=. `capsule` was the live example -- it ships from
# praisonai-plugins, worked with run_in=, and was invisible to tools_run_on=.
_SANDBOX_ONLY_FLOOR = ("subprocess", "sandlock", "ssh", "novita")
def sandbox_only_names() -> tuple:
"""Sandbox backends with no compute-registry equivalent.
Anything the compute registry already provides is excluded, so a name that
exists in both (docker, e2b, modal, daytona) keeps its richer compute
implementation rather than being downgraded to the one-instance adapter.
"""
names = set(_SANDBOX_ONLY_FLOOR)
try:
from ..sandbox._sandbox_bridge import get_sandbox_registry
registry = get_sandbox_registry()
registry = registry.default() if isinstance(registry, type) else registry
names |= {str(n).lower() for n in registry.list_all_names()}
except Exception: # pragma: no cover - discovery must never break resolution
pass
try:
from ._compute_bridge import _PROVIDERS
names -= set(_PROVIDERS)
except Exception:
pass
return tuple(sorted(names))
class _SandboxOnly(tuple):
"""A tuple that re-reads the registry, so `in` sees plugins installed later.
Kept as a tuple subclass because SANDBOX_ONLY is imported and iterated in
several places that predate this.
"""
def __contains__(self, item):
return item in sandbox_only_names()
def __iter__(self):
return iter(sandbox_only_names())
def __len__(self):
return len(sandbox_only_names())
SANDBOX_ONLY = _SandboxOnly(_SANDBOX_ONLY_FLOOR)
# Names that need connection details no bare string can carry.
NEEDS_INSTANCE = {
"ssh": (
"run on a remote machine over SSH by passing the object, so it can "
"carry the host: SSHSandbox(host='...', user='...')"
),
}
class SandboxComputeAdapter:
"""One ``SandboxProtocol`` environment, wearing the compute-provider shape."""
def __init__(self, sandbox_type: str, config: Optional[Any] = None,
sandbox: Optional[Any] = None):
self._type = sandbox_type
self._config = config
self._sandbox = sandbox # pre-built instance, if supplied
self._owns_sandbox = sandbox is None
self._instance_id: Optional[str] = None
# ── identity ─────────────────────────────────────────────────────────────
@property
def provider_name(self) -> str:
return self._type
@property
def is_available(self) -> bool:
if self._sandbox is not None:
return bool(getattr(self._sandbox, "is_available", True))
try:
from ..sandbox._sandbox_bridge import resolve_sandbox_class
cls = resolve_sandbox_class(self._type)
probe = cls() if self._type not in NEEDS_INSTANCE else None
return bool(getattr(probe, "is_available", False)) if probe else False
except Exception:
return False
# ── lifecycle ────────────────────────────────────────────────────────────
async def provision(self, config: Any) -> Any:
from .protocols import InstanceInfo, InstanceStatus
if self._sandbox is None:
from ..sandbox import SandboxConfig
from ..sandbox.manager import SandboxManager
cfg = self._config
if cfg is None:
# "/workspace" is the convention for *container* sandboxes and
# does not exist on a Mac or a bare Linux box -- creating it
# fails with "Read-only file system". Local backends pick their
# own private temp dir, so only pass a working_dir through when
# the caller actually chose one.
wanted_dir = getattr(config, "working_dir", None)
local_place = self._type in ("subprocess", "sandlock")
if local_place and wanted_dir in (None, "", "/workspace"):
wanted_dir = None
cfg = SandboxConfig(
sandbox_type=self._type,
env=dict(getattr(config, "env", None) or {}),
**({"working_dir": wanted_dir} if wanted_dir else {}),
)
# This sandbox exists to back the model's execute_command tool,
# which IS a shell. The default policy sets allow_subprocess=
# False, which makes `sh -c` -- and therefore every pipe,
# redirect and && the model writes -- fail as "disabled by
# security policy". Blocking the shell inside a shell tool is a
# contradiction, not a protection; blocked_commands and
# blocked_paths still apply.
if getattr(cfg, "security_policy", None) is not None:
cfg.security_policy.allow_subprocess = True
if getattr(config, "image", None):
cfg.image = config.image
self._manager = SandboxManager(cfg)
self._sandbox = await self._manager.__aenter__()
else:
self._manager = None
starter = getattr(self._sandbox, "start", None)
if starter is not None:
await starter()
self._instance_id = f"{self._type}_{uuid.uuid4().hex[:12]}"
logger.info("[sandbox_adapter] provisioned %s as %s", self._type, self._instance_id)
return InstanceInfo(
instance_id=self._instance_id,
status=InstanceStatus.RUNNING,
endpoint=f"{self._type}://local",
provider=self._type,
metadata={"adapter": True, "discoverable": False},
)
async def execute(self, instance_id: str, command: str,
timeout: Optional[int] = 300) -> Dict[str, Any]:
if self._sandbox is None:
raise RuntimeError(
f"{self._type} sandbox is not running; provision() was not called"
)
# The bridged execute_command tool hands us a shell command string, and
# models write pipes, redirects and && constantly. Without shell=True
# `echo x > f` is argv-split and "> f" arrives as a literal argument.
try:
result = await self._sandbox.run_command(command, shell=True)
except TypeError:
# Backends predating the shell= parameter.
result = await self._sandbox.run_command(command)
# A policy denial arrives as result.error with exit_code=None. Dropping
# it would turn "blocked by security policy" into a silent success --
# the caller would see exit 0 and empty output.
error = getattr(result, "error", None)
exit_code = getattr(result, "exit_code", None)
stderr = getattr(result, "stderr", "") or ""
if error:
stderr = f"{stderr}\n{error}".strip() if stderr else str(error)
if exit_code in (None, 0):
exit_code = 1
return {
"stdout": getattr(result, "stdout", "") or "",
"stderr": stderr,
"exit_code": exit_code or 0,
}
async def shutdown(self, instance_id: str) -> None:
try:
if getattr(self, "_manager", None) is not None:
await self._manager.__aexit__(None, None, None)
elif self._sandbox is not None:
stopper = getattr(self._sandbox, "stop", None)
if stopper is not None:
await stopper()
finally:
if self._owns_sandbox:
self._sandbox = None
self._manager = None
self._instance_id = None
async def get_status(self, instance_id: str) -> Any:
from .protocols import InstanceInfo, InstanceStatus
running = self._sandbox is not None and instance_id == self._instance_id
return InstanceInfo(
instance_id=instance_id,
status=InstanceStatus.RUNNING if running else InstanceStatus.STOPPED,
provider=self._type,
)
async def list_instances(self) -> List[Any]:
"""Only what this process provisioned -- see the module docstring."""
from .protocols import InstanceInfo, InstanceStatus
if self._instance_id is None:
return []
return [InstanceInfo(
instance_id=self._instance_id,
status=InstanceStatus.RUNNING,
provider=self._type,
metadata={"adapter": True, "discoverable": False},
)]
# ── file transfer ────────────────────────────────────────────────────────
async def upload_file(self, instance_id: str, local_path: str, remote_path: str) -> None:
with open(local_path, "r") as handle:
await self._sandbox.write_file(remote_path, handle.read())
async def download_file(self, instance_id: str, remote_path: str, local_path: str) -> None:
content = await self._sandbox.read_file(remote_path)
with open(local_path, "w") as handle:
handle.write(content if isinstance(content, str) else str(content))
def wrap_sandbox_instance(sandbox: Any) -> SandboxComputeAdapter:
"""Adapt an already-constructed sandbox (e.g. ``SSHSandbox(host=...)``)."""
name = getattr(sandbox, "sandbox_type", None) or type(sandbox).__name__.lower()
return SandboxComputeAdapter(str(name), sandbox=sandbox)
@@ -17,7 +17,6 @@ import logging
import shlex
import threading
import uuid
import weakref
from typing import Any, Dict, List, Optional
from ._compute_bridge import resolve_compute
@@ -119,13 +118,8 @@ class SharedCompute:
def shutdown(self) -> None:
"""Tear down the instance and restore any agents we patched."""
for ref, original_tools, original_backstory in self._patched:
agent = ref()
if agent is None:
continue # already collected; nothing left to restore
for agent, original_tools, original_backstory in self._patched:
try:
if getattr(agent, "_tools_sandbox", None) is self:
agent._tools_sandbox = None
agent.tools = original_tools
agent.backstory = original_backstory
cache = getattr(agent, "_system_prompt_cache", None)
@@ -142,8 +136,6 @@ class SharedCompute:
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)
self.instance_id = None
raise # the caller logs whether the sandbox was released
finally:
self.instance_id = None
@@ -195,14 +187,9 @@ class SharedCompute:
# 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.
# Uppercase hex on purpose. uuid4().hex is lowercase, and the
# security policy substring-matches blocked_commands against the
# whole command -- "dd" is one of them, and appears in 9.6% of
# random lowercase hex strings. One write in ten failed with
# "Blocked command pattern: dd" for no reason the caller could see.
delimiter = f"__PRAISON_EOF_{uuid.uuid4().hex.upper()}__"
delimiter = f"__PRAISON_EOF_{uuid.uuid4().hex}__"
while delimiter in content:
delimiter = f"__PRAISON_EOF_{uuid.uuid4().hex.upper()}__"
delimiter = f"__PRAISON_EOF_{uuid.uuid4().hex}__"
heredoc = (
f"mkdir -p $(dirname {quoted}) && "
f"cat > {quoted} <<'{delimiter}'\n{content}\n{delimiter}"
@@ -216,25 +203,13 @@ class SharedCompute:
"""List files in a directory on the shared remote sandbox."""
return self._format(self.run_command(f"ls -la {shlex.quote(path)}"))
bridged = [execute_command, read_file, write_file, list_files]
# Tag them so Agent.clone_for_channel() drops them: each closes over
# THIS sandbox, so a clone inheriting them would talk to the original
# agent's instance and then attach a second sandbox of its own --
# duplicating the capability prompt and splitting its work across two
# machines. The clone regenerates its own on first use.
for tool in bridged:
tool._praison_sandbox_tool = True
return bridged
return [execute_command, read_file, write_file, list_files]
def attach(self, agents: List[Any], *, override_declared: bool = False) -> None:
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.
``override_declared`` is set only by the agent's *own* placement, which
is this sandbox's whole reason for existing. A run-level sandbox leaves
it False so an agent that named its own place keeps it.
"""
tools = self.build_tools()
by_name = {t.__name__: t for t in tools}
@@ -242,31 +217,6 @@ class SharedCompute:
for agent in agents:
if agent is None or getattr(agent, "backend", None) is not None:
continue
# An agent that named its own place, or already has a sandbox, must
# not be attached again. A second attach appends CAPABILITY_PROMPT
# twice and, if the two teardowns do not happen in LIFO order,
# permanently restores the wrong tools: the agent is left holding
# four tools bound to a shut-down instance.
#
# `tools_run_on` is checked as well as `_tools_sandbox` because of
# ordering: a flow attaches at the start of the run, before the
# agent's first chat() has created its own sandbox. Checking only
# the live sandbox would attach here and let the agent attach again
# on its first turn. An explicit per-agent choice outranks the run's
# default, so the flow leaves it alone.
declared_own = (
not override_declared
and getattr(agent, "tools_run_on", None) is not None
)
if getattr(agent, "_tools_sandbox", None) is not None or declared_own:
logger.debug(
"[shared_compute] %r already runs its tools on %s; leaving it alone",
getattr(agent, "name", agent),
getattr(agent, "tools_run_on", "its own sandbox"),
)
continue
if any(agent is ref() for ref, _, _ in self._patched):
continue
original_tools = list(getattr(agent, "tools", None) or [])
# Agent.__init__ copies `instructions` into role/goal/backstory and
@@ -298,18 +248,4 @@ class SharedCompute:
logger.warning("[shared_compute] could not attach tools to %r: %s", agent, exc)
continue
# Mark where this agent's tools now run, so a nested attach (or a
# later ensure_tools_placed) can see it is already bound.
try:
agent._tools_sandbox = self
except Exception: # pragma: no cover - exotic agent objects
pass
# Weakly: an agent points at its SharedCompute and the SharedCompute
# pointed back, so a dropped agent was reclaimed as a cycle and its
# sandbox was never shut down. Holding the agent weakly lets
# weakref.finalize see the agent die and release the sandbox.
try:
ref = weakref.ref(agent)
except TypeError: # pragma: no cover - non-weakrefable agent
ref = lambda _agent=agent: _agent
self._patched.append((ref, original_tools, original_backstory))
self._patched.append((agent, original_tools, original_backstory))
@@ -11,62 +11,94 @@ using different transport methods:
Protocol Revision: 2025-11-25
"""
from .mcp import MCP
from .._lazy import create_lazy_getattr
def _t(module_path: str, attr_name: str):
"""Build a (module_path, attr_name) lazy-import target.
Using a helper instead of bare 2-string tuple literals keeps secret
scanners from misclassifying the auth/oauth import targets below as
hardcoded "Authentication Tuple" credentials (they are import paths,
not secrets).
"""
return (module_path, attr_name)
# Lazy imports for optional components
_LAZY_IMPORTS = {
"WebSocketMCPClient": _t("praisonaiagents.mcp.mcp_websocket", "WebSocketMCPClient"),
"SessionManager": _t("praisonaiagents.mcp.mcp_session", "SessionManager"),
"SecurityConfig": _t("praisonaiagents.mcp.mcp_security", "SecurityConfig"),
"BaseTransport": _t("praisonaiagents.mcp.mcp_transport", "BaseTransport"),
"TransportConfig": _t("praisonaiagents.mcp.mcp_transport", "TransportConfig"),
"ToolsMCPServer": _t("praisonaiagents.mcp.mcp_server", "ToolsMCPServer"),
"launch_tools_mcp_server": _t("praisonaiagents.mcp.mcp_server", "launch_tools_mcp_server"),
"function_to_mcp_schema": _t("praisonaiagents.mcp.mcp_utils", "function_to_mcp_schema"),
"get_tool_metadata": _t("praisonaiagents.mcp.mcp_utils", "get_tool_metadata"),
"python_type_to_json_schema": _t("praisonaiagents.mcp.mcp_utils", "python_type_to_json_schema"),
"fix_array_schemas": _t("praisonaiagents.mcp.mcp_schema_utils", "fix_array_schemas"),
"ThreadLocalEventLoop": _t("praisonaiagents.mcp.mcp_schema_utils", "ThreadLocalEventLoop"),
"get_thread_local_event_loop": _t("praisonaiagents.mcp.mcp_schema_utils", "get_thread_local_event_loop"),
# Auth storage (lazy loaded) — import targets, not credentials.
"MCPAuthStorage": _t("praisonaiagents.mcp.mcp_auth_storage", "MCPAuthStorage"),
"get_default_auth_filepath": _t("praisonaiagents.mcp.mcp_auth_storage", "get_default_auth_filepath"),
# OAuth callback utilities (lazy loaded) — import targets, not credentials.
"OAuthCallbackHandler": _t("praisonaiagents.mcp.mcp_oauth_callback", "OAuthCallbackHandler"),
"generate_state": _t("praisonaiagents.mcp.mcp_oauth_callback", "generate_state"),
"generate_code_verifier": _t("praisonaiagents.mcp.mcp_oauth_callback", "generate_code_verifier"),
"generate_code_challenge": _t("praisonaiagents.mcp.mcp_oauth_callback", "generate_code_challenge"),
"get_redirect_url": _t("praisonaiagents.mcp.mcp_oauth_callback", "get_redirect_url"),
"OAUTH_CALLBACK_PORT": _t("praisonaiagents.mcp.mcp_oauth_callback", "OAUTH_CALLBACK_PORT"),
"OAUTH_CALLBACK_PATH": _t("praisonaiagents.mcp.mcp_oauth_callback", "OAUTH_CALLBACK_PATH"),
# Zero-config OAuth: discovery, dynamic registration, orchestration.
"discover_oauth_metadata": _t("praisonaiagents.mcp.oauth_discovery", "discover_oauth_metadata"),
"parse_www_authenticate": _t("praisonaiagents.mcp.oauth_discovery", "parse_www_authenticate"),
"register_client": _t("praisonaiagents.mcp.oauth_registration", "register_client"),
"MCPOAuthProvider": _t("praisonaiagents.mcp.oauth_provider", "MCPOAuthProvider"),
"InteractiveAuthRequired": _t("praisonaiagents.mcp.oauth_provider", "InteractiveAuthRequired"),
def __getattr__(name):
"""Lazy import for optional modules."""
if name == "WebSocketMCPClient":
from .mcp_websocket import WebSocketMCPClient
return WebSocketMCPClient
elif name == "SessionManager":
from .mcp_session import SessionManager
return SessionManager
elif name == "SecurityConfig":
from .mcp_security import SecurityConfig
return SecurityConfig
elif name == "BaseTransport":
from .mcp_transport import BaseTransport
return BaseTransport
elif name == "TransportConfig":
from .mcp_transport import TransportConfig
return TransportConfig
elif name == "ToolsMCPServer":
from .mcp_server import ToolsMCPServer
return ToolsMCPServer
elif name == "launch_tools_mcp_server":
from .mcp_server import launch_tools_mcp_server
return launch_tools_mcp_server
elif name == "function_to_mcp_schema":
from .mcp_utils import function_to_mcp_schema
return function_to_mcp_schema
elif name == "get_tool_metadata":
from .mcp_utils import get_tool_metadata
return get_tool_metadata
elif name == "python_type_to_json_schema":
from .mcp_utils import python_type_to_json_schema
return python_type_to_json_schema
elif name == "fix_array_schemas":
from .mcp_schema_utils import fix_array_schemas
return fix_array_schemas
elif name == "ThreadLocalEventLoop":
from .mcp_schema_utils import ThreadLocalEventLoop
return ThreadLocalEventLoop
elif name == "get_thread_local_event_loop":
from .mcp_schema_utils import get_thread_local_event_loop
return get_thread_local_event_loop
# Auth storage (lazy loaded)
elif name == "MCPAuthStorage":
from .mcp_auth_storage import MCPAuthStorage
return MCPAuthStorage
elif name == "get_default_auth_filepath":
from .mcp_auth_storage import get_default_auth_filepath
return get_default_auth_filepath
# OAuth callback utilities (lazy loaded)
elif name == "OAuthCallbackHandler":
from .mcp_oauth_callback import OAuthCallbackHandler
return OAuthCallbackHandler
elif name == "generate_state":
from .mcp_oauth_callback import generate_state
return generate_state
elif name == "generate_code_verifier":
from .mcp_oauth_callback import generate_code_verifier
return generate_code_verifier
elif name == "generate_code_challenge":
from .mcp_oauth_callback import generate_code_challenge
return generate_code_challenge
elif name == "get_redirect_url":
from .mcp_oauth_callback import get_redirect_url
return get_redirect_url
elif name == "OAUTH_CALLBACK_PORT":
from .mcp_oauth_callback import OAUTH_CALLBACK_PORT
return OAUTH_CALLBACK_PORT
elif name == "OAUTH_CALLBACK_PATH":
from .mcp_oauth_callback import OAUTH_CALLBACK_PATH
return OAUTH_CALLBACK_PATH
# Protocols (lazy loaded)
"MCPClientProtocol": _t("praisonaiagents.mcp.protocols", "MCPClientProtocol"),
elif name == "MCPClientProtocol":
from .protocols import MCPClientProtocol
return MCPClientProtocol
# Loader (lazy loaded)
"load_mcp_tools": _t("praisonaiagents.mcp.loader", "load_mcp_tools"),
elif name == "load_mcp_tools":
from .loader import load_mcp_tools
return load_mcp_tools
# Resource/prompt normalisation helpers (lazy loaded)
"normalize_resource_result": _t("praisonaiagents.mcp.resources", "normalize_resource_result"),
"normalize_prompt_result": _t("praisonaiagents.mcp.resources", "normalize_prompt_result"),
}
__getattr__ = create_lazy_getattr(_LAZY_IMPORTS, __name__)
elif name == "normalize_resource_result":
from .resources import normalize_resource_result
return normalize_resource_result
elif name == "normalize_prompt_result":
from .resources import normalize_prompt_result
return normalize_prompt_result
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
# Client
@@ -106,10 +138,4 @@ __all__ = [
"get_redirect_url",
"OAUTH_CALLBACK_PORT",
"OAUTH_CALLBACK_PATH",
# Zero-config OAuth (lazy loaded)
"discover_oauth_metadata",
"parse_www_authenticate",
"register_client",
"MCPOAuthProvider",
"InteractiveAuthRequired",
]
@@ -428,15 +428,9 @@ class MCP:
self.is_npx = False
return
# Handle the single string format for stdio client.
# Shell-split the command string first, then decide based on how many
# tokens it yields. This makes multi-word forms like
# MCP("npx -y pkg", args=["/tmp"]) work — the first token is the
# executable and the remaining tokens are prepended to any explicitly
# supplied args. Using shlex handles all whitespace (spaces, tabs,
# newlines) and lets an executable path that contains spaces be passed
# intact by quoting it, e.g. MCP('"/opt/my app"', args=["run"]).
if isinstance(command_or_string, str):
# Handle the single string format for stdio client
if isinstance(command_or_string, str) and args is None:
# Split the string into command and args using shell-like parsing
if platform.system() == 'Windows':
# Use shlex with posix=False for Windows to handle quotes and paths with spaces
parts = shlex.split(command_or_string, posix=False)
@@ -444,19 +438,13 @@ class MCP:
parts = [part.strip('"') for part in parts]
else:
parts = shlex.split(command_or_string)
if len(parts) > 1:
# Multi-token string: first token is the executable, the rest
# are prepended to any explicitly supplied args.
cmd = parts[0]
arguments = parts[1:] + (args or [])
else:
# Single token (or a quoted spaced path): treat the whole
# string as a bare executable and keep explicit args as-is.
cmd = parts[0] if parts else command_or_string
arguments = args or []
if not parts:
raise ValueError("Empty command string")
cmd = parts[0]
arguments = parts[1:] if len(parts) > 1 else []
else:
# Use the original format with a bare command and separate args
# Use the original format with separate command and args
cmd = command_or_string
arguments = args or []
@@ -1,231 +0,0 @@
"""
MCP OAuth Metadata Discovery.
Implements the discovery half of zero-config OAuth for remote MCP servers:
- Parse a ``WWW-Authenticate: Bearer resource_metadata="..."`` challenge from a
``401`` response (RFC 9728 / MCP authorization spec).
- Fetch protected-resource metadata (``/.well-known/oauth-protected-resource``).
- Fetch authorization-server metadata
(``/.well-known/oauth-authorization-server`` with an OpenID configuration
fallback), yielding ``authorization_endpoint``, ``token_endpoint`` and
``registration_endpoint``.
Network access is HTTPS-only (except loopback) to preserve the SSRF-safe
posture used elsewhere in the MCP transports. ``requests`` is imported lazily so
this module stays additive and does not affect import time.
"""
import re
from typing import Any, Dict, List, Optional
from urllib.parse import urljoin, urlparse
from praisonaiagents._logging import get_logger
logger = get_logger("mcp-oauth-discovery")
_LOCALHOST_HOSTS = {"localhost", "127.0.0.1", "::1"}
def _require_https(url: str) -> None:
"""Reject non-HTTPS metadata URLs (loopback HTTP is allowed for testing)."""
parsed = urlparse(url)
if parsed.scheme == "https":
return
if parsed.scheme == "http" and (parsed.hostname or "") in _LOCALHOST_HOSTS:
return
raise ValueError(
f"Refusing to fetch OAuth metadata over insecure transport: {url!r}"
)
def parse_www_authenticate(header: Optional[str]) -> Dict[str, str]:
"""
Parse a ``WWW-Authenticate`` challenge header into its parameters.
Only ``Bearer`` challenges are relevant here. Returns a dict of the
``key="value"`` auth-params (e.g. ``resource_metadata``, ``scope``,
``error``). Returns an empty dict when the header is missing or not a
Bearer challenge.
"""
if not header:
return {}
# Strip the leading scheme (e.g. "Bearer ") if present.
stripped = header.strip()
if stripped.lower().startswith("bearer"):
stripped = stripped[len("bearer"):].lstrip()
params: Dict[str, str] = {}
for match in re.finditer(r'(\w+)\s*=\s*"([^"]*)"', stripped):
params[match.group(1).lower()] = match.group(2)
return params
def _get_json(url: str, timeout: float = 10.0) -> Dict[str, Any]:
"""Fetch and parse a JSON metadata document over HTTPS.
Redirects are followed but every hop — including the final destination — is
re-validated with :func:`_require_https` so a redirect cannot escape the
HTTPS/loopback guard to an internal or plaintext endpoint (SSRF-safe).
"""
_require_https(url)
import requests # lazy import — optional dependency
resp = requests.get(
url,
headers={"Accept": "application/json"},
timeout=timeout,
allow_redirects=True,
)
# Re-validate every redirect hop and the final URL: allow_redirects only
# checks the initial URL, so a 30x to http:// or an internal host would
# otherwise bypass the guard.
for previous in getattr(resp, "history", None) or []:
_require_https(previous.url)
_require_https(resp.url)
resp.raise_for_status()
return resp.json()
def _wellknown_candidates(server_url: str) -> List[str]:
"""Build ``.well-known`` metadata URL candidates for a server URL."""
parsed = urlparse(server_url)
origin = f"{parsed.scheme}://{parsed.netloc}"
return [
urljoin(origin + "/", ".well-known/oauth-protected-resource"),
urljoin(origin + "/", ".well-known/oauth-authorization-server"),
urljoin(origin + "/", ".well-known/openid-configuration"),
]
def discover_protected_resource(
server_url: str,
resource_metadata_url: Optional[str] = None,
timeout: float = 10.0,
) -> Optional[Dict[str, Any]]:
"""
Discover protected-resource metadata for an MCP server.
Args:
server_url: The MCP server URL that returned a 401.
resource_metadata_url: Optional explicit URL from the
``WWW-Authenticate: resource_metadata="..."`` challenge.
timeout: Per-request timeout in seconds.
Returns:
The protected-resource metadata document, or None if unavailable.
"""
url = resource_metadata_url or _wellknown_candidates(server_url)[0]
try:
return _get_json(url, timeout=timeout)
except Exception as exc: # noqa: BLE001 - discovery is best-effort
logger.debug(f"Protected-resource metadata unavailable at {url}: {exc}")
return None
def discover_authorization_server(
server_url: str,
authorization_servers: Optional[List[str]] = None,
timeout: float = 10.0,
) -> Optional[Dict[str, Any]]:
"""
Discover authorization-server metadata.
Tries, in order: any ``authorization_servers`` advertised by the
protected-resource metadata, then the server-origin
``/.well-known/oauth-authorization-server`` and OpenID-configuration
fallbacks.
Returns the first metadata document that exposes at least an
``authorization_endpoint`` and ``token_endpoint``, or None.
"""
candidates: List[str] = []
for issuer in authorization_servers or []:
issuer = issuer.rstrip("/")
candidates.append(issuer + "/.well-known/oauth-authorization-server")
candidates.append(issuer + "/.well-known/openid-configuration")
# Fall back to the resource server's own origin.
for candidate in _wellknown_candidates(server_url)[1:]:
candidates.append(candidate)
for url in candidates:
try:
metadata = _get_json(url, timeout=timeout)
except Exception as exc: # noqa: BLE001 - best-effort discovery
logger.debug(f"Auth-server metadata unavailable at {url}: {exc}")
continue
if metadata.get("authorization_endpoint") and metadata.get("token_endpoint"):
return metadata
return None
def discover_oauth_metadata(
server_url: str,
www_authenticate: Optional[str] = None,
timeout: float = 10.0,
) -> Optional[Dict[str, Any]]:
"""
Full metadata discovery for an OAuth-protected MCP server.
Combines the ``WWW-Authenticate`` challenge (if any), protected-resource
metadata, and authorization-server metadata into a single normalised dict:
{
"authorization_endpoint": str,
"token_endpoint": str,
"registration_endpoint": str | None,
"scopes_supported": list[str] | None,
"resource": str | None,
}
Returns None if a usable authorization/token endpoint pair cannot be found.
"""
challenge = parse_www_authenticate(www_authenticate)
resource_meta = discover_protected_resource(
server_url,
resource_metadata_url=challenge.get("resource_metadata"),
timeout=timeout,
)
authorization_servers = None
resource_id = None
if resource_meta:
authorization_servers = resource_meta.get("authorization_servers")
resource_id = resource_meta.get("resource")
auth_meta = discover_authorization_server(
server_url,
authorization_servers=authorization_servers,
timeout=timeout,
)
if not auth_meta:
return None
# Enforce the HTTPS/loopback guard on the advertised endpoints too: an
# HTTPS metadata document must not be able to smuggle a plaintext
# authorization/token/registration endpoint that would later receive
# authorization codes, PKCE verifiers, refresh tokens, or client secrets
# over an insecure transport.
authorization_endpoint = auth_meta["authorization_endpoint"]
token_endpoint = auth_meta["token_endpoint"]
registration_endpoint = auth_meta.get("registration_endpoint")
_require_https(authorization_endpoint)
_require_https(token_endpoint)
if registration_endpoint:
_require_https(registration_endpoint)
return {
"authorization_endpoint": authorization_endpoint,
"token_endpoint": token_endpoint,
"registration_endpoint": registration_endpoint,
"scopes_supported": (
auth_meta.get("scopes_supported")
or (resource_meta or {}).get("scopes_supported")
),
"resource": resource_id,
}
Loaded 100 of 554 files, more files were not shown because too many files have changed in this diff. Show more