The URL monitor's response.read() bounded bytes and per-socket waits but
had no total deadline, so a slow drip-feed endpoint could keep the
synchronous read alive within each socket window and stall the sequential
scheduler tick, delaying other due jobs. Read the body in bounded chunks
and check an absolute deadline between reads so a slow response fails the
probe at the monitor timeout instead of blocking progress.
Co-authored-by: Mervin Praison <[email protected]>
The existing collision guard only ensured no two keys collapse to the same
canonical form. It did not catch a single non-canonical key (e.g. 'full-auto'),
which validation would accept but the direct dict.get(canonical_preset_key(...))
lookup would miss, silently falling back to the disabled default. Add an
invariant test enforcing key == canonical_preset_key(key) for all four
resolver preset dicts. (addresses Greptile review)
Co-authored-by: Mervin Praison <[email protected]>
Validation normalised strip().lower().replace('-','_') while the four
resolvers did a raw dict .get(), so blessed spellings like
autonomy='full-auto' or context='sliding-window' were accepted then
silently ran with the feature off.
Add canonical_preset_key() in parse_utils and route both the validator
and the context/autonomy/output/execution resolvers through it. Replace
the vacuous test_normalized_preset_accepted assertion with the
resolution checks its own comment described.
Co-authored-by: MervinPraison <[email protected]>
_maybe_deliver_failure only recorded the negative outcome, leaving
JobResult.delivered False even when the failure summary was delivered
successfully — so monitoring reported a delivered notification as
undelivered (Greptile P1 on PR #4198). Set result.delivered = True on
the success branch and add regression tests for both the delivered and
failed failure-summary paths.
Co-authored-by: Mervin Praison <[email protected]>
_deliver_scheduled_result returned None on every path, so the executor
treated 'the handler did not raise' as success — a routing failure was
recorded delivered=True with delivery_error=None next to a logged
'Failed to deliver'. Return a bool from the gateway handler, propagate it
through _dispatch_delivery, and key delivered/delivery_error off it at the
three executor call sites plus the failure-summary path.
Co-authored-by: MervinPraison <[email protected]>
Snapshot and restore the pre-existing module entry in the vendored-fallback
import helper so the forced standalone re-import cannot leak the fallback
PluginRegistry into later tests expecting the bridged canonical class.
Addresses CodeRabbit review on #4184.
Co-authored-by: Mervin Praison <[email protected]>
vendored fallback copies used when praisonai-sandbox / praisonai-deploy are
installed standalone did not get it. Apply the same guard so an installed
distribution can never replace a built-in loader via entry-point discovery.
Add a class-parametrised test that forces the standalone fallback import path,
proving each vendored copy is protected (the previous test only exercised the
canonical class through varied group-name strings).
Co-authored-by: MervinPraison <[email protected]>
EndpointProviderRegistry.default() caches a per-class singleton whose
entry-point discovery runs once at construction. cmd_invoke resolves
providers through that cache, so under the CI root shard (-n 2 --dist
loadfile) a singleton built by an earlier test on the same worker — before
this file publishes the `acme` plugin — never sees it, and invoke falls back
to the recipe HTTP path (assert http == [] => assert 2 == 0). Evict the
cached singletons in the `published` fixture before and after each case so
the plugin is rediscovered and no poisoned instance leaks forward.
Test-only change in the praisonai wrapper; no production or praisonai-agents
code touched. Strengthens isolation rather than weakening any assertion.
Co-authored-by: Mervin Praison <[email protected]>
Wrapping only chat() dropped all execution telemetry for start(stream=True)
(-> _start_stream) and backend-backed run()/start() (-> _delegate_to_backend),
neither of which funnels through chat(). Re-instrument run/start/chat behind a
per-agent re-entrancy flag so the outermost call records exactly one execution,
preserving single-count for run()->chat() while covering stream/backend paths.
Co-authored-by: Mervin Praison <[email protected]>
The full-suite CI failure (`assert 2 == 0`, "Unknown provider type: acme")
was a test-isolation bug: the CLI handler's cmd_invoke resolves
EndpointProviderRegistry via the `praisonai.cli.features._endpoint_registry`
shim namespace, a distinct class object from the `praisonai_code...` one the
test imports. Their default() singletons are cached independently, so an
earlier full-suite test that warmed the handler's cache left it stale and the
monkeypatched entry points never reached it. Reset the cached _default_instance
on both class objects so discovery re-runs under the patched entry points.
Unrelated to PR #4187's session-docs changes; fixes the pre-existing flake.
Co-authored-by: Mervin Praison <[email protected]>
- token_collector: total_tokens no longer double-counts cached/reasoning/audio
tokens (they are subsets of input/output, not additional buckets)
- cost_utils: replace loose substring matching with longest-key exact/prefix
match so gpt-4o-mini is no longer billed at gpt-4o rates (16.7x); strip
provider prefixes; exclude "default" from matching
- performance_monitor: consult shared telemetry state live so a later
disable_telemetry() actually stops the import-time global monitor
- telemetry.__init__: atexit handler no longer resurrects a shut-down/opted-out
telemetry instance at interpreter exit
- integration: instrument only chat()/execute_tool(); run()/start() delegate to
chat() so wrapping them double-counted executions and errors
- telemetry.flush: only zero counters after a successful send
Co-authored-by: MervinPraison <[email protected]>
- _execute_loop/route/repeat/if now return a `stop` flag so a nested
on_error="stop" step halts the enclosing workflow instead of only its
own pattern iteration. Both nested dispatch and top-level _run_impl
honor the propagated signal.
- Parallel fail_fast/fail_all no longer `raise ... from <str>` (which
raised TypeError and masked the real failure); the string branch error
is preserved in WorkflowStepError.errors and only real exceptions chain.
- Added regression tests: nested-stop halts outer workflow, and parallel
fail_fast reports failed without TypeError.
Co-authored-by: Mervin Praison <[email protected]>
Parse safety judge responses tolerantly (markdown bolding, bullets, case)
and fail closed when nothing usable is parsed. Previously a bolded field
label - the default output style of most models - matched nothing and the
verdict silently fell through to 10.0/10 SAFE, even on a detected severe
violation. An unparsed verdict is now treated as UNSAFE (1.0), matching the
existing exception handler in evaluate_safety.
Co-authored-by: MervinPraison <[email protected]>
- _is_process_running: treat PermissionError as running so a gateway owned
by another uid is not reported dead and its lock deleted, admitting a
second poller for the same bot token.
- Record a create_time fingerprint in the lock so a recycled PID cannot be
mistaken for the original gateway; verified before stop/refuse decisions.
- check_port_available: use net_connections with a connections() fallback and
a broad except so the port-collision diagnostic works on psutil 7 instead
of raising 'invalid attr name connections'.
Co-authored-by: MervinPraison <[email protected]>
Previously is_safe_http_url returned True for any allowlisted hostname before
resolving/classifying its addresses, so SEARXNG_URL_ALLOWLIST=169.254.169.254
could reach the cloud metadata endpoint. Now every address is classified and an
allowlisted host only bypasses the loopback class; private, link-local,
multicast and unspecified addresses stay blocked. Docstring corrected to match.
Co-authored-by: Mervin Praison <[email protected]>
Nested patterns (loop/parallel/when/route/repeat/if) routed failing steps
through _apply_step_policies, which folded f"Error: {e}" into the step output
and never consulted on_error. Wrapping a pipeline in any pattern therefore lost
failure detection: the run reported status="completed" and the next step
consumed the exception text as input.
_apply_step_policies now returns (output, step_error) instead of folding the
error into output. _execute_single_step_internal applies the same
on_error/status logic the top-level _run_impl already uses: it marks the run
failed, signals a stop when on_error="stop", and never hands the exception text
forward. Pattern executors honor the stop signal, and Parallel on_failure modes
fire again since the branch failure is now surfaced.
Co-authored-by: MervinPraison <[email protected]>
The shared is_safe_http_url unconditionally allowlisted loopback and
consulted it before the IP-class checks, re-opening web_crawl to
127.0.0.1/localhost/::1. Split the allowlist into an explicit per-caller
opt-in consulted after the IP checks. validate_searxng_url opts into
loopback (SearXNG is self-hosted); the crawler passes none.
Co-authored-by: MervinPraison <[email protected]>
The restored TOP_LEVEL_KWARG regex only balanced one level of parens, so
a top-level rejected kwarg after a two-level nested call
(Agent(memory=MemoryConfig(db=db(...)), session_id=...)) escaped the guard,
re-opening the #4178 regression (greptile). Replace the regex with a
depth-aware scanner that attributes each kwarg to its enclosing call at any
nesting depth, and skips string literals and comments so dict keys
(memory={"session_id": ...}) and comments (# session_id = resume) are not
false positives. Add regression tests for the nested-call, dict-key, comment
and prose cases.
Co-authored-by: Mervin Praison <[email protected]>
Addresses Greptile's P2 finding on this PR plus one gap found reviewing the
diff independently.
- pattern-d-platform.md: the note added in the previous commit corrected the
platform agent roster path, but line 25 still listed it as /api/v1/agents,
which the same note defines as the unrelated endpoint served by
`praisonai serve agents`. A reader following the numbered list would have
integrated against the wrong service. Also softened "Three registries may
coexist" — a full install has more than three; the three listed are the ones
relevant to agent_id correlation.
- auth/__init__.py: the module docstring said implementations "live in the
platform package" without saying where that is, which is now ambiguous since
the package left this repository. Points at the separate repo and, more
usefully, states first that you can implement the protocols against your own
store. This matches the change made to protocols.py in the previous commit.
src/praisonai-agents/tests/unit/auth/ passes (33 tests).
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014ZfTXTzdudKb4oswNSHpHw
praisonai-platform is extracted to MervinPraison/PraisonAI-Platform, with full
history carried over via git filter-repo.
Why it belongs outside this repo:
- Nothing here imports it. A grep for praisonai_platform across every .py
outside its own directory returns no hits, and no pyproject declares it.
- It is in no CI job. No workflow under .github/ references it — it is absent
from install-monorepo-packages and from pypi-release.yml, which enumerates the
nine tiered packages and notes it is near GitHub's 25-input cap.
- It is absent from ARCHITECTURE.md, which describes a nine-package tiered
model; the platform was a tenth, untiered package with no boundary manifest.
- Separate-repo is the ecosystem norm: PraisonAI-Tools, PraisonAI-Plugins,
PraisonAIUI and Agent-Recipes all live outside and consume praisonaiagents as
a plain versioned PyPI dependency. The platform already did exactly that, and
already carried its own uv.lock.
- Its releases were already documented as an independent semver line.
Changes here:
- Remove src/praisonai-platform/ (9,263 lines).
- SECURITY.md: drop the platform row from supported packages and point
vulnerability reports at the new repository's advisories.
- security-audit.md: the platform is no longer triaged, released or audited
from this runbook. Removes publish steps 13 and 15 and the two table rows so
an agent executing it cannot cd into a directory that no longer exists.
- auth/protocols.py: WorkspaceContextProtocol's example named
PlatformWorkspaceContext, a class no longer in this tree. Renamed to a
generic example and noted where the reference implementation lives. Core
keeps the protocol with no in-tree implementation, which is the documented
design — auth/__init__.py already states that implementations live in the
platform package, and the core tests use local mocks.
- pattern-d-platform.md: kept, since it documents an integration contract
rather than platform internals. Adds a pointer to the new repo and corrects
the agent roster path, which is /api/v1/workspaces/{id}/agents — /api/v1/agents
is a different endpoint served by `praisonai serve agents`.
src/praisonai-agents/tests/unit/auth/ passes (33 tests).
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014ZfTXTzdudKb4oswNSHpHw
PR #4179 replaced the textual kwarg guard with an executing one but the
executor is blind to session/__init__.py (documented via an indented
docstring, zero python fences) and to README blocks excluded by
_is_self_contained. The surviving text check only covered _EXAMPLE_FILES,
so the original #4178 bug (a top-level Agent(session_id=...)) could be
reintroduced with the suite green.
- Restore test_docs_do_not_pass_rejected_kwargs_to_agent, widened to cover
DOCS as well as _EXAMPLE_FILES. TOP_LEVEL_KWARG matches only inside
Agent(, so prose mentioning session_id= is not a false positive.
- Add test proving an executor-only guard is blind to a fenceless file.
- Fix session README "Using with DB Adapter" block: import the callable
via `from praisonaiagents.db import db` instead of `from praisonaiagents
import db`, which yields the non-callable module and raised
TypeError: 'module' object is not callable whenever the wrapper was
importable (README.md#3 was green only via importorskip).
Co-authored-by: MervinPraison <[email protected]>
The deprecated entry-point group bypassed #4171's built-in guard because
_discover_legacy_entry_points ran first and called _add_loader
unconditionally. A plugin published under the legacy spelling could thus
replace a built-in, contradicting the docstring. Apply the same built-in
guard to the legacy path.
Co-authored-by: MervinPraison <[email protected]>
One helper resolves the llm=/model= pair everywhere: model= is canonical,
llm= is a deprecated alias, passing both raises TypeError, and LLMConfig is
unwrapped for classes that store a bare model name. Adds model= to CodeAgent,
RealtimeAgent, AgentTeam and AgentFlow.
Co-authored-by: MervinPraison <[email protected]>
The main (3.11) job dropped the plugin `aider` across every registry-backed
surface (fixture tests + entry-point discovery test). The base guard and
production code are correct; the failure is a test-isolation issue that only
surfaces under `pytest -n --dist loadfile` + coverage on CI:
- The entry-point discovery test patched `praisonai_code._registry.entry_points`
by dotted string. Under editable-install + coverage import tracing a
re-imported copy of that module can differ from the one the
`ExternalAgentRegistry` class actually discovers through, leaving the real
path unpatched and silently dropping `aider`. Patch `entry_points` on the
exact base module the class resolves via `__mro__`, and accept `group`
positionally or by keyword.
- The `registered_plugin` fixture now also clears and restores the per-class
`PluginRegistry.default()` cache so no sibling test file sharing the xdist
worker can shadow the registered plugin.
No assertions weakened: built-ins still win over colliding entry points,
plugins can still add new names, and every surface must still agree.
Co-authored-by: Mervin Praison <[email protected]>
Addresses two blind spots greptile flagged in the executing session-doc guard:
- ImportError from our own packages (praisonaiagents/praisonai) now re-raises
instead of being swallowed by the optional-dependency skip, so a doc that
advertises a renamed/removed symbol fails the guard. Optional third-party
deps still skip.
- Blocks dropped by the self-contained filter are now asserted against a known
set of documentation idioms, so a typo/omitted import in an otherwise-complete
example fails a diagnostic test rather than vanishing from execution.
Co-authored-by: Mervin Praison <[email protected]>
The #4127 guard regexed the docs for four hard-coded kwarg names, so
renaming the fixed example's session_id= to sessionid= left it green while
the documented MemoryConfig call raised. Replace the text match with a
primary guard that extracts every ```python fence, keeps the AST
self-contained ones (used - bound - builtins == set()), and execs them with
Agent.start/chat stubbed and PRAISONAI_HOME isolated. It now fails on the
sessionid= reproduction with the exact TypeError, and catches the next
mistake too. Non-hermetic examples opt out via <!-- praisonai: skip=true -->
and wrapper-backed db examples are skipped when praisonai is absent.
Co-authored-by: MervinPraison <[email protected]>
The entry-point shadowing guard is added inside praisonai-code 0.0.64, but the
wrapper floor was praisonai-code>=0.0.64 — the same version number that shipped
without the guard. A pre-fix 0.0.64 install would satisfy >=0.0.64 yet lack the
protection, restoring the shadowing path once the wrapper's local guard was
removed. Bump praisonai-code to 0.0.65 and raise the wrapper floor so the
dependency constraint actually guarantees the fix is present.
Co-authored-by: Mervin Praison <[email protected]>
Add deterministic tests exercising the public execute_tool_async path
(not just the impl) to close the coverage gap CodeRabbit flagged:
- a tool-owned `retryable` field does not drive the async retry loop
(body runs exactly once, field preserved)
- the private `_praison_retryable` control key is not visible on the
async surfaced result
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Extract the tool function name before json.loads so callers (streaming
verbose warning, deferred-media path) report the actual tool name instead
of 'unknown function' on malformed arguments.
Co-authored-by: Mervin Praison <[email protected]>
Relocate praisonaiagents/runtime/example_harness.py (365-line, unreferenced
demonstration module) to examples/runtime/example_harness.py so it no longer
ships in the installed praisonaiagents wheel. Convert its relative imports to
absolute and correct the pre-existing broken TurnRuntimeProtocol import
(runtime.protocols, not runtime.turn_context) so the example runs standalone.
Update the AGENTS.md path reference. No public API or runtime module changes.
Co-authored-by: MervinPraison <[email protected]>
The primary --external-agent execution path (get_integration/execute) called
_catalog() which discarded the too-old-wrapper diagnostic and returned an empty
catalog, producing a misleading "Unknown integration" error on a version-skewed
install (Greptile P1). Add _require_catalog() that raises the upgrade guidance on
the execution path while listing surfaces (help/argparse/UI) keep degrading
quietly. execute() now catches RuntimeError to print the guidance cleanly.
Also fix test hygiene: restore the process-global root logger handlers via
monkeypatch instead of permanently clearing them (CodeRabbit).
Co-authored-by: Mervin Praison <[email protected]>
#4158 patched only ExternalAgentRegistry. The real hole was in the base
PluginRegistry: builtins are registered first and entry points second, so any
installed distribution declaring a built-in name silently replaced it across
all ten registries (openai, docker, aws, ...). Those loaders decide where user
code runs.
Record built-in names once and skip colliding entry points during discovery,
matching the existing BotPlatformRegistry precedent (DEBUG, not WARNING, since
packages legitimately re-declare their own builtins). Runtime register() still
overrides -- deliberate dependency injection. The now-redundant re-assertion in
ExternalAgentRegistry is removed.
Co-authored-by: MervinPraison <[email protected]>
Extract a private _extract_tool_call_name_and_args helper and reuse it in
the non-streaming, streaming, and deferred-media tool-calling paths. Each
call site keeps its own failure handling (tool-role reply vs. yield), so
behaviour is unchanged.
Co-authored-by: MervinPraison <[email protected]>
The retry machinery tagged its own verdict with an un-namespaced `retryable`
key, so a tool whose result legitimately contained `retryable` (common for
HTTP-API wrappers) had its body re-executed up to 3x and the internal verdict
leaked to the model. The sync outer loop separately escalated any returned
error dict of an uncovered type to retryable, re-running the tool body for a
decision it had already made.
- Rename the framework verdict to the private `_praison_retryable` key in the
async impl (exception + timeout branches) and the sync `_execute_tool_impl`
exception flatten, matching the `_outer_timeout` convention from #4133.
- Read and strip `_praison_retryable` in both retry loops so it never reaches
the model and cannot collide with a tool's own `retryable` field.
- Sync outer loop escalates only framework-produced transients
(`_outer_timeout`, `circuit_open`, or a flattened raised exception via
`_praison_retryable`, and only when the type is not already re-driven by the
inner loop). A plain returned error dict now runs exactly once.
- Update existing parity tests to the new key and add regression tests proving
a tool's own `retryable` field does not drive the loop and the verdict is not
visible to the model.
Co-authored-by: MervinPraison <[email protected]>
- list_external_agents() now derives from external_agent_catalog() so argparse
--help choices can never advertise a plugin the handler then rejects
- registry uses a module logger instead of the root logger (no basicConfig on
the embedding application)
- praisonai-code distinguishes an absent wrapper from a too-old wrapper: a
mixed-version install now reports "too old" instead of a silent empty catalog
Co-authored-by: MervinPraison <[email protected]>
The prefix filter in _mistyped_command_suggestions spared both extensions
(``tests`` for ``test``) and truncations (``deplo`` for ``deploy``) of a
command. Truncations are typos: sparing them forwarded the word to ``run``
and billed a mistaken LLM call per invocation. Drop the truncation half so
only extensions — plausible prompts — stay untouched.
Co-authored-by: MervinPraison <[email protected]>
After the bounded verify loop exhausts, nothing was persisted, so returning
True made the caller's 'history was lost' branch unreachable and printed a
misleading 'raced with a concurrent writer' warning. Return False and log the
real failure so the caller's existing warning fires.
Co-authored-by: MervinPraison <[email protected]>
If _EXAMPLE_ROOTS is renamed/moved the guard silently emitted no
parametrized cases, letting the Agent(session_id=)/db= regression it
protects against slip back in. Raise a clear collection error instead.
Co-authored-by: Mervin Praison <[email protected]>
Passing limit=None to migrate legacy transcripts (fixing #4160) turned an
already per-agent full-scan into a full-disk scan on every save: the save
loop over N agents re-enumerated every stored session N times, even once
migrated. Cache the scan result per Session keyed on the opt-in flag, since
legacy records are a frozen migration artefact never mutated by save or
restore. Addresses the P2 hot-path regression raised in PR review.
Co-authored-by: Mervin Praison <[email protected]>
Replace the fictional PostgresAdapter import in session/README.md with the
real db(database_url=...) shortcut under MemoryConfig, and move the top-level
db=/session_id= Agent kwargs into MemoryConfig across the db package
docstrings and the runnable redis example. Also align the session_dir default
path in the API reference (~/.praisonai/sessions).
Extend the doc-example guard to discover all Agent( examples under the db
package and examples/persistence, and assert none reference a class we do not
ship, closing the gap the previous two-file textual guard left open.
Co-authored-by: MervinPraison <[email protected]>
_legacy_agent_histories called list_sessions() with no argument, inheriting
its default limit=50 (sorted by updated_at desc). A legacy per-agent record is
by definition older than anything written after the upgrade, so any user who
touched 50 sessions since upgrading had their tagged sub-agent transcript
silently skipped on restore. Pass limit=None (with a TypeError fallback for
stores whose limit is not optional) so migration scans every stored session.
Co-authored-by: MervinPraison <[email protected]>
Re-assert built-in loaders after entry-point discovery so a third-party
distribution publishing a built-in name (e.g. "claude") can no longer
silently replace the shipped ClaudeCodeIntegration. Plugins may still add
new external agents. Adds a regression test.
Co-authored-by: MervinPraison <[email protected]>
- praisonai-code endpoint registry loaded openai-compat via a static
`from praisonai.endpoints...` import, tripping the C7 hot-path import gate
(smoke CI). Route it through the sanctioned _wrapper_bridge dynamic import so
the standalone package no longer statically imports the wrapper.
- Provider adapter routed --stream through invoke(stream=True), whose terminal
InvokeResult the CLI cannot render (Greptile P1). Consume
BaseProvider.invoke_stream() instead and print SSE-shaped events.
- Add a streaming test asserting --stream drives invoke_stream, not invoke.
Co-authored-by: Mervin Praison <[email protected]>
- praisonai-code external_agents handler now resolves the registry catalog
through _wrapper_bridge.optional_wrapper_attr instead of a direct
``from praisonai.integrations.registry import ...`` reverse import. This
restores the C7/C8 import boundary (the smoke `check_c7_imports.sh` gate)
and degrades to an empty catalog when the wrapper is absent so the handler
stays usable on a standalone praisonai-code install.
- external_agent_catalog() now skips any external agent whose entry point
raises a non-ValueError during load (Greptile P1), so one broken optional
plugin can no longer blank the catalog for argparse/CLI/UI surfaces.
Co-authored-by: MervinPraison <[email protected]>
Resolves two Greptile P1 issues on PR #4155:
- validate command accessed provider.value which crashes when the
provider is a registry plugin string; now handles both enum and str.
- sample YAML generation fell through to a hardcoded aws block for
plugin providers; now emits the requested provider name.
Co-authored-by: Mervin Praison <[email protected]>
_apply_default_llm reset _llm_instance but left the chat-mixin
_unified_dispatcher cached against the old model, so a used agent (team
default applied post-execution, or an AgentFlow re-run with a new llm)
kept calling the previous model. Drop the dispatcher too; add regression
tests for used and pinned agents.
Co-authored-by: MervinPraison <[email protected]>
Address the two P1 review findings on the retry consolidation:
- N² attempts: the outer loop re-escalated timeout/circuit_open results as
retryable even though the inner circuit-breaker loop already retries those
error types to exhaustion, restarting the whole inner budget on every outer
pass (up to max_attempts^2 tool-body runs on a persistently-timing-out MCP
tool). Route every error dict through one guard: only escalate error types
the inner loop does NOT retry. A genuine outer wall-clock timeout is tagged
(_outer_timeout) so it still earns its single outer retry pass.
- Backoff parity: translating ExecutionConfig into a RetryPolicy silently
dropped the delay cap from BackoffPolicy's 60s to RetryPolicy's 30s. Lift the
translated max_delay_ms back to 60s so existing ExecutionConfig users keep the
same delay ceiling.
Adds 5 tests: inner-retried timeout capped at max_attempts (not N^2),
non-inner-retried errors still get outer passes, and the 60s cap is preserved.
Co-authored-by: Mervin Praison <[email protected]>
Both endpoint-provider readers now scan the canonical
`praisonai.endpoint_providers` group, with `praisonai.endpoints.providers`
honoured as a deprecated alias (DeprecationWarning). Registers the orphaned
`openai-compat` provider, adapts BaseProvider subclasses into the CLI dispatch
shape, re-asserts CLI builtins so a plugin cannot hijack a shipped type, and
replaces the silent `_invoke_recipe` fallback for unknown `--type` with a
validation error. Declares the group in pyproject.toml and documents it.
Co-authored-by: MervinPraison <[email protected]>
Collapse the four hand-written copies of the external-agent list into a
single source of truth in the registry. Adds list_external_agents() and
external_agent_catalog() to praisonai.integrations.registry; the argparse
--external-agent choices, the praisonai_code CLI handler INTEGRATIONS, and
the UI EXTERNAL_AGENTS toggles now all read the registry. Presentation
metadata (display_label, install_hint) moves onto the integration classes.
Backward-compatible: INTEGRATIONS and EXTERNAL_AGENTS stay Mappings
supporting in / iteration / []. Built-in order and labels are unchanged on
a default install.
Co-authored-by: MervinPraison <[email protected]>
The CloudProvider enum could not be extended at runtime, so plugin
providers discovered/resolved via the praisonai.deploy.providers
entry-point group were rejected by validation. Widen CloudConfig.provider
to Union[CloudProvider, str] with a mode="before" validator that consults
the registry via coerce_cloud_provider. Built-ins keep enum identity and
byte-identical serialisation; plugins validate as normalised lowercase names.
Co-authored-by: MervinPraison <[email protected]>
The broad async exception handler flattened every raised exception into an
error dict with retryable = not isinstance(e, (ValueError, TypeError,
AttributeError)). A ToolExecutionError(is_retryable=False) is none of those,
so it was silently marked retryable=True and re-run up to max_attempts,
diverging from the sync path (tool_execution.py) which preserves an existing
ToolExecutionError's explicit verdict. Now the async path honours
e.is_retryable for ToolExecutionError and only applies the programming-error
rule to other exceptions.
Adds tests for terminal and retryable ToolExecutionError parity.
Co-authored-by: Mervin Praison <[email protected]>
IntegrationRegistry.__init__ reassigned self._loaders = {} right after
super().__init__() populated it from the entry-point group, discarding
every discovered third-party plugin. Remove the reassignment (builtins are
registered afterwards and still win on name clashes) and document the
entry-point group.
Co-authored-by: MervinPraison <[email protected]>
Presence of the vector_store.provider key (not its truthiness) now marks an
explicit choice, so an explicitly empty ("") or null (None) provider surfaces
the preset error instead of silently degrading to Mem0. Adds parametrized
regression coverage for both falsey values.
Co-authored-by: Mervin Praison <[email protected]>
The as_tool() Handoff conversion in _process_handoffs produces a closure
bound to the source agent (its chat_history/tools/memory). clone_for_channel()
shallow-copied that closure verbatim, so a channel clone would delegate using
the ORIGINAL agent's state, leaking one channel's context into another.
Tag the generated closure with its source Handoff and, during cloning, swap it
back so the clone's own _process_handoffs rebinds it to the clone -- mirroring
the existing _praison_sandbox_tool drop-and-regenerate pattern. Adds a clone
regression test asserting the clone's converted tool is bound to the clone, not
the origin.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
to_knowledge_config() emitted reranker.config.model without a provider.
mem0's RerankerConfig requires a provider and Knowledge._prepare_mem0_config
strips all but provider/config, so a provider-less rerank_model was silently
dropped. Derive the provider from a "provider/model" prefix (falling back to
the model string) so the requested reranker is instantiated.
Co-authored-by: Mervin Praison <[email protected]>
AgentTeam stored self.llm and nothing read it, so members silently kept
gpt-4o-mini. AgentFlow(llm=) only reached agents it built itself, never a
ready-made Agent step. Add Agent._apply_default_llm to adopt a container
default as a fill-in (agents that named their own model keep it), and call
it from both containers.
Co-authored-by: MervinPraison <[email protected]>
Agent(auth="claude-code") was silently billed to the API key because auth=
was dropped on the commonest construction branch (bare model name / no model)
and self.auth was never assigned, so clone_for_channel() always lost it.
- Land auth on the final else branch: bare-name + auth= now builds an LLM
instance that injects the resolved OAuth credentials.
- Assign self.auth so clone_for_channel() forwards it.
- Resolve the subscription provider eagerly at construction so an unknown or
unusable provider raises instead of silently downgrading to API-key billing.
- Default the model to the auth provider's vendor when no model is given.
- Inject subscription credentials in _build_responses_params too, mirroring
_build_completion_params.
- Add regression tests and wire the subscription-auth smoke tests into CI.
Co-authored-by: MervinPraison <[email protected]>
as_tool() returns a Handoff, and its own docstring passes the result into
tools=[...]. But tools= stores objects verbatim and only handoffs= ran
to_tool_function(), so an unconverted Handoff was invisible to both the LLM
tool schema and execute_tool(). Convert Handoff entries found in self.tools
inside _process_handoffs, the first point where the parent agent that
to_tool_function() needs actually exists.
Co-authored-by: MervinPraison <[email protected]>
MemoryConfig declared backends (redis/valkey/postgres) that had no adapter,
so requests were silently redirected to a local SQLite/JSON store, and
"sqlite" was overloaded (Agent built ChromaDB while Memory built SQLite).
- MemoryBackend enum now mirrors the registry (file + registered adapters)
- Unimplemented backends raise a clear error instead of substituting
- "sqlite" now builds SQLite on both Agent and Memory (no rag rewrite)
- Memory provider with no adapter fails loudly; deliberate fallbacks warn
- Drop redis/postgres presets and URL schemes that dropped host/credentials
- register_memory_adapter() remains the escape hatch for custom backends
Co-authored-by: MervinPraison <[email protected]>
Eight of KnowledgeConfig's ten fields never reached the internal config:
chunk_size, chunk_overlap, chunking_strategy, chunker, vector_store,
auto_retrieve, rerank_model and retrieval_threshold were read then dropped.
Also config={...} replaced the whole resolved dict, silently resetting
retrieval_k.
- agent.py: forward every declared field with the keys RetrievalConfig
reads (min_score, not threshold); config= now overrides instead of
replacing.
- feature_configs.py: ChunkingStrategy gains TOKEN/RECURSIVE, FIXED/PARAGRAPH
kept as aliases; declared chunking defaults aligned to the effective ones.
- chunking.py: normalize_chunker_type maps strategy names to real chunkers.
- knowledge.py: chunker reads from config instead of hardcoding recursive/512/50.
- retrieval_config.py: add rerank_model + chunking fields, round-trip them,
and emit a chunker block from to_knowledge_config.
Defaults unchanged so existing users are unaffected on upgrade.
Co-authored-by: MervinPraison <[email protected]>
ToolConfig(retry_policy=RetryPolicy(max_attempts=N)) now caps how many
times a tool body runs. Previously two nested loops consumed two
independent vocabularies (RetryPolicy vs ExecutionConfig) in different
units, so max_attempts was silently overridden by max_retry_limit,
running non-idempotent tools more times than configured.
The effective RetryPolicy is now the single budget; ExecutionConfig's
retries/seconds/fraction spelling is translated into it in one place
(_effective_tool_retry_policy). No behaviour change for users who do not
set retry_policy=.
Co-authored-by: MervinPraison <[email protected]>
An all-or-nothing all(isinstance(t, str)) gate silently left every
tool-name string unresolved as soon as one callable was present, so
tools=[my_tool, 'read_file'] dropped 'read_file'. Resolve per element
via a new resolve_tools_list() helper that passes non-strings through
untouched and reports/strict-checks unknown names exactly as the
all-strings path already does.
Co-authored-by: MervinPraison <[email protected]>
execute_tool_async caught a raised tool exception and returned an untyped
error dict; the caller re-classified it as "unknown", which no default
RetryPolicy.retry_on contains, so async always ran a failing tool exactly
once. Carry the retry verdict across the flattening (same rule as the sync
path: programming errors terminal, everything else retryable) and honour it
in the retry loop.
Co-authored-by: MervinPraison <[email protected]>
Knowledge.memory used provider_mapping.get(provider, "mem0"), silently
substituting Mem0 for any provider outside a hardcoded four-name list —
discarding every adapter registered via register_knowledge_adapter() and
swallowing typos. Match memory.py: keep the table for genuine legacy
aliases and pass everything else to the registry, raising a helpful
make_preset_error when an explicit provider is unknown.
Co-authored-by: MervinPraison <[email protected]>
Prompt caching is not applied automatically. LLM._explicit_cache_breakpoints()
gates Anthropic cache_control injection on prompt_caching being truthy, and
Agent defaults it to None. Document the correct spelling
caching=CachingConfig(prompt_caching=True).
Co-authored-by: Mervin Praison <[email protected]>
Resolves two P1 review findings on the closed-set preset validation:
- approval hyphen aliases (accept-edits, bypass-permissions, full-auto,
dont-ask, ...) were rejected by the new guard even though
PermissionMode.resolve accepts them (-/_ interchangeable). The validator
now normalizes hyphens too, restoring previously-valid construction.
- learn= used case-sensitive, non-stripped == comparisons, so a validated
value like "AGENTIC" or " agentic " fell through to else and SILENTLY
disabled learning. It now uses .strip().lower() like self_improve does.
- resolve()'s preset lookup and the approval deny-set lookup now normalize
whitespace/hyphens too, so every spelling the guard accepts also resolves
(previously " sliding_window "/"read-only" passed validation but the
deny-set lookup produced an EMPTY deny set — the exact read-only-sandbox
bypass this PR exists to prevent).
Adds regression tests for hyphen aliases, case/whitespace-normalized
presets (learn actually enabled), and the read-only deny-set security path.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Replace the single module-level identity pointer (_autocreated_store/
_autocreated_dir) with a per-instance marker attribute stamped on any store
this module auto-creates. A partial teardown that restores only
_default_store without restoring the out-of-band tracking variable could
otherwise misclassify a previously auto-created store as caller-injected and
pin it to a stale session directory (Greptile P1).
Also make the DEFAULT_SESSION_DIR __getattr__ hook honour an explicit module
override consistently via _resolve_default_session_dir(), and fix the
override test's leaky monkeypatch (the lazy __getattr__ made setattr restore
a computed value as a real global, leaking into later tests). Adds a
regression test for the restored-store scenario.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
The session module's __init__.py and README.md documented
Agent(name=..., session_id=...), which Agent.__init__ rejects with TypeError.
session_id/user_id/db were consolidated into MemoryConfig, and prompt_caching
was never a parameter (caching is applied automatically per model capability).
Fixes the docs to the working spelling and adds a two-sided guard test.
Co-authored-by: MervinPraison <[email protected]>
Address two P1 review findings on the sub-agent transcript relocation:
- Partial migration: legacy per-agent records were only consulted when the
parent held no current-format histories, so a second agent still living in
a tagged legacy record silently resumed empty. A new _merged_agent_histories
helper always layers legacy under current-format entries (current wins), used
by both single and bulk restore paths.
- Concurrent saves: _store_agent_history read the whole map outside the store
lock then replaced it, letting a concurrent writer under the same parent drop
another agent. It now re-reads the freshest map before each write and verifies
no other key regressed, retrying a bounded number of times.
Adds regression tests for partial-migration recovery and concurrent-save
preservation.
Co-authored-by: Mervin Praison <[email protected]>
The mistyped-command guard used difflib ratio >= 0.8 alone, which also
matches legitimate one-word prompts that are plurals or extensions of a
command word (`tests`->`test`, `runs`->`run`, `server`->`serve`). Those
wrongly exited 2 before reaching the bare-prompt path.
Drop prefix-related matches: an extension contains the command as a prefix
(or vice-versa) whereas a genuine typo (`deploi` vs `deploy`) does not, so
real prompts reach `run` while typos still exit 2 with a suggestion.
Co-authored-by: Mervin Praison <[email protected]>
Seven Agent(...) preset params (output, execution, context, autonomy,
approval, learn, self_improve) silently fell back to defaults on a typo'd
preset name. Worst case: approval="read_onl" produced an empty deny set,
allowing all 17 dangerous tools instead of the intended read-only sandbox.
Wire the existing make_preset_error helper to these params via a small
validate_preset_string() in parse_utils.py plus a compact
_PRESET_STRING_PARAMS table and _validate_preset_params() loop run at the
top of the consolidated-params block, before every fast path that could
swallow the value. Free-form string params (knowledge, guardrails, skills,
runtime, auth) are deliberately excluded.
Co-authored-by: MervinPraison <[email protected]>
An empty list/tuple resolved to None (no-op) on main, but the new
fail-loud guard rejected it with TypeError. Exempt empty collections
so an empty opt-in stays backward-compatible; genuinely unsupported
values still raise. Adds a regression test.
Co-authored-by: Mervin Praison <[email protected]>
ExecutionConfig.parallel_tool_calls now uses None as an internal "not set"
sentinel (normalised to bool in __post_init__) so Agent._merge_parallel_tool_calls
can tell an explicit False from an omitted default. A ToolConfig(parallel=True)
alongside an explicit ExecutionConfig(parallel_tool_calls=False) now raises
TypeError instead of silently enabling parallel tool calls (Greptile P1).
Co-authored-by: Mervin Praison <[email protected]>
Sub-agent transcripts were persisted as separate top-level sessions named
"{session_id}_{name}:{role}", whose sanitised path collided with real user
sessions of the same name (e.g. Session("chat") + Agent("support", "agent")
-> chat_support_agent.json). An ordinary close()/save_state() then silently
overwrote an unrelated user's conversation and could leak it back into the
agent on restore.
Move sub-agent histories into the parent session's own
metadata["agent_histories"][agent_key], removing the second namespace
entirely. Legacy tagged records migrate forward on save; untagged pre-tag
records are restored only behind PRAISONAI_SESSION_LEGACY_AGENT_KEYS=1
(ambiguous by construction) and are never deleted.
Co-authored-by: MervinPraison <[email protected]>
DEFAULT_SESSION_DIR was evaluated at module import time, freezing the
store root to whatever PRAISONAI_HOME resolved to during the first import.
Any later override -- a container exporting PRAISONAI_HOME after the
package was imported, or a test monkeypatching get_sessions_dir -- was
silently ignored, so the store read, wrote and deleted sessions in the
wrong directory.
- Resolve the directory live in DefaultSessionStore.__init__ via
_resolve_default_session_dir().
- Keep DEFAULT_SESSION_DIR readable/assignable via PEP 562 __getattr__.
- Rebuild the singleton when the resolved directory changes, while
never second-guessing a store a caller injected.
- Fix two repo tests that set the unread PRAISONAI_SESSIONS_DIR env var
and reset the wrong singleton attribute.
- Add tests/unit/session/test_session_store_dir_resolution.py.
Co-authored-by: MervinPraison <[email protected]>
Add behavioural coverage for require_approval's sync and async wrappers.
The existing approval suite only asserted registration metadata, so both
denial `raise PermissionError` lines could be deleted with the suite green.
New tests install a denying backend, call a decorated tool, and assert the
PermissionError plus that the wrapped body never ran (side-effect probe).
Positive controls, modified_args, fail-closed, and async-context cases
included.
Co-authored-by: MervinPraison <[email protected]>
The praisonai dispatcher only introspected the `run` subcommand's options,
never the root callback's global flags (`--output-format`/`-o`). As a result:
- `praisonai --output-format json version` misrouted to the legacy engine,
dropped `version`, sent `json` to an LLM, and exited 0.
- A typo'd verb like `praisonai deploi` fell through to the bare-prompt path,
was billed to an LLM, created a session, and exited 1.
Fix, entirely in the dispatcher:
1. Introspect the root callback and union with `run`'s options for argv
tokenisation, so `--output-format` routes correctly.
2. Hoist global-only flags ahead of `run` when synthesising a `run` call
(Click accepts group options only before the subcommand). Options declared
by both (`-o`, `-v`) stay with `run`, preserving existing semantics.
3. Add an unknown-subcommand guard: a lone positional that difflib rates a
close match (>= 0.8) to a registered command exits 2 with a suggestion,
guarded so genuine one-word prompts still work.
Co-authored-by: MervinPraison <[email protected]>
Agent(guardrails=<validator object>) previously fell past every dispatch
branch and was dropped with no exception or warning, so the agent ran with
zero enforcement. Adapt protocol-conforming guardrail objects (and lists of
them) into GuardrailChain, raise TypeError on unsupported values instead of
storing something that never runs, and wire _tool_call_guardrails so
validate_tool_call is no longer dead code.
Co-authored-by: MervinPraison <[email protected]>
ToolConfig.parallel is now a deprecated alias (None = unset) that feeds
ExecutionConfig.parallel_tool_calls, which every public entry point forwards
to the LLM. A bare ToolConfig() no longer silently disables an explicit
parallel_tool_calls=True, and conflicting explicit spellings raise TypeError.
Co-authored-by: MervinPraison <[email protected]>
Previously any --transport value other than exactly "stdio" fell through
to the SSE branch, so a typo (e.g. `stido`) or the advertised-but-
unimplemented `http-stream` silently started a listening socket instead
of a stdio pipe. Add explicit validation rejecting unknown/unimplemented
transports with exit code 2 before any branch can bind.
Co-authored-by: MervinPraison <[email protected]>
- Workflow YAMLs run through YAMLWorkflowParser, which has no cli_backend
wiring; the framework-level compatibility check passes for 'praisonai'
and the field was silently dropped, running the workflow on the native
LLM path. _build_yaml_workflow now fails fast with a clear error,
mirroring the existing tool_timeout and CLI-capability rejections.
- _chat_via_cli_backend: post-success hook emission and chat-history
bookkeeping failures are contained (logged) instead of being reported
as backend-execution failures, which invited callers to retry work the
external CLI already completed; the failure hook now receives the
backend's error result (with its audit metadata) instead of None.
- Tests: workflow-YAML rejection, post-execution containment, and
error-result metadata propagation.
Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01Pwwvk68iPoNnGw6ov5Lpga
The precise duplicate-add detection introduced in this PR classifies a
schedule add as successful only when the response contains the producer-owned
confirmation "Schedule '<name>' added". The pre-existing timezone-surface test
stubbed schedule_add with a generic "Schedule added" string lacking the job
name, so the new guard correctly treated it as a rejection and the CLI exited
non-zero. Update the stub to return the real success shape; the guard is
unchanged (test was stale, not the code).
Co-authored-by: Mervin Praison <[email protected]>
- schedule add: failure detected as absence of the producer-owned
success confirmation instead of substring matching, so a schedule
literally named 'Error monitor' no longer exits 1 on success
- --backend-timeout: nan/inf/-inf rejected before the job is created
(inf previously raised OverflowError AFTER the schedule was stored);
guard tolerates direct calls passing Typer OptionInfo defaults
- backend gate: go-path state_updates (cursor/watermark) now persist
only after the backend turn succeeds — a policy block, resolution
failure, timeout, or backend error no longer advances the cursor past
unprocessed work; skip-path persistence unchanged; regression test
- CLI session: result validated and resume state recorded BEFORE the
cancellable hook await, so cancellation during the hook cannot lose
is_resume for the next turn (and the lock is already released)
Cross-event-loop lock coordination (two OS threads sharing one Agent,
each with its own loop) is documented as out of scope: sharing an Agent
across threads is unsupported for the wider Agent state; the re-key
path preserves per-loop serialisation, which is the supported usage.
Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01Pwwvk68iPoNnGw6ov5Lpga
- CLI-backend session lock: asyncio.CancelledError bypasses 'except
Exception', which could leave the per-session lock held forever; a
dedicated handler now releases (guarded by a lock_held flag so a lock
re-acquired by a concurrent turn is never stolen) and re-raises. The
same flag guards the generic exception path.
- Backend pre-run gate now mirrors the native path's state handling:
persisted gate state is loaded, passed to state-aware gates, and
decision.state_updates persist on both skip and go — a backend
monitor job keeps its cursor/watermark across ticks.
- schedule add --backend-timeout: positive sub-millisecond values no
longer collapse to 0 (which the executor replaced with the 300s
default); rounded up to at least 1ms.
Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01Pwwvk68iPoNnGw6ov5Lpga
The defensive conflict check marked the run failed but returned before
_audit_output and _maybe_deliver_failure, unlike every other backend
failure path — audited deployments would lose the record and the owner
notification. Now consistent; regression test asserts the audit.
Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01Pwwvk68iPoNnGw6ov5Lpga
A duplicate-name add creates nothing and (post the job_created guard) mutates
nothing, but still printed success and exited 0 — misleading scripts/CI. Treat
the 'already exists'/'Error' responses as failures so a rejected add exits 1 in
both text and --json modes. Adds a regression test asserting no mutation + exit 1.
Co-authored-by: Mervin Praison <[email protected]>
- all backend-branch _audit_output calls now go through
asyncio.to_thread, matching the native-agent failure paths so slow
audit storage cannot block ticker/delivery coroutines
- 'cli_backend: null' in YAML behaves as unset (backward compatible);
the fail-closed error fires only for a non-null value that cannot
resolve
Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01Pwwvk68iPoNnGw6ov5Lpga
ef6bb47b started scrubbing secret ENV lines on capture via
container.commit(..., changes=scrub) -- a real docker-py kwarg -- but the
suite's _FakeContainer.commit() still took only (repository, tag). Every
capturing provision raised TypeError, fell back to ephemeral, and left
commit_log/list_captures empty: the IndexError and 0==N failures in CI.
Production code is correct; the fake had drifted from the API it mocks.
Widened the fake to the real signature so a successful commit stays a
successful commit.
Co-authored-by: Mervin Praison <[email protected]>
Addresses the remaining review findings:
- --command and --backend are mutually exclusive at the CLI; the
executor fails loudly on persisted jobs carrying both (defensive)
- backend jobs now evaluate the pre-run condition gate (skipped, no
subprocess when nothing to do; gate context appended to the prompt)
and pass the message through RunPolicy.scan_prompt when configured,
matching native-agent jobs
- CLI session establishment is serialised per session id with an
asyncio lock (cross-event-loop safe) so concurrent turns cannot
spawn two fresh CLI conversations for one session
Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01Pwwvk68iPoNnGw6ov5Lpga
Addresses the three review findings:
- schedule add: trusted post-add options apply only when this invocation
actually created the job — a duplicate-name rejection can no longer
reconfigure a live schedule
- YAML cli_backend that cannot resolve now raises with remediation
instead of silently running the native LLM path
- backend resolution/timeout/launch failures now write the durable
audit record before failure delivery (all three early-return sites)
Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01Pwwvk68iPoNnGw6ov5Lpga
Greptile P1: the retry loop still mutated self._current_profile on
rotation, and mark_failure/mark_success plus token-model attribution read
that shared pointer. Concurrent requests on one LLM instance could mark
the wrong profile failed/healthy or misattribute cost.
Rotation is now a call-local: _handle_retry_exception takes the active
profile in and returns the (possibly rotated) profile out; both retry
loops keep it in a local and use it for mark_failure/mark_success.
self._current_profile stays pinned to the initial profile. Token usage
is attributed from the serving model on the response (race-free), so the
now-unused _active_profile_values() is removed.
Co-authored-by: Mervin Praison <[email protected]>
Schedule a job that runs its message as one headless turn through a
registered coding-CLI backend (claude-code, codex-cli, gemini-cli,
grok-cli): new ScheduleJob.backend/backend_options fields, an executor
action that resolves backends lazily via the sanctioned _code_bridge
seam (bot never hard-depends on praisonai-code), and
'praisonai schedule add --backend/--backend-cwd/--backend-timeout'.
Output is bounded, failures flow through run records, audit and
failure delivery, and the pinned model is passed to the CLI.
Backend fixes shipped alongside, all previously silent:
- timeouts now return CliBackendResult(error=...) instead of escaping
as bare TimeoutError (all four backends)
- captured stderr reaches the error message (codex/gemini/grok)
- codex: 'exec resume <id>' subcommand order; TOML-safe instructions
escaping; -m model flag; -C honors cwd
- gemini/grok/claude: model flag + cwd threading
- CliSessionBinding.is_resume is now set on the second turn of a
session, activating the resume branches in all four backends
- YAML 'cli_backend:' resolves through _resolve_yaml_cli_backend into
the cli_backend kwarg instead of misrouting into the runtime
registry (shipped example now works)
- 'praisonai backends' registered as a Typer command (standalone
praisonai-code installs previously could not reach the legacy path)
Tests: 19 new (backend action + serialisation round-trip + fixes);
existing scheduler/backend suites verified unchanged vs main;
check_bot_code_imports gate passes.
Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01Pwwvk68iPoNnGw6ov5Lpga
_switch_to_profile wrote the rotated profile's api_key/base_url/model
onto the shared LLM instance. With one instance serving concurrent
requests (asyncio.gather on a shared Agent, or a cached per-session
Agent), a failover rotation for request A changed the credentials and
model that request B read on its next completion build, silently
sending B's traffic to the wrong provider. Rotation is now a per-call
concern: the retry loop threads the active profile through request
kwargs, and instance attributes keep the initial profile values.
Token tracking follows the profile actually used for the call.
Closes#3613 (gap 2).
_release() called asyncio.run() directly, so GC on a thread already driving
an event loop raised RuntimeError before shutdown ran -- the instance leaked.
Bridge the loop the way SharedCompute._run_sync does (offload to a private
thread when a loop is live) so teardown survives either case.
DockerSandbox.execute() labelled its ephemeral --rm containers praisonai=managed,
the sole input to DockerCompute.list_instances(). Those praisonai-<uuid> names
then surfaced in `managed ps` but `managed stop` resolves praisonai_<id> and
could never reclaim them. Relabel as praisonai=sandbox-exec; the timeout kill
uses --name and is unaffected.
Adds a regression test proving _release reclaims when invoked on a live loop.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Mervin Praison <[email protected]>
Both found by adversarial review, both pre-existing, both leaks.
A TIMED-OUT execution left its container running. The handler called
proc.kill(), which ends the docker CLI while the container it started
carries on. The container also had no --name, so it got a random Docker
name -- neither the timeout handler nor `praisonai managed ps` could
find it, and the user was left with something they could see in
`docker ps` and had no supported way to stop. It is named and labelled
now, and the timeout kills the container rather than the client.
The sibling run_command() path already did this, with the comment "Kill
the container, not just the client". execute() never got the same
treatment; the two paths have been quietly different ever since.
run_on= NEVER RECLAIMED ITS INSTANCE. ComputeManagedAgent defaults to
keep_alive=True, which is right -- an instance should survive between
turns -- but it registered no finalizer, so it also survived the process.
For docker that was a container per script; for e2b, modal, daytona,
tenki and flyio it is a billed cloud instance whose only other reaper is
the provider's own idle timer, and docker and flyio have none.
It now uses the same weakref.finalize treatment tools_placement uses for
agents, which covers collection and interpreter exit, and stands down
when the caller shuts down deliberately. The finalizer deliberately does
not close over the backend -- that would keep alive the very object whose
collection it waits for.
Verified from a confirmed-zero baseline: 0 containers after a timeout
(was 1), 0 after a run_on= script exits (was 1), and ordinary runs
unaffected.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Address two P1 review findings on the YAML adapter/drop-in feature:
- Trust gate bypass: project-local .praisonai/channels/*.py files were
classified by the plugins-scoped _is_project_local() (which checks
.praisonai/plugins), so they executed without PRAISONAI_ALLOW_PROJECT_PLUGINS.
Add _is_project_local_channel() scoped to the channels dir and gate on it.
- adapter field reaching constructor: the loader-only YAML `adapter:` import
ref was passed through into adapter_cls(**init_kwargs), raising TypeError for
adapters that don't accept an `adapter` kwarg. Skip it in the gateway
pass-through alongside platform/token.
Adds regression tests for project-local opt-in enforcement (blocked/allowed)
and the adapter-field exclusion.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Restores the CLI/YAML/Python 3-way surface for custom gateway channels:
- ChannelConfigSchema gains an optional `adapter:` dotted "module:Class"
import string; unregistered platforms are lazily imported, validated
against BasePlatformAdapter, and self-registered before startup.
- BotPlatformRegistry auto-discovers single-file adapters dropped into
./.praisonai/channels/ and ~/.praisonai/channels/, reusing the same
trust gate as single-file plugins.
Backward compatible: absent the adapter key and drop-in dir, resolution
behaves exactly as before.
Co-authored-by: MervinPraison <[email protected]>
The sandbox directory is mounted at /sandbox, so that is the path a user
sees from inside the container and the one they naturally reach for. But
the file API treats "/" as the sandbox root, so the container spelling
was joined onto the root a second time:
write_file("/sandbox/report.txt") -> True
cat /sandbox/report.txt -> No such file or directory
find /sandbox -name report.txt -> /sandbox/sandbox/report.txt
and the reverse direction failed too -- a file the container had just
written to /sandbox/out.txt was invisible to read_file("/sandbox/out.txt")
and absent from list_files("/sandbox"), while read_file("out.txt") found
it. Two spellings of one location, one of them silently wrong, and the
wrong one is the spelling the container itself shows you.
Both now resolve to the same file. Only the exact mount prefix is
stripped, so a genuine subdirectory called "sandbox" is not swallowed --
there is a test for that, because prefix-stripping is the kind of fix
that quietly breaks the case it was not thinking about.
Introduced by the mount added to make writes reach the container at all;
before that, /sandbox did not exist inside it, so nothing could be
addressed either way.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Three valid P1s from adversarial review:
- execute()'s _build_docker_command passed max_processes straight to
--pids-limit, so the default 10 killed the container before python
started (exit 125). Apply MIN_CONTAINER_PIDS floor, matching run_command().
- docker commit scrubbed only a fixed secret-name set. ComputeConfig.env is
public (.praisonai/environment.yaml), so an arbitrary custom secret was
baked into the persistent image. Scrub the union of the known set and the
config's own env names -- the values actually injected into the container.
- open_in_sandbox/makedirs_in_sandbox referenced os.O_DIRECTORY / O_NOFOLLOW
and dir_fd unconditionally, raising AttributeError on Windows. Gate the
openat walk behind a capability flag; fall back to the resolved-string
guard where openat is unavailable (Docker there runs a Linux VM, so no
host-shared dir exists to race).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Mervin Praison <[email protected]>
Four defects found by adversarial review of the merged consolidation. The
first two are mine.
ESCAPE. The bind mount added so write_file() actually reaches the
container turned a harmless check-then-use into an exploitable race.
safe_sandbox_path() validates a path STRING; the caller then opens that
string -- two syscalls with a gap. Nothing but the host could write to
that directory before it was mounted. Afterwards, code inside the
container could swap a name between a regular file and a symlink:
for i in $(seq 1 6); do (while true; do
rm -f /sandbox/notes.txt; ln -s /etc/passwd /sandbox/notes.txt
rm -f /sandbox/notes.txt; : > /sandbox/notes.txt
done) & done
Measured before the fix: an escape to an arbitrary host file on write
attempt 375, and a host secret disclosed on read attempt 4. Arbitrary
host read and write, from code that where_does_it_run() calls "a Docker
container".
No amount of stricter string checking fixes this -- anything verified
before the open can be invalidated after it. So the file is never
re-opened by name: open_in_sandbox() walks the path one component at a
time against an open directory descriptor with O_NOFOLLOW at each step,
so a symlink substituted at any level fails the open rather than
redirecting it. Re-running the attack: host file untouched, secret not
disclosed, ordinary writes unaffected.
LEAK ON EVERY EXIT. tools_run_on= releases its sandbox from a weakref
finalizer, which usually runs as the process ends -- and by then
concurrent.futures has set its own atexit flag, so run_in_executor raises
"cannot schedule new futures after interpreter shutdown". The failure was
swallowed and logged as a successful release, so every run left a
container up while reporting it had cleaned up. Docker and Fly.io have no
idle reaper, so those were indefinite.
_offload() now falls back to calling inline when the executor is gone,
which is correct there: no event loop is left to starve. Unrelated
RuntimeErrors still propagate. DockerCompute was never converted to the
shared base in the earlier extraction, so it had the same bug in four
more places; it inherits now. Teardown reports failure instead of
claiming success, since a failed release means something is still
running -- or still billing.
SECRETS IN A COMMITTED IMAGE. `docker commit` preserves Config.Env, so
the model API keys forwarded into a container were baked into an image
that persists for days and is readable by anyone who can run `docker
inspect`. The capture exists to reuse installed packages, not the
credentials that happened to be present while installing them, so the
commit now scrubs them.
FORK BOMBS. run_command() accepted max_processes and ignored it while
the other execution path applied it. Docker's --pids-limit is per
container, so unlike RLIMIT_NPROC it means what it says -- but the
default of 10 was chosen for the rlimit world and kills the container
before the shell finishes starting. The cap has a floor a container can
live within; it still bounds a fork bomb, it just stops the bound from
being the bug.
Also drops `native` from the not-a-boundary list: it aliases sandlock,
which applies Landlock and seccomp, so the two spellings of one backend
were giving opposite security advice.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Seed the outbound carrier with provider headers first, then inject the
active trace context last so a fresh traceparent/tracestate always wins
over any stale provider-supplied values (Greptile review on #4103).
Co-authored-by: Mervin Praison <[email protected]>
Extend the dependency-free GatewayTraceHook seam so an exporter can continue
an upstream caller's distributed trace and propagate context downstream:
- stage() gains an optional parent_carrier (inbound traceparent/tracestate)
so a stage can be entered under an extracted parent span context.
- inject_context()/extract_carrier() no-op defaults let egress call sites
write the active traceparent and ingress boundaries continue a caller's
trace. Real extract/inject lives in the OTel exporter plugin.
NullGatewayTraceHook stays the zero-cost, OpenTelemetry-free default:
inject_context leaves the carrier untouched and extract_carrier returns None.
Additive and backward compatible.
Co-authored-by: Mervin Praison <[email protected]>
The error branch in _parse_tool_call_arguments (PR #4075, commit 991b2ff28)
intentionally reads the real tool name from tool_call["function"]["name"] so a
malformed/truncated arguments string is reported back to the model for the
correct tool instead of aborting the turn with a fabricated name. The stale
test still asserted the old "unknown_function" behaviour, causing a pre-existing
test-core failure unrelated to the docker-sandbox changes on this branch.
Update the assertion to the current contract and add a case proving the
"unknown_function" fallback still applies when no name is recoverable.
Co-authored-by: Mervin Praison <[email protected]>
run_in="docker" goes through execute(), which mounted only its own
script under /code -- so it could not see files write_file() wrote and
anything it created vanished with the container. Mount the sandbox dir
and run the script from there, matching run_command(). Adds a regression
test proving execute() reads a written file and its output persists.
Co-authored-by: Mervin Praison <[email protected]>
It said "backends extracted from the praisonai wrapper", which stopped
being the whole truth when the seven compute providers moved in. The
package now holds both shapes: eight sandboxes (one object IS one
environment) and seven compute providers (one object hands out many).
The distribution name stays. "sandbox" is imprecise for a package that
also contains fleet managers, but it is published, it appears in every
install hint and extra, and renaming a released package costs more than
the imprecision does. The precision lives in the subpackage instead:
praisonai_sandbox.compute is unambiguous about which shape you are
importing.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Two user-visible defects, found by comparing the two docker
implementations now that they sit in one package.
WRITES WENT NOWHERE. write_file() writes into a host temp directory, and
`docker run` never mounted it. So the write reported success, the file
landed outside the container, and reading it back failed:
write_file("/data.txt", ...) -> True
run_command("cat /data.txt") -> exit 1, No such file or directory
Nothing carried between commands either, since each one got a fresh --rm
container with no shared storage. The directory is now mounted at
/sandbox and commands run there by default, so a write is visible to the
next read and to the next command.
LISTING LEAKED THE HOST PATH. list_files() returned entries like
"/../../../../../../private/var/folders/.../praisonai_sandbox_xxx/a.txt".
On macOS the sandbox directory comes back as /var/folders/... while
os.walk reports /private/var/folders/... -- /var being a symlink -- so
the relative-path computation escaped upward and handed back the real
host location. Both sides are resolved before comparing now, and any
entry that still escapes the root is dropped rather than reported.
Isolation is unchanged and tested: the host filesystem stays invisible,
only the sandbox directory crosses.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The C13 sandbox import gate flags any `from praisonai` line in
praisonai_sandbox/. The moved compute providers still documented the
legacy `praisonai.integrations.compute.*` alias in their docstring
examples, tripping the gate and failing test-core, test-core (sandbox)
and smoke. Point every example at the canonical
`praisonai_sandbox.compute.*` path (the old path is now only an alias),
which both satisfies the gate and gives users the correct import.
Co-authored-by: Mervin Praison <[email protected]>
- praisonai-sandbox[all] now includes tenki and flyio, so a user who runs
`praisonai-sandbox[all]` and selects tools_run_on="tenki" gets the SDK
instead of a missing-dependency error (Greptile P1).
- _compute_bridge _EXTRA_HINTS now point at praisonai-sandbox[<vendor>] rather
than the heavy praisonai wrapper — the whole reason the providers moved
(58 vs 143 resolved packages) — and cover the previously-missing tenki hint.
Co-authored-by: Mervin Praison <[email protected]>
Reaching E2B through `tools_run_on=` required the praisonai wrapper: 20
hard dependencies and 8 sibling packages, including a browser package and
a Telegram bot, to run a command in a sandbox. The identical capability
through `run_in="e2b"` needed only praisonai-sandbox[e2b]. Measured, that
was 143 resolved packages against 58, for the same vendor.
The seven compute providers now live in praisonai_sandbox/compute/,
beside the sandbox backends they share a vendor with. That package was
already the right shape for this: four hard dependencies and one optional
extra per vendor, sitting directly above core. Nothing needed inventing --
the extras grid already existed and was already correct.
Verified with the wrapper made genuinely unimportable: providers resolve
and a real Docker container runs. That property now has a test, so the
providers cannot quietly drift back behind a dependency users should not
need.
Compatibility. The old modules alias the new ones through sys.modules
rather than re-exporting from them. A plain `from ... import X` shim
looks equivalent right up until someone monkeypatches a module-level name
through the old path -- the patch lands on the shim and the real module
never sees it, which is exactly what the wrapper's docker-capture tests
do. Aliasing keeps one shared module object, so every existing import,
attribute and patch keeps working.
tenki's SDK pin follows its implementation; flyio needs none, since it
speaks plain HTTP through aiohttp, which praisonaiagents already
requires.
This does not merge the two implementations of the four shared vendors.
That decision is still open, and it is now easier to make because the
pairs finally sit in the same distribution.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
When a provider's SDK was missing, the error told the user what to
install. Three of those instructions were wrong:
praisonai[e2b] did not exist
praisonai[docker] did not exist
praisonai[daytona] existed but was EMPTY -- installed nothing
pip warns about an unknown extra and then carries on succeeding, so the
user followed the official hint, waited for the install, and hit the
identical failure again. That is worse than no hint at all, because it
looks like it is helping.
The wrapper extras are now real and delegate to praisonai-sandbox, which
already pins every vendor SDK correctly, rather than standing up a second
grid of pins to drift against the first.
A test walks every hint, resolves the extra it names against the real
pyproject, and asserts it exists and is non-empty. The only reason this
survived is that nothing checked whether the advice was true.
Rebased onto main and rebuilt rather than merged: this branch and main
had both appended to test_provider_matrix.py, so a textual merge cut a
function in half. Main's copy is taken whole and the one test unique to
this branch re-applied on top.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Compute truncation before emit_run_result in the actions/custom-agent
paths so --output stream-json consumers never receive a contradictory
run.result {ok:true} ahead of the status:"truncated" outcome (exit 2).
Adds a regression test exercising the stream-bridge ordering.
Co-authored-by: Mervin Praison <[email protected]>
auth._PROVIDER_ENV_KEYS retained only each provider's first catalogue
env-var, so providers with multiple credential spellings (together:
TOGETHER_API_KEY/TOGETHERAI_API_KEY, fireworks: FIREWORKS_API_KEY/
FIREWORKS_AI_API_KEY) had their secondary alias dropped. The runtime
(env.py) already honours all aliases, so `auth list`/`status` reported
"not found" for a credential the runtime accepts — the exact drift this
PR set out to eliminate. Now the map keeps all aliases and auth prefers
whichever the user actually set. Adds parametrised coverage over every
multi-alias provider.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Denials now support optional free-text guidance surfaced back to the model
as a course-correction instead of a bare "User denied" dead-end.
- ApprovalDecision gains optional feedback field (backward-compatible).
- ConsoleBackend adds a [d] "deny & redirect" choice capturing one line
of guidance.
- Tool-loop denial handlers fold feedback into the tool result the model
sees next turn (reuses existing result path, no new subsystem).
- InteractiveCLIApprovalBackend now also sets decision.feedback.
Co-authored-by: MervinPraison <[email protected]>
A run that hits its max_iter/max_steps budget returns a non-empty
finalisation summary, which the CLI's _run_succeeded accepted as success —
masking incomplete work with exit 0 and status "success".
The core already records the terminal reason on the agent via
last_stop_reason ("max_steps" on truncation), so consult it on the
in-process run paths that hold the Agent object and report a distinct
tri-state outcome:
- new _run_was_truncated(agent): reads last_stop_reason (best-effort)
- new _report_run_truncated(output, result): preserves the summary text,
emits status "truncated" for --output json, prints a one-line stderr
notice for interactive users, and exits 2 (distinct from failure=1 and
completion=0)
Wired into the actions, custom-agent, and profiled-prompt paths. Genuine
completion and hard-failure paths are unchanged.
Co-authored-by: MervinPraison <[email protected]>
Wire the two remaining parallel provider->env-var maps to the canonical
PROVIDER_ENV_CATALOGUE so key auto-detection, provider inference, and the
auth surface can no longer drift:
- model_resolver._provider_for_model() now resolves via the catalogue
(provider_for_model + env_vars_for_provider), covering openrouter,
mistral, deepseek, xai, together, fireworks, ... instead of returning
None. Keeps the historical eight-provider ladder as an offline fallback.
- auth._PROVIDER_ENV_KEYS is derived from the catalogue at import, with the
historical literal map retained as an offline fallback.
- Add parametrised drift tests asserting every catalogued provider is
resolvable by auth, model_resolver, and env first-run detection, so
future drift fails CI.
Co-authored-by: MervinPraison <[email protected]>
- audit.py: bind sensitive_keys to the built-in redactor via functools.partial
at construction so both default and custom redactors are always invoked with
a single argument, honouring the declared Callable[[Any], Any] contract.
Custom one-arg redactors no longer raise TypeError before _write runs.
- db_session_adapter.py: add per-session init lock so concurrent first access
calls the non-idempotent on_agent_start exactly once, preventing duplicate-key
races in SQL conversation stores. Init locks are cleaned up on delete_session.
Co-authored-by: Mervin Praison <[email protected]>
- security/audit.py: redact sensitive tool_input keys (api_key, token, etc.)
via a default denylist redactor; chmod the log file to 0o600.
- integrations/db_session_adapter.py: guard the four in-memory containers with
a threading.RLock (DB I/O stays outside the lock) to prevent dropped messages
and duplicated on_agent_start under concurrent multi-agent load.
- __init__.py: disambiguate ModuleNotFoundError on e.name so a broken transitive
dependency surfaces the real cause instead of a misleading "has no attribute".
Co-authored-by: MervinPraison <[email protected]>
Address two P1 review findings on the untrusted-request fencing:
- use_system_prompt=False bypass: add a self-describing inline notice
(request_payload_notice) prepended once outside the fence at each
ingress boundary (webhook render_prompt, hook render_template), so the
untrusted-data semantics travel with the payload even when the agent
never receives the system-prompt trust clause.
- deliver_only markup leak: resolve_message gains a fence flag; the
gateway deliver_only path calls it with fence=False so recipients of
direct channel deliveries never see literal fence tags.
Tests updated for the inline notice and unfenced deliver_only output.
Co-authored-by: Mervin Praison <[email protected]>
Address reviewer-flagged issues on the unified schedule management surface:
- schedule_update now clears last_run_at on cadence change so a new at:/every/
cron schedule is evaluated fresh (a previously-run job updated to at: was
treated as already-fired and never ran).
- CLI pause/resume/update/remove/delete mutate the exact record resolved by id
(via store.update/store.remove) instead of round-tripping through the
name-based tool, so duplicate names cannot cause the wrong job to change.
- schedule list --json emits a single valid JSON document ({store, daemon})
instead of mixing a JSON array with the daemon's human-readable table.
- schedule describe consults the canonical store first, so store-backed jobs
authored by schedule add can be inspected; falls back to the daemon.
Adds a regression test for the cadence-reset fix. Full scheduler suite passes.
Co-authored-by: Mervin Praison <[email protected]>
Address final-review findings on the unattended-run wiring:
- schedules_runner: on the canonical store, claim_due advances last_run_at at
claim time, so raising in on_trigger still consumed the occurrence. Only
start the poll loop when an executor was built; otherwise leave the loop
unstarted so no due job is claimed/consumed (P1: atomic claim consumes
failed occurrence).
- schedules_runner: drop the misleading "process-global registry" claim from
the resolver docstring — no such registry exists in host embedding; message
jobs are recorded failed (auditable) and command jobs run (P1: agent
resolution).
- gateway server: rebuild RunPolicy each tick from the current _loaded_config
so a hot-reloaded scheduler: block applies without a process restart
(P1: reloaded policy remains stale).
- test: replace the tautological on_trigger stub test with one that asserts
the real bridge does not start the loop when no executor is available.
Full scheduler unit suite: 307 passed, 8 skipped (no regressions).
Co-authored-by: Mervin Praison <[email protected]>
Addresses Greptile P1 on PR #4088: resolve_launch_host() previously wrote the
process-wide PRAISONAI_LAUNCH_AUTH_TOKEN before the no-agents / missing-deps
guards, so a launch that never started a server could still flip global auth
and retroactively 401 an already-running endpoint. The bind guard now runs only
after both paths are committed to serving. A generated token is also reused for
the process lifetime so a later non-loopback launch cannot mint a fresh token
and lock out an endpoint already serving with the previous one.
Co-authored-by: Mervin Praison <[email protected]>
- run_stream() now records a terminal RecipeResult (success/failed/policy/
missing-deps/dry-run) via best-effort history, closing the streaming gap
- recipe runs: label the identifier column "Run ID" and align judge guidance,
since `recipe judge` consumes the run_id (the index has no trace_id)
- legacy praison_ai: use direct attribute access (removes Ruff B009)
- tests: add streaming-persistence coverage; legacy test now exercises the
wrapper-bridge resolution path the legacy branch actually uses
Co-authored-by: Mervin Praison <[email protected]>
Addresses reviewer P1s in github_commit_and_push:
- Safety refusals now run BEFORE staging a commit, so a refused push leaves
pending changes intact and a retry can succeed.
- Existing local target branches are switched to with a plain checkout instead
of `checkout -B`, so their local-only commits are not discarded.
- Divergence (force-push) refusal is factored out and applied to every branch,
including agent-prefixed ones, and is never overridable.
Co-authored-by: Mervin Praison <[email protected]>
Defect 1: gateway now constructs a default RunPolicy (deliver-on-failure,
prompt scan, denied toolsets) with optional gateway.yaml `scheduler:` overrides,
so failed scheduled jobs notify the owner and unattended tool downscoping fires.
Defect 2: the host-app schedule bridge now executes due jobs via
ScheduledAgentExecutor; when no executor can be built it raises so ScheduleLoop
leaves the job due instead of silently absorbing it.
Defect 3: the BotOS scheduler path now enforces pin_model via the canonical
_check_model_drift before agent.chat — a drifted pinned job takes no model turn
and is recorded failed with the drift reason.
Co-authored-by: MervinPraison <[email protected]>
Wire the existing untrusted-content fencing at the HTTP ingestion boundary so
webhook/hook payloads arrive labeled as data, not instructions:
- Add reusable wrap_request_payload() + <external_request_payload> fence
constants to tools/trust.py (ingress counterpart to wrap_if_external).
- Extend the system trust clause to name the new fence.
- Fence payload-derived content in the webhook channel render_prompt: template
interpolations and the raw-body default are fenced; the no-prompt case gains a
fixed operator prefix so attacker text is never the sole instruction.
- Fence payload interpolations in the gateway hook resolve_message; operator
template text stays outside the fence.
Fence closers embedded in payloads are delimiter-escaped to prevent breakout.
Co-authored-by: MervinPraison <[email protected]>
Address CodeRabbit/Greptile feedback: the prompt-only regression test
previously never asserted that execution completed or that the agent
branch ran, so it could pass while the job stayed queued. Now records
the agent-branch call and asserts a 200 result of "agent-result".
Co-authored-by: Mervin Praison <[email protected]>
- Persist every RecipeResult via history.store_run at the end of recipe
core.run(); gated by PRAISONAI_RECIPE_HISTORY (default on), write
failures never fail the run (logged and ignored).
- Add `praisonai recipe runs [name] [--status] [--session] [--limit]`
to the Typer app, rendering recipe/version/status/when/session/trace-id
and delegating to history.list_runs.
- Fix the broken legacy import in praisonai-code by routing through the
wrapper bridge to the real praisonai.cli.features.recipe module.
- Add unit tests for persistence, env flag, safe write failure, status
filtering, registry independence, and clean handler import.
Co-authored-by: MervinPraison <[email protected]>
Agent.launch served an unauthenticated prompt-execution HTTP endpoint on
0.0.0.0, while Agents.launch enforced PRAISONAI_LAUNCH_AUTH_TOKEN. Factor the
request authoriser and a fail-closed bind guard into a shared
launch_security module and apply it to both paths:
- Agent.launch HTTP route now returns 401 without the bearer token.
- Both paths auto-generate and print a one-time token when bound to a
non-loopback host keyless (loopback keyless dev flow unchanged).
- Default host changed 0.0.0.0 -> 127.0.0.1 for both launch methods.
- Bearer compare is now constant-time.
Co-authored-by: MervinPraison <[email protected]>
Make store-backed jobs first-class in the CLI so what `schedule add`
writes is what `list`/`delete` and the new subcommands read.
- CLI: `list` now merges store jobs (labeled [store]) with daemon
schedulers; add `runs`, `pause`, `resume`, `update`, `remove` operating
on the ScheduleJob store; `delete` finds store jobs first; `add --once`.
- Agent tools: add `schedule_pause`, `schedule_resume`, `schedule_update`;
expose `once` on `schedule_add` (maps to delete_after_run). Registered
in TOOL_MAPPINGS for lazy string resolution.
Scope: kept to praisonaiagents + praisonai per AGENTS.md. `schedule tick`
requires the Tier-2 praisonai-bot executor and is intentionally not added.
Co-authored-by: MervinPraison <[email protected]>
Refuse autonomous pushes to the default/protected branch, foreign-author
branches, and diverged remotes (never force). Auto-create praisonai/ branch
when on default. Opt-in override via allow_unsafe_branch / env.
Co-authored-by: MervinPraison <[email protected]>
The runs API declared recipe_name/recipe_config on JobSubmitRequest and the
executor implemented the recipe branch, but the router never copied those
fields into the Job — so POST /api/v1/runs {"recipe_name": ...} silently ran
nothing recipe-related. Copy the fields through and add regression tests.
Co-authored-by: MervinPraison <[email protected]>
`docker` is implemented twice -- once as a compute provider, once as a
sandbox backend -- and the two defaults had drifted a minor version
apart. Same word, different container:
tools_run_on="docker" -> Python 3.12.14
run_in="docker" -> Python 3.11.16
Nobody decided that. Two independent implementations of one idea simply
diverged, and unifying the vocabulary made the divergence easier to reach
rather than harder, because both spellings now look like the same thing.
The sandbox side moves to python:3.12-slim to match, along with the CLI,
docs and examples that quoted the old default.
The parity test compares the values a caller actually gets --
SandboxConfig.docker().image against ComputeConfig().image. An earlier
version of it scanned the DockerCompute docstring, which meant it passed
while the defaults were genuinely 3.11 and 3.12 apart. A test that cannot
fail is worse than no test, because it is counted as coverage.
Rebased onto main and rebuilt rather than merged: this branch and main
had both appended to test_provider_matrix.py, and a textual merge cut a
function in half. Main's copy is taken whole and the one test unique to
this branch re-applied on top.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Addresses reviewer P1 findings on the async batch/window fixes:
- _depends_on_pending now also detects dependencies expressed via
task.previous_tasks (workflow next_tasks edges are stored as task names),
not only task.context, so a workflow successor is no longer queued in the
same async batch as its predecessor and forced to read an empty result.
- New _deps_failed method extends the failure cascade to the async_execution
path in both the sequential and workflow branches: after a same-batch flush,
an async dependent whose prerequisite failed is marked failed instead of
running with missing upstream context (previously only sync dependents were
guarded).
- TruncateOptimizer re-skips leading orphaned tool results after its
token-budget pop loop, which could otherwise drop an assistant tool_calls
message while retaining its role="tool" result and leave the trimmed
transcript starting on an orphan (Issue #3089).
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
1. Guard the tool-call argument JSON parse in the non-streaming native
loops (openai_client.chat_completion_with_tools / achat_completion_with_tools)
so a malformed/truncated arguments string is reported back to the model
instead of aborting the whole agent turn, matching the streaming path.
Also read the real tool name from tool_call["function"]["name"] in
llm._parse_tool_call_arguments' error branch instead of the never-present
top-level "name" key.
2. Route DefaultSessionStore._enforce_window's window slice through the
existing _trim_preserving_tool_exchanges guard, and skip leading orphaned
role="tool" messages in the context TruncateOptimizer/SlidingWindowOptimizer,
so a persisted/optimized window never starts mid tool-exchange.
3. Split an async task batch on an intra-batch context dependency in
arun_all_tasks (sequential + workflow) so a dependent async task's context
is built after its same-batch dependency completes, not before.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
The generic ComputeManagedAgent forwards image= through ComputeConfig.image,
which Docker/Daytona/Fly read but E2B (used metadata[e2b_template]) and Modal
(hardcoded debian_slim) ignored -- so run_on='e2b', image=... silently booted
the provider default. E2B now takes a non-default image as its template; Modal
builds it via from_registry. The default value leaves both unchanged. Adds
boundary conformance tests asserting the value each SDK actually receives.
Co-authored-by: Mervin Praison <[email protected]>
Every cloud SDK here is synchronous and ComputeProviderProtocol is async,
so each provider grew the same five methods whose entire body is "hand the
matching _*_sync call to a thread":
async def provision(self, config):
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, self._provision_sync, config)
Measured: provision, execute, shutdown, upload_file and download_file were
byte-identical across e2b, daytona, modal and tenki -- six identical pairs
per method, about 210 lines of the same four statements.
That was the largest genuinely duplicated block in the project, larger
than all four cross-stack vendor pairs combined. It stayed invisible
because it sits *within* one stack rather than between two, and the
attention had been on the cross-stack pairs, where the real overlap turns
out to be about 6%.
A vendor now implements only the _*_sync half it differs in. Anything a
provider genuinely does differently it overrides, because these are
ordinary methods -- flyio does exactly that, since it speaks HTTP through
aiohttp and is natively async rather than sync-wrapped.
Each unimplemented operation raises NotImplementedError naming the
provider, rather than silently returning a wrong answer.
Net -119 lines. Verified live: docker and modal both still provision and
execute, and the provider matrix passes for all thirteen places.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
`run_on="docker"` had its own 321-line managed backend while the other
seven places shared one generic implementation. The justification written
into that file -- it talks to the daemon directly and skips a
provisioning layer -- did not survive being checked.
It named its containers `praisonai-agent-<hex>`, while DockerCompute's
lookup expects `praisonai_<instance_id>`. So:
praisonai managed ps -> shows the container
praisonai managed stop -> ValueError: No docker container found
Listed but unreclaimable, which is the worst combination: the user can
see the thing they cannot stop. Routing through the provider fixes it by
construction, and 321 lines go with it.
Two gaps in the generic backend had to close first, both real:
- `image=` fell into **kwargs and was silently ignored, so asking for a
specific image quietly got the provider's default.
- the failure when a place is unavailable now names the place and
offers tools_run_on=, which needs no daemon.
The generated class is still called DockerManagedAgent -- make_loader
names each backend after its place -- but it is now a ComputeManagedAgent
subclass, so there is one code path for every runtime.
Also adds the conformance tests that would have caught this class of
problem: the four vendors implemented twice must agree on their
conventions. Docker-only parity is exactly what let Daytona drift into
asking for 1024 GiB of RAM.
Verified live: docker provisions, runs a whole agent loop, appears in
`managed ps`, and `managed stop` now reclaims it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
test_hosted_agent_only_accepts_anthropic encoded a pre-run_on= assumption
that modal/e2b were unavailable managed runtimes. The placement feature
intentionally registered them via the backend registry, so HostedAgent now
resolves them. Assert the guarantee that still holds — providers with no
registered backend (openai/gemini) raise a helpful ValueError — instead of
the stale one.
Co-authored-by: Mervin Praison <[email protected]>
Replace inspect.getsource() substring checks with behavioral tests that
mock the Daytona SDK and assert the actual value passed: memory converted
to GiB (2048MB->2, 512MB->1) and upload_file(src, dst) argument order.
Addresses Greptile p2: source inspection let equivalent regressions pass
and harmless refactors fail.
Co-authored-by: Mervin Praison <[email protected]>
Two live defects on the compute side of Daytona, both found by comparing
it against the sandbox side, which had already learned the conventions.
MEMORY UNITS. daytona_sdk documents Resources.memory as "Amount of memory
in GiB to allocate". The compute provider passed ComputeConfig.memory_mb
straight through, so the default configuration asked Daytona for 1024 GiB
of RAM. That is a provisioning failure, not a rounding error. The sandbox
implementation converts correctly and always has.
ARGUMENT ORDER. FileSystem.upload_file(src, dst) takes the content first
and the destination second. The compute side called
upload_file(remote_path, content), so an upload wrote the path into a
file named after the content. The sandbox side calls it correctly.
Neither is a typo in the ordinary sense: both are the same class of
failure, where one of two implementations of a single vendor learned a
convention and the other never did. Nothing compared them, so nothing
noticed. The wrapper's daytona tests only assert provider_name and that
the constructor exists -- nothing exercises provision() or upload.
Tests now assert both conventions on the compute side, because a shared
vendor name is not a shared understanding of that vendor.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The provisioning tests asserted an exact count of one. SharedCompute
provisions LAZILY, so a run that never touches a tool correctly
provisions nothing -- the exact count was asserting when provisioning
happened rather than the property that matters, and it flaked.
The guarantee is that a run never provisions MORE than one sandbox, and
that a three-row batch does not mean three. Verified the weaker assertion
still has teeth: mutating _needs_tools_scope to always open a scope fails
six tests including the batch one.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
An ephemeral place (keep_alive=False) provisions a billable cloud
instance, but execute() only tore it down on the success path -- a
failed write, remote run, or parse left the instance running until
external cleanup. Move shutdown into a finally so teardown is
unconditional; keep_alive=True still keeps the warm instance.
Fixes the Greptile P1 on compute_managed_agent.py.
Co-authored-by: Mervin Praison <[email protected]>
SANDBOX_ONLY was a hardcoded tuple of four names. The sandbox registry
accepts plugins by entry point, so a contributed backend worked with
run_in= and could not be selected with tools_run_on= at all -- the same
hardcoded-list bug just fixed for the compute registry, one layer down.
`capsule` was the live example: it ships from praisonai-plugins, resolves
fine through run_in=, and was missing from the tools_run_on= vocabulary
for no reason a user could see.
The names now derive from the registry, with the original four kept as a
floor so nothing depends on discovery succeeding. Anything the compute
registry already provides is excluded, so docker/e2b/modal/daytona keep
their richer implementations instead of being downgraded to the
one-instance adapter.
SANDBOX_ONLY stays a tuple subclass that re-reads the registry on
membership and iteration, because several call sites predate this and
import it directly.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The phrase table is a literal, so it cannot know about a package
installed later: an external place printed as a bare name
(tools_run_on='demo') while every built-in got a readable phrase. A
provider may now declare `display_name` and have it used in the repr and
in where_does_it_run().
Verified with a plugin installed from outside the repo: it reports
"a Demo cloud sandbox" in both. Declaring it stays optional and the
fallback to the bare name is unchanged, so nothing existing shifts.
That leaves one derived surface still hand-maintained -- the ToolPlace
Literal -- which is inherent to static typing rather than fixable: a type
checker cannot see a runtime plugin. Its union already stays open, so an
unknown name type-checks and only loses autocomplete.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
_PS_PROVIDERS was a hardcoded tuple, so a place contributed by another
package could be provisioned and then never listed or stopped. An
unreclaimable cloud sandbox is a bill nobody sees, which makes this the
one remaining hand-maintained list with a cost attached.
Exclusions are now stated with reasons rather than by omission: local and
subprocess run here and have nothing to reclaim, ssh is someone else's
machine and not ours to stop, sandlock is a process rather than an
instance, and adapter-backed places have no cross-process registry to
enumerate.
_PS_PROVIDERS stays as a module attribute so existing imports keep
working.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Modal's `run_in=` path was broken in five separate ways and nothing
noticed, because nothing had ever run it. Most of what breaks an
integration is checkable for free, so this checks it: for all ten places
the class resolves, satisfies the protocol, exposes coroutines rather
than sync methods (a sync one here does not raise -- it returns a
coroutine nobody awaits, so the call silently does nothing), answers
is_available without throwing, is accepted by tools_run_on=, is accepted
or refused by run_on= according to whether it can host a loop, and is
described in the repr as the place it was actually given.
The one that matters most in practice is the last check: a missing
credential has to fail with something actionable -- naming the key, the
install command or the daemon -- rather than an AttributeError from
inside a vendor SDK. All seven credential-less places pass it today.
Live where the machine allows: docker, subprocess, local and modal
provision and execute for real; the rest skip loudly.
Also removes a latent flake. The batch-provisioning test asserts an exact
provision count, and agents from earlier tests release their sandbox when
collected -- which could land inside the measurement window now that
lifetime is tied to a finalizer. The window is opened after a collection
instead of hoping the timing misses.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
run_on= accepted two names while tools_run_on= accepted twelve. That was
not a fact about the places -- a place that can run a command can run the
agent loop, which is just one more command -- it was an implementation
detail: no backend had been written for the rest.
One generic ComputeManagedAgent wraps any ComputeProviderProtocol rather
than eleven near-identical vendor backends, taking run_on= from 2 places
to 8: anthropic, docker, e2b, modal, daytona, flyio, tenki, novita.
Verified live end to end on Modal -- the whole loop ran there and came
back with the answer, having installed the package remotely first.
Excluded deliberately, with tests asserting they still raise: `local`
would run the agent in your own shell, which is what you get by passing
nothing; `ssh` needs an object, not a name; `subprocess`/`sandlock`
isolate tools rather than host a runtime. `docker` keeps its specialised
backend since it can talk to the daemon directly.
Two details worth keeping:
- the registry loader returns a real subclass, not a factory function,
because HostedAgent's factory path calls issubclass() on it
- files reach the remote machine through a quoted heredoc, since execute()
is the only primitive every compute place has, and the delimiter is
uppercase for the same reason as elsewhere: a lowercase hex delimiter
contains "dd", a blocked command, about a tenth of the time
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Modal is authenticated on this machine, so the path could finally be
exercised. It failed five times in a row, each fault hiding the next:
1. keep_warm / allow_concurrent_inputs were renamed on 2025-02-24 and
the old spellings now RAISE rather than warn. Both generations are
handled by inspecting the installed SDK's signature, since hardcoding
either spelling breaks half the installed base.
2. The decorated function is defined inside a method, which Modal only
accepts with serialized=True.
3. The App was never started. A Modal function only carries the metadata
it needs while its App is running, so .remote() failed with "Function
has not been hydrated". The app.run() context is now held for the
sandbox's lifetime and closed in stop().
4. add_python="3.11" was passed to an image that already ships Python --
the default is python:3.11 -- so the image build failed outright.
5. The default image was pinned to 3.11 while a serialized function is
pickled locally and unpickled inside it, so anyone not on 3.11 got
"defined with Python 3.12, but its Image has 3.11". The default now
follows the running interpreter.
Verified end to end: execute_code(run_in="modal") returns 'from modal'
with exit 0 against real Modal infrastructure.
tools_run_on="modal" was already working and is now verified live too --
provision, uname, a file written and read back across calls, shutdown.
The test asserting a hardcoded python:3.11 default was updated rather
than deleted; it now pins the interpreter-matching rule that replaced it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Two of the three registries already discovered plugins by entry point --
praisonai.sandbox and praisonai.managed_backends. The compute registry,
which is where tools_run_on= places live, was a hardcoded dict, so adding
one meant editing core:
_compute_bridge._PROVIDERS the resolver
compute/__init__.py the lazy import
placement.ToolPlace or it gets no typing
cli/commands/managed._PS_PROVIDERS or `managed ps` cannot see it
execution_location._PLACE_WORDS or it prints a bare name
plus the lists in two docstrings
Six edits across two packages, for something that is by nature a plugin.
It now reads praisonai.compute as well, so a provider ships in its own
distribution:
[project.entry-points."praisonai.compute"]
runpod = "praisonai_compute_runpod:RunpodCompute"
Verified end to end with a package installed outside this repo: it
appeared in available_providers(), passed tools_run_on= validation,
resolved, executed, and Agent(tools_run_on="demo") built -- with no
change to any file here. The repr falls back to the bare name, which is
the one remaining hand-maintained list and degrades gracefully.
Providers load on demand, so an installed-but-broken plugin fails only
for the caller that names it. There is a test for that.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The bots-gateway CI shard runs from src/praisonai-bot, whose gateway.yaml was
auto-discovered when these tests invoked `gateway start` with config=None.
Discovery then ran the verify-turn pre-flight (a real agent turn, default-on
per #4042) before the monkeypatched start(), exiting 1 with CI's fake key.
Pass an explicit channel-less config so every start-time pre-flight is skipped
and the command reaches the patched handler deterministically. The exit-code and
watchdog-flag assertions are unchanged — the tests still guard exactly what they
did, just hermetically.
Co-authored-by: Mervin Praison <[email protected]>
The compute-only branch ran before the provider object was normalised, so a
managed backend holding a LocalCompute (provider_name="local") skipped it and
the alias table collapsed "local" to "subprocess" -- describing the
unrestricted shell in the locked-down backend's stronger words. Normalise
first, then apply the compute-only check, and read a backend's compute via the
compute vocabulary since that object is a compute-registry provider.
Co-authored-by: Mervin Praison <[email protected]>
Three issues raised in review of run_on="docker":
- execute()/stream() ran the blocking Docker start-up, install and container
turn directly on the event-loop thread, stalling concurrent async callers.
Offload to a worker thread with asyncio.to_thread.
- keep_alive=False only tore the container down on success, so a failure after
start-up leaked a praisonai-agent-* container. Move teardown into finally.
- Both READMEs showed run_on="docker" raising TypeError while also documenting
it as supported. The typo example now uses e2b, which genuinely cannot host
an agent loop, so the two examples no longer contradict each other.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Mervin Praison <[email protected]>
`run_on="anthropic"` hands the entire agent to a vendor's runtime. This is
the same shape, self-hosted: the loop runs in a local container instead.
The distinction from tools_run_on="docker" is what crosses the boundary:
tools_run_on="docker" your process runs the loop and calls the model;
only shell and file tools go into the container
run_on="docker" the loop itself runs inside, so the model call is
made from the container and nothing on your
filesystem is reachable unless you mounted it
Both name the same place, and that is not an ambiguity -- the parameter
carries the scope. So the two value sets deliberately overlap now, and
the tests say so: a name in both must work in both, a name in only one
must be refused by the other with a message naming the right parameter,
and the object must still report which scope you chose.
Implementation is just a backend registration. run_on= resolves against
ManagedBackendRegistry, so adding DockerManagedAgent there makes the
parameter, the repr, the explanation and the conflict checks all pick it
up with no changes to any of them.
Two things this exposed:
- _backend_places() identified hosted runtimes by CLASS NAME ("Hosted" or
"Anthropic" in it). A self-hosted DockerManagedAgent matched neither,
so an agent whose entire loop ran in a container reported that it was
thinking on this machine. Backends are now asked what they are.
- The runner writes its payload into the container through `docker exec
-i ... tee` rather than an argument, so instructions and API keys never
appear in `docker inspect` or a process listing.
Stated limitations rather than hidden ones: Python callables in tools= do
not cross (same as the hosted runtimes), the container needs the model
API key, and the first run installs the package unless image= supplies
one that has it.
Tests that need a live daemon are marked skipif rather than silently
passing; the rest -- registration, both scopes, config marshalling,
result parsing, the missing-daemon message -- run without Docker.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Two gaps found while documenting every accepted value.
"local" resolves differently depending on which parameter carried it:
run_in="local" is collapsed by SandboxManager to the subprocess backend
-- scrubbed environment, blocked commands and paths -- while
tools_run_on="local" is the wrapper's LocalCompute, a plain shell in the
current directory with the full environment inherited and no security
policy at all. Both printed "a separate process on this machine", so the
weaker of the two was described in the stronger one's words. That is
precisely the failure this module exists to prevent, hiding in the one
place the rest of the work did not look. say_place() now takes the
registry that resolved the name.
Resolver aliases were rejected at construction. Validation ran before
alias resolution, so tools_run_on="native" raised "not a known place"
while resolve_compute("native") returned a working sandlock adapter.
tool_places() now includes the alias table it validates against.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
LocalManagedAgent carried its own hardcoded provider list, which is why
LocalAgent(tools_run_on="sandlock") raised "Unknown compute provider"
for a place the rest of the library resolves happily. Routing it through
the merged resolver changes the message to "Unknown place", listing
every place both registries offer.
The behaviour is unchanged -- an unknown name still raises ValueError --
so only the assertion was stale. Added coverage for what the delegation
actually buys: the sandbox-only places now resolve here too.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Two coroutines that astart() the SAME team under one asyncio.gather share
the event-loop thread, so the previous threading.local scope state was
shared between them: the second saw the first's depth==1, skipped its own
sandbox, and ran against a sandbox the first could tear down mid-flight.
Switch _tools_scope_depths from threading.local to a contextvars.ContextVar
holding an immutable frozen mapping replaced via .set() (never mutated in
place). A ContextVar is copied per task at gather/create_task time, so each
run gets its own depth map; it stays per-thread too, so sync callers remain
isolated. The frozen value matters -- a copied context shares the reference
to a mutable dict, so in-place mutation would leak straight back across the
tasks the copy was meant to separate.
Adds a deterministic regression test that fails on the old design
(['own', 'borrowed']) and passes now (['own', 'own']) without needing a
live model.
Co-authored-by: Mervin Praison <[email protected]>
The security audit demonstrated `cat $(echo /etc/passwd)` reading the
file end-to-end through the subprocess place: blocked_paths matches
tokens, and any shell indirection -- command substitution, a variable, a
nested sh -c -- hides the path from it. That is inherent to a blocklist
over a real shell, not a patchable pattern, so where_does_it_run() now
says the command list can be worked around rather than implying the
tier contains anything.
Also updates the flagship YAML example to tools_run_on:, lists the four
newly reachable places, and marks which of them are real boundaries
(sandlock is kernel-enforced; subprocess is process separation only).
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Mutation testing showed 7 of 14 deliberate breakages left the suite
green. The pattern was consistent: argument validation and repr strings
were well covered, and every path that actually moves work to a sandbox
was not covered at all. Deleting the ensure_tools_placed() call sites --
the feature's entire point -- broke nothing.
Bugs fixed:
- A clone inherited the source agent's bridged tools, which close over
the SOURCE's sandbox, and its backstory already carrying the capability
prompt. Its first turn then attached a second sandbox: two copies of
the prompt, and work split across two machines. The tools are now
tagged so the existing clone filter drops them, and the clone inherits
the pre-attach backstory.
- test_apply_rlimits_sets_memory_limit still asserted RLIMIT_NPROC was
set. It passed on main and failed here, because dropping NPROC is
deliberate. Replaced with an assertion that it is NOT set, plus a new
test that one unsupported limit no longer skips the others -- the
latent bug that made RLIMIT_NOFILE never apply on macOS.
Tests added for what survived mutation:
- every entry point (chat/achat/_start_stream) places tools before the
first turn, asserted by driving the real method
- AgentTeam start/astart/start_for_each provision exactly one sandbox,
counted through a fake provider, instead of poking a private method
- policy_scan_parts expands shell payloads, survives unbalanced quotes,
strips quoted heredoc bodies but NOT unquoted ones, and a blocked
path inside `sh -c` is genuinely refused (asserting the file was not
read, not merely that an error was returned)
- SandboxComputeAdapter, which had zero tests despite being the largest
new file: policy denials report failure rather than exit 0, shell
semantics work, state persists across calls
- SandboxManager constructor introspection, including that the guided
error names `host` and SSHSandbox rather than leaking a raw TypeError
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Two reliability bugs the security audit reproduced.
write_file() built a heredoc delimiter from uuid4().hex, which is
lowercase. The security policy substring-matches blocked_commands
against the whole command string, and "dd" is one of them -- present in
9.6% of random lowercase hex strings (measured over 100k). So roughly
one write in ten failed with "Blocked command pattern: dd", for no
reason the caller could see. The delimiter is uppercase now.
The same substring scan also read the heredoc BODY, which is the literal
bytes of a file, not a command. Saving a shell tutorial containing
"rm -rf" was rejected as an attempt to run it; a file mentioning
/etc/passwd was rejected as an attempt to read it. Quoted heredoc
bodies are now stripped before the policy check -- only the quoted form,
since an unquoted heredoc still undergoes shell expansion and its
contents really can execute.
Verified the boundary is unchanged: `cat /etc/passwd` and `rm -rf /`
are still blocked, while all three content cases now write, and 60
consecutive writes produce 0 spurious failures.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Four lifecycle bugs, all found by adversarial validation.
LEAK. An agent and its SharedCompute referenced each other, so a dropped
agent was reclaimed by the CYCLE collector, vanished from the WeakSet the
atexit hook iterated, and its sandbox was never shut down. Gateways clone
an agent per request, so that was one orphaned container per request --
measured: 5 requests, 5 provisioned, 0 shut down.
Fixed by tying lifetime to the agent with weakref.finalize (which also
covers interpreter exit, so the atexit hook and weak set are gone), and
by holding agents WEAKLY in SharedCompute._patched. The weak _patched is
the load-bearing half: while the restore list held agents strongly, the
finalizer kept its own agent alive and could never fire.
Now: 5 provisioned, 5 shut down, 0 leaked.
RACE. close_tools_sandbox() cleared the marker first and shut down after,
outside the lock. A concurrent chat() re-attached in that window and the
in-flight shutdown restored over it, leaving the agent marked as
sandboxed while holding no sandbox tools -- unrepairable, because
ensure_tools_placed()'s idempotent early return sees the marker and does
nothing. It is all under the lock now.
EXIT HANG. The exit path took a process-global lock, so any thread
holding it blocked interpreter exit forever (reproduced: rc=124). The
realistic form was a bounded stall, since the lock was held across
SharedCompute(...) -- which imports the provider SDK.
CONTENTION. That same global lock serialised placement for unrelated
agents (16 concurrent attaches: 0.87s against a 0.05s parallel floor),
and being non-reentrant while held across attach()'s writes to
agent.tools, any subclass with a tools setter that touched placement
would self-deadlock in its own constructor. Locks are per-agent now, and
the only global lock covers a dictionary lookup.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Two bugs found by adversarial validation, both silent.
1. AgentTeam handed the model the PRE-ATTACH tool list.
_build_execution_context snapshots agent.tools and passes it to chat()
as an explicit `tools=`, which wins over agent.tools. The sandbox
attached later, inside chat(), so the model got the local tools while
a real sandbox sat provisioned and unused -- every tool ran on the
host. Placement now happens before the snapshot.
It read as flaky rather than broken: in start_for_each() row 1 got
local tools and row 2 got the bridged set, because row 1's chat() had
mutated agent.tools in time for row 2's snapshot.
2. The scope depth was shared class-wide, not per team.
`_tools_scope_state` is a class attribute, so ANY team started while
another team's run was on the stack saw depth==1 and skipped its own
sandbox. A nested team ran on the host; a team sharing an agent with
the outer run executed on the OUTER team's sandbox; two teams in one
asyncio.gather gave one a sandbox and the other nothing. Depth is now
keyed per team, and the key is dropped at zero so the dict cannot grow
without bound on a long-lived worker thread.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Three parameters meant "somewhere else" and none of them said which
somewhere: `run_on=` (tools only, on Flow/Team), `compute=` (tools only,
on LocalAgent) and `backend=` (the whole agent). One concept had two
names and one name had two concepts, so the docs had to keep correcting
the API.
Now two words, each answering a different question:
run_on= the WHOLE agent moves to a managed runtime
tools_run_on= only the tools move; thinking stays here
run_in= one execute_code() call, this call only
Because the two questions have disjoint answer sets, a wrong pairing is
a typo rather than a preference. `run_on="docker"` cannot mean anything
-- Docker does not host agent loops -- so it raises TypeError naming the
parameter the user actually wanted, instead of silently placing work on
a machine nobody chose. Never "last one wins".
Reachability: sandbox-only backends (subprocess, sandlock, ssh, novita)
spoke SandboxProtocol -- one object IS one environment -- while
tools_run_on= speaks ComputeProviderProtocol -- one object HANDS OUT
many. SandboxComputeAdapter supplies the missing half, taking the
registry from 7 places to 11. This finally makes `sandlock` selectable:
kernel-enforced Landlock + seccomp-bpf, the only real local boundary in
the project, previously unreachable.
Shipped bugs found and fixed along the way:
- AgentTeam.astart() never provisioned the shared sandbox at all, so
every async team ran its tools on the host and said nothing.
- start_for_each() provisioned one sandbox per input row: a 50-row
batch paid for 50, and no row could see what an earlier row wrote.
- The re-entry guard set `self.run_on = None` for the duration of a
run, so a concurrent start() observed "no sandbox" and executed
every tool on the host. Now thread-local; placement stays readable.
- SharedCompute.attach() could attach an agent twice (flow + its own),
duplicating the capability prompt and, on non-LIFO teardown, leaving
the agent holding tools bound to a shut-down instance.
- SandboxManager passed config= to every backend; SSHSandbox and
ModalSandbox do not accept it, so they raised before starting.
- RLIMIT_NPROC was applied per *real user ID*, not per sandbox, so
max_processes=10 counted the user's existing processes and the
sandbox could not fork at all.
- blocked_paths stopped matching once commands ran through `sh -c`,
because the path hid inside the shell payload.
Removed ExecutionConfig.code_sandbox_mode. It defaulted to "sandbox"
while no execution path read it, so its own default told every reader
they had isolation they did not have.
where_does_it_run() now names the tools that will NOT move. Only four
tool names are bridged; the capability prompt tells the model the
sandbox is where things happen, so anything staying behind has to be
said out loud.
Migration:
- YAML `run_on:` stays a silent alias -- it is data people already
shipped, and there is no Agent-level run_on: key to confuse it with.
- Python `run_on=` on Flow/Team raises with a directed message; it
would otherwise mean the opposite of `run_on=` on Agent.
- `compute=` warns (FutureWarning, visible to end users per PEP 565).
- `Agent(sandbox=)` is kept, not removed: clone_for_channel() passes
it and it is a legitimate per-agent default. `run_in=` overrides it
per call.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Extract the repeated inline `next((t for t in self.tasks.values()
if t.name == <name>), None)` into a single `_task_by_name` helper and
call it at all 13 sites. Behaviour-identical: first-match by name,
None when absent. No public API or behaviour change.
Co-authored-by: MervinPraison <[email protected]>
Remove the shutil.which("echo") skipif: TemplateInterpolator runs commands
with shell=True, so echo is available as a shell builtin on both cmd.exe and
/bin/sh even without a standalone executable. Normalise output with .strip()
for Windows CRLF instead, restoring coverage. Also drops the now-unused shutil
import and trailing whitespace.
Co-authored-by: Mervin Praison <[email protected]>
Cross-reference validation only inspected step.agent, step.steps, and
step.routes, so undefined agents nested inside the newly accepted bare
runtime-form payloads (route:/parallel:/loop:/repeat:/if:) silently
passed while the runtime parser drops/rejects them (Greptile P1).
- Walk route: value lists (agent-name scalars) and parallel/loop/repeat/if
sub-step dicts (agent: keys), leaving non-agent scalars like
loop: {over: <var>} untouched.
- Regenerate the stale agents.schema.json artefact to match the PR's
broadened WorkflowStep model.
- Add tests for undefined agents in route/parallel payloads and a
loop-over-variable false-positive guard.
Co-authored-by: MervinPraison <[email protected]>
- Async event loop: run_autonomous_async now offloads verification hook
execution to a worker thread via _verification_gate_async so a slow/blocking
CommandVerificationHook no longer stalls concurrent coroutines.
- Durable journal: rewire _record_verification_journal to the real
DurableRunContext.record_verification (append JournalEvent, KIND_VERIFICATION)
instead of a non-existent _run_journal.record API; events now actually persist.
- Goal gate: a failing blocking hook now carries the captured stdout/stderr
summary into last_reason so the next continuation prompt has actionable repair
diagnostics instead of a bare label.
- Tests: +4 covering async thread-offload, durable journal persistence, and
goal-failure diagnostics propagation.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Addresses two P1 correctness gaps from PR review:
- Explicit --resume onto an absent/misspelled/deleted checkpoint now returns
a clear error instead of silently running the workflow from step 1 and
repeating already-completed side effects.
- _definition_fingerprint now hashes agent config values (not just keys), so
semantic edits (instructions/model/tools/condition/routing) invalidate the
checkpoint rather than combining a stale step index with an edited workflow.
Adds two regression tests: missing-checkpoint fail-closed, config-value edit
changes fingerprint.
Co-authored-by: MervinPraison <[email protected]>
Addresses two P1 review findings on PR #4059:
- handoff._cap: capping an oversized recap dropped the actionable NEXT:
continuation guidance. Cap now preserves the head (preamble/goal/progress)
and the NEXT: tail verbatim, trimming only the middle recap block.
- summary_builder: assistant turns that merely proposed or *asked* about work
were recorded under "Completed Actions", so a resumed agent could skip work
only discussed. Question-bearing assistant turns now flow to Pending
Questions / Remaining Work only; executed tool calls/results stay completed.
Adds regression tests for both.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
- Defect 1: map parsed markdown routing (TaskRoutingConfig) onto real
Task.branch_condition/next_steps the executor reads; stop clobbering
condition with a dataclass; guard dict-condition crash in step loop.
- Defect 2: wire Task(when=..., then_task=..., else_task=...) into the
WorkflowManager step-advance path for per-step conditional redirection.
- Defect 3: register `workflow validate` on the Typer workflow app so it
reaches the existing handler.
- Defect 4: align WorkflowStep schema with the runtime dialect (bare
agent/route/if/loop/repeat/parallel/include steps) and treat
backstory/instructions as aliases so shipped example workflows validate.
- Defect 5: fix .praison -> .praisonai list dir via shared path helper;
reject unsupported on_fail values clearly; fail loudly on YAML files in
the markdown workflows dir.
- Tests: executable markdown branch routing (matched + unmatched), when
routing, and a round-trip validation over examples/yaml/workflows/.
Co-authored-by: MervinPraison <[email protected]>
Wire --resume/--checkpoint/--rebase-checkpoint into `workflow run` and add a
`workflow checkpoints` subcommand. Store a stable, normalized definition
fingerprint in each checkpoint and compare it on resume, refusing to silently
land on a stale step index after the workflow file was edited (with
--rebase-checkpoint to force). Record resumed_from_step in the result.
Co-authored-by: MervinPraison <[email protected]>
Addresses two reviewer-flagged issues in the scheduler cross-run memory:
- P2 (nested aliasing): get_state/set_state now deepcopy so a caller
mutating a nested list/dict in a read result can no longer bypass the
16 KiB size cap or copy-on-read guarantee.
- P1 (orphan one-shot state): _persist_job_state now skips
delete_after_run jobs, which claim_due already removed (state popped)
before the run — persisting would recreate an orphan job_state entry
nothing cleans up.
Adds tests: deep copy-on-read/snapshot isolation (core) and one-shot
non-recreation (wrapper).
Co-authored-by: Mervin Praison <[email protected]>
Make the dormant verification primitive enforceable:
- Wire _verification_gate() into run_autonomous(_async): blocking hook
failures block a success outcome and feed captured stdout/stderr back as
feedback; never-passing checks end at max_iterations, not success.
- Fix lossy _run_verification_hooks re-wrap (preserve details/error/duration).
- Add declarative FileCheckHook (exists/non_empty/contains/json_field==equals),
no shell; CommandVerificationHook now accepts a string command.
- Feed the goal judge executable evidence; a failing blocking hook
short-circuits to continue without spending a judge call.
- add_verification()/remove_verification() for runtime registration.
- Add 'verification' run-journal event kind and YAML `verification:` config
parity mapping command:/file: shapes onto the two hook classes.
Opt-in only: agents without verification_hooks are unchanged.
Co-authored-by: MervinPraison <[email protected]>
Un-stub the documented structured_template compaction feature with a
deterministic DefaultSummaryBuilder that renders SUMMARY_TEMPLATE (falling
back to the plain summary on failure), wired as the default builder in
ContextCompactor when structured_template=True.
Add a deterministic session handoff assembler (build_handoff_prompt) that
turns durable state (recap + persisted GoalState + workflow checkpoint) into a
self-contained continuation prompt with no LLM call, plus a
`praisonai session handoff` CLI command with --json/--copy.
Co-authored-by: MervinPraison <[email protected]>
Address two P1 review findings on the standalone scheduled sender:
- _HttpSendError now preserves the raw Retry-After header verbatim (via a
headers dict) instead of pre-parsing to float, so server_retry_after honours
both integer-seconds and HTTP-date forms. A float(raw) previously discarded
any HTTP-date delay, letting the sender retry early and exhaust attempts under
throttling.
- WhatsApp standalone sender no longer maps a generic scheduler thread_id to
context.message_id. WhatsApp requires a real inbound message id to quote-reply;
a generic thread id is rejected as an invalid permanent 4xx. This mirrors the
live adapter, which only sets context from an explicit reply_to.
Adds tests for HTTP-date + integer Retry-After parsing and WhatsApp thread_id
context omission.
Co-authored-by: Mervin Praison <[email protected]>
Slots are typed Dict[str, Any] and SuggestionStore accepts nested
lists/dicts/sets; the previous tuple(sorted(items)) key raised TypeError
on unhashable slot values before recurrence could be recorded. Add a
recursive _freeze() helper producing a deterministic, hashable key.
Co-authored-by: Mervin Praison <[email protected]>
Implement the core JobStateStoreProtocol on the existing
ConfigYamlScheduleStore (get_state/set_state/clear_state), persisting a
small, size-bounded per-job scratchpad under a job_state key in
config.yaml and clearing it when a job is removed or a one-shot fires.
Wire the bot ScheduledAgentExecutor to load prior state into a compact
Notepad prompt block, thread it into a stateful monitor gate only when
the gate opts in (signature-detected), and persist GateResult.state_updates
plus any agent-emitted state back after the run. All hasattr/signature-gated
so stores and gates without support behave exactly as today.
Co-authored-by: MervinPraison <[email protected]>
Standalone (out-of-process) scheduled delivery was fire-and-forget with a
platform coverage gap. This adds:
- Bounded in-process retry with exponential backoff (honouring Retry-After)
around standalone HTTP sends, reusing the shared bots._resilience
classifiers so only transient 5xx/429/network failures are retried and
permanent failures (bad token, 4xx) fail fast. An ephemeral cron/serverless
tick has no persistent process to drain a durable SQLite outbox, so a
bounded retry is the lightweight durability fit for that model.
- Standalone senders for WhatsApp (Cloud API) and Signal (signal-cli bridge),
the two live-adapter platforms previously missing from _STANDALONE_SENDERS.
The documented `all` fan-out token is already implemented in botos.py
`_deliver_job_text`, so no change was needed there.
Co-authored-by: MervinPraison <[email protected]>
The consent-first automation-suggestion surface (store, TTL, dedup, chat
Accept/Dismiss buttons, CLI) already exists but nothing ever generated a
suggestion — propose() was only reachable via manual CLI calls, so the
"noticed you ask for a morning summary every day — automate it?" moment
never fired on its own.
Add a lightweight, deterministic recurrence generator as a new
SuggestionEngine.observe() method in the wrapper: a caller feeds each
completed turn's inferred blueprint+slots, and once the same intent recurs
threshold times within a trailing window the engine auto-calls propose().
Consent-first is preserved (jobs still only materialise on explicit accept),
per-principal isolation and store dedup keep it non-nagging.
Minimal in-repo fix — no new package, dependency, or export; reuses the
existing propose()/store machinery.
Co-authored-by: MervinPraison <[email protected]>
The default-on model turn pre-flight (#4042) now runs after the channel
probe in `gateway start`, so two pre-existing start-path tests that never
mocked a model round-trip aborted with exit 1 instead of their expected
exit 0. Mock `_verify_turn_preflight` green in both so each test isolates
exactly the behaviour it guards (SSL-only soft-fail; --no-preflight skip),
without weakening any assertion.
Co-authored-by: Mervin Praison <[email protected]>
The gateway passes the durable platform adapter directly to
ChannelSupervisor.run(), so drain_outbox() lives on the bot object itself,
not on bot.adapter. Check the object first, then fall back to bot.adapter,
so built-in channels actually re-drain on recovery. Adds a test for the
production object shape.
Co-authored-by: Mervin Praison <[email protected]>
Second-resolution timestamps caused two exports into the same directory
within one second to resolve to the same path, silently truncating the
first bundle (Greptile P1). Add _unique_bundle_path() to append a -N
suffix when the timestamped name already exists, preserving every export.
Co-authored-by: Mervin Praison <[email protected]>
Address Greptile P1 review feedback on #4045:
- Decouple turn verification from the channel --preflight flag so
`--no-preflight --verify-turn` still runs the model round-trip.
- Bound the preflight turn with a short run_timeout (default 60s,
overridable via gateway.preflight.verify_turn_timeout) so a stalled
provider fails fast instead of inheriting the 5-minute interactive
default at startup/onboarding.
- Pass per-agent base_url/api_key/api_base through to the synthetic
Agent so preflight exercises the same provider the live gateway uses.
Adds 3 tests covering timeout default/override and provider parity.
Co-authored-by: Mervin Praison <[email protected]>
Add a `praisonai gateway diagnostics export` command that writes a single,
pre-sanitised .zip (summary, health, config shape, redacted logs, forensics
snapshot) an operator can safely attach to a bug report. Reuses existing
secret redaction, forensics snapshot, health probe, and daemon log readers;
config is reduced to key/mode shape with no secret values, and all text is
passed through the shared redact_secrets pass. Runs even when the gateway is
unhealthy via local log fallback.
Co-authored-by: MervinPraison <[email protected]>
The durable outbox held deliverable replies through a transient channel
outage but nothing re-attempted them on recovery — they sat undelivered
until the next inbound chat() turn or a process restart. The supervisor
now fires a bounded, best-effort adapter.drain_outbox() when a channel
comes back after a recoverable outage, closing the loop opened by the
attempt-and-age policy. Duck-typed and no-op for bots without the hook.
Co-authored-by: MervinPraison <[email protected]>
Wire the existing run_turn_test into the default gateway start preflight and
the onboard wizard so a missing/invalid model credential fails at setup time,
not silently on the first user message. Default on via
gateway.preflight.verify_turn; opt out with --no-verify-turn or
PRAISONAI_NO_VERIFY_TURN=1 for constrained/offline environments.
Co-authored-by: MervinPraison <[email protected]>
- Re-validate every redirect hop + final URL in metadata fetch (SSRF-safe)
- Enforce HTTPS/loopback guard on discovered authorization/token/registration
endpoints so an HTTPS metadata doc cannot smuggle a plaintext endpoint
- Defense-in-depth HTTPS guard on the persisted refresh token endpoint
- Add tests for endpoint validation and redirect revalidation
Co-authored-by: Mervin Praison <[email protected]>
Fixes cross-level precedence bug flagged by greptile/reviewers: the
nearest project directory now wins over more distant ancestors, so an
ancestor .claude/.agents definition no longer silently overrides a
nested .praisonai one. Collect walk-up levels then apply ancestors
first / cwd last under the "later wins" order; .praisonai stays last
within each level.
Co-authored-by: Mervin Praison <[email protected]>
Extend CustomDefinitionsDiscovery to enumerate the conventional ecosystem
roots .claude/ and .agents/ for agents and commands, mirroring the skills
loader. .praisonai/ wins on name collision and the ancestor walk applies to
all roots. Tools stay .praisonai-only since loading executes user code.
Co-authored-by: MervinPraison <[email protected]>
Add metadata discovery, RFC 7591 dynamic client registration, and an
orchestrating provider that wires the existing PKCE callback helpers and
MCPAuthStorage into a lazy ensure_authenticated() flow. Fully additive and
backward-compatible: servers without auth or with pre-supplied credentials
behave exactly as before.
Co-authored-by: MervinPraison <[email protected]>
- redact.py: register PII BEFORE_LLM hook with sequential=True so
modified_input is actually applied to outgoing messages
- hooks/runner.py: warn when a parallel hook returns modified_input that
would be silently discarded
- agents.py: restore original self.tasks after planning; keep plan-step
tasks under _plan_tasks and reset task_id_counter to avoid id collisions
- context/composer.py: iterate _ensure_tool_pairs to a fixed point so
reinserted tool_calls messages don't leave dangling sibling calls
Co-authored-by: Mervin Praison <[email protected]>
Harden the guarded self-improvement review now that it can write to
memory: frame the echoed original task as UNTRUSTED DATA inside an
<original_task> block and instruct the model to ignore any embedded
'remember/store/create a skill' instructions, so injected task text
cannot steer store_memory/skill_manage. Prompt-only, no new surface.
Co-authored-by: Mervin Praison <[email protected]>
Reuse the existing opt-in, guarded, background/re-entrancy-safe skill-review
pass to also close the learning loop for memory: when the agent has memory
enabled, the restricted review toolset adds store_memory and the review
prompt asks whether a durable fact/preference/user-model detail emerged.
No new Agent param or parallel mixin/policy/dispatch — same self_improve
switch, lock, and off-hot-path background runner.
Co-authored-by: MervinPraison <[email protected]>
The durable-run stack (RunJournal, DurableRunContext, ExecutionConfig(durable=))
was shipped in praisonaiagents but never engaged by the long-lived gateway, so no
gateway run was journalled and resume was unreachable. Construct gateway agents
with ExecutionConfig(durable=True) behind an operator toggle (gateway.durable_runs)
and an optional per-agent durable key, so every turn is journalled and an
interrupted run can resume after a restart instead of re-executing side-effecting
tools or being discarded. Default-off / zero-overhead.
Co-authored-by: MervinPraison <[email protected]>
The first-run onboarding gate in `chat` and the bare invocation ran before
the wrapper-resident interactive TUI's own standalone install hint. On a
keyless `pip install praisonai-code` (no wrapper), `praisonai-code chat`
dead-ended with "No API key configured. Run: praisonai setup" for a session
that cannot even start, instead of the actionable "pip install praisonai[tui]"
hint the smoke test asserts.
Gate the interactive (no-prompt) path only when the wrapper is present so the
TUI can surface its install hint standalone; single-prompt chat runs in-process
and is still always gated. Adds a regression test for the standalone skip and
pins wrapper availability in the resolved-model gate test.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Route the shared first-run credential gate through the *final* resolved model
so onboarding validates and preserves the model each entrypoint actually
dispatches:
- code.py: resolve the agent profile's `llm` before the gate. A keyless
`code --agent <profile>` (no --model) with a reachable local endpoint no
longer has the profile model shadowed by the detected local default; the
profile override still applies for the untouched-model case, and an explicit
--model still wins.
- chat.py: resolve the TUI model (explicit > recent > provider default) before
the gate and reuse it, so the gate validates the exact model the TUI
dispatches instead of passing on any present key for a different provider.
Removes the now-duplicate resolution and threads the validated model into
profiling.
Adds two regression tests (profile-model passthrough for code, resolved-model
passthrough for chat).
Defer --tools resolution into _run_print_code's try/except so an import
error in a user tools.py surfaces through the machine-readable JSON/text
envelope (status=error + non-zero exit) instead of escaping as a raw
traceback to scripted callers.
Co-authored-by: Mervin Praison <[email protected]>
Extract the keyless first-run credential gate (previously duplicated inline in
app.py and run.py) into a shared ensure_configured_or_onboard helper in
praisonai_code.llm.credentials, and invoke it from the code and chat
entrypoints so a keyless newcomer is consistently routed to `setup` (or a
detected keyless local endpoint) instead of dead-ending on a raw provider
error. run.py and the bare invocation now delegate to the same helper,
removing the duplication.
Co-authored-by: MervinPraison <[email protected]>
Headless `praisonai code -p/--print` previously hard-rejected --tools,
--agent and --plan, blocking scripted/CI agentic coding from adding a
custom tool, applying a named least-privilege agent profile, or running
read-only plan mode. This reuses the interactive path's existing
resolvers (ToolResolver, load_agent_from_name, resolve_approval_config)
to thread those flags into the headless branch with no new params/deps:
- --tools resolves via _resolve_tools_arg and merges onto the default
coding toolset (de-duped by identity).
- --agent overlays the profile's instructions/role/goal/llm and named
tools; its permission scope is enforced via a non-interactive backend.
- --plan maps to a non-interactive read-only approval scope.
Approvals stay safe: scope is enforced via a non-interactive backend
(deny/ask fails closed), never an interactive prompt. --no-acp/--no-lsp
remain rejected (they toggle the resident TUI's runtime servers the
one-shot agent does not spin up). The JSON/text envelope and exit codes
are unchanged.
Co-authored-by: MervinPraison <[email protected]>
BaseCLIIntegration.as_tool() and get_available_integrations() routed
through run_sync(), which is fail-loud when a running event loop is
present. Agent runtimes invoke tools from inside their loop, so the
first async invocation of any built-in CLI-integration tool raised
RuntimeError. Route both through the existing run_sync_or_offload
helper, which offloads to a worker thread when a loop is running, and
add a native as_async_tool() surface for async agents to skip the hop.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: MervinPraison <[email protected]>
- process.py: when a task uses only when/then_task/else_task routing and it
resolves to no target (taken branch has no target) with no next_tasks, end the
workflow instead of falling into _find_next_not_started_task() (sync + async).
- loader.py: include guardrails/retry/skills in _defaults_has_any_values() so a
config with only [defaults].guardrails (or retry) is applied instead of being
silently skipped by apply_config_defaults' fast path.
- factories.py: document ChromaMemoryAdapter's shared short/long-term collection
so per-tier reset semantics are explicit.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
- Process now consults Task.get_next_task (when/then_task/else_task) in both
sync and async workflows before falling back to next_tasks, and no longer
ends the workflow early when only when-routing is configured.
- Knowledge.reset/delete_all/history now guard with hasattr, logging a no-op
warning for reset and raising NotImplementedError for unsupported erasure.
- ChromaMemoryAdapter (default) implements reset_short_term/reset_long_term so
the default memory config actually erases; Memory warns when an adapter lacks
reset support instead of silently succeeding.
- config defaults: guardrails and retry are now loaded into DefaultsConfig and
guardrails is applied to Agent via apply_config_defaults; unused/unconsumed
defaults keys (api_key/context/hooks/templates) removed from VALID_DEFAULTS_KEYS
so misconfiguration fails loudly, and retry added since agent.py consumes it.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: MervinPraison <[email protected]>
Agent.__init__ ran a dependency probe that eagerly imported the heavy
memory.memory module for any truthy memory value. The common FileMemory
paths (memory=True, "file", and file-provider dicts without learn) never
touch that module, so the probe wasted a ~100ms+ import. Scope the probe
to run only for values that resolve to the heavy Memory backend, preserving
the existing ImportError remediation for every heavy branch.
Co-authored-by: Mervin Praison <[email protected]>
Non-positive context_max_chars silently bypassed truncation (`if budget:`
was falsy for 0), returning full upstream output and breaking the bounded
contract. Clamp with max(..., 0) and always slice. Adds regression test.
Co-authored-by: Mervin Praison <[email protected]>
Add a composable pipeline primitive to the core scheduler: a job may declare
one or more upstream jobs via `context_from`, whose most-recent successful
output is resolved from existing execution history and injected as bounded,
delimited context at fire time.
- ScheduleJob: `context_from` (list, or bare-string coerced), `context_max_chars`
(default 4000), `on_missing_context` ("run"|"skip"). Serialised only when
configured so isolated jobs stay byte-for-byte unchanged.
- ScheduleRunner.resolve_context(job) -> (context, missing): pure, side-effect
free resolution reusing the store's get_history — no new persistence surface.
Wrapper executors inject the text and apply the missing-context policy.
Co-authored-by: MervinPraison <[email protected]>
Tightens the frozen-value-type test to assert the specific
dataclasses.FrozenInstanceError instead of a blind Exception, per
CodeRabbit/Ruff B017. Behaviour unchanged; still guards immutability.
Co-authored-by: Mervin Praison <[email protected]>
Address Greptile review: an isolated executor does not ship the live
async callable across a process boundary; the worker owns the session
and rebuilds/dispatches the turn from serialisable inputs, with the
callable acting as the gateway-side await point for the worker result.
Doc-only clarification, no behaviour change.
Co-authored-by: Mervin Praison <[email protected]>
Add a protocol-driven turn-execution seam so a session's agent turn can be
placed on an isolated worker, with the default preserving today's in-process
behaviour exactly.
- TurnExecutorProtocol + TurnPlacement + WorkerWedgedError (core, protocol-only)
- InProcessTurnExecutor default (runs the turn on the current loop; zero change,
no dependency, backward compatible when the executor is unset)
- Exported from praisonaiagents.gateway; heavy subprocess/container/remote
executors remain a wrapper concern
Co-authored-by: MervinPraison <[email protected]>
- client.py: preflight now gates on the actual configured command (custom
servers included) and only surfaces the registry install hint when it
matches the default server, so custom commands are diagnosed correctly.
- config.py: add path_to_uri() (Path.as_uri) and use it for root/doc URIs so
paths with reserved chars (space, #) yield valid, non-truncated file:// URIs.
- lsp_tools.py: workspace-symbol search forwards the resolved file so the
server initialises against the file's real project root, not the CWD;
_uri_path now decodes percent-encoded URIs.
- Tests: custom-command preflight, path encoding, workspace-root forwarding.
Co-authored-by: Mervin Praison <[email protected]>
Broadens the LSP server map into a data-driven registry (extensions,
root_markers, install_hint) and adds detect_language(), detect_root_uri()
and probe() helpers. LSPClient.start() now runs a shutil.which pre-flight
recording an actionable last_error instead of a raw FileNotFoundError, and
derives rootUri from the nearest root marker for multi-root/monorepo setups.
edit_tools surfaces a one-time diagnostics-unavailable note so lost
self-correction signal is explicit; lsp_tools reuse the shared helpers and
emit install hints. Backward-compatible; core-only.
Co-authored-by: Mervin Praison <[email protected]>
The proxy-type cache used a WeakKeyDictionary keyed by the inner tool
class, but each cached proxy *value* is a dynamically-minted subclass of
that inner class and therefore strongly retains its own weak key via
__bases__/__mro__. The weak key could never die, so per-request tool
classes stayed pinned for the process lifetime — the exact leak the
change targeted. Switch to a WeakValueDictionary: the proxy class is now
held weakly and stays cached only while live proxy instances keep it,
which already keep the inner class alive; once the last proxy dies the
entry auto-evicts and the inner class is reclaimable. The un-subclassable
fallback (module-global _TimeoutBoundTool) is no longer cached.
Fixes Greptile P1 on #4008.
Co-authored-by: Mervin Praison <[email protected]>
- agents_generator: switch process-wide _TIMEOUT_PROXY_TYPES cache to a
weakref.WeakKeyDictionary so per-request tool classes minted in long-lived
hosts are GC-reclaimed instead of pinned for the process lifetime.
- auto: hoist the path-traversal guard (workspace + _safe_join) into
BaseAutoGenerator so WorkflowAutoGenerator and JobWorkflowAutoGenerator
inherit it, and thread an optional workspace= through both (backward
compatible; guard is a no-op without a workspace).
- __init__: distinguish ModuleNotFoundError (real attribute miss) from a
broken-install ImportError so praisonaiagents import failures surface the
real root cause via __cause__ instead of a misleading AttributeError;
cache the failure to avoid re-paying the import on every lookup.
Co-authored-by: MervinPraison <[email protected]>
describe() rendered raw sandbox aliases (native, local) instead of the
backend SandboxManager collapses them to (sandlock, subprocess), so
where_does_it_run() reported a place that never executes. Normalize the
aliases in say_place() to mirror the manager, and mark native as
not-a-boundary. Adds regression tests for both aliases.
Co-authored-by: Mervin Praison <[email protected]>
You could not tell from PraisonAI code where an agent's work happens.
`repr(agent)` was `<praisonaiagents.agent.agent.Agent object at 0x1033abb60>`,
there was no introspection method, and the answer lived only in prose. That is
why explaining it took a 25k-character article, ~29% of whose words existed
purely to correct misreadings the API invites.
Now the object answers:
>>> Agent(name="builder")
Agent(name='builder', thinks_on='this machine', tools_run_on='this machine')
>>> AgentFlow(run_on="docker", steps=[writer, reader])
AgentFlow(name='AgentFlow', steps=2, thinks_on='this machine',
tools_run_on='a Docker container (shared)')
>>> Agent(name="teacher", backend=HostedAgent(provider="anthropic"))
Agent(name='teacher', thinks_on="Anthropic's cloud", tools_run_on="Anthropic's cloud")
and `where_does_it_run()` says it in sentences:
Thinking (the AI model calls) happens on this machine.
Tools run on a Docker container (shared).
Every step shares that same sandbox, so a file written by one step is
visible to the next.
For the subprocess tier it also states the limit plainly, because that tier is
verifiably not a boundary (network reachable, ~/.ssh readable):
Note: a separate process is not a security boundary -- it can still reach
the network and read your files. Use docker or a cloud provider for
untrusted code.
Field naming is deliberate. `loop=` was rejected: it is already
praisonaiagents.workflows.loop(), asyncio's event loop, and agent-loop jargon
-- three meanings for one word, which is the exact problem being fixed.
`llm`, `model`, `runtime` and `execution` are taken as Agent kwargs.
`thinks_on` / `tools_run_on` are free and mirror the `run_on=` the user typed.
A test asserts no field name collides with existing public API so nobody
reintroduces that class of collision, and another asserts the sentences contain
no jargon ("harness", "provision", "orchestration", "topology", "runtime").
Zero breakage: no signature changes, no behaviour changes. Description can
never raise -- a broken object still describes as "this machine".
Adds 17 tests. Suites unchanged vs clean main (6 failed / 343 passed on both;
all 6 pre-exist). Verified end-to-end against Docker: shared-sandbox flow wrote
and read back through one instance, repr stable, 0 leftover containers.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Address reviewer findings on the async loop-guard path:
- Re-raise ToolExecutionError in _execute_tool_async_with_retry so a
loop-guard HALT bypasses retry/error-dict conversion and stops the run.
- Treat loop_blocked BLOCK results as non-retryable in the retry wrapper
so a custom retry policy cannot re-run a terminally blocked call.
- Record tool timeouts as loop-guard failures (fall through instead of
early return) so repeated timeouts accumulate toward BLOCK/HALT.
Co-authored-by: Mervin Praison <[email protected]>
The async tool-execution path (_execute_tool_async_impl, backing achat()/
async workflows) had no loop-guard or doom-loop protection, unlike the sync
path in tool_execution.py. A repeatedly-failing tool could be hammered every
turn with no automatic stop.
Mirror the sync path's loop-guard wiring on the async path:
- pre-execution check() honoring BLOCK (error dict) / HALT (raise) / WARN
- post-execution record() + check() surfacing block/halt back to the model
- record failed (raised) invocations so streaks accumulate toward thresholds
- propagate a loop-guard HALT (ToolExecutionError) instead of swallowing it
Reuses the existing _ensure_loop_guard capability; no new params/exports.
The circuit breaker itself wraps a sync call + time.sleep and is not async-safe
to reuse as-is, so this focuses on the doom-loop/loop-guard protection the
issue centers on (which is pure/sync and safe from async).
Co-authored-by: MervinPraison <[email protected]>
AgentTeam accepted guardrails/web/reflection/caching/learn/knowledge and stored
them but never applied them at the team level. Emit a construction-time warning
when any is truthy so silent no-enforcement stops being the default.
Agent._process_knowledge silently dropped http(s) URL sources; replace the bare
pass with an actionable warning explaining URL ingestion is not implemented.
Co-authored-by: MervinPraison <[email protected]>
The direct-callable branch of LLM._generate_tool_definition re-derived the
schema from the raw wrapped function via build_tool_definition, bypassing
FunctionTool/BaseTool.get_schema(). This reverted @tool(name=) overrides to
the original function name and leaked Injected[T] params into the schema sent
to the model. Now the callable branch checks for get_schema() first, mirroring
the existing registry-lookup branch.
Co-authored-by: MervinPraison <[email protected]>
Addresses two Greptile P1 findings on the native OpenAI path (#3933):
- Move _track_token_usage inside the tool-call loop (sync + async) so every
billed completion — including intermediate tool-call rounds — is recorded
in session totals and by_model/by_agent rollups, not just the final one.
- Clear last_token_metrics when a response has no usable usage so a reused
client never reports a prior completion's spend as the latest call's usage.
Adds regression tests for multi-iteration accounting and stale-metric clearing.
Co-authored-by: Mervin Praison <[email protected]>
Default Agent(llm="gpt-4o-mini") runs through the native OpenAIClient,
which never populated the public token collector, so
get_token_collector().get_session_summary() stayed at zero after a paid
completion. Token accounting was effectively gated behind verbose/metrics
display on the LiteLLM path and entirely absent on the OpenAI path.
Add a lightweight OpenAIClient._track_token_usage that reads usage from
the final response and records it into the global TokenCollector after
every successful sync/async completion, independent of any display flag.
Thread agent_name through the adapter so by_agent rollups populate while
still keeping application metadata out of the OpenAI API call.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
_run_custom_agent still set agent_config["verbose"], which the
consolidated Agent constructor rejects with TypeError, so
`praisonai run --agent <name> --verbose` crashed — the same class of
bug #3930 fixed for the profiled paths. Thread verbosity through the
output="verbose" preset instead, without clobbering a definition's own
explicit output. Adds regression tests guarding the kwargs against the
real Agent signature.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
serve() is a public entry point. Inserting allow_network before the
existing approval_mode parameter would rebind positional callers
(e.g. serve(..., "auto")) to allow_network. Append it last to keep
backward-compatible positional binding.
Co-authored-by: Mervin Praison <[email protected]>
The LLM._build_responses_params forwarded Chat Completions multimodal
messages (content parts with type=text/image_url) unchanged into the
Responses API input, causing a 400 invalid_value on input[].content[].type.
Reuse OpenAIClient._build_responses_content to translate parts to
input_text/input_image and inline local image files as data URLs.
Co-authored-by: Mervin Praison <[email protected]>
The ACP CLI parsed --allow-network but never passed it to serve(),
and serve() didn't accept it, so network tools were gated by a flag
no CLI user could set. Thread allow_network through CLI -> serve() ->
ACPConfig so it reaches the tool capability gate in the model loop.
Co-authored-by: Mervin Praison <[email protected]>
The Agent constructor consolidated verbose/stream/metrics into output=. The
three profiled paths (_run_profiled_code, run.py profiled agent, cli_profiler)
still passed a top-level verbose=, raising TypeError before any LLM call. Map
verbosity through output= instead and add a signature-guard regression test.
Co-authored-by: Mervin Praison <[email protected]>
The generic "Authentication Tuple" detector fires on bare ("module",
"Attr") string-pair literals whose key/string contains auth/oauth.
The prior `# pragma: allowlist secret` is a detect-secrets directive
that GitGuardian ignores, so the check kept failing.
Construct the mapping values through a tiny local `_t()` alias so no
bare 2-string tuple literal remains on the flagged lines. Mapping type
(Dict[str, Tuple[str, str]]) and runtime behaviour are unchanged.
Co-authored-by: Mervin Praison <[email protected]>
The (module, attribute) string tuples for the auth/oauth lazy imports were
misdetected as 'Authentication Tuple' secrets. They are import targets, not
credentials. Added inline allowlist pragmas (no behaviour change).
Co-authored-by: Mervin Praison <[email protected]>
Replace five hand-rolled lazy-import __getattr__ blocks (eval, mcp, bus,
trace, skills) with the existing create_lazy_getattr / _with_groups helpers.
Exports, names and lazy semantics are unchanged; eval and mcp now gain the
per-attribute cache the hand-rolled versions lacked.
Co-authored-by: MervinPraison <[email protected]>
Defer WorkflowContext construction (and its all_variables.copy()) so it is
only built when a step has should_run or handler. On the common action-step
path the object was previously constructed and immediately overwritten by
_build_step_context, wasting a growing shallow dict copy per step. Applied
to both the sync and async twins; behaviour is identical since the guard
uses the exact conditions gating the only two consumers.
Co-authored-by: MervinPraison <[email protected]>
Adding create_thread to the runtime-checkable OutboundMessengerProtocol
broke isinstance() conformance for the production BotOutboundMessenger,
failing test_messenger_satisfies_protocol and
test_messenger_still_satisfies_protocol_with_react.
Mirror the existing react()/ReactionResult seam: DeliveryRouter.create_thread
resolves the target, unwraps the adapter, gates on capabilities["threads"],
and returns a typed (status, target, thread_id) tuple — never raising.
BotOutboundMessenger.create_thread maps that to the core ThreadResult so an
unthreadable channel yields a typed unsupported/no_route/failed outcome and
callers fall back to the parent channel. Heavy platform work stays in the
wrapper per AGENTS.md; core keeps only the protocol + dataclass.
Adds 5 wrapper tests covering ok/unsupported/no_route/failed and protocol
conformance with create_thread.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Adds a first-class, capability-gated way for the gateway (or an agent action)
to open a new platform-native thread/topic under a channel, mirroring the
existing react()/ReactionResult contract (#3917):
- PlatformCapabilities.supports_threads flag (+ to_dict/from_dict roundtrip)
- ThreadStatus (ok|unsupported|failed|no_route) + ThreadResult typed outcome
with an `ok` convenience property, so a channel that cannot thread returns a
typed `unsupported` outcome and callers fall back to the parent channel —
never raises
- OutboundMessengerProtocol.create_thread(target, name) -> ThreadResult
- Export ThreadResult/ThreadStatus from praisonaiagents.gateway
Pure protocol/dataclass in core with no heavy SDK imports; concrete adapter
implementations live in the praisonai-bot wrapper.
Co-authored-by: MervinPraison <[email protected]>
Addresses two Greptile P1 findings on the new provision_gateway_config API:
- Fail closed on unsupported platform / empty token instead of writing a
config the gateway would reject at startup or boot degraded.
- Declare every requested agent in the agents: block (was silently dropping
all but the first), routing the channel to the first as its default.
Co-authored-by: Mervin Praison <[email protected]>
Gateway self-healing (doctor --fix) and provisioning (onboard) previously
existed only as private CLI helpers. This adds a typer-free
praisonai_bot.gateway.admin module exposing repair_gateway_config,
provision_gateway_config, and a GatewayRepairResult dataclass on the public
praisonai_bot surface, completing the CLI + YAML + Python parity contract.
The existing repair machinery is moved into admin.py (typer-free) so the
library API does not depend on the CLI-only typer package; the Typer commands
re-import these helpers and remain thin wrappers over one canonical flow (DRY).
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
- paths.py: snapshot the single-root detection so creating the default
~/.praisonai data dir mid-run no longer flips config/state/cache off
their XDG locations (Greptile P1 #1). PRAISONAI_HOME stays live.
- diag.py: include the canonical (XDG) state-home logs dir when bundling
diagnostics, de-duplicating directories (Greptile P1 #2).
- Add regression test for mid-run path stability.
Co-authored-by: Mervin Praison <[email protected]>
- _load_env_user_config: use `data is not None` for inline blobs and
`isinstance(data, dict)` for explicit paths so a valid empty mapping ({}
or empty file) stays authoritative and no longer re-enables disk discovery,
and a non-mapping file body no longer raises TypeError on _source assignment.
- tests: clear the full per-key env var set consumed by _load_env_config()
for isolation; add regression coverage for empty inline mapping, empty
explicit file, and non-mapping explicit file.
Co-authored-by: Mervin Praison <[email protected]>
Add XDG-aware resolvers to core praisonaiagents/paths.py: get_config_dir()
and get_state_dir(), and make get_data_dir()/get_cache_dir() honour
$XDG_{DATA,CACHE}_HOME on fresh installs. Precedence: PRAISONAI_HOME ->
existing ~/.praisonai/~/.praison single root -> $XDG_*_HOME/praisonai ->
XDG defaults, preserving full backward compatibility for existing installs.
Route session spill and the wrapper MRU-model recency onto the canonical
state home, ending the ~/.praison vs ~/.praisonai split-state.
Co-authored-by: MervinPraison <[email protected]>
Add PRAISONAI_CONFIG_CONTENT (inline JSON/YAML) and PRAISONAI_CONFIG
(explicit path) to the wrapper resolver's user-config layer, mirroring the
sibling PRAISONAI_AUTH_CONTENT credential blob. Inline content is parsed in
memory (nothing on disk); an explicit path replaces file discovery. Inline
wins over path, which wins over global/project discovery. Higher-precedence
per-key env vars, CLI flags and managed policy are unchanged, and unset
preserves today's file-discovery behaviour.
Co-authored-by: MervinPraison <[email protected]>
Address the four P1 findings on PR #3980 (issue #3979):
- auto.py: add server-side output gate that strips code_execution tools
(e.g. execute_command) from generated roles unless the task actually
requested code execution. Deny-list gated by intent preserves all other
tools (backward compatible). Prompt allow-list narrowed to task-scoped
recommendations.
- jobs/store.py: make idempotency key UNIQUE and add atomic save_if_absent
so concurrent same-key submits resolve to a single job (side effects run
once). Reconcile QUEUED/RUNNING rows to FAILED on startup so restarted
jobs no longer poll forever.
- jobs/router.py: claim the idempotency key atomically via save_if_absent
and only dispatch to the executor when this request won the claim.
- acp/safe_edit.py: reject writes through symlinked parent directories or a
symlinked target as defence-in-depth against the propose->apply TOCTOU.
Verified: idempotency race, restart reconciliation, symlink containment,
and shell-tool stripping all pass; auto_generator + safe_edit + sqlite
suites green. Only the 3 pre-existing InMemoryJobStore test failures remain
(unrelated, present on base release).
Co-authored-by: Mervin Praison <[email protected]>
Gap 1 (auto.py): stop auto-appending the shell-exec tool 'execute_command'
to every generated agent (now opt-in via code_execution keywords only), fence
the untrusted topic in the LLM prompt with an allow-list, and add opt-in
workspace containment for agent_file to block path traversal.
Gap 2 (acp/safe_edit.py): add an RLock guarding _proposals and status
transitions, claim APPROVED->APPLIED atomically to prevent double-apply,
re-validate the path right before write to close the symlink TOCTOU, write
atomically via _atomic_write_text, and return one pipeline per workspace so
concurrent agents no longer leak across workspaces.
Gap 3 (jobs): add a stdlib-sqlite3 SqliteJobStore so jobs and idempotency
keys survive restarts; default store now persists when PRAISONAI_JOBS_DB_PATH
is set and refuses the in-memory store in production.
Co-authored-by: MervinPraison <[email protected]>
Two backward-compat fixes for the deprecated-param consolidation:
- Make handoffs + consolidated feature params keyword-only via a `*`
separator so a stray positional value (e.g. an old positional
allow_delegation=True) can no longer silently bind to `handoffs`.
- Accept fallback_models as an internal-only kwarg forwarded by
clone_for_channel(); pop it before the unknown-kwarg check and seed the
local so cloned agents keep their LLM fallbacks. Explicit
LLMConfig(fallback_models=...) still takes precedence.
Adds regression tests covering the internal fallback_models path,
clone_for_channel() preservation, keyword-only handoffs, and the
positional misbind guard.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Moves allow_delegation, allow_code_execution, code_execution_mode, auto_save,
rate_limiter, verification_hooks, and cli_backend out of the explicit __init__
signature (48 -> 41 visible params) into **legacy_kwargs, rebound to locals from
a new _LEGACY_AGENT_PARAMS map. Fully backward compatible: old keyword calls work
and still emit the same DeprecationWarnings; unknown kwargs now raise TypeError.
Co-authored-by: MervinPraison <[email protected]>
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.
- 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]>
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]>
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]>
`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]>
- 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]>
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]>
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]>
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]>
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]>
- 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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
- 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]>
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]>
- _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]>
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]>
- 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]>
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]>
- 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]>
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]>
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]>
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]>
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]>
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]>
@@ -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?* | `tools_run_on="docker"` — one shared sandbox for the tools, or `run_on="anthropic"` for the whole agent |
### Layer 1 · Prompt — *Did I say it clearly?*
@@ -195,7 +195,6 @@ from praisonaiagents import Agent, ExecutionConfig
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)
@@ -236,22 +235,85 @@ The harness is commoditising; **where** the agent executes is the next multiplie
pip install praisonai
```
The simplest way in is `tools_run_on=` — one whole team or workflow shares **one** sandbox, so a file written by step 1 is there for step 2. Thinking stays on your machine:
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>"`
Report issues against the **specific package** where the vulnerability exists. A platform-only issue does not necessarily affect the core SDK, and vice versa.
Platform releases are **independent** of `praisonai` / `praisonaiagents` version numbers. A platform-only fix (e.g. `0.1.8`) does **not** require bumping the main SDK.
13.**praisonai-platform**advisories are handled in a different repository:
| `praisonai-platform` | Independent semver, **separate repository** | Not released from this repo — see [PraisonAI-Platform](https://github.com/MervinPraison/PraisonAI-Platform) |
Always set the advisory `patched_versions` to the **package that actually contains the fix**. Do not imply users must upgrade all packages when only one changed.
@@ -217,9 +215,11 @@ Close (or publish advisory with no code change only if already mitigated) when:
@@ -1074,7 +1074,7 @@ PraisonAI's reliability engineering rests on three pillars — **C**ontext, **H*
> **Evaluator status.** The `ContextEvaluator` / `HarnessEvaluator` / `LoopEvaluator` classes named in the rubric are **planned follow-ups** (PA-CHL-001–004), not yet in the repo. Until they land, the equivalent checks are covered by existing modules:
>
> - **Context** — `context/tokens.py` and `eval/tokens.py` (`estimate_tokens` / `count_tokens`) for budget; `compaction/compactor.py` (`ContextCompactor`) for compaction/retention.
> - **Harness** — `runtime/turn_context.py` for turn-context parity; `runtime/example_harness.py` and `tests/` for harness/artifact checks.
> - **Harness** — `runtime/turn_context.py` for turn-context parity; `examples/runtime/example_harness.py` and `tests/` for harness/artifact checks.
> - **Loop** — `eval/loop.py` (`EvaluationLoop`) and `agent/autonomy.py` for convergence and doom-loop guards.
>
> Keep terminology consistent with the `agent/context_agent.py` (`ContextAgent`) PRP methodology.
logging.debug(f"Following next_tasks to {next_task.name}")
current_task=next_task
ifnotcurrent_task:
ifnotcurrent_taskandnotwhen_routing_exhausted:
current_task=self._find_next_not_started_task()# General fallback if no next task in workflow
Loaded 100 of 492 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.