The AI code-editor smoke-test fixture is intentionally broken so live
scenarios (implement celsius_to_fahrenheit / fahrenheit_to_celsius / mode,
fix mean/median empty-list handling) exercise the AI editor's repair
capability. Pre-implementing these functions caused acceptance tests to
pass WITHOUT the editor doing any work, defeating the fixture's purpose.
Also removes the redundant conftest.py sys.path mutation; pyproject.toml
already sets pythonpath = ["src"].
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]>
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]>
Explicitly calling tracker.end(total_tokens=...) inside a
`with Profiler.streaming(...)` block previously recorded twice because the
context manager's finally also calls end(). Guard with a _recorded flag so
only the first invocation records a StreamingRecord.
Co-authored-by: Mervin Praison <[email protected]>
Import TimingRecord/StreamingRecord/StreamingTracker from
praisonaiagents.profiling and subclass them for the wrapper's superset
fields (file/line, total_tokens) instead of re-declaring them. Core is now
the single owner of the shared primitives; wrapper public API and --profile
flags are unchanged.
Co-authored-by: MervinPraison <[email protected]>
Fixes two P1 issues from PR review:
- Concurrent first-use tool calls (e.g. parallel steps) could each pass the
unguarded instance_id check and provision separate sandboxes, splitting the
run and leaking one instance. ensure_provisioned is now guarded by a lock
with double-checked locking.
- write_file used a fixed __PRAISON_EOF__ heredoc delimiter, so file content
containing that line would terminate the heredoc early, truncate the file,
and execute the remainder as shell commands. The delimiter is now a random
per-write token with a collision guard.
Adds regression tests for concurrent provisioning and delimiter-bearing
content, and updates the fake provider to parse the randomized delimiter.
Co-authored-by: Mervin Praison <[email protected]>
Managed execution was per-Agent only: `backend=` exists on Agent but not on
AgentTeam or AgentFlow, and each LocalAgent(compute=...) provisions its own
isolated instance. A workflow therefore got N sandboxes with no shared
filesystem, so agents could not hand files to each other -- and YAML users
had no path to remote execution at all.
Adds a single `compute=` param to AgentFlow (and its Workflow/Pipeline
aliases) and AgentTeam. One sandbox is provisioned per run and every
participating agent's shell/file tools are bound to it, so a file written by
step 1 is visible to step 2. Orchestration stays local.
flow = AgentFlow(compute="docker", steps=[writer, reader])
team = AgentTeam(agents=[w, r], tasks=[...], compute="docker")
Details:
- praisonaiagents/managed/_compute_bridge.py resolves provider names lazily
from the praisonai wrapper, mirroring sandbox/_sandbox_bridge.py.
- Provisioning is lazy (a run that never touches the sandbox costs nothing)
and teardown is guaranteed via context manager, even when a step raises.
- Agents that already carry their own `backend=` are left untouched.
- Tools are bound onto `backstory`, not `instructions`: Agent.__init__ copies
instructions into role/goal/backstory and builds the system prompt from
those, so patching `.instructions` would be silently ignored.
- The capability prompt is required, not cosmetic: without it models
routinely refuse to call bridged tools ("I can't execute shell commands"),
so the sandbox spins up and is never used.
- Tool results are never the empty string; an empty result reads as "no
progress" and trips the loop guard into a stuck-agent error.
Verified end-to-end against local Docker: writer wrote /workspace/data.txt
and reported container hostname; reader read the same file and reported the
same hostname, with zero leftover containers after teardown.
Adds 12 tests using a fake compute provider (no Docker/cloud needed).
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Moves agentflow.gif and latest_ai_news_and_crawl_each_url_to_find_info.gif
out of the hero and down to the bottom, directly above the Langfuse
Tracing section. The "* export TAVILY_API_KEY=xxxxx" footnote travels
with the news GIF it annotates.
The hero is now: install -> Elon badge -> five-layer stack -> dashboard
screenshot -> ASCII art -> docs badge.
Pure move - no content change (identical line multiset).
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
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]>
The previous example advertised assigning `agent.thinking_budget = budget`,
but `_thinking_budget` is stored and never read by the Agent execution
pipeline (verified: no consumer in agent.py or the LLM call path). Point
users to the working `reasoning_steps=True` option and document the
`ThinkingBudget.get_tokens_for_complexity` helper instead.
Co-authored-by: Mervin Praison <[email protected]>
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]>
KnowledgeConfig.embedder / embedder_config were dropped between Agent
resolution and Knowledge construction, so non-OpenAI embedders silently
fell back to text-embedding-3-small.
Forward them through RetrievalConfig and normalise into mem0's
embedder = {provider, config} shape (embedder acts as provider shorthand).
Co-authored-by: MervinPraison <[email protected]>
Agent.__init__ has no **kwargs, so db=/session_id=, thinking_budget=,
and observability= raised TypeError immediately. Update the db, thinking,
and obs module docstrings to the working config-object / property forms.
Co-authored-by: MervinPraison <[email protected]>
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]>
Fixes four inaccuracies flagged in review of the five-layer stack section:
- Budget stop via ExecutionConfig(on_budget_exceeded="stop") surfaces as
completion_reason="error" (BudgetExceededError), not "budget_exhausted"
- Default handoff SUMMARY policy truncates to the last few messages verbatim;
it does not summarise
- ManagedAgent() with no ANTHROPIC_API_KEY falls back to a local loop, not cloud
- `sessions list`/`resume` require agent-id and session-id+prompt arguments
Co-authored-by: Mervin Praison <[email protected]>
Add a "Five-Layer Agent Stack" section after "Meet your first Agent",
showing that PraisonAI covers every layer of the agent stack rather than
just multi-agent orchestration:
1 Prompt - instructions/role/goal, output=
2 Context - memory=, knowledge=, context=
3 Harness - tools=, MCP(), approval=
4 Loop - execution=ExecutionConfig(...), reflection=, autonomy=
5 Graph - AgentFlow + route()/parallel()/repeat()
+ Managed Agents - remote sandbox or fully hosted loop
Includes a concentric-ring diagram (each layer wraps the one inside it),
a summary table, and a short runnable example per layer. Every snippet
was executed against the local source, not just constructed.
Also corrects API claims elsewhere in the README that raise TypeError on
copy-paste, since Agent.__init__ takes no **kwargs:
handoff= -> handoffs=[other_agent]
web_search= -> web=
prompt_caching= -> caching=
thinking_budget= -> agent.thinking_budget (property, not a kwarg)
db=/session_id= -> memory={"db": ..., "session_id": ...}
user_id= -> memory={"user_id": ...}
No existing content removed.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Use a non-blocking lock so commit() and cancel() are mutually exclusive,
preventing an unauthorized final mutation from starting after the deadline
while still never blocking cancellation on backend I/O (keeps #3924 fixed).
Add guard-level regression tests for both properties.
Co-authored-by: Mervin Praison <[email protected]>
Only accept supported auth modes (api-key, jwt) when binding to a
non-localhost interface, preventing a misspelled/unsupported mode from
exposing an unauthenticated recipe server.
Co-authored-by: Mervin Praison <[email protected]>
DeliveryRouter.react read adapter.platform_capabilities.reactions, but
PlatformCapabilities has no such field — real adapters advertise reaction
support via capabilities["reactions"] (matching _status.py). The old gate
always defaulted to False, so every Telegram/Slack/Discord reaction call
degraded to "unsupported" and never dispatched. Align the gate (and the
masking test fixture + docstrings) with the real capabilities contract.
Co-authored-by: Mervin Praison <[email protected]>
- Add "signal" to the gateway's tokenless platform set (from_config_file
_tokenless) and the is_email_platform exemptions in start_channels /
_start_single_channel, so a documented token-free Signal channel starts
instead of failing with a missing-token ValueError (greptile P1).
- Rename SignalBot's session manager attribute from _session_mgr to _session
so the gateway routing handler stages per-route tool policies on it
(Issue #2298), matching every other adapter (slack/discord/agentmail).
Previously routed Signal turns ran without their route's tool restrictions
(greptile P1 / Security).
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Close the incomplete outbound contract where PlatformCapabilities.reactions
was advertised but unreachable by agents. Add a typed ReactionResult plus a
react() method to core OutboundMessengerProtocol, and react/unreact actions to
the built-in send_message tool (emoji passed as message, reusing the existing
send-policy guard). The wrapper's BotOutboundMessenger/DeliveryRouter dispatch
to each adapter's existing add_reaction/remove_reaction, capability-gated so
unsupported channels degrade to a typed {"status":"unsupported"} outcome. The
inbound message_id is carried on SessionSource so react("origin", ...) targets
the message being handled without a new tool param.
Co-authored-by: MervinPraison <[email protected]>
Ship first-class Signal (end-to-end-encrypted) coverage through the existing
channel registration seam. The adapter uses the linked-device / bridge model
(signal-cli-rest-api over HTTP) — the same connection style as the WhatsApp
Web adapter — so it inherits outbox/DLQ, retry, sessions, commands, and hooks
from the shared mixins with no new architecture.
- Wrapper-only: praisonai_bot.bots.signal.SignalBot (aiohttp lazy-imported)
- Registered in _BUILTIN_PLATFORMS with a ChannelDescriptor (config keys +
prompt hint) and honest PlatformCapabilities (typing yes, live_edit no)
- channels.signal.account/bridge_url wired via _EXTRA_ENV_MAP; SignalBot export
- Core protocol unchanged
Co-authored-by: MervinPraison <[email protected]>
Sessionless (pre-hello/join) connections could never be reaped because the
liveness reaper defaulted a missing session's activity to now on every pass,
so the policy always returned KEEP and a half-open handshake retained its
socket/queue/scope/auth state forever (Greptile P1).
Track a per-connection _client_last_seen wall-clock: seeded at registration,
refreshed on every inbound frame, dropped on teardown/reap, and used as the
liveness fallback when no session is bound. Adds 4 tests covering seeding,
sessionless reap, inbound refresh, and teardown cleanup.
Co-authored-by: Mervin Praison <[email protected]>
Addresses Greptile P1: _reconnect closed the old client but left it assigned
to _client during the backoff window, so concurrent writes bypassed
dropped_writes accounting (calling a closed client) and gateway status kept
reporting redis_connected=true throughout the outage.
- redis_pubsub.py: null _client/_pubsub the moment the listener detects a drop
(before marking degraded), so writes hit the disconnected guard and are
counted, and status reflects the outage.
- server.py: redis_connected now also requires not is_degraded.
- tests: add outage-bookkeeping test asserting writes are counted and client
is cleared during the reconnect window; update recover test for cleared handles.
Co-authored-by: Mervin Praison <[email protected]>
Add PollingConfig.max_queue_size (default 1000) and apply it when creating
each polling client's queue in PollManager.register_client. This makes the
QueueFull -> durable-store overflow path reachable in production instead of
only via manual test override, closing the Greptile P1 finding. Setting
max_queue_size=0 preserves the legacy unbounded behaviour.
Co-authored-by: Mervin Praison <[email protected]>
Consume the already-merged core LivenessPolicy contract (#2799) at runtime:
- server: _liveness_loop emits PING at the policy interval and reaps
half-open sessions whose last_activity exceeds the window, closing them
with LIVENESS_TIMEOUT and releasing session/client state; PONG/PING
handled; last_activity stamped on every inbound frame; reaped count and
liveness state surfaced in health(); heartbeat_ms advertised from the
liveness interval so client and server agree.
- client: _heartbeat_loop sends PING on the advertised cadence and
force-reconnects (reusing backoff/resume) after ~2x silence; replies to
server PING with PONG.
- liveness is enabled out-of-box (server task present) while remaining
backward-compatible: an explicit interval_ms=0 opt-out stays inert.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
The gateway HA fan-out adapter (RedisPubSubAdapter) previously looped forever
against a stale pub/sub handle on connection loss — silently dropping
cross-instance delivery while status/doctor reported healthy. Now the listener
reconnects with bounded backoff, re-subscribes all channels, records itself as
a degraded owner in DegradedCapabilityRegistry (route:redis-pubsub) with a
redacted reason + actionable retry_hint, and clears it on recovery. Publish and
presence no-ops while disconnected are counted and surfaced in gateway status.
Co-authored-by: Mervin Praison <[email protected]>
Route PollManager overflow through the existing DeliveryGuaranteeManager so
long-poll clients share the durable at-least-once store used by WebSocket:
- enqueue_for_client persists on QueueFull instead of a silent drop
- poll_messages replays durably-pending events (restart/backpressure)
- ack endpoint no longer reports ok=True without a delivery manager
Co-authored-by: Mervin Praison <[email protected]>
Greptile P2: close() nulled _timeout_executor but left _leaked_workers
stale, so a restarted scheduler recycled its fresh pool on first use.
Reset the counter with the pool.
Co-authored-by: Mervin Praison <[email protected]>
- AgentScheduler.stop() no longer self-joins from its own thread; the
budget brake now only sets the stop event and teardown runs in a
finally block, so is_running is cleared and the timeout pool is closed.
- Profiler memory()/memory_snapshot() now reference-count the process-wide
tracemalloc toggle under a lock so concurrent scopes stop returning (0,0).
- Scheduler timeout pool size and leak threshold are now distinct: recycle
once half the workers have leaked, matching AgentsGenerator.
Co-authored-by: MervinPraison <[email protected]>
Addresses two P1 regressions raised in review of #3908:
1. String guardrails no longer fire a synchronous input-validation LLM call
on every chat()/achat(). A string guardrail becomes an LLMGuardrail which
also exposes validate_input via GuardrailProtocol; the input gate wrongly
invoked it (hot-path perf regression + blocking the async event loop).
The string-derived LLMGuardrail is now marked output-only and skipped by
_validate_input_with_guardrail. Explicit GuardrailChain/GuardrailProtocol
objects remain the only deliberate input surface.
2. The no-running-loop managed-stream bridge no longer leaks its producer
thread. The producer is now a daemon thread with a cancel Event, a bounded
queue (back-pressure), and a finally block that signals cancellation and
joins on early generator close/GC.
Co-authored-by: Mervin Praison <[email protected]>
- Wire GuardrailChain/GuardrailProtocol.validate_input into chat()/achat()
so input-side guardrails actually run before the LLM call (no-op for
plain callable/string guardrails; fails closed on error).
- Fire BEFORE_LLM/AFTER_LLM hooks on the async _execute_unified_achat_completion
path for parity with sync _chat_completion, so hook features like
enable_pii_redaction() apply to achat()/async workflows too.
- Stream managed-backend chunks incrementally in the no-running-loop path
via a bridging queue/thread instead of buffering the whole response,
mirroring the running-loop branch.
Co-authored-by: MervinPraison <[email protected]>
Adds load_gateway_config integration coverage for the #3050 validation
path and documents load-time gateway block checks in the example.
Supersedes #3060; core fix landed in #3079.
Co-authored-by: botbikamordehai2-sketch <[email protected]>
ExecutionConfig and MemoryConfig live in praisonaiagents.config.feature_configs,
not the non-existent config.execution / config.memory modules. The prior paths
raised ModuleNotFoundError on the deprecated allow_code_execution / auto_save
conversion path.
Co-authored-by: MervinPraison <[email protected]>
Removes the unreferenced _get_registry() method and its backing
self._registry field from the tools CLI handler. The canonical
_get_resolver() path remains the single tool-resolution entry point.
Co-authored-by: MervinPraison <[email protected]>
- run the sync flush in a daemon thread so a timed-out worker never
blocks interpreter shutdown (Greptile P1)
- make preview_older_slice a safe superset covering token-budget-driven
removals (SLIDING / TRUNCATE second pass), not just preserve_recent, so
durable facts in budget-dropped messages are still offered to the flush
(Greptile P1)
- add coverage for daemon-thread timeout and budget-boundary preview
Co-authored-by: Mervin Praison <[email protected]>
- Gate workflow YAML runs so --resume/--session/--continue/--fork and
--output stream-json fail fast instead of being silently ignored, since
the native workflow path does not wire up session continuity or the
stream-json bridge.
- Record each import once in the default profiler even when multiple
ImportProfiler scopes overlap, preventing inflated global import counts.
- Add regression coverage for both fixes.
Co-authored-by: Mervin Praison <[email protected]>
Route the Discord/Slack/Telegram retry-command failure handlers through
failure_reply_text(e) so raw exception text is no longer exposed, and add a
safe generic fallback in _failure.py when the core renderer module is
unavailable (older praisonaiagents), so the error path never itself raises.
Co-authored-by: Mervin Praison <[email protected]>
Registering the novita backend without a corresponding _EXTRA_HINTS
entry made an unavailable backend recommend only `pip install
praisonai-sandbox`, which does not install novita-sandbox. Now it
correctly suggests `pip install praisonai-sandbox[novita]`.
Co-authored-by: Mervin Praison <[email protected]>
Registers NovitaSandbox in the same lazy-loaded registry pattern used
by the existing E2B/Daytona/Modal backends, following the E2B/Daytona
implementation shape (async lifecycle, NOVITA_API_KEY env var,
optional novita-sandbox dependency extra).
Close SSRF gap where compare_extraction fetched via newspaper3k and
requests.get without _validate_url, unlike the trafilatura path.
Co-authored-by: Mervin Praison <[email protected]>
- Reject non-phone/non-lid alternate domains in learn(); use exact
domain matching instead of substring so group/broadcast/hostile
JIDs are never learned and @lid-extra is never canonicalized.
- Canonicalize the Chat JID for the self-chat comparison so an
LID-addressed self-chat is not mistaken for an outgoing message
and silently dropped. Raw chat_jid retained for reply routing.
- Add regression tests for invalid domains, malformed LID suffix,
and lid-to-lid pairing rejection.
Co-authored-by: Mervin Praison <[email protected]>
WhatsApp addresses the same person with two interchangeable JID forms — the
privacy LID (<lid>@lid) and the phone JID (<phone>@s.whatsapp.net). The web
bridge could surface either for the same user, and the adapter propagated the
raw form straight through as user_id, splitting one person into two principals:
divergent session/memory, a broken DM allowlist match (authorised user silently
dropped) and spurious re-pairing.
- Core: add dependency-free IdentityCanonicalizerProtocol contract in
gateway/protocols.py (exported from praisonaiagents.gateway).
- Adapter: WhatsAppIdentityCanonicalizer learns the LID<->phone mapping from
whatsmeow's SenderAlt alternate-JID field and reconciles toward the phone
form BEFORE the allowlist/session/memory/pairing keys are derived. Fail-open:
unknown addresses are returned unchanged, so behaviour is never worse than
today and no canonicalizer registration is required.
Co-authored-by: MervinPraison <[email protected]>
recover_orphans() terminalises in-flight runs to LOST and never re-scans
them, but the recovery notice went through DeliveryRouter whose dedup is a
per-process LRU. A boot-time send to a not-yet-ready channel returned False
and the one notice owed to the user was lost for good.
Route the notice through the durable SQLite scheduled_outbox (the same path
scheduled results use) under a stable run-recovery:{run_id} key before
delivery, so a boot-time miss leaves a pending row that drains on the next
boot. Falls back to the best-effort restart-notice path when no durable
outbox/router is available. Wrapper-only, no core change, no new deps.
Co-authored-by: Mervin Praison <[email protected]>
praisonai-code is an OPTIONAL dependency of praisonai-bot, but the gateway
config resolver routed ~/.praisonai/bot.yaml discovery exclusively through
praisonai_code.cli._paths and swallowed the ImportError, so a lean install
with an onboarded home config fell through to only ./gateway.yaml and
gateway start exited 78 despite the config existing (Greptile P1).
Inline the equivalent cwd bot.yaml -> PRAISONAI_BOT_CONFIG /
~/.praisonai/bot.yaml (PRAISONAI_HOME-aware) fallback so home discovery works
without praisonai-code, keeping the praisonai-code resolver as the preferred
path. Adds two regression tests exercising the missing-praisonai-code branch.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Cancellation while a task waited for a concurrency slot raised
CancelledError from the semaphore acquisition, which sat outside the
try/except — so on_complete never fired for that path. Move the
`async with semaphore` inside the try so every terminal outcome
(success, failure, timeout, active + queued cancellation) routes
through _safe_on_complete.
Co-authored-by: Mervin Praison <[email protected]>
On gateway boot, drive recovery from the durable run ledger: call
SQLiteRunLedger.recover_orphans() to reconcile runs a crashed process
left in flight to LOST with a terminal outcome, then re-notify each
run's origin channel/thread through the existing durable, exactly-once
delivery path. An interrupted run now ends in a recorded outcome and a
visible user-facing message instead of silence.
Best-effort and default-safe: a no-op when core lacks the ledger or no
ledger.db exists yet, so gateways that never used it are unaffected.
Co-authored-by: MervinPraison <[email protected]>
gateway start now auto-discovers the onboarded config (./bot.yaml ->
~/.praisonai/bot.yaml, gateway.yaml alias) instead of silently starting
channel-less, and validates/forward-migrates config_version the same way
doctor does before binding. doctor/test/status/send/channels resolve the
same canonical path so onboard/start/doctor cannot drift apart.
Co-authored-by: MervinPraison <[email protected]>
Every terminal branch (success, failure, timeout, cancellation) now
invokes on_complete via a shared _safe_on_complete helper, matching the
BackgroundJobManager contract so gateway/proactive runs always deliver a
visible outcome to the origin. Cancellation still re-raises for
cooperative shutdown, but only after notifying.
Co-authored-by: MervinPraison <[email protected]>
The default timeout feature added a top-level `import os` to llm/registry.py,
which broke test_registry_only_imports_typing (asserts top-level imports stay
within stdlib typing/threading). Move the import inside default_llm_timeout()
as a lazy import — the isolation test explicitly permits lazy imports in call
paths. Env-var timeout behavior is unchanged.
Co-authored-by: Mervin Praison <[email protected]>
Greptile P1: loose model=/session= kwargs were merged only into cli_config,
which the generator's allow-list ignores (model comes from config_list[0],
session from resume_session). Now model=/llm= writes onto config_list like
--llm, and session maps to resume_session; docstrings corrected.
Co-authored-by: Mervin Praison <[email protected]>
- llm/registry.py, llm/gateways.py: seed default timeout (60s, tunable via
PRAISONAI_LLM_TIMEOUT) + bounded num_retries on every LiteLLM call so an
unresponsive provider can't pin a coroutine/worker forever. Caller override wins.
- api/call.py: resolve realtime WebSocket URL/model/key via env overrides
(PRAISONAI_REALTIME_URL/MODEL/API_KEY) instead of a hardcoded OpenAI literal,
and add open/ping/close timeouts + frame cap so a dead upstream can't hold a
Twilio media leg indefinitely. OpenAI defaults preserved for back-compat.
- _entrypoint.py: run()/arun() accept **kwargs forwarded through cli_config,
giving Python callers the CLI's advanced-option pass-through without a new surface.
Co-authored-by: Mervin Praison <[email protected]>
- execute_tool_async now back-fills result_hash via record_tool_outcome on
both the hooked and fast-path returns, so async no-progress/poll-loop
detection accumulates a streak (sync parity).
- achat() applies the configured rate limiter and cancellation token before
delegating to a managed backend, so backend requests honour throttling and
cooperative cancellation instead of bypassing both guards.
- Knowledge.index() retains memory IDs whose deletion failed so a later
re-index retries their cleanup instead of orphaning stale chunks.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
- async _achat_impl now acquires the rate limiter (acquire_async), matching
the sync path so async bursts are throttled
- execute_tool_async runs the same tool-loop detection as the sync path
- achat() delegates to a configured managed backend via _adelegate_to_backend
(covers arun/astart, which route through achat)
- GuardrailChain.__call__ makes Agent(guardrails=chain) actually validate output
- Knowledge.index() tracks per-file memory IDs and deletes stale chunks before
re-adding a changed file during incremental indexing
Co-authored-by: Mervin Praison <[email protected]>
_validate_url only inspected IP literals, so a plain domain or an encoded
form (a decimal IP, or "<internal-ip>.nip.io") passed even though it
resolves to an internal address, defeating the SSRF guard. Resolve the
hostname and reject any private, loopback, reserved or link-local address,
matching praisonaiagents/tools/file_tools.py.
Lazy-load A2AClient via package __getattr__ so server-only A2A usage no
longer pulls in aiohttp at import time. Public surface unchanged:
A2AClient remains importable and listed in __all__.
Co-authored-by: MervinPraison <[email protected]>
Undo the earlier fork-workflow revert now that the push has workflow scope, so
the branch's workflows match main again — this removes the 7 stray workflow
files from the PR diff and drops main's pypi-release.yml (a GitGuardian false
positive) back out of our changes.
The wrapper-free `code` fallback (`_run_resident_code`) had three defects
flagged by reviewers on PR #3818:
- NameError: it referenced an undefined `compact` local, crashing every
standalone `code` invocation before the TUI could start.
- --plan was silently dropped: the resolved read-only PLAN backend was
never registered, so mutating tools (write/edit/shell) stayed enabled.
- --no-acp/--no-lsp were ignored: the flags never reached the TUI, which
hardcoded ACP+LSP tool loading and runtime startup.
Fix by registering the resolved approval backend on the global registry
(same pattern `chat` uses; the TUI syncs its PLAN state from it) and
threading plan/no-acp/no-lsp onto AsyncTUIConfig via new enable_acp/
enable_lsp fields (default True, backward compatible). _load_tools and
_start_runtime now honor those fields.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Relocate the async split-pane TUI (async_tui.py) and InteractiveCore
(core.py) from the praisonai wrapper into praisonai-code so
`pip install praisonai-code` alone yields a full interactive chat/code
session with no wrapper required.
- Move async_tui.py + core.py impls into praisonai_code.cli.interactive;
leave identity-preserving back-compat shims at the old praisonai paths
(plus config/events shims so isinstance/monkeypatch hold across paths).
- Drop the hard-fail in chat.py; route interactive chat to the resident
AsyncTUI. Route interactive code to a resident AsyncTUI path
(_run_resident_code) when the wrapper is absent, keeping the richer
legacy dispatch when it is installed.
- Convert lazy wrapper-only imports (_async_bridge, integration.context_files)
to _wrapper_bridge with graceful fallback so no `praisonai` import
remains in the interactive package.
- Extend scripts/check_c7_imports.sh to gate interactive async_tui/core
at module load; C7 gate now reports 0 wrapper import lines.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: MervinPraison <[email protected]>
The warm runtime is a separate process that never receives the CLI's
PRAISONAI_APPEND_SYSTEM_PROMPT export and reuses a cached agent, so an
eligible `run` that forwarded to the daemon silently dropped the appended
suffix. Exclude the warm-runtime fast path when the suffix is set so the
in-process path (which applies it) is used, mirroring the existing
thinking_budget/isolated/instructions gates.
Adds regression tests: append-suffix runs never attach warm; ordinary
no-save runs still do.
Fixes Greptile P1 review on #3743.
Co-authored-by: Mervin Praison <[email protected]>
Adds a per-invocation --append-system-prompt flag to the code, chat and run
commands. The flag accepts literal text or an @file reference and falls back
to the PRAISONAI_APPEND_SYSTEM_PROMPT env var for CI.
Kept lightweight: no new Agent constructor param. The CLI exports the resolved
value via the env var and the core Agent appends it at the END of the assembled
system prompt (both the __init__ prompt and the chat _build_system_prompt path),
preserving the prompt-cache prefix (#2993) and never persisting to agent files.
Co-authored-by: MervinPraison <[email protected]>
Greptile P1: the instance-wide _draining flag turned every concurrent
failed-reply enqueue into a no-op during boot recovery, silently dropping
genuinely-new obligations and violating the durable-outbound contract.
Replace the boolean drain guard with a per-entry signature match so only the
nested re-park of the exact reply currently being redelivered is suppressed;
any other concurrent failure is still persisted. Also add the missing
typing.Any import (would NameError under get_type_hints).
Co-authored-by: Mervin Praison <[email protected]>
The reply path is already durable-by-default (permanent/exhausted sends are
parked in a canonical per-platform OutboundDLQ), but its crash-recovery had no
boot caller — the missing counterpart to the inbound journal's
_replay_inbound_journal sweep. A parked reply sat until an operator manually
replayed it.
- OutboundDLQ.redeliver(send): drains parked replies oldest-first through a raw
send, keeping + re-attempting on failure, with a _draining guard so a
re-failed send never double-parks.
- WebSocketGateway._replay_outbound_dlq(bot): outbound counterpart to
_replay_inbound_journal; redelivers via the adapter's own send_message with a
visible "recovered — may be a duplicate" marker, scheduled after the channel's
transport connects, on full boot and hot-reload.
Minimal and safe-by-default: no new params/modules/exports, nothing added to
core, success path unchanged.
Co-authored-by: MervinPraison <[email protected]>
The replay-safety gate only ran inside the overloaded/idle_timeout
branch of resolve_failover_decision. Post-dispatch failures whose
messages lack a timeout/overloaded/5xx substring (bare connection
reset, remote protocol error, incomplete/chunked read) classify as
"unknown" and were retried without the gate, risking double-execution
of side-effecting tool turns. Move the gate ahead of all retry
branches so any post-dispatch failure on a tool turn surfaces
provider_outcome_unknown instead of replaying.
Co-authored-by: Mervin Praison <[email protected]>
Post-dispatch transient failures (read timeout, connection reset
mid-response) may mean the provider already produced output and already
ran side-effecting tool calls in that turn. Blindly replaying such a
failure on a tool-calling turn can double-execute the turn.
Adds is_replay_unsafe() to error_classifier.py to distinguish
pre-dispatch failures (DNS/connect/TLS — safe to replay) from
post-dispatch failures (read timeout / connection reset — replay-unsafe),
and gates resolve_failover_decision so a post-dispatch failure on a
side-effecting turn (one exposing tools) surfaces a typed
'provider_outcome_unknown' outcome instead of silently retrying.
Default-safe and additive: tool-less turns and pre-dispatch failures
retry exactly as before; no new user-facing config surface.
Co-authored-by: MervinPraison <[email protected]>
Address reviewer P1 findings on the coherent undo/revert path:
- Interactive /undo /revert now rewind the reused warm Agent's own
chat_history in lockstep with the auxiliary conversation view, matched
by user-message count so interleaved system/tool messages don't skew the
boundary. Applies to both the modern REPL and the default AsyncTUI, which
each cache and reuse a single Agent across turns.
- praisonai run --revert rewinds a named session's conversation by real
user-turn boundaries instead of a fixed 2*n assumption, so turns that
persisted tool/system/assistant messages no longer strand orphaned
messages or drop an unrelated earlier turn.
- Make run --revert honest that file checkpoints are workspace-wide and may
not correspond exactly to a named session; point users to interactive
/undo for fully per-session coherence.
- Add unit coverage for agent chat_history rewind (with interleaved tool
messages), clear-to-preamble, and best-effort no-agent safety.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Wire the existing SessionCheckpointManager into the modern/default CLI
surfaces so /undo and /revert roll back files + conversation together,
and reconcile the split checkpoints.auto default.
- session_checkpoints: add a `default` param to from_config so one
checkpoints.auto key drives both file checkpoints and coherent revert
(interactive surfaces pass default=True; programmatic default stays False).
- modern repl.py: register /undo + /revert, checkpoint each turn, and rewind
the in-memory conversation via a thin store adapter.
- default AsyncTUI: register /undo + /revert and checkpoint file-mutating turns.
- run.py: add --revert (files + conversation) distinct from file-only --restore.
- tests: assert /undo restores both file and conversation on the default path.
Co-authored-by: MervinPraison <[email protected]>
- db/adapter.py: sync on_agent_start now passes an async-safe create_session
dispatcher so an async-mode store actually persists a new session instead of
dropping an un-awaited coroutine (Greptile P1).
- db/adapter.py: aon_agent_start/aon_agent_end now share the agent:{session_id}
state key and the end transition updates the started record in place, keeping
agent_name/metadata context instead of writing a disconnected record
(Greptile P2).
- api/agent_invoke.py: AgentRegistry.unregister uses a sentinel so a removal is
reported correctly even when the stored value is None/falsy (Greptile P2).
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
- api/agent_invoke.py: introduce thread-safe AgentRegistry (atomic pop
removes unregister TOCTOU 500) with optional per-embedding scoping via
a `registry=` kwarg; public functions stay backward-compatible.
- observability/hooks.py: memoize discover_observability_sinks() so the
importlib.metadata entry-point walk no longer runs on every run/request;
add reset_observability_sink_discovery() escape hatch.
- db/adapter.py: align PraisonAIDB.aon_agent_start/aon_agent_end with the
AsyncDbAdapter protocol (correct arg names/order, persist user_id instead
of dropping it); guard the sync on_agent_start against async-mode-store
coroutines via _resolve_sync to avoid the opaque 'coroutine not iterable'
crash.
Co-authored-by: MervinPraison <[email protected]>
Address reviewer findings on PR #3855:
- json.dumps guards now catch (TypeError, ValueError) so circular tool
results are stringified instead of aborting the sync tool loop (llm.py x2,
openai_client.py x2).
- _build_metadata_filter overwrites any conflicting metadata_filter["user_id"]
with the explicit user_id, preventing cross-tenant memory reads.
- Short/long-term memory searches over-fetch (limit*10) from mem0, MongoDB,
ChromaDB, and SQLite when a metadata_filter is present, then post-filter and
truncate to limit, so scoped records ranked beyond limit are not dropped.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
- llm.py/openai_client.py: capture tool failures and non-JSON-serializable
results into an error/result payload fed back to the model instead of
raising out of get_response(), matching the async execution_mixin path.
- hooks/runner.py: a hook that raises or times out now yields an explicit
deny (fail-closed) so BEFORE_TOOL security gates block instead of
silently allowing the call.
- memory/memory.py: search_short_term/search_long_term now accept and apply
metadata_filter/user_id scoping instead of silently dropping them into
**kwargs, restoring the SearchMixin contract for multi-tenant isolation.
Co-authored-by: MervinPraison <[email protected]>
The upstream merge pulled in .github/workflows changes that aren't part of this
feature; revert them to the fork's versions so the push doesn't require the
workflow OAuth scope. Main's workflows win at merge time and are what run for
the PR anyway.
Align the integration-test skip guard with the credential resolution added in
d2fa4df8 — otherwise the tests skip even when only TENKI_AUTH_TOKEN is set.
(Greptile follow-up)
- managed_local.provision_compute() now forwards `networking` and `metadata`
into ComputeConfig. Without this they were dropped, so Tenki's allow_outbound
/ tenki_image (the first provider to read them) always saw defaults regardless
of the caller's request. (Greptile: 'networking policy is dropped')
- TenkiCompute credential resolution now matches the SDK's precedence
(auth_token -> TENKI_AUTH_TOKEN -> TENKI_API_KEY), so is_available agrees with
what Client() resolves; docstring + is_available test updated. (CodeRabbit)
- networking: allow_outbound now keys off the real enum — only 'unrestricted'
gets outbound; 'limited' (also used for --no-networking) disables it. The old
check compared against a nonexistent 'restricted' value, so outbound was never
actually disabled.
- get_status/list_instances: a refresh() *exception* is now treated as unknown
(assume running) rather than STOPPED, so a transient outage no longer hides a
live, still-billing sandbox. A successful refresh with a non-RUNNING state
still reports stopped.
- failed-install teardown: terminate() before dropping the local handle, so a
failed terminate keeps the sandbox tracked instead of leaking it (matches the
shutdown path).
- don't log raw pip/npm specs (can carry private-index URLs/tokens); log counts.
Remove two unreferenced pure helpers (json_type_to_python_type,
extract_schema_properties) that have no callers, are not in __all__,
and have no test coverage. Also drop the now-unused Optional import.
Live helpers (fix_array_schemas, build_openai_tool_dict,
python_type_to_json_schema, ThreadLocalEventLoop) are unchanged.
Co-authored-by: MervinPraison <[email protected]>
Lift the multimodal tool-result wrapper into a single module-level helper
try_append_multimodal_tool_result in agent/tool_execution.py, next to its
canonical build_tool_result_message_pair. openai_client.py imports it directly
under the same private alias; the LLM method now delegates to it, preserving its
signature for any external subclass override. No behaviour change.
Co-authored-by: MervinPraison <[email protected]>
Replace the duplicated public-callable extraction walk in the legacy
recipe/workflow tools.py path with the canonical owner in
praisonai_code.tool_resolver. Adds extract_functions_from_loaded_module
(single authority over the walk) and reuses it from both
ToolResolver.load_functions_from_module and workflow_commands, preserving
the PRAISONAI_ALLOW_LOCAL_TOOLS gate, safe-loader, own-module-origin
filter, and "Loaded N tools" notice.
Co-authored-by: MervinPraison <[email protected]>
- GateResult.__post_init__ forces run=False when no_change is set so a
silent-suppress outcome can never let a runner fire a suppressed tick.
- ScheduleJob.to_dict persists monitor when not None (incl. {}) so an
explicitly configured empty spec round-trips instead of reverting to
stateless.
- Document caller contract: detect state capability before passing
state= so legacy stateless gates never raise TypeError.
Co-authored-by: Mervin Praison <[email protected]>
Extend the core scheduler contract so change-detection monitor
automations can be expressed across CLI + YAML + Python, keeping heavy
implementation (shell/URL probe, hashing, diffing, persistence) in the
wrapper. Additive and backward-compatible: jobs that set neither
`monitor` nor per-job state behave exactly as today.
- GateResult: add `no_change` (distinct silent-suppress outcome) and
`state_updates` (bounded KV a gate persists for the next tick).
- JobConditionProtocol.should_run: optional keyword-only `state` so a
stateful monitor gate can compare against prior state; existing
stateless gates satisfy the protocol unchanged.
- New JobStateStoreProtocol: bounded per-job get/set/clear scratchpad.
- RunRecord.status: add `no_change` alongside succeeded/failed/skipped.
- ScheduleJob: add optional `monitor` source spec with round-trip.
Co-authored-by: Mervin Praison <[email protected]>
- Core: FleetSupervisionPolicy no longer records events while cooling down and
clears its event window when the cooldown elapses, so the breaker re-arms
without an external reset() and held (non-)restarts cannot re-trip it.
- Monitor: evaluate the failing-channel fraction on every sweep (note_fleet_state
now has a live caller) and re-evaluate/clear the breaker inside the monitor
loop, so the degraded-owner fact clears without a status read.
- Monitor: a disabled per-channel restart budget (max_restarts_per_hour=0) no
longer counts idle channels as failing.
- Tests: fix CI regression in test_gateway_health_block_matches_runtime_consumer
(new fleet keys are genuinely consumed by from_dict), add monitor-loop cooldown
recovery coverage, tighten failing-fraction assertion, correct misleading comment.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Address reviewer findings on PR #3842:
- core: migrate_config_with_doctor now rejects a config stamped by a newer
build (version > GATEWAY_CONFIG_VERSION) and a malformed/boolean stamp via
new ConfigVersionError, so an older binary can never downgrade a newer
config or coerce config_version: true to 1. Exported from gateway package.
- wrapper: doctor guards the migration import (ImportError-safe for older core),
surfaces the version error as a warning without migrating, and rewrites
gateway.yaml atomically (tmpfile + fsync + os.replace) so an interrupted
--fix can't truncate the config. --fix help/docs document config migration.
- tests: newer-version rejection, boolean/non-integer stamp rejection, export.
Co-authored-by: Mervin Praison <[email protected]>
Per-channel restart budgets are blind to a systemic fault that restarts
every channel at once. Add a pure core FleetSupervisionPolicy breaker
(praisonaiagents/gateway) enforced by the wrapper ChannelHealthMonitor,
which holds restarts and records ONE gateway degraded-owner fact on the
shared DegradedCapabilityRegistry when the fleet thrashes. Wire the
gateway's shared registry into the single supervision owner and expose
gateway.health.fleet_* YAML thresholds.
Co-authored-by: MervinPraison <[email protected]>
Add a canonical GATEWAY_CONFIG_VERSION stamp and a declarative
LegacyConfigRule contract in core (praisonaiagents/gateway/config.py) with a
single migrate_config_with_doctor executor. Wire `gateway doctor` to detect an
out-of-date config and `--fix` to migrate it forward once and stamp the new
version, so cross-release upgrades no longer silently drift.
Co-authored-by: MervinPraison <[email protected]>
Address reviewer feedback on PR #3839:
- When litellm is installed but a capability helper is missing or raises,
preserve litellm as authoritative (return False) instead of overriding
with the static heuristic. Heuristics now run only when litellm is
genuinely absent (litellm is None), preventing unsupported params
(response_format / web_search_options) from reaching provider requests.
- supports_parallel_function_calling now uses a dedicated, narrower
heuristic instead of the general function-calling one, so serial-only
tool-calling families are not over-reported as supporting parallel calls.
- Add tests for installed-litellm error path and serial-vs-parallel.
Co-authored-by: Mervin Praison <[email protected]>
Capability helpers (supports_structured_outputs / function_calling /
parallel_function_calling / web_search / prompt_caching) delegated entirely
to litellm and returned False for every model when litellm was absent,
silently disabling structured-output / tool-calling / caching / web paths on
lean provider-native installs. Each helper now falls back to a small,
conservative, pattern-based static heuristic only when litellm is
unavailable. litellm remains authoritative when installed (no behaviour
change for existing deployments); no new deps, no network, no config knobs.
Co-authored-by: MervinPraison <[email protected]>
The before-tool injection hook allowed a tool call whenever no scanned
string matched. Because _extract_strings bounded the walk by bytes /
cardinality and returned a partial result, an attacker controlling
tool_input could pad benign strings ahead of an injection payload so the
payload was never reached, and the gate allowed the call (Greptile P1).
_extract_strings_bounded now reports whether the walk was truncated, and
create_hook blocks (fail-closed) on truncated, un-vettable input instead
of allowing it. _extract_strings is kept as a back-compat shim.
Co-authored-by: Mervin Praison <[email protected]>
- security/injection.py: _extract_strings walks iteratively bounded by bytes+
cardinality instead of a depth-4 cutoff, scans dict keys, and is cycle-safe so
nested tool inputs can no longer bypass the scanner.
- db/adapter.py: share one _serialize_tool_call helper across sync/async paths
that persists the full tool result verbatim (no silent [:1000] truncation).
- persistence/orchestrator.py: add aretrieve_knowledge/aadd_knowledge that
offload sync stores via asyncio.to_thread (and await native async stores) so
RAG no longer blocks the event loop.
Co-authored-by: Mervin Praison <[email protected]>
Gap 1: add shared require_scope() in knowledge/protocols.py and enforce it in
ChromaKnowledgeAdapter.delete_all and SQLiteKnowledgeAdapter.delete_all so an
unscoped delete_all raises ScopeRequiredError instead of wiping the whole
shared store, matching mem0's existing contract.
Gap 2: add MemoryMixin._apersist_message async wrapper that offloads
_persist_message to a worker thread, and await it at every _achat_impl call
site so file-locked disk I/O no longer blocks the event loop each turn (sync
_chat_impl and the sync _start_stream generator keep the sync call).
Gap 3: skip VAR_POSITIONAL/VAR_KEYWORD in build_parameters_schema so *args and
**kwargs are no longer emitted as required parameters, matching
build_tool_definition and preventing invalid schemas / tool-call TypeErrors.
Co-authored-by: Mervin Praison <[email protected]>
The step retry exponential-backoff + progress-print block was duplicated
byte-for-byte between the top-level _run_impl loop and the shared
_apply_step_policies helper. Extract it into a single private
_retry_backoff() used by both paths. Behaviour is unchanged: same
2**(retry_count-1) timing, same verbose print, same time.sleep. All
retryability, guardrail, on_step_error and guardrail_failed semantics are
preserved.
Co-authored-by: MervinPraison <[email protected]>
Wrapper BaseFrameworkAdapter._resolve_llm now delegates the spec->model-name
precedence to core's super()._resolve_llm instead of re-implementing it, then
derives base_url/api_key locally to build the PraisonAIModel provider object.
Removes duplicate logic and drift risk. Behaviour-preserving.
Co-authored-by: MervinPraison <[email protected]>
Move the workflow_runs page implementation into praisonai_bot where its
consumer (host_app.py) expects it, following the established bot_health
C9 shim pattern. The wrapper module becomes a backward-compat shim.
Co-authored-by: MervinPraison <[email protected]>
- Use self._hook_runner (agent-scoped registry) instead of the process-wide
default so agent-registered MODEL_FALLBACK hooks actually fire.
- Add async _aemit_model_fallback that awaits the runner; the async recovery
path no longer drops hooks via execute_sync() inside a running loop.
- Sync fallback now forwards the active stream callback so streaming consumers
receive the MODEL_FALLBACK stream event.
- Async fallback honours emit_events (no stream leak when emit_events=False).
- Extract shared _build_model_fallback_input / _emit_model_fallback_stream.
- Tests: agent-registry isolation, async hook dispatch, suppression.
Co-authored-by: Mervin Praison <[email protected]>
When the primary model becomes unavailable mid-turn and the runtime switches to
the next entry in fallback_models, the switch was invisible except for a log
line. Add HookEvent.MODEL_FALLBACK (+ typed ModelFallbackInput) and
StreamEventType.MODEL_FALLBACK, emitted at both switch sites in chat_mixin.py
via a guarded _emit_model_fallback helper. Zero overhead when nothing is
subscribed; provider internals stay redacted (only the failure class is
exposed). Follows the existing SESSION_PERSIST_FAILED observability precedent.
Co-authored-by: MervinPraison <[email protected]>
Widen the plugin bridge so before_message/after_message receive the full
resolved context (platform, sender_id, channel_id, channel_type, message_id,
session_id) alongside content, enabling per-user rate limiting, access
control, and channel-scoped redaction. Add message_sent/message_undelivered
base methods bridged to the already-emitted MESSAGE_SENT/MESSAGE_UNDELIVERED
events for delivery observability. Additive and backward-compatible.
Co-authored-by: Mervin Praison <[email protected]>
asyncio.wait_for cannot cancel a sync tool already running in the executor,
so the async retry loop was re-invoking timed-out tools up to max_attempts
times while the original invocation kept running — duplicating DB writes,
API calls, and file mutations (Greptile P1).
Mark the async timeout result with retryable=False and short-circuit it in
_execute_tool_async_with_retry alongside the other non-retryable outcomes.
Co-authored-by: Mervin Praison <[email protected]>
- process=hierarchical: drive the ahierarchical()/hierarchical() generators
manually so the real manager_task id is sent back via .send()/.asend(),
restoring the self-delegation guard and manager-task exclusion (was always
None under plain for/async for).
- execute_tool_async: apply the per-agent ToolConfig.timeout via
asyncio.wait_for so the async path no longer hangs indefinitely on a stuck
tool, matching the sync path.
- guardrails: fail loud with a clear ValueError when unenforced policy-string
guardrails (guardrails=["policy:..."] or GuardrailConfig.policy/.policies)
are passed, instead of running with silent zero enforcement; fixed the
misleading GuardrailConfig docstring.
Co-authored-by: Mervin Praison <[email protected]>
Resolve the shared background runner once and inject it into
BackgroundHandler in _handle_tasks_command so a single /tasks
invocation uses one instance, and harden the test fixture to own the
process-wide singleton lifecycle (bypassing the package's lazy
__getattr__) so a cross-file daemon can't empty the dedicated runner.
Co-authored-by: Mervin Praison <[email protected]>
Address greptile P1 review findings on tools list MCP discovery:
- Naming mismatch: the run path registers MCP tools by their bare __name__
(e.g. `read`), but `tools list` previously fabricated `<server>_<tool>`
names that would not resolve. Discovery now lists tools under the exact
runtime callable name, with cross-server duplicates disambiguated as
`name (server)` so nothing is hidden.
- Timeout blocking: each server is now discovered in a worker thread under a
hard per-server cap (_MCP_DISCOVERY_TIMEOUT_S), so a slow/unreachable server
is abandoned and reported as unavailable instead of stalling the listing.
Updated + added unit tests (runtime names, duplicate disambiguation, timeout).
Co-authored-by: Mervin Praison <[email protected]>
Add an `mcp` source to `praisonai tools list` so the terminal shows the
tools exposed by configured MCP servers alongside built-in, local,
external, and registered tools. Discovery is lazy (only when the default
view or `--source mcp` is requested) and fail-soft: an unreachable or
misconfigured server is reported as unavailable with a reason instead of
blocking the rest of the listing. Reuses the existing guarded
`MCPHandler.create_mcp_from_server` and `_collect_mcp_servers_from_config`
so no new dependencies or Agent params are introduced.
Co-authored-by: MervinPraison <[email protected]>
The two failing CLI tests (test_tasks_detail_and_cancel,
test_tasks_lists_background_tasks_in_repl) are a pre-existing cross-worker
flake unrelated to this PR's todo-streaming changes: under
`pytest -n 2 --dist loadfile` a background daemon from another file mutates
the process-wide BackgroundRunner singleton mid-assertion, so the handler
resolves a different runner than the test pinned.
Add an optional `runner=` dependency-injection seam to
`_handle_tasks_command` (None in all production call sites, so behaviour is
unchanged) and have the tests inject their dedicated runner directly. This
bypasses global-singleton resolution entirely, making the assertions immune
to the race. Verified deterministic across 500 iterations under active
singleton chaos.
Co-authored-by: Mervin Praison <[email protected]>
The async tool-execution path (_execute_tool_async_impl) never entered
tool_progress_channel, so emit_todo_update / emit_tool_progress always
returned False during async single-Agent runs — live TODO_UPDATED events
were invisible to subscribers. Mirror the sync execute_tool path: build a
progress sink from the existing stream emitter and activate the channel
around both the coroutine await and the run_in_executor call (set before
copy_context_to_callable so the contextvar propagates into the worker
thread). Zero overhead when no stream callbacks are registered.
Adds regression tests covering the TODO_UPDATED event, TodoTools emission,
the single-in_progress invariant, and async-path channel propagation.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Adds StreamEventType.TODO_UPDATED plus a small emit_todo_update() helper
that reuses the existing thread-local progress sink (already active around
every tool call). The built-in todo tool now publishes the full ordered
list on each add/update, so any frontend can render a live checklist.
Enforces the "exactly one in_progress" convention and consolidates the
planning TodoItem status vocabulary to include cancelled.
Co-authored-by: MervinPraison <[email protected]>
Per-request AgentsGenerators borrow the cached generator's tool-timeout
executor but previously kept leak accounting local, so a stuck sync tool
never incremented the owner's _leaked_workers and the shared pool was
never recycled — eventually starving all requests (Greptile P1).
Delegate _get_tool_timeout_executor/_note_leaked_worker/_timeout_owner_key
to the cached owner so leaks are counted on, and recycling happens on, the
generator that actually owns the pool.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
- serve agents: build a per-request AgentsGenerator that borrows the cached
generator's warm immutable pieces (adapter, config_list, tool resolver,
tool-timeout pool) instead of mutating shared cli_config under a global
lock, so concurrent requests are no longer serialised.
- persistence: add scan_prefix() to StateStore with a prefix-scoped default;
override with non-blocking SCAN on Redis and Upstash so get_runs/get_traces
never issue KEYS praison:* across the whole keyspace.
- security: split strict jailbreak patterns (auto-HIGH) from soft role-play /
imperative patterns (MEDIUM alone, HIGH only when combined), so benign
prompts like "You are now analyzing..." are not blocked while real attacks
("ignore all previous instructions", DAN) still block on a single hit.
Co-authored-by: MervinPraison <[email protected]>
Pre-existing xdist race (unrelated to PR #3810's agent/knowledge changes):
under -n 2 --dist loadfile a persistent worker's process-wide background
runner could interleave with these tests, surfacing as "No background tasks"
and an uncancelled RUNNING task. Add an explicit xdist_group so the file's
tests stay serial on one worker, and reset the module-level _shared_runner on
fixture teardown so no dedicated instance leaks into a later module. Assertions
are unchanged.
Co-authored-by: Mervin Praison <[email protected]>
cleanup_launch_registration filtered the shared FastAPI router by path
only, so closing an agent launched at a path that also serves a
different verb (e.g. built-in GET /health, GET /) removed that unrelated
route too. Filter on path AND POST method since launch() always
registers via .post(path).
Co-authored-by: Mervin Praison <[email protected]>
- Agent.close() now tears down the routes launch() actually registered
(module-level state in execution_mixin.py) instead of a disconnected,
never-populated ServerRegistry — closes the launched-endpoint handle leak.
- Remove dead Agent.checkpoints/Agent.background pass-through state and
properties that were never consumed by any code path (no live consumer),
keeping the surface lightweight.
- MongoDBKnowledgeAdapter.add/search now honor user_id/agent_id/run_id
scoping, matching SQLite/Chroma adapters, fixing the silent cross-tenant
data leak.
Co-authored-by: MervinPraison <[email protected]>
The /tasks CLI resolves the runner via ``from praisonaiagents.background
import get_background_runner``, which reads the name off the package (whose
__init__ exposes it through a lazy __getattr__), imported at call time by
BackgroundHandler._get_runner. The fixture only patched the ``runner``
submodule binding, so once another worker file materialised the package
attribute, the CLI bypassed the patch and resolved the process-wide shared
runner instead — letting a concurrent cross-file daemon race the two tests
under ``pytest -n 2 --dist loadfile`` (the observed "No background tasks"
and uncancelled RUNNING failures). Patch the name on the package too so the
CLI deterministically sees the dedicated per-test runner. Test-only; no
assertions weakened and no SDK behaviour changed.
Co-authored-by: Mervin Praison <[email protected]>
Move the byte-for-byte-identical _deliver_result and _build_delivery
methods from AgentScheduler and AsyncAgentScheduler up to the shared
_BaseAgentScheduler, mirroring the existing consolidation of
_should_suppress_delivery, _budget_exceeded and _build_stats. Removes
~55 lines of duplication and the drift risk on a safety-relevant
delivery path. No behaviour change: both methods are plain synchronous
methods referencing only attributes both subclasses set identically.
Co-authored-by: MervinPraison <[email protected]>
Addresses CodeRabbit & Greptile P1 review feedback on the YAML/prompt CLI
router (#3793):
- main() now classifies the first positional value-aware when flags are
present, so a leading value-taking run option (e.g. `--session abc123
agents.yaml`) consumes its value instead of mistaking it for the target.
Discovery is skipped on the flagless fast path and falls back conservatively
when introspection fails.
- Quarantine `-s`/`-f` (legacy `--save`/`--file` vs modern
`--session`/`--framework`) from the modern forwarders so existing YAML/prompt
invocations carrying them stay on legacy — zero silent reinterpretation.
Unambiguous long forms still reach the modern engine.
- Reuse the derived value_opts in the bare-prompt branch (no double
introspection).
Tests: added leading-value-option (YAML + prompt) regressions and
legacy-colliding-short-opt coverage for both classifiers.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Workflow YAML invocations (praisonai agents.yaml) were routed to the legacy
argparse path, bypassing the modern run features: session continuity
(--continue/--session/--fork), --output modes, the first-run credential gate
and permission flags. The modern `run` command already fully handles YAML
files inside that envelope; only the CLI router diverted YAML to legacy.
Add _looks_like_yaml_run_target() and forward a workflow YAML target to
`run <file>` when all flags are run-supported, restoring YAML parity with the
CLI/Python surfaces. YAML with a genuinely legacy-only flag still falls back to
legacy with the existing one-line notice.
Co-authored-by: MervinPraison <[email protected]>
The two failing CLI tests (test_tasks_detail_and_cancel,
test_tasks_lists_background_tasks_in_repl) are a pre-existing shared
BackgroundRunner singleton isolation flake, unrelated to this PR's
gateway-only changes. Port the already-authored isolation fixture that
pins a dedicated runner per test and monkeypatches get_background_runner
to return it, so no other file's daemon loop can reach the instance
_handle_tasks_command resolves. Assertions (list, detail, real CANCELLED
status) are unchanged.
Co-authored-by: Mervin Praison <[email protected]>
The two test_interactive_tasks_command.py tests fail intermittently under CI's
pytest -n 2 --dist loadfile because the process-wide BackgroundRunner singleton
(and a live daemon loop another file left running on it) races these tests,
surfacing as "No background tasks" and an uncancelled RUNNING task. This is
unrelated to the scheduler-delivery changes in this PR.
The prior fix only reset _shared_runner, still sharing a process-wide object
other threads could touch. Instead, pin a dedicated BackgroundRunner per test
and monkeypatch get_background_runner to return exactly it, so no other file's
daemon can reach the instance _handle_tasks_command resolves. No assertions
weakened: list, detail, and the real CANCELLED status are all still verified.
Verified passing even with a pre-polluted singleton.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Addresses reviewer findings on PR #3806:
- Greptile P1 (blocking): the pin no longer permanently mutates the shared
registry Agent. _check_model_drift now returns a run-scoped restore callable
applied in a finally around the whole run (gate/policy/turn/exception paths),
so a scheduled job's model never leaks into attended turns on the same agent.
Extracted the run tail into _run_pinned for a single guaranteed restore point.
- CodeRabbit (Major): provider/model are normalized (embedded "openai/gpt-4o"
prefix lifted out) before comparison, so a provider-qualified pin and a bare
resolved model no longer false-drift; split provider snapshots compare equal
to embedded agent strings too.
- CLI: clarified --pin/--no-pin help — pinning enforces a snapshot only once one
exists via an explicit --model; kept the CLI lightweight (no agent/LLM
instantiation at creation time) per AGENTS.md.
Tests: added run-scoped-restore and provider-prefix-normalization cases; core
scheduler (127) and wrapper scheduler (274) suites pass.
Co-authored-by: Mervin Praison <[email protected]>
A NaN/inf budget or rss slipped past the `rss <= target` check (all NaN
comparisons are False) and spuriously evicted every warm cache. Guard with
math.isfinite and safely coerce headroom_ratio, falling back to 0.9.
Co-authored-by: Mervin Praison <[email protected]>
Unattended scheduled runs resolved their model at fire time, so a job created
against a cheap/local default silently inherited whatever the default later
became — an invisible cost/behaviour drift for automations run without
supervision.
- core: additive provider/model/pin_model snapshot fields on ScheduleJob with
backward-compatible round-trip serialisation (unset = today's behaviour).
- wrapper executor: before the model turn, compare the resolved agent's model
with the snapshot; on drift fail closed (recorded error + optional fail-closed
delivery) with no model turn; otherwise pin the run to the snapshot.
- CLI: `schedule add --model` / `--pin/--no-pin`.
Co-authored-by: MervinPraison <[email protected]>
Add a pure, import-free core planner plan_pressure_evictions plus a
WarmSession fact and a MemoryPressureProtocol (cgroup limit + anon RSS)
to the gateway protocols. Under memory pressure the gateway can now name
the coldest (LRU) rebuildable warm session-agent caches to soft-evict
before the kernel OOM-kills the whole process, guarding against evicting
in-flight or not-yet-flushed sessions. Mirrors the existing
MemoryPressurePolicy admission contract; the decision is testable in
isolation and the gateway enacts the returned plan.
Co-authored-by: MervinPraison <[email protected]>
- failure.py: map validation/invalid_output -> format_error and OR in the
source outcome's is_retryable() so invalid_output stays retryable
- presentation.py: DegradedDelivery now uses immutable Tuple fields; report
derived from the same conversion/selection as adapt_presentation (select->
buttons, priority/cap truncation), passes callback_store, and reports
callback shortening only for genuinely lossy payloads (reply actions & select
options), never plain callbacks or store-backed lossless refs
- tests: pytest.raises for frozen checks; add invalid_output-retryable,
long-callback-with-store, plain-callback, and long-select-option coverage
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Addresses reviewer feedback on #3800/#3802:
- P1: Build SchedulerDelivery eagerly in the (sync/async) scheduler __init__
when a deliver token is set, via a shared _build_delivery() helper. The
creation-time pre-flight warning/preview now fires the moment the scheduler
is created instead of lazily at fire time (after the first run). Fire-time
lazy build is retained as a fallback; self-heal remains the second line.
- P2: Move optional validate_target/preview_target off the runtime_checkable
DeliveryResolverProtocol into a separate DeliveryPreflightProtocol so
existing resolve()-only resolvers still satisfy the base protocol under
isinstance. Export DeliveryPreflightProtocol from praisonaiagents.gateway.
Backward-compatible, no new deps, no Agent params. Scheduler suite: 262
passed, 7 skipped; preview suite: 9 passed.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Add a creation-time pre-flight so a scheduled/agent-initiated send's
DeliveryTarget is checked and previewed the moment it is created, not only
when the job fires. Core gains a thin protocol seam (DeliveryValidation,
ScheduleTargetError, DeliveryResolverProtocol.validate_target/preview_target)
and a pure DeliveryTarget.preview(); the wrapper SchedulerDelivery validates
its target on construction, logging an actionable warning for unroutable
tokens and a preview otherwise. Fire-time self-heal is kept as second defence.
Co-authored-by: MervinPraison <[email protected]>
Add two pure, dependency-free core primitives so every bot turn ends in a
visible, actionable outcome and presentation downgrades are never silent:
- praisonaiagents/bots/failure.py: FailureReply + render_failure_reply maps an
AgentRunOutcome or PraisonAIError to typed, user-facing next-step copy keyed
by failure class (expired auth, missing key, rate limit, timeout, budget,
doom-loop, needs-help), reusing the existing remediation_hint.
- praisonaiagents/bots/presentation.py: DegradedDelivery + a backward-compatible
adapt_presentation_with_report returning the adapted presentation plus a typed
record of dropped controls with machine-readable reason codes. Existing
adapt_presentation signature is unchanged.
Mirrors classify_final / resolve_ingress_admission so no adapter re-decides the
failure/degradation outcome. No new deps, no config knobs.
Co-authored-by: MervinPraison <[email protected]>
- env.py: _provider_from_model prefers the env-set credential alias so
Together/Fireworks (TOGETHERAI_API_KEY, FIREWORKS_AI_API_KEY) resolve a key
instead of always using the first declared var.
- credentials.py: _stored_providers_for_vars derives from PROVIDER_ENV_CATALOGUE
so a stored key for any catalogued provider (Mistral, DeepSeek, xAI, Together,
Perplexity, Fireworks) satisfies is_configured() for its explicit model.
- setup/handler.py: _provider_setup_info picks the catalogue env-var (honouring
the alias actually set) and the catalogue representative model for
catalogue-only providers.
- tests: derive the env-clearing/detection key lists from the catalogue so
default-model and interactive-path assertions stay deterministic even when the
CI host exports a catalogued key (e.g. OPENROUTER_API_KEY). Fixes the three
failing test-core (cli) checks without weakening assertions.
Co-authored-by: Mervin Praison <[email protected]>
Replace the three hardcoded provider lists (is_configured's 8-key tuple,
_PROVIDER_DEFAULTS, and the setup wizard's 4-key env auto-detect) with a
single data catalogue PROVIDER_ENV_CATALOGUE mapping provider id ->
env-var(s) + representative model + model prefix.
Zero-config `praisonai run` now recognises a valid key for any catalogued
provider (Mistral, DeepSeek, Together, Fireworks, xAI, Perplexity, …) and
selects a provider-appropriate default model instead of gating on the old
~8-provider set. The catalogue keeps the historical 8 at its head so
existing ordered-preference behaviour is unchanged and new providers are
purely additive and backward compatible.
Co-authored-by: MervinPraison <[email protected]>
Auto-compaction previously read a static per-model context-window table with a
flat 128k default, mis-estimating the trigger point for any non-hardcoded model
(overflow for small windows, premature compaction for large ones).
get_model_limit()/get_output_reserve() now consult litellm's model_cost registry
(max_input_tokens/max_output_tokens) first, then fall back to the static tables.
Lazy-imported: zero overhead and fully backward compatible when litellm is absent.
Co-authored-by: MervinPraison <[email protected]>
- auto.py: _rewrite_agent_refs now rewrites route target strings
(route: {label: [agent_id]}) and loop/branches payload refs including
feedback_to, so renamed _auto_N agents are no longer routed to the
pre-existing colliding agent.
- orchestrator.py: PRAISONAI_SESSION_CACHE_MAX now falls back to the
documented default (1024) on empty/non-numeric values instead of raising
ValueError during persistence init.
Co-authored-by: Mervin Praison <[email protected]>
- Gap 1: PersistenceOrchestrator._session_cache is now a bounded LRU
(OrderedDict + PRAISONAI_SESSION_CACHE_MAX, default 1024) to prevent
unbounded memory growth in long-running serve/jobs/bot processes.
- Gap 2: flow.start_flow() now joins LANGFLOW_COMPONENTS_PATH with a comma
(pydantic list separator) instead of ';', matching the CLI helper so a
pre-existing env value no longer silently disables all components.
- Gap 3: merge_with_existing_workflow now tracks agent renames and rewrites
step agent references (including nested parallel/route/loop/branches) so
merged steps dispatch to the freshly-generated agent, with a log line on
each rename.
Co-authored-by: MervinPraison <[email protected]>
Remove calculate_cost_from_tokens and get_model_cost_info, which are
never referenced anywhere in the package, wrapper, tests, docs, or
public API. Also drop the now-unused Dict import. The live cost path
(calculate_cost) and tracking helpers remain unchanged.
Co-authored-by: MervinPraison <[email protected]>
Security: _caller_principal now treats the authenticated SessionContext.
unified_user_id as authoritative — an agent-supplied `principal` tool argument
can no longer override it, closing the cross-tenant impersonation gap flagged by
CodeRabbit/Greptile. Explicit principal still applies on the trusted no-session
path (CLI / gateway bridge).
CI regressions (stale test doubles):
- _FakeEngine now accepts principal= (matches the real principal-aware
SuggestionEngine API the gateway threads through).
- async_tui parity fake _execute now accepts read_only= (param added by the
earlier /code-review PR); pre-existing failure unrelated to this feature.
Tests: rewrote impersonation coverage (session wins), added no-session explicit
path test, and restore both global schedule stores in teardown for determinism.
Co-authored-by: Mervin Praison <[email protected]>
_execute_in_background now calls _execute_prompt(prompt, read_only=...) after
the read-only review work landed, but the test's _fake_execute only accepted
`prompt`, so the extra kwarg raised TypeError (swallowed by the background
error handler) and captured["prompt"] was never set -> KeyError: 'prompt'.
The fake now accepts read_only; assertions (single expansion, sentinel body
reaches the prompt) are unchanged.
Co-authored-by: Mervin Praison <[email protected]>
Multi-agent review found the npm package (praisonai 1.7.3) does not compile
from a clean checkout and has several core-loop bugs. This makes v1.7.4
releasable.
Build was broken everywhere but the author's machine:
- src/db/ (6 files) and src/cli/output/ (4 files) were never committed --
the repo-root .gitignore entries `db` and `output` swallowed them, so 63
of 65 tsc errors were missing-module. Committed the files and added
negation rules.
- pnpm-lock.yaml was out of date (axios spec, better-sqlite3, removed
optional ai-sdk providers); regenerated.
- @jest/types and @jest/globals were not direct devDependencies, so jest
could not even load its config under pnpm; added, plus
@ai-sdk/provider-utils and @opentelemetry/api for the dynamic imports.
- adapter.ts imported tool()/jsonSchema() from @ai-sdk/provider-utils,
which never exported them (they live in `ai`); now tries `ai` first with
the old fallback preserved.
Core bugs fixed (each confirmed at runtime by the review):
- require('praisonai') threw at import when OPENAI_API_KEY was unset, even
for Anthropic/Google users; check moved to client creation.
- Agent.chat() sent every user prompt to the model twice (pushed to
history, then start() replayed history AND appended it again).
- Constructor tool processing aliased and mutated the caller's tools array
while iterating it: raw functions plus duplicate definitions were sent
to the API. Now iterates a snapshot and rebuilds this.tools.
- Plain-function tools received the parsed args OBJECT as their first
positional parameter ("Weather in [object Object]"); named args are now
mapped positionally via declared parameter names.
- Tool results were serialized with .toString() -- objects became
"[object Object]", null/undefined threw; now JSON.stringify.
- Tools and outputSchema silently ignored on non-OpenAI providers; now
logged loudly (proper support is follow-up work).
- OpenAIService defaulted to gpt-5-nano while hardcoding temperature 0.7,
which reasoning-family models reject with a 400; the default temperature
is now omitted for gpt-5*/o1*/o3*/o4*.
New capability (Python parity: output_json/output_pydantic):
- Agent outputSchema / outputSchemaName wired through generateText and
generateChat as OpenAI response_format json_schema.
- The exported parity MCP class's callTool() returned null (placeholder);
it now delegates to the real MCPClient (stdio/HTTP/SSE).
Release readiness: version 1.7.4 (1.7.3 is already on npm), LICENSE file
added (files list referenced it but it did not exist), engines.node >=18
(openai@4 requires it; >=14 was wrong).
Results: tsc 65 errors -> 0; jest "cannot start" -> 59/59 suites, 999
tests passing (87 skipped are live-API); npm pack produces a complete
tarball including LICENSE. Also added 9 unit tests covering every fix
above.
Co-Authored-By: Claude Fable 5 (1M context) <[email protected]>
- Move PresentationRendererProtocol to bots/protocols.py (core extension-point
convention), re-exported from presentation.py for backward-compatible imports.
- Normalize platform ids (case-insensitive) in the renderer registry to match
the channel registry, so mixed-case plugin ids resolve their renderer.
- Validate registrations at the API boundary: reject empty platform ids and
renderers missing get_limits/render, failing loudly at registration.
- Make wrapper built-in registration older-core safe (swallow ImportError) and
non-destructive (only fill empty slots, so plugin overrides win).
- Case-insensitive older-core fallback in get_renderer.
- Fix pre-existing CLI test failure: _execute_prompt fake stub now accepts the
read_only kwarg the production signature passes.
Co-authored-by: Mervin Praison <[email protected]>
- pairing.py: reject empty/whitespace secrets, fail closed on read errors of
an existing secret (no silent HMAC rotation), and create the secret atomically
with O_CREAT|O_EXCL so racing processes converge on one key.
- _pairing_ui.py: narrow the callback-secret fallback to storage errors and warn
explicitly instead of silently substituting a diverging random key.
- bot_checks.py: doctor guidance now notes PRAISONAI_CALLBACK_SECRET must be
unset/identical across hosts for button signatures to align.
- test_interactive_surfaces_parity.py: update the _execute_prompt fake to accept
read_only (matching the real signature added in #3758), fixing KeyError:'prompt'.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
The async TUI review path calls self._execute_prompt(prompt, read_only=...),
but the parity test's fake stub only accepted `prompt`. The resulting TypeError
was swallowed by the broad except in _execute_in_background, so the fake never
recorded the prompt (KeyError: 'prompt'). Align the fake with production.
Co-authored-by: Mervin Praison <[email protected]>
Aligns resolve_ingress_admission with the live BotConfig.group_policy
default (mention_only) instead of respond_all, so an adapter forwarding
an unset policy fails safe (mention-gated) rather than replying to all
group traffic. Clarifies allowlist/pairing docstring contract.
Co-authored-by: Mervin Praison <[email protected]>
Add PresentationRendererProtocol + register_presentation_renderer /
get_presentation_renderer to praisonaiagents.bots.presentation so any
channel — built-in or a pip-installed plugin — can register a native
renderer the same way it already contributes config via ChannelDescriptor.
The wrapper now registers the four built-in renderers through this seam and
resolves via the core registry, so plugin channels no longer silently
degrade interactive UI to plain text.
Co-authored-by: MervinPraison <[email protected]>
Add pure, zero-dependency resolve_ingress_admission(...) -> IngressDecision
in praisonaiagents.bots.admission, mirroring evaluate_channel_health and
BotLoopGuard. Unifies channel/user allowlist, block-list, group policy
(respond_all/mention_only/command_only/observe) and pairing into one typed
verdict with a machine-readable reason_code so drops are recordable instead
of logger.debug-only and cannot drift per transport. Adds no new user knob.
Co-authored-by: MervinPraison <[email protected]>
Route the callback-button HMAC secret (bots/_pairing_ui.py) through the
same auto-provisioned, owner-only persisted secret used by the pairing
code path, so approve/deny buttons keep verifying across process restarts
with zero operator action. Explicit PRAISONAI_CALLBACK_SECRET override
still wins. Also collapse the stale ephemeral _get_secret() onto the
persisted store and correct the doctor check message.
Co-authored-by: MervinPraison <[email protected]>
Addresses the reviewer-flagged P1 that ``--pure`` / ``--no-plugins`` leaked
across in-process invocations: the flag set PRAISONAI_NO_PLUGINS with no
restore, so a later run/chat/code call in the same process inherited the
plugin-free baseline it never asked for.
- Add scoped_no_plugins() context manager + scopes_no_plugins() decorator in
cli/utils/env_utils.py that set PRAISONAI_NO_PLUGINS for the duration of one
command and always restore the prior value on return (mirrors the existing
--allow-local-tools save/restore pattern). functools.wraps preserves the
Typer signature so the real option set is unchanged.
- Apply the decorator to run/chat/code callbacks and drop the unscoped env
mutation from each body.
- Add regression tests for set/restore, prior-value restore, no-op, exception
safety, and decorator signature preservation.
Also repair pre-existing (present on main) test stubs broken by the earlier
read_only review-agent change: async-TUI stubs must accept the read_only
kwarg. Source is correct; only the stubs were stale.
Not implemented (out of scope per issue #3773): suppressing praisonai.tools /
praisonai.tool_sources entry-point discovery. The issue scopes suppression to
the praisonai.plugins group; tool suppression is explicitly optional/secondary
and would be scope creep.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
_execute_in_background calls _execute_prompt(prompt, read_only=...) since the
review-command work (#3734). The parity test's _fake_execute stub only accepted
`prompt`, so the extra kwarg raised TypeError inside the inlined execute thread,
which was swallowed and left `captured` empty (KeyError: 'prompt'). Mirror the
real signature in the stub; assertions (single-pass expansion + sentinel body)
are unchanged.
Co-authored-by: Mervin Praison <[email protected]>
Address reviewer feedback on the doom_loop approval gate:
- Reset the whole matching (tool,args) streak on an explicit allow instead of a
blind pop() so a legitimate repeat gets a fresh critical_threshold window and
is not re-prompted on every subsequent call. Removing by identity also leaves
concurrent unrelated records intact (fixes the pop() race).
- Move the synthetic gate out of DEFAULT_DANGEROUS_TOOLS to a namespaced
internal target (__doom_loop__) so it can never reserve/collide with a real
user tool named doom_loop or leak into the safe/read_only presets.
- Honour an explicit PermissionManager allow/deny on the doom_loop policy alias
before consulting a backend, so Agent-level policy is not bypassed.
- Scope the approval request to the exact repeated call via a stable args
fingerprint so an allow for one loop cannot silently authorise another.
Adds regression tests: streak-reset (no per-call reprompt), per-argument
scoping, user-tool-name isolation, and internal-target criticality.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
- credentials: treat a set-but-empty PRAISONAI_AUTH_CONTENT as a
misconfiguration (ValueError) instead of silently falling back to disk,
preserving the zero-disk contract; validate that each provider entry is a
JSON object so malformed entries fail fast with a clear error.
- tests: add regression coverage for empty value and non-object provider entry.
- fix pre-existing CI failure in test_async_tui_expands_at_mentions: the
_fake_execute stub predated the read_only kwarg on _execute_prompt, so the
real call raised a swallowed TypeError and left the prompt uncaptured.
Co-authored-by: Mervin Praison <[email protected]>
Add an ephemeral, per-run flag to suppress discovery/loading of external
plugins without mutating persisted enable/disable state.
- Core: PluginManager honours a PRAISONAI_NO_PLUGINS env var and an optional
`disabled` constructor param (Python parity for plugins=False) that
short-circuits discover_entry_points()/auto_discover_plugins(), emitting a
single "running without external plugins" notice.
- Wrapper: add --pure / --no-plugins to run/chat/code, which set
PRAISONAI_NO_PLUGINS for the process only.
- Surface reproduction hint / suppression status in `plugins doctor`.
Co-authored-by: MervinPraison <[email protected]>
Route a critical loop verdict through the unified approval pipeline as a
synthetic `doom_loop` permission target instead of unconditionally blocking.
This gives human override, per-project policy control (allow|ask|deny) and
cost safety while preserving the historical hard-stop default.
Co-authored-by: MervinPraison <[email protected]>
Load the whole credential store from a single env var (JSON) in memory when
PRAISONAI_AUTH_CONTENT is set; skip all disk reads and make writes in-memory
no-ops so nothing is persisted. Surface the in-memory source in auth list/status.
Co-authored-by: MervinPraison <[email protected]>
- tools.py: read max_bytes+1 and reject when a github: tool file exceeds the
1 MiB cap instead of silently persisting truncated (broken) source as success
(Greptile P1).
- test_interactive_surfaces_parity: _fake_execute now accepts read_only= to
match _execute_prompt(prompt, read_only=...) — the stub predated the read-only
review signature, so the real path swallowed a TypeError and never set the
captured prompt (CI KeyError: 'prompt'). Test guarantee (expand-once) preserved.
Co-authored-by: MervinPraison <[email protected]>
Gap 2 (security): praisonai tools add now honours PRAISONAI_ALLOW_LOCAL_TOOLS
like every other loader, inspects added files via ast.parse instead of
exec_module, and hardens GitHub downloads (HTTPS-only, 1 MiB cap, safe basename).
Gap 3 (async safety): OpenAI-compat handlers offload sync provider.invoke via
asyncio.to_thread; simulated streaming emits word-by-word instead of per-char;
A2UEventBus mutations are guarded by a threading.RLock, the singleton is
double-checked-locked, and /a2u/health now requires authentication.
Gap 1 (adapter parity): add warn_unsupported_fields to surface YAML fields a
backend silently drops, wired into CrewAI/AutoGen adapters; AutoGen agent_callback
is now fired per agent.
Co-authored-by: MervinPraison <[email protected]>
Address reviewer feedback on #3769:
- memory_mixin: rollback now removes only the failing turn's own messages by
object identity (tracked per-turn via contextvars, thread/task-isolated) so a
concurrent turn's messages are never clobbered; positional fallback kept for
untracked callers (backward compatible).
- chat_mixin: wire _begin/_end/_clear_turn_tracking into sync chat() (try/finally)
and async achat() custom-LLM paths.
- agent: stop persisting a live Memory/db() backend in _memory_config so
clone_for_channel() isolates it via _memory_instance instead of sharing one
store across channel clones.
- test_interactive_surfaces_parity: fix outdated _fake_execute signature that
didn't accept _execute_prompt's read_only kwarg (pre-existing CI failure).
- Gap 1: clone_for_channel() now re-instantiates a fresh memory backend per
clone (via _isolated_memory_for_clone), so per-channel/per-user clones no
longer share one live Memory store. Also persists _memory_config on self so
clones actually receive the memory config.
- Gap 2a: add _rollback_chat_history_to() which only removes this turn's own
appended slice, so a failing turn can't clobber a concurrent turn's messages;
all chat()/achat() rollbacks now use it instead of _truncate_chat_history.
- Gap 2b: achat() custom-LLM path now uses the atomic
_add_to_chat_history_if_not_duplicate() helper (like sync chat()) instead of
an unlocked check-then-act, closing the TOCTOU duplicate-message race.
- Gap 3: forward Agent(retry=...) into LLM init params for all _using_custom_llm
branches (provider/model, dict, base_url) so max_retries is honored instead of
LLM's hardcoded default; user-set max_retries in dict configs still wins.
Co-authored-by: MervinPraison <[email protected]>
The async TUI passes read_only= to _execute_prompt in _execute_in_background,
but the test's fake only accepted (prompt), causing a TypeError swallowed by
the surrounding except, so captured['prompt'] was never set (KeyError). Align
the fake signature with production. Unrelated to PR #3764's LLM changes.
Co-authored-by: Mervin Praison <[email protected]>
Per-call max_tokens overrides bypassed the reasoning-model conversion because
override_params were merged after the instance-level mapping. Move the
max_tokens -> max_completion_tokens normalization (and sampling-param drop)
to after the override merge so o1/o3/gpt-5.x calls never carry the rejected
max_tokens. Explicit max_completion_tokens takes precedence.
Co-authored-by: Mervin Praison <[email protected]>
Reasoning models (o1/o3/gpt-5.x) reject the legacy max_tokens parameter
and require max_completion_tokens. They also reject sampling params like
temperature/top_p. The Chat Completions param builder now detects
reasoning models via litellm.supports_reasoning() (with a prefix fallback)
and emits the correct token param while dropping unsupported sampling params.
Co-authored-by: MervinPraison <[email protected]>
Addresses reviewer findings on PR #3747:
- Route /code-review and /security-review through a cached read-only review
agent whose tool set excludes write/command-execution tools, enforcing the
advertised read-only boundary at the capability level (not just by prompt).
- Skip @file expansion for generated review prompts so a `@../../secret`
token inside an untrusted diff cannot exfiltrate files.
- Distinguish diff-collection failures (raise ReviewDiffError) from a
successful empty diff, so errors are no longer masked as "Working tree clean".
- Parse review args with shlex so quoted paths survive; reject multiple
positional paths with a usage message.
- Derive slash-command autocomplete from _BUILTIN_COMMANDS so code-review /
security-review are discoverable and stay in sync with registration.
- Tests: option forwarding, multi-path rejection, diff-error vs clean tree,
ReviewDiffError propagation, and read-only tool filtering.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <[email protected]>
Under CI's `pytest -n 2 --dist loadfile`, a worker process persists across
files, so the process-wide BackgroundRunner singleton (and any tasks other
files scheduled on its daemon loop) leaked into these REPL /tasks tests. The
old fixture only cleared `_tasks`, which a concurrently-mutating daemon task
could repopulate — surfacing as "No background tasks" and an uncancelled
RUNNING task. Fully reset the singleton so `_handle_tasks_command` resolves the
same fresh instance we seed. No assertions weakened; behaviour still fully
verified (list, detail, actual CANCELLED status).
Co-authored-by: Mervin Praison <[email protected]>
Resolve two P1 correctness gaps from Greptile's second review pass:
- session fork: the store persists sessions under a *sanitized* filename
(non [alnum-_] -> _), so a global-only id containing e.g. '.' or ':' was
found by session_exists_anywhere but missed by the literal '{id}.json'
store-dir check, forking an empty history from the project store. Sanitize
identically when resolving the store dir.
- REPL /branch: replace the inline, unlocked busy check with the shared
_worker_busy() helper, which reads current_task and queue size under the
worker's processing_lock. This closes the dequeue/publish race where a turn
is in-flight but momentarily invisible.
Tests: sanitized-global-id fork resolution; /branch busy state read under lock.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
When the legacy REPL is launched with --approval plan the backend already
enforces PermissionMode.PLAN, but session_state['plan_mode'] defaulted to
False so the first no-arg /plan re-enabled an already-active mode instead of
exiting it (Greptile P1). Seed plan_mode from backend.permission_mode on first
use, mirroring the async TUI's _sync_plan_mode_from_backend. Adds a source
regression test.
Co-authored-by: Mervin Praison <[email protected]>
The Enter handler expanded @file mentions before _queue_or_execute, but
_execute_in_background already expands canonically. Since
_process_file_mentions is not idempotent, the second pass re-interpreted
@tokens embedded in attached file contents and appended unrequested files.
Removes the redundant enter-handler expansion so all paths expand once.
Strengthens the parity test to drive the real _execute_in_background path
and assert _process_file_mentions runs exactly once.
Co-authored-by: Mervin Praison <[email protected]>
Address three P1 review findings on the mid-session forking feature:
- session fork CLI now resolves the store directory from the same
canonical CLI stores session_exists_anywhere() searched (project then
global), so forking a global-only session copies its real history
instead of producing an empty fork from the project-scoped store.
- session fork rejects an out-of-range --at-message (negative or >=
message count) up front rather than silently wrapping via Python slice
semantics while reporting success.
- REPL /branch refuses to fork while a turn is still executing (worker
busy or queue pending), preventing the async worker from saving the
parent's in-flight turn onto the freshly-switched fork.
Adds tests for out-of-range indices, global-only forking, and
branch-while-busy rejection.
Co-authored-by: Mervin Praison <[email protected]>
Address two Greptile P1s on the headless `praisonai code -p` path:
- Profiling no longer bypasses headless output. The `-p` branch now runs
before the `--profile` branch and rejects the `-p` + `--profile/--profile-deep`
combination, so the machine-readable JSON/text contract and status exit code
are always honored instead of a human report exiting 0.
- `-p` fails closed on options the minimal headless agent cannot honor
(`--tools`, `--agent`, `--plan`, `--no-acp`, `--no-lsp`) rather than silently
dropping the caller's tools/profile/scope. Options that ARE honored
(`--model`, `--thinking`, `--verbose`, `--workspace`, `--resume/--session/--continue`)
remain allowed. Chose fail-closed over re-implementing the heavy ACP/LSP
wiring to keep the command lightweight (AGENTS.md).
Adds tests for both guards.
Co-authored-by: Mervin Praison <[email protected]>
Address four valid P1 findings from Greptile review of the /tasks feature:
1. Bot command dispatch was missing: wire handle_tasks_command into the
telegram, discord and slack adapters (was advertised but never invoked).
2. Task registries were disconnected: arun_background now submits to the
process-wide get_background_runner() so /tasks and `background list` see
the same tasks (previously each call built its own runner).
3. Ownerless tasks bypassed isolation: _owned now fails closed — a bot user
only sees/acts on tasks whose metadata.user_id matches exactly.
4. Cancellation left execution running: add BackgroundRunner.cancel_task_sync
(hops to the background loop) and use it so the underlying future is
actually cancelled instead of merely marking the record.
Tests updated for fail-closed ownership + new ownerless-isolation case.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Addresses Greptile P1: the TUI lazily built the checkpoint manager only on
the first /diff or /undo, so its "session start" snapshot captured an
already-modified workspace — /diff omitted prior edits and /undo restored the
post-edit state. Now on_mount() eagerly establishes the baseline before any
turn, and each submission records a pre-turn checkpoint so /undo can roll back
individual turns. Both paths are best-effort and no-op when checkpointing is
disabled. Adds two lifecycle regression tests.
Co-authored-by: Mervin Praison <[email protected]>
resolve_session accepts legacy-only ids via the deprecation-window
fallback, but rename_session did a canonical-only lookup so `session
rename` failed on a pre-existing session the CLI otherwise lists,
resumes, deletes and exports. Add a _legacy_rename fallback mirroring
_legacy_delete, backed by a new SessionManager.rename that persists the
title in the existing metadata `name` field (empty clears it, matching
canonical store semantics). Adds regression tests for the store and CLI
legacy paths.
Co-authored-by: Mervin Praison <[email protected]>
Addresses Greptile P1 findings and CI failures on PR #3750:
- async_tui/legacy: exiting PLAN now restores the launch-time policy
(accept-edits/bypass) instead of forcing DEFAULT.
- async_tui: sync config.plan_mode from a live PLAN backend at startup so
the [PLAN] indicator shows and the first /plan toggles off, not on.
- Fix collision with pre-existing planning tests: /plan <task> message keeps
"Creating plan" substring; /plan no-arg test updated for the intentional
persistent-toggle behavior.
- Add regression tests for restore-on-exit and startup sync.
Co-authored-by: Mervin Praison <[email protected]>
Address two Greptile P1 issues in the interactive shell escape:
- attach !!cmd output even when the command fails (do not silently
drop context the user explicitly requested)
- retain _pending_context until agent.start() succeeds so a model
error does not lose the attached shell output
Applies to both the REPL and TUI paths; adds regression tests.
Co-authored-by: Mervin Praison <[email protected]>
Cap build_recap() at RECAP_MAX_CHARS (3500) so an unbounded persisted/
compaction summary can never exceed Telegram's 4096-char message limit or
flood a terminal. Only the older summary is trimmed; the recent tail is
always preserved. max_chars<=0 disables the cap for full-block callers.
Fixes the valid unbounded-output concern raised in review; keeps the fix at
the core layer so every channel (CLI + all bots) benefits without per-adapter
plumbing.
Co-authored-by: Mervin Praison <[email protected]>
Address reviewer findings on the custom-command bridge (#3729):
- Telegram: register a catch-all MessageHandler(filters.COMMAND, ...) after
the built-in/registered CommandHandlers so file-based and entry-point
slash commands (never registered as explicit handlers) actually reach
handle_command and render, instead of being silently dropped.
- Resolver: require BOTH deployment opt-in and the command's own
allow_shell frontmatter before live !`cmd` substitution runs, so a
deployment enabling shell can't upgrade a command that declared it off.
- Telegram custom-command reply now routes through the normal delivery
pipeline (pop_last_presentation, fire_message_sending,
_send_response_with_media, render_presentation, fire_message_sent) and
sanitizes errors via safe_log_message/extract_root_cause_from_error/
safe_error_message instead of leaking raw exceptions to chat users.
- /help and native command-menu entries now merge resolver-discovered
file/entry-point commands (builtin/registered precedence preserved).
- Tests: add per-command allow_shell gating coverage.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
- Record SKILL.md mtime when a LoadedSkill enters the index (discover/reload/
add_skill) so an edit made between discover() and the first reload() is
detected instead of silently masked.
- Track discovery-owned skill names so reload() no longer reports skills
registered via add_skill() as removed (backward compatible).
- Refresh the change-detection baseline after manager-owned telemetry writes
so a telemetry-only SKILL.md update is not misread as a content change and
does not drop an activated skill.
- Make `praisonai skills reload` an honest scan report (no live-session
consumer exists cross-process) instead of an always-empty reload diff.
- Add tests: edit-before-first-reload, add_skill preservation, telemetry-only
no-false-change.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
- Force stream=False so a TTY streaming generator is not str()'d into a repr;
defensively materialize any generator that slips through.
- Explicitly set autonomy=False on the side agent so it can never trigger the
tool-using autonomous loop regardless of ambient config (read-only contract).
- Parse --keep only as the leading option so '/btw what does --keep mean?' is
preserved as question text.
- Replace the unconditional 'or True' rendering assertion with a real stdout
capture; add tests for leading-only --keep, autonomy=False, and generator
materialization.
Co-authored-by: Mervin Praison <[email protected]>
Address blocking reviewer findings on #3728 plugin runtime:
- discovery: trust gate now judges a plugin file by its own location, so a
repository-controlled symlink under .praisonai/plugins can no longer bypass
PRAISONAI_ALLOW_PROJECT_PLUGINS by pointing its target outside the project.
- discovery: track only tools a module actually contributed (pre-exec registry
snapshot) so unload never unregisters tools owned by another plugin; guard
_loaded_plugin_tools with a lock.
- loader: preserve boolean `plugins.enabled: true` (no longer collapses to an
empty allow-list that disables everything); refuse ambiguous disable.
- loader: fail fast when writing a TOML config without tomli_w instead of
writing an ignored YAML sidecar (config split-brain).
- CLI plugins: reload unloads stale single-file modules before rediscovery and
reports real counts + the discovery gate; disable unloads single-file plugin
tools; doctor checks registered-plugin hooks; plain-text output (no Rich
markup via typer.echo); chained exceptions; logged swallowed errors.
- tests: add symlink-gate, boolean-enabled, and isolated unload cases.
Co-authored-by: Mervin Praison <[email protected]>
Give the flagship `code` command a scripting-grade headless mode at parity
with `run --output json`/`chat --json`. `-p/--print` routes around the
decorated interactive path (which prints `Chat mode:`/`Prompt:` diagnostics
+ a profiling block and always exited 0), builds the code agent directly,
and emits a clean machine-readable envelope {result, session_id,
usage:{in,out,cost}, status} on stdout. Exit code reflects status (0 ok,
nonzero on error/empty result). `--resume <id> -p` composes for scripted
multi-turn. Usage is read from the existing token collector + cost tracker,
so no new Agent params, exports, or wrapper wiring are introduced.
Co-authored-by: MervinPraison <[email protected]>
Expose mid-session conversation forking that previously only existed as a
startup flag. Adds:
- /branch [title] [--at N] in the interactive REPL, forking the live
UnifiedSession, switching onto the fork, and recording parent/child lineage.
- praisonai session fork <id> [--at-message N] [--title ...] mirroring the
existing run --fork HierarchicalSessionStore substrate.
- Fork lineage (parent_id/children_ids) on UnifiedSession, merge-safe, shown
in /session and session list.
Wrapper-only change; no praisonaiagents core edits, no new deps.
Co-authored-by: MervinPraison <[email protected]>
Sessions were addressable only by opaque id. Add a lightweight,
metadata-only rename:
- DefaultSessionStore.rename_session() stores metadata["title"] via the
existing locked read-modify-write path; empty title clears it.
- list_sessions() surfaces "title"; _session_title() prefers it.
- session_resolver.rename_session() targets the canonical store holding
the id (mirrors delete_session).
- `praisonai session rename <id> <title>` subcommand; `session list`
Name column prefers the title, falling back to agent name.
Backward compatible: un-renamed sessions still resolve/resume by id.
Co-authored-by: MervinPraison <[email protected]>
Surfaces the already-complete background-task substrate on the interactive
surfaces so users can check on backgrounded work without leaving the session.
- interactive_legacy: /tasks (list), /tasks <id> (detail), /tasks cancel <id>
via the existing BackgroundHandler renderer
- bots/_commands: register `tasks` + handle_tasks_command with compact,
per-user-scoped rendering (owner via task metadata user_id)
- background: add lazy process-wide get_background_runner() so all surfaces
share one runner view
- fix latent crash in the CLI detail renderer when duration_seconds is None
Co-authored-by: MervinPraison <[email protected]>
Adds a working /diff to the coding REPL showing all file changes made this
session (session baseline -> working dir), with --turn and <file> scopes,
reusing the existing CheckpointService engine via SessionCheckpointManager.
Replaces the wrapper TUI's dead /diff and /undo stubs (which only printed
"use git diff" help text) with real handlers backed by the same engine as
the legacy REPL. Default-safe: when checkpointing is disabled, /diff reports
how to enable it rather than failing silently.
Also verifies and regression-tests the wrapper-resident `context` command
mapping (resolves via praisonai.cli.commands.context, not the placeholder
local module).
Co-authored-by: MervinPraison <[email protected]>
Reuses the existing gated executor (PRAISONAI_ALLOW_SHELL env-gate, 30s
timeout, 100KB output cap) from custom_definitions.py via a new shared
run_shell_escape helper. Wires !cmd / !!cmd handling into the legacy REPL
and async TUI input paths before command/prompt dispatch, rendering output
inline without consuming a model turn. !!cmd attaches output as context for
the next turn.
Co-authored-by: MervinPraison <[email protected]>
Wire the existing PermissionMode.PLAN enforcement into the REPL /plan slash
command instead of a one-shot prompt template. /plan toggles read-only mode
(set_permission_mode(PLAN)), /plan off exits, /plan <task> plans under
enforcement; async TUI shows a [PLAN] status indicator. Also list plan,
accept-edits and bypass in the --approval help text.
Co-authored-by: MervinPraison <[email protected]>
Reuse the existing summariser purely to inform the user with a 'where were we' block, without mutating the transcript or triggering compaction. Adds a core build_recap() primitive, the /recap bot command, and a --recap flag on session show.
Co-authored-by: MervinPraison <[email protected]>
Re-scan skill sources into a running session without restart. reload()
diffs discovery against the current registry, swaps the index atomically
(index changes take effect on the next turn, not mid-turn), preserves
activation/telemetry for unchanged skills, and deactivates removed ones.
Adds a thin `skills reload` CLI parity command reporting +added/~changed/-removed.
Co-authored-by: MervinPraison <[email protected]>
Ask a side question in a parallel, throwaway context so the main task's
history and momentum stay untouched. Builds a fresh read-only agent, runs
the question in isolation, renders a distinct block, and never appends to
the main conversation history. Opt-in `/btw --keep` records a one-line note.
Co-authored-by: MervinPraison <[email protected]>
Address reviewer findings on $1..$n interpolation:
- preserve literal out-of-range dollar-number text (e.g. legacy "$100")
instead of erasing it
- tokenize with non-POSIX shlex so Windows paths (C:\Users\alice) survive
- merge $ARGUMENTS + positional substitution into a single pass so injected
tokens that look like $1/$ARGUMENTS are never re-scanned
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Bots were a command island: .praisonai/commands/*.md and plugin-bundle
commands surfaced only in the code REPL/TUI, never in Telegram/Slack/etc.
This adds a shared CustomCommandResolver in praisonai-bot that consumes the
existing custom_definitions loader/interpolator (via the sanctioned
_code_bridge seam — no cross-tier hard dependency, no reimplementation) with
a bot-appropriate safety posture:
- Shell (!`cmd`) substitution OFF by default regardless of frontmatter;
opt in per deployment via bots.commands.allow_shell. When off, live
segments render inert instead of executing or failing the command.
- Project-scope only by default (user ~/.praisonai excluded unless
include_user_scope), allow-listable via expose.
- Builtin precedence preserved; file commands merged into /help and the
command menu through the shared ChatCommandMixin (all adapters benefit).
- praisonai.bot_commands entry-point group lets installed packages add bot
commands without forking; file/project commands win on collision.
Telegram is wired as the reference adapter (catch-all dispatch renders the
command and submits it via the normal session.chat path). Adds config schema
(BotCommandsConfigSchema) and TDD tests covering project command execution,
shell-off-by-default, allow-list, user-scope exclusion, builtin precedence /
help merge, and entry-point resolution.
Co-authored-by: MervinPraison <[email protected]>
Defect 1 (config split-brain / fake CLI): add core get_plugin_registry()
returning real entry-point, registered and single-file plugins with
provenance and enabled state; the praisonai-code plugins CLI now lists/
inspects/enables against it and deletes the hardcoded four-plugin list.
enable/disable write the plugins.enabled allow-list in .praisonai/config.yaml
(the same file the runtime reads) via a new config-loader write path — no
more ~/.praisonai/config.json split-brain.
Defect 2 (trust-gate bypass): route project-local single-file plugin exec
in plugins/discovery.load_plugin through an opt-in gate
(PRAISONAI_ALLOW_PROJECT_PLUGINS / plugins.allow_project_plugins, default
false), mirroring the project-tools gate. A cloned repo's
.praisonai/plugins/*.py no longer runs until the user opts in. User-global
plugins remain trusted.
Defect 3 (leaky unload / unreachable reload): unload_plugin now unregisters
the tools a module harvested (tracked per module) instead of leaking them;
new `praisonai plugins reload` forces rediscovery + hook rewire so plugins
take effect without a restart.
Co-authored-by: MervinPraison <[email protected]>
Adds the minimal, self-contained slice of the plugin-bundles proposal that
strengthens existing local commands without a new subsystem: parse
argument-hint/model/tools (allowed-tools) frontmatter onto CustomCommand and
support shell-style positional $1..$n interpolation alongside $ARGUMENTS.
Positional tokens are split with shlex, injected before $ARGUMENTS (so user
input is never re-scanned), and escaped identically so they can never
introduce shell substitution. Legacy two-field command files are unchanged.
Co-authored-by: MervinPraison <[email protected]>
Address reviewer feedback on PR #3726 (Greptile/CodeRabbit):
- Never silently drop unsupported delivery targets: _can_deliver now always
dispatches a configured target so _dispatch_delivery raises and the caller
records delivery_error (matches PR's stated contract).
- Reuse chunk_message so Telegram/Slack/Discord size limits are not exceeded.
- Fall back to the gateway's persisted HomeChannelRegistry state file when
{PLATFORM}_HOME_CHANNEL env is unset, so gateway-registered home channels
resolve out of process.
- Preserve Discord threaded context via message_reference (parity with the
Telegram/Slack thread_id handling).
- Tests updated to lock the new contract (chunking, discord thread reference,
registry fallback, unsupported-target error recorded).
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
The bot layer's is_permanent_target_failure recognises a broader set of
whole-target death patterns (bot is not a member, need administrator
rights, channel_archived, ...) than core's deliberately narrow text
classifier. Previously classify_send_failure returned "unknown" for those,
so DeliveryRouter persisted a misleading "unknown: bot is not a member"
dead-target reason. Now, when core returns "unknown" but the broader
predicate confirms a permanent failure, the kind is upgraded to
"forbidden" so diagnostics read "forbidden: ..." — without over-classifying
genuine unknowns/transient errors (they stay unknown/transient, retryable).
Addresses Greptile P2 review feedback on PR #3725.
Co-authored-by: Mervin Praison <[email protected]>
Scheduled jobs could only deliver their `deliver:` target when a live,
in-process bot adapter was running in the same process as the scheduler.
Running `praisonai schedule tick` out of process (OS cron, CI, serverless,
scale-to-zero) computed the result but silently dropped delivery because the
executor's injected `delivery_handler` was None.
This adds a stateless, token-authenticated standalone sender per platform
(Telegram/Slack/Discord) in the praisonai-bot wrapper. When no live
`delivery_handler` is wired, the executor falls back to a single HTTP call
using the same `{PLATFORM}_BOT_TOKEN` env the gateway uses, with a bare
`deliver: telegram` home-channel target resolving its chat id from
`{PLATFORM}_HOME_CHANNEL`. Delivery failures are recorded as `delivery_error`
exactly as the live-adapter path does.
Wrapper-only change (no core edits, no new Agent params, zero new deps —
stdlib urllib). The live-adapter path is unchanged.
Co-authored-by: MervinPraison <[email protected]>
Add SendErrorKind enum + error_kind/retryable fields to core SendResult, a
pure status/text-aware classify_send_error fallback, and a classify_error seam
on BasePlatformAdapter. _send_with_retry now classifies failures once and
short-circuits provably-permanent kinds (forbidden/target_not_found/auth_fatal/
invalid_request) instead of blindly retrying. The bot-layer DeliveryRouter now
records dead targets by kind via the shared taxonomy, replacing English-only
substring reasons.
Co-authored-by: MervinPraison <[email protected]>
A fast-exiting command (e.g. sys.exit(3)) could be mis-reported as a
[exit 124] timeout on a loaded CI runner: after the reader thread hit
stdout EOF, proc.poll() could transiently return None before the exit
status was reaped, and the old logic inferred a timeout purely from
process state and SIGKILL'd the already-exiting process. Timeout is now
decided solely by the wall-clock deadline, with a brief grace wait to
reap fast exits, so the real exit code is preserved.
Co-authored-by: Mervin Praison <[email protected]>
- DeferredResolver.resolve now buffers values that arrive before the handle
is registered (early completion) instead of discarding them; the buffered
value is delivered when register()/register_if_absent() runs.
- Add register_if_absent() so the LLM run loop registers atomically and a
gateway-owned or concurrent-turn callback is never clobbered.
- LLM._register_deferred_if_any uses register_if_absent (no check-then-set race).
- cancel() clears any buffered early resolution.
- Add tests for early-resolution buffering, atomic no-clobber, cancel-clears-buffer.
Co-authored-by: Mervin Praison <[email protected]>
Move max_rpm<=0 validation ahead of the explicit-limiter check in both
Agent.__init__ and yaml_parser so ExecutionConfig(max_rpm=0, rate_limiter=obj)
raises ValueError as required by the behaviour matrix. Add regression test.
Co-authored-by: Mervin Praison <[email protected]>
Address reviewer feedback on PR #3720:
- Catch UnicodeDecodeError alongside JSONDecodeError so corrupt/binary
session files are quarantined instead of propagating an uncaught
decode error (both DefaultSessionStore and HierarchicalSessionStore).
- Make _quarantine_corrupt() pick a collision-free destination so two
corrupt files in the same millisecond are each preserved distinctly.
- Strengthen tests: assert exact byte-for-byte preservation, add an
invalid-UTF-8 regression, and add a HierarchicalSessionStore
corruption-recovery smoke test verifying the SESSION_PERSIST_FAILED
hook metadata.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
max_rpm was stored on the Agent but never wired to a runtime RateLimiter,
so ExecutionConfig(max_rpm=N) did not throttle requests. Now the Agent
auto-builds RateLimiter(requests_per_minute=N) when max_rpm is set and no
explicit rate_limiter is provided. Explicit rate_limiter still takes
precedence; max_rpm<=0 raises ValueError. YAML max_rpm now also wires a
live limiter.
Co-authored-by: Dhivya-Bharathy <[email protected]>
Add a lightweight DeferredResolver registry in tools/call_executor.py that
links a deferred handle_id to a resolution callback. The run loop now registers
any deferred tool result (via LLM._register_deferred_if_any) so that a later
resolve_deferred(handle_id, value) — fired on background completion — re-injects
the result instead of it being lost. Exposes register_deferred/resolve_deferred/
get_deferred_resolver/DeferredResolver from praisonaiagents.tools.
Co-authored-by: MervinPraison <[email protected]>
A malformed session JSON file was read as an empty session and then
overwritten by the next write, permanently destroying a user's history
with only a log line. Now the corrupt file is quarantined aside
(<file>.json.corrupt-<ts>) before starting fresh, preserving recoverable
bytes, and the event is surfaced via the existing SESSION_PERSIST_FAILED
hook rather than silently dropped. Applied to both the default and
hierarchical stores.
Co-authored-by: MervinPraison <[email protected]>
Extend the existing import-free header parser so a single-file plugin can
declare its capability surface (Channels, Provides/Tools, Config,
Auto Enable When Configured) statically. discover_plugins()/list_plugins
already surface this metadata to onboarding/doctor without importing the
plugin's runtime, enabling capability enumeration and config validation
before runtime loads.
Co-authored-by: MervinPraison <[email protected]>
Address reviewer findings on plugins/tools add commands:
- uv pip install now pins --python sys.executable so the package lands in the
interpreter running the CLI rather than an env uv auto-discovers.
- Add importlib.invalidate_caches() after install so a just-installed
distribution is importable before discovery runs.
- tools add now builds a fresh ToolResolver after install so instance-level
availability caches (e.g. praisonai_tools importability) are re-evaluated;
invalidate() only clears the per-name resolve cache.
- Add --global option to both commands (uv --system / plain pip) for installing
into the ambient system environment.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Concurrent callers of discover_plugins() could both pass the _discovered
check before either set it, double-loading entry points and double-counting.
Wrap the first scan in the re-entrant registry lock so concurrent callers
block, then observe _discovered and return 0 without rescanning. The get()
path already holds the RLock, so re-acquisition is deadlock-safe.
Co-authored-by: Mervin Praison <[email protected]>
Adds one-command install-and-verify for plugin/tool packages from the CLI.
Installs via the resolved installer (uv pip or python -m pip), triggers
discovery, and reports exactly which plugins/tools registered (name, type,
hooks/source) or exits non-zero with a clear message if nothing was
discovered. Supports --dry-run (verify only) and --upgrade.
Co-authored-by: MervinPraison <[email protected]>
The core tool registry now discovers the documented canonical
`praisonai.tools` entry-point group in addition to the legacy
`praisonaiagents.tools` and `praisonai.tool_sources` groups (kept as
deprecated aliases with a one-cycle DeprecationWarning). Because the CLI,
YAML and Python surfaces all defer to the core registry, an installed
third-party tool package is now resolvable by name across all three
surfaces automatically. The canonical group wins on a name collision and
the lazy, cached entry-point loading pattern is preserved.
Co-authored-by: MervinPraison <[email protected]>
- run_sync_or_offload now holds the in-flight bridge future and cancels it
when the worker join times out, so stuck async work stops instead of
lingering on the bridge loop (CodeRabbit).
- A2U _MAX_SUBS/_QUEUE_MAX now fall back to defaults on non-positive or
unparsable env values, preventing asyncio.Queue(maxsize<=0) from silently
becoming unbounded (CodeRabbit).
Co-authored-by: Mervin Praison <[email protected]>
- glob/grep: PRAISONAI_WORKSPACE confinement is now opt-in; without it set,
no sandbox is enforced (cwd was never a real boundary and enforcing it
broke all callers passing an explicit directory + 15 CI tests).
- a2u_server: fail-closed guard now reads the launcher-set
PRAISONAI_CALL_BIND_HOST (with PRAISONAI_A2U_BIND_HOST override) and
rejects unauthenticated non-loopback/unknown binds, matching the existing
call-server security contract (fixes Greptile P1).
Co-authored-by: Mervin Praison <[email protected]>
- _async_bridge: add awaitable arun_sync_or_offload() sibling and bound the
run_sync_or_offload() worker join with a timeout so a stuck coroutine can no
longer pin the caller (and, when the caller is the loop thread, the loop)
indefinitely.
- tools (glob/grep/multiedit): confine to PRAISONAI_WORKSPACE root, filter
security.is_protected paths from results/reads, refuse edits to protected
files, and cap per-line length in grep to bound ReDoS cost.
- endpoints/a2u_server: refuse fail-open auth on non-loopback binds, cap
subscriptions, and bound per-subscription queues with drop-on-full so a
slow/stalled consumer or subscription flood cannot OOM the process.
Co-authored-by: MervinPraison <[email protected]>
Address two P1 review findings on PR #3708:
1. Racy output-mode registration: concurrently-constructed Agents could
both observe "no mode enabled" and clobber each other's process-global
display callbacks. Wrap the check-and-enable in a module-level lock
(_output_mode_lock); the common silent path stays lock-free.
2. MCP "Error: " prefix misclassification: a successful MCP tool may
legitimately return text starting with "Error: ", which the prefix-only
normalizer converted into a retryable error dict. Narrow normalization
to MCP's unambiguous internal timeout signatures only, preserving valid
tool output.
Co-authored-by: Mervin Praison <[email protected]>
- __init__.py: point HarnessProfile/resolve_harness/register_profile lazy map at
praisonaiagents.model_harness.profiles (real submodule) so top-level access no
longer re-enters the module package __getattr__ under the non-reentrant lock.
- _lazy.py: make _cache_lock an RLock as defense-in-depth so any future mapping
of this shape degrades to redundant work instead of a self-deadlock.
- agent/agent.py: make process-global output modes (editor/trace/status)
mutually exclusive — first mode wins, so a second Agent with a different mode
cannot clobber another agent's callbacks (wrong formatting/redaction routing).
- agent/tool_execution.py: normalize MCP "Error: ..." string failures into the
native {"error": ..., "timeout": ...} dict shape so MCP tool failures engage
retry, doom-loop detection and circuit-breaking like native tool failures.
Co-authored-by: MervinPraison <[email protected]>
- Reset task status/result/retry_count/variables before each batch item so
run_task/arun_task actually re-execute (previously items 2..N returned the
first item's stale "completed" result).
- Snapshot task run state before the batch and restore it in finally, keeping
the public Task API backward-compatible.
- Validate each input is a dict: fail_fast raises ValueError, continue records
a per-item error instead of crashing.
- Add regression tests: two-item lifecycle reset, invalid-input handling.
Co-authored-by: Mervin Praison <[email protected]>
Match the injected manager_task by its actual task id (manager_task_id)
instead of its name in both sync and async hierarchical paths. A legitimate
user task named "manager_task" is no longer incorrectly rejected/hidden.
Also state 0-based ids in the manager schema and add regression coverage.
Co-authored-by: Mervin Praison <[email protected]>
The hierarchical manager could delegate to the injected manager_task because
validation only checked membership in self.tasks (which includes it) and the
schema field described task_id as "1-based" while runtime IDs are 0-based. As a
result the manager would self-select task_id=1 and the real user task never ran.
- manager_schema.py: task_id description now references the exact 0-based ids
and forbids selecting manager_task.
- process.py (sync + async): reject unknown ids AND manager_task; re-prompt with
only delegable (non-manager) task ids in the error context.
Co-authored-by: Dhivya-Bharathy <[email protected]>
When a Task sets output_pydantic (or output_json) but the agent returns
freeform prose, the task no longer silently succeeds with pydantic=None.
- default_completion_checker: structured output that was requested but not
produced is now treated as NOT complete, driving the existing retry loop
and leaving the task "failed" after retries instead of succeeding on prose.
- _process_task_result: use model_validate, include the schema name and the
underlying error in the warning, and return TaskResult(success=False) with
a clear error when structured output could not be produced. Raw output is
preserved for debugging.
Co-authored-by: Dhivya-Bharathy <[email protected]>
Adds a lightweight map-over-inputs primitive that runs the same team once
per input dict, reusing the existing {{key}} `variables` interpolation
(per-run task copy => template immutability + isolation). Returns an
aggregate dict with per-item outcomes, ordered results, continue/fail_fast
error policy, and a batch token-usage rollup. No new heavy classes/modules.
Co-authored-by: Dhivya-Bharathy <[email protected]>
FunctionTool.get_schema was a near-verbatim copy of the inherited
BaseTool.get_schema producing byte-identical output. Since __init__
always sets _schema_override and parameters, the parent's hasattr guard
is always satisfied, making the override dead duplication. Removed it so
FunctionTool inherits BaseTool.get_schema, and dropped the now-unused
copy import.
Co-authored-by: MervinPraison <[email protected]>
Replace triplicated eight-field mapping in Agent.context_manager with a
single _manager_config_from_context_config helper. Pure DRY clean-up,
identical output for all input types (ContextConfig, preset string, dict).
Co-authored-by: MervinPraison <[email protected]>
- await awaitable on_event results so async callbacks actually run
- when a progress sink is passed, route through subprocess so SDK mode
never silently drops progress events
- replace vacuous stream test assertion with exact event equality
- add async-callback coverage
Co-authored-by: Mervin Praison <[email protected]>
ClaudeCodeIntegration.stream() built `claude -p --output-format stream-json`
without --verbose, which the CLI rejects, so it could never run -- and nothing
added --include-partial-messages, so no token/tool-use deltas were emitted.
_build_command now forces both flags for stream-json.
execute() gains an optional on_event/on_progress callback: when supplied, the
run is streamed and every parsed event (system/init, tool-use, text deltas,
result) is forwarded live so a caller or UI can show progress, while the final
result text is still returned. A failing progress sink never breaks the run.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Prefer as_dict(), fall back to dataclasses.asdict for a bare
GatewayStatus-shaped snapshot, and use json.dumps(default=str) so a
partially-populated or non-standard snapshot never dead-ends.
Co-authored-by: Mervin Praison <[email protected]>
Adds a lightweight, read-only introspection tool so an agent can inspect
and report its own live gateway state (run status, active-session
inventory, delivery/DLQ backlog, degraded owners) from inside a turn.
Mirrors the send_message / OutboundMessengerProtocol pattern: core ships
GatewayStatusProtocol + GatewayStatus snapshot shape and the built-in
gateway_status tool, which resolves the live source from the per-turn
session context (register_gateway_status). When no gateway is running,
the tool is simply not offered and fails cleanly. No Agent-class changes,
no new heavy dependencies; the concrete live binding belongs in the
praisonai-bot wrapper.
Co-authored-by: MervinPraison <[email protected]>
A non-numeric persisted command_timeout (e.g. a hand-edited "five") raised
ValueError at float(...) outside the command error handler, escaping the ticker
loop and breaking every other job. Normalise defensively in the executor:
non-numeric, non-positive, or non-finite values fall back to 60s and the job
still runs. Adds a regression test.
Co-authored-by: Mervin Praison <[email protected]>
- Fix CI: test_command_action.py used the deprecated
asyncio.get_event_loop().run_until_complete() which raises RuntimeError
on Python 3.10+ with no current loop. Switch to asyncio.run() to match
the sibling test_condition_gate.py / test_run_policy.py convention.
- Address Greptile P1 (memory): stream and cap command output as it is read
(bounded to _MAX_COMMAND_OUTPUT_CHARS) instead of buffering the full
stream via communicate(), so a chatty/runaway command in the long-running
gateway cannot exhaust memory. Timeout still kills the process group.
Co-authored-by: Mervin Praison <[email protected]>
Adds a model-free command action to scheduled jobs: a job carrying a
`command` runs that shell command on its schedule and delivers stdout
verbatim to its DeliveryTarget — no agent resolved, no model turn taken.
- core: `command`/`command_timeout` fields on `ScheduleJob` with
backward-compatible serialisation (persisted only when set).
- wrapper (praisonai-bot): executor runs the command off the event loop,
bounded by `command_timeout` and killed with its process group on POSIX
(mirrors ShellConditionGate); non-zero exit surfaces exit code + output;
output is bounded before verbatim delivery via the existing path.
- CLI: `praisonai schedule add --command/--script [--command-timeout]`.
Additive and backward-compatible: agent-driven jobs are untouched.
Co-authored-by: MervinPraison <[email protected]>
Non-numeric, non-finite (NaN/inf), or non-positive timeout_s values now
fall back to the 120s default and are clamped to a 3600s upper bound so a
model-controlled timeout can never undermine the tool's bounded-wait
"never a silent hang" guarantee (Greptile review, PR #3691).
Co-authored-by: Mervin Praison <[email protected]>
Adds an agent-callable request/reply tool that sends a prompt to a symbolic
target and awaits that target's next correlated reply, returning it into the
agent's turn. Unlike fire-and-deliver send_message, it always resolves to a
typed outcome (reply | timeout | undelivered | no_route) so a path never hangs
silently.
Core-only surface mirroring the existing send_message pattern:
- ConversationReply + ConversationReplyStatus + ConversationRequestProtocol in
gateway/protocols.py (protocol seam bound by the running gateway/bot).
- register/get/clear_conversation_requester context slot in session/context.py.
- ask_conversation tool in messaging_tools.py, reusing the send-policy guard
and cross-thread loop scheduling used by send_message.
Co-authored-by: MervinPraison <[email protected]>
Close two gaps where the read-only --plan contract silently downgraded:
- run --plan --profile: thread the resolved approval backend
(PermissionMode.PLAN) into _run_prompt_profiled and
_run_from_file_profiled so profiled runs stay permission-gated.
- code --plan REPL: the interactive worker's _build_agent now unpacks
args.agent_approval into Agent(**agent_extra_kwargs), matching the
single-prompt path, so REPL sessions deny mutating tools instead of
falling back to the legacy approval prompt.
Adds regression tests for both paths.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
- run.py: move config + recorded-session model resolution ahead of the keyless
local-first credential gate so a resumed session on a reachable local endpoint
is no longer silently switched to the local model (Greptile/CodeRabbit).
- store.py: SessionData.from_dict now folds legacy top-level model/llm (mirrored
by to_dict) back into metadata so historical sessions recover their recorded
model on resume (CodeRabbit backward-compat).
- tests: add latest-turn-wins and legacy top-level normalization coverage.
Skipped async get_session_model accessor: no async consumer exists; only the
synchronous CLI resume path calls it, so it would be dead API surface (AGENTS.md).
Co-authored-by: Mervin Praison <[email protected]>
Add a first-class `--plan` boolean flag to `praisonai run` and `praisonai code`
that selects the existing read-only planning permission mode
(PermissionMode.PLAN). It maps onto the same approval plumbing as
`--approval plan` rather than a bespoke deny-set, so the agent may
explore/read/search while every mutating tool is denied.
Wrapper-only change: core (Agent(approval="plan")) and YAML (mode: plan)
already thread onto PermissionMode.PLAN, so no new enforcement logic or Agent
params are introduced. Contradictory permission flags fail closed.
Co-authored-by: MervinPraison <[email protected]>
Resume restored conversation history but not the model the session was
running: with no explicit --model, it re-resolved the current default, so a
change to the user's default between runs silently switched the model
mid-conversation. Now --continue/--session restore the recorded model
(explicit --model still wins and updates it going forward).
- core: add DefaultSessionStore.get_session_model() reading the session-level
metadata model, falling back to the latest turn's recorded model.
- wrapper: resolve and restore the recorded model in run_main before dispatch
when no model was chosen, with an info line noting the restored model.
Co-authored-by: MervinPraison <[email protected]>
install_hook()/_resolve_backend() were never called by any runtime path
(Greptile P1). Approval is already enforced via the adapter's
_resolve_agent_approval() -> Agent(approval=ApprovalConfig(...)) core gate,
so these methods were a redundant, untested, second before_tool enforcement
that would double-gate. Removing per AGENTS.md (no dead code with no live
consumer / duplicate behaviour). Tests unchanged and green.
Co-authored-by: Mervin Praison <[email protected]>
- praisonai_adapter: resolve YAML/CLI handoff {to:[roles],...} into core
Agent.handoffs after all agents exist, mapping timeout/max_depth/
max_concurrent/detect_cycles onto HandoffConfig. The --handoff family is
no longer silently dropped.
- _approval_spec.install_hook: replace the two TODO no-op stubs with real
backend dispatch (prompt via resolved backend, deny on rejection, fail
safe when no backend is available).
- auto.py: drop the duplicated LEGACY TOOLS block on the resolver-available
path (it repeated the installed tool list for zero information gain).
Co-authored-by: MervinPraison <[email protected]>
PR #3681 fixed the Windows cp1252 UnicodeEncodeError only in docker.py, but
the same bare-emoji print() crash remained in the other Python-API deploy
paths that also bypass the CLI's _configure_stdio() bootstrap:
- api.py foreground serve (🚀📁🔗💬)
- providers/aws.py, azure.py, gcp.py deploy/destroy (📦📝🚀🛑🗑️🔧🌍)
Replace them with ASCII [api]/[aws]/[azure]/[gcp] prefixes. cli/features
uses Rich console.print (encoding-safe) and is left unchanged.
Co-authored-by: Mervin Praison <[email protected]>
The praisonai-deploy Python API path (Deploy.deploy) bypasses the CLI's
UTF-8 stdio bootstrap from #3605, so bare emoji print() lines in docker.py
raised UnicodeEncodeError on default Windows cp1252 consoles and were masked
as "Docker build failed". Replace the six emoji status prints with portable
ASCII "[docker]" prefixes so both CLI and Python API paths are encoding-safe.
Co-authored-by: Dhivya-Bharathy <[email protected]>
Addresses the two P1 findings from Greptile on PR #3679:
1. Secret-context capture reuse: definition_hash excluded env values, so a
capture whose setup baked env-derived state into the filesystem could be
reused across provisions with the same env names but different values. Add
capture_key() which binds env *values* and use it as the docker image tag /
reuse key; keep the value-free definition_hash only as a non-sensitive
display label in the registry.
2. Concurrent registry lost-update: registry.json read-modify-write was
unlocked. Add a best-effort fcntl file lock (no-op on non-POSIX) and route
capture/touch/prune through an atomic _update_registry() so concurrent
writers don't drop each other's entries. Image deletion in prune moved
outside the critical section.
Tests: +4 core capture_key tests, +3 docker tests (secret isolation, concurrent
preservation, definition label). 28 core + 10 docker tests green.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Amortise environment provisioning: after packages + setup: run once, the
docker backend commits the prepared container to a local image keyed by a
stable definition hash, and subsequent provisions of the same definition
start from that capture — skipping pull + install + setup.
Core (praisonaiagents):
- definition_hash(config): stable sha256 over image/packages/setup/env-names/
resources (env values excluded so secrets never enter the key).
- SupportsCapture optional protocol (default: backends without it stay ephemeral).
- Loader accepts refresh: and capture: keys (carried in metadata; no new
ComputeConfig field without a typed consumer).
Docker compute (praisonai wrapper):
- capture()/has_capture() via docker commit -> praisonai-env:{hash12}.
- Reuse-on-match; edited definition -> new hash -> full rebuild + new capture.
- refresh: runs only on capture-based starts (cheap incremental step).
- Registry ~/.praisonai/environments/registry.json + list_captures()/
prune_captures() age-based GC.
- Failed commit degrades to today's ephemeral behaviour with a log line.
managed_local.provision_compute carries capture metadata (refresh/capture)
from the definition to the backend.
Tests: definition-hash stable/sensitive + env-value exclusion; docker
capture+reuse skips setup; definition change invalidates; refresh on hit
only; capture failure degrades ephemeral; non-capturing no-op; registry GC.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
On a second .start()/.run() of the same AgentTeam, the prior run's
synthetic manager_task remains last in self.tasks, so the return-task
snapshot picked it and returned the previous Manager output instead of
the final user-task result. _last_hierarchical_task_id now walks tasks
in reverse and skips any manager_task, selecting the real final task.
Addresses Greptile P1 review on agents.py.
Co-authored-by: Mervin Praison <[email protected]>
- process="hierarchical": track the last real (user-supplied) task id before
the synthetic manager_task is injected, so .start()/.astart()/.run() return
the final delegated task's result instead of the Manager's own output.
- FileMemory/LearnManager: re-read the on-disk state before each write so two
instances sharing the same user_id no longer clobber each other's entries
(last-writer-wins fixed for short/long-term, entities, auto-promotion, learn
stores).
- Approval registry: scope the "already approved" cache key to the requesting
agent so one agent's approval can't silently pre-authorize an identical call
from a different, stricter agent in the same context.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: MervinPraison <[email protected]>
Multi-agent gap review of the release pipeline surfaced 17 findings across
the gate, the workflow, and the bump script. This fixes the confirmed ones.
Gate (release-gate.js + nightly-release-gate.yml):
- A pypi-release run stuck in `waiting` (environment approval) blocked all
auto-releases with no age limit -- GitHub only auto-fails unapproved
deployments after 30 days, and exactly this stalled releases for 16h this
week. Waiting runs older than 6h are now ignored (with a warning linking
the run); in_progress/queued runs still block unconditionally, and the
stuck run is never cancelled -- if later approved, the concurrency group
serializes it and pypi_exists no-ops anything already published.
- PACKAGE_PATHS covered 3 of 9 published packages, so a change landing only
in e.g. praisonai-bot was never auto-released. Now lists all nine.
- The 3-day dedupe counted any successful run -- including dry_run=true,
which publishes nothing -- blocking real releases for 3 days after a dry
run. Dedupe now keys on v* GitHub releases, which only real releases create.
- The nightly path required last-green-Core-Tests SHA == HEAD exactly; a
docs-only or [skip ci] commit at the tip (the release safety-net commit
itself!) stalled the cron path indefinitely. A green ancestor with no
package-path changes since is now accepted.
- Replaced the dead 'Bump praisonai' workflow_run filter (those commits no
longer exist) with 'chore(release):', and wired the previously never-run
release-gate selftest into the gate as a step.
Workflow (pypi-release.yml):
- New `only` input: a dedicated single-package release is one dropdown pick
instead of eight skip flags. A "Resolve effective skips" step is the single
source of truth; the 19 publish/wait conditions are untouched (they gate
via the seeded pypi_exists outputs).
- PyPI existence probes now distinguish 404 (publish) from outages (fail the
run before anything is published) instead of treating any error as "not
published" and then dying on duplicate upload midway.
- The token-refresh step is continue-on-error and its consumer only swaps
tokens on success -- a failed re-mint previously overwrote a possibly-valid
token with an empty string, guaranteeing the safety net could not push.
- The safety net now (a) reverts uncommitted wrapper-side rewrites when the
wrapper release didn't complete, so a wrapper version that never reached
PyPI is not committed and then skipped past forever, and (b) retries
rebase+push 3x preferring the version bumps on conflict -- losing that
commit deadlocks future auto-releases ("already published" forever).
- "Verify PyPI versions" actually probes PyPI now; it previously echoed
checkmarks derived from input flags for publishes that may never have run.
- Dedicated releases get per-package tags (praisonai-mcp-v0.0.12) since the
wrapper's "Release v..." tag doesn't exist on that path; only packages
confirmed on PyPI are tagged.
- uv publish --check-url makes re-uploads idempotent (partial-upload
recovery); dry-run summary now includes sandbox/deploy and the effective
skip plan.
Bump script (bump_and_release.py):
- A conflicted `git pull --rebase` previously printed "trying to continue"
and proceeded from a mid-rebase detached HEAD -- tagging the wrong commit
and cascading conflict markers into the safety net. It now aborts, retries
preferring the release commit, and exits before pushing if that fails.
- Commit failures are no longer swallowed (only "nothing to commit" is
tolerated), so the tag can't silently point at the previous HEAD.
- `gh release create` is idempotent (view-then-create), unblocking reruns
after a transient failure between push and wrapper publish.
- Tag push is scoped to the release's own tag; `git push --tags -f` could
force-move historical tags.
Verified: node selftest (10 checks incl. new stale-waiting/dry-run-dedupe
cases), py_compile, YAML parse, actionlint (same 3 pre-existing SC2129
style warnings as main, nothing new), and the rebase-conflict retry logic
exercised against a scratch remote (bump wins, upstream commit preserved).
Co-Authored-By: Claude Fable 5 (1M context) <[email protected]>
Two existing tests asserted PraisonAIAgents raises ImportError (a v4
"removed" decision). Issue #3674 (owner) explicitly restores it as a
root-level silent alias for AgentTeam, which PR #3675 implements. Update
those tests to assert the restored back-compat contract instead.
Co-authored-by: Mervin Praison <[email protected]>
PraisonAIAgents was exported from praisonaiagents.agents but omitted from
the root lazy-import map and __all__, so `from praisonaiagents import
PraisonAIAgents` raised ImportError. Added it alongside its siblings and a
regression test covering all back-compat aliases.
Co-authored-by: MervinPraison <[email protected]>
Add functools.lru_cache to the pure model-capability predicates so the
same (model_name -> bool) result is computed once per process instead of
on every request/turn. Behaviour is unchanged: guards, litellm-None
fallbacks and except paths are all preserved, and keys are hashable
strings.
Co-authored-by: MervinPraison <[email protected]>
Addresses the three Greptile P1 findings on PR #3671:
- Wire load_environment_definition() into provision_compute() as an opt-in
baseline (explicit kwargs/instance config still win) so a committed
.praisonai/environment.yaml is actually applied.
- Validate nested config shapes in the loader so malformed values raise a
contextual ValueError (with file path) instead of incidental
AttributeError/TypeError.
- Tear down the just-started Docker container if package install or setup
fails, so a failed provision no longer leaks an unreachable container +
registry entry.
Co-authored-by: Mervin Praison <[email protected]>
Add the missing repo-committed environment definition joint: a lightweight
loader that maps .praisonai/environment.yaml onto the existing ComputeConfig
schema, plus the one new field (setup) with a live consumer.
- Core (praisonaiagents): ComputeConfig.setup: List[str]; load_environment_definition()
and find_environment_definition() (walks up like config discovery). YAML is
lazy-imported; unknown top-level keys raise ValueError with the file path.
network/backend preferences carried in existing typed fields (no new surface).
- Live consumer: docker compute provider runs setup commands once post-provision,
streamed to logs, with failures surfaced (raised) rather than swallowed.
- No file -> byte-identical behaviour (loader returns None; callers keep defaults).
Deliberately minimal per AGENTS.md (lightweight & powerful): no new CLI group,
no dispatcher rewrite, no SandboxConfig bridge, no RuntimeConfig remap.
Co-authored-by: MervinPraison <[email protected]>
Fix two valid reviewer (Greptile) findings in the research-writer example:
- The roles-file loader (praisonai_adapter._build_agents_and_tasks) sequences
tasks by declaration order and does not consume the top-level dependencies:
block; annotate agents.yaml and README to state this accurately.
- praisonai.run() is the Python API equivalent of the positional
`praisonai <agent_file>` CLI form (there is no `praisonai run <yaml>` CLI
subcommand for roles files); correct the README prose accordingly.
Docs/example only — no core SDK changes.
Co-authored-by: Mervin Praison <[email protected]>
The example passed topic via team.start(inputs=...), but AgentTeam.start
swallows **kwargs and never consumes `inputs`; interpolation reads only
task/team `variables` and replaces double-brace {{key}} placeholders.
Switch to AgentTeam(variables=...) with {{topic}} so the example actually
substitutes the topic instead of emitting a literal placeholder.
Co-authored-by: Mervin Praison <[email protected]>
Documents that Task.on_task_complete 2-param callbacks receive
(task_output, metadata) — NOT (task, task_output) like AgentTeam's hook.
Adds a copy-pasteable sequential human-review example and a regression
guard test. No new API surface (lightweight per AGENTS.md).
Co-authored-by: Dhivya-Bharathy <[email protected]>
Adds examples/yaml/teams/research-writer/ with a runnable single-file
agents.yaml (roles + nested tasks + dependencies) and README documenting
the existing praisonai run / AgentsGenerator loader path. No new core
modules or wrapper exports — examples/docs only, per the narrow scope.
Co-authored-by: Dhivya-Bharathy <[email protected]>
Addresses Greptile finding: the lint guard scanned examples/ + internal
CLI but omitted src/praisonai-agents/tests, where this PR migrated an
AgentTeam(...) call (crewai-tools-example.py). Reintroducing verbose=
there would previously go undetected.
Adds a scoped _SCAN_FILES list covering the specific migrated file rather
than sweeping the entire tests tree (which holds ~40 unrelated legacy
scripts still using verbose=/max_iter= that are out of scope for this PR).
Co-authored-by: Mervin Praison <[email protected]>
- flat layout (drop src/) so `python -m <slug>.main` works from the
project root without an install step
- switch YAML placeholders to double-brace {{topic}} and pass
variables= to AgentTeam (start(inputs=) was silently dropped)
- reject Python keywords in slugify to avoid invalid imports
- update pyproject packages.find to flat include and extend tests
Co-authored-by: Mervin Praison <[email protected]>
The delegate_task tool documented a `timeout` param but discarded it,
letting delegated work block past its deadline (Greptile P1). Run the
synchronous subagent spawn on a bounded ThreadPoolExecutor and return a
structured timeout failure on expiry. timeout<=0 disables the bound.
No new params/exports; reuses existing create_subagent_tool runtime.
Co-authored-by: Mervin Praison <[email protected]>
The rich.table import was executed before the console None-check, so an
ImportError bubbled to the outer handler and showed the static model list
instead of the real catalogue. Guard the import so the plain-text table
renders the live catalogue when Rich is absent (greptile P2).
Co-authored-by: Mervin Praison <[email protected]>
- Reject empty/whitespace query in forget() to prevent broad LIKE '%%'
deletion of unrelated short-term and long-term records (Greptile P1)
- Scope query-based forget() to long_term to mirror remember()/recall()
- Fix test fixture to use sqlite provider (was 'none' -> in_memory)
- Add empty-query guard test
Co-authored-by: Mervin Praison <[email protected]>
AgentTeam.__init__ accepts neither verbose= nor **kwargs, so passing
verbose= raised TypeError at construction. Verbosity is controlled via the
consolidated output= param (silent/minimal/verbose) and iteration limits via
execution=. Migrated all internal CLI, template generators, and examples off
the removed verbose=/max_iter=/session= kwargs, and added an AST lint guard
that scans examples + internal CLI for invalid AgentTeam kwargs to prevent
regression.
Co-authored-by: Dhivya-Bharathy <[email protected]>
Replace the always-failing stub in delegate_task with a call into the
existing create_subagent_tool runtime. A lightweight sub-Agent is spawned
(role derived from agent_type) to execute the task, preserving the JSON
response schema and the @require_approval(risk_level="medium") gate.
Co-authored-by: Dhivya-Bharathy <[email protected]>
praisonai models list crashed on Windows cp1252 consoles when Rich rendered
emoji (U+1F527 wrench) in the Capabilities column. Added stdout_supports_unicode()
helper and _capabilities_label() ASCII fallback, routed the table through the
encoding-aware output console, and applied the same guard to models describe/validate.
Co-authored-by: Dhivya-Bharathy <[email protected]>
Adds thin, intuitive aliases over existing long-term store/search/delete
methods and makes Memory(config=...) optional so Memory() works standalone.
No new modules, dataclasses, exports, or backend changes; fully backward
compatible.
Co-authored-by: Dhivya-Bharathy <[email protected]>
Define ManagerInstructions once at module level with extra="forbid" so the
generated JSON schema includes additionalProperties: false, which OpenAI's
strict structured-output API requires. Removes two duplicate inline class
definitions in process.py and adds a regression test.
Co-authored-by: Dhivya-Bharathy <[email protected]>
resolve_output and resolve_execution live in param_resolver.py, not
feature_configs.py. Split the test imports so resolver functions come
from param_resolver while config dataclasses stay in feature_configs.
Unblocks the entire praisonai-agents unit suite collection.
Co-authored-by: Dhivya-Bharathy <[email protected]>
Address reviewer feedback on the local-first session mirror:
- set_chat_history now mirrors the replaced transcript (Session save_state
and bot session manager persist via this path, not add_message, so those
histories were never reaching the mirror). Records carry stable ids so a
re-append is idempotent last-writer per id — the append-only shape the
mirror expects.
- _SessionMirrorWriter.enqueue drops-to-log after close() instead of
silently queuing records with no running consumer.
- The mirror writer thread is now started AFTER os.makedirs so a failed
construction on an unusable session dir never leaks an idle daemon thread.
Adds tests for all three; full session suite (123 tests) green.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Adds the core, unblocked half of session sync: a tiny SessionMirrorProtocol
(append/load/list_sessions) plus an optional mirror= on DefaultSessionStore
that appends persisted records to the mirror on a bounded background thread.
Local-first: the local write always happens first and a mirror outage never
blocks, fails, or corrupts a turn. Zero overhead / no thread when unset.
Heavy backend adapters + `session sync` CLI are deferred to the wrapper and
depend on the store-unification sibling (#3645, not yet merged).
Co-authored-by: MervinPraison <[email protected]>
- protocols.py: LocalTurnLock.release() now identity-checked via a per-key
holder map so a stale/duplicate token can never release a waiter's lease
(fixes concurrent-turns-on-one-session race). Add type-only asyncio import
under TYPE_CHECKING to resolve the 'asyncio.Lock' annotations (F821).
- config.py: reject NaN ttl (not self.ttl > 0); from_dict tolerates explicit
YAML nulls for backend/ttl via the 'or default' idiom.
- tests: add stale-token release regression, NaN-ttl rejection, null-tolerant
from_dict; make same/distinct-session concurrency tests deterministic with
asyncio.Event barriers (3.10-safe, no Barrier).
Co-authored-by: Mervin Praison <[email protected]>
UnifiedSessionStore hardcoded ~/.praison/sessions/ while every other CLI
surface reads/writes the canonical ~/.praisonai/sessions/ root via
paths.get_sessions_dir(). This made `praisonai code` conversations live in
a separate directory, invisible to `session list/show/export` and the
dashboard.
Resolve the default sessions directory from the canonical paths helper
(with a safe fallback to the legacy path if the helper is unavailable), so
code sessions land under the shared root. Explicit session_dir overrides
and the backward-compatible DEFAULT_SESSION_DIR attribute are preserved.
Co-authored-by: MervinPraison <[email protected]>
Turn serialisation is enforced by an in-process asyncio.Lock (LockMap) that
cannot see across processes/hosts, so a horizontally-scaled gateway
(replicas > 1) runs concurrent turns on one session — corrupting the shared
transcript, tripping provider strict-alternation, duplicating replies and
double-billing.
Add a pure, dependency-free TurnLockProtocol + TurnLeaseToken and a zero-cost
in-process LocalTurnLock default to core gateway protocols, plus a TurnLockConfig
selector wired into GatewayConfig (Python + YAML). The "local" default reproduces
today's asyncio.Lock behaviour byte-for-byte (single-replica unchanged, no new
dependency); "redis" is the seam for a distributed lease implementation in the
wrapper/bot package (reusing RedisConfig + the scheduler's owner+TTL pattern).
Mirrors how every other gateway robustness knob (drain, admission, rate-limit,
liveness, dead-letter) is a swappable pure protocol in core.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Address reviewer findings (CodeRabbit, Greptile) on PR #3641:
- Plumb the gateway's real bind host into enable_shell_tools via a new
keyword-only gateway_bind_host param. Production callers construct a
per-channel BotConfig that never carried the bind host, so an externally
bound (0.0.0.0) gateway was invisible to the exposure downgrade. The
gateway now passes self.config.bind_host / self._host explicitly.
- Treat every configured group_policy (including command_only) as a
multi-user surface: a group member's command can reach execute_command,
so blanket auto-approve must downgrade to approval.
- Call callable host accessors (e.g. GatewayServer.host) before
stringifying so is_loopback classifies loopback deployments correctly.
- Add tests for command_only downgrade, explicit external/loopback
gateway_bind_host, and callable host accessor resolution.
Co-authored-by: Mervin Praison <[email protected]>
- Keep DegradedCapabilityProtocol unchanged (mark/clear/list_degraded); move
find() onto new DegradedCapabilityLookupProtocol so existing external
registries retain structural isinstance conformance.
- Module guard falls back to list_degraded() when a legacy registry lacks both
the guard and find(), failing-closed instead of raising AttributeError.
- Export DegradedCapabilityLookupProtocol.
- Fix Ruff B015 pointless-comparison in no-op guard tests (assert ... is None).
- Add legacy-registry coverage for base/lookup protocol conformance and the
list_degraded() fallback.
Co-authored-by: Mervin Praison <[email protected]>
Complete the degraded-owner contract's read side (Issue #3518) by adding
the core fail-closed primitive so a request targeting a degraded owner
short-circuits to a typed, redacted "unavailable — next action" outcome
instead of proceeding and failing silently.
Adds to praisonaiagents/gateway/degraded_state.py:
- OwnerUnavailable exception carrying only DegradedOwner's redacted fields
- DegradedCapabilityRegistry.find() point read
- DegradedCapabilityRegistry.assert_owner_available() guard
- module-level assert_owner_available() no-op-safe for an optional registry
Exports both from praisonaiagents.gateway and covers with unit tests.
Co-authored-by: Mervin Praison <[email protected]>
Blanket auto_approve_shell now degrades to explicit approval when the
gateway binds to a non-loopback interface or the channel is a
multi-user/group surface, mirroring the auth token's assert_external_bind_safe
posture. Operators can opt back into blanket auto-approve with
auto_approve_shell_acknowledge_exposed: true.
Co-authored-by: Mervin Praison <[email protected]>
Address reviewer findings on PR #3638:
- Loop detection & loop guard now observe the FULL tool result; the compact
model-facing view is swapped in only right before return, so a stable
compact summary no longer masks progressing polls as no-progress loops.
- The compact override now passes through wrap_if_external, so external/
untrusted model_output cannot bypass the prompt-injection fence.
- Model-facing annotations (AFTER_TOOL additional_context, loop-guard notices)
are re-applied to the compact view so they always reach the model.
- Unified the to_model_output hook contract (receives the output value in both
safe_run and the executor) and guarded it in safe_run so a failing builder
degrades to full output instead of corrupting a successful result.
- Corrected resolve_model_output docstring to match implementation.
Co-authored-by: Mervin Praison <[email protected]>
Address reviewer findings on PR #3637:
- save_session_state now returns the SessionStore bool result so a silent
False (serialisation/disk failure) no longer reports success to callers.
- Tests use pytest tmp_path (no leaked temp dirs) and verify cross-instance
durability via a separate DefaultSessionStore over the same directory.
Co-authored-by: Mervin Praison <[email protected]>
Add an optional, backward-compatible way for tools to feed the LLM a
compact summary while keeping the full result for display/hooks/tracing:
- ToolResult(model_output=...) compact view + resolve_model_output helper
- BaseTool.to_model_output(result) override, wired through safe_run
- @tool(to_model_output=fn) on the decorator / FunctionTool
- tool_execution.py resolves the compact view and swaps it in for the
model only after tracing + AFTER_TOOL hooks saw the full payload
No override => unchanged behaviour (model sees full output).
Co-authored-by: MervinPraison <[email protected]>
Route AgentTeam.save_session_state/restore_session_state through the
durable, project-scoped SessionStore (the same store single-Agent
--continue uses), keyed deterministically by session_id via
update_session_metadata/get_session. This removes the fuzzy
search_short_term(limit=10) lookup and the memory-gating: multi-agent
runs now resume deterministically even with memory=False.
shared_memory is demoted to optional enrichment, and the legacy
semantic lookup is kept only as a fallback for pre-existing sessions,
preserving full backward compatibility. No new params or exports.
Co-authored-by: MervinPraison <[email protected]>
- _resolve_framework gains require_workflow: WorkflowAutoGenerator now
rejects an installed-but-non-workflow default (e.g. when native praisonai
is absent) instead of emitting a workflow YAML AgentsGenerator would later
reject (Greptile/CodeRabbit P1).
- PushClient.supports_publish now also requires an active connection, closing
the disconnected/pre-connect false-positive that let gated producer calls
proceed and fail with ConnectionError (Greptile P1).
- Tests: _fake_registry sets SUPPORTS_WORKFLOW; framework-resolution tests now
assert exact create()/pick_default() interactions (CodeRabbit).
Co-authored-by: Mervin Praison <[email protected]>
- agents_generator: let FileNotFoundError bubble from _load_config and drop
the `if config is None: return` guards so run/arun/serve raise instead of
returning a silent None for a missing agent file.
- auto: add BaseAutoGenerator._resolve_framework and route both AutoGenerator
and WorkflowAutoGenerator through registry.pick_default() with adapter
validation, so defaults and validation are a single source of truth.
- push: PollingTransport.send now raises NotImplementedError for unmapped
message types (publish/create/presence) instead of silently dropping them;
PushClient logs the capability loss on fallback and exposes supports_publish.
Co-authored-by: MervinPraison <[email protected]>
- read-only policy preset now denies real dangerous tool names
(tool:write_*/delete_* + *_write*/*_delete* globs) instead of
patterns that never match the SDK's own tools
- LLMGuardrail._llm_validate recognizes the SDK's LLM.get_response
interface, so string/LLM-instance guardrails validate instead of
silently failing closed on every call
- iter_stream warns when an output guardrail is attached, since
token-level streaming cannot retroactively validate output
Co-authored-by: MervinPraison <[email protected]>
Strengthen test_generate_dockerfile_includes_gunicorn_timeout per reviewer
feedback (Greptile/CodeRabbit): bare "--timeout" substring also matched the
HEALTHCHECK --timeout=10s option, so a malformed CMD could pass. Now assert
the exact option-value pairs in the generated Gunicorn command.
Co-authored-by: Mervin Praison <[email protected]>
Agent LLM workloads (cold import + first model round-trip) routinely
exceed Gunicorn's 30s default worker timeout, causing the worker to be
SIGKILL'd mid-request on the first /chat call. Add explicit --timeout 120
and --graceful-timeout 30 to the generated CMD.
Co-authored-by: Dhivya-Bharathy <[email protected]>
Collapsing the release to a single commit (#3572) removed the per-package
"Bump praisonai-mcp to 0.0.12" commits. On a full release that is fine --
the wrapper's "Release v<x.y.z>" commit describes the whole batch. But a
single-package release (every other skip_* set, including skip_wrapper)
never reaches the wrapper, so its bump lands via the safety-net step and
was labelled with the generic "persist version bumps".
Derive the package list from the staged paths so that case reads
"chore(release): bump praisonai-mcp [skip ci]" instead. Falls back to the
generic message when nothing under src/ is staged.
Verified the extraction against a scratch repo: single package, multiple
packages, and the no-src fallback.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Address reviewer feedback on the memoisation:
- Replace unbounded functools.lru_cache with a WeakKeyDictionary so cached
bound methods no longer keep their owning Agent alive (memory leak in
long-lived processes creating many short-lived agents).
- Fall back to direct signature inspection for unhashable/unweakreferenceable
callables (e.g. objects with __hash__ = None) instead of raising TypeError,
preserving correct 3-arg tool dispatch.
- Drop now-unused functools import.
Co-authored-by: Mervin Praison <[email protected]>
Cache the inspect.signature() on_progress-kwarg probe on the callable so it
runs once per dispatcher callable instead of once per tool call. execute_tool_fn
is invariant per agent, so the memoised result is identical. The
(TypeError, ValueError) fallback for non-introspectable built-in/C callables is
preserved. No behaviour or API change.
Co-authored-by: MervinPraison <[email protected]>
Addresses the three P1 reviewer findings on PR #3624:
- telegram.py: streaming branch now calls _maybe_send_voice_reply after
finalize, so voice.mode always/match_inbound is honoured when streaming
is enabled (previously text-only).
- gateway/server.py: the Telegram handler serving VOICE|AUDIO now threads
inbound_was_voice into _send_response_with_media, so match_inbound fires
for gateway-owned channels.
- tools/audio.py + bots/_tts.py: forward configured voice.speed through
tts_tool -> AudioAgent.speech (previously dropped).
- tests: update fake tts_tool signature and add speed-forwarding regression.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Adds a first-class, opt-in outbound voice-reply path mirroring the existing
inbound `stt` setting. New `bots/_tts.py` provides TtsConfig,
resolve_tts_config, should_voice_reply and synthesize_voice_reply (reusing
tools.audio.tts_tool so heavy TTS deps stay in tools). A TtsConfigSchema +
`voice` channel field give tri-surface (YAML/metadata) parity with `stt`,
carried through config.metadata by the gateway. Telegram wired as the
reference consumer: replies are spoken per the off/always/match_inbound mode,
degrading cleanly to the already-sent text on failure.
Co-authored-by: Mervin Praison <[email protected]>
Address the three Greptile P1 findings on PR #3622:
- Channel system_prompt_hint now reaches the model: inject into
backstory + rebuild system_prompt (the consumed prompt fields) instead
of the construction-only `instructions` attribute, which the runtime
prompt builder never re-reads. Idempotent and cache-stable.
- empty_final override is now reachable via the existing BotConfig.metadata
seam (no new typed knob); direct attribute still honoured for callers.
- Inbound crash-replay docstring/log corrected to state its real effect
(unblock stranded claimed rows so redelivery / session-resume can
reprocess) rather than over-claiming standalone re-drive, which is
already owned by _resume_interrupted_turns (#3379).
Co-authored-by: Mervin Praison <[email protected]>
Gap 1 — visible-outcome guarantee for empty finals: add a dependency-free
core classifier praisonaiagents.bots.silence.classify_final() that separates
deliberate silence from a blank/whitespace/[tool_calls: …] final, and resolve
"empty, not silence" once at the shared fire_message_sending funnel with a
recorded, typed fallback (operator log; optional empty_final_message config).
Every adapter (Slack/Telegram/etc.) now delivers a visible outcome instead of
silently dropping, DLQ-parking an empty send, or leaking a machine placeholder.
Gap 2 — wire the dead system_prompt_hint seam: inject a channel's declared
system_prompt_hint into the per-channel cloned agent's instructions via the
already-existing get_channel_system_prompt_hint() helper (the missing caller),
bounded and prompt-cache-stable. No-op for built-ins; third-party channels can
now teach the agent their constraints as the ChannelDescriptor contract states.
Gap 3 — invoke un-wired inbound crash-replay: call InboundJournal.replay() at
channel start-up (and hot-reload) so an inbound stranded 'claimed' by a crash
is re-driven exactly-once on the default-durable path, honouring the module's
own durability contract. Replayed counts are logged for operator visibility.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
- Fix NameError regression: _run_custom_agent referenced an undefined
`instructions` var (broke test-core cli suite). Add the param and thread
merged_instructions from the caller so the --agent path injects declared
instruction sources into the backstory.
- Forward merged_instructions on the --command path and skip the warm-runtime
attach when instructions are present (per-invocation context can't cross the
runtime process boundary), so declared instructions are no longer dropped.
- SSRF guard for remote instruction URLs: reject private/loopback/link-local/
reserved destinations and re-validate redirects, matching the existing
managed-config resolver policy. Opt-out via PRAISONAI_INSTRUCTIONS_ALLOW_LOCAL_URLS.
- Add regression tests for custom-agent instruction injection and the SSRF guard.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
PraisonAI.main() replaces args via parse_args() and previously carried
over only session fields, silently dropping the approval/permission
settings that the YAML run path threads onto praison.args. YAML workflows
launched with --approval/--approve-all-tools/--approval-timeout or
--allow/--deny (which derive a console backend) therefore bypassed the
approval gate. Preserve these fields unconditionally across the reparse,
independently of session flags (--no-save sets approval but not
cli_project_sessions). Adds regression tests exercising the real main()
preservation boundary.
Co-authored-by: Mervin Praison <[email protected]>
Adds a wrapper-level `instructions` config key and repeatable
`run --instructions <path|glob|url>` flag so teams can declare extra
instruction/context sources (files, globs, ~ paths, http(s):// URLs)
loaded alongside the convention-only AGENTS.md/CLAUDE.md auto-load.
- context_files: resolve_instruction_sources() expands globs/~, reads
local files, and lazily fetches remote URLs (stdlib urllib, size-bounded,
best-effort with warnings) — no new heavy deps.
- run.py: resolve config `instructions` (layer-concat via the existing
ConfigResolver list-merge), merge repeatable --instructions on top, and
inject up front into the agent backstory via the existing subtree-context
hook (passed as already_loaded to dedup nested files).
- schema: add `instructions` to config_hierarchy CONFIG_SCHEMA and the
resolver's KNOWN_TOP_LEVEL_KEYS (so it validates and is not warned).
Wrapper-only; no core changes, no new Agent params.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: MervinPraison <[email protected]>
Thread the existing --approval/--approve-all-tools/--approval-timeout and --allow/--deny/--permissions rules from praisonai run workflow.yaml into the YAML engine args so YAML agents are permission-gated exactly like single-agent run. Session continuity and structured --output already flow through _run_from_file; no new engine wiring or core changes are added.
Co-authored-by: MervinPraison <[email protected]>
The write tools resolved the symlink only inside is_protected() but then
open()'d the original path, so (a) an in-workspace symlink to an out-of-
workspace file bypassed the workspace-confinement check and (b) a symlink
retargeted between check and write could redirect an approved edit into a
protected file. Add security.resolve_real_path() and resolve abs_path once
in write_file/append/apply_diff/search_replace so the protection check,
workspace-confinement check and the actual read/write all act on the same
real target. New files are unaffected (realpath returns the path unchanged).
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
1. Call/agent-invoke server auth: constant-time token compare via
hmac.compare_digest; media-stream no longer carries the shared server
token in the URL (mints a one-shot, TTL-bound session token instead);
guard active_connections/client_ips mutations with asyncio locks to
close the async TOCTOU on the rate/connection limits.
2. Protected-path guard: resolve symlinks in is_protected /
get_protection_reason so a same-directory symlink to .env is no longer
treated as harmless (bypass fix; centralised so all writers inherit it).
3. run_python: pass code as an argv list to subprocess.run instead of
building a shell-quoted string re-tokenised by shlex, preserving the
caller's exact source (newlines/backslashes/quotes) on POSIX and Windows.
Co-authored-by: MervinPraison <[email protected]>
Cloud providers shell out to external CLIs and never generate the API
server, so the parsed deploy.api config had no live consumer and was
silently ignored. Remove it to avoid misleading config (per reviewer
feedback from CodeRabbit/Greptile). Docker path already honors the
sibling api block; add runtime tests asserting PRAISONAI_API_AUTH is
propagated when auth_enabled: false and omitted otherwise.
Co-authored-by: Mervin Praison <[email protected]>
Docker deploy ignored the optional sibling `deploy.api` block, so
`auth_enabled: false` was silently dropped and the generated server
defaulted auth on, returning 401 on /chat.
- schema.py: parse optional `deploy.api` for type docker and cloud
- main.py: pass PRAISONAI_API_AUTH=disabled to container when disabled
- add unit test + update example agents-docker.yaml
Co-authored-by: Dhivya-Bharathy <[email protected]>
The @require_approval decorator now keys approvals on the tool's effective
arguments, so a bare mark_approved(name) no longer unlocks arbitrary-value
positional calls (the sticky-cache bypass this PR closes). Update the test to
mark/check with the same args the calls use, preserving the mark->call intent
without weakening the security guarantee.
Co-authored-by: Mervin Praison <[email protected]>
The require_approval decorator only keyed approvals on kwargs, so
directly-invoked decorated tools called positionally (read_file("/a"))
all collapsed to the same empty-argument key — one approval unlocked any
later positional value. Bind positional + keyword args into a normalized
dict via the function signature so the approval key reflects the actual
call. Falls back to an __args__ tuple for C-callables without a bindable
signature. Adds regression tests.
Co-authored-by: Mervin Praison <[email protected]>
- approval: thread `arguments` through mark_approved/is_already_approved,
the require_approval decorator wrappers, and the Agent tool path so a
single approval no longer permanently unlocks a tool for any later
attacker-chosen arguments (safe-by-default).
- agent memory: stop defaulting to the shared constant "praison"/"default";
when memory is enabled without an explicit user_id, generate a per-instance
non-shared id and warn, preventing silent cross-session memory merges.
Co-authored-by: MervinPraison <[email protected]>
- _kill_pid now returns result.returncode == 0 on Windows and catches OSError
- _destroy_api returns success=False with failed PIDs when any kill fails
- tests: mock zero returncode, add nonzero-taskkill and partial-kill cases
Co-authored-by: Mervin Praison <[email protected]>
Add regression coverage for the separate _configure_stdio() helper in
__main__.main(), asserting utf-8 + errors="replace" are applied before app
dispatch, plus a no-op safety case. Also assert errors=="replace" in the
existing deploy._configure_stdio test.
Co-authored-by: Mervin Praison <[email protected]>
Replace Unix-only lsof in _destroy_api() with cross-platform port
discovery (netstat -ano on Windows, lsof on Unix) and cross-platform
process termination (taskkill on Windows, SIGTERM on Unix). Prevents
orphaned Flask API servers on Windows after destroy.
Co-authored-by: Dhivya-Bharathy <[email protected]>
Windows default consoles use cp1252, which cannot encode the emoji banner
prefixes emitted by the deploy CLI, causing UnicodeEncodeError on every
command. Reuse the established _configure_stdio() pattern from
praisonai-browser to reconfigure stdout/stderr to UTF-8 (errors=replace)
at both the praisonai-deploy console entry and the praisonai deploy bridge
entry (handle_deploy_command).
Co-authored-by: Dhivya-Bharathy <[email protected]>
Replace NamedTemporaryFile + os.unlink pattern with pytest tmp_path fixture
to avoid PermissionError [WinError 32] on Windows where the file handle
cannot be deleted while still open inside the context manager.
Co-authored-by: Dhivya-Bharathy <[email protected]>
The generated Flask API server was spawned with a bare 'python' which
resolves to a different interpreter than the parent venv on Windows and
multi-Python setups, causing ModuleNotFoundError: No module named 'flask'.
Spawn with sys.executable and preserve the parent environment so the child
inherits Flask and OPENAI_API_KEY for /chat.
Co-authored-by: Dhivya-Bharathy <[email protected]>
Extract the triplicated "build a ToolResolver, bucket its
list_available_sources(), then fall back to a TOOL_MAPPINGS +
praisonai_tools scan" helper into one internal module
templates/_tool_sources.py. tools.py, tools_doctor.py and
dependency_checker.py now call the shared helper; per-module
presentation stays local so output is byte-identical.
Co-authored-by: MervinPraison <[email protected]>
Add a pure next_fire_time(cron_expr, base) helper in
praisonaiagents.scheduler.due as the single owner of the "compute next
cron fire from a base" step. Core is_due and the wrapper's
ScheduleTicker.is_due now both call it, removing the duplicated
croniter().get_next(float) logic from the wrapper. No behaviour or API
change; croniter-missing graceful degradation preserved.
Co-authored-by: MervinPraison <[email protected]>
Address final-review findings on the durable-write salvage path:
- spill filenames add a random token so consecutive same-ms/PID
failures no longer overwrite each other (no lost turns)
- _reingest_spill runs _enforce_window before persisting so recovered
turns respect the retention policy like any ordinary write
- guard spill payload shape (non-object root / non-list messages /
non-object message) so a malformed spill can't block recovery
Co-authored-by: Mervin Praison <[email protected]>
When a durable session write fails (disk-full / SQLite-FTS corruption /
permission OSError) the store previously collapsed it to a silent False that
no caller reads, so the already-produced turn lived only in the in-memory
cache and was permanently lost on the next shutdown/crash — with no operator
signal.
This adds a safe-by-default, stdlib-only salvage path in the core session
store that triggers only on the failure path (no hot-path regression):
- New SESSION_PERSIST_FAILED lifecycle hook + SessionPersistFailedInput so a
failed durable write is observable (metrics/alerting/forensics), mirroring
the outbound MESSAGE_UNDELIVERED posture.
- On write failure, spill the unpersisted turn to an atomic fallback file
(~/.praisonai/state/session_spill/*.json, 0600, temp-file + os.replace +
best-effort dir fsync) instead of holding it only in memory.
- On session load, re-ingest any spilled turns (dedup on role+content+
timestamp), persist them back, and delete each spill file only after a
successful re-insert.
No new Agent params, no new dependencies, no behaviour change on the happy
path. Adds 6 unit tests.
Co-authored-by: MervinPraison <[email protected]>
Flipping SessionConfig.persist to True (durable-by-default) exposed two
latent bugs that the prior off-by-default masked:
1. CLI opt-out ignored: the multi-bot CLI builds WebSocketGateway with a
default session config, so __init__ selected a persistent store before
start_with_config loaded the YAML — and that loader never read the
session: block. A documented session.persist: false was silently ignored.
Fix: start_with_config now re-reads the session: block and re-selects the
store (unless an explicit store was passed to the constructor).
2. Startup crash on unwritable home: SqliteTranscriptStore/DefaultSessionStore
eagerly makedirs(~/.praisonai/sessions); on a read-only/absent home both
the primary store and its fallback raised, escaping __init__ and crashing
the gateway. Fix: the store selection now degrades to in-memory sessions
instead of aborting startup.
Store-selection logic extracted into a shared _build_session_store helper so
__init__ and start_with_config pick identically. Adds regression tests for
the opt-out, the explicit-store precedence, and from_dict persist defaults.
Co-authored-by: Mervin Praison <[email protected]>
fallback_text() unconditionally imported table_to_markdown/chart_to_text
and referenced BlockType.TABLE/CHART, which raised ImportError/AttributeError
on praisonaiagents releases predating those symbols (bot pins >=1.6.152) —
breaking fallback delivery even for text-only presentations.
- Import the two new helpers defensively with inline fallbacks
- Dispatch table/chart blocks via string block-type values (getattr-guarded)
Co-authored-by: Mervin Praison <[email protected]>
Add TABLE and CHART members to the portable presentation vocabulary so
agents can describe tabular and chart data once and have each channel
render it natively where supported or degrade deterministically to a
markdown table / compact text summary elsewhere.
- BlockType.TABLE / BlockType.CHART + PresentationBlock.make_table/make_chart
- columns/rows/chart_kind/series fields with dict round-trip
- PresentationLimits: supports_tables/supports_charts + max_table_rows/cols
- adapt_presentation clamps and degrades table/chart per capability flags
- table_to_markdown/chart_to_text helpers (exported) + fallback_text branch
Co-authored-by: MervinPraison <[email protected]>
Flip SessionConfig.persist from False to True so an out-of-box gateway
remembers conversations across restarts via the existing SQLite
transcript store. Updates the two YAML-parse fallbacks to match. Users
who want ephemeral sessions can still opt out with session.persist: false.
Co-authored-by: MervinPraison <[email protected]>
The previous approach replaced the bash:<command> approval target with a
shell:external-path:<path> target for out-of-workspace commands. This both
broke command-specific deny rules (a hard `deny: bash:rm *` no longer matched,
regressing DENY -> ASK and letting a user approve a denied command) and
collided with the existing `shell:` shell-prefix namespace in
PermissionManager.check().
The out-of-workspace boundary is already fully implemented in
PermissionManager (`_check_shell_command` + `_external_dir_target`): with a
workspace_root configured, a broad `bash:*` allow escalates external paths to
ASK while a command-specific deny still fires. build_permission_target now
keeps the verbatim `bash:<command>` target so that machinery applies and
command identity is preserved.
- approval/utils.py: drop the redundant shell:external-path namespace and the
_shell_external_paths helper; shell tools map back to bash:<command>.
- tests: replace target-mangling assertions with boundary-enforcement tests
that verify (a) broad bash:* does not cover external paths, (b) in-workspace
stays allowed, (c) command-specific deny still fires on external paths, and
(d) backward-compat with no workspace_root.
Fixes the deny-bypass flagged by Greptile and CodeRabbit.
Co-authored-by: Mervin Praison <[email protected]>
- share: atomic temp-file write + os.replace so a failed/interrupted
write never truncates a previously published transcript
- share/unshare: guard OSError (unwritable/full data home) as clean
command errors with chained context instead of raw tracebacks
- unshare: unlink unconditionally, treat FileNotFoundError as an
idempotent no-op (fixes concurrent-deletion race + ruff B904)
- soften overstated 'always redacts secrets' copy to accurate
best-effort wording (redactor #3426 is high-signal/best-effort)
- test: isolate test_share_path_is_stable_per_session via project fixture
Co-authored-by: Mervin Praison <[email protected]>
Adds `praisonai session share <id>` / `unshare <id>` to the CLI. `share`
reuses the existing session resolver + transcript redactor to publish a
single self-contained HTML transcript to ~/.praisonai/shares and returns a
file:// link; `unshare` revokes it. Zero external infrastructure or new
dependencies; sharing is opt-in and always redacts secrets first.
Co-authored-by: MervinPraison <[email protected]>
Make build_permission_target emit a distinct shell:external-path:<path>
target when a shell command touches a path outside the workspace root,
so a broad bash:*/"allow shell"/session grant cannot silently authorise
out-of-workspace access. Reuses the existing command_parser and
path_safety.resolve_within_root primitives (no new modules/params).
Default-on and fail-closed, with a PRAISONAI_SHELL_WORKSPACE_BOUNDARY
opt-out for trusted sandboxed/CI runs.
Co-authored-by: MervinPraison <[email protected]>
_TimeoutBoundTool was a plain proxy that did not subclass the wrapped tool's
class, so isinstance(proxy, BaseTool) returned False. Downstream executors
(praisonaiagents tool_execution, CrewAI/LangChain) route on that isinstance
check to call .run; the proxy failed it and, being non-callable, also skipped
the generic callable branch -> a BaseTool plugin tool with tool_timeout set
silently executed nothing.
Build the proxy as an instance of a dynamic subclass of type(inner) so
isinstance keeps passing (verified for praisonaiagents BaseTool and pydantic
CrewAI/LangChain-style tools) while still never mutating the shared inner
object and still routing _run/run through the per-generator timeout wrapper.
Added regression tests for isinstance/schema preservation and cross-generator
executor isolation.
Co-authored-by: Mervin Praison <[email protected]>
Gap 1: create_unified_app now enforces the api_key via the shared
_api_auth middleware (previously accepted but never referenced), and
serve unified refuses to bind a non-localhost host without an API key,
mirroring serve agents.
Gap 2: tool timeout wrapping no longer mutates shared framework tool
objects in place; each generator installs its own _TimeoutBoundTool
proxy that delegates schema attributes but routes _run/run through its
own executor. Owner identity is a stable per-generator uuid compared by
value, so the idempotency guard actually matches.
Gap 3: resume_or_create_session accepts a create_session dispatcher; the
orchestrator routes it through _sync so async conversation stores have
their create-session coroutine awaited instead of silently dropped.
Co-authored-by: MervinPraison <[email protected]>
Replace the fixed .tmp.<pid> temp filename with tempfile.mkstemp so
concurrent writers in the same process (multiple threads or FileMemory
instances writing the same file) cannot clobber each other's temp file,
where the first os.replace would remove the temp the sibling still needs.
Matches the atomic-write convention used elsewhere in the SDK
(storage/base.py, session/store.py). Addresses Greptile P1.
Co-authored-by: Mervin Praison <[email protected]>
- agents.py: add _gather_with_isolation so a failing async task no longer
orphans its siblings; siblings are awaited to completion and the first
exception is re-raised (return_exceptions=True) across all 3 gather sites.
- handoff.py: guard finally:_pop_handoff() with a `pushed` flag at all 5
call sites so a _check_safety rejection (which raises before _push_handoff)
no longer pops an ancestor's entry and corrupts the cycle/depth guard.
- file_memory.py: write JSON to a temp file + fsync + os.replace so a crash
mid-write can no longer truncate the persisted memory store.
Co-authored-by: MervinPraison <[email protected]>
Address the three P1 findings on the declarative webhook channel:
- Gateway wiring: add a `webhook` branch to WebSocketGateway._create_bot and
recognize WebhookBot in the supervisor's own-aiohttp-server start branch, so a
`type: webhook` YAML channel actually constructs and starts (was silently
skipped by the hardcoded adapter switch). Guard _inject_routing_handler for
ingress channels without an on_message hook.
- Fail-loud dispatch: the HTTP handler now returns 500 when agent dispatch
raises, so providers retry instead of receiving a false 200 ack.
- Durable ingress id: fall back to a deterministic sha256(path + raw_body) when
no delivery header is present, keeping ingress journaling/dedup active for
generic senders (an empty message_id disabled both).
Adds tests for 500-on-failure, delivery-header and body-hash message ids, and
gateway construction of the webhook adapter.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Add a first-class `webhook` gateway channel that turns any HTTP event into an
agent trigger through configuration alone — no bespoke adapter code.
- core (praisonaiagents.bots): a small, import-light declarative filter
(WebhookFilter + evaluate_webhook_filter/resolve_field) with all/any/not
combinators and exists/equals/contains/in/regex leaf operators. Pure logic,
no HTTP, fail-safe on malformed input.
- wrapper (praisonai-bot): WebhookBot HTTP-ingress adapter + WebhookRoute,
reusing existing HmacWebhookVerifier / enforce_webhook_verification, the
aiohttp server pattern, and BotSessionManager (durable dedup ingress). Adds
prompt templating ({{ dotted.path }}) and a silent (ack-only) route.
- register `webhook` as a built-in loader in the channel registry.
Reuses existing primitives and ChannelRouteConfig.metadata; no core config
schema change or new Agent params.
Co-authored-by: MervinPraison <[email protected]>
Reads the 'unchanged default = use stock image' sentinel off ComputeConfig's
dataclass field default instead of hardcoding "python:3.12-slim", so the
image-selection logic can't silently drift if that default changes
(CodeRabbit out-of-diff nit).
- get_status/list_instances now refresh remote Tenki state (sandbox.refresh
+ .state) instead of trusting the local map, so a server-side idle timeout
no longer surfaces as RUNNING while execute() hits a dead sandbox. Mirrors
the E2B provider's is_running() reconciliation.
- add exception chaining on the SDK ImportError (raise ... from e)
- drop the misleading docstring image example (it passed the default sentinel
that is intentionally treated as the stock image)
The maintainer's review bot flagged these but couldn't push to a fork, so
applying directly:
- Raise on a configured-but-unknown TENKI_WORKSPACE_ID/PROJECT_ID instead of
silently using the first workspace/project (wrong-workspace billing).
- shlex.quote every pip/npm spec (command-injection hardening).
- Honour a non-default ComputeConfig.image, not just metadata["tenki_image"].
- Respect ComputeConfig.networking (restricted -> allow_outbound=False).
- Fail provisioning and tear down the sandbox when package install fails
(no false RUNNING / leaked sandbox).
- Terminate before dropping the handle in shutdown, so a failed terminate keeps
the sandbox tracked for retry instead of silently leaking it.
Add Tenki Cloud (https://tenki.cloud) as a compute provider for managed
agents, alongside E2B, Daytona, Modal, Fly.io, Docker and local.
- TenkiCompute implements ComputeProviderProtocol (provision / execute /
shutdown / get_status / upload_file / download_file / list_instances),
running tools in disposable Tenki microVMs. Sync SDK wrapped via
run_in_executor, matching the existing providers.
- Registered as "tenki" in the compute barrel, the _resolve_compute factory,
and the compute-provider hint sets.
- Uses only stable Tenki features (exec + file I/O). Default stock image
installs pip packages on demand; set metadata["tenki_image"] for a custom
image. Auto-resolves workspace/project from the API key.
- Enabled via TENKI_API_KEY; optional `tenki` extra (tenki-sandbox).
- Unit + live (skipped-by-default) tests mirroring the E2B/Daytona suites.
2026-07-16 15:34:46 +05:30
577 changed files with 59707 additions and 7069 deletions
@@ -106,6 +96,203 @@ agent.start("Analyze the top 3 tech trends of 2026 and format as a markdown tabl
---
## 🧬 The Five-Layer Agent Stack
Most frameworks hand you one or two layers and leave the rest as homework. PraisonAI covers **all five** — plus the outer layer that decides *where* your agent actually runs.
Each layer wraps the one inside it. When an agent misbehaves, the layer tells you where to look.
| **1 · Prompt** | Did I say it clearly? | `instructions=`, `role`/`goal`/`backstory`, `output=`, `templates=` |
| **2 · Context** | Is the right thing in the window? | `memory=`, `knowledge=`, `context=`, handoff `ContextPolicy` |
| **3 · Harness** | Can it act, and be checked? | `tools=`, `MCP()`, `guardrails=`, `approval=`, `hooks=`, `sandbox=` |
| **4 · Loop** | When do we stop? | `execution=ExecutionConfig(...)`, `reflection=`, `autonomy=`, doom-loop detection |
| **5 · Graph** | Who runs when, and who checks whom? | `AgentFlow`, `route()`, `parallel()`, `loop()`, `repeat()` |
| **⬡ Managed** | *Where does it actually run?* | `run_on="docker"` — one shared remote sandbox, or a fully hosted loop |
### Layer 1 · Prompt — *Did I say it clearly?*
Role, instructions, examples, output format.
```python
frompraisonaiagentsimportAgent
agent=Agent(
role="Senior Data Analyst",
goal="Turn raw numbers into decisions",
output="verbose",# markdown-formatted output
)
agent.start("Summarise Q3 revenue trends")
```
### Layer 2 · Context — *Is the right thing in the window?*
Write, select, compress, isolate — the four context operations, one parameter each.
```python
frompraisonaiagentsimportAgent
agent=Agent(
instructions="You are a support engineer.",
memory={"user_id":"u-42"},# write — persists across runs (needs a user_id)
knowledge=["docs/"],# select — retrieves only what's relevant
context="summarize",# compress — auto-compacts before the limit
)
```
> **Isolate** is `handoffs=[specialist]` — a sub-agent inherits the last few messages and the intersection of your tools, not your whole transcript. [📖 Handoffs](https://docs.praison.ai/docs/concepts/handoffs)
### Layer 3 · Harness — *Can it act, and be checked?*
*Agent = Model + Harness.* Tool dispatch, plus the guides that steer before acting and the sensors that observe after.
# (with on_budget_exceeded="stop", hitting the cap raises BudgetExceededError,
# surfaced here as completion_reason="error")
```
> **Doom-loop detection is on by default.** Repeated identical tool calls and A→B→A→B oscillation get caught — while a poller whose output keeps changing does not. [📖 Doom Loop Detection](https://docs.praison.ai/docs/features/doom-loop-detection)
### Layer 5 · Graph — *Who runs when, and who checks whom?*
Topology as a versionable artifact: prompt chaining, routing, parallelisation, orchestrator-worker.
> The same graph is expressible in YAML with no Python at all. [📖 AgentFlow](https://docs.praison.ai/docs/concepts/agentflow)
### ⬡ Outside the stack: Managed Agents — *Where does it actually run?*
The harness is commoditising; **where** the agent executes is the next multiplier. Rather than burning your laptop's CPU, hand an agent a short-lived cloud sandbox — repo, tools and tests run there.
```bash
pip install praisonai
```
The simplest way in is `run_on=` — one whole team or workflow shares **one** sandbox, so a file written by step 1 is there for step 2:
agent.start("Write a Python script that prints the first 10 primes, then run it")
```
See what is running and reclaim strays:
```bash
praisonai managed ps # list running sandboxes
praisonai managed stop --all # reclaim them
```
Sandboxes shut themselves down when idle (`auto_shutdown`, `idle_timeout_s`), and a post-setup snapshot is reused so the next run skips the image pull and dependency install. Commit a `.praisonai/environment.yaml` and the environment travels with the repo.
> 📖 [20 runnable examples](examples/python/managed-agents/) · manage sessions with `praisonai managed sessions list <agent-id>` or `praisonai managed sessions resume <session-id> "<prompt>"`
<sub>Stack framing adapted from [The Five-Layer Agent Stack](https://mer.vin/2026/07/five-layer-agent-stack-match-bug-to-right-layer/) and [Agent Harnesses vs Orbs](https://mer.vin/2026/08/agent-harnesses-vs-orbs-why-remote-sandboxes-beat-local-agent-loops/).</sub>
---
## 🌌 The PraisonAI Ecosystem
Start simple with the core SDK, or expand to full visual builders and dashboards when you're ready.
@@ -200,9 +387,9 @@ Powered by 100+ LLMs (OpenAI, Anthropic, Gemini & local models).
token_metrics:Optional['TokenMetrics']=None# Add token metrics field
callback_error:Optional[str]=None
non_fatal_errors:Optional[list[str]]=None
defjson(self)->Optional[str]:
ifself.output_format=="JSON"andself.json_dict:
returnjson.dumps(self.json_dict)
returnNone
defto_dict(self)->dict:
output_dict={}
ifself.json_dict:
output_dict.update(self.json_dict)
ifself.pydantic:
output_dict.update(self.pydantic.model_dump())
returnoutput_dict
def__str__(self):
ifself.pydantic:
returnstr(self.pydantic)
elifself.json_dict:
returnjson.dumps(self.json_dict)
else:
returnself.raw
# Re-export the single canonical TaskOutput so main.TaskOutput and
# output.models.TaskOutput are the SAME class. Previously two near-identical
# classes both named TaskOutput existed, which broke guardrail validation
# (GuardrailResult expected one class while agents produced the other).
from.output.modelsimportTaskOutput
Loaded 100 of 577 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.