Compare commits

...
Author SHA1 Message Date
praisonai-triage-agent[bot] e27b9d13f5 Release v4.6.163 2026-08-15 19:15:31 +00:00
MervinPraison 3c6e8afa2c docs: auto-update documentation parity trackers [skip ci] 2026-08-15 13:13:36 +00:00
praisonai-triage-agent[bot] 7d5e33e81d Merge pull request #3976 from MervinPraison/fix/revert-sandbox-tool-injection
revert(sandbox): stop auto-injecting code-execution tools from sandbox=
2026-08-15 13:12:58 +00:00
MervinPraison 546422aee3 fix(ci): sync DOCS_GITHUB_TOKEN when provisioning pipeline secrets
Optional DOCS_GITHUB_TOKEN is copied to target repos during secret sync so
PraisonAI-Tools generate-docs can push to PraisonAIDocs.
2026-08-15 13:34:30 +01:00
MervinPraison 8315e72a0c fix(ci): recognise Claude replies and unblock stale-FINAL after rebase
Align merge-gate stale-FINAL recovery with PraisonAI-Tools: accept MervinPraison
Claude finished comments and bypass the 15-minute debounce when HEAD moves after
a conflict rebase without a fresh FINAL trigger.
2026-08-15 13:33:25 +01:00
praisonai-triage-agent[bot]andMervin Praison e6f785aa82 test(sandbox): robust no-injection contract + real agentic guard
- Replace brittle exact-comment assertion with concept-based checks so
  harmless wording edits no longer break the suite (Greptile feedback).
- Add key-gated end-to-end test that runs Agent(sandbox=True).start()
  through a real LLM and asserts the model-visible tool set never gains
  execute_python_code / execute_shell_command (CodeRabbit feedback).

Co-authored-by: Mervin Praison <[email protected]>
2026-08-15 12:23:18 +00:00
MervinPraisonandClaude Opus 5 9bf8625175 revert(sandbox): stop auto-injecting code-execution tools from sandbox=
Reverts the tool-injection block added in #3975. Adversarial review found it
is a net security regression, not a fix. Everything else from that PR is kept.

Why it must go:

1. It escalated privilege. `sandbox=` is a RESTRICTION, but the injection made
   it GRANT capability -- and the granted tools bypass the approval registry,
   whose presets gate `execute_code`/`execute_command`, not the injected
   `execute_python_code`/`execute_shell_command`:

       Agent(approval="read_only")                -> tools: []
       Agent(approval="read_only", sandbox=True)  -> tools: [execute_python_code,
                                                             execute_shell_command]

   A security-labelled flag handed the model two ungated arbitrary-code-execution
   tools under the strictest approval preset.

2. It advertised isolation the backend does not provide. The default
   subprocess backend enforces none of its own SecurityPolicy. Verified on this
   machine: outbound HTTPS returned 200 with allow_network=False; sandboxed code
   read a real SSH private key despite `~/.ssh` in blocked_paths; `import
   subprocess` worked despite blocked_imports. The tool description shown to the
   model said "Execute Python code safely in sandbox."

3. Coexistence made it worse, not better. With autonomy=True the agent still
   carries the host `execute_command` (injected earlier in __init__), so the
   model can simply pick the unsandboxed tool.

Also fixes the test that let this ship: it asserted `child_pid != os.getpid()`
and called that isolation. A separate process is not a security boundary. The
suite is now contract-based -- sandbox= must not add tools, must not widen an
approval preset, and must still configure explicit execute_code().

Kept from #3975 (all genuinely correct):
- the `metadata` kwarg fix (flagged code no longer raises TypeError)
- the clone fix (`sandbox_config`, not the never-assigned `_sandbox_config`)
- the FutureWarning when sandbox= and backend= are combined
- NOT IMPLEMENTED labels on the inert autonomy/code_sandbox_mode fields

Giving the model a sandboxed execution tool should be an explicit act by the
caller, the way MCP() is -- not a side effect of a restriction flag. No peer
framework (smolagents, AutoGen, deepagents, OpenAI Agents SDK, Pydantic AI)
grants execution capability from a config flag.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 13:17:49 +01:00
MervinPraison 13ca53b8fd docs: auto-update documentation parity trackers [skip ci] 2026-08-15 12:05:29 +00:00
Mervin Praison 084daf83c3 Merge pull request #3975 from MervinPraison/fix/sandbox-security-gaps
fix(sandbox): make Agent(sandbox=...) actually isolate execution
2026-08-15 13:04:58 +01:00
praisonai-triage-agent[bot]andMervin Praison 9d6f6dc312 fix(sandbox): regenerate clone-bound execution tools; robust PID assert
Channel clones copied the framework-generated execute_python_code /
execute_shell_command wrappers, which close over the source agent. The
constructor's name-dedup then skipped creating clone-bound replacements,
so a clone's execution tools ran through the source agent's SandboxManager
— sharing isolation state across channels (CodeRabbit/Greptile P1).

- Tag generated sandbox tools with `_praison_sandbox_tool` in
  SandboxMixin._get_code_execution_tools().
- clone_for_channel() now drops tagged tools when copying, so the
  constructor regenerates clone-owned wrappers. User tools with colliding
  names lack the tag and are preserved.
- Add regression test asserting clone tool identity and SandboxManager
  differ from the source.
- Compare parsed integer PIDs (not substrings) in the out-of-process test;
  use next(...) per ruff RUF015.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-15 12:01:52 +00:00
MervinPraisonandClaude Opus 5 826c2e63ff fix(sandbox): make Agent(sandbox=...) actually isolate execution
`Agent(sandbox=...)` built a SandboxConfig that nothing ever read. The agent
looked isolated and was not -- the worst failure mode a security setting can
have. Four defects, each verified before and after:

1. sandbox= gave the model no sandboxed tools.
   `_get_code_execution_tools()` had zero non-test callers, so `sandbox=True`
   added nothing to `agent.tools`. It now attaches execute_python_code /
   execute_shell_command (skipping names the user already defined).
   Verified: code now runs in a separate PID from the caller.

   Scope note: this isolates code the MODEL writes. Python callables passed
   via `tools=` are ordinary in-process functions and cannot be retrofitted
   into a sandbox -- that limit is now stated rather than implied.

2. Sandboxed execution crashed exactly when it mattered.
   `execute_code()` injected a `metadata` kwarg that flowed into
   SandboxProtocol.execute(), which has no such parameter -- so any code
   tripping a security warning raised TypeError instead of running. Warnings
   are now attached to the result instead of forwarded.

3. Clones silently lost their sandbox.
   `clone_for_channel()` read `_sandbox_config`; SandboxMixin writes
   `sandbox_config`. The underscore name is never assigned anywhere, so every
   clone ran unisolated.

4. Two docstrings promised protection that does not exist.
   `AutonomyConfig.sandbox` claimed subprocess isolation is auto-enabled at
   level="full_auto" (implemented nowhere), and
   `ExecutionConfig.code_sandbox_mode` documents subprocess isolation while
   having zero consumers. Both are now labelled NOT IMPLEMENTED. The fields
   are kept -- removing public dataclass fields would TypeError for existing
   users -- so deletion belongs in a major version.

Also: combining sandbox= with backend= now emits a FutureWarning. The managed
backend takes the whole turn before any local execution, so sandbox= was
silently ignored with no signal.

Opt-in and non-breaking: agents without sandbox= are byte-for-byte unchanged
(asserted by test).

Adds 10 regression tests. Suite: 473 passed vs 463 on clean main (+10, no new
failures); the 2 test_param_consolidation warning-registry failures reproduce
identically on clean main with the same selection and are unrelated.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 12:51:31 +01:00
praisonai-triage-agent[bot] 908debe4f8 Merge pull request #3974 from MervinPraison/claude/issue-3973-20260815-0949
fix: harden gateway pairing store (at-rest hashing + store-level lockout)
2026-08-15 10:52:15 +00:00
praisonai-triage-agent[bot]andMervin Praison befeb72b36 fix: don't reset pairing lockout on channel_id mismatch; count rehydrated pending in max_pending
- verify_and_pair now validates the channel_id binding BEFORE clearing the
  brute-force counter, so a valid HMAC presented against the wrong channel_id
  is rejected without handing later guesses a fresh attempt budget (Greptile P1).
- generate_code enforces max_pending against _pending_by_hash (all logical
  pending entries, including ones rehydrated from disk) so repeated restarts
  cannot grow pairing.json past the configured bound (CodeRabbit).
- Add regression tests for both behaviours.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-15 10:01:51 +00:00
praisonai-triage-agent[bot]andMervinPraison 264ae6c847 fix: harden gateway pairing store with at-rest hashing and store-level lockout (fixes #3973)
Persist only a salted SHA-256 hash of each pairing code (no plaintext code
ever lands in pairing.json), add a per-channel_type brute-force lockout that
applies uniformly across HTTP/in-chat/CLI approval surfaces, and generate
codes from an unambiguous alphabet (drops 0/O/1/I). Backward-compatible:
public API unchanged, legacy plaintext stores still load.

Co-authored-by: MervinPraison <[email protected]>
2026-08-15 09:55:13 +00:00
Mervin Praison eb8aa9683b Merge pull request #3972 from MervinPraison/feat/yaml-run-on
feat(managed): support `run_on:` in workflow YAML
2026-08-15 10:38:44 +01:00
Mervin Praison 420925ac81 Merge pull request #3971 from MervinPraison/claude/issue-3966-20260815-0912
fix: unify praisonai gateway CLI with canonical command surface
2026-08-15 10:38:15 +01:00
Mervin Praison 6874ebff94 Merge pull request #3970 from MervinPraison/claude/issue-3967-20260815-0913
fix: register deferred tool results on the native tool loop
2026-08-15 10:37:43 +01:00
Mervin Praison 29cf8c4db4 Merge pull request #3969 from MervinPraison/claude/issue-3968-20260815-0913
fix: honour after_tool plugin return so tool results can be redacted
2026-08-15 10:36:51 +01:00
Mervin Praison a10592c304 Merge pull request #3965 from MervinPraison/claude/issue-3964-20260815-0851
fix: replay conversation visually when resuming a session
2026-08-15 10:36:31 +01:00
praisonai-triage-agent[bot]andMervin Praison cd27a40ad5 fix(managed): attach action-only temp agents to run_on sandbox
An action-only YAML step builds its Agent during the run, after the
initial shared-compute attach(), so its shell/file tools silently ran
locally instead of in the run_on: sandbox. Expose the live SharedCompute
on the Workflow and attach the temp agent before it chats. Adds tests.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-15 09:32:38 +00:00
praisonai-triage-agent[bot] 659290c8a2 Merge pull request #3963 from MervinPraison/claude/issue-3962-20260815-0824
fix: offload blocking auto-gen I/O, fix claude stream flag drift, fail-fast tool_timeout on workflow
2026-08-15 09:29:09 +00:00
praisonai-triage-agent[bot]andMervin Praison 8aa5bd9ae9 fix: route native deferred results to durable history so late completions survive
The native OpenAI-SDK tool loop re-injected resolved DeferredToolResults into
the per-call `messages = list(messages)` copy, which is discarded once the loop
returns. Since background jobs typically complete after the loop returns, the
resolved value never reached subsequent turns — unlike the LiteLLM path, which
appends to the persistent chat_history (llm.py:_register_deferred_if_any).

Thread an optional `deferred_history_sink` through the sync/async/streaming
native loops and _handle_native_deferred_result; the agent passes its existing
chat_history as the durable sink. Falls back to `messages` when absent, so
existing callers are unaffected. No new Agent params/modules/exports.

Adds tests for durable-sink survival past loop return, backward-compat fallback,
and early (buffered) resolution delivery.

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

Co-Authored-By: Mervin Praison <[email protected]>
2026-08-15 09:26:55 +00:00
MervinPraisonandClaude Opus 5 70e9d1daf7 feat(managed): support run_on: in workflow YAML
Closes the last functional gap in remote execution: no-code users had no
way to reach it at all. `run_on=` existed only on the Python API, so anyone
driving PraisonAI through agents.yaml was locked out of shared sandboxes.

    name: remote-demo
    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"

The provider name is validated at load time against the same registry the
Python API uses, so a typo fails when the file loads -- listing the valid
names -- instead of surfacing at the first tool call, halfway through a run.

Also updates the README's managed-agents section, which still advertised the
deprecated `ManagedAgent` factory and the old `compute=` name:
  - leads with `run_on=` (Python) and `run_on:` (YAML)
  - single-agent examples now use LocalAgent / HostedAgent, which emit no
    DeprecationWarning
  - documents `praisonai managed ps` / `stop --all`

Adds examples/yaml/workflows/remote_sandbox_workflow.yaml, executed
end-to-end against Docker: writer wrote /workspace/note.txt and reader read
'hello from step 1' back from the SAME instance (docker_72722484ef46), with
zero leftover containers.

12 new parser tests (no Docker needed). Managed suite 289 passed; the same 6
failures exist on clean main. YAML suite 168 passed, no parser regression.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 10:26:04 +01:00
praisonai-triage-agent[bot]andMervin Praison d54920c2da fix: narrow gateway click-probe exception and cover legacy Namespace dispatch
- Catch (ImportError, AttributeError) instead of broad Exception in the
  vendored typer._click probe (resolves Ruff S110/BLE001).
- Add test_namespace_caller_still_dispatches to pin that every long option
  _namespace_to_gateway_argv emits is accepted by the canonical Typer command.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-15 09:24:00 +00:00
praisonai-triage-agent[bot]andMervin Praison c2bbeacb3b fix: consume after_tool redaction/block in both tool executors (#3968)
The AFTER_TOOL bridge mutated data.tool_output and could deny, but the
sync (tool_execution.py) and async (execution_mixin.py) executors
discarded both — so redaction/blocking never reached the model. Both
paths now read back the rewritten tool_output and honour a block
decision, mirroring the BEFORE_TOOL path. Clarified GuardrailBlocked
docstring for after_tool and added real-agent execute_tool regression
tests plus a GuardrailBlocked bridge test.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-15 09:23:49 +00:00
praisonai-triage-agent[bot]andMervinPraison 19b9215116 fix: unify praisonai gateway CLI with canonical command surface (fixes #3966)
Route the public `praisonai gateway <cmd>` entry point to the single
canonical Typer gateway command app instead of a reduced argparse handler
that only wired start/status/hooks and dead-ended on everything else.

Now `praisonai gateway` exposes the same surface as `praisonai-bot gateway`
(doctor, test, channels, stop, restart, pause/resume/reconnect,
install/uninstall, mint-link, logs, send, hooks, sessions) with one
`start` implementation carrying the credential/tool preflight,
config-version migration and `--openai-api`/`--mcp` flags. The gateway's
own degraded-state retry hints (`praisonai gateway doctor`/`--fix`/`test`)
now name commands the operator can actually run.

Deletes the duplicate reduced argparse start/status/hooks parser and
dispatch; unknown commands and bad flags now degrade gracefully to a
non-zero exit code with a rendered message rather than a static dead-end.

Co-authored-by: MervinPraison <[email protected]>
2026-08-15 09:19:01 +00:00
praisonai-triage-agent[bot]andMervinPraison dbbddbb968 fix: register deferred tool results on the native tool loop (fixes #3967)
The native OpenAI-SDK tool loop in llm/openai_client.py called
execute_tool_fn directly and stringified a returned DeferredToolResult,
losing the handle so its eventual background value was never re-injected —
unlike the LiteLLM path (_register_deferred_if_any). Add a small shared
helper that registers the handle on the global deferred resolver and
surfaces the note to the model, wired into the sync, async and streaming
native tool-result blocks. No new Agent params or exports.

Co-authored-by: MervinPraison <[email protected]>
2026-08-15 09:18:00 +00:00
praisonai-triage-agent[bot]andMervinPraison 79a0941098 fix: honour after_tool plugin return so tool results can be redacted (fixes #3968)
The after_tool hook bridge discarded the plugin's return value, so a plugin
could only observe tool results, never rewrite or redact them — contradicting
the documented "Can modify result" contract and leaving no post-tool guardrail
path for scrubbing secrets/PII. Mirror the after_llm/before_tool bridges: write
the returned value back onto data.tool_output and honour deny/HookResult and
GuardrailBlocked decisions for symmetry.

Co-authored-by: MervinPraison <[email protected]>
2026-08-15 09:16:21 +00:00
praisonai-triage-agent[bot]andMervin Praison f8a486a389 fix: harden session replay — clear stale display, fix omission count, render fallback turns
Addresses reviewer findings on PR #3965 (wrapper-only, praisonai-code):
- /continue now clears self.messages before replay so repeated resumes (or
  resuming after activity) don't duplicate or retain stale on-screen turns.
- /continue loads the full stored history instead of the default 50-message
  tail so the "… N earlier turns" note reflects the true total.
- _run_simple fallback renders restored user/assistant turns (not only system
  lines) so replayed conversations are visible without prompt_toolkit.
- Type-guard tool_calls handling in both renderers.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-15 09:02:53 +00:00
praisonai-triage-agent[bot]andMervinPraison 6d954316a4 fix: replay conversation visually when resuming a session (fixes #3964)
Redraw restored user/assistant turns (and tool activity) via the same live
renderer on interactive resume instead of only printing a one-line summary.
Bounded to the last N turns with an "… earlier turns" note.

Co-authored-by: MervinPraison <[email protected]>
2026-08-15 08:53:58 +00:00
praisonai-triage-agent[bot]andMervin Praison 236e4b09ba fix: restore _resolve_effective_tool_timeout delegation on workflow path (fixes #3251 regression)
The #3251 regression guard asserts _build_yaml_workflow resolves tool_timeout
via _resolve_effective_tool_timeout. The prior explicit-timeout helper bypassed
it, breaking the guard. Delegate per-agent/role resolution back to the shared
_resolve_effective_tool_timeout so both the sequential/hierarchical and workflow
paths use one source of truth (and its tightest-value warnings still fire),
while still ignoring the injected bare CLI --tool-timeout default.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-15 08:43:36 +00:00
praisonai-triage-agent[bot]andMervin Praison 6eeb906487 fix: only reject explicit workflow tool_timeout, not implicit CLI default
The legacy CLI always injects its --tool-timeout argparse default (60) into
cli_config, so the new workflow fail-fast rejected every ordinary workflow YAML
run (Greptile P1). Distinguish an explicit user request (per-agent/role YAML
timeout or a changed CLI value) from the bare CLI default via
_resolve_explicit_workflow_tool_timeout. Adds regression tests.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-15 08:35:03 +00:00
praisonai-triage-agent[bot]andMervinPraison 221aa9f242 fix: offload blocking auto-gen I/O, fix claude stream flag drift, fail-fast tool_timeout on workflow (fixes #3962)
- auto.py: wrap blocking convert_and_save/_save_workflow in asyncio.to_thread
  across AutoGenerator/WorkflowAutoGenerator/JobWorkflowAutoGenerator so
  agenerate() no longer parks the event loop on merge/yaml-dump/fsync.
- claude_code.py: pop caller output_format before forcing stream-json in
  stream(), matching cursor/gemini siblings (fixes TypeError drift).
- agents_generator.py: fail fast on tool_timeout for process: workflow YAMLs
  instead of warn-only no-op, mirroring _validate_workflow_cli_capabilities.

Co-authored-by: MervinPraison <[email protected]>
2026-08-15 08:28:11 +00:00
MervinPraison 631cc7e9cd docs: auto-update documentation parity trackers [skip ci] 2026-08-15 08:06:21 +00:00
praisonai-triage-agent[bot] 3f23a3aeff Merge pull request #3960 from MervinPraison/claude/issue-3959-20260815-0709
fix: align async sequential process, recycle poisoned tool executor, fix doom-loop no-progress check
2026-08-15 08:05:42 +00:00
Mervin Praison 67ce4c9813 Merge pull request #3954 from dajiaohuang/fix/3930-headless-code
fix(code): restore headless coding agent execution
2026-08-15 08:59:33 +01:00
MervinPraison f55bb3feb3 docs: auto-update api.md [skip ci] 2026-08-15 07:58:44 +00:00
Mervin Praison d2389cd77a Merge pull request #3961 from MervinPraison/feat/managed-ps
feat(managed): add `praisonai managed ps`/`stop`, rename compute= to run_on=
2026-08-15 08:58:18 +01:00
praisonai-triage-agent[bot]andMervin Praison 70e6deb467 fix(suite_runner): deterministic latest-report tie-break on equal mtime
The CI-flaky test_finds_latest_by_mtime creates two run dirs back-to-back;
when their mtimes collide (coarse fs resolution), a stable sort left the
winner up to arbitrary iterdir() order, so the older-named dir could win.
Break ties by name so timestamp-named dirs stay deterministic.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-15 07:35:23 +00:00
praisonai-triage-agent[bot]andMervin Praison 788d17e66a fix(managed): surface provider inventory failures in ps/stop; fix docstring
- _discover_instances now returns (instances, errors): an available provider
  whose list_instances() fails is no longer silently reported as clean success,
  so `ps`/`stop --all` never lie about an empty inventory while sandboxes run.
- Explicit `--provider <name>` for an uninstalled/unknown provider now surfaces
  an error instead of a silent empty result. Uninstalled/unconfigured providers
  in the default scan remain silently skipped (a missing E2B key is normal).
- `ps --json` now returns {"sandboxes": [...], "errors": [...]} and exits non-zero
  when any provider could not be queried.
- Corrected ManagedAgent factory docstring: compute-provider names warn and
  delegate to LocalManagedAgent; they do NOT raise ValueError.
- Tests updated for new contract + added coverage for listing failures and
  explicit unknown-provider errors.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-15 07:32:06 +00:00
MervinPraisonandClaude Opus 5 c4516e7dbe feat(managed): add praisonai managed ps/stop, rename compute= to run_on=
Three things, all stemming from reviewing the shared-sandbox feature (#3949).

1. Sandbox visibility (the piece left open in #3949)

   praisonai managed ps          # list running sandboxes
   praisonai managed ps --json   # machine-readable
   praisonai managed stop <id>   # reclaim one
   praisonai managed stop --all  # reclaim everything

   Sandboxes normally self-destruct when a run ends, but a crashed or killed
   run leaves one behind; there was no way to see or reclaim it.

2. Cross-process instance discovery (bugs found while building the above)

   DockerCompute tracked instances only in an in-process dict, so:
   - list_instances() always returned nothing from a fresh process -- exactly
     when you need it, i.e. finding strays left by a previous run.
   - _shutdown_sync() popped from that dict and, finding nothing, silently
     did nothing while reporting success. `managed stop` claimed "Stopped"
     and left the container running.
   - get_status() reported STOPPED for any live container it didn't start.

   All three now fall back to Docker itself (the `praisonai=managed` label and
   the `praisonai_<id>` name), and shutdown raises rather than pretending to
   succeed when the container cannot be found.

3. Naming: `compute=` -> `run_on=`

   Researched how comparable tools name this. `executor` is the most-attested
   name industry-wide (smolagents, ADK, AutoGen, Airflow, Nextflow, Dagster,
   ...), but it is already used as a concept inside workflows.py. `backend`,
   `runtime` and `sandbox` are all taken as public Agent params, and `provider`
   is used ~147 times for LLM/vector-store providers.

   `compute` as a noun is cloud-industry jargon (Wiktionary labels the noun
   sense "chiefly cloud computing"), which works against the goal of a name a
   non-developer can read. `run_on` is plain English, has mass-audience
   precedent in GitHub Actions' `runs-on:`, and has zero collisions in this
   codebase.

       AgentFlow(run_on="docker", steps=[writer, reader])
       AgentTeam(agents=[w, r], run_on="docker")

   `compute` stays as the INTERNAL vocabulary (ComputeConfig,
   ComputeProviderProtocol, SharedCompute, _compute_bridge) where the
   industry term is the right one. No alias is added: `compute=` merged in
   #3949 but is NOT in any published release (PyPI 1.6.166 predates it), so
   this rename breaks nobody and avoids a second param.

4. Doc fix: the ManagedAgent factory docstring claimed compute-provider names
   raise ValueError; they actually emit a DeprecationWarning and delegate to
   LocalManagedAgent. Corrected to match the code.

Verified against local Docker: `run_on="docker"` write-then-read hit one
shared instance; `managed ps` found a real stray container with correct
uptime; `managed stop --all` genuinely removed it (0 containers after).
16 new CLI tests + 14 core tests, none requiring Docker.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 08:25:14 +01:00
praisonai-triage-agent[bot]andMervin Praison f20a15280a fix: address reviewer P1s — async cascade at consumption, doom-loop window boundary, bounded tool-executor recycling
- agents.py: re-check dependency failure at consumption time in the async
  sequential path. asequential() checks deps inside the generator, but
  async_execution tasks are buffered and drained later, so an upstream async
  task may not be failed yet when a dependent is yielded. Re-checking after
  flushing pending async tasks makes the failure cascade fire for the async
  path too.
- doom_loop.py: count progress markers against the boundary *before* the
  no-progress window (the preceding action's completion timestamp) so a marker
  recorded during the window's first action — timestamped before that action's
  record_action() — is no longer wrongly filtered out.
- tool_execution.py: bound orphaned tool executors. Recycling on timeout keeps
  throughput, but repeated timeouts could leak unbounded blocked threads. Cap
  recycles at _MAX_ORPHANED_TOOL_EXECUTORS, then fall back to reusing the
  bounded pool so process resources can't be exhausted.
- Add regression tests for the doom-loop window boundary edge.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-15 07:24:50 +00:00
praisonai-triage-agent[bot]andMervin Praison 6367ae8f6f fix(code): contain headless search tools to workspace
grep/glob/ast_grep_search were loaded raw from praisonaiagents, so their
containment defaulted to os.getcwd() (grep/glob) or was absent
(ast_grep_search). In headless `code -p --workspace <dir>` (no chdir) a
model-supplied path could read outside the selected workspace. Bind each
search tool's path to the configured workspace (fail-closed), mirroring the
edit/acp/lsp groups, and add a credential-free regression test.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-15 07:23:43 +00:00
Mervin Praison 7bbd2ebdc6 Merge pull request #3958 from dajiaohuang/fix/3934-responses-vision-input
fix(llm): translate multimodal Responses API input
2026-08-15 08:21:38 +01:00
Mervin Praison 90fce937a5 Merge pull request #3957 from dajiaohuang/fix/3933-token-collector-default
fix(llm): collect token usage with quiet output
2026-08-15 08:20:58 +01:00
Mervin Praison f9a629d14b Merge pull request #3956 from dajiaohuang/fix/3932-harbor-budget
fix(harbor): configure coding budget and verifier flow
2026-08-15 08:19:57 +01:00
Mervin Praison 05b7361f93 Merge pull request #3955 from dajiaohuang/fix/3931-acp-tools
fix(acp): wire workspace tools and capability gates
2026-08-15 08:19:27 +01:00
Mervin Praison d3ba6cc250 Merge pull request #3936 from dajiaohuang/fix/async-state-deepcopy-overlap
fix: coordinate async state access with deepcopy
2026-08-15 08:17:11 +01:00
Mervin Praison 69032526d9 Merge pull request #3935 from dajiaohuang/test/stream-follow-up-completion
test: verify streamed tool follow-up completion
2026-08-15 08:16:01 +01:00
MervinPraison 04f698587d docs: auto-update api.md [skip ci] 2026-08-15 07:15:30 +00:00
Mervin Praison edab0deaa7 Merge pull request #3944 from MervinPraison/claude/issue-3939-20260814-1431
fix: unify duplicate TaskOutput classes so agent guardrails work
2026-08-15 08:15:01 +01:00
MervinPraison 17f9d9e048 docs: auto-update feature parity trackers [skip ci] 2026-08-15 07:14:49 +00:00
praisonai-triage-agent[bot]andMervinPraison 7c291587f1 fix: align async sequential, recycle tool executor, fix doom-loop no-progress (fixes #3959)
- asequential(): port failed-task skip + dependency-cascade check from
  sequential() so async runs short-circuit tasks with failed upstream deps.
- tool_execution: retire the tool ThreadPoolExecutor on timeout so hung
  threads no longer permanently poison the 2-worker pool.
- doom_loop: store (marker, timestamp) and only count progress markers
  within the current no-progress window, so NO_PROGRESS detection is no
  longer permanently disabled after the first successful tool call.

Co-authored-by: MervinPraison <[email protected]>
2026-08-15 07:14:22 +00:00
Mervin Praison 5afe4a94e5 Merge pull request #3943 from MervinPraison/claude/issue-3940-20260814-1431
fix: MCP splits multi-word command string when args supplied
2026-08-15 08:14:21 +01:00
dajiaohuang d82555bc21 fix(llm): propagate local image validation 2026-08-15 03:51:22 +08:00
dajiaohuang 8370d0109f fix(code): contain headless tools to workspace 2026-08-15 03:51:20 +08:00
dajiaohuang 11926a2839 fix(harbor): include iteration budget metadata 2026-08-15 03:49:37 +08:00
dajiaohuang 1809f85206 fix(llm): preserve image detail setting 2026-08-15 03:49:25 +08:00
dajiaohuang 4d6b9a15bb fix(llm): cover all non-stream usage paths 2026-08-15 03:49:22 +08:00
dajiaohuang 8cd0ccfbf9 fix(code): harden headless lifecycle 2026-08-15 03:49:18 +08:00
praisonai-triage-agent[bot]andMervin Praison 9c1d8e4f90 fix(harbor): use backend-aware last_stop_reason for stop diagnostics
Replaces the fragile private _get_stop_reason helper (which checked the
wrong _llm_instance attr and missed the native OpenAI backend) with the
public backend-aware Agent.last_stop_reason property, so slash-less
OpenAI model names no longer record a null stop_reason.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-14 19:48:13 +00:00
dajiaohuang 36d4952169 fix(llm): translate multimodal responses input 2026-08-15 03:41:55 +08:00
dajiaohuang 13667392b3 fix(llm): collect token usage with quiet output 2026-08-15 03:41:55 +08:00
dajiaohuang a90c0817a1 fix(acp): wire workspace tools and capability gates 2026-08-15 03:41:54 +08:00
dajiaohuang b53dc46452 fix(harbor): configure coding budget and verifier flow 2026-08-15 03:41:54 +08:00
praisonai-triage-agent[bot]andMervin Praison 76c58b96e7 fix: remove misleading PydanticUserError special-case in guardrail path
The root cause (unresolved TaskOutput forward reference) is fixed at source
in output/models.py via model_rebuild(), so PydanticUserError no longer
occurs in the guardrail path. The re-raise in _process_guardrail was also
swallowed by the 15 downstream chat handlers that return None, so it never
achieved its stated propagation contract (Greptile P1). Removing it keeps
guardrail error handling simple and consistent without touching hot paths.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-14 14:40:20 +00:00
praisonai-triage-agent[bot]andMervin Praison e410647483 fix: robust MCP stdio command parsing for spaced paths and whitespace
Run shlex.split first and decide by token count. Fixes Greptile P1
(spaced executable paths + args no longer mis-split; quote the path as
an escape hatch) and P2 (tabs/newlines now handled, not just spaces).

Co-authored-by: Mervin Praison <[email protected]>
2026-08-14 14:38:47 +00:00
praisonai-triage-agent[bot]andMervinPraison bca50850d1 fix: unify duplicate TaskOutput classes so agent guardrails work (fixes #3939)
Passing a callable to Agent(guardrails=...) returned None because two
different classes named TaskOutput existed and the guardrail path mixed them.

- output/models.py: import TokenMetrics at runtime and call
  TaskOutput.model_rebuild() so the model can actually be instantiated
  (previously the forward ref was TYPE_CHECKING-only and never resolved).
- main.py: re-export the single canonical TaskOutput from output.models so
  main.TaskOutput and output.models.TaskOutput are the same class.
- agent.py: let PydanticUserError propagate from _process_guardrail instead of
  swallowing it into a validation failure that silently nulls the response.

Co-authored-by: MervinPraison <[email protected]>
2026-08-14 14:34:15 +00:00
praisonai-triage-agent[bot]andMervinPraison 817942a4ab fix: MCP splits multi-word command string when args supplied (fixes #3940)
Previously MCP("npx -y pkg", args=[...]) passed the whole string as the
executable, so the subprocess spawn silently failed and 0 tools were
discovered. Now the command string is split on whitespace regardless of
whether args are supplied, using the first token as the executable and
prepending the remaining tokens to any explicit args.

Also updates examples/python/mcp/filesystem-mcp.py to use the reliable
executable + args form.

Co-authored-by: MervinPraison <[email protected]>
2026-08-14 14:33:12 +00:00
dajiaohuang cc82b8a462 test: cover async entry during state cloning 2026-08-14 20:55:52 +08:00
dajiaohuang 03e7cb8bed test: document real stream dispatch coverage 2026-08-14 20:51:00 +08:00
dajiaohuang 395835c7a0 test: cover direct async state context 2026-08-14 20:51:00 +08:00
dajiaohuang 7af79419bf test: require streamed follow-up completion 2026-08-14 20:51:00 +08:00
dajiaohuang 338001d31f fix: guard state cloning during async access 2026-08-14 20:51:00 +08:00
98 changed files with 3598 additions and 539 deletions

No files matched your search

+42 -2
View File
@@ -74,7 +74,11 @@ function isClaudeTriggerNoise(c) {
function isClaudeFinalReplyComment(c) {
const login = (c.user?.login || '').toLowerCase();
if (!login.includes('praisonai-triage')) return false;
const fromAutomation =
login.includes('praisonai-triage') ||
login.includes('github-actions') ||
AUTO_ACTORS.some((a) => a.toLowerCase() === login);
if (!fromAutomation) return false;
return (c.body || '').includes('Claude finished');
}
@@ -96,15 +100,47 @@ function isConflictRebaseTriggerComment(c) {
function isConflictRebaseCompletionComment(c) {
const login = (c.user?.login || '').toLowerCase();
if (!login.includes('praisonai-triage') && !login.includes('github-actions')) return false;
const fromAutomation =
login.includes('praisonai-triage') ||
login.includes('github-actions') ||
AUTO_ACTORS.some((a) => a.toLowerCase() === login);
if (!fromAutomation) 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;
@@ -245,6 +281,9 @@ 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,
@@ -916,6 +955,7 @@ module.exports = {
hasRecentConflictComment,
isConflictRebaseTriggerComment,
isConflictRebaseCompletionComment,
needsFinalReReviewAfterConflictRebase,
conflictRebaseQuiescent,
hasHumanChangesRequested,
hasAnyChangesRequested,
@@ -24,6 +24,7 @@ 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
@@ -34,4 +35,7 @@ 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"
+44 -10
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?* | `backend=ManagedAgent(compute="e2b")` — remote sandbox, or a fully hosted loop |
| **⬡ Managed** | *Where does it actually run?* | `run_on="docker"` — one shared remote sandbox, or a fully hosted loop |
### Layer 1 · Prompt — *Did I say it clearly?*
@@ -236,22 +236,56 @@ The harness is commoditising; **where** the agent executes is the next multiplie
pip install praisonai
```
The simplest way in is `run_on=` — one whole team or workflow shares **one** sandbox, so a file written by step 1 is there for step 2:
```python
from praisonai import Agent, ManagedAgent, LocalManagedConfig
from praisonaiagents import Agent, AgentFlow
writer = Agent(name="Writer", instructions="You write files.")
reader = Agent(name="Reader", instructions="You read files.")
flow = AgentFlow(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
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, pick the axis you need — remote **tools** or a remote **loop**:
```python
from praisonai import Agent, LocalAgent, LocalAgentConfig, HostedAgent
# 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)
agent = Agent(name="builder", backend=LocalAgent(
compute="e2b", # or modal | daytona | flyio | docker | tenki
config=LocalAgentConfig(model="gpt-4o-mini", name="RemoteTools"),
))
# 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())
# B. The entire agent loop runs in the cloud (needs ANTHROPIC_API_KEY)
agent = Agent(name="teacher", backend=HostedAgent(provider="anthropic"))
agent.start("Write a Python script that prints the first 10 primes, then run it")
```
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>"`
+3 -3
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, TaskOutput, ToolResult, ToolValidationError, WorkflowContext
from praisonaiagents import ContextPolicy, GuardrailResult, HandoffConfig, HandoffCycleError, HandoffDepthError, HandoffError, HandoffInputData, HandoffResult, HandoffTimeoutError, ReflectionOutput, StepResult, ToolResult, ToolValidationError, WorkflowContext
```
Methods:
@@ -16,8 +16,6 @@ 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>
@@ -890,11 +888,13 @@ 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>
+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.6.162" \
"praisonai>=4.6.163" \
"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.6.162" \
"praisonai>=4.6.163" \
"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.6.162" \
"praisonai>=4.6.163" \
"praisonai[ui]" \
"praisonai[crewai]"
+2 -2
View File
@@ -7,12 +7,12 @@ allowed_dirs = [
"/path/to/other/allowed/dir"
]
# Use a single string command with allowed directories
# Pass the executable as the first token and the allowed directories as args
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 -y @modelcontextprotocol/server-filesystem", args=allowed_dirs)
tools=MCP("npx", args=["-y", "@modelcontextprotocol/server-filesystem", *allowed_dirs])
)
filesystem_agent.start("List files in the allowed directories")
+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:
- hello-world
- fix-permissions
- csv-to-parquet
- terminal-bench/build-pmars
- terminal-bench/bn-fit-modify
- terminal-bench/break-filter-js-from-html
agents:
- import_path: examples.terminal_bench.praisonai_code_agent:PraisonAICodeAgent
@@ -81,17 +81,19 @@ class PraisonAICodeAgent(BaseInstalledAgent):
model = self.model_name or "openai/gpt-4o-mini"
command = (
"log_file=$(mktemp /tmp/praisonai_code.XXXXXX); "
"chmod 600 \"$log_file\"; "
f"praisonai code -p --output json {shlex.quote(instruction)} "
f"--dangerously-skip-approval "
f"--model {shlex.quote(model)} "
"> /tmp/praisonai_code.log 2>&1; "
"> \"$log_file\" 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.
"cp /tmp/praisonai_code.log /tmp/praisonai_code_run.log 2>/dev/null || true; "
"cat /tmp/praisonai_code.log; "
"cat \"$log_file\"; "
"rm -f \"$log_file\"; "
# `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.
@@ -128,7 +130,6 @@ class PraisonAICodeAgent(BaseInstalledAgent):
"agent_type": "code-cli",
"agent_name": self.name(),
"model": self.model_name,
"log_path": "/tmp/praisonai_code.log",
"session_id": envelope.get("session_id") if envelope else None,
"status": envelope.get("status") if envelope else "unknown",
}
@@ -34,6 +34,7 @@ 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"
@@ -41,6 +42,13 @@ 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."""
@@ -142,26 +150,14 @@ class PraisonAIExternalAgent(BaseAgent):
),
tools=[bash_tool],
llm=self.model_name or "openai/gpt-4o-mini",
execution=TB_EXECUTION,
)
# Execute the agent with outer loop to handle premature stopping
# Execute one deliberately long coding turn. Harbor's hidden verifier
# is the only completion authority; model or pytest text is not.
print(f"🚀 PraisonAI Agent starting task: {instruction[:100]}...")
result = await agent.achat(instruction)
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")
print("✅ PraisonAI Agent completed task")
# Populate Harbor context with metadata
self._populate_context(agent, context, result)
@@ -203,6 +199,10 @@ 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")
@@ -0,0 +1,39 @@
"""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",
]
@@ -0,0 +1,40 @@
# Run a whole workflow's shell/file tools inside ONE shared remote sandbox.
#
# praisonai examples/yaml/workflows/remote_sandbox_workflow.yaml
#
# `run_on:` is the only line that differs from a normal local workflow. Every
# step shares the same sandbox and /workspace, so the file the writer creates
# is there for the reader. Orchestration still runs on your machine, and the
# sandbox is torn down when the run finishes.
#
# Providers: docker | e2b | modal | daytona | flyio | tenki | local
# - docker needs a local Docker daemon and nothing else
# - e2b / modal / daytona / flyio need that provider's API key
#
# See what is still running: praisonai managed ps
# Reclaim strays: praisonai managed stop --all
name: remote-sandbox-demo
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"
+15 -15
View File
@@ -18,16 +18,16 @@ This report compares **Python SDK feature categories** against **Python document
| Category | Features | Docs | Lines |
|----------|----------|------|-------|
| ✅ AGUI | 1 | 2 | 420 |
| ✅ Agent | 22 | 50 | 15809 |
| ✅ Agent | 22 | 50 | 15813 |
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2996 |
| ✅ Agent-to-User (A2U) | 1 | 3 | 689 |
| ✅ Approval | 1 | 5 | 2537 |
| ✅ Audio | 2 | 12 | 1000 |
| ✅ Auto Generation | 5 | 10 | 3049 |
| ✅ Autonomy | 3 | 7 | 2744 |
| ✅ Bots | 7 | 31 | 13314 |
| ✅ Autonomy | 3 | 7 | 2752 |
| ✅ Bots | 7 | 31 | 13366 |
| ✅ Budget | 1 | 1 | 287 |
| ✅ CLI | 5 | 121 | 36021 |
| ✅ CLI | 5 | 121 | 36048 |
| ✅ Chunking | 2 | 2 | 385 |
| ✅ Citations | 2 | 1 | 202 |
| ✅ Code Execution | 2 | 14 | 4583 |
@@ -41,17 +41,17 @@ This report compares **Python SDK feature categories** against **Python document
| ✅ Events | 1 | 2 | 749 |
| ✅ Execution | 3 | 4 | 1562 |
| ✅ Failover | 2 | 1 | 432 |
| ✅ Files | 2 | 7 | 2273 |
| ✅ Files | 2 | 7 | 2275 |
| ✅ Flow | 1 | 3 | 788 |
| ✅ Gateway | 7 | 72 | 26391 |
| ✅ Guardrails | 4 | 4 | 2017 |
| ✅ Gateway | 7 | 72 | 26395 |
| ✅ Guardrails | 4 | 4 | 2025 |
| ✅ Handoffs | 11 | 6 | 2563 |
| ✅ Hooks | 2 | 9 | 4317 |
| ✅ Hooks | 2 | 9 | 4434 |
| ✅ Image | 1 | 11 | 1255 |
| ✅ Knowledge | 4 | 13 | 4333 |
| ✅ LLM | 3 | 15 | 5192 |
| ✅ Loops | 4 | 5 | 1422 |
| ✅ MCP | 1 | 61 | 13847 |
| ✅ MCP | 1 | 61 | 13993 |
| ✅ Memory | 6 | 18 | 7070 |
| ✅ OCR | 2 | 1 | 237 |
| ✅ Observability | 2 | 23 | 2814 |
@@ -59,18 +59,18 @@ This report compares **Python SDK feature categories** against **Python document
| ✅ Output | 3 | 5 | 1201 |
| ✅ Parallel Execution | 3 | 2 | 497 |
| ✅ Planning | 6 | 6 | 1549 |
| ✅ Plugins | 8 | 4 | 2850 |
| ✅ Plugins | 8 | 4 | 2858 |
| ✅ Prompts | 2 | 9 | 1965 |
| ✅ Providers | 1 | 56 | 7735 |
| ✅ Providers | 1 | 56 | 7743 |
| ✅ Query | 1 | 2 | 736 |
| ✅ RAG | 5 | 15 | 3003 |
| ✅ Realtime | 2 | 5 | 668 |
| ✅ Reflection | 3 | 3 | 680 |
| ✅ Retrieval | 2 | 5 | 1054 |
| ✅ Routing | 1 | 2 | 430 |
| ✅ Sandbox | 5 | 6 | 2918 |
| ✅ Security | 1 | 3 | 3206 |
| ✅ Sessions | 4 | 17 | 6963 |
| ✅ Sandbox | 5 | 6 | 2982 |
| ✅ Security | 1 | 3 | 3208 |
| ✅ Sessions | 4 | 18 | 7158 |
| ✅ Skills | 6 | 15 | 5638 |
| ✅ Tasks | 2 | 6 | 2819 |
| ✅ Telemetry | 1 | 2 | 604 |
@@ -81,7 +81,7 @@ This report compares **Python SDK feature categories** against **Python document
| ✅ Video | 2 | 6 | 649 |
| ✅ Vision | 2 | 1 | 329 |
| ✅ Web | 3 | 9 | 2467 |
| ✅ Workflows | 5 | 16 | 6589 |
| ✅ Workflows | 5 | 16 | 6593 |
## Documentation Without Features
@@ -2447,6 +2447,40 @@ Your Goal: {self.goal}
# Sandbox configuration - initialize SandboxMixin
super().__init__(sandbox=sandbox)
# NOTE: `sandbox=` deliberately does NOT add code-execution tools.
#
# An earlier version auto-attached execute_python_code /
# execute_shell_command here. That was reverted: `sandbox=` is a
# RESTRICTION, and it must never grant a capability the caller did not
# ask for. Concretely, the injection
# - escalated privilege: Agent(approval="read_only") exposes no tools,
# but Agent(approval="read_only", sandbox=True) exposed two ungated
# arbitrary-code-execution tools, since the injected names are not in
# the approval registry's dangerous-tool presets; and
# - advertised them to the model as "safely in sandbox" while the
# default subprocess backend enforces none of its own SecurityPolicy
# (network reachable despite allow_network=False; ~/.ssh readable
# despite blocked_paths; `import subprocess` works despite
# blocked_imports).
#
# `sandbox=` still configures isolation for the explicit, user-invoked
# agent.execute_code() / run_shell_command() APIs, which is what it has
# always meant. Giving the model a sandboxed execution tool should be an
# explicit act by the caller, the way MCP() is.
# A managed backend takes over the whole turn before any local execution,
# so a sandbox set alongside it silently does nothing. Say so.
if self.sandbox_config is not None and getattr(self, "backend", None) is not None:
import warnings
warnings.warn(
"Agent(sandbox=..., backend=...): the managed backend handles the "
"entire turn, so the sandbox= setting is ignored. Configure "
"isolation on the backend instead (e.g. LocalAgent(compute='docker')).",
FutureWarning,
stacklevel=2,
)
@staticmethod
def _resolve_tool_config(tool_config):
"""Resolve tool_config parameter with backward compatibility."""
@@ -2555,8 +2589,15 @@ Your Goal: {self.goal}
'api_key': getattr(self, 'api_key', None),
'auth': getattr(self, 'auth', None),
# Shallow copy tools to avoid deepcopy issues with nested objects
'tools': list(self.tools) if self.tools else None,
# Shallow copy tools to avoid deepcopy issues with nested objects.
# Drop framework-generated sandbox tools: they close over the source
# agent, so the constructor must regenerate clone-bound replacements
# (see the sandbox wiring below). User tools with colliding names
# lack the tag and are preserved.
'tools': (
[t for t in self.tools if not getattr(t, "_praison_sandbox_tool", False)]
if self.tools else None
),
# Skip handoffs entirely - they shouldn't be shared across channels
# and can contain nested Agent instances that cause RLock issues
@@ -2607,7 +2648,10 @@ Your Goal: {self.goal}
'interrupt_controller': None, # Let new instance create its own
# Sandbox config
'sandbox': getattr(self, '_sandbox_config', None),
# SandboxMixin stores this as `sandbox_config`; reading the
# underscore name (never assigned) made every clone silently drop
# its sandbox and run unisolated.
'sandbox': getattr(self, 'sandbox_config', None),
}
# Handle deprecated parameters for backward compatibility
@@ -5853,7 +5897,7 @@ Answer:"""
# Convert the result to a GuardrailResult
return GuardrailResult.from_tuple(result)
except Exception as e:
logging.error(f"Agent {self.name}: Error in guardrail validation: {e}")
# On error, return failure
@@ -110,14 +110,32 @@ 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 the protected value while replacing all lock state."""
"""Copy protected state, rejecting overlap with asynchronous access."""
result = type(self).__new__(type(self))
memo[id(self)] = result
with self.lock():
result.value = copy.deepcopy(self.value, memo)
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
result._lock = DualLock()
result._activity_lock = threading.Lock()
result._async_holders = 0
result._copying = False
return result
@contextmanager
@@ -130,7 +148,25 @@ class AsyncSafeState:
async def async_lock(self):
"""Acquire lock in async context."""
async with self._lock.async_lock():
yield self.value
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
def __enter__(self):
"""Support for synchronous context manager protocol (backward compatibility)."""
@@ -146,12 +182,20 @@ 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()
async_lock.release()
try:
self._end_async_access()
finally:
async_lock.release()
return None
def get(self) -> Any:
@@ -106,8 +106,11 @@ 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 for execution isolation.
When level="full_auto" and sandbox is None, subprocess sandbox is auto-enabled.
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").
snapshot_dir: Directory for snapshot storage. Defaults to ~/.praisonai/snapshots.
Usage::
@@ -2371,7 +2371,11 @@ Your Goal: {self.goal}"""
task_name=task_name,
task_description=task_description,
task_id=task_id,
agent_name=getattr(self, 'name', 'assistant')
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),
)
return final_response
except ValueError as e:
@@ -2427,6 +2431,8 @@ 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
@@ -1456,6 +1456,18 @@ 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):
@@ -79,19 +79,30 @@ class SandboxMixin:
)
# Security pre-check if enabled
security_warnings = None
if check_security:
from ..sandbox import check_code_safety, format_warnings
warnings = check_code_safety(code, language)
if warnings:
warning_text = format_warnings(warnings)
security_warnings = check_code_safety(code, language)
if security_warnings:
warning_text = format_warnings(security_warnings)
if self.verbose:
logger.warning(f"Security warnings for code execution:\n{warning_text}")
# 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)
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
def execute_code_sync(
self,
@@ -230,5 +241,15 @@ class SandboxMixin:
return result.stdout or result.output
else:
return f"Error: {result.error or result.stderr}"
return [execute_python_code, execute_shell_command]
# 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
@@ -22,6 +22,13 @@ 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
@@ -787,7 +794,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'):
if not hasattr(self, '_tool_executor') or self._tool_executor is None:
self._tool_executor = concurrent.futures.ThreadPoolExecutor(
max_workers=2, thread_name_prefix=f"tool-{self.name}"
)
@@ -801,6 +808,18 @@ class ToolExecutionMixin:
future.cancel()
logging.warning(f"Tool {function_name} timed out after {tool_timeout}s")
result = {"error": f"Tool timed out after {tool_timeout}s", "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
else:
with tool_progress_channel(_progress_sink), with_injection_context(state):
result = self._execute_tool_with_circuit_breaker(function_name, arguments)
@@ -1130,6 +1149,20 @@ 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
@@ -660,13 +660,12 @@ class AgentTeam(SpawnAnnounceProtocol):
reflection: Optional[Any] = None, # Union[bool, ReflectionConfig] - self-reflection
caching: Optional[Any] = None, # Union[bool, CachingConfig] - caching
learn: Optional[Any] = None, # Union[bool, LearnConfig] - continuous learning
# Union[str, ComputeProviderProtocol] - run every agent's shell/file
# tools in ONE shared remote sandbox ("docker", "e2b", "modal",
# "daytona", "flyio", "tenki", "local"). Agents share /workspace, so
# files written by one agent are visible to the others. Orchestration
# stays local. Pass a configured provider instance instead of a name to
# customise image/resources.
compute: Optional[Any] = None,
# Union[str, ComputeProviderProtocol] - where every agent's shell/file
# tools run: "docker", "e2b", "modal", "daytona", "flyio", "tenki",
# "local". The team shares ONE sandbox and /workspace, so files written
# by one agent are visible to the others. Orchestration stays local.
# Pass a configured provider instance to customise resources.
run_on: Optional[Any] = None,
):
"""
Initialize AgentManager with consolidated feature parameters.
@@ -898,7 +897,7 @@ class AgentTeam(SpawnAnnounceProtocol):
self.stream = _stream
self.name = name
# Remote execution: one shared sandbox for every agent on this team.
self.compute = compute
self.run_on = run_on
# Callbacks for workflow execution
self.on_task_start = _on_task_start
@@ -1464,7 +1463,25 @@ class AgentTeam(SpawnAnnounceProtocol):
if async_tasks_to_run:
await self._gather_with_isolation(async_tasks_to_run)
async_tasks_to_run = []
def _deps_failed(task_id):
"""Re-check dependency failure at consumption time.
asequential() runs this 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.
"""
task = self.tasks[task_id]
if getattr(task, 'context', None):
return any(
self.tasks[dep.id].status == "failed"
for dep in task.context
if hasattr(dep, 'id') and dep.id in self.tasks
)
return False
async for task_id in process.asequential():
if self.tasks[task_id].async_execution:
# Collect async tasks to run in parallel
@@ -1472,6 +1489,11 @@ class AgentTeam(SpawnAnnounceProtocol):
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 _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
@@ -1763,18 +1785,18 @@ class AgentTeam(SpawnAnnounceProtocol):
"""
# Remote execution: provision ONE sandbox shared by every agent on the
# team, and tear it down even if execution raises. Re-enters start()
# with compute cleared so the wrapper applies exactly once.
if getattr(self, "compute", None) is not None:
# with run_on cleared so the wrapper applies exactly once.
if getattr(self, "run_on", None) is not None:
from ..managed.shared_compute import SharedCompute
compute, self.compute = self.compute, None
run_on, self.run_on = self.run_on, None
try:
with SharedCompute(compute) as shared:
with SharedCompute(run_on) as shared:
shared.attach(list(self.agents or []))
return self.start(content=content, return_dict=return_dict,
output=output, **kwargs)
finally:
self.compute = compute
self.run_on = run_on
# Track execution via telemetry
if hasattr(self, '_telemetry') and self._telemetry:
@@ -847,9 +847,11 @@ 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"
# NOT IMPLEMENTED - no execution path reads this field, so setting it
# provides NO isolation. Do not rely on it for safety. Use
# Agent(sandbox=...) for local isolation, or AgentFlow(run_on="docker")
# to run a whole workflow in a remote sandbox.
code_sandbox_mode: str = "sandbox" # "sandbox" or "direct" (inert)
# Code-execution-with-tools (code mode): when True, model-generated code may
# call the agent's registered tools directly via injected proxies, enabling
@@ -6,7 +6,7 @@ Provides recovery strategies when loops are detected.
"""
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Tuple
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[str] = []
self._progress_markers: List[Tuple[str, float]] = []
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)
self._progress_markers.append((marker, time.time()))
def is_doom_loop(self) -> bool:
"""
@@ -379,10 +379,19 @@ class DoomLoopDetector:
if len(self._actions) < self.config.max_no_progress_steps:
return False
# If we have progress markers, we're making progress
# 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
recent_markers = [
m for m in self._progress_markers
if True # Could add timestamp filtering
m for (m, ts) in self._progress_markers
if ts >= boundary
]
if recent_markers:
return False
+71 -27
View File
@@ -1356,7 +1356,10 @@ Respond with ONLY a valid JSON tool call in this format:
The completion response from litellm
"""
import litellm
return self._call_with_retry(litellm.completion, **completion_params)
response = self._call_with_retry(litellm.completion, **completion_params)
if not completion_params.get("stream"):
self._track_token_usage(response, self.model)
return response
async def _acompletion_with_retry(self, **completion_params):
"""Execute litellm.acompletion with automatic retry on rate limit errors.
@@ -1371,7 +1374,13 @@ Respond with ONLY a valid JSON tool call in this format:
The completion response from litellm
"""
import litellm
return await self._call_with_retry_async(litellm.acompletion, **completion_params)
response = await self._call_with_retry_async(
litellm.acompletion,
**completion_params,
)
if not completion_params.get("stream"):
self._track_token_usage(response, self.model)
return response
def _supports_web_search(self) -> bool:
"""
@@ -2646,7 +2655,11 @@ 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)
# Track token usage
# 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)
if self.metrics and hasattr(resp, 'usage') and resp.usage:
usage = resp.usage
tokens_in = getattr(usage, 'input_tokens', 0) or 0
@@ -2869,10 +2882,6 @@ 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
@@ -3086,9 +3095,6 @@ 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:
@@ -3278,9 +3284,6 @@ 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:
@@ -5414,27 +5417,66 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
litellm.callbacks.append(event)
self._registered_callbacks = list(events)
def _track_token_usage(self, response: Dict[str, Any], model: str) -> Optional[TokenMetrics]:
def _track_token_usage(self, response: 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:
usage = response.get("usage", {})
if isinstance(response, dict):
usage = response.get("usage", {})
else:
usage = getattr(response, "usage", None)
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.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)
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",
),
)
# Store metrics
@@ -5942,7 +5984,9 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
Returns a ``ResponsesAPIResponse`` object.
"""
import litellm
return await self._call_with_retry_async(litellm.aresponses, **params)
response = await self._call_with_retry_async(litellm.aresponses, **params)
self._track_token_usage(response, self.model)
return response
def _extract_from_responses_output(self, response) -> tuple:
"""
@@ -6,7 +6,9 @@ supporting both synchronous and asynchronous operations.
"""
import os
import base64
import logging
import mimetypes
from praisonaiagents._logging import get_logger
import time
import json
@@ -16,6 +18,7 @@ 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
@@ -34,6 +37,54 @@ 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
@@ -675,7 +726,11 @@ class OpenAIClient:
"output": msg.get("content", ""),
})
else:
input_items.append(msg)
item = dict(msg)
item["content"] = self._build_responses_content(
msg.get("content", "")
)
input_items.append(item)
if instructions:
params["instructions"] = instructions
@@ -709,6 +764,65 @@ 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
@@ -890,6 +1004,8 @@ 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
@@ -1157,6 +1273,8 @@ 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
@@ -1352,6 +1470,8 @@ 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
@@ -1411,6 +1531,8 @@ 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
@@ -1487,6 +1609,11 @@ 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)()
@@ -1723,6 +1850,12 @@ 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):
@@ -1812,6 +1945,8 @@ 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)()
@@ -2032,6 +2167,12 @@ 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):
@@ -2104,6 +2245,8 @@ 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)
@@ -2241,6 +2384,12 @@ 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
+5 -33
View File
@@ -742,36 +742,8 @@ class ReflectionOutput(BaseModel):
reflection: str
satisfactory: Literal["yes", "no"]
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
# 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
@@ -428,9 +428,15 @@ class MCP:
self.is_npx = False
return
# 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
# 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):
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)
@@ -438,13 +444,19 @@ class MCP:
parts = [part.strip('"') for part in parts]
else:
parts = shlex.split(command_or_string)
if not parts:
raise ValueError("Empty command string")
cmd = parts[0]
arguments = parts[1:] if len(parts) > 1 else []
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 []
else:
# Use the original format with separate command and args
# Use the original format with a bare command and separate args
cmd = command_or_string
arguments = args or []
@@ -6,11 +6,16 @@ It is kept separate from main.py to avoid importing rich at module level.
"""
import json
from typing import Optional, Dict, Any, Literal, TYPE_CHECKING
from typing import Optional, Dict, Any, Literal
from pydantic import BaseModel, ConfigDict
if TYPE_CHECKING:
# Import token metrics at runtime so the forward reference in TaskOutput can be
# resolved by model_rebuild() below. Importing only under TYPE_CHECKING left the
# model perpetually "not fully defined", making any instantiation raise.
try:
from ..telemetry.token_collector import TokenMetrics
except ImportError:
TokenMetrics = None
class TaskOutput(BaseModel):
@@ -52,3 +57,8 @@ class ReflectionOutput(BaseModel):
"""Output model for self-reflection results."""
reflection: str
satisfactory: Literal["yes", "no"]
# Resolve the 'TokenMetrics' forward reference now that it is available at
# runtime, so TaskOutput can actually be instantiated.
TaskOutput.model_rebuild()
@@ -828,7 +828,16 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
if _overrides("after_tool"):
def after_tool_hook(data, _p=plugin):
_p.after_tool(getattr(data, "tool_name", ""), getattr(data, "tool_output", None))
try:
new_result = _p.after_tool(getattr(data, "tool_name", ""),
getattr(data, "tool_output", None))
except GuardrailBlocked as e:
return HookResult.block(e.reason)
decision = _as_decision(new_result)
if decision is not None:
return decision
if new_result is not None and hasattr(data, "tool_output"):
data.tool_output = new_result
return HookResult.allow()
yield HookEvent.AFTER_TOOL, after_tool_hook
@@ -26,9 +26,12 @@ class GuardrailBlocked(Exception):
A ``POLICY``/``GUARDRAIL`` plugin can raise this from any ``before_*``
method (``before_tool``, ``before_llm``, ``before_agent``,
``before_message``) to stop the tool call / LLM request / agent run /
inbound message. The plugin bridge converts it into a denying
``HookResult`` so the runtime's existing ``is_blocked`` enforcement skips
the action and surfaces ``reason``.
inbound message. It may also be raised from ``after_tool`` to block
*propagation* of a tool result (e.g. secret/PII detected) — the tool has
already run, but the output is suppressed before it reaches the model.
The plugin bridge converts it into a denying ``HookResult`` so the
runtime's existing ``is_blocked`` enforcement skips the action and
surfaces ``reason``.
"""
def __init__(self, reason: str = "Blocked by guardrail plugin"):
@@ -177,7 +180,13 @@ class Plugin(ABC):
return args
def after_tool(self, tool_name: str, result: Any) -> Any:
"""Called after tool execution. Can modify result."""
"""Called after tool execution.
Return a modified ``result`` (rewrite/redact) to replace the effective
tool output before it reaches the model or a channel, a deny/block
decision (``PluginDecision.deny(reason)`` / a ``HookResult`` / raise
:class:`GuardrailBlocked`), or ``None`` for no-op.
"""
return result
def before_tool_definitions(
@@ -956,7 +956,19 @@ Workflow Finished: {self.workflow_finished} # ADDED: Workflow Finished Status
async def asequential(self) -> AsyncGenerator[str, None]:
"""Async version of sequential method"""
for task_id in self.tasks:
if self.tasks[task_id].status != "completed":
task = self.tasks[task_id]
if task.status == "failed":
continue # Skip failed tasks
# Check if any context dependency failed
if hasattr(task, 'context') and task.context:
deps_failed = any(
self.tasks[dep.id].status == "failed"
for dep in task.context if hasattr(dep, 'id') and dep.id in self.tasks
)
if deps_failed:
task.status = "failed"
continue
if task.status != "completed":
yield task_id
async def ahierarchical(self) -> AsyncGenerator[str, None]:
@@ -631,12 +631,12 @@ class AgentFlow:
# ============================================================
# REMOTE EXECUTION
# ============================================================
# Union[str, ComputeProviderProtocol] - run every step's shell/file tools in
# ONE shared remote sandbox ("docker", "e2b", "modal", "daytona", "flyio",
# "tenki", "local"). Agents share /workspace, so a file written by one step
# is visible to later steps. Orchestration stays local. Pass a configured
# provider instance instead of a name to customise image/resources.
compute: Optional[Any] = None
# Union[str, ComputeProviderProtocol] - where each step's shell/file tools
# run: "docker", "e2b", "modal", "daytona", "flyio", "tenki", "local".
# Every step shares ONE sandbox and /workspace, so a file written by one
# step is visible to later steps. Orchestration stays local. Pass a
# configured provider instance instead of a name to customise resources.
run_on: Optional[Any] = None
# ============================================================
# ROBUSTNESS PARAMS (debugging & audit trail)
@@ -1115,20 +1115,27 @@ class AgentFlow:
"separate Workflow instance per concurrent run."
)
try:
if self.compute is None:
if self.run_on is None:
return self._run_impl(input, llm, verbose, stream)
# One sandbox for the whole run; torn down even if a step raises.
with self._shared_compute() as shared:
shared.attach(self._collect_agents())
return self._run_impl(input, llm, verbose, stream)
# Expose the live sandbox so agents created *during* the run
# (e.g. an action-only step's temporary agent) can be attached
# too, instead of silently executing tools locally.
self._active_shared_compute = shared
try:
return self._run_impl(input, llm, verbose, stream)
finally:
self._active_shared_compute = None
finally:
self._execution_lock.release()
def _shared_compute(self):
"""Build the SharedCompute for this run (see the ``compute=`` field)."""
"""Build the SharedCompute for this run (see the ``run_on=`` field)."""
from ..managed.shared_compute import SharedCompute
return SharedCompute(self.compute)
return SharedCompute(self.run_on)
def _collect_agents(self) -> List[Any]:
"""Walk steps (including nested route/parallel/loop/repeat/if) for Agents.
@@ -1516,6 +1523,13 @@ class AgentFlow:
caching=self.caching, # Propagate caching config
hooks=self.hooks, # Propagate hooks/callbacks
)
# An action-only step builds its agent here, after the
# run's initial attach(). Bind it to the shared sandbox
# too, so its shell/file tools land in the same instance
# as every other step instead of running locally.
shared = getattr(self, "_active_shared_compute", None)
if shared is not None:
shared.attach([temp_agent])
# Substitute variables in action
action = step.action
action = _substitute_action_variables(action, all_variables, previous_output, input)
@@ -347,7 +347,25 @@ class YAMLWorkflowParser:
# Parse history flag for execution tracing (robustness feature)
history_enabled = data.get('history', False)
# Remote execution: `run_on: docker` gives every step's shell/file tools
# one shared sandbox. Validated here so a typo fails at load time with
# the list of valid names, rather than at first tool call.
run_on = data.get('run_on')
if run_on is not None:
if not isinstance(run_on, str):
raise ValueError(
f"'run_on' must be a provider name string, got {type(run_on).__name__}"
)
from ..managed._compute_bridge import available_providers
if run_on.lower().strip() not in available_providers():
raise ValueError(
f"Unknown run_on provider {run_on!r}. "
f"Available: {', '.join(available_providers())}"
)
run_on = run_on.lower().strip()
workflow = Workflow(
name=name,
steps=steps,
@@ -360,6 +378,7 @@ class YAMLWorkflowParser:
memory=memory_value, # Pass memory config to Workflow
context=context_value, # Pass context management config to Workflow
history=history_enabled, # Enable execution history tracking (robustness)
run_on=run_on, # Shared remote sandbox for every step (None = local)
)
# Store additional attributes for feature parity with agents.yaml
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.6.166"
version = "1.6.167"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -0,0 +1,32 @@
"""Opt-in real-provider coverage for default token collection."""
import os
import pytest
@pytest.mark.network
@pytest.mark.skipif(
os.environ.get("RUN_REAL_KEY_TESTS") != "1"
or not os.environ.get("OPENAI_API_KEY"),
reason="Set RUN_REAL_KEY_TESTS=1 and OPENAI_API_KEY for the real agentic test",
)
def test_default_agent_start_records_real_provider_usage():
from praisonaiagents import Agent
from praisonaiagents.telemetry.token_collector import get_token_collector
collector = get_token_collector()
collector.reset()
result = Agent(
name="token-collector-real-smoke",
instructions="Reply with the single word OK.",
llm="gpt-4o-mini",
).start("Say OK")
summary = collector.get_session_summary()
print(result)
print(summary)
assert result
assert summary["total_interactions"] >= 1
assert summary["total_metrics"]["input_tokens"] > 0
assert summary["total_metrics"]["output_tokens"] > 0
@@ -12,8 +12,8 @@ Required environment variables:
- OPENAI_API_KEY: OpenAI API key
"""
import os
import base64
import sys
import time
import pytest
# Note: Only real API tests are gated by RUN_REAL_KEY_TESTS.
@@ -230,6 +230,30 @@ class TestResponsesAPIRealAgent:
assert len(response) > 100, f"Response too short ({len(response)} chars)"
print(f"[PASS] gpt-4o-mini - {len(response)} chars")
def test_bare_model_accepts_local_image_attachment(self, tmp_path):
"""Exercise Agent.start() through OpenAIClient with a local image."""
from praisonaiagents import Agent
image_path = tmp_path / "pixel.png"
image_path.write_bytes(base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8"
"/x8AAusB9WlV9ZkAAAAASUVORK5CYII="
))
agent = Agent(
name="LocalImageAgent",
instructions="Answer image questions briefly.",
llm="gpt-4o-mini",
)
response = agent.start(
"Confirm that you can inspect the attached image.",
attachments=[str(image_path)],
stream=False,
)
assert response
print(response)
def test_responses_api_with_tool(self):
"""
Test that Responses API correctly handles tool calls — the key
@@ -251,7 +275,7 @@ class TestResponsesAPIRealAgent:
assert response is not None
assert "42" in response, f"Expected '42' in response, got: {response[:200]}"
print(f"[PASS] Tool call via Responses API (Path P1) - response contains '42'")
print("[PASS] Tool call via Responses API (Path P1) - response contains '42'")
def test_openai_client_path_with_tool(self):
"""
@@ -275,7 +299,7 @@ class TestResponsesAPIRealAgent:
assert response is not None
assert "42" in response, f"Expected '42' in response, got: {response[:200]}"
print(f"[PASS] Tool call via OpenAIClient Responses API (Path P2) - response contains '42'")
print("[PASS] Tool call via OpenAIClient Responses API (Path P2) - response contains '42'")
# ── OpenAIClient-specific unit tests ───────────────────────────────────
@@ -321,6 +345,71 @@ class TestOpenAIClientResponsesAPI:
# Tools should be in Responses API format (flattened)
assert params["tools"] == [{"type": "function", "name": "add", "description": "Add", "parameters": {}}]
def test_build_responses_input_maps_chat_multimodal_parts(self):
from praisonaiagents.llm.openai_client import OpenAIClient
client = OpenAIClient.__new__(OpenAIClient)
messages = [{
"role": "user",
"content": [
{"type": "text", "text": "Describe this image"},
{
"type": "image_url",
"image_url": {
"url": "https://example.com/image.png",
"detail": "high",
},
},
],
}]
params = client._build_responses_input(messages, "gpt-4o-mini")
assert params["input"] == [{
"role": "user",
"content": [
{"type": "input_text", "text": "Describe this image"},
{
"type": "input_image",
"image_url": "https://example.com/image.png",
"detail": "high",
},
],
}]
def test_build_responses_input_encodes_local_image(self, tmp_path):
from praisonaiagents.llm.openai_client import OpenAIClient
image_path = tmp_path / "pixel.png"
image_path.write_bytes(b"\x89PNG\r\n\x1a\n")
client = OpenAIClient.__new__(OpenAIClient)
params = client._build_responses_input([{
"role": "user",
"content": [{
"type": "image_url",
"image_url": {"url": str(image_path)},
}],
}], "gpt-4o-mini")
image_url = params["input"][0]["content"][0]["image_url"]
assert image_url == "data:image/png;base64,iVBORw0KGgo="
def test_build_responses_input_rejects_missing_local_image(self, tmp_path):
from praisonaiagents.llm.openai_client import OpenAIClient
client = OpenAIClient.__new__(OpenAIClient)
missing_path = tmp_path / "missing.png"
with pytest.raises(FileNotFoundError, match="Local image file does not exist"):
client._build_responses_input([{
"role": "user",
"content": [{
"type": "image_url",
"image_url": {"url": str(missing_path)},
}],
}], "gpt-4o-mini")
def test_responses_to_chat_completion(self):
from praisonaiagents.llm.openai_client import OpenAIClient
@@ -1,4 +1,8 @@
"""Opt-in real-provider coverage for streamed tool dispatch."""
"""Opt-in real-provider coverage for streamed tool dispatch.
Run with ``RUN_REAL_KEY_TESTS=1`` and ``OPENAI_API_KEY`` set to exercise the
provider-backed follow-up completion path.
"""
import os
@@ -26,7 +30,9 @@ def test_real_agent_dispatches_tool_while_streaming():
llm="gpt-4o-mini",
instructions=(
"Always call stream_lookup exactly once before answering. "
"Include the tool's complete result in the final answer."
"After the tool call, reply with the exact marker "
"STREAM-FOLLOW-UP-COMPLETE and include the tool's complete "
"result in the final answer."
),
tools=[stream_lookup],
)
@@ -39,4 +45,5 @@ def test_real_agent_dispatches_tool_while_streaming():
print(result)
assert calls == ["NEBULA-7"]
assert "STREAM-FOLLOW-UP-COMPLETE" in result
assert "stream-result:NEBULA-7" in result
@@ -0,0 +1,72 @@
"""Action-only steps must also join the shared sandbox.
A workflow step that carries an `action` but no pre-defined `agent` builds a
temporary Agent *during* the run -- after the initial attach(). Without the fix
that temp agent runs its shell/file tools locally, silently bypassing the
`run_on:` sandbox. No Docker required: a fake compute records what it patches.
"""
from praisonaiagents.workflows.workflows import Workflow
from praisonaiagents.task.task import Task
class _RecordingShared:
"""Stand-in for SharedCompute that records every agent it attaches."""
def __init__(self):
self.attached = []
def attach(self, agents):
self.attached.extend(a for a in agents if a is not None)
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def _build_workflow(monkeypatch):
"""A one-step, action-only workflow wired to a recording shared compute."""
shared = _RecordingShared()
wf = Workflow(
name="temp-agent-run-on",
steps=[Task(name="s1", action="write a file to /workspace")],
run_on="docker",
)
monkeypatch.setattr(wf, "_shared_compute", lambda: shared)
return wf, shared
def test_action_only_temp_agent_is_attached(monkeypatch):
wf, shared = _build_workflow(monkeypatch)
captured = {}
def fake_chat(self, action, **kwargs):
# By the time the temp agent chats, it must already be attached.
captured["names"] = [getattr(a, "name", None) for a in shared.attached]
return "done"
from praisonaiagents.agent.agent import Agent
monkeypatch.setattr(Agent, "chat", fake_chat)
result = wf.run(input="go")
assert result["output"] == "done"
# The temp agent created for the action-only step reached shared.attach()
# before its chat() ran -- i.e. it joined the sandbox, not run locally.
assert captured.get("names"), "temp agent was never attached to shared compute"
def test_active_shared_compute_cleared_after_run(monkeypatch):
wf, _shared = _build_workflow(monkeypatch)
from praisonaiagents.agent.agent import Agent
monkeypatch.setattr(Agent, "chat", lambda self, action, **kw: "done")
wf.run(input="go")
# The live-sandbox handle must not leak past the run.
assert getattr(wf, "_active_shared_compute", None) is None
@@ -237,9 +237,9 @@ def test_collect_agents_walks_nested_steps_and_dedupes():
assert [x.name for x in collected] == ["A", "B"], "each agent exactly once, in order"
def test_flow_without_compute_does_not_provision():
def test_flow_without_run_on_does_not_provision():
from praisonaiagents import AgentFlow
flow = AgentFlow(steps=[FakeAgent("A")])
assert flow.compute is None
assert flow.run_on is None
assert flow._collect_agents()[0].tools == []
@@ -0,0 +1,63 @@
"""`run_on:` support in workflow YAML.
Gives no-code users the same shared-sandbox execution the Python API has.
No Docker required: parsing and validation only.
"""
import pytest
from praisonaiagents.workflows.yaml_parser import YAMLWorkflowParser
BASE = """
name: {name}
{extra}agents:
writer:
role: Writer
goal: write things
steps:
- agent: writer
action: "write a file"
"""
def _parse(extra="", name="wf"):
return YAMLWorkflowParser().parse_string(BASE.format(name=name, extra=extra))
def test_run_on_is_parsed():
assert _parse("run_on: docker\n").run_on == "docker"
def test_run_on_defaults_to_none():
"""Omitting run_on must leave the workflow running locally, as before."""
assert _parse().run_on is None
def test_run_on_is_case_and_space_insensitive():
assert _parse("run_on: DOCKER\n").run_on == "docker"
assert _parse('run_on: " docker "\n').run_on == "docker"
@pytest.mark.parametrize("provider", ["docker", "e2b", "modal", "daytona", "flyio", "local"])
def test_all_known_providers_accepted(provider):
assert _parse(f"run_on: {provider}\n").run_on == provider
def test_unknown_provider_fails_at_load_time():
"""A typo must fail when the file loads, not at the first tool call."""
with pytest.raises(ValueError) as exc:
_parse("run_on: dokcer\n")
assert "dokcer" in str(exc.value)
assert "Available:" in str(exc.value), "error should list valid names"
def test_non_string_run_on_rejected():
with pytest.raises(ValueError, match="must be a provider name string"):
_parse("run_on: 123\n")
def test_run_on_reaches_the_workflow_object():
"""The parsed value must land on the field AgentFlow.run() actually reads."""
wf = _parse("run_on: docker\n")
assert "run_on" in getattr(type(wf), "__dataclass_fields__", {})
assert wf.run_on == "docker"
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Native-path deferred-tool-result parity tests (Issue #3967).
The LiteLLM loop already registers a ``DeferredToolResult`` on the shared
resolver so its eventual background value is re-injected. The native
OpenAI-SDK loop (``llm/openai_client.py``) previously stringified the handle
and lost it. These tests assert the native path now behaves identically:
a deferred handle is registered, the ``note`` is surfaced to the model now,
and a later ``resolve_deferred`` re-injects a follow-up tool message.
"""
from praisonaiagents.llm.openai_client import _handle_native_deferred_result
from praisonaiagents.tools.call_executor import (
defer,
get_deferred_resolver,
resolve_deferred,
)
def test_plain_result_is_unchanged_and_not_registered():
messages = []
resolver = get_deferred_resolver()
out = _handle_native_deferred_result("plain-value", messages, "cid-1", "tool_a")
assert out == "plain-value"
assert messages == []
assert resolver.is_pending("cid-1") is False
def test_deferred_result_surfaces_note_and_registers_handle():
messages = []
resolver = get_deferred_resolver()
d = defer(note="queued job 99", handle_id="native-job-99")
out = _handle_native_deferred_result(d, messages, "cid-2", "tool_b")
# Note surfaced to the model now instead of the raw handle object.
assert out == "queued job 99"
# Handle registered so the background result is not lost.
assert resolver.is_pending("native-job-99") is True
# Later background completion re-injects a follow-up tool message into the
# same messages history the loop replays on the next turn.
assert resolve_deferred("native-job-99", "final report") is True
assert messages == [
{
"role": "tool",
"tool_call_id": "cid-2",
"content": "[deferred:tool_b] final report",
}
]
assert resolver.is_pending("native-job-99") is False
def test_registration_never_raises_on_bad_input():
# A malformed deferred-like object must not break the turn; helper returns
# the note attribute it can read and swallows any registration error.
class _Broken:
handle_id = None
note = "still-runs"
# Not a DeferredToolResult instance -> returned unchanged (safe default).
out = _handle_native_deferred_result(_Broken(), [], "cid-3", "tool_c")
assert isinstance(out, _Broken)
def test_late_result_lands_in_durable_sink_not_discarded_copy():
# The real bug (Issue #3967 / reviewer P1): the native loop runs on a
# per-call ``messages`` copy that is discarded once it returns. A background
# job usually completes AFTER that, so the re-injection must target the
# caller's durable history (e.g. the agent's ``chat_history``) — the list a
# subsequent turn replays — not the transient copy.
transient_loop_messages = [] # discarded after the loop returns
durable_history = [] # replayed on the next turn
d = defer(note="queued job 100", handle_id="native-job-100")
out = _handle_native_deferred_result(
d, transient_loop_messages, "cid-4", "tool_d",
history_sink=durable_history,
)
assert out == "queued job 100"
# Simulate the loop finishing and its copy being discarded, then the
# background job completing later.
transient_loop_messages.clear()
assert resolve_deferred("native-job-100", "late report") is True
# Result survives in durable history; the discarded copy is untouched.
assert transient_loop_messages == []
assert durable_history == [
{
"role": "tool",
"tool_call_id": "cid-4",
"content": "[deferred:tool_d] late report",
}
]
def test_no_sink_falls_back_to_messages_for_backward_compat():
# Absent a durable sink, prior behaviour is preserved: an in-loop resolution
# re-injects into ``messages`` so existing callers are unaffected.
messages = []
d = defer(note="queued job 101", handle_id="native-job-101")
_handle_native_deferred_result(d, messages, "cid-5", "tool_e")
assert resolve_deferred("native-job-101", "in-loop report") is True
assert messages == [
{
"role": "tool",
"tool_call_id": "cid-5",
"content": "[deferred:tool_e] in-loop report",
}
]
def test_early_resolution_delivers_to_durable_sink_immediately():
# If the background job finishes before the loop registers (early
# resolution), the buffered value is delivered on registration and must
# still land in the durable sink.
durable_history = []
# Register-then-resolve is exercised above; here resolve first (buffered),
# then register to confirm immediate delivery into the durable sink.
assert resolve_deferred("native-job-102", "buffered report") is False
d = defer(note="queued job 102", handle_id="native-job-102")
_handle_native_deferred_result(
d, [], "cid-6", "tool_f", history_sink=durable_history,
)
assert durable_history == [
{
"role": "tool",
"tool_call_id": "cid-6",
"content": "[deferred:tool_f] buffered report",
}
]
if __name__ == "__main__":
import sys
import pytest
sys.exit(pytest.main([__file__, "-v"]))
@@ -11,6 +11,20 @@ import asyncio
import copy
import threading
import pytest
class _BlockingDeepcopyValue:
def __init__(self, entered, release):
self.entered = entered
self.release = release
def __deepcopy__(self, memo):
self.entered.set()
if not self.release.wait(timeout=5):
raise TimeoutError("timed out waiting to finish deepcopy")
return "copied"
class TestAgentDeepCopy:
"""Test Agent.__deepcopy__ and clone_for_channel()."""
@@ -104,3 +118,82 @@ class TestAgentDeepCopy:
assert cloned._approvals_lock is not agent._approvals_lock
assert agent._approvals_lock.locked() is True
assert cloned._approvals_lock.locked() is False
@pytest.mark.parametrize(
"use_async_lock_helper",
[True, False],
ids=["async-lock-helper", "async-context-manager"],
)
def test_deepcopy_rejects_active_async_state_access(
self,
use_async_lock_helper,
):
from praisonaiagents.agent.async_safety import AsyncSafeState
async def _exercise():
state = AsyncSafeState(["stable"])
entered = asyncio.Event()
release = asyncio.Event()
async def _hold_state():
context = state.async_lock() if use_async_lock_helper else state
async with context:
entered.set()
await release.wait()
holder = asyncio.create_task(_hold_state())
await entered.wait()
try:
with pytest.raises(
RuntimeError,
match="during asynchronous access",
):
copy.deepcopy(state)
finally:
release.set()
await holder
cloned = copy.deepcopy(state)
assert cloned.value == ["stable"]
asyncio.run(_exercise())
@pytest.mark.parametrize(
"use_async_lock_helper",
[True, False],
ids=["async-lock-helper", "async-context-manager"],
)
def test_async_state_access_rejects_active_deepcopy(
self,
use_async_lock_helper,
):
from praisonaiagents.agent.async_safety import AsyncSafeState
async def _exercise():
copy_entered = threading.Event()
copy_release = threading.Event()
state = AsyncSafeState(
_BlockingDeepcopyValue(copy_entered, copy_release)
)
copy_task = asyncio.create_task(asyncio.to_thread(copy.deepcopy, state))
assert await asyncio.to_thread(copy_entered.wait, 5)
try:
context = state.async_lock() if use_async_lock_helper else state
with pytest.raises(
RuntimeError,
match="during synchronous deepcopy",
):
async with context:
pass
finally:
copy_release.set()
cloned = await copy_task
assert cloned.value == "copied"
context = state.async_lock() if use_async_lock_helper else state
async with context as value:
assert value is state.value
asyncio.run(_exercise())
@@ -0,0 +1,93 @@
"""Regression coverage for token accounting with quiet output defaults."""
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from collections import defaultdict
from praisonaiagents.llm.llm import LLM
from praisonaiagents.telemetry.token_collector import get_token_collector
def _display_functions():
return defaultdict(MagicMock)
def test_chat_completion_tracks_tokens_when_metrics_display_is_disabled(
monkeypatch,
):
collector = get_token_collector()
collector.reset()
llm = LLM(model="custom/test-model", api_key="test")
llm.metrics = False
llm.verbose = False
monkeypatch.setattr(llm, "_supports_responses_api", lambda: False)
monkeypatch.setattr(
llm,
"_call_with_retry",
lambda _fn, **_kwargs: {
"choices": [{"message": {"content": "ok"}}],
"usage": {"prompt_tokens": 12, "completion_tokens": 3},
},
)
with patch(
"praisonaiagents.llm.llm._get_display_functions",
return_value=_display_functions(),
):
assert llm.get_response(prompt="hello", stream=False, verbose=False) == "ok"
summary = collector.get_session_summary()
assert summary["total_interactions"] == 1
assert summary["total_metrics"]["input_tokens"] == 12
assert summary["total_metrics"]["output_tokens"] == 3
def test_nested_usage_details_are_collected():
collector = get_token_collector()
collector.reset()
llm = LLM(model="custom/test-model", api_key="test")
llm._track_token_usage({"usage": {
"prompt_tokens": 12,
"completion_tokens": 3,
"prompt_tokens_details": {"cached_tokens": 4, "audio_tokens": 2},
"completion_tokens_details": {"reasoning_tokens": 1, "audio_tokens": 1},
}}, llm.model)
metrics = collector.get_session_summary()["total_metrics"]
assert metrics["cached_tokens"] == 4
assert metrics["reasoning_tokens"] == 1
assert metrics["audio_input_tokens"] == 2
assert metrics["audio_output_tokens"] == 1
def test_responses_api_tracks_tokens_when_metrics_display_is_disabled(
monkeypatch,
):
collector = get_token_collector()
collector.reset()
llm = LLM(model="openai/gpt-4o-mini", api_key="test")
llm.metrics = False
llm.verbose = False
response = SimpleNamespace(
usage=SimpleNamespace(input_tokens=8, output_tokens=2)
)
monkeypatch.setattr(llm, "_supports_responses_api", lambda: True)
monkeypatch.setattr(llm, "_build_responses_params", lambda **_kwargs: {})
monkeypatch.setattr(llm, "_call_responses_api", lambda **_kwargs: response)
monkeypatch.setattr(
llm,
"_extract_from_responses_output",
lambda _response: ("ok", [], None),
)
with patch(
"praisonaiagents.llm.llm._get_display_functions",
return_value=_display_functions(),
):
assert llm.get_response(prompt="hello", stream=False, verbose=False) == "ok"
summary = collector.get_session_summary()
assert summary["total_interactions"] == 1
assert summary["total_metrics"]["input_tokens"] == 8
assert summary["total_metrics"]["output_tokens"] == 2
@@ -21,6 +21,7 @@ from praisonaiagents.hooks.runner import HookRunner
from praisonaiagents.hooks.types import HookEvent, HookResult
from praisonaiagents.hooks.events import (
AfterLLMInput,
AfterToolInput,
BeforeToolInput,
SessionStartInput,
SessionEndInput,
@@ -48,6 +49,37 @@ class ToolArgPlugin(Plugin):
return args
class RedactToolResultPlugin(Plugin):
@property
def info(self):
return PluginInfo(name="redacttool", hooks=[PluginHook.AFTER_TOOL])
def after_tool(self, tool_name, result):
return str(result).replace("sk-SECRET123", "[REDACTED]")
class BlockToolResultPlugin(Plugin):
@property
def info(self):
return PluginInfo(name="blocktoolresult", hooks=[PluginHook.AFTER_TOOL])
def after_tool(self, tool_name, result):
if "sk-SECRET123" in str(result):
return PluginDecision.block("Secret detected in tool output")
return result
class RaiseBlockToolResultPlugin(Plugin):
@property
def info(self):
return PluginInfo(name="raiseblocktool", hooks=[PluginHook.AFTER_TOOL])
def after_tool(self, tool_name, result):
if "sk-SECRET123" in str(result):
raise GuardrailBlocked("Secret detected in tool output")
return result
class NoopPlugin(Plugin):
@property
def info(self):
@@ -151,6 +183,107 @@ class TestBridge:
runner.execute_sync(HookEvent.BEFORE_TOOL, data)
assert data.tool_input.get("injected") is True
def test_after_tool_plugin_redacts_result(self):
reg = HookRegistry()
mgr = PluginManager()
mgr.register(RedactToolResultPlugin())
mgr.wire_into_hook_registry(reg)
runner = HookRunner(registry=reg, cwd=os.getcwd())
data = AfterToolInput(
session_id="s", cwd=os.getcwd(), event_name=HookEvent.AFTER_TOOL,
timestamp=str(time.time()), agent_name="a",
tool_name="fetch", tool_output="token=sk-SECRET123 ok",
)
runner.execute_sync(HookEvent.AFTER_TOOL, data)
assert data.tool_output == "token=[REDACTED] ok"
assert "sk-SECRET123" not in data.tool_output
def test_after_tool_passthrough_leaves_result(self):
reg = HookRegistry()
mgr = PluginManager()
mgr.register(BlockToolResultPlugin())
mgr.wire_into_hook_registry(reg)
runner = HookRunner(registry=reg, cwd=os.getcwd())
data = AfterToolInput(
session_id="s", cwd=os.getcwd(), event_name=HookEvent.AFTER_TOOL,
timestamp=str(time.time()), agent_name="a",
tool_name="fetch", tool_output="clean output",
)
runner.execute_sync(HookEvent.AFTER_TOOL, data)
assert data.tool_output == "clean output"
def test_after_tool_decision_blocks(self):
reg = HookRegistry()
mgr = PluginManager()
mgr.register(BlockToolResultPlugin())
mgr.wire_into_hook_registry(reg)
runner = HookRunner(registry=reg, cwd=os.getcwd())
data = AfterToolInput(
session_id="s", cwd=os.getcwd(), event_name=HookEvent.AFTER_TOOL,
timestamp=str(time.time()), agent_name="a",
tool_name="fetch", tool_output="token=sk-SECRET123",
)
results = runner.execute_sync(HookEvent.AFTER_TOOL, data)
assert runner.is_blocked(results) is True
def test_after_tool_guardrail_blocked_raises(self):
reg = HookRegistry()
mgr = PluginManager()
mgr.register(RaiseBlockToolResultPlugin())
mgr.wire_into_hook_registry(reg)
runner = HookRunner(registry=reg, cwd=os.getcwd())
data = AfterToolInput(
session_id="s", cwd=os.getcwd(), event_name=HookEvent.AFTER_TOOL,
timestamp=str(time.time()), agent_name="a",
tool_name="fetch", tool_output="token=sk-SECRET123",
)
results = runner.execute_sync(HookEvent.AFTER_TOOL, data)
assert runner.is_blocked(results) is True
assert runner.get_blocking_reason(results) == "Secret detected in tool output"
def test_agent_execute_tool_redacts_output(self):
"""End-to-end: an AFTER_TOOL plugin scrubs the value the executor returns."""
from praisonaiagents import Agent, tool
@tool
def leak() -> str:
"""Return a secret token."""
return "token=sk-SECRET123 ok"
reg = HookRegistry()
mgr = PluginManager()
mgr.register(RedactToolResultPlugin())
mgr.wire_into_hook_registry(reg)
agent = Agent(name="a", role="r", goal="g", tools=[leak], hooks=reg)
result = agent.execute_tool("leak", {})
print(f"[test_agent_execute_tool_redacts_output] result={result!r}")
assert "sk-SECRET123" not in str(result)
assert "[REDACTED]" in str(result)
def test_agent_execute_tool_blocks_output(self):
"""End-to-end: an AFTER_TOOL block stops secret output reaching the model."""
from praisonaiagents import Agent, tool
@tool
def leak() -> str:
"""Return a secret token."""
return "token=sk-SECRET123"
reg = HookRegistry()
mgr = PluginManager()
mgr.register(BlockToolResultPlugin())
mgr.wire_into_hook_registry(reg)
agent = Agent(name="a", role="r", goal="g", tools=[leak], hooks=reg)
result = agent.execute_tool("leak", {})
print(f"[test_agent_execute_tool_blocks_output] result={result!r}")
assert "sk-SECRET123" not in str(result)
def test_disabled_plugin_not_wired(self):
reg = HookRegistry()
mgr = PluginManager()
@@ -0,0 +1,156 @@
"""Contract tests for `Agent(sandbox=...)`.
History worth keeping: an earlier version of this file asserted
`child_pid != os.getpid()` and treated that as proof of isolation. That is a
false equivalence -- a separate process is not a security boundary -- and it
let a privilege-escalating change ship on a green suite. These tests assert the
*contract* instead: `sandbox=` configures isolation for explicit execute_code()
calls, and never silently grants the model new capabilities.
"""
import os
import pytest
from praisonaiagents import Agent
def _tool_names(agent):
return [getattr(t, "__name__", getattr(t, "name", None)) for t in (agent.tools or [])]
def a_tool(x: str) -> str:
"""A plain user tool."""
return x
# ── sandbox= must not grant capabilities ─────────────────────────────────────
def test_sandbox_does_not_inject_tools():
"""`sandbox=` is a restriction; it must never add tools the caller didn't ask for."""
plain = Agent(name="A", instructions="x", tools=[a_tool])
boxed = Agent(name="B", instructions="x", tools=[a_tool], sandbox=True)
assert _tool_names(boxed) == _tool_names(plain) == ["a_tool"]
def test_sandbox_does_not_bypass_approval_presets():
"""Regression: approval='read_only' exposed no tools, but adding sandbox=True
exposed two ungated arbitrary-code-execution tools, because the injected
names are absent from the approval registry's dangerous-tool presets."""
read_only = Agent(name="A", instructions="x", approval="read_only")
with_sandbox = Agent(name="B", instructions="x", approval="read_only", sandbox=True)
assert _tool_names(with_sandbox) == _tool_names(read_only), (
"sandbox= must not widen what an approval preset exposes"
)
def test_sandbox_still_configures_explicit_execution():
"""The documented meaning of sandbox=: configure agent.execute_code()."""
agent = Agent(name="T", instructions="x", sandbox=True)
assert agent.has_sandbox
assert agent.sandbox_config is not None
assert agent.get_sandbox_manager() is not None
def test_no_sandbox_leaves_agent_untouched():
agent = Agent(name="T", instructions="x", tools=[a_tool])
assert _tool_names(agent) == ["a_tool"]
assert agent.sandbox_config is None
assert agent.has_sandbox is False
# ── the metadata fix (kept from the reverted change) ─────────────────────────
def test_security_warning_does_not_break_execution():
"""`metadata` was forwarded into SandboxProtocol.execute(), which has no such
parameter, so flagged code raised TypeError instead of executing."""
agent = Agent(name="T", instructions="x", sandbox=True)
result = agent.execute_code_sync("import os\nprint('flagged-ok')")
assert "flagged-ok" in (result.stdout or result.output or "")
def test_security_warnings_are_attached_to_result():
agent = Agent(name="T", instructions="x", sandbox=True)
result = agent.execute_code_sync("import os\nprint('x')")
meta = getattr(result, "metadata", None) or {}
assert "security_warnings" in meta, "flagged code should carry its warnings"
# ── the clone fix (kept from the reverted change) ────────────────────────────
def test_clone_keeps_sandbox():
"""clone_for_channel() read `_sandbox_config`, which is never assigned, so
every clone silently ran unisolated."""
agent = Agent(name="T", instructions="x", sandbox=True)
clone = agent.clone_for_channel()
assert getattr(clone, "sandbox_config", None) is not None
def test_clone_does_not_accumulate_tools():
agent = Agent(name="T", instructions="x", tools=[a_tool], sandbox=True)
once = agent.clone_for_channel()
twice = once.clone_for_channel()
assert _tool_names(twice) == _tool_names(agent)
# ── the backend precedence warning (kept) ────────────────────────────────────
class _FakeBackend:
async def execute(self, prompt, **kwargs):
return ""
def test_sandbox_plus_backend_warns():
"""A managed backend takes the whole turn, making sandbox= inert."""
with pytest.warns(FutureWarning, match="sandbox") as caught:
Agent(name="T", instructions="x", sandbox=True, backend=_FakeBackend())
assert any("backend" in str(w.message) for w in caught)
def test_backend_alone_does_not_warn(recwarn):
Agent(name="T", instructions="x", backend=_FakeBackend())
assert not [
w for w in recwarn
if issubclass(w.category, FutureWarning) and "sandbox" in str(w.message)
]
# ── the no-injection contract must stay documented at the call site ──────────
def test_no_injection_contract_is_documented():
"""The call site should explain *why* sandbox= adds no tools, so the next
person does not "fix" the missing injection. Assert on the concepts rather
than an exact phrase, so harmless wording edits don't fail the suite."""
import inspect
from praisonaiagents.agent import agent as agent_mod
src = inspect.getsource(agent_mod.Agent.__init__).lower()
assert "sandbox" in src
assert "does not add" in src or "not add" in src or "no code-execution" in src
assert "restriction" in src
# ── inert fields must stay labelled ──────────────────────────────────────────
def test_inert_fields_are_documented_as_inert():
import inspect
from praisonaiagents.agent import autonomy
from praisonaiagents.config import feature_configs
assert "NOT IMPLEMENTED" in (autonomy.AutonomyConfig.__doc__ or "")
src = inspect.getsource(feature_configs.ExecutionConfig)
idx = src.find("code_sandbox_mode")
assert "NOT IMPLEMENTED" in src[max(0, idx - 400):idx]
# ── real agentic run: sandbox= adds no model-visible execution tools ─────────
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY"),
reason="requires a live LLM key; run locally to exercise the real path",
)
def test_sandbox_agent_start_adds_no_execution_tools():
"""End-to-end guard: build a sandboxed Agent, run a real prompt through the
LLM, print the output, and confirm the model-visible tool set never gained
execute_python_code / execute_shell_command from `sandbox=` alone."""
agent = Agent(name="E2E", instructions="Reply briefly.", sandbox=True)
output = agent.start("Say the single word: ok")
print(output)
names = set(_tool_names(agent))
assert "execute_python_code" not in names
assert "execute_shell_command" not in names
@@ -0,0 +1,61 @@
"""
Regression tests for DoomLoopDetector._check_no_progress window boundary.
The no-progress heuristic only counts progress markers recorded within the
current window. A marker recorded *during* the window's oldest action is
timestamped before that action's record_action() timestamp (which is set at
completion), so filtering on the oldest action's timestamp would wrongly drop
it and report NO_PROGRESS despite genuine recent progress.
"""
import time
from praisonaiagents.escalation.doom_loop import (
DoomLoopConfig,
DoomLoopDetector,
)
def _make_detector(steps: int = 3) -> DoomLoopDetector:
return DoomLoopDetector(DoomLoopConfig(max_no_progress_steps=steps))
def test_marker_during_window_first_action_still_counts():
"""Progress made during the window's oldest action must suppress NO_PROGRESS."""
det = _make_detector(steps=3)
# Marker recorded *before* the first windowed action completes.
det.mark_progress("did-real-work")
time.sleep(0.001)
for i in range(3):
det.record_action("poll", {"i": i}, f"same-{0}", True)
assert det._check_no_progress() is False
def test_marker_before_window_is_ignored():
"""A stale marker predating the window must not suppress NO_PROGRESS."""
det = _make_detector(steps=3)
# One early action + marker, then a full window of identical results with
# no further progress. The early marker is outside the window and must be
# ignored so the stuck window is detected.
det.mark_progress("early-success")
det.record_action("setup", {"i": -1}, "start", True)
time.sleep(0.001)
for i in range(3):
det.record_action("poll", {"i": i}, "stuck", True)
assert det._check_no_progress() is True
def test_recent_marker_within_window_suppresses():
"""A marker recorded mid-window still suppresses NO_PROGRESS."""
det = _make_detector(steps=3)
det.record_action("poll", {"i": 0}, "stuck", True)
det.mark_progress("mid-window-progress")
det.record_action("poll", {"i": 1}, "stuck", True)
det.record_action("poll", {"i": 2}, "stuck", True)
assert det._check_no_progress() is False
+1 -1
View File
@@ -3082,7 +3082,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.6.166"
version = "1.6.167"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.0.47"
__version__ = "0.0.48"
@@ -72,29 +72,34 @@ def approve(
try:
store = PairingStore(store_dir=store_dir)
# First check if the code exists and get its details without consuming it
# Look up the pending entry for its channel_id metadata. Codes are
# hashed at rest, so cross-process a pending entry may not expose the
# raw code — fall back to matching the salted hash of the presented code.
pending_pairings = store.list_pending()
code_hash = store._hash(code)
code_info = None
for pairing in pending_pairings:
if pairing.get('code') == code:
if pairing.get('code') == code or pairing.get('code_hash') == code_hash:
code_info = pairing
break
if not code_info:
typer.echo(f"❌ Invalid or expired code: {code}", err=True)
raise typer.Exit(1)
# Now verify and pair (this will consume the code)
# Verify and pair (this consumes the code). verify_and_pair performs the
# authoritative hash lookup + HMAC check + brute-force lockout, so a
# missing pre-match is not itself fatal — trust its return value.
success = store.verify_and_pair(
code=code,
channel_id=channel_id,
channel_type=platform,
label=label
)
if success:
# Use the actual channel_id from the code info if channel_id was auto-resolved
resolved_channel_id = channel_id or code_info.get('channel_id', 'unknown')
resolved_channel_id = (
channel_id
or (code_info.get('channel_id') if code_info else None)
or 'unknown'
)
typer.echo(f"✅ Successfully paired {platform} channel {resolved_channel_id}")
if label:
typer.echo(f" Label: {label}")
@@ -904,158 +904,87 @@ def handle_gateway_command(args) -> int:
Args:
args: List of CLI arguments (from main.py unknown_args) or argparse Namespace.
"""
import argparse
if isinstance(args, list):
parser = argparse.ArgumentParser(
prog="praisonai gateway",
description="Manage the PraisonAI Gateway server",
)
subparsers = parser.add_subparsers(dest="gateway_command", help="Gateway commands")
# start subcommand
start_parser = subparsers.add_parser("start", help="Start the gateway server")
start_parser.add_argument("--host", default="127.0.0.1", help="Host to bind to (default: 127.0.0.1)")
start_parser.add_argument("--port", type=int, default=8765, help="Port to listen on (default: 8765)")
start_parser.add_argument("--agents", help="Path to agent configuration file")
start_parser.add_argument("--config", dest="config_file", help="Path to gateway.yaml for multi-bot mode")
start_parser.add_argument(
"--drain-timeout", dest="drain_timeout", type=float, default=None,
help="Seconds to wait for in-flight agent turns to finish on shutdown (0 disables; #2375)",
)
start_parser.add_argument(
"--max-concurrent-runs", dest="max_concurrent_runs", type=int, default=None,
help="Gateway-wide ceiling on simultaneously-running agent turns (0 disables; #2454)",
)
start_parser.add_argument(
"--queue-depth", dest="queue_depth", type=int, default=None,
help="Bounded wait queue depth when at the concurrency ceiling (#2454)",
)
start_parser.add_argument(
"--overflow-policy", dest="overflow_policy", default=None,
choices=["reject", "queue", "shed_oldest"],
help="Behaviour when the wait queue is full (default: reject; #2454)",
)
start_parser.add_argument(
"--reliability", dest="reliability", default=None,
choices=["production", "default", "off"],
help="Named reliability posture composing drain + admission in one "
"switch (#2531)",
)
start_parser.add_argument(
"--identity-store", dest="identity_store", default=None,
help="Enable cross-platform conversation continuity: path to the "
"identity link-map JSON (default ~/.praisonai/identity.json). "
"Paired/linked users share one session + memory across "
"channels (#3020)",
)
start_parser.add_argument(
"--scale-to-zero", dest="scale_to_zero", action="store_true", default=None,
help="Quiesce the gateway when idle for --idle-minutes (scale-to-zero; #3021)",
)
start_parser.add_argument(
"--idle-minutes", dest="idle_minutes", type=float, default=None,
help="Minutes of no inbound / in-flight work before quiescing (#3021)",
)
start_parser.add_argument(
"--drain-marker", dest="drain_marker", default=None,
help="Path to watch for an epoch-aware external drain marker file (#3021)",
)
start_parser.add_argument(
"--watchdog", dest="watchdog", action="store_true", default=None,
help="Enable the event-loop liveness watchdog: an OS-thread backstop "
"that dumps stacks and hard-exits (restart code 75) if the loop "
"freezes, so the supervisor relaunches the process (#3410)",
)
start_parser.add_argument(
"--watchdog-timeout", dest="watchdog_timeout", type=float, default=None,
help="Seconds the event loop may stall before the watchdog trips a "
"restart (default ~15s = 5s x 3 strikes; #3410)",
)
# Route the public ``praisonai gateway ...`` entry point to the SINGLE
# canonical Typer command app so it advertises exactly the same command
# surface -- and the same safe ``start`` (auto-discovery, config-version
# migration, credential + tool preflight, ``--openai-api``/``--mcp``) -- as
# ``praisonai-bot gateway ...``. One command surface, one ``start``: the
# gateway's own degraded-state retry hints (``praisonai gateway doctor`` /
# ``doctor --fix`` / ``test``) now name commands the operator can actually
# run from the ``praisonai`` binary (#3966).
import click
from typer.main import get_command
# status subcommand
status_parser = subparsers.add_parser("status", help="Check gateway status")
status_parser.add_argument("--host", default="127.0.0.1", help="Gateway host (default: 127.0.0.1)")
status_parser.add_argument("--port", type=int, default=8765, help="Gateway port (default: 8765)")
from praisonai_bot.cli.commands.gateway import app as gateway_app
# hooks subcommand — manage inbound trigger hooks (Issue #2281)
hooks_parser = subparsers.add_parser(
"hooks", help="Manage inbound trigger hooks (POST /hooks/<path>)"
)
hooks_sub = hooks_parser.add_subparsers(
dest="hooks_command", help="Hook commands"
)
# Typer may drive the command with a vendored copy of click
# (``typer._click``) whose exception classes are distinct from the
# top-level ``click`` ones. Catch both families so unknown-subcommand /
# bad-flag errors degrade to an exit code with a rendered message rather
# than a traceback, regardless of the installed Typer version (#3966).
click_exception_types = [click.ClickException]
abort_types = [click.exceptions.Abort]
try: # pragma: no cover - only one click family is present at runtime
from typer import _click as _typer_click # type: ignore
hooks_add = hooks_sub.add_parser("add", help="Add a hook to gateway.yaml")
hooks_add.add_argument("path", help="Hook path, e.g. 'gmail' -> POST /hooks/gmail")
hooks_add.add_argument("--agent", help="Agent id to run (default: first agent)")
hooks_add.add_argument(
"--action", dest="action_type", default="agent",
choices=["agent", "wake"], help="agent runs a turn, wake nudges a session",
)
hooks_add.add_argument("--auth", help="Bearer token / shared secret for this hook")
hooks_add.add_argument("--session-key", dest="session_key", help="Session key template")
hooks_add.add_argument(
"--idempotency-key", dest="idempotency_key", help="Idempotency key template",
)
hooks_add.add_argument("--deliver-to", dest="deliver_to", help="channel:target for the reply")
hooks_add.add_argument("--message", help="Message template from the payload")
hooks_add.add_argument(
"--config", dest="config_file", default="gateway.yaml",
help="Path to gateway.yaml (default: gateway.yaml)",
)
click_exception_types.append(_typer_click.exceptions.ClickException)
abort_types.append(_typer_click.exceptions.Abort)
except (ImportError, AttributeError):
# Typer without a vendored ``_click`` (or a differing layout): the
# top-level ``click`` families already loaded above are sufficient.
pass
click_exception_types = tuple(click_exception_types)
abort_types = tuple(abort_types)
hooks_list = hooks_sub.add_parser("list", help="List configured hooks")
hooks_list.add_argument(
"--config", dest="config_file", default="gateway.yaml",
help="Path to gateway.yaml (default: gateway.yaml)",
)
# A pre-parsed argparse ``Namespace`` (legacy internal callers) is adapted
# back to an argv list so there is exactly one dispatch path through the
# canonical Typer app; a plain list is passed straight through.
argv = args if isinstance(args, list) else _namespace_to_gateway_argv(args)
hooks_remove = hooks_sub.add_parser("remove", help="Remove a hook")
hooks_remove.add_argument("path", help="Hook path to remove")
hooks_remove.add_argument(
"--config", dest="config_file", default="gateway.yaml",
help="Path to gateway.yaml (default: gateway.yaml)",
)
try:
args = parser.parse_args(args)
except SystemExit:
return 1
handler = GatewayHandler()
subcommand = getattr(args, "gateway_command", None) or "start"
if subcommand == "start":
# Propagate the supervisor-friendly exit code from start() (#2437):
# 0 clean, 75 transient/restart, 78 fatal-config/do-not-restart.
return handler.start(
host=getattr(args, "host", "127.0.0.1"),
port=getattr(args, "port", 8765),
agent_file=getattr(args, "agents", None),
config_file=getattr(args, "config_file", None),
drain_timeout=getattr(args, "drain_timeout", None),
max_concurrent_runs=getattr(args, "max_concurrent_runs", None),
queue_depth=getattr(args, "queue_depth", None),
overflow_policy=getattr(args, "overflow_policy", None),
reliability=getattr(args, "reliability", None),
identity_store=getattr(args, "identity_store", None),
scale_to_zero=getattr(args, "scale_to_zero", None),
idle_minutes=getattr(args, "idle_minutes", None),
drain_marker=getattr(args, "drain_marker", None),
watchdog=getattr(args, "watchdog", None),
watchdog_timeout=getattr(args, "watchdog_timeout", None),
)
elif subcommand == "status":
handler.status(
host=getattr(args, "host", "127.0.0.1"),
port=getattr(args, "port", 8765),
)
elif subcommand == "hooks":
return handler.hooks(args)
else:
print(f"Unknown gateway command: {subcommand}")
print("Available commands: start, status, hooks")
command = get_command(gateway_app)
try:
# ``standalone_mode=False`` returns the command's value (exit code)
# instead of calling ``sys.exit`` — so the caller (parse_args) can
# ``sys.exit`` once at the top level, matching the argparse contract.
return command(args=list(argv), standalone_mode=False) or 0
except abort_types:
# Ctrl+C at a prompt → clean interrupt.
return 1
return 0
except click_exception_types as exc:
# Unknown subcommand / bad flags: render the message (the way click
# would in standalone mode) and surface a non-zero code instead of a
# traceback, so the public CLI degrades gracefully like the canonical
# binary rather than dead-ending (#3966).
exc.show()
return exc.exit_code
except SystemExit as exc: # --help and other explicit exits
code = exc.code
return code if isinstance(code, int) else (0 if code is None else 1)
def _namespace_to_gateway_argv(args) -> list:
"""Adapt a legacy argparse ``Namespace`` to a ``gateway`` argv list.
Only ``start``/``status``/``hooks`` were ever produced by the old argparse
parser; anything else falls back to ``start`` (its historical default) so
the canonical Typer app handles it identically to the public path.
"""
subcommand = getattr(args, "gateway_command", None) or "start"
argv: list = [subcommand]
if subcommand in ("start", "status"):
host = getattr(args, "host", None)
port = getattr(args, "port", None)
if host is not None:
argv += ["--host", str(host)]
if port is not None:
argv += ["--port", str(port)]
if subcommand == "start":
if getattr(args, "agents", None):
argv += ["--agents", str(args.agents)]
if getattr(args, "config_file", None):
argv += ["--config", str(args.config_file)]
if subcommand == "hooks":
hooks_command = getattr(args, "hooks_command", None)
if hooks_command:
argv.append(hooks_command)
return argv
@@ -30,6 +30,18 @@ _DEFAULT_STORE_DIR = os.path.join(
)
_DEFAULT_STORE_FILE = "pairing.json"
# Unambiguous alphabet for human-entered codes (drops 0/O/1/I) so a code
# read aloud or retyped from another device is not mis-entered.
_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
_CODE_LENGTH = 8
# Store-level brute-force ceiling defaults (per channel_type). These guard the
# verification path uniformly across every approval surface (HTTP, in-chat
# button, CLI) — the optional IP-keyed HTTP limiter remains an outer layer.
_LOCKOUT_MAX_FAILURES = 5
_LOCKOUT_WINDOW_SECONDS = 60.0
_LOCKOUT_COOLDOWN_SECONDS = 300.0
def _get_secret() -> str:
"""Return the HMAC signing secret.
@@ -216,15 +228,21 @@ class PairedChannel:
class PairingStore:
"""File-backed store of pairing codes and authorised channels.
The store generates *8-character* hex codes signed with HMAC-SHA256.
A channel presents the code to the gateway; the gateway verifies
the signature and adds the channel to the allow-list.
The store generates *8-character* codes (from an unambiguous alphabet that
drops ``0/O/1/I``) signed with HMAC-SHA256. A channel presents the code to
the gateway; the gateway verifies the signature and adds the channel to the
allow-list.
Thread-safe via ``threading.Lock``.
Security:
- Codes are one-time use (consumed on verify).
- HMAC-signed to prevent forgery.
- Only a salted hash of each code is persisted — reading the state
file never yields a usable pairing code.
- Store-level brute-force ceiling: failed verifications are counted
per channel_type and locked out after ``max_failures``, uniformly
across every approval surface (HTTP, in-chat button, CLI).
- Timing-safe comparison via ``hmac.compare_digest``.
- Atomic file writes (tempfile + rename) to prevent corruption.
- Configurable TTL for pending codes.
@@ -256,6 +274,9 @@ class PairingStore:
code_ttl: float = 300.0,
secret: Optional[str] = None,
max_pending: int = 100,
max_failures: int = _LOCKOUT_MAX_FAILURES,
lockout_window: float = _LOCKOUT_WINDOW_SECONDS,
lockout_cooldown: float = _LOCKOUT_COOLDOWN_SECONDS,
) -> None:
self._dir = store_dir or _DEFAULT_STORE_DIR
self._path = os.path.join(self._dir, _DEFAULT_STORE_FILE)
@@ -264,11 +285,25 @@ class PairingStore:
self._max_pending = max_pending
self._lock = threading.Lock()
# code -> {signature, channel_type, created_at}
# code -> {signature, channel_type, created_at} (raw code in memory only)
self._pending: Dict[str, dict] = {}
# code_hash -> {signature, channel_type, created_at} (rehydrated from disk;
# lets verification succeed cross-process without persisting the raw code)
self._pending_by_hash: Dict[str, dict] = {}
# (channel_id, channel_type) -> PairedChannel
self._paired: Dict[tuple, PairedChannel] = {}
# Store-level brute-force ceiling, keyed by channel_type. Reuses the
# gateway's own limiter so protection is uniform across every approval
# surface (HTTP, in-chat button, CLI), not just the HTTP routes.
from .rate_limiter import AuthRateLimiter
self._lockout = AuthRateLimiter(
max_attempts=max_failures,
window_seconds=lockout_window,
lockout_seconds=lockout_cooldown,
)
self._load()
# ── Code lifecycle ────────────────────────────────────────────────
@@ -279,23 +314,29 @@ class PairingStore:
The code is HMAC-signed so the gateway can verify it was not forged.
Raises ``RuntimeError`` if max pending codes is reached.
"""
code = secrets.token_hex(4) # 8 hex chars
code = "".join(secrets.choice(_CODE_ALPHABET) for _ in range(_CODE_LENGTH))
sig = self._sign(code)
with self._lock:
self._prune_expired()
if len(self._pending) >= self._max_pending:
# Count *all* logical pending entries, including ones rehydrated
# from disk into ``_pending_by_hash`` after a restart. ``_pending``
# alone under-counts cross-process state, letting repeated restarts
# grow ``pairing.json`` past ``max_pending``.
if len(self._pending_by_hash) >= self._max_pending:
raise RuntimeError(
f"Too many pending pairing codes (max={self._max_pending}). "
"Wait for existing codes to expire or be consumed."
)
self._pending[code] = {
info = {
"signature": sig,
"channel_type": channel_type,
"channel_id": channel_id,
"created_at": time.time(),
}
self._save() # NEW — persist pending codes
self._pending[code] = info
self._pending_by_hash[self._hash(code)] = dict(info)
self._save() # NEW — persist pending codes (hashed at rest)
return code
def verify_and_pair(
@@ -311,20 +352,41 @@ class PairingStore:
The code is consumed (one-time use) regardless of outcome.
If ``channel_id`` is omitted, a channel-bound pending code may provide it.
"""
# Store-level brute-force ceiling: refuse verification for a channel
# type that has exceeded its failed-attempt budget, regardless of which
# approval surface (HTTP, in-chat, CLI) is calling. Checking the lockout
# here does not itself count as an attempt — only failures are counted.
if self._locked_out(channel_type):
logger.warning(
"Pairing verification locked out for channel_type=%s", channel_type
)
return False
with self._lock:
self._prune_expired()
pending = self._pending.pop(code, None)
if pending is None:
# Cross-process fallback: the raw code was never persisted, so
# match against the salted hash rehydrated from disk.
pending = self._pending_by_hash.pop(self._hash(code), None)
if pending is not None:
self._pending_by_hash.pop(self._hash(code), None)
self._save() # NEW — persist the pop
if pending is None:
self._record_failure(channel_type)
return False
# Timing-safe comparison
expected_sig = self._sign(code)
if not hmac.compare_digest(pending["signature"], expected_sig):
self._record_failure(channel_type)
return False
# Validate the channel binding *before* clearing the failure counter.
# A valid HMAC presented against the wrong ``channel_id`` still fails
# to pair — clearing the lockout here would hand subsequent guesses a
# fresh attempt budget after an unsuccessful pairing.
pending_channel_id = pending.get("channel_id")
if pending_channel_id and channel_id and channel_id != pending_channel_id:
return False
@@ -333,6 +395,10 @@ class PairingStore:
if not resolved_channel_id:
return False
# Binding is valid — this was a legitimate, successful verification, so
# the brute-force counter for this channel_type is cleared.
self._clear_failures(channel_type)
paired = PairedChannel(
channel_id=resolved_channel_id,
channel_type=channel_type,
@@ -407,10 +473,12 @@ class PairingStore:
pending_list = []
now = time.time()
in_memory_hashes = set()
for code, info in self._pending.items():
in_memory_hashes.add(self._hash(code))
if channel_type and info.get("channel_type") != channel_type:
continue
ct = info.get("channel_type", "unknown")
cid = info.get("channel_id")
created_at = info.get("created_at", now)
@@ -425,7 +493,31 @@ class PairingStore:
"user_name": f"User {code}",
"age_seconds": int(now - created_at),
})
# Cross-process pending codes rehydrated from disk carry only a
# salted hash — the raw code was never persisted. Surface them so
# operators still see a pending request exists (and can approve by
# re-entering the code from chat), but never expose a usable code.
for chash, info in self._pending_by_hash.items():
if chash in in_memory_hashes:
continue
if channel_type and info.get("channel_type") != channel_type:
continue
ct = info.get("channel_type", "unknown")
cid = info.get("channel_id")
created_at = info.get("created_at", now)
pending_list.append({
"code": None,
"code_hash": chash,
"channel_type": ct,
"channel_id": cid,
"created_at": created_at,
"channel": ct,
"user_id": cid,
"user_name": f"User {cid}" if cid else "pending",
"age_seconds": int(now - created_at),
})
return pending_list
# ── Persistence ───────────────────────────────────────────────────
@@ -437,13 +529,25 @@ class PairingStore:
"""
try:
Path(self._dir).mkdir(parents=True, exist_ok=True)
# Merge in-memory (raw-code) pending with any cross-process entries
# rehydrated from disk. Only a salted hash of the code is persisted —
# reading pairing.json never yields a usable code.
pending_out = []
seen_hashes = set()
for c, info in self._pending.items():
chash = self._hash(c)
seen_hashes.add(chash)
pending_out.append({"code_hash": chash, **info})
for chash, info in self._pending_by_hash.items():
if chash in seen_hashes:
continue
pending_out.append({"code_hash": chash, **info})
data = {
"paired": [
asdict(ch) for ch in self._paired.values()
],
"pending": [
{"code": c, **info} for c, info in self._pending.items()
],
"pending": pending_out,
}
# Atomic write: tempfile → rename
fd, tmp_path = tempfile.mkstemp(
@@ -473,11 +577,22 @@ class PairingStore:
for entry in data.get("paired", []):
ch = PairedChannel(**entry)
self._paired[(ch.channel_id, ch.channel_type)] = ch
# Load pending codes
# Load pending codes. New format persists only a salted ``code_hash``;
# the legacy plaintext ``code`` field is still read for backward
# compatibility so existing on-disk stores keep working.
for entry in data.get("pending", []):
code = entry.pop("code")
self._pending[code] = entry
logger.debug("Loaded %d paired channels, %d pending codes", len(self._paired), len(self._pending))
if "code" in entry: # legacy plaintext store
code = entry.pop("code")
self._pending[code] = entry
self._pending_by_hash[self._hash(code)] = dict(entry)
elif "code_hash" in entry: # hashed-at-rest store
chash = entry.pop("code_hash")
self._pending_by_hash[chash] = entry
logger.debug(
"Loaded %d paired channels, %d pending codes",
len(self._paired),
len(self._pending_by_hash),
)
except (OSError, json.JSONDecodeError, TypeError) as exc:
logger.warning("Failed to load pairing store: %s", exc)
@@ -486,6 +601,27 @@ class PairingStore:
def _sign(self, code: str) -> str:
return hmac.new(self._secret, code.encode(), hashlib.sha256).hexdigest()
def _hash(self, code: str) -> str:
"""Salted SHA-256 of a code for at-rest storage.
The salt is derived from the per-install HMAC secret, so the hash is
stable across processes (enabling cross-process verification) yet a
precomputed rainbow table is useless without the secret.
"""
return hashlib.sha256(self._secret + b":" + code.encode()).hexdigest()
# ── Brute-force ceiling helpers ───────────────────────────────────
def _locked_out(self, channel_type: str) -> bool:
return self._lockout.time_until_allowed("pairing_verify", channel_type) > 0
def _record_failure(self, channel_type: str) -> None:
# ``allow`` counts this attempt and trips the lockout at the ceiling.
self._lockout.allow("pairing_verify", channel_type)
def _clear_failures(self, channel_type: str) -> None:
self._lockout.reset("pairing_verify", channel_type)
def _prune_expired(self) -> None:
"""Remove expired pending codes (caller holds lock)."""
now = time.time()
@@ -494,4 +630,11 @@ class PairingStore:
if (now - info["created_at"]) >= self._code_ttl
]
for c in expired:
self._pending_by_hash.pop(self._hash(c), None)
del self._pending[c]
expired_hashes = [
h for h, info in self._pending_by_hash.items()
if (now - info["created_at"]) >= self._code_ttl
]
for h in expired_hashes:
del self._pending_by_hash[h]
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "praisonai-bot"
version = "0.0.47"
version = "0.0.48"
description = "Bots, gateway, and channel CLI for PraisonAI — messaging platforms and WebSocket control plane extracted from the praisonai wrapper."
readme = "README.md"
license = "MIT"
@@ -141,25 +141,40 @@ async def test_exec_approval_allowlist():
@pytest.mark.asyncio
async def test_pending_persists_across_instances():
"""Pending codes should survive PairingStore recreation (cross-process test)."""
"""Pending codes should survive PairingStore recreation (cross-process test).
Codes are hashed at rest, so the raw code is NOT recoverable from a fresh
instance — but the pending request is still visible and verifiable.
"""
import tempfile
import os
import json
# Create temporary directory for this test
with tempfile.TemporaryDirectory() as tmpdir:
# Instance A generates a code (no explicit secret - uses persisted secret file)
store_a = PairingStore(store_dir=tmpdir)
code = store_a.generate_code(channel_type="telegram", channel_id="tg_42")
assert len(code) == 8
# Instance B (simulating CLI in separate process) should see the pending code
# The raw code must never be written to disk in plaintext.
with open(os.path.join(tmpdir, "pairing.json")) as fh:
raw = fh.read()
assert code not in raw
assert "code_hash" in json.loads(raw)["pending"][0]
# Instance B (simulating CLI in separate process) should see the pending
# request (metadata) but not the raw code.
store_b = PairingStore(store_dir=tmpdir)
pending = store_b.list_pending()
assert len(pending) == 1
assert pending[0]["code"] == code
assert pending[0]["code"] is None
assert pending[0]["channel_type"] == "telegram"
assert pending[0]["channel_id"] == "tg_42"
# And it can still verify the code presented from chat (hash match).
assert store_b.verify_and_pair(code, None, "telegram") is True
@pytest.mark.asyncio
async def test_secret_persists_across_instances():
@@ -366,3 +381,124 @@ async def test_cli_approve_survives_restart():
# Verify pairing persisted (fresh instance uses same secret file)
final_store = PairingStore(store_dir=tmpdir)
assert final_store.is_paired("tg_restart", "telegram") is True
@pytest.mark.asyncio
async def test_codes_use_unambiguous_alphabet():
"""Generated codes must avoid ambiguous characters (0/O/1/I)."""
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
store = PairingStore(store_dir=tmpdir)
for _ in range(50):
code = store.generate_code(channel_type="telegram")
assert len(code) == 8
assert set(code) <= set("ABCDEFGHJKLMNPQRSTUVWXYZ23456789")
@pytest.mark.asyncio
async def test_codes_hashed_at_rest():
"""The raw pairing code must never be written to pairing.json."""
import tempfile
import os
import json
with tempfile.TemporaryDirectory() as tmpdir:
store = PairingStore(store_dir=tmpdir)
code = store.generate_code(channel_type="telegram", channel_id="tg_hash")
with open(os.path.join(tmpdir, "pairing.json")) as fh:
data = json.load(fh)
entry = data["pending"][0]
assert "code" not in entry
assert entry["code_hash"] != code
assert len(entry["code_hash"]) == 64 # sha256 hexdigest
@pytest.mark.asyncio
async def test_store_level_brute_force_lockout():
"""After N failed verifications a channel_type is locked out uniformly."""
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
store = PairingStore(
store_dir=tmpdir,
max_failures=3,
lockout_window=60.0,
lockout_cooldown=300.0,
)
good = store.generate_code(channel_type="telegram", channel_id="tg_bf")
# Enough wrong codes to exceed the ceiling and trip the lockout window.
for _ in range(4):
assert store.verify_and_pair("WRONGXXX", "tg_bf", "telegram") is False
# Once locked out, even the VALID code is refused while the cooldown is
# active — protection is store-level (not tied to a single surface).
assert store.verify_and_pair(good, "tg_bf", "telegram") is False
assert store.is_paired("tg_bf", "telegram") is False
@pytest.mark.asyncio
async def test_successful_verify_clears_failures():
"""A successful pairing resets the failed-attempt counter."""
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
store = PairingStore(store_dir=tmpdir, max_failures=3)
# Two failures, then a success should clear the counter.
assert store.verify_and_pair("WRONGXXX", "tg_ok", "telegram") is False
assert store.verify_and_pair("WRONGYYY", "tg_ok", "telegram") is False
code = store.generate_code(channel_type="telegram", channel_id="tg_ok")
assert store.verify_and_pair(code, "tg_ok", "telegram") is True
# Counter cleared: a fresh burst of failures is allowed again without
# the earlier two counting toward the ceiling.
code2 = store.generate_code(channel_type="telegram", channel_id="tg_ok2")
assert store.verify_and_pair("WRONGZZZ", "tg_ok2", "telegram") is False
assert store.verify_and_pair("WRONGWWW", "tg_ok2", "telegram") is False
assert store.verify_and_pair(code2, "tg_ok2", "telegram") is True
@pytest.mark.asyncio
async def test_channel_id_mismatch_does_not_reset_failures():
"""A valid code presented with the wrong channel_id must not clear the
brute-force counter — otherwise each rejected binding hands the next guess
a fresh attempt budget."""
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
store = PairingStore(store_dir=tmpdir, max_failures=3)
# Three failed guesses prime the counter right up to the ceiling.
assert store.verify_and_pair("WRONGXXX", "tg_x", "telegram") is False
assert store.verify_and_pair("WRONGYYY", "tg_x", "telegram") is False
assert store.verify_and_pair("WRONGZZZ", "tg_x", "telegram") is False
# A valid code bound to tg_bound, but presented with the WRONG
# channel_id: it is rejected and must NOT reset the primed counter.
good = store.generate_code(channel_type="telegram", channel_id="tg_bound")
assert store.verify_and_pair(good, "tg_wrong", "telegram") is False
# Counter was preserved, so the next failure trips the lockout.
assert store.verify_and_pair("WRONGWWW", "tg_x", "telegram") is False
# Locked out now: even a fresh valid code is refused during cooldown.
good2 = store.generate_code(channel_type="telegram", channel_id="tg_bound2")
assert store.verify_and_pair(good2, "tg_bound2", "telegram") is False
@pytest.mark.asyncio
async def test_max_pending_counts_rehydrated_entries():
"""max_pending must count entries rehydrated from disk after a restart,
not just in-memory raw codes — otherwise repeated restarts grow the store
past the configured bound."""
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
store_a = PairingStore(store_dir=tmpdir, max_pending=2)
store_a.generate_code(channel_type="telegram", channel_id="tg_1")
store_a.generate_code(channel_type="telegram", channel_id="tg_2")
# Fresh instance rehydrates both pending entries as hashes only.
store_b = PairingStore(store_dir=tmpdir, max_pending=2)
with pytest.raises(RuntimeError):
store_b.generate_code(channel_type="telegram", channel_id="tg_3")
@@ -0,0 +1,97 @@
"""Regression: ``praisonai gateway`` and ``praisonai-bot gateway`` share one surface.
The public ``praisonai gateway <cmd>`` entry point used to route through a
reduced argparse handler that only wired ``start``/``status``/``hooks`` and
dead-ended on everything else — even though the gateway's own degraded-state
retry hints tell operators to run ``praisonai gateway doctor``/``test`` (#3966).
These tests pin the unified behaviour: the public list-args handler dispatches
to the single canonical Typer command app, so the two entry points advertise
the same subcommands and ``gateway doctor`` resolves rather than printing
``Unknown gateway command: doctor``.
"""
from __future__ import annotations
import click
from praisonai_bot.cli.commands.gateway import app as gateway_app
from praisonai_bot.cli.features.gateway import handle_gateway_command
def _canonical_subcommands() -> set[str]:
from typer.main import get_command
command = get_command(gateway_app)
return set(command.list_commands(click.Context(command)))
def test_public_and_canonical_expose_same_gateway_surface():
"""The full operational command set is reachable from ``praisonai gateway``."""
surface = _canonical_subcommands()
# Every operational verb the issue enumerates must be present on the single
# canonical app the public handler now routes to.
for cmd in (
"start", "status", "stop", "restart", "doctor", "test", "channels",
"pause", "resume", "reconnect", "install", "uninstall", "mint-link",
"logs", "send", "hooks", "sessions",
):
assert cmd in surface, f"missing gateway subcommand: {cmd}"
def test_gateway_doctor_resolves_from_public_handler():
"""``praisonai gateway doctor`` resolves instead of dead-ending (#3966)."""
# ``--help`` exercises the routing/parse path without running diagnostics.
rc = handle_gateway_command(["doctor", "--help"])
assert rc == 0
def test_gateway_start_exposes_openai_and_mcp_flags():
"""The public ``start`` is the canonical safe ``start`` with compat surfaces.
The reduced argparse ``start`` omitted ``--openai-api``/``--mcp``; routing to
the canonical app means the public ``start`` now advertises them (#3966).
"""
from typer.main import get_command
command = get_command(gateway_app)
start = command.get_command(click.Context(command), "start")
opts = [opt for param in start.params for opt in param.opts]
assert "--openai-api" in opts
assert "--mcp" in opts
def test_namespace_caller_still_dispatches():
"""Legacy argparse callers keep working through argv adaptation (#3966).
``_namespace_to_gateway_argv`` depends on exact canonical option names; pin
that every long option it emits is actually accepted by the target Typer
command so an option-name drift is caught at review time.
"""
from argparse import Namespace
from typer.main import get_command
from praisonai_bot.cli.features.gateway import _namespace_to_gateway_argv
argv = _namespace_to_gateway_argv(
Namespace(gateway_command="status", host="127.0.0.1", port=8765)
)
assert argv[0] == "status"
command = get_command(gateway_app)
status = command.get_command(click.Context(command), "status")
opts = {opt for param in status.params for opt in param.opts}
for token in argv[1:]:
if token.startswith("--"):
assert token in opts, f"status does not accept {token}"
def test_unknown_gateway_command_no_longer_dead_ends(capsys):
"""An unknown verb yields a non-zero code, not the old static dead-end."""
rc = handle_gateway_command(["definitely-not-a-command"])
assert rc != 0
combined = capsys.readouterr()
assert "Available commands: start, status, hooks" not in (
combined.out + combined.err
)
+2 -2
View File
@@ -1416,7 +1416,7 @@ wheels = [
[[package]]
name = "praisonai-bot"
version = "0.0.47"
version = "0.0.48"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -1498,7 +1498,7 @@ provides-extras = ["gateway", "bot", "bot-whatsapp-web", "all"]
[[package]]
name = "praisonaiagents"
version = "1.6.166"
version = "1.6.167"
source = { directory = "../praisonai-agents" }
dependencies = [
{ name = "aiohttp" },
@@ -1 +1 @@
__version__ = "0.0.13"
__version__ = "0.0.14"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "praisonai-browser"
version = "0.0.13"
version = "0.0.14"
description = "Browser automation for PraisonAI — Chrome extension bridge, CDP, and Playwright agents extracted from the praisonai wrapper."
readme = "README.md"
license = {text = "MIT"}
+2 -2
View File
@@ -920,7 +920,7 @@ wheels = [
[[package]]
name = "praisonai-browser"
version = "0.0.13"
version = "0.0.14"
source = { editable = "." }
dependencies = [
{ name = "click" },
@@ -964,7 +964,7 @@ provides-extras = ["server", "playwright", "all"]
[[package]]
name = "praisonaiagents"
version = "1.6.166"
version = "1.6.167"
source = { directory = "../praisonai-agents" }
dependencies = [
{ name = "aiohttp" },
@@ -12,6 +12,6 @@ the remaining terminal-agent modules, with PEP 562 shims left behind at the
old ``praisonai.*`` paths for backward compatibility.
"""
__version__ = "0.0.62"
__version__ = "0.0.63"
__all__ = ["__version__"]
@@ -491,13 +491,6 @@ def _run_print_code(
except Exception:
resolved_session = None
workspace = os.environ.get("PRAISONAI_WORKSPACE") or os.getcwd()
tool_groups = ["basic", "acp", "edit", "search", "lsp"]
code_tools = _get_headless_code_tools(
groups=tool_groups,
workspace=workspace,
)
agent_config = {
"name": "CodeAgent",
"role": "Code Assistant",
@@ -510,7 +503,6 @@ def _run_print_code(
# A minimal output preset keeps the agent from printing its own
# decorations so stdout carries only our envelope.
"output": "minimal",
"tools": code_tools,
}
if model:
agent_config["llm"] = model
@@ -532,6 +524,11 @@ def _run_print_code(
result = None
error_message = None
try:
workspace = os.environ.get("PRAISONAI_WORKSPACE") or os.getcwd()
agent_config["tools"] = _get_headless_code_tools(
groups=["acp", "edit", "search", "lsp"],
workspace=workspace,
)
agent = Agent(**agent_config)
if thinking_budget is not None:
agent.thinking_budget = thinking_budget
@@ -300,9 +300,25 @@ def session_resume(
if restored.chat_history:
output.print("\n--- Restored Conversation ---\n")
for msg in restored.chat_history[-10:]:
limit = 20
total = len(restored.chat_history)
shown = restored.chat_history[-limit:] if total > limit else restored.chat_history
if total > len(shown):
output.print(f"… {total - len(shown)} earlier turns\n")
for msg in shown:
role = msg.get("role", "?")
content = msg.get("content", "")
if not content:
tool_calls = msg.get("tool_calls")
if isinstance(tool_calls, list) and tool_calls:
names = ", ".join(
(tc.get("function", {}) or {}).get("name", "tool")
for tc in tool_calls
if isinstance(tc, dict)
)
content = f"[tool call] {names}" if names else "[tool call]"
else:
continue
output.print(f"[{role}] {content}")
return
@@ -232,30 +232,146 @@ def _load_basic_tools() -> Dict[str, Callable]:
return tools
def _load_search_tools() -> Dict[str, Callable]:
def _load_search_tools(config: ToolConfig) -> Dict[str, Callable]:
"""Lazy load fast codebase-search tools from praisonaiagents.
Wires the existing core search builtins into the default interactive
toolset so the coding agent no longer has to shell out to a raw
``grep -rn``: ripgrep-backed ``grep`` and ``glob`` (gitignore-aware,
result-capped, truncation-safe) and structural ``ast_grep_search``.
The core ``grep``/``glob`` contain paths to ``os.getcwd()`` and
``ast_grep_search`` performs no containment at all. In headless
``code -p --workspace <dir>`` mode the process is not chdir'd into the
selected workspace, so a model-supplied ``path`` (relative *or* absolute)
could read outside it. Fail closed by binding every search tool's ``path``
to the configured workspace before delegating — mirroring the containment
the edit/ACP/LSP groups already enforce. If containment is unavailable we
do not expose the search tools rather than expose an unbounded reader.
The wrapper resolves the caller path against the workspace root and then
delegates with the process cwd temporarily pinned to that root, so the
core tool's own ``os.getcwd()``-based containment agrees with ours instead
of rejecting a legitimate absolute in-workspace path.
"""
tools = {}
tools: Dict[str, Callable] = {}
try:
from praisonaiagents.tools import grep
import contextlib
from pathlib import Path
from praisonaiagents.tools.path_safety import resolve_within_root
except ImportError as e:
logger.warning(
f"Search tools disabled: path containment unavailable: {e}"
)
return tools
root = str(Path(config.workspace).resolve())
def _contained(path: Optional[str]) -> Optional[str]:
"""Resolve *path* under the workspace root, or ``None`` if it escapes."""
return resolve_within_root(path or ".", root)
@contextlib.contextmanager
def _in_workspace():
"""Pin the process cwd to the workspace root for the delegated call.
The core search builtins hard-bind containment/relative resolution to
``os.getcwd()``. Restored in a ``finally`` so it never leaks. Best
effort: if the chdir fails we still return the (already contained)
absolute path to the core tool.
"""
prev = None
try:
prev = os.getcwd()
except OSError:
prev = None
try:
os.chdir(root)
except OSError:
pass
try:
yield
finally:
if prev is not None:
try:
os.chdir(prev)
except OSError:
pass
try:
from praisonaiagents.tools import grep as _grep
def grep(pattern: str, path: str = ".", glob: Optional[str] = None,
case_insensitive: bool = False, max_results: int = 100) -> str:
"""Search file contents for a regex/literal pattern in the workspace.
Args:
pattern: Regex/literal to search for.
path: Directory/file under the workspace to search.
glob: Optional filename glob (e.g. ``"*.py"``).
case_insensitive: Case-insensitive matching when True.
max_results: Hard cap on the number of matching lines returned.
Returns:
``path:line: match`` entries, or an error string.
"""
safe = _contained(path)
if safe is None:
return f"Error: path {path!r} escapes the workspace."
with _in_workspace():
return _grep(pattern, safe, glob, case_insensitive, max_results)
tools["grep"] = grep
except ImportError:
logger.debug("grep not available")
try:
from praisonaiagents.tools import glob
from praisonaiagents.tools import glob as _glob
def glob(pattern: str, path: str = ".", max_results: int = 100) -> str:
"""Return files matching a glob pattern under the workspace.
Args:
pattern: Glob pattern (e.g. ``"**/*.py"``).
path: Directory under the workspace to search.
max_results: Hard cap on the number of paths returned.
Returns:
Newline-joined relative file paths, or an error string.
"""
safe = _contained(path)
if safe is None:
return f"Error: path {path!r} escapes the workspace."
with _in_workspace():
return _glob(pattern, safe, max_results)
tools["glob"] = glob
except ImportError:
logger.debug("glob not available")
try:
from praisonaiagents.tools import ast_grep_search
from praisonaiagents.tools import ast_grep_search as _ast_grep_search
def ast_grep_search(pattern: str, lang: str, path: str = ".",
json_output: bool = True) -> str:
"""Structural (AST) code search under the workspace.
Args:
pattern: AST pattern (e.g. ``"def $FN($$$)"``).
lang: Programming language (python, javascript, ...).
path: Directory/file under the workspace to search.
json_output: Return JSON when True.
Returns:
Search results, or an error string.
"""
safe = _contained(path)
if safe is None:
return f"Error: path {path!r} escapes the workspace."
return _ast_grep_search(pattern, lang, safe, json_output)
tools["ast_grep_search"] = ast_grep_search
except ImportError:
logger.debug("ast_grep_search not available")
@@ -557,7 +673,7 @@ def get_interactive_tools(
# Load fast search tools (always available, no runtime needed)
if config.enable_search and not (disable and "search" in disable):
search_tools = _load_search_tools()
search_tools = _load_search_tools(config)
all_tools.update(search_tools)
# Load targeted/fuzzy edit tools (no runtime needed)
@@ -800,6 +800,43 @@ class AsyncTUI:
return None
return self._registry
def _replay_history(self, history, limit: int = 20) -> None:
"""Redraw a restored conversation as live chat messages.
Reuses the same renderer that draws live turns so a resumed session
shows prior user/assistant turns (and tool activity) exactly as they
appeared, instead of only a one-line summary. Bounded to the last
``limit`` turns with a note about older turns.
"""
if not history:
return
total = len(history)
shown = history[-limit:] if limit and total > limit else history
if total > len(shown):
self.messages.append(ChatMessage(
role="system",
content=f"… {total - len(shown)} earlier turns",
))
for msg in shown:
role = msg.get("role", "assistant")
content = msg.get("content", "")
if role == "tool":
role = "system"
if not content:
tool_calls = msg.get("tool_calls")
if isinstance(tool_calls, list) and tool_calls:
names = ", ".join(
(tc.get("function", {}) or {}).get("name", "tool")
for tc in tool_calls
if isinstance(tc, dict)
)
content = f"[tool call] {names}" if names else "[tool call]"
else:
continue
self.messages.append(ChatMessage(role=role, content=content))
def _handle_command(self, command: str) -> bool:
"""Handle slash commands. Returns True if handled."""
parts = command.split(maxsplit=1)
@@ -1000,13 +1037,24 @@ Tips:
session = store.load(sorted_sessions[0].get("session_id"))
if session:
self.session_id = session.session_id
# Load history
history = session.get_chat_history()
# Load the full stored history (not the default
# 50-message tail) so the replay's "… N earlier
# turns" note reflects the true total and older
# turns aren't silently dropped before bounding.
history = session.get_chat_history(
max_messages=max(session.message_count, 1)
)
self._conversation_history = history
# Reset the display so repeated /continue calls (or
# continuing after prior activity) redraw the
# restored conversation cleanly instead of appending
# to — and duplicating — stale on-screen turns.
self.messages.clear()
self.messages.append(ChatMessage(
role="system",
content=f"Continued session: {self.session_id} ({len(history)} messages)"
))
self._replay_history(history)
else:
self.messages.append(ChatMessage(role="system", content="Could not load session."))
except Exception as e:
@@ -1721,10 +1769,22 @@ Example: /handoff code "refactor the auth module" """
continue
if user_input.startswith("/"):
before = len(self.messages)
self._handle_command(user_input)
for msg in self.messages:
if msg.role == "system":
# Commands like /continue clear and repopulate the message
# list with restored user/assistant turns. Render every
# newly-appended message (not just system lines) so the
# replayed conversation is visible in fallback mode too.
new_msgs = self.messages[before:] if len(self.messages) >= before else list(self.messages)
for msg in new_msgs:
if msg.role == "user":
print(f"› {msg.content}")
elif msg.role == "assistant":
print(f"● {msg.content}")
else:
print(f" {msg.content}")
# Drop rendered system lines; keep user/assistant turns as
# conversational context for subsequent prompts.
self.messages = [m for m in self.messages if m.role != "system"]
continue
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "praisonai-code"
version = "0.0.62"
version = "0.0.63"
description = "Agentic terminal CLI for PraisonAI — the terminal-native agent (run, chat, code, runtime, cli backends) extracted from the praisonai wrapper."
readme = "README.md"
license = {text = "MIT"}
@@ -132,7 +132,7 @@ def test_code_print_uses_supported_agent_kwargs_and_workspace_tools(monkeypatch,
assert captured["output"] == "minimal"
assert captured["tools"]
assert captured["workspace"] == str(tmp_path)
assert captured["tool_groups"] == ["basic", "acp", "edit", "search", "lsp"]
assert captured["tool_groups"] == ["acp", "edit", "search", "lsp"]
@pytest.mark.skipif(
@@ -154,6 +154,7 @@ def test_code_print_real_agent_writes_workspace(tmp_path):
"gpt-4o-mini",
"--workspace",
str(tmp_path),
"--dangerously-skip-approval",
f"Create {target.name} containing exactly HEADLESS_OK.",
],
)
@@ -164,6 +165,40 @@ def test_code_print_real_agent_writes_workspace(tmp_path):
assert target.read_text(encoding="utf-8").strip() == "HEADLESS_OK"
def test_headless_search_tools_are_workspace_bound(tmp_path):
"""Search tools must contain reads to --workspace, not the process cwd.
The core ``grep``/``glob`` default containment to ``os.getcwd()`` and
``ast_grep_search`` performs none, so the headless loader must bind them to
the configured workspace. A model-supplied path that escapes the workspace
(absolute or ``..``) must be rejected without touching the filesystem.
"""
from praisonai_code.cli.features.interactive_tools import (
ToolConfig,
_load_search_tools,
)
(tmp_path / "inside.txt").write_text("needle here", encoding="utf-8")
config = ToolConfig(workspace=str(tmp_path))
tools = _load_search_tools(config)
by_name = {t.__name__: t for t in tools.values()}
# grep is always available (pure-Python fallback), so assert on it.
grep = by_name["grep"]
assert "escapes the workspace" in grep("needle", path="/etc")
assert "escapes the workspace" in grep("needle", path="../../..")
# An in-workspace search still works and finds the seeded match.
assert "inside.txt" in grep("needle", path=".")
if "glob" in by_name:
assert "escapes the workspace" in by_name["glob"]("*", path="/etc")
if "ast_grep_search" in by_name:
assert "escapes the workspace" in by_name["ast_grep_search"](
"x", lang="python", path="/etc"
)
def test_code_print_text_mode_clean_stdout(monkeypatch):
"""`-p --output text` prints just the result text, no envelope or decorations."""
import praisonaiagents
+3 -3
View File
@@ -5,8 +5,8 @@ resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.14' and sys_platform != 'win32'",
"python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'",
"python_full_version < '3.11' and sys_platform == 'win32'",
"python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'",
"python_full_version < '3.11' and sys_platform == 'win32'",
"python_full_version < '3.11' and sys_platform != 'win32'",
]
@@ -1358,7 +1358,7 @@ wheels = [
[[package]]
name = "praisonai-code"
version = "0.0.62"
version = "0.0.63"
source = { editable = "." }
dependencies = [
{ name = "click" },
@@ -1391,7 +1391,7 @@ requires-dist = [
[[package]]
name = "praisonaiagents"
version = "1.6.166"
version = "1.6.167"
source = { directory = "../praisonai-agents" }
dependencies = [
{ name = "aiohttp" },
@@ -1 +1 @@
__version__ = "0.0.6"
__version__ = "0.0.7"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "praisonai-deploy"
version = "0.0.6"
version = "0.0.7"
description = "Deployment for PraisonAI — API, Docker, and cloud (AWS, Azure, GCP) deployments extracted from the praisonai wrapper."
readme = "README.md"
license = {text = "MIT"}
+2 -2
View File
@@ -965,7 +965,7 @@ wheels = [
[[package]]
name = "praisonai-deploy"
version = "0.0.6"
version = "0.0.7"
source = { editable = "." }
dependencies = [
{ name = "click" },
@@ -1002,7 +1002,7 @@ provides-extras = ["api", "all"]
[[package]]
name = "praisonaiagents"
version = "1.6.166"
version = "1.6.167"
source = { directory = "../praisonai-agents" }
dependencies = [
{ name = "aiohttp" },
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.0.13"
__version__ = "0.0.14"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "praisonai-mcp"
version = "0.0.13"
version = "0.0.14"
description = "MCP server hosting for PraisonAI — expose agents and tools to Cursor, Claude Desktop, and other MCP clients."
readme = "README.md"
license = {text = "MIT"}
+3 -3
View File
@@ -5,8 +5,8 @@ resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.14' and sys_platform != 'win32'",
"python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'",
"python_full_version < '3.11' and sys_platform == 'win32'",
"python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'",
"python_full_version < '3.11' and sys_platform == 'win32'",
"python_full_version < '3.11' and sys_platform != 'win32'",
]
@@ -1032,7 +1032,7 @@ wheels = [
[[package]]
name = "praisonai-mcp"
version = "0.0.13"
version = "0.0.14"
source = { editable = "." }
dependencies = [
{ name = "click" },
@@ -1072,7 +1072,7 @@ provides-extras = ["server", "auth", "all"]
[[package]]
name = "praisonaiagents"
version = "1.6.166"
version = "1.6.167"
source = { directory = "../praisonai-agents" }
dependencies = [
{ name = "aiohttp" },
@@ -1,6 +1,6 @@
{
"version": "1.5.87",
"lastUpdated": "2026-08-14",
"lastUpdated": "2026-08-15",
"generatedBy": "praisonai._dev.parity.generator",
"sourceOfTruth": "Python SDK (praisonaiagents)",
"status": "PARITY_ACHIEVED",
@@ -1 +1 @@
__version__ = "0.0.11"
__version__ = "0.0.12"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "praisonai-sandbox"
version = "0.0.11"
version = "0.0.12"
description = "Isolated agent code execution for PraisonAI — Docker, E2B, Modal, Novita, Sandlock, SSH backends extracted from the praisonai wrapper."
readme = "README.md"
license = {text = "MIT"}
+31 -4
View File
@@ -1331,6 +1331,28 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
]
[[package]]
name = "novita-sandbox"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "dockerfile-parse" },
{ name = "httpcore" },
{ name = "httpx" },
{ name = "packaging" },
{ name = "protobuf" },
{ name = "pydantic" },
{ name = "python-dateutil" },
{ name = "rich" },
{ name = "typing-extensions" },
{ name = "wcmatch" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1d/40/7bc405ef0335a292296c131a8789361df251864127f9903903fde770c272/novita_sandbox-2.1.0.tar.gz", hash = "sha256:dab6508370ea53b037db754ccf9e7bb9e559ce6966586ab8921c44365c0a55cb", size = 495277, upload-time = "2026-08-12T05:42:02.023Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/1a/c9d35a60ca68d263f2955eca325e0ff0ebd0333623ce6794ca50265d11d1/novita_sandbox-2.1.0-py3-none-any.whl", hash = "sha256:834d007a88b960345238463a68a1e4d3d4e225378e96948b199824fc93e33a8a", size = 623296, upload-time = "2026-08-12T05:42:00.293Z" },
]
[[package]]
name = "obstore"
version = "0.8.2"
@@ -1584,7 +1606,7 @@ wheels = [
[[package]]
name = "praisonai-sandbox"
version = "0.0.11"
version = "0.0.12"
source = { editable = "." }
dependencies = [
{ name = "click" },
@@ -1600,6 +1622,7 @@ all = [
{ name = "docker" },
{ name = "e2b-code-interpreter" },
{ name = "modal" },
{ name = "novita-sandbox" },
{ name = "sandlock" },
]
daytona = [
@@ -1614,6 +1637,9 @@ e2b = [
modal = [
{ name = "modal" },
]
novita = [
{ name = "novita-sandbox" },
]
sandlock = [
{ name = "sandlock" },
]
@@ -1629,17 +1655,18 @@ requires-dist = [
{ name = "docker", marker = "extra == 'docker'", specifier = ">=7.0.0" },
{ name = "e2b-code-interpreter", marker = "extra == 'e2b'", specifier = ">=1.0.0" },
{ name = "modal", marker = "extra == 'modal'", specifier = ">=0.64.0" },
{ name = "praisonai-sandbox", extras = ["docker", "e2b", "sandlock", "ssh", "modal", "daytona"], marker = "extra == 'all'" },
{ name = "novita-sandbox", marker = "extra == 'novita'", specifier = ">=2.0.0" },
{ name = "praisonai-sandbox", extras = ["docker", "e2b", "sandlock", "ssh", "modal", "daytona", "novita"], marker = "extra == 'all'" },
{ name = "praisonaiagents", directory = "../praisonai-agents" },
{ name = "rich", specifier = ">=13.7" },
{ name = "sandlock", marker = "extra == 'sandlock'", specifier = ">=0.1.0" },
{ name = "typer", specifier = ">=0.12.0" },
]
provides-extras = ["docker", "e2b", "sandlock", "ssh", "modal", "daytona", "all"]
provides-extras = ["docker", "e2b", "sandlock", "ssh", "modal", "daytona", "novita", "all"]
[[package]]
name = "praisonaiagents"
version = "1.6.166"
version = "1.6.167"
source = { directory = "../praisonai-agents" }
dependencies = [
{ name = "aiohttp" },
@@ -1 +1 @@
__version__ = "0.0.17"
__version__ = "0.0.18"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "praisonai-train"
version = "0.0.17"
version = "0.0.18"
description = "Training for PraisonAI — LLM fine-tuning (Unsloth) and iterative agent training extracted from the praisonai wrapper."
readme = "README.md"
license = "MIT"
+2 -2
View File
@@ -2472,7 +2472,7 @@ wheels = [
[[package]]
name = "praisonai-train"
version = "0.0.17"
version = "0.0.18"
source = { editable = "." }
dependencies = [
{ name = "click" },
@@ -2558,7 +2558,7 @@ provides-extras = ["llm", "vision", "all"]
[[package]]
name = "praisonaiagents"
version = "1.6.166"
version = "1.6.167"
source = { directory = "../praisonai-agents" }
dependencies = [
{ name = "aiohttp" },
+1 -1
View File
@@ -1,6 +1,6 @@
{
"version": "1.5.87",
"lastUpdated": "2026-08-14",
"lastUpdated": "2026-08-15",
"generatedBy": "praisonai._dev.parity.generator",
"sourceOfTruth": "Python SDK (praisonaiagents)",
"summary": {
+1 -1
View File
@@ -1,6 +1,6 @@
# Feature Parity Tracker
> **Version:** 1.5.87 | **Last Updated:** 2026-08-14
> **Version:** 1.5.87 | **Last Updated:** 2026-08-15
> **Source of Truth:** Python SDK (praisonaiagents)
## Summary
+222 -20
View File
@@ -41,10 +41,6 @@ curl -fsSL https://praison.ai/install.sh | bash
<img src=".github/images/dashboard.png" alt="PraisonAI Dashboard" width="800" />
</p>
<p align="center">
<img src=".github/images/agentflow.gif" alt="PraisonAI AgentFlow" width="800" />
</p>
```
██████╗ ██████╗ █████╗ ██╗███████╗ ██████╗ ███╗ ██╗ █████╗ ██╗
██╔══██╗██╔══██╗██╔══██╗██║██╔════╝██╔═══██╗████╗ ██║ ██╔══██╗██║
@@ -56,12 +52,6 @@ curl -fsSL https://praison.ai/install.sh | bash
pip install praisonai
```
<p align="center">
<img src=".github/images/latest_ai_news_and_crawl_each_url_to_find_info.gif" alt="PraisonAI command execution" width="800" />
</p>
\* `export TAVILY_API_KEY=xxxxx`
<div align="center">
<a href="https://docs.praison.ai">
<p align="center">
@@ -106,6 +96,204 @@ agent.start("Analyze the top 3 tech trends of 2026 and format as a markdown tabl
---
## 🧬 The Five-Layer Agent Stack
Most frameworks hand you one or two layers and leave the rest as homework. PraisonAI covers **all five** — plus the outer layer that decides *where* your agent actually runs.
Each layer wraps the one inside it. When an agent misbehaves, the layer tells you where to look.
```
┌─────────────────────────────────────────────────────────────────┐
│ ⬡ MANAGED AGENTS — Where does it actually run? │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 5 · GRAPH — Who runs when, and who checks whom? │ │
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
│ │ │ 4 · LOOP — When do we stop? │ │ │
│ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │
│ │ │ │ 3 · HARNESS — Can it act, and be checked? │ │ │ │
│ │ │ │ ┌─────────────────────────────────────────────────┐ │ │ │ │
│ │ │ │ │ 2 · CONTEXT — Is the right thing in the window? │ │ │ │ │
│ │ │ │ │ ┌─────────────────────────────────────────────┐ │ │ │ │ │
│ │ │ │ │ │ 1 · PROMPT — Did I say it clearly? │ │ │ │ │ │
│ │ │ │ │ └─────────────────────────────────────────────┘ │ │ │ │ │
│ │ │ │ └─────────────────────────────────────────────────┘ │ │ │ │
│ │ │ └─────────────────────────────────────────────────────┘ │ │ │
│ │ └─────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
| Layer | The question it answers | PraisonAI |
|:--|:--|:--|
| **1 · Prompt** | Did I say it clearly? | `instructions=`, `role`/`goal`/`backstory`, `output=`, `templates=` |
| **2 · Context** | Is the right thing in the window? | `memory=`, `knowledge=`, `context=`, handoff `ContextPolicy` |
| **3 · Harness** | Can it act, and be checked? | `tools=`, `MCP()`, `guardrails=`, `approval=`, `hooks=`, `sandbox=` |
| **4 · Loop** | When do we stop? | `execution=ExecutionConfig(...)`, `reflection=`, `autonomy=`, doom-loop detection |
| **5 · Graph** | Who runs when, and who checks whom? | `AgentFlow`, `route()`, `parallel()`, `loop()`, `repeat()` |
| **⬡ Managed** | *Where does it actually run?* | `run_on="docker"` — one shared remote sandbox, or a fully hosted loop |
### Layer 1 · Prompt — *Did I say it clearly?*
Role, instructions, examples, output format.
```python
from praisonaiagents import Agent
agent = Agent(
role="Senior Data Analyst",
goal="Turn raw numbers into decisions",
output="verbose", # markdown-formatted output
)
agent.start("Summarise Q3 revenue trends")
```
### Layer 2 · Context — *Is the right thing in the window?*
Write, select, compress, isolate — the four context operations, one parameter each.
```python
from praisonaiagents import Agent
agent = Agent(
instructions="You are a support engineer.",
memory={"user_id": "u-42"}, # write — persists across runs (needs a user_id)
knowledge=["docs/"], # select — retrieves only what's relevant
context="summarize", # compress — auto-compacts before the limit
)
```
> **Isolate** is `handoffs=[specialist]` — a sub-agent inherits the last few messages and the intersection of your tools, not your whole transcript. [📖 Handoffs](https://docs.praison.ai/docs/concepts/handoffs)
### Layer 3 · Harness — *Can it act, and be checked?*
*Agent = Model + Harness.* Tool dispatch, plus the guides that steer before acting and the sensors that observe after.
```python
from praisonaiagents import Agent, MCP, tool
@tool
def deploy(env: str) -> str:
"""Deploy the current build to an environment."""
return f"Deployed to {env}"
agent = Agent(
name="ReleaseEngineer",
instructions="You are a release engineer.",
tools=[deploy, MCP("npx -y @modelcontextprotocol/server-filesystem /tmp")],
approval=True, # guide — human gate before risky tools run
)
agent.start("Deploy to staging, then list the files you can read")
```
### Layer 4 · Loop — *When do we stop?*
Hard iteration caps, budget ceilings, no-progress detection and completion checks — every brake is explicit.
```python
from praisonaiagents import Agent, ExecutionConfig
agent = Agent(
instructions="Fix the failing tests.",
execution=ExecutionConfig(max_iter=30, max_budget=0.50, on_budget_exceeded="stop"),
reflection=True, # completion check — the agent grades its own answer
autonomy=True, # required to drive the loop with run_autonomous()
)
result = agent.run_autonomous("Refactor the auth module", max_iterations=5)
print(result.completion_reason)
# goal | no_tool_calls | max_iterations | timeout | doom_loop | needs_help | error
# (with on_budget_exceeded="stop", hitting the cap raises BudgetExceededError,
# surfaced here as completion_reason="error")
```
> **Doom-loop detection is on by default.** Repeated identical tool calls and A→B→A→B oscillation get caught — while a poller whose output keeps changing does not. [📖 Doom Loop Detection](https://docs.praison.ai/docs/features/doom-loop-detection)
### Layer 5 · Graph — *Who runs when, and who checks whom?*
Topology as a versionable artifact: prompt chaining, routing, parallelisation, orchestrator-worker.
```python
from praisonaiagents import AgentFlow
from praisonaiagents.workflows import route, parallel, repeat
flow = AgentFlow(steps=[
classifier,
route({"bug": [bug_agent], "feature": [feature_agent], "default": [triage]}),
parallel([reviewer, tester]), # fan out, join automatically
repeat(editor, until=lambda ctx: "approved" in ctx.previous_result.lower(),
max_iterations=3), # evaluator–optimizer
])
flow.run("Ticket #123: login fails on Safari")
```
> The same graph is expressible in YAML with no Python at all. [📖 AgentFlow](https://docs.praison.ai/docs/concepts/agentflow)
### ⬡ Outside the stack: Managed Agents — *Where does it actually run?*
The harness is commoditising; **where** the agent executes is the next multiplier. Rather than burning your laptop's CPU, hand an agent a short-lived cloud sandbox — repo, tools and tests run there.
```bash
pip install praisonai
```
The simplest way in is `run_on=` — one whole team or workflow shares **one** sandbox, so a file written by step 1 is there for step 2:
```python
from praisonaiagents import Agent, AgentFlow
writer = Agent(name="Writer", instructions="You write files.")
reader = Agent(name="Reader", instructions="You read files.")
flow = AgentFlow(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
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, pick the axis you need — remote **tools** or a remote **loop**:
```python
from praisonai import Agent, LocalAgent, LocalAgentConfig, HostedAgent
# A. Tools run in a remote sandbox; the agent loop stays local
agent = Agent(name="builder", backend=LocalAgent(
compute="e2b", # or modal | daytona | flyio | docker | tenki
config=LocalAgentConfig(model="gpt-4o-mini", name="RemoteTools"),
))
# B. The entire agent loop runs in the cloud (needs ANTHROPIC_API_KEY)
agent = Agent(name="teacher", backend=HostedAgent(provider="anthropic"))
agent.start("Write a Python script that prints the first 10 primes, then run it")
```
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>"`
<sub>Stack framing adapted from [The Five-Layer Agent Stack](https://mer.vin/2026/07/five-layer-agent-stack-match-bug-to-right-layer/) and [Agent Harnesses vs Orbs](https://mer.vin/2026/08/agent-harnesses-vs-orbs-why-remote-sandboxes-beat-local-agent-loops/).</sub>
---
## 🌌 The PraisonAI Ecosystem
Start simple with the core SDK, or expand to full visual builders and dashboards when you're ready.
@@ -200,9 +388,9 @@ Powered by 100+ LLMs (OpenAI, Anthropic, Gemini & local models).
| 🧠 | **Planning Mode** — plan → execute → reason | `planning=True` |
| 🔍 | **Deep Research** — multi-step autonomous research | [Docs](https://docs.praison.ai/docs/agents/deep-research) |
| 🤖 | **External Agents** — orchestrate Claude Code, Gemini CLI, Codex | [Docs](https://docs.praison.ai/docs/code/external-agents) |
| 🔄 | **Agent Handoffs** — seamless conversation passing | `handoff=True` |
| 🔄 | **Agent Handoffs** — seamless conversation passing | `handoffs=[other_agent]` |
| 🛡️ | **Guardrails** — input/output validation | [Docs](https://docs.praison.ai/docs/concepts/guardrails) |
| | **Web Search + Fetch** — native browsing | `web_search=True` |
| | **Web Search + Fetch** — native browsing | `web=True` |
| 🪞 | **Self Reflection** — agent reviews its own output | [Docs](https://docs.praison.ai/docs/concepts/reflection) |
| 🔀 | **Workflow Patterns** — route, parallel, loop, repeat | [Docs](https://docs.praison.ai/docs/concepts/agentflow) |
| 🧠 | **Memory (zero deps)** — works out of the box | `memory=True` |
@@ -212,9 +400,9 @@ Powered by 100+ LLMs (OpenAI, Anthropic, Gemini & local models).
| | Feature | How |
|--|---------|-----|
| 💡 | **Prompt Caching** — reduce latency + cost | `prompt_caching=True` |
| 💡 | **Prompt Caching** — reduce latency + cost | `caching=True` |
| 💾 | **Sessions + Auto-Save** — persistent state across restarts | `auto_save="my-project"` |
| 💭 | **Thinking Budgets** — control reasoning depth | `thinking_budget=1024` |
| 💭 | **Thinking Budgets** — control reasoning depth | `agent.thinking_budget = 1024` |
| 📚 | **RAG + Quality-Based RAG** — auto quality scoring retrieval | [Docs](https://docs.praison.ai/docs/concepts/rag) |
| 📊 | **Model Router** — auto-routes to cheapest capable model | [Docs](https://docs.praison.ai/docs/features/model-router) |
| 🧊 | **Shadow Git Checkpoints** — auto-rollback on failure | [Docs](https://docs.praison.ai/docs/features/checkpoints) |
@@ -341,8 +529,10 @@ from praisonaiagents import Agent, db
agent = Agent(
name="Assistant",
db=db(database_url="postgresql://localhost/mydb"),
session_id="my-session"
memory={
"db": db(database_url="postgresql://localhost/mydb"),
"session_id": "my-session",
},
)
agent.chat("Hello!") # Auto-persists messages, runs, traces
```
@@ -788,6 +978,16 @@ PraisonAI is built for speed, with agent instantiation in around 14μs. This red
---
<p align="center">
<img src=".github/images/agentflow.gif" alt="PraisonAI AgentFlow" width="800" />
</p>
<p align="center">
<img src=".github/images/latest_ai_news_and_crawl_each_url_to_find_info.gif" alt="PraisonAI command execution" width="800" />
</p>
\* `export TAVILY_API_KEY=xxxxx`
## 🔍 Langfuse Tracing
```bash
@@ -891,8 +1091,10 @@ from praisonaiagents import Agent, db
agent = Agent(
name="Assistant",
db=db(database_url="postgresql://localhost/mydb"),
session_id="my-session"
memory={
"db": db(database_url="postgresql://localhost/mydb"),
"session_id": "my-session",
},
)
```
@@ -908,8 +1110,8 @@ from praisonaiagents import Agent
agent = Agent(
name="Assistant",
memory=True, # Enables file-based memory (no extra deps!)
user_id="user123"
# Enables file-based memory (no extra deps!)
memory={"user_id": "user123"},
)
```
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.162.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.162.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.163.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.163.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
+85 -8
View File
@@ -59,10 +59,11 @@ class ACPServer:
self._sessions: Dict[str, ACPSession] = {}
self._client = None # ACP client connection
self._cancelled_sessions: set = set()
self._agents_by_workspace: Dict[str, Any] = {}
_setup_stderr_logging(self.config.debug)
def _get_agent(self):
def _get_agent(self, workspace: Optional[Path] = None):
"""Lazy load or create agent."""
if self._agent is not None:
return self._agent
@@ -70,18 +71,85 @@ class ACPServer:
if self._agents is not None:
return self._agents
# Create default agent
workspace = Path(workspace or self.config.workspace).resolve()
cache_key = str(workspace)
if cache_key in self._agents_by_workspace:
return self._agents_by_workspace[cache_key]
# Create a workspace-scoped default agent.
try:
from praisonaiagents import Agent
self._agent = Agent(
name="PraisonAI",
instructions="You are a helpful AI coding assistant.",
model=self.config.model or "gpt-4o-mini",
tools = self._filter_workspace_tools(
self._load_workspace_tools(workspace)
)
return self._agent
agent = Agent(
name="PraisonAI",
instructions=(
"You are a coding assistant inside an editor. Use the "
"provided tools to inspect and modify files when permitted. "
"Keep all file operations inside the workspace."
),
model=self.config.model or "gpt-4o-mini",
tools=tools,
output="minimal",
approval=True if self.config.approval_mode == "auto" else False,
)
self._agents_by_workspace[cache_key] = agent
return agent
except ImportError:
logger.error("praisonaiagents not installed")
raise RuntimeError("praisonaiagents package required")
def _load_workspace_tools(self, workspace: Path) -> List[Any]:
"""Load the canonical coding tools bound to an ACP session workspace."""
from praisonai_code.cli.features.interactive_tools import (
ToolConfig,
get_interactive_tools,
)
tool_config = ToolConfig(
workspace=str(workspace),
approval_mode=self.config.approval_mode,
)
return get_interactive_tools(
groups=["basic", "acp", "edit", "search", "lsp"],
config=tool_config,
workspace=str(workspace),
)
def _filter_workspace_tools(self, tools: List[Any]) -> List[Any]:
"""Apply ACPConfig capability gates before tools reach the model."""
read_tools = {
"read_file", "list_files", "grep", "glob", "ast_grep_search",
"lsp_list_symbols", "lsp_find_definition", "lsp_find_references",
"lsp_get_diagnostics",
}
write_tools = {
"write_file", "edit_file", "apply_patch", "acp_create_file",
"acp_edit_file", "acp_delete_file",
}
shell_tools = {"execute_command", "acp_execute_command"}
network_tools = {"internet_search", "web_crawl"}
allowed = set(read_tools)
if self.config.can_write():
allowed.update(write_tools)
if self.config.can_execute_shell():
allowed.update(shell_tools)
if self.config.allow_network:
allowed.update(network_tools)
return [
tool for tool in tools
if getattr(tool, "__name__", "") in allowed
]
@staticmethod
def _reject_unsupported_mcp_servers(mcp_servers: List[Dict[str, Any]]) -> None:
"""Fail closed until client-provided MCP lifecycle is implemented."""
if mcp_servers:
raise ValueError(
"MCP servers are not supported by the PraisonAI ACP server yet"
)
def on_connect(self, client) -> None:
"""Called when client connects."""
@@ -121,6 +189,11 @@ class ACPServer:
"http": False,
"sse": False,
},
"sessionCapabilities": {
"fork": {},
"list": {},
"resume": {},
},
},
"agentInfo": {
"name": "praisonai",
@@ -149,6 +222,8 @@ class ACPServer:
mcp_servers: List of MCP server configurations
"""
logger.info(f"New session: cwd={cwd}")
self._reject_unsupported_mcp_servers(mcp_servers)
workspace = Path(cwd).resolve()
session = ACPSession.create(
@@ -180,6 +255,8 @@ class ACPServer:
Replays conversation history via session/update notifications.
"""
logger.info(f"Load session: session_id={session_id}")
self._reject_unsupported_mcp_servers(mcp_servers)
# Try to load from store
session = self._session_store.load(session_id)
@@ -286,7 +363,7 @@ class ACPServer:
try:
# Get agent response
agent = self._get_agent()
agent = self._get_agent(session.workspace)
# Send thinking update
await self._send_thought(session_id, "Processing your request...")
+66 -9
View File
@@ -922,6 +922,51 @@ class AgentsGenerator:
name, v, tightest,
)
return tightest
# Mirror the legacy CLI's --tool-timeout argparse default so the workflow
# validation can tell an implicit default apart from a user request.
_CLI_TOOL_TIMEOUT_DEFAULT = 60
def _resolve_explicit_workflow_tool_timeout(self, config):
"""Resolve a tool_timeout the user *explicitly* requested, else ``None``.
The workflow path cannot enforce per-tool timeouts, so it fails fast on
a requested one. But the legacy CLI always injects its --tool-timeout
argparse default into ``cli_config`` (see direct_prompt.py), so a plain
``_resolve_effective_tool_timeout`` would flag every ordinary workflow
run. Here a per-agent/role YAML timeout always counts as explicit; a CLI
value only counts when the user actually changed it away from the bare
argparse default. The per-agent/role resolution is delegated to
``_resolve_effective_tool_timeout`` so both paths share one source of
truth and its override warnings still fire.
"""
# A per-agent/role YAML timeout is always an explicit request. Resolve it
# from config alone (ignoring the always-injected CLI default) via the
# shared effective resolver, so the tightest-value warning still fires.
cli_timeout = (self.cli_config or {}).get("tool_timeout")
cli_changed = (
isinstance(cli_timeout, (int, float))
and not isinstance(cli_timeout, bool)
and float(cli_timeout) != float(self._CLI_TOOL_TIMEOUT_DEFAULT)
)
if cli_changed:
return float(cli_timeout)
# No user-changed CLI value: fall back to a YAML-declared timeout, if any.
# Temporarily mask the injected CLI default so the effective resolver
# reports only what the YAML declared.
saved_cli_config = self.cli_config
try:
if isinstance(self.cli_config, dict) and "tool_timeout" in self.cli_config:
self.cli_config = {
k: v for k, v in self.cli_config.items() if k != "tool_timeout"
}
declared = self._resolve_effective_tool_timeout(config)
finally:
self.cli_config = saved_cli_config
if declared and declared > 0:
return float(declared)
return None
def _select_framework(self, framework: str, config: Dict[str, Any]) -> Any:
"""Select and resolve the appropriate framework adapter.
@@ -1337,15 +1382,27 @@ class AgentsGenerator:
# tool_timeout is enforced by _wrap_tool_with_timeout on the
# sequential/hierarchical path via tools_dict. The native workflow engine
# resolves its own tools by name, so that wrapper hook does not apply
# here. Surface the gap explicitly rather than dropping the field
# silently, so a user who set a timeout knows it is not enforced on this
# path.
effective_timeout = self._resolve_effective_tool_timeout(config)
if effective_timeout and effective_timeout > 0:
self.logger.warning(
"tool_timeout=%s is not enforced on the workflow (process: workflow) "
"path; per-tool timeouts apply to sequential/hierarchical runs only.",
effective_timeout,
# here. Fail fast rather than accepting the field and silently ignoring
# it, mirroring _validate_workflow_cli_capabilities which already
# refuses --resume/--session/--fork and --output stream-json on this
# path. A warn-only no-op left the same CLI+YAML surface with silently
# different execution semantics gated on the process: value.
#
# Only an *explicit* request is rejected: the legacy CLI always injects
# its --tool-timeout argparse default (60) into cli_config, so treating
# that implicit value as a user request would break every ordinary
# workflow YAML run. We therefore ignore the bare CLI default and only
# fail on a per-agent/role YAML tool_timeout or a CLI value the user
# actually changed. The per-agent/role resolution is delegated to
# _resolve_effective_tool_timeout so the sequential/hierarchical and
# workflow paths share one source of truth.
explicit_timeout = self._resolve_explicit_workflow_tool_timeout(config)
if explicit_timeout and explicit_timeout > 0:
raise ValueError(
f"tool_timeout={explicit_timeout} is not supported for "
"'process: workflow' YAMLs; per-tool timeouts are only enforced "
"on the sequential/hierarchical path. Either remove tool_timeout "
"or convert to a sequential/hierarchical process."
)
# Pass model from config_list to workflow as default_llm
+19 -7
View File
@@ -1039,7 +1039,10 @@ class AutoGenerator(BaseAutoGenerator):
is_async=is_async,
)
json_data = json.loads(response.model_dump_json())
self.convert_and_save(json_data, merge=merge)
# Off-loop the blocking merge/YAML-dump/fsync so a running event loop
# (e.g. under `praisonai serve`) is not parked; mirrors the run path's
# asyncio.to_thread offload in AgentsGenerator._aload_config.
await asyncio.to_thread(self.convert_and_save, json_data, merge=merge)
full_path = os.path.abspath(self.agent_file)
return full_path
@@ -1546,10 +1549,16 @@ Respond with:
)
json_data = json.loads(response.model_dump_json())
if merge and os.path.exists(self.workflow_file):
return self._save_workflow(self.merge_with_existing_workflow(json_data), pattern)
return self._save_workflow(json_data, pattern)
# Off-loop the blocking merge/read + YAML-dump/fsync so a running event
# loop (e.g. under `praisonai serve`) is not parked; mirrors the run
# path's asyncio.to_thread offload in AgentsGenerator._aload_config.
def _merge_and_save():
if merge and os.path.exists(self.workflow_file):
return self._save_workflow(self.merge_with_existing_workflow(json_data), pattern)
return self._save_workflow(json_data, pattern)
return await asyncio.to_thread(_merge_and_save)
def merge_with_existing_workflow(self, new_data: Dict) -> Dict:
"""
@@ -1955,8 +1964,11 @@ class JobWorkflowAutoGenerator(BaseAutoGenerator):
],
is_async=is_async,
)
return self._save_workflow(response)
# Off-loop the blocking YAML-dump/fsync so a running event loop (e.g.
# under `praisonai serve`) is not parked; mirrors the run path's
# asyncio.to_thread offload in AgentsGenerator._aload_config.
return await asyncio.to_thread(self._save_workflow, response)
def _get_prompt(self, include_judge: bool, include_approve: bool) -> str:
"""Generate the prompt for job workflow generation."""
@@ -653,6 +653,191 @@ def ids_restore(
# ─────────────────────────────────────────────────────────────────────────────
# Register sub-apps onto main app
# ─────────────────────────────────────────────────────────────────────────────
# ─────────────────────────────────────────────────────────────────────────────
# sandbox lifecycle: ps / stop
# ─────────────────────────────────────────────────────────────────────────────
# Providers worth polling for running sandboxes. "local" is excluded: it runs
# on this machine and has nothing to reclaim.
_PS_PROVIDERS = ("docker", "e2b", "modal", "daytona", "flyio", "tenki")
def _discover_instances(provider_filter: Optional[str] = None):
"""Return (instances, errors) for reachable providers.
``instances`` is [(provider_name, InstanceInfo), ...].
``errors`` is [(provider_name, message), ...] for providers that were
*available* (installed + configured) but failed while listing — those must
surface, otherwise ``ps``/``stop --all`` would wrongly report an empty,
successful inventory while sandboxes keep running.
Providers that are not installed or not configured are skipped silently --
absence of an E2B key is a normal state, not an error worth printing.
"""
import asyncio
from praisonaiagents.managed._compute_bridge import resolve_compute
names = [provider_filter] if provider_filter else list(_PS_PROVIDERS)
found = []
errors = []
async def _collect():
for name in names:
try:
provider = resolve_compute(name)
except Exception:
# Not installed / unknown provider. Silent unless explicitly
# requested, where the user deserves to know it went nowhere.
if provider_filter:
errors.append((name, "provider not installed or unknown"))
continue
try:
if not getattr(provider, "is_available", True):
continue # daemon down / no credentials — a normal state
except Exception:
continue
try:
for info in await provider.list_instances():
found.append((name, info))
except Exception as e:
# Available but listing failed: real error, never hide it.
errors.append((name, str(e)))
asyncio.run(_collect())
return found, errors
def _format_uptime(created_at: float) -> str:
import time
if not created_at:
return "-"
seconds = max(0, int(time.time() - created_at))
if seconds < 60:
return f"{seconds}s"
if seconds < 3600:
return f"{seconds // 60}m"
if seconds < 86400:
return f"{seconds // 3600}h{(seconds % 3600) // 60:02d}m"
return f"{seconds // 86400}d{(seconds % 86400) // 3600:02d}h"
@app.command("ps")
def managed_ps(
provider: Optional[str] = typer.Option(
None, "--provider", "-p", help="Only this provider (docker, e2b, modal, ...)"
),
as_json: bool = typer.Option(False, "--json", help="Machine-readable output"),
):
"""List running agent sandboxes.
Sandboxes normally shut themselves down when a run finishes, but a crashed
or killed run can leave one behind. This shows what is still up, so nothing
keeps burning resources unnoticed.
Example:
praisonai managed ps
praisonai managed ps --provider docker
praisonai managed ps --json
"""
rows, errors = _discover_instances(provider)
if as_json:
typer.echo(json.dumps({
"sandboxes": [
{
"provider": name,
"instance_id": info.instance_id,
"status": getattr(info.status, "value", str(info.status)),
"endpoint": info.endpoint,
"created_at": info.created_at,
"metadata": info.metadata,
}
for name, info in rows
],
"errors": [{"provider": name, "error": msg} for name, msg in errors],
}, indent=2))
raise typer.Exit(1 if errors else 0)
if not rows:
typer.echo("No running sandboxes.")
else:
typer.echo(f"{'INSTANCE ID':<26} {'PROVIDER':<10} {'STATUS':<10} {'UPTIME':<8} IMAGE")
typer.echo("-" * 78)
for name, info in rows:
status = getattr(info.status, "value", str(info.status))
image = (info.metadata or {}).get("image") or info.endpoint or ""
typer.echo(
f"{info.instance_id:<26} {name:<10} {status:<10} "
f"{_format_uptime(info.created_at):<8} {image}"
)
typer.echo(f"\n{len(rows)} running. Stop with: praisonai managed stop <instance-id>")
if errors:
typer.echo("\nSome providers could not be queried (sandboxes may still be running):")
for name, msg in errors:
typer.echo(f" {name}: {msg}")
raise typer.Exit(1)
@app.command("stop")
def managed_stop(
instance_id: Optional[str] = typer.Argument(None, help="Instance ID to stop"),
all_instances: bool = typer.Option(False, "--all", help="Stop every running sandbox"),
provider: Optional[str] = typer.Option(
None, "--provider", "-p", help="Only this provider"
),
):
"""Stop a running agent sandbox (or all of them).
Example:
praisonai managed stop docker_a1b2c3d4
praisonai managed stop --all
praisonai managed stop --all --provider docker
"""
import asyncio
from praisonaiagents.managed._compute_bridge import resolve_compute
if not instance_id and not all_instances:
typer.echo("Give an instance ID or --all. See: praisonai managed ps")
raise typer.Exit(1)
rows, errors = _discover_instances(provider)
if instance_id:
rows = [r for r in rows if r[1].instance_id == instance_id]
if not rows:
typer.echo(f"No running sandbox with ID {instance_id!r}. See: praisonai managed ps")
raise typer.Exit(1)
if not rows and not errors:
typer.echo("No running sandboxes.")
return
stopped, failed = 0, 0
for name, info in rows:
try:
asyncio.run(resolve_compute(name).shutdown(info.instance_id))
typer.echo(f"Stopped {info.instance_id} ({name})")
stopped += 1
except Exception as e:
typer.echo(f"Failed to stop {info.instance_id} ({name}): {e}")
failed += 1
typer.echo(f"\n{stopped} stopped" + (f", {failed} failed" if failed else ""))
if errors:
# A provider we could not even inventory may still hold live sandboxes
# we never got the chance to stop. Never claim a clean sweep.
typer.echo("\nSome providers could not be queried (sandboxes may still be running):")
for name, msg in errors:
typer.echo(f" {name}: {msg}")
if failed or errors:
raise typer.Exit(1)
app.add_typer(sessions_app, name="sessions")
app.add_typer(agents_app, name="agents")
app.add_typer(envs_app, name="envs")
@@ -295,7 +295,10 @@ class ClaudeCodeIntegration(BaseCLIIntegration):
Yields:
dict: Parsed JSON events from the CLI
"""
# Use stream-json format for streaming (local parameter, no instance mutation)
# Use stream-json format for streaming (per-call override, no instance
# mutation); drop a caller-supplied output_format so the forced value
# wins instead of raising "multiple values for keyword argument".
options.pop("output_format", None)
cmd = self._build_command(prompt, output_format="stream-json", **options)
async for line in self.stream_async(cmd):
@@ -392,28 +392,53 @@ class DockerCompute:
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, self._shutdown_sync, instance_id)
def _find_container(self, instance_id: str):
"""Look up a container by name, for instances this process didn't start.
``_containers`` only knows about instances provisioned by *this*
process, so a CLI invocation (``praisonai managed stop``) would
otherwise find nothing and silently no-op.
"""
try:
return self._get_client().containers.get(f"praisonai_{instance_id}")
except Exception:
return None
def _shutdown_sync(self, instance_id: str) -> None:
info = self._containers.pop(instance_id, None)
if info:
container = info["container"]
try:
container.stop(timeout=10)
if info["config"].auto_shutdown:
container.remove(force=True)
except Exception as e:
logger.warning("[docker_compute] shutdown error: %s", e)
logger.info("[docker_compute] shutdown: %s", instance_id)
container = info["container"] if info else self._find_container(instance_id)
if container is None:
# Report rather than silently succeeding: callers (and the CLI)
# must be able to tell "stopped it" from "never found it".
raise ValueError(f"No docker container found for instance {instance_id!r}")
# Containers started by another process have no in-process config;
# default to removing, matching ComputeConfig.auto_shutdown's default.
auto_shutdown = info["config"].auto_shutdown if info else True
try:
container.stop(timeout=10)
if auto_shutdown:
container.remove(force=True)
except Exception as e:
logger.warning("[docker_compute] shutdown error: %s", e)
raise
logger.info("[docker_compute] shutdown: %s", instance_id)
async def get_status(self, instance_id: str) -> Any:
from praisonaiagents.managed.protocols import InstanceInfo, InstanceStatus
info = self._containers.get(instance_id)
if not info:
return InstanceInfo(
instance_id=instance_id,
status=InstanceStatus.STOPPED,
provider="docker",
)
# May have been started by another process; look it up by name
# before declaring it stopped.
container = self._find_container(instance_id)
if container is None:
return InstanceInfo(
instance_id=instance_id,
status=InstanceStatus.STOPPED,
provider="docker",
)
info = {"container": container}
try:
info["container"].reload()
@@ -528,23 +553,75 @@ class DockerCompute:
return False
async def list_instances(self) -> list:
"""List every running praisonai-managed container on this host.
Enumerates Docker by the ``praisonai=managed`` label rather than the
in-process ``_containers`` registry, so instances started by an earlier
process (a finished script, a crashed run) are still discoverable --
which is the whole point of being able to find and reclaim strays.
"""
from praisonaiagents.managed.protocols import InstanceInfo, InstanceStatus
result = []
for iid, info in self._containers.items():
import calendar
import email.utils
def _created_at(container, fallback: float) -> float:
"""Best-effort container start time as a POSIX timestamp."""
created = (container.attrs or {}).get("Created")
if not isinstance(created, str) or not created:
return fallback
try:
info["container"].reload()
if info["container"].status == "running":
result.append(InstanceInfo(
instance_id=iid,
status=InstanceStatus.RUNNING,
endpoint=f"docker://{info['container'].id[:12]}",
provider="docker",
created_at=info.get("created_at", 0),
))
# Docker reports RFC3339 with nanosecond precision, which
# datetime cannot parse directly; trim to microseconds.
text = created.replace("Z", "+00:00")
if "." in text:
head, _, tail = text.partition(".")
frac, sign, offset = (
tail.partition("+") if "+" in tail else tail.partition("-")
)
text = f"{head}.{frac[:6]}{sign}{offset}"
from datetime import datetime
return datetime.fromisoformat(text).timestamp()
except Exception:
pass
return result
try:
return calendar.timegm(email.utils.parsedate(created))
except Exception:
return fallback
def _list_sync():
client = self._get_client()
containers = client.containers.list(
filters={"label": "praisonai=managed", "status": "running"}
)
found = []
for container in containers:
instance_id = (container.labels or {}).get("instance_id")
if not instance_id:
# Fall back to the naming convention for older containers.
instance_id = (container.name or "").removeprefix("praisonai_")
if not instance_id:
continue
known = self._containers.get(instance_id, {})
found.append(InstanceInfo(
instance_id=instance_id,
status=InstanceStatus.RUNNING,
endpoint=f"docker://{container.id[:12]}",
provider="docker",
created_at=_created_at(container, known.get("created_at", 0)),
metadata={
"image": (container.image.tags or [""])[0] if container.image else "",
"name": container.name or "",
},
))
return found
try:
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, _list_sync)
except Exception as exc:
logger.debug("[docker_compute] list_instances failed: %s", exc)
return []
def _install_packages_sync(self, container, packages: Dict[str, list]) -> None:
pip_pkgs = packages.get("pip", [])
@@ -1087,15 +1087,15 @@ def ManagedAgent(
- provider="anthropic" → HostedAgent(provider="anthropic", ...)
- provider in {"openai","gemini","ollama","local"} → LocalAgent(...)
(DeprecationWarning: "use LocalAgent directly; put LLM name in model=")
- provider in {"e2b","modal","flyio","daytona","docker"} → raise ValueError
("Cloud compute belongs on LocalAgent(compute=...). Hosted runtimes for
these providers are not yet available.")
- provider in {"e2b","modal","flyio","daytona","docker","tenki"} → emits a
DeprecationWarning and delegates to LocalManagedAgent(compute=<provider>).
(It does NOT raise; prefer LocalAgent(compute="...") directly.)
Returns:
An instance satisfying ``ManagedBackendProtocol``.
Raises:
ValueError: For compute-provider names that should use LocalAgent(compute=).
An instance satisfying ``ManagedBackendProtocol``. Compute-provider
names ({"e2b","modal","flyio","daytona","docker","tenki"}) do NOT
raise — they emit a ``DeprecationWarning`` and delegate to
``LocalManagedAgent(compute=<provider>)``.
"""
# Track if provider was auto-detected to avoid spurious deprecation warnings
auto_detected = provider is None
@@ -190,8 +190,9 @@ def find_latest_report_dir(suite: str, base_dir: Optional[Path] = None) -> Optio
if not candidates:
return None
# Sort by mtime (newest first)
candidates.sort(key=lambda p: p.stat().st_mtime, reverse=True)
# Sort by mtime (newest first), breaking ties by name so timestamp-named
# dirs (e.g. "20260109_140000") remain deterministic when mtimes collide.
candidates.sort(key=lambda p: (p.stat().st_mtime, p.name), reverse=True)
return candidates[0]
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.6.162"
__version__ = "4.6.163"
+8 -8
View File
@@ -12,14 +12,14 @@ dependencies = [
"rich>=13.7",
"markdown>=3.5",
"pyparsing>=3.0.0",
"praisonaiagents>=1.6.166",
"praisonai-code>=0.0.62",
"praisonai-bot>=0.0.47",
"praisonai-train>=0.0.17",
"praisonai-browser>=0.0.13",
"praisonai-mcp>=0.0.13",
"praisonai-sandbox>=0.0.11",
"praisonai-deploy>=0.0.6",
"praisonaiagents>=1.6.167",
"praisonai-code>=0.0.63",
"praisonai-bot>=0.0.48",
"praisonai-train>=0.0.18",
"praisonai-browser>=0.0.14",
"praisonai-mcp>=0.0.14",
"praisonai-sandbox>=0.0.12",
"praisonai-deploy>=0.0.7",
"python-dotenv>=0.19.0",
"litellm>=1.83.14,<2",
"PyYAML>=6.0",
@@ -106,6 +106,32 @@ class TestACPRealLLM:
# Log for verification (no secrets)
print(f"[TEST] Prompt completed: stopReason={prompt_result['stopReason']}")
print(f"[TEST] Session: {session_id[:20]}...")
@pytest.mark.asyncio
async def test_prompt_writes_file_when_explicitly_allowed(self, tmp_path):
"""Exercise the real ACP → Agent → workspace tool loop."""
from praisonai.acp.config import ACPConfig
from praisonai.acp.server import ACPServer
server = ACPServer(config=ACPConfig(
workspace=tmp_path,
model="gpt-4o-mini",
read_only=False,
allow_write=True,
approval_mode="auto",
))
session = await server.new_session(cwd=str(tmp_path), mcp_servers=[])
result = await server.prompt(
prompt=[{
"type": "text",
"text": "Create acp_probe.txt containing exactly ACP_TOOLS_OK.",
}],
session_id=session["sessionId"],
)
assert result["stopReason"] == "end_turn"
assert (tmp_path / "acp_probe.txt").read_text(encoding="utf-8").strip() == "ACP_TOOLS_OK"
@pytest.mark.asyncio
async def test_session_resume(self, acp_server):
+62 -8
View File
@@ -38,6 +38,9 @@ class TestACPServer:
assert result["protocolVersion"] == 1
assert "agentCapabilities" in result
assert result["agentCapabilities"]["loadSession"] is True
assert set(result["agentCapabilities"]["sessionCapabilities"]) == {
"fork", "list", "resume"
}
assert result["agentInfo"]["name"] == "praisonai"
def test_new_session(self):
@@ -54,20 +57,71 @@ class TestACPServer:
assert result["sessionId"] in server._sessions
def test_new_session_with_mcp_servers(self):
"""Test new_session with MCP servers."""
"""Unsupported MCP configs fail closed instead of being ignored."""
import pytest
server = ACPServer()
mcp_servers = [
{"name": "test", "command": "/usr/bin/test", "args": []}
]
result = asyncio.run(server.new_session(
cwd="/tmp/test",
mcp_servers=mcp_servers,
))
session = server._sessions[result["sessionId"]]
assert session.mcp_servers == mcp_servers
with pytest.raises(ValueError, match="MCP servers are not supported"):
asyncio.run(server.new_session(
cwd="/tmp/test",
mcp_servers=mcp_servers,
))
def test_read_only_agent_exposes_only_non_mutating_tools(self, monkeypatch):
server = ACPServer(config=ACPConfig(read_only=True))
tools = self._fake_tools()
monkeypatch.setattr(server, "_load_workspace_tools", lambda _workspace: tools)
agent = server._get_agent()
names = {tool.__name__ for tool in agent.tools}
assert {"read_file", "list_files", "grep"} <= names
assert not names & {
"write_file", "edit_file", "acp_create_file",
"execute_command", "internet_search",
}
def test_permission_flags_control_write_shell_and_network_tools(self, monkeypatch):
config = ACPConfig(
read_only=False,
allow_write=True,
allow_shell=True,
allow_network=True,
approval_mode="auto",
)
server = ACPServer(config=config)
tools = self._fake_tools()
monkeypatch.setattr(server, "_load_workspace_tools", lambda _workspace: tools)
agent = server._get_agent()
names = {tool.__name__ for tool in agent.tools}
assert {
"write_file", "edit_file", "acp_create_file",
"execute_command", "internet_search",
} <= names
@staticmethod
def _fake_tools():
def make(name):
def tool():
return name
tool.__name__ = name
return tool
return [
make(name) for name in (
"read_file", "list_files", "grep", "write_file", "edit_file",
"acp_create_file", "execute_command", "acp_execute_command",
"internet_search", "web_crawl",
)
]
def test_set_session_mode(self):
"""Test set_session_mode handler."""
+230
View File
@@ -0,0 +1,230 @@
"""Tests for `praisonai managed ps` / `stop` and cross-process instance lookup.
No Docker required: providers are faked. The real Docker path is exercised
manually; see the PR description.
"""
import time
import pytest
from typer.testing import CliRunner
from praisonai.cli.commands.managed import (
_format_uptime,
app,
)
import praisonai.cli.commands.managed as managed_cli
class _Status:
def __init__(self, value):
self.value = value
class _Info:
def __init__(self, instance_id, created_at=None, image="python:3.12-slim"):
self.instance_id = instance_id
self.status = _Status("running")
self.endpoint = f"docker://{instance_id[:12]}"
self.provider = "docker"
self.created_at = created_at if created_at is not None else time.time() - 90
self.metadata = {"image": image}
class FakeProvider:
"""Stands in for a ComputeProviderProtocol implementation."""
def __init__(self, instances=None, available=True):
self.is_available = available
self._instances = list(instances or [])
self.shutdowns = []
async def list_instances(self):
return list(self._instances)
async def shutdown(self, instance_id):
self.shutdowns.append(instance_id)
self._instances = [i for i in self._instances if i.instance_id != instance_id]
@pytest.fixture
def runner():
return CliRunner()
@pytest.fixture
def one_docker_instance(monkeypatch):
"""Route every provider lookup to a single fake docker provider."""
provider = FakeProvider([_Info("docker_abc123")])
def fake_resolve(name):
if name == "docker":
return provider
raise ImportError(f"{name} not installed")
monkeypatch.setattr(managed_cli, "_PS_PROVIDERS", ("docker", "e2b"))
monkeypatch.setattr(
"praisonaiagents.managed._compute_bridge.resolve_compute", fake_resolve
)
return provider
# ── uptime formatting ────────────────────────────────────────────────────────
@pytest.mark.parametrize("seconds,expected", [
(0, "-"), # unknown created_at renders as "-"
(5, "5s"),
(90, "1m"),
(3700, "1h01m"),
(90000, "1d01h"),
])
def test_format_uptime(seconds, expected):
created = 0 if seconds == 0 else time.time() - seconds
assert _format_uptime(created) == expected
# ── ps ───────────────────────────────────────────────────────────────────────
def test_ps_lists_running_sandbox(runner, one_docker_instance):
result = runner.invoke(app, ["ps"])
assert result.exit_code == 0
assert "docker_abc123" in result.output
assert "python:3.12-slim" in result.output
assert "1 running" in result.output
def test_ps_json_is_machine_readable(runner, one_docker_instance):
import json
result = runner.invoke(app, ["ps", "--json"])
assert result.exit_code == 0
payload = json.loads(result.output)
sandboxes = payload["sandboxes"]
assert sandboxes[0]["instance_id"] == "docker_abc123"
assert sandboxes[0]["provider"] == "docker"
assert sandboxes[0]["status"] == "running"
assert payload["errors"] == []
def test_ps_reports_empty_state(runner, monkeypatch):
monkeypatch.setattr(managed_cli, "_discover_instances", lambda *a, **k: ([], []))
result = runner.invoke(app, ["ps"])
assert result.exit_code == 0
assert "No running sandboxes" in result.output
def test_ps_skips_unavailable_providers(runner, monkeypatch):
"""A provider with no credentials/daemon is a normal state, not an error."""
monkeypatch.setattr(managed_cli, "_PS_PROVIDERS", ("docker",))
monkeypatch.setattr(
"praisonaiagents.managed._compute_bridge.resolve_compute",
lambda name: FakeProvider([_Info("never_shown")], available=False),
)
result = runner.invoke(app, ["ps"])
assert result.exit_code == 0
assert "never_shown" not in result.output
def test_ps_surfaces_listing_failure(runner, monkeypatch):
"""An available provider that fails to list must NOT report clean success."""
class Broken(FakeProvider):
async def list_instances(self):
raise RuntimeError("daemon exploded")
monkeypatch.setattr(managed_cli, "_PS_PROVIDERS", ("docker",))
monkeypatch.setattr(
"praisonaiagents.managed._compute_bridge.resolve_compute",
lambda name: Broken(available=True),
)
result = runner.invoke(app, ["ps"])
assert result.exit_code == 1
assert "daemon exploded" in result.output
def test_ps_explicit_unknown_provider_is_loud(runner, monkeypatch):
"""`--provider foo` for an uninstalled/unknown provider should not be silent."""
def fake_resolve(name):
raise ImportError(f"{name} not installed")
monkeypatch.setattr(
"praisonaiagents.managed._compute_bridge.resolve_compute", fake_resolve
)
result = runner.invoke(app, ["ps", "--provider", "e2b"])
assert result.exit_code == 1
assert "e2b" in result.output
# ── stop ─────────────────────────────────────────────────────────────────────
def test_stop_requires_target(runner):
result = runner.invoke(app, ["stop"])
assert result.exit_code == 1
assert "--all" in result.output
def test_stop_unknown_id_fails_loudly(runner, one_docker_instance):
result = runner.invoke(app, ["stop", "docker_does_not_exist"])
assert result.exit_code == 1
assert "No running sandbox" in result.output
assert one_docker_instance.shutdowns == []
def test_stop_by_id(runner, one_docker_instance):
result = runner.invoke(app, ["stop", "docker_abc123"])
assert result.exit_code == 0
assert one_docker_instance.shutdowns == ["docker_abc123"]
def test_stop_all(runner, one_docker_instance):
result = runner.invoke(app, ["stop", "--all"])
assert result.exit_code == 0
assert one_docker_instance.shutdowns == ["docker_abc123"]
assert "1 stopped" in result.output
def test_stop_reports_failure_exit_code(runner, monkeypatch):
class Failing(FakeProvider):
async def shutdown(self, instance_id):
raise RuntimeError("daemon gone")
provider = Failing([_Info("docker_abc123")])
monkeypatch.setattr(managed_cli, "_PS_PROVIDERS", ("docker",))
monkeypatch.setattr(
"praisonaiagents.managed._compute_bridge.resolve_compute", lambda name: provider
)
result = runner.invoke(app, ["stop", "--all"])
assert result.exit_code == 1
assert "Failed to stop" in result.output
# ── cross-process lookup (the reason ps/stop work at all) ────────────────────
def test_docker_shutdown_raises_when_container_missing():
"""A stop that found nothing must not report success."""
docker_mod = pytest.importorskip("praisonai.integrations.compute.docker")
compute = docker_mod.DockerCompute()
compute._containers = {}
compute._find_container = lambda instance_id: None
with pytest.raises(ValueError, match="No docker container"):
compute._shutdown_sync("docker_missing")
def test_docker_shutdown_falls_back_to_lookup_by_name():
"""Instances started by another process must still be stoppable."""
docker_mod = pytest.importorskip("praisonai.integrations.compute.docker")
class FakeContainer:
def __init__(self):
self.stopped = False
self.removed = False
def stop(self, timeout=None):
self.stopped = True
def remove(self, force=False):
self.removed = True
container = FakeContainer()
compute = docker_mod.DockerCompute()
compute._containers = {} # nothing known in-process
compute._find_container = lambda instance_id: container
compute._shutdown_sync("docker_from_other_process")
assert container.stopped and container.removed
@@ -208,6 +208,39 @@ def test_timeout_proxy_isolated_across_generators():
exec_b.shutdown()
def test_explicit_workflow_timeout_ignores_cli_default():
# The legacy CLI always injects its --tool-timeout argparse default (60)
# into cli_config, so a bare default must NOT be treated as a user request
# (otherwise every ordinary workflow YAML run would raise). Regression for
# the workflow tool_timeout fail-fast (PR #3963).
gen = _make_generator()
gen.cli_config = {"tool_timeout": 60}
assert gen._resolve_explicit_workflow_tool_timeout({}) is None
assert gen._resolve_explicit_workflow_tool_timeout(
{"roles": {"a": {}}}
) is None
def test_explicit_workflow_timeout_flags_changed_cli_value():
gen = _make_generator()
gen.cli_config = {"tool_timeout": 30}
assert gen._resolve_explicit_workflow_tool_timeout({}) == 30.0
def test_explicit_workflow_timeout_flags_yaml_declared():
gen = _make_generator()
gen.cli_config = {"tool_timeout": 60} # bare CLI default
config = {"roles": {"a": {"tool_timeout": 10}}}
assert gen._resolve_explicit_workflow_tool_timeout(config) == 10.0
def test_explicit_workflow_timeout_ignores_bool_yaml():
gen = _make_generator()
gen.cli_config = {}
config = {"agents": {"a": {"tool_timeout": True}}}
assert gen._resolve_explicit_workflow_tool_timeout(config) is None
def test_ag2_not_in_default_priority():
from praisonai.framework_adapters.registry import FrameworkAdapterRegistry