Compare commits

...
Author SHA1 Message Date
praisonai-triage-agent[bot] 1b79b25d2f Release v4.6.162 2026-08-13 00:20:05 +00:00
MervinPraison 4c205fe005 docs: auto-update feature parity trackers [skip ci] 2026-08-11 18:46:24 +00:00
praisonai-triage-agent[bot] e7ccb83581 Merge pull request #3865 from MervinPraison/claude/issue-3864-20260811-1714
fix: defer A2AClient import to avoid eager aiohttp load for server-only A2A
2026-08-11 18:45:55 +00:00
praisonai-triage-agent[bot]andMervinPraison 256cfea4d1 fix: defer A2AClient import to avoid eager aiohttp load for server-only A2A (fixes #3864)
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]>
2026-08-11 17:16:10 +00:00
praisonai-triage-agent[bot] 4e8e0879c7 Merge pull request #3863 from MervinPraison/claude/issue-3862-20260811-0944
fix: redeliver crash-parked replies on boot, symmetric with inbound journal
2026-08-11 10:41:08 +00:00
MervinPraison 36ae067ee3 docs: auto-update feature parity trackers [skip ci] 2026-08-11 10:10:41 +00:00
praisonai-triage-agent[bot] 1aad75206e Merge pull request #3861 from MervinPraison/claude/issue-3860-20260811-0917
fix: add replay-safety gate to provider-call retry
2026-08-11 10:10:14 +00:00
praisonai-triage-agent[bot] b31ca3e36b Merge pull request #3859 from MervinPraison/claude/issue-3858-20260811-0857
fix: coherent turn-level undo/revert on the default CLI path
2026-08-11 09:59:28 +00:00
praisonai-triage-agent[bot]andMervin Praison ef64433db7 fix: scope outbound DLQ nested-park guard to in-flight entry (fixes #3862 P1)
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]>
2026-08-11 09:58:50 +00:00
praisonai-triage-agent[bot]andMervinPraison c9e2dcb824 fix: redeliver crash-parked replies on boot, symmetric with inbound journal (fixes #3862)
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]>
2026-08-11 09:51:51 +00:00
praisonai-triage-agent[bot] f737994462 Merge pull request #3857 from MervinPraison/claude/issue-3856-20260811-0832
fix: harden wrapper db/observability/api surfaces (#3856)
2026-08-11 09:34:23 +00:00
praisonai-triage-agent[bot]andMervin Praison 111f513494 fix: hoist replay-safety gate to cover unknown post-dispatch failures (#3860)
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]>
2026-08-11 09:29:51 +00:00
praisonai-triage-agent[bot]andMervinPraison bb89c36bee fix: add replay-safety gate to provider-call retry (fixes #3860)
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]>
2026-08-11 09:22:49 +00:00
praisonai-triage-agent[bot]andMervin Praison 4026c75025 fix: rewind reused agent context and use turn-aware run reverts (#3859)
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]>
2026-08-11 09:16:57 +00:00
praisonai-triage-agent[bot]andMervinPraison c3b9cd8729 fix: bring coherent turn-level undo/revert to the default CLI path (fixes #3858)
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]>
2026-08-11 09:06:33 +00:00
praisonai-triage-agent[bot]andMervin Praison 033a58ea9a fix: persist async-store sessions on sync start + align lifecycle keys
- 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]>
2026-08-11 08:42:59 +00:00
praisonai-triage-agent[bot]andMervinPraison 781051af26 fix: harden wrapper db/observability/api surfaces (fixes #3856)
- 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]>
2026-08-11 08:36:35 +00:00
praisonai-triage-agent[bot] 0a0c1cef60 Merge pull request #3849 from MervinPraison/claude/issue-3848-20260810-1623
fix: consolidate module-member tool extraction onto canonical helper
2026-08-10 17:25:43 +00:00
praisonai-triage-agent[bot]andMervinPraison f4964e118f fix: consolidate module-member tool extraction onto canonical helper (fixes #3848)
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]>
2026-08-10 16:28:29 +00:00
MervinPraison 257a7c8920 docs: auto-update documentation parity trackers [skip ci] 2026-08-10 10:37:19 +00:00
praisonai-triage-agent[bot] 10c6cb8841 Merge pull request #3842 from MervinPraison/claude/issue-3841-20260810-0915
fix: gateway config_version stamp + doctor-driven migration
2026-08-10 10:36:40 +00:00
praisonai-triage-agent[bot] 96f65443bd Merge pull request #3843 from MervinPraison/claude/issue-3840-20260810-0915
fix: fleet-level crash-loop breaker for gateway channel supervision
2026-08-10 10:36:18 +00:00
MervinPraison 5fbdd1ef1b docs: auto-update feature parity trackers [skip ci] 2026-08-10 09:43:23 +00:00
praisonai-triage-agent[bot] 1d806720cb Merge pull request #3839 from MervinPraison/claude/issue-3838-20260810-0852
fix: litellm-free capability fallback in model_capabilities
2026-08-10 09:42:56 +00:00
praisonai-triage-agent[bot]andMervin Praison 7647606064 fix: fleet breaker recovery, failing-fraction enforcement, CI schema guard (#3840)
- 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]>
2026-08-10 09:34:05 +00:00
praisonai-triage-agent[bot]andMervin Praison db412e59ca fix: reject newer/malformed gateway config_version + atomic doctor write (#3841)
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]>
2026-08-10 09:32:49 +00:00
praisonai-triage-agent[bot]andMervinPraison ae9b0cc893 fix: add fleet-level crash-loop breaker for gateway channel supervision (fixes #3840)
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]>
2026-08-10 09:22:44 +00:00
praisonai-triage-agent[bot]andMervinPraison e0e46a087e fix: add gateway config_version stamp + doctor-driven migration (fixes #3841)
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]>
2026-08-10 09:20:12 +00:00
praisonai-triage-agent[bot] e0254be916 Merge pull request #3837 from MervinPraison/claude/issue-3836-20260810-0829
fix: async RAG offload, non-lossy tool persistence, nesting-proof injection scan
2026-08-10 09:18:13 +00:00
praisonai-triage-agent[bot]andMervin Praison 4317371aeb fix: keep litellm authoritative on error; narrower parallel fallback
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]>
2026-08-10 09:04:01 +00:00
praisonai-triage-agent[bot]andMervinPraison f55da515bd fix: add litellm-free capability fallback in model_capabilities (fixes #3838)
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]>
2026-08-10 08:56:44 +00:00
praisonai-triage-agent[bot]andMervin Praison e705e1b222 fix(security): fail-closed when injection scan budget is exhausted
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]>
2026-08-10 08:40:45 +00:00
praisonai-triage-agent[bot]andMervin Praison 48a8d4f4e6 fix: async RAG offload, non-lossy tool persistence, nesting-proof injection scan (fixes #3836)
- 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]>
2026-08-10 08:33:34 +00:00
praisonai-triage-agent[bot] 7b1ddd4d6e Merge pull request #3830 from MervinPraison/claude/issue-3826-20260809-1620
fix: remove superseded unreferenced argparse builders in eval and sandbox CLI
2026-08-09 17:39:52 +00:00
praisonai-triage-agent[bot] 814a215391 Merge pull request #3831 from MervinPraison/claude/issue-3827-20260809-1620
fix: delegate _resolve_llm model precedence to core
2026-08-09 17:24:56 +00:00
praisonai-triage-agent[bot] fc131bf84f Merge pull request #3829 from MervinPraison/claude/issue-3825-20260809-1619
fix: restore orphaned workflow-runs L3 dashboard page
2026-08-09 17:24:46 +00:00
praisonai-triage-agent[bot] 638f4e21db Merge pull request #3828 from MervinPraison/claude/issue-3824-20260809-1619
fix: reuse image/URL helpers from replay.judge in recipe_optimizer
2026-08-09 17:18:47 +00:00
praisonai-triage-agent[bot]andMervinPraison 605ff7b87b fix: delegate _resolve_llm model precedence to core (fixes #3827)
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]>
2026-08-09 16:21:51 +00:00
praisonai-triage-agent[bot]andMervinPraison 4fe61c8d35 fix: remove superseded unreferenced argparse builders in eval and sandbox CLI (fixes #3826)
Co-authored-by: MervinPraison <[email protected]>
2026-08-09 16:21:20 +00:00
praisonai-triage-agent[bot]andMervinPraison d1f387eec2 fix: restore orphaned workflow-runs L3 dashboard page (fixes #3825)
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]>
2026-08-09 16:21:08 +00:00
praisonai-triage-agent[bot]andMervinPraison ee4ba6608f fix: reuse image/URL helpers from replay.judge in recipe_optimizer (fixes #3824)
Co-authored-by: MervinPraison <[email protected]>
2026-08-09 16:20:45 +00:00
MervinPraison 84752e77d8 docs: auto-update documentation parity trackers [skip ci] 2026-08-09 12:06:31 +00:00
praisonai-triage-agent[bot] 6a3281483c Merge pull request #3821 from MervinPraison/claude/issue-3820-20260809-0917
fix: emit MODEL_FALLBACK hook + stream event on silent model switch
2026-08-09 12:05:53 +00:00
praisonai-triage-agent[bot]andMervin Praison 7af120c1fe fix: route MODEL_FALLBACK through agent registry + async emitter (PR #3821 review)
- 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]>
2026-08-09 11:13:31 +00:00
praisonai-triage-agent[bot]andMervinPraison 614d2b688d fix: emit MODEL_FALLBACK hook + stream event on silent model switch (fixes #3820)
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]>
2026-08-09 11:13:31 +00:00
praisonai-triage-agent[bot] 63754c70f0 Merge pull request #3817 from MervinPraison/claude/issue-3815-20260809-0849
fix: include live MCP server tools in tools list
2026-08-09 11:11:53 +00:00
praisonai-triage-agent[bot]andMervin Praison 82fe82c00d fix: deterministic /tasks REPL test isolation under xdist loadfile
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]>
2026-08-09 10:14:53 +00:00
praisonai-triage-agent[bot]andMervin Praison b3d1b5927d fix: list MCP tools under runtime-callable names + bound discovery timeout
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]>
2026-08-09 10:13:52 +00:00
praisonai-triage-agent[bot]andMervinPraison f4e32cfaf1 fix: include live MCP server tools in tools list (fixes #3815)
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]>
2026-08-09 10:13:52 +00:00
MervinPraison 144683d6e3 docs: auto-update feature parity trackers [skip ci] 2026-08-09 10:12:15 +00:00
praisonai-triage-agent[bot] 0641e5cc58 Merge pull request #3816 from MervinPraison/claude/issue-3813-20260809-0848
fix: emit live TODO_UPDATED stream events on todo mutations
2026-08-09 10:11:49 +00:00
praisonai-triage-agent[bot] ca76128451 Release v4.6.161 2026-08-09 09:39:28 +00:00
praisonai-triage-agent[bot]andMervin Praison ae1aa47304 fix: make REPL /tasks tests deterministic via runner injection
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]>
2026-08-09 09:34:20 +00:00
praisonai-triage-agent[bot] f2ab5a79f5 Merge pull request #3812 from MervinPraison/claude/issue-3811-20260809-0831
fix: serve concurrency, Redis SCAN, and injection over-blocking
2026-08-09 09:31:04 +00:00
praisonai-triage-agent[bot]andMervin Praison 2d2ba1d12a fix: install tool-progress channel on async tool path for live todo events
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]>
2026-08-09 09:01:39 +00:00
praisonai-triage-agent[bot]andMervinPraison a0170e7a3c fix: emit live TODO_UPDATED stream events on todo mutations (fixes #3813)
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]>
2026-08-09 08:53:25 +00:00
praisonai-triage-agent[bot]andMervin Praison 3e87d05fa7 fix: route serve per-request tool-timeout leaks to cached pool owner
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]>
2026-08-09 08:42:48 +00:00
praisonai-triage-agent[bot]andMervinPraison 4cde8cf333 fix: serve concurrency, Redis SCAN, and injection over-blocking (fixes #3811)
- 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]>
2026-08-09 08:35:47 +00:00
MervinPraison 9335bc82c8 docs: auto-update api.md [skip ci] 2026-08-09 08:31:48 +00:00
praisonai-triage-agent[bot] c5781b6f9a Merge pull request #3810 from MervinPraison/claude/issue-3809-20260809-0711
fix: cleanup launched routes, drop dead checkpoints/background state, scope MongoDB knowledge adapter
2026-08-09 08:31:21 +00:00
praisonai-triage-agent[bot]andMervin Praison 9b9dcc7ce4 test: pin REPL /tasks tests to one xdist worker and reset shared runner
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]>
2026-08-09 07:54:10 +00:00
praisonai-triage-agent[bot]andMervin Praison e01a8ae1ba fix: scope launch-route cleanup to agent-owned POST route
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]>
2026-08-09 07:23:35 +00:00
praisonai-triage-agent[bot]andMervinPraison 6b6707c1a9 fix: cleanup launched routes, drop dead checkpoints/background state, scope MongoDB knowledge adapter (fixes #3809)
- 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]>
2026-08-09 07:16:23 +00:00
praisonai-triage-agent[bot] 51b102edf5 Merge pull request #3808 from MervinPraison/claude/issue-3807-20260808-1615
refactor: consolidate scheduler delivery methods into base class
2026-08-08 17:37:53 +00:00
praisonai-triage-agent[bot]andMervin Praison ac4b91c77e fix: patch package-level get_background_runner in tasks-command test fixture
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]>
2026-08-08 16:51:35 +00:00
praisonai-triage-agent[bot]andMervinPraison 5894a0182e refactor: consolidate scheduler delivery methods into base class (fixes #3807)
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]>
2026-08-08 16:17:51 +00:00
praisonai-triage-agent[bot] 9e0c608894 Merge pull request #3802 from MervinPraison/claude/issue-3800-20260808-0918
fix: validate & preview scheduler delivery target at creation time
2026-08-08 11:19:40 +00:00
MervinPraison 5b1a437e4d docs: auto-update documentation parity trackers [skip ci] 2026-08-08 11:11:18 +00:00
praisonai-triage-agent[bot] 51f337b209 Merge pull request #3805 from MervinPraison/claude/issue-3804-20260808-0947
fix: cgroup-aware memory-pressure cache eviction planner
2026-08-08 11:10:45 +00:00
MervinPraison 8c9970e226 docs: auto-update documentation parity trackers [skip ci] 2026-08-08 10:45:35 +00:00
praisonai-triage-agent[bot] ee9c8153ec Merge pull request #3806 from MervinPraison/claude/issue-3803-20260808-0947
fix: pin model/provider per scheduled job and fail closed on drift
2026-08-08 10:44:57 +00:00
praisonai-triage-agent[bot]andMervin Praison f69fe97699 test: pin dedicated background runner in REPL /tasks tests to fix xdist race
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]>
2026-08-08 10:28:28 +00:00
MervinPraison 601b047ea8 docs: auto-update documentation parity trackers [skip ci] 2026-08-08 10:12:58 +00:00
praisonai-triage-agent[bot] ceab879139 Merge pull request #3801 from MervinPraison/claude/issue-3799-20260808-0917
fix: visible failure-reply & degraded-delivery bot primitives
2026-08-08 10:12:24 +00:00
praisonai-triage-agent[bot]andMervin Praison 82bd30fbc0 fix: pin dedicated background runner in REPL /tasks tests to close xdist race
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]>
2026-08-08 10:07:17 +00:00
praisonai-triage-agent[bot]andMervin Praison ea94c4721d fix: make scheduled model pin run-scoped and normalize provider/model on drift check
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]>
2026-08-08 10:01:58 +00:00
praisonai-triage-agent[bot]andMervin Praison 65d72e96cd fix: reject non-finite budget/rss and non-numeric headroom in eviction planner
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]>
2026-08-08 09:57:11 +00:00
MervinPraison ec65b2a919 docs: auto-update feature parity trackers [skip ci] 2026-08-08 09:52:31 +00:00
praisonai-triage-agent[bot]andMervinPraison e9642301df fix: pin model/provider per scheduled job and fail closed on drift (fixes #3803)
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]>
2026-08-08 09:52:25 +00:00
praisonai-triage-agent[bot]andMervinPraison ca0337f66f fix: add cgroup-aware memory-pressure cache eviction planner (fixes #3804)
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]>
2026-08-08 09:52:15 +00:00
praisonai-triage-agent[bot] a5c064f5ca Merge pull request #3796 from MervinPraison/claude/issue-3795-20260808-0857
fix: derive compaction context window from litellm model_cost (fixes #3795)
2026-08-08 09:52:11 +00:00
praisonai-triage-agent[bot]andMervin Praison cb01663f9d fix: preserve invalid_output retryability & align degraded-delivery report with adaptation
- 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]>
2026-08-08 09:35:33 +00:00
praisonai-triage-agent[bot]andMervin Praison c8f929444c fix: run scheduler delivery pre-flight at creation & split optional resolver protocol
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]>
2026-08-08 09:32:15 +00:00
praisonai-triage-agent[bot] 644421d238 Merge pull request #3792 from MervinPraison/claude/issue-3791-20260808-0827
fix: bound persistence session cache, fix flow components path separator, rewrite merged workflow step refs
2026-08-08 09:26:36 +00:00
praisonai-triage-agent[bot]andMervinPraison 7ff6194a35 fix: validate & preview scheduler delivery target at creation time (fixes #3800)
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]>
2026-08-08 09:23:17 +00:00
praisonai-triage-agent[bot]andMervinPraison 892b9fb633 fix: add visible failure-reply and degraded-delivery bot primitives (fixes #3799)
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]>
2026-08-08 09:22:56 +00:00
praisonai-triage-agent[bot]andMervinPraison e5c6b5bb82 fix: derive compaction context window from litellm model_cost before static fallback (fixes #3795)
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]>
2026-08-08 08:59:28 +00:00
praisonai-triage-agent[bot]andMervin Praison 6bb1f0738f fix: rewrite route/loop agent refs on merge collision; guard cache-size env parse
- 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]>
2026-08-08 08:37:45 +00:00
praisonai-triage-agent[bot]andMervinPraison 129ac43f30 fix: bound persistence session cache, fix flow components path separator, rewrite merged workflow step refs (fixes #3791)
- 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]>
2026-08-08 08:29:45 +00:00
MervinPraison 2aa2348afb docs: auto-update documentation parity trackers [skip ci] 2026-08-07 10:37:01 +00:00
praisonai-triage-agent[bot] ddf496f275 Merge pull request #3783 from MervinPraison/claude/issue-3778-20260807-0915
fix: add core presentation renderer registration seam for plugin channels
2026-08-07 10:36:17 +00:00
MervinPraison 9e1ea9061b docs: auto-update documentation parity trackers [skip ci] 2026-08-07 10:24:42 +00:00
praisonai-triage-agent[bot] ce3e896167 Merge pull request #3782 from MervinPraison/claude/issue-3780-20260807-0915
fix: add canonical inbound admission primitive to core bots
2026-08-07 10:24:06 +00:00
MervinPraison d169cf24cf docs: auto-update feature parity trackers [skip ci] 2026-08-07 10:11:24 +00:00
praisonai-triage-agent[bot] 7186a5c75d Merge pull request #3777 from MervinPraison/claude/issue-3773-20260807-0849
feat: add one-shot plugin-isolation flag (--pure / --no-plugins)
2026-08-07 10:10:56 +00:00
praisonai-triage-agent[bot]andMervin Praison 322f8d67d5 fix: harden presentation renderer seam + fix unrelated CLI test stub
- 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]>
2026-08-07 09:30:19 +00:00
praisonai-triage-agent[bot]andMervin Praison cf37bd2af7 fix: match _execute_prompt fake signature to accept read_only in parity test
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]>
2026-08-07 09:30:05 +00:00
praisonai-triage-agent[bot]andMervin Praison a49916ae31 fix: default unset group_policy to mention_only in admission primitive
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]>
2026-08-07 09:25:16 +00:00
praisonai-triage-agent[bot]andMervinPraison 8dc05d5405 fix: add core presentation renderer registration seam for plugin channels (fixes #3778)
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]>
2026-08-07 09:20:38 +00:00
praisonai-triage-agent[bot]andMervinPraison b86653d119 fix: add canonical inbound admission primitive to core bots (fixes #3780)
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]>
2026-08-07 09:19:18 +00:00
praisonai-triage-agent[bot]andMervin Praison 2b450bb06d fix: scope --pure suppression per-run and repair pre-existing read_only test stubs
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]>
2026-08-07 09:15:35 +00:00
praisonai-triage-agent[bot]andMervinPraison 13d8199ceb feat: add one-shot plugin-isolation flag (--pure / --no-plugins) (fixes #3773)
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]>
2026-08-07 08:56:22 +00:00
praisonai-triage-agent[bot] 22518228f1 Merge pull request #3758 from MervinPraison/claude/issue-3744-20260806-1029
fix: parity across interactive REPL surfaces (async @file expansion, /stats, /export, single registry)
2026-08-06 13:11:21 +00:00
praisonai-triage-agent[bot] 80a3d65511 Merge pull request #3757 from MervinPraison/claude/issue-3738-20260806-1028
fix: add headless -p/--print + --output json|text to praisonai code (#3738)
2026-08-06 12:54:05 +00:00
praisonai-triage-agent[bot] 5f6575fce0 Merge pull request #3747 from MervinPraison/claude/issue-3734-20260806-1027
feat: add /code-review + /security-review interactive commands
2026-08-06 12:46:13 +00:00
praisonai-triage-agent[bot] 6ca4b97037 Merge pull request #3755 from MervinPraison/claude/issue-3731-20260806-1026
feat: add /branch REPL command and session fork CLI verb
2026-08-06 12:40:11 +00:00
praisonai-triage-agent[bot] 50605a10a0 Merge pull request #3753 from MervinPraison/claude/issue-3732-20260806-1026
fix: add working /diff (session file changes) and wire TUI /diff & /undo
2026-08-06 12:31:34 +00:00
MervinPraison d354049ab8 docs: auto-update documentation parity trackers [skip ci] 2026-08-06 12:24:39 +00:00
praisonai-triage-agent[bot] c0f927803d Merge pull request #3752 from MervinPraison/claude/issue-3733-20260806-1027
feat: add /tasks command to interactive session and bot registry
2026-08-06 12:24:05 +00:00
praisonai-triage-agent[bot] 85b9494b7a Merge pull request #3746 from MervinPraison/claude/issue-3735-20260806-1028
feat: add /btw side-question command to interactive REPL
2026-08-06 12:17:43 +00:00
MervinPraison 2928219218 docs: auto-update documentation parity trackers [skip ci] 2026-08-06 12:13:59 +00:00
praisonai-triage-agent[bot] 7fe53aa0a3 Merge pull request #3754 from MervinPraison/claude/issue-3737-20260806-1028
fix: add session rename for human-readable titles
2026-08-06 12:13:21 +00:00
praisonai-triage-agent[bot]andClaude e6eb0d099a fix: enforce read-only review, harden diff handling in TUI review commands
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]>
2026-08-06 12:03:34 +00:00
praisonai-triage-agent[bot]andMervinPraison 910f1359b7 feat: add /code-review + /security-review interactive commands (fixes #3734)
Compose the shipped read-only review agent preset with GitManager diff
collection: /code-review and /security-review audit the uncommitted (or
--staged / per-file) diff inline. Empty diff reports a clean working tree.

Co-authored-by: MervinPraison <[email protected]>
2026-08-06 12:03:34 +00:00
praisonai-triage-agent[bot] 6b59958d36 Merge pull request #3751 from MervinPraison/claude/issue-3742-20260806-1029
feat: add !command shell escape to interactive REPL/TUI
2026-08-06 12:02:02 +00:00
praisonai-triage-agent[bot] 3f1d8e19cf Merge pull request #3750 from MervinPraison/claude/issue-3736-20260806-1028
fix: make interactive /plan a real read-only mode with enforcement
2026-08-06 11:59:54 +00:00
MervinPraison fd67458c27 docs: auto-update api.md [skip ci] 2026-08-06 11:55:09 +00:00
praisonai-triage-agent[bot] 47c76273ca Merge pull request #3748 from MervinPraison/claude/issue-3739-20260806-1029
feat: add SkillManager.reload() + praisonai skills reload for live skill refresh
2026-08-06 11:54:42 +00:00
praisonai-triage-agent[bot]andMervin Praison c3a3e392a3 fix: isolate REPL /tasks tests from shared background runner singleton
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]>
2026-08-06 11:48:21 +00:00
praisonai-triage-agent[bot] f3679c5aa1 Merge pull request #3749 from MervinPraison/claude/issue-3740-20260806-1029
feat: add /recap on-demand read-only session summary
2026-08-06 11:47:47 +00:00
praisonai-triage-agent[bot] c4af36d340 Merge pull request #3745 from MervinPraison/claude/issue-3729-20260806-1020
fix: bridge file-based custom slash commands into bot chats
2026-08-06 11:45:43 +00:00
praisonai-triage-agent[bot] ab1696bfcd Merge pull request #3726 from MervinPraison/claude/issue-3724-20260806-0946
fix: add out-of-process standalone sender for scheduled delivery
2026-08-06 11:42:40 +00:00
praisonai-triage-agent[bot] f42009e67e Merge pull request #3730 from MervinPraison/claude/issue-3727-20260806-1019
fix: command frontmatter parity + positional args (#3727)
2026-08-06 11:40:24 +00:00
MervinPraison a18f4e849e docs: auto-update documentation parity trackers [skip ci] 2026-08-06 11:37:56 +00:00
praisonai-triage-agent[bot] 94fa543a68 Merge pull request #3741 from MervinPraison/claude/issue-3728-20260806-1019
fix: truthful plugins CLI, project-plugin trust gate, clean unload
2026-08-06 11:37:19 +00:00
praisonai-triage-agent[bot]andMervin Praison ccabb98b43 fix: sanitize fork store lookup + close /branch busy-check race
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]>
2026-08-06 11:18:59 +00:00
praisonai-triage-agent[bot]andMervin Praison b4218d38b5 fix: sync legacy /plan toggle from live backend at startup
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]>
2026-08-06 11:13:27 +00:00
praisonai-triage-agent[bot]andMervin Praison 2b031fda6c fix: expand @file mentions exactly once in async TUI (fixes double expansion)
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]>
2026-08-06 11:06:37 +00:00
praisonai-triage-agent[bot]andMervin Praison 77c5fa7974 fix: harden session fork against wrong store, bad index, and in-flight branch
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]>
2026-08-06 11:04:20 +00:00
praisonai-triage-agent[bot]andMervin Praison 449b3974cd fix: harden code -p headless mode (profiling precedence + fail-closed on unsupported opts)
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]>
2026-08-06 11:04:08 +00:00
praisonai-triage-agent[bot]andMervin Praison 60a4e716d6 fix: resolve /tasks P1 issues — dispatch, shared runner, ownership, cancellation
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]>
2026-08-06 11:02:55 +00:00
praisonai-triage-agent[bot]andMervin Praison f29750f09d fix: capture TUI session baseline at startup + pre-turn checkpoints
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]>
2026-08-06 11:00:55 +00:00
praisonai-triage-agent[bot]andMervin Praison 6329b23656 fix: allow renaming legacy-only sessions (Issue #3737)
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]>
2026-08-06 10:59:52 +00:00
praisonai-triage-agent[bot]andMervin Praison caa6b9b420 fix: restore prior approval mode on /plan exit + sync PLAN state at startup
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]>
2026-08-06 10:57:37 +00:00
praisonai-triage-agent[bot]andMervin Praison 11f4d9652f fix: retain !!cmd context on failure and attach failed output
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]>
2026-08-06 10:56:45 +00:00
praisonai-triage-agent[bot]andMervin Praison 0f690385c4 fix: bound /recap output so a large summary can't overflow a channel
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]>
2026-08-06 10:53:05 +00:00
praisonai-triage-agent[bot]andMervin Praison 707d689d22 fix: reach file commands in bots, gate per-command shell, sanitize errors
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]>
2026-08-06 10:51:28 +00:00
praisonai-triage-agent[bot]andMervin Praison f4266f78b7 fix: record skill mtime baseline at load; preserve add_skill() on reload
- 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]>
2026-08-06 10:49:35 +00:00
praisonai-triage-agent[bot]andMervin Praison f6df4a8049 fix: harden /btw side-question (streaming, autonomy, --keep parsing)
- 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]>
2026-08-06 10:44:34 +00:00
praisonai-triage-agent[bot]andMervin Praison 20831c74ac fix: close symlink gate bypass, config corruption, stale reload/disable in plugins
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]>
2026-08-06 10:40:13 +00:00
praisonai-triage-agent[bot]andMervinPraison cacb4f96bb fix: parity across interactive REPL surfaces (fixes #3744)
- async TUI now expands advertised @file mentions on submit (real bug)
- add /stats to async TUI (alias of the stats renderer)
- add /export [file] to legacy REPL, delegating to session export
- remove duplicate create_default_registry (rename legacy slash_commands
  factory to create_slash_command_registry; canonical registry unchanged)

Co-authored-by: MervinPraison <[email protected]>
2026-08-06 10:38:28 +00:00
praisonai-triage-agent[bot]andMervinPraison b61bc2c42d fix: add headless -p/--print + --output json|text to praisonai code (fixes #3738)
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]>
2026-08-06 10:36:56 +00:00
praisonai-triage-agent[bot]andMervinPraison 55eb6a17cb feat: add /branch REPL command and session fork CLI verb (fixes #3731)
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]>
2026-08-06 10:35:53 +00:00
praisonai-triage-agent[bot]andMervinPraison 149ee1684e fix: add session rename for human-readable titles (fixes #3737)
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]>
2026-08-06 10:35:37 +00:00
praisonai-triage-agent[bot]andMervinPraison 43fdb1df0d feat: add /tasks command to interactive session and bot registry (fixes #3733)
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]>
2026-08-06 10:35:29 +00:00
praisonai-triage-agent[bot]andMervinPraison 94ba57c5f6 fix: add working /diff (session file changes) and wire TUI /diff & /undo (fixes #3732)
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]>
2026-08-06 10:35:26 +00:00
praisonai-triage-agent[bot]andMervinPraison 8967c6000b feat: add !command shell escape to interactive REPL/TUI (fixes #3742)
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]>
2026-08-06 10:34:57 +00:00
praisonai-triage-agent[bot]andMervinPraison 5364f23f7c fix: make interactive /plan a real read-only mode with enforcement (fixes #3736)
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]>
2026-08-06 10:34:53 +00:00
praisonai-triage-agent[bot]andMervinPraison 9f0645756e feat: add /recap on-demand read-only session summary (fixes #3740)
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]>
2026-08-06 10:34:45 +00:00
praisonai-triage-agent[bot]andMervinPraison 0d8d32f3a2 feat: add SkillManager.reload() + praisonai skills reload for live skill refresh (fixes #3739)
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]>
2026-08-06 10:33:38 +00:00
praisonai-triage-agent[bot]andMervinPraison 7d31c35aba feat: add /btw side-question command to interactive REPL (fixes #3735)
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]>
2026-08-06 10:31:42 +00:00
MervinPraison 2a5b3d4210 docs: auto-update documentation parity trackers [skip ci] 2026-08-06 10:31:18 +00:00
praisonai-triage-agent[bot]andMervin Praison 0ccfea1919 fix: harden command positional interpolation (#3727)
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]>
2026-08-06 10:31:16 +00:00
praisonai-triage-agent[bot]andMervinPraison e34989a5ca fix: bridge file-based custom slash commands into bot chats (fixes #3729)
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]>
2026-08-06 10:31:14 +00:00
praisonai-triage-agent[bot] 8281400ab6 Merge pull request #3721 from MervinPraison/claude/issue-3716-20260806-0916
fix: wire deferred tool results to a resolver so long-running results are re-injected
2026-08-06 10:30:05 +00:00
praisonai-triage-agent[bot] 386e2c2a73 Merge pull request #3719 from MervinPraison/claude/issue-3717-20260806-0916
feat: static capability manifest for plugins/channels without runtime import
2026-08-06 10:29:40 +00:00
praisonai-triage-agent[bot]andMervinPraison 3945dfe27f fix: truthful plugins CLI, project-plugin trust gate, clean unload (fixes #3728)
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]>
2026-08-06 10:28:13 +00:00
praisonai-triage-agent[bot]andMervinPraison 7b4726cbd4 fix: add command frontmatter parity and positional $1..$n args (fixes #3727)
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]>
2026-08-06 10:23:53 +00:00
MervinPraison 5f67cef76f docs: auto-update documentation parity trackers [skip ci] 2026-08-06 10:21:02 +00:00
praisonai-triage-agent[bot] 6ebc99e0b6 Merge pull request #3720 from MervinPraison/claude/issue-3715-20260806-0915
fix: quarantine corrupt session files instead of silently resetting history
2026-08-06 10:20:25 +00:00
praisonai-triage-agent[bot] 7d426c71bd Merge pull request #3714 from MervinPraison/claude/issue-3712-20260806-0855
feat: add 'plugins add' and 'tools add' install-and-verify CLI commands
2026-08-06 10:17:11 +00:00
praisonai-triage-agent[bot] e55212db0f Merge pull request #3713 from MervinPraison/claude/issue-3711-20260806-0854
fix: unify tool discovery under canonical praisonai.tools entry-point group
2026-08-06 10:09:48 +00:00
praisonai-triage-agent[bot] d2506aa74d Merge pull request #3710 from MervinPraison/claude/issue-3709-20260806-0824
fix: harden async bridge, agent file tools, and A2U server (#3709)
2026-08-06 10:01:55 +00:00
praisonai-triage-agent[bot]andMervin Praison 3c7bb56e2f fix: standalone sender chunking, home-channel registry, discord threads, unsupported-target error
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]>
2026-08-06 10:00:53 +00:00
MervinPraison 8a546e1512 docs: auto-update documentation parity trackers [skip ci] 2026-08-06 09:59:51 +00:00
Mervin Praison 3ab5c03b9d Merge pull request #3722 from MervinPraison/claude/issue-3718-20260806-0918
fix: auto-create RateLimiter from ExecutionConfig max_rpm
2026-08-06 10:59:16 +01:00
praisonai-triage-agent[bot]andMervinPraison 72c5af7fd0 fix: add out-of-process standalone sender for scheduled delivery (fixes #3724)
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]>
2026-08-06 09:52:07 +00:00
praisonai-triage-agent[bot]andMervin Praison 4ae40f10ac fix: eliminate false 124 timeout race in scheduler command runner
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]>
2026-08-06 09:40:02 +00:00
praisonai-triage-agent[bot]andMervin Praison c69b3a2ee6 fix: buffer early deferred resolutions and make registration atomic (#3716)
- 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]>
2026-08-06 09:30:17 +00:00
praisonai-triage-agent[bot]andMervin Praison 8b9bde62ec fix: validate max_rpm before limiter precedence (PR #3722 review)
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]>
2026-08-06 09:28:40 +00:00
praisonai-triage-agent[bot]andMervin Praison fe2b0d6531 fix: quarantine invalid-UTF-8 session files and use collision-free quarantine paths (#3715)
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]>
2026-08-06 09:27:57 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy b49c3b3f25 fix: auto-create RateLimiter from ExecutionConfig max_rpm (fixes #3718)
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]>
2026-08-06 09:23:03 +00:00
praisonai-triage-agent[bot]andMervinPraison 158de275ae fix: wire deferred tool results to a resolver so long-running results are re-injected (fixes #3716)
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]>
2026-08-06 09:21:45 +00:00
praisonai-triage-agent[bot]andMervinPraison 841b1dd177 fix: quarantine corrupt session files instead of silently resetting history (fixes #3715)
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]>
2026-08-06 09:20:37 +00:00
praisonai-triage-agent[bot]andMervinPraison 682b8d1b34 feat: static capability manifest for plugins/channels without runtime import (fixes #3717)
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]>
2026-08-06 09:19:50 +00:00
praisonai-triage-agent[bot]andMervin Praison 946851453b fix: target CLI interpreter for uv installs, refresh discovery, add --global
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]>
2026-08-06 09:07:42 +00:00
praisonai-triage-agent[bot]andMervin Praison c7b1a0f5f4 fix: serialize first plugin discovery under registry lock
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]>
2026-08-06 09:03:46 +00:00
praisonai-triage-agent[bot]andMervinPraison 343c9363aa feat: add 'plugins add' and 'tools add' install-and-verify CLI commands (fixes #3712)
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]>
2026-08-06 08:58:39 +00:00
praisonai-triage-agent[bot]andMervinPraison 7ebbe47cf6 fix: unify tool discovery under canonical praisonai.tools entry-point group (fixes #3711)
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]>
2026-08-06 08:57:29 +00:00
praisonai-triage-agent[bot]andMervin Praison a2f65e6309 fix: cancel offloaded coro on join-timeout; validate A2U limits as positive
- 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]>
2026-08-06 08:41:12 +00:00
praisonai-triage-agent[bot]andMervin Praison 86dd01a0e2 fix: make workspace confinement opt-in and bind A2U guard to real host
- 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]>
2026-08-06 08:37:08 +00:00
praisonai-triage-agent[bot]andMervinPraison b12b2b6831 fix: harden async bridge, agent file tools, and A2U server (fixes #3709)
- _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]>
2026-08-06 08:28:59 +00:00
MervinPraison d2374cda20 docs: auto-update documentation parity trackers [skip ci] 2026-08-06 08:06:27 +00:00
praisonai-triage-agent[bot] 7ce96766ac Merge pull request #3706 from MervinPraison/claude/issue-3700-20260806-0653
fix: reject hierarchical manager selecting synthetic manager_task
2026-08-06 08:05:54 +00:00
MervinPraison 404c03c95e docs: auto-update api.md [skip ci] 2026-08-06 07:58:40 +00:00
praisonai-triage-agent[bot] 90b35023c4 Merge pull request #3705 from MervinPraison/claude/issue-3703-20260806-0653
fix: fail-closed on output_pydantic parse failure
2026-08-06 07:58:13 +00:00
MervinPraison 8fc5055e2a docs: auto-update feature parity trackers [skip ci] 2026-08-06 07:51:36 +00:00
praisonai-triage-agent[bot] f31bb19a12 Merge pull request #3704 from MervinPraison/claude/issue-3702-20260806-0653
feat: add AgentTeam.start_for_each / astart_for_each batch map API
2026-08-06 07:51:11 +00:00
praisonai-triage-agent[bot]andMervin Praison b00d73de05 fix: reset & restore task state per batch item; validate batch inputs
- 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]>
2026-08-06 07:05:31 +00:00
praisonai-triage-agent[bot]andMervin Praison 15858cf254 fix: identify synthetic manager_task by id, not name, in hierarchical selection
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]>
2026-08-06 07:04:42 +00:00
praisonai-triage-agent[bot]andMervin Praison 216b90b24d fix: accept falsey structured output and preserve parse error detail
- default_completion_checker: treat json_dict/pydantic `is not None` as
  complete so valid falsey JSON ({}, [], false, 0) no longer triggers
  unnecessary retries and terminal failure.
- _process_task_result: capture and surface the underlying JSON/Pydantic
  parse error in TaskResult.error for actionable diagnostics.
- tests: cover empty-object JSON success, preserved error detail, and
  falsey-JSON completion-checker behaviour.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-06 07:03:14 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy e706a2bb85 fix: reject hierarchical manager selecting synthetic manager_task (fixes #3700)
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]>
2026-08-06 06:58:00 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy 38302607e7 fix: fail-closed on output_pydantic/output_json parse failure (fixes #3703)
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]>
2026-08-06 06:57:24 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy 4263111eda feat: add AgentTeam.start_for_each / astart_for_each batch map API (fixes #3702)
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]>
2026-08-06 06:57:18 +00:00
Mervin Praison 06b6c3051e Merge pull request #3678 from MervinPraison/claude/issue-3677-20260805-0717
fix: hierarchical return, shared-memory data loss, per-agent approval scope
2026-08-05 20:30:22 +01:00
MervinPraison ffb8f7d31d docs: auto-update documentation parity trackers [skip ci] 2026-08-05 19:30:11 +00:00
Mervin Praison 139f9c0746 Merge pull request #3698 from MervinPraison/claude/issue-3697-20260805-1717
refactor: DRY ContextConfig→ManagerConfig mapping in Agent.context_manager
2026-08-05 20:29:29 +01:00
MervinPraison ab3e0bb7dc docs: auto-update api.md [skip ci] 2026-08-05 18:29:39 +00:00
praisonai-triage-agent[bot] f65e85c269 Merge pull request #3699 from MervinPraison/claude/issue-3696-20260805-1717
fix: remove dead FunctionTool.get_schema override
2026-08-05 18:29:03 +00:00
praisonai-triage-agent[bot] aadb62acf9 Release v4.6.160 2026-08-05 18:04:40 +00:00
praisonai-triage-agent[bot]andMervinPraison 9e7e426277 fix: remove dead FunctionTool.get_schema override (fixes #3696)
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]>
2026-08-05 17:19:46 +00:00
praisonai-triage-agent[bot]andMervinPraison 2b985071b4 refactor: extract ContextConfig→ManagerConfig mapping helper (fixes #3697)
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]>
2026-08-05 17:19:28 +00:00
praisonai-triage-agent[bot] 468f40d120 Merge pull request #3695 from MervinPraison/feat/claude-code-progress-stream
fix(claude-code): live progress from stream-json + on_event callback
2026-08-05 14:07:45 +00:00
praisonai-triage-agent[bot]andMervin Praison 4420701ff3 fix(claude-code): await async progress callbacks, route SDK progress, tighten test
- 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]>
2026-08-05 12:36:55 +00:00
MervinPraisonandClaude Opus 4.8 d23cd30204 fix(claude-code): make streaming progress actually work + on_event callback
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]>
2026-08-05 13:31:45 +01:00
praisonai-triage-agent[bot] b424558473 Merge pull request #3690 from MervinPraison/claude/issue-3688-20260805-0915
feat: add read-only agent-callable gateway_status tool
2026-08-05 11:02:27 +00:00
MervinPraison a0baace73c docs: auto-update documentation parity trackers [skip ci] 2026-08-05 10:55:50 +00:00
praisonai-triage-agent[bot] 366d09ba87 Merge pull request #3693 from MervinPraison/claude/issue-3692-20260805-0945
feat: no-LLM command action for scheduler jobs
2026-08-05 10:55:08 +00:00
praisonai-triage-agent[bot] a47fd4661d Merge pull request #3686 from MervinPraison/claude/issue-3685-20260805-0850
fix: restore a resumed session's model instead of re-resolving the default
2026-08-05 10:32:35 +00:00
praisonai-triage-agent[bot] 541b4bfd06 Merge pull request #3687 from MervinPraison/claude/issue-3684-20260805-0849
fix: surface read-only plan mode via discoverable --plan flag
2026-08-05 10:22:10 +00:00
praisonai-triage-agent[bot]andMervin Praison cd0aa898ae fix: harden gateway_status serialization for any dataclass snapshot
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]>
2026-08-05 10:17:43 +00:00
praisonai-triage-agent[bot]andMervinPraison 91e6900715 feat: add read-only agent-callable gateway_status tool (fixes #3688)
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]>
2026-08-05 10:17:43 +00:00
MervinPraison cc9d1740da docs: auto-update documentation parity trackers [skip ci] 2026-08-05 10:15:32 +00:00
praisonai-triage-agent[bot] f94b85ada1 Merge pull request #3691 from MervinPraison/claude/issue-3689-20260805-0916
feat: add ask_conversation cross-conversation request/reply tool
2026-08-05 10:14:56 +00:00
praisonai-triage-agent[bot]andMervin Praison 0e665bbec9 fix: normalise corrupt command_timeout so a bad value fails only its own job
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]>
2026-08-05 10:06:26 +00:00
praisonai-triage-agent[bot]andMervin Praison 7aadc2869a fix: bound command output at read boundary and use asyncio.run in tests
- 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]>
2026-08-05 10:00:56 +00:00
praisonai-triage-agent[bot]andMervinPraison f96bf72de9 feat: add no-LLM command action to scheduler jobs (fixes #3692)
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]>
2026-08-05 09:51:48 +00:00
praisonai-triage-agent[bot] 9fcd38acbd Merge pull request #3683 from MervinPraison/claude/issue-3682-20260805-0824
fix: wire --handoff, make approval hook functional, drop duplicate LEGACY TOOLS
2026-08-05 09:31:30 +00:00
praisonai-triage-agent[bot]andMervin Praison 897f0e91a2 fix: clamp ask_conversation timeout to a finite positive bound
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]>
2026-08-05 09:28:52 +00:00
praisonai-triage-agent[bot]andMervinPraison 9acafb1011 feat: add ask_conversation cross-conversation request/reply tool (fixes #3689)
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]>
2026-08-05 09:22:28 +00:00
MervinPraison f084e06ea5 docs: auto-update documentation parity trackers [skip ci] 2026-08-05 09:18:26 +00:00
praisonai-triage-agent[bot] 602f5f6338 Merge pull request #3679 from MervinPraison/claude/issue-3670-20260805-0729
feat: capture-after-setup env reuse keyed by definition hash
2026-08-05 09:17:41 +00:00
praisonai-triage-agent[bot]andMervin Praison ee2d56ac7f fix: propagate plan mode through profiled runs and REPL worker
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]>
2026-08-05 09:07:22 +00:00
praisonai-triage-agent[bot]andMervin Praison 2f534609cc fix: resolve session model before local fallback + restore legacy top-level model on load
- 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]>
2026-08-05 09:03:58 +00:00
Mervin Praison 692e3c2067 Merge pull request #3681 from MervinPraison/claude/issue-3680-20260805-0746
fix: prevent Windows cp1252 crash in praisonai-deploy docker.py Python API path
2026-08-05 10:02:41 +01:00
praisonai-triage-agent[bot]andMervinPraison 8969d8d8ed fix: surface read-only plan mode via discoverable --plan flag (fixes #3684)
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]>
2026-08-05 08:57:27 +00:00
praisonai-triage-agent[bot]andMervinPraison 279bc07f1e fix: restore a resumed session's model instead of re-resolving the default (fixes #3685)
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]>
2026-08-05 08:54:59 +00:00
praisonai-triage-agent[bot]andMervin Praison 0b8abfbe8b refactor: drop dead ApprovalSpec.install_hook parallel enforcement path
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]>
2026-08-05 08:38:28 +00:00
praisonai-triage-agent[bot]andMervinPraison 3eb3718776 fix: wire --handoff, make approval hook functional, drop duplicate LEGACY TOOLS (fixes #3682)
- 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]>
2026-08-05 08:31:16 +00:00
praisonai-triage-agent[bot]andMervin Praison 4b34c8221d fix: replace emoji prints in api.py and cloud providers to complete cp1252 crash fix (#3680)
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]>
2026-08-05 07:55:01 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy 11cfa85ade fix: replace emoji prints in docker.py with ASCII to prevent Windows cp1252 crash (fixes #3680)
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]>
2026-08-05 07:49:10 +00:00
praisonai-triage-agent[bot]andMervin Praison 8ff2277673 fix: isolate captures per secret context + lock registry writes
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]>
2026-08-05 07:46:17 +00:00
praisonai-triage-agent[bot]andMervin Praison 583ac1e01c feat: capture-after-setup env reuse keyed by definition hash (fixes #3670)
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]>
2026-08-05 07:36:33 +00:00
praisonai-triage-agent[bot]andMervin Praison 878cf5c23b fix: skip leftover manager_task on repeated hierarchical runs
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]>
2026-08-05 07:33:32 +00:00
Mervin Praison 7fcca21480 Merge pull request #3676 from MervinPraison/ci/release-pipeline-hardening
ci(release): harden the release pipeline + first-class single-package releases
2026-08-05 08:27:12 +01:00
praisonai-triage-agent[bot]andMervinPraison 3f0354d6d1 fix: hierarchical return, shared-memory data loss, per-agent approval scope (fixes #3677)
- 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]>
2026-08-05 07:26:56 +00:00
MervinPraisonandClaude Fable 5 0ca67c6b47 ci(release): harden the pipeline and add first-class single-package releases
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]>
2026-08-05 07:52:22 +01:00
MervinPraison de48d26505 docs: auto-update api.md [skip ci] 2026-08-05 00:25:10 +00:00
Mervin Praison 5561e78a63 Merge pull request #3638 from MervinPraison/claude/issue-3636-20260804-0853
feat: declarative per-tool model-facing output (model_output/to_model_output) for context economy
2026-08-05 01:24:37 +01:00
Mervin Praison 45b694816f Merge pull request #3673 from MervinPraison/claude/issue-3672-20260804-1715
fix: memoise pure model-capability lookups in LLM hot path
2026-08-05 01:24:14 +01:00
MervinPraison fe4927e809 docs: auto-update feature parity trackers [skip ci] 2026-08-05 00:24:04 +00:00
Mervin Praison 770666f990 Merge pull request #3675 from MervinPraison/claude/issue-3674-20260804-2348
fix: expose PraisonAIAgents alias from package root
2026-08-05 01:23:38 +01:00
praisonai-triage-agent[bot]andMervin Praison bbd128327a fix: update stale v4 removal tests to restored PraisonAIAgents alias contract (#3674)
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]>
2026-08-04 23:55:27 +00:00
praisonai-triage-agent[bot]andMervinPraison ca06462b62 fix: expose PraisonAIAgents alias from package root (fixes #3674)
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]>
2026-08-04 23:50:15 +00:00
praisonai-triage-agent[bot]andMervinPraison a61add405c fix: memoise pure model-capability lookups in LLM hot path (fixes #3672)
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]>
2026-08-04 17:18:01 +00:00
MervinPraison fbbeacfe7e docs: auto-update documentation parity trackers [skip ci] 2026-08-04 16:14:45 +00:00
praisonai-triage-agent[bot] 580b02ee54 Merge pull request #3671 from MervinPraison/claude/issue-3669-20260804-1506
feat: add .praisonai/environment.yaml loader + ComputeConfig.setup
2026-08-04 16:14:07 +00:00
praisonai-triage-agent[bot]andMervin Praison 213d255d33 fix: wire environment loader to live consumer, validate nested shapes, clean up leaked container
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]>
2026-08-04 15:17:25 +00:00
praisonai-triage-agent[bot]andMervinPraison ee83a4a454 feat: add .praisonai/environment.yaml loader + ComputeConfig.setup (fixes #3669)
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]>
2026-08-04 15:10:45 +00:00
praisonai-triage-agent[bot] cffde6682e Merge pull request #3668 from MervinPraison/claude/issue-3665-20260804-1027
docs: correct-signature human-review team example + regression test
2026-08-04 12:07:33 +00:00
praisonai-triage-agent[bot] 9e7de1a53f Merge pull request #3667 from MervinPraison/claude/issue-3666-20260804-1027
docs: add minimal sequential team YAML example (fixes #3666)
2026-08-04 12:03:11 +00:00
praisonai-triage-agent[bot] 80a7acaa70 Merge pull request #3664 from MervinPraison/claude/issue-3654-20260804-1008
fix: migrate invalid AgentTeam kwargs to output=/execution=
2026-08-04 12:02:24 +00:00
praisonai-triage-agent[bot] 9a2c969d4e Merge pull request #3663 from MervinPraison/claude/issue-3650-20260804-1008
feat: add praisonai init team multi-agent project scaffold
2026-08-04 11:51:32 +00:00
praisonai-triage-agent[bot] 73de340045 Merge pull request #3661 from MervinPraison/claude/issue-3655-20260804-1008
fix: ASCII-safe capabilities in models CLI on Windows cp1252
2026-08-04 11:45:38 +00:00
MervinPraison c33129f633 docs: auto-update api.md [skip ci] 2026-08-04 11:40:52 +00:00
praisonai-triage-agent[bot] 0074495b80 Merge pull request #3660 from MervinPraison/claude/issue-3651-20260804-1008
feat: unified remember/recall/forget convenience API for Memory
2026-08-04 11:40:25 +00:00
MervinPraison f3dacae7b7 docs: auto-update documentation parity trackers [skip ci] 2026-08-04 11:35:01 +00:00
praisonai-triage-agent[bot] 4163d7bd36 Merge pull request #3659 from MervinPraison/claude/issue-3657-20260804-1008
fix: OpenAI-compatible ManagerInstructions schema for hierarchical process
2026-08-04 11:34:20 +00:00
MervinPraison 018839104d docs: auto-update documentation parity trackers [skip ci] 2026-08-04 11:18:10 +00:00
praisonai-triage-agent[bot] f5d862e9cb Merge pull request #3662 from MervinPraison/claude/issue-3649-20260804-1008
fix: wire delegate_task tool to subagent runtime
2026-08-04 11:17:31 +00:00
praisonai-triage-agent[bot] fb39305667 Merge pull request #3658 from MervinPraison/claude/issue-3656-20260804-1008
fix: correct stale resolve_output import in precedence ladder tests
2026-08-04 11:11:22 +00:00
praisonai-triage-agent[bot] b434a69080 Merge pull request #3641 from MervinPraison/claude/issue-3639-20260804-0918
fix: make shell auto-approval exposure-aware on inbound channels
2026-08-04 11:09:38 +00:00
MervinPraison 4240b98e11 docs: auto-update documentation parity trackers [skip ci] 2026-08-04 11:02:25 +00:00
praisonai-triage-agent[bot] 9a3fcdc525 Merge pull request #3642 from MervinPraison/claude/issue-3640-20260804-0918
fix: add fail-closed read to degraded-owner contract (#3640)
2026-08-04 11:01:49 +00:00
praisonai-triage-agent[bot] f8978ea08b Merge pull request #3647 from MervinPraison/claude/issue-3645-20260804-0951
fix: point code REPL/TUI session store at canonical sessions root
2026-08-04 10:53:05 +00:00
MervinPraison b1ff51c305 docs: auto-update documentation parity trackers [skip ci] 2026-08-04 10:44:15 +00:00
praisonai-triage-agent[bot] 214d0ea94f Merge pull request #3648 from MervinPraison/claude/issue-3646-20260804-0952
feat: local-first session mirror protocol + non-blocking dual-write (#3646)
2026-08-04 10:43:42 +00:00
praisonai-triage-agent[bot]andMervin Praison 9d04e581e9 docs: correct loader/ordering semantics in sequential team example
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]>
2026-08-04 10:36:00 +00:00
praisonai-triage-agent[bot]andMervin Praison 0064f0c919 fix: use variables={{topic}} interpolation in human-review team example
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]>
2026-08-04 10:35:54 +00:00
praisonai-triage-agent[bot] 8a87d89466 Merge pull request #3644 from MervinPraison/claude/issue-3643-20260804-0942
fix: cluster-wide turn-lock protocol for multi-replica gateways
2026-08-04 10:34:55 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy 0f384c95b2 docs: add correct-signature human-review team example + regression test (fixes #3665)
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]>
2026-08-04 10:29:59 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy 38cfcbaf45 docs: add minimal sequential team YAML example (fixes #3666)
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]>
2026-08-04 10:29:13 +00:00
praisonai-triage-agent[bot]andMervin Praison 97bcf005fd test: extend AgentTeam kwarg lint to cover migrated crewai-tools-example
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]>
2026-08-04 10:24:44 +00:00
MervinPraison 72feffbe4f docs: auto-update documentation parity trackers [skip ci] 2026-08-04 10:24:16 +00:00
MervinPraison 8904471fd6 docs: auto-update api.md [skip ci] 2026-08-04 10:23:44 +00:00
praisonai-triage-agent[bot] 14c5e76645 Merge pull request #3637 from MervinPraison/claude/issue-3635-20260804-0852
fix: durable transcript-backed session resume for AgentTeam
2026-08-04 10:23:19 +00:00
praisonai-triage-agent[bot]andMervin Praison 805358129b fix: make generated team project runnable and topic-aware
- 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]>
2026-08-04 10:22:55 +00:00
praisonai-triage-agent[bot]andMervin Praison 3d2fd74090 fix: enforce delegate_task timeout via bounded worker thread
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]>
2026-08-04 10:18:24 +00:00
praisonai-triage-agent[bot]andMervin Praison 77a815adcc fix: make plain-table fallback reachable when Rich unavailable in models list
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]>
2026-08-04 10:18:19 +00:00
praisonai-triage-agent[bot]andMervin Praison 4a926a83aa fix: guard forget() empty query and scope to long-term memory
- 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]>
2026-08-04 10:17:12 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy 92c2b6dd64 fix: migrate invalid AgentTeam kwargs to output=/execution= (fixes #3654)
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]>
2026-08-04 10:16:49 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy 4e1fd9af2b feat: add praisonai init team multi-agent project scaffold (fixes #3650)
Adds a `team` subcommand to `praisonai init` that generates a runnable,
YAML-first AgentTeam starter project (agents.yaml + tasks.yaml + team.py +
main.py + README + .env.example + optional pyproject.toml).

- Reuses existing AgentTeam/Agent/Task runtime — no new core SDK surface.
- Single-agent init behaviour is unchanged.
- Flags: --process sequential|hierarchical, --agents N, --force, --no-pyproject.
- ASCII-only templates for Windows (cp1252) safety.

Co-authored-by: Dhivya-Bharathy <[email protected]>
2026-08-04 10:13:20 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy 264f851d3e fix: wire delegate_task tool to subagent runtime (fixes #3649)
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]>
2026-08-04 10:12:45 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy fd6f314c6e fix: ASCII-safe capabilities in models CLI on Windows cp1252 (fixes #3655)
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]>
2026-08-04 10:11:40 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy eadda0c53d feat: add unified remember/recall/forget convenience API to Memory (fixes #3651)
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]>
2026-08-04 10:11:21 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy 4fd988ff9a fix: OpenAI-compatible ManagerInstructions schema for hierarchical process (fixes #3657)
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]>
2026-08-04 10:11:00 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy 61a330f8d7 fix: correct stale resolve_output/resolve_execution import in precedence ladder tests (fixes #3656)
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]>
2026-08-04 10:10:28 +00:00
praisonai-triage-agent[bot]andMervin Praison 76714ca234 fix: mirror transcript replacements, reject post-close appends, order writer after makedirs (#3646)
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]>
2026-08-04 10:04:47 +00:00
praisonai-triage-agent[bot]andMervinPraison f9523a16e5 feat: add local-first SessionMirrorProtocol + non-blocking dual-write hook (#3646)
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]>
2026-08-04 09:58:01 +00:00
praisonai-triage-agent[bot]andMervin Praison e6ed451518 fix: identity-checked turn-lock release + config hardening (#3643)
- 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]>
2026-08-04 09:55:35 +00:00
praisonai-triage-agent[bot]andMervinPraison 726394e6d1 fix: point code REPL/TUI session store at canonical sessions root (fixes #3645)
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]>
2026-08-04 09:54:33 +00:00
praisonai-triage-agent[bot]andMervin Praison ccc9d6806b fix: add cluster-wide turn-lock protocol for multi-replica gateways (fixes #3643)
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]>
2026-08-04 09:47:18 +00:00
praisonai-triage-agent[bot]andMervin Praison a423a5ba8f fix: close shell auto-approval exposure gaps on inbound channels
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]>
2026-08-04 09:31:00 +00:00
praisonai-triage-agent[bot]andMervin Praison 87d2ba3d1e fix: preserve backward-compat protocol contract + fix B015 in degraded-owner guard (#3640)
- 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]>
2026-08-04 09:28:49 +00:00
praisonai-triage-agent[bot]andMervin Praison 538ef47769 fix: add fail-closed read to degraded-owner contract (fixes #3640)
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]>
2026-08-04 09:22:56 +00:00
praisonai-triage-agent[bot]andMervin Praison 78c20c60b1 fix: make shell auto-approval exposure-aware on inbound channels (fixes #3639)
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]>
2026-08-04 09:22:48 +00:00
praisonai-triage-agent[bot]andMervin Praison 4eedb85445 fix: keep compact model-output safe — trust-fence it, preserve full result for loop detection/hooks
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]>
2026-08-04 09:12:09 +00:00
praisonai-triage-agent[bot]andMervin Praison f50aea8040 fix: propagate durable team-session save result + harden resume tests
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]>
2026-08-04 09:03:50 +00:00
praisonai-triage-agent[bot]andMervinPraison 22ce20c8ad feat: declarative per-tool model-facing output (model_output/to_model_output) for context economy (fixes #3636)
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]>
2026-08-04 09:01:51 +00:00
praisonai-triage-agent[bot]andMervinPraison bf4c609fc0 fix: durable transcript-backed session resume for AgentTeam (fixes #3635)
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]>
2026-08-04 08:56:33 +00:00
Mervin Praison 631299846c Merge pull request #3634 from MervinPraison/claude/issue-3633-20260804-0833
fix: surface FileNotFound, unify auto-generator defaults, harden push polling fallback
2026-08-04 09:53:09 +01:00
MervinPraison 25f9812a5e docs: auto-update feature parity trackers [skip ci] 2026-08-04 08:51:56 +00:00
Mervin Praison c98abe685c Merge pull request #3632 from MervinPraison/claude/issue-3631-20260804-0718
fix: enforce policy/guardrail contracts in praisonaiagents core
2026-08-04 09:51:24 +01:00
praisonai-triage-agent[bot]andMervin Praison 1afe7d0bfc fix: gate WorkflowAutoGenerator default on SUPPORTS_WORKFLOW; require active connection for supports_publish
- _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]>
2026-08-04 08:51:08 +00:00
praisonai-triage-agent[bot]andMervinPraison 5a8e98a257 fix: surface FileNotFound, unify auto-generator defaults, harden push polling fallback (fixes #3633)
- 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]>
2026-08-04 08:40:13 +00:00
praisonai-triage-agent[bot]andMervin Praison ff2eb1be94 fix: close streaming guardrail + read-only policy enforcement gaps
- Move guardrail-bypass warning into _start_stream() so start(stream=True)
  warns too, not only iter_stream() (Greptile P1).
- Extend read-only preset to deny built-in mutating tools edit_file,
  apply_patch, copy_file, move_file, append_file (Greptile P1).
- Extend regression test to cover the new mutating tool names.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-04 07:29:54 +00:00
praisonai-triage-agent[bot]andMervinPraison 95b7e03679 fix: enforce policy/guardrail contracts in praisonaiagents core (fixes #3631)
- 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]>
2026-08-04 07:22:33 +00:00
praisonai-triage-agent[bot] c5559ef300 Merge pull request #3630 from MervinPraison/claude/issue-3629-20260804-0454
fix: set Gunicorn --timeout 120 in generated Dockerfile
2026-08-04 06:12:12 +00:00
praisonai-triage-agent[bot]andMervin Praison 1791fb9551 test: assert exact Gunicorn --timeout/--graceful-timeout arg pairs
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]>
2026-08-04 05:00:22 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy 97a10c6fc3 fix: set Gunicorn --timeout 120 in generated Dockerfile (fixes #3629)
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]>
2026-08-04 04:56:20 +00:00
Mervin Praison 860470e3a8 Merge pull request #3628 from MervinPraison/ci/release-descriptive-bump-commit
ci(release): name the packages in the safety-net commit message
2026-08-04 01:33:29 +01:00
MervinPraisonandClaude Opus 5 ce0ae7942e ci(release): name the packages in the safety-net commit message
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]>
2026-08-04 01:33:04 +01:00
MervinPraison d4a8778cc1 docs: auto-update documentation parity trackers [skip ci] 2026-08-03 17:41:55 +00:00
Mervin Praison 81cf493c7e Merge pull request #3622 from MervinPraison/claude/issue-3621-20260803-0924
fix: close three gateway/bot robustness gaps (#3621)
2026-08-03 18:41:19 +01:00
Mervin Praison 1747640e46 Merge pull request #3627 from MervinPraison/claude/issue-3626-20260803-1715
perf: memoise on_progress signature probe in call_executor
2026-08-03 18:40:53 +01:00
praisonai-triage-agent[bot]andMervin Praison 300fe0dc3e fix: use weak, hashing-safe cache for on_progress probe
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]>
2026-08-03 17:24:06 +00:00
praisonai-triage-agent[bot]andMervinPraison f1afa57291 perf: memoise on_progress signature probe in call_executor (fixes #3626)
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]>
2026-08-03 17:18:03 +00:00
praisonai-triage-agent[bot] 8785911812 Merge pull request #3620 from MervinPraison/claude/issue-3618-20260803-0854
feat: config-declared, layerable instruction/context sources
2026-08-03 11:55:04 +00:00
Mervin Praison 0efc251918 Merge pull request #3624 from MervinPraison/claude/issue-3623-20260803-0946
fix: cross-platform voice-reply (TTS-out) gateway primitive
2026-08-03 12:04:44 +01:00
praisonai-triage-agent[bot] 00bc40e584 Merge pull request #3619 from MervinPraison/claude/issue-3617-20260803-0853
fix: permission-gate YAML workflows on the unified run pipeline
2026-08-03 10:40:21 +00:00
praisonai-triage-agent[bot]andMervin Praison e6dde15000 fix: apply voice-reply policy in streaming path, gateway inbound state & forward speed (#3623)
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]>
2026-08-03 09:59:40 +00:00
praisonai-triage-agent[bot]andMervin Praison ea02170892 fix: add cross-platform voice-reply (TTS-out) gateway primitive (fixes #3623)
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]>
2026-08-03 09:52:30 +00:00
praisonai-triage-agent[bot]andMervin Praison c47c3edf15 fix: land #3621 reviewer fixes on effective seams (hint→prompt, metadata override, accurate replay)
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]>
2026-08-03 09:48:22 +00:00
praisonai-triage-agent[bot]andMervin Praison c536c42d72 fix: close three gateway/bot robustness gaps (fixes #3621)
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]>
2026-08-03 09:34:45 +00:00
praisonai-triage-agent[bot]andMervin Praison 3628e5c935 fix: thread instructions through --agent/--command paths + SSRF-guard remote fetch
- 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]>
2026-08-03 09:20:59 +00:00
MervinPraison 8e2051ef9a docs: auto-update documentation parity trackers [skip ci] 2026-08-03 09:14:42 +00:00
MervinPraison d4eb09ab1d docs: auto-update feature parity trackers [skip ci] 2026-08-03 09:12:07 +00:00
praisonai-triage-agent[bot]andMervin Praison c3e6af47f0 fix: preserve YAML approval flags across the legacy parse_args() reparse
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]>
2026-08-03 09:10:01 +00:00
Mervin Praison b5b6eb0276 Merge pull request #3616 from MervinPraison/claude/issue-3615-20260803-0822
fix: close three wrapper safety/correctness gaps (call auth, protected-path symlink, run_python quoting)
2026-08-03 10:09:41 +01:00
Mervin Praison 72f430c2c6 Merge pull request #3614 from MervinPraison/claude/issue-3613-20260803-0716
fix: argument-scope tool approvals and isolate default memory user_id
2026-08-03 10:09:02 +01:00
Mervin Praison ad05a34d6f Merge pull request #3601 from MervinPraison/claude/issue-3600-20260802-1617
fix: consolidate cron due-check into core next_fire_time helper
2026-08-03 10:08:14 +01:00
Mervin Praison 482d8a9dc6 Merge pull request #3591 from MervinPraison/claude/issue-3589-20260802-0850
fix: path-scope shell approval targets for out-of-workspace paths
2026-08-03 10:07:49 +01:00
MervinPraison 4af50a2b5d docs: auto-update documentation parity trackers [skip ci] 2026-08-03 09:07:37 +00:00
Mervin Praison 1fe4aa40dd Merge pull request #3586 from MervinPraison/claude/issue-3585-20260802-0714
fix: gather exception isolation, handoff chain guard, atomic memory writes
2026-08-03 10:07:04 +01:00
praisonai-triage-agent[bot]andMervinPraison 94a7f2a201 feat: config-declared, layerable instruction/context sources (fixes #3618)
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]>
2026-08-03 09:05:01 +00:00
praisonai-triage-agent[bot]andMervinPraison f9e8112b9b fix: permission-gate YAML workflows on the unified run pipeline (fixes #3617)
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]>
2026-08-03 08:59:29 +00:00
Mervin Praison 796f56780f Merge pull request #3609 from MervinPraison/claude/issue-3604-20260803-0627
fix: honor deploy.api YAML block for docker/cloud deploy
2026-08-03 09:51:14 +01:00
praisonai-triage-agent[bot]andMervin Praison 9f6326fee7 fix: bind protected-path check to the real target to close symlink TOCTOU/escape (fixes #3615)
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]>
2026-08-03 08:38:54 +00:00
praisonai-triage-agent[bot] 74eff72b5d Merge pull request #3612 from MervinPraison/claude/issue-3606-20260803-0629
fix: cross-platform API destroy for Windows (lsof)
2026-08-03 08:30:38 +00:00
praisonai-triage-agent[bot]andMervinPraison ed270cadc3 fix: close three wrapper safety/correctness gaps (fixes #3615)
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]>
2026-08-03 08:28:22 +00:00
praisonai-triage-agent[bot] 0da0221000 Merge pull request #3611 from MervinPraison/claude/issue-3605-20260803-0629
fix: force UTF-8 stdio in praisonai-deploy CLI (Windows cp1252 crash)
2026-08-03 08:24:19 +00:00
praisonai-triage-agent[bot]andMervin Praison 12ac9dc0ff fix: drop dead cloud deploy.api parse; add docker auth-env runtime tests
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]>
2026-08-03 08:15:33 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy 728a15633b fix: honor deploy.api YAML block for docker/cloud deploy (fixes #3604)
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]>
2026-08-03 08:15:33 +00:00
praisonai-triage-agent[bot] cb9f696e9b Merge pull request #3610 from MervinPraison/claude/issue-3607-20260803-0629
fix: use pytest tmp_path in deploy schema tests for Windows compatibility
2026-08-03 08:10:13 +00:00
praisonai-triage-agent[bot] 4184faa4ae Merge pull request #3608 from MervinPraison/claude/issue-3603-20260803-0627
fix: use sys.executable for background API deploy
2026-08-03 08:02:09 +00:00
praisonai-triage-agent[bot]andMervin Praison 6d3047bd5f test: align test_improved_decorator with argument-scoped approvals
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]>
2026-08-03 07:39:02 +00:00
praisonai-triage-agent[bot]andMervin Praison 45272b1d17 fix: scope @require_approval by positional args too (fixes #3613)
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]>
2026-08-03 07:28:18 +00:00
praisonai-triage-agent[bot]andMervinPraison 2804f1254f fix: argument-scope tool approvals and isolate default memory user_id (fixes #3613)
- 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]>
2026-08-03 07:20:50 +00:00
praisonai-triage-agent[bot]andMervin Praison ecb164efa1 fix: report taskkill/destroy failures instead of false success
- _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]>
2026-08-03 06:37:37 +00:00
praisonai-triage-agent[bot]andMervin Praison 4dc034cd9c test: cover praisonai-deploy __main__ stdio safeguard + replace policy (#3605)
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]>
2026-08-03 06:36:41 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy 795644e500 fix: cross-platform API destroy for Windows (fixes #3606)
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]>
2026-08-03 06:32:18 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy ddfd86dbac fix: force UTF-8 stdio in praisonai-deploy CLI to prevent cp1252 crash (fixes #3605)
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]>
2026-08-03 06:32:11 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy 36e5408849 fix: use pytest tmp_path in deploy schema tests for Windows compatibility (fixes #3607)
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]>
2026-08-03 06:31:23 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy 5804871151 fix: use sys.executable for background/foreground API deploy (fixes #3603)
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]>
2026-08-03 06:29:35 +00:00
praisonai-triage-agent[bot] 5521a7e737 Merge pull request #3602 from MervinPraison/claude/issue-3599-20260802-1617
fix: dedupe tool-source resolution helper across three wrapper modules
2026-08-02 17:20:17 +00:00
praisonai-triage-agent[bot]andMervinPraison 7f7e3f51f1 fix: dedupe tool-source resolution helper across three wrapper modules (fixes #3599)
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]>
2026-08-02 16:23:09 +00:00
praisonai-triage-agent[bot]andMervinPraison bbd589c11d fix: consolidate cron due-check into core next_fire_time helper (fixes #3600)
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]>
2026-08-02 16:20:51 +00:00
MervinPraison 700714dead docs: auto-update documentation parity trackers [skip ci] 2026-08-02 10:40:11 +00:00
praisonai-triage-agent[bot] 83f27a527e Merge pull request #3598 from MervinPraison/claude/issue-3597-20260802-0947
fix: make session-store durable-write failures observable + recoverable
2026-08-02 10:39:40 +00:00
praisonai-triage-agent[bot] 88bf9a1e8f Merge pull request #3595 from MervinPraison/claude/issue-3593-20260802-0914
fix: persist gateway conversation history by default
2026-08-02 10:19:18 +00:00
MervinPraison 4cfb558c4c docs: auto-update documentation parity trackers [skip ci] 2026-08-02 10:10:22 +00:00
praisonai-triage-agent[bot] 4cc0f46f7b Merge pull request #3596 from MervinPraison/claude/issue-3594-20260802-0914
feat: add portable table and chart presentation blocks
2026-08-02 10:09:49 +00:00
praisonai-triage-agent[bot]andMervin Praison 066d9e1df3 fix: harden session spill recovery (collision, retention, payload)
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]>
2026-08-02 10:00:05 +00:00
praisonai-triage-agent[bot] a7de297a78 Merge pull request #3592 from MervinPraison/claude/issue-3590-20260802-0850
feat: add session share/unshare to publish redacted transcripts
2026-08-02 09:56:18 +00:00
praisonai-triage-agent[bot]andMervinPraison 4c8a6294e7 fix: make session-store durable-write failures observable + recoverable (fixes #3597)
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]>
2026-08-02 09:53:55 +00:00
praisonai-triage-agent[bot]andMervin Praison d3c9973145 fix: honor gateway session.persist opt-out and never crash on durable-by-default (#3593)
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]>
2026-08-02 09:27:59 +00:00
praisonai-triage-agent[bot]andMervin Praison 05af67ecca fix: make table/chart fallback rendering backward-compatible with older core
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]>
2026-08-02 09:25:34 +00:00
praisonai-triage-agent[bot]andMervinPraison ffd37b4df1 feat: add portable table and chart presentation blocks (fixes #3594)
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]>
2026-08-02 09:19:01 +00:00
praisonai-triage-agent[bot]andMervinPraison 50eab3b9dc fix: persist gateway conversation history by default (fixes #3593)
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]>
2026-08-02 09:16:56 +00:00
praisonai-triage-agent[bot] 8a75bd17c7 Merge pull request #3588 from MervinPraison/claude/issue-3587-20260802-0820
fix: close unauth RCE surface, cross-generator tool corruption, async session-create drop
2026-08-02 09:15:50 +00:00
praisonai-triage-agent[bot]andMervin Praison 308d984438 fix: preserve shell command identity in approval target (security)
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]>
2026-08-02 09:09:00 +00:00
praisonai-triage-agent[bot]andMervin Praison cdd41eb4bd fix: harden session share/unshare filesystem handling and copy
- 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]>
2026-08-02 09:02:41 +00:00
praisonai-triage-agent[bot]andMervinPraison 9c275929bc feat: add session share/unshare to publish redacted transcripts (fixes #3590)
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]>
2026-08-02 08:55:35 +00:00
praisonai-triage-agent[bot]andMervinPraison b2589fe5b5 fix: path-scope shell approval targets for out-of-workspace paths (fixes #3589)
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]>
2026-08-02 08:55:12 +00:00
praisonai-triage-agent[bot]andMervin Praison bafcc358c3 fix: preserve tool type identity in timeout proxy so isinstance dispatch works
_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]>
2026-08-02 08:39:24 +00:00
praisonai-triage-agent[bot]andMervinPraison 0ba6829984 fix: close unauth RCE surface, cross-generator tool corruption, async session-create drop (fixes #3587)
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]>
2026-08-02 08:28:56 +00:00
praisonai-triage-agent[bot]andMervin Praison f68d7dbee1 fix: use unique temp name for atomic memory writes (concurrent-safe)
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]>
2026-08-02 07:25:49 +00:00
praisonai-triage-agent[bot]andMervinPraison 5ed306c5da fix: gather exception isolation, handoff chain guard, atomic memory writes (fixes #3585)
- 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]>
2026-08-02 07:20:17 +00:00
MervinPraison 119b551eb1 docs: auto-update feature parity trackers [skip ci] 2026-08-02 05:06:45 +00:00
Mervin Praison c587eea8d6 Merge pull request #3581 from MervinPraison/claude/issue-3580-20260801-0944
feat: add generic declarative webhook-trigger channel (any HTTP event to agent)
2026-08-02 06:06:13 +01:00
praisonai-triage-agent[bot] ce0a38d723 Release v4.6.159 2026-08-01 18:16:53 +00:00
praisonai-triage-agent[bot] 17091e1426 Merge pull request #3583 from MervinPraison/claude/issue-3582-20260801-1615
fix: remove orphaned legacy AutoGen tool-wrapper dead code
2026-08-01 17:14:40 +00:00
praisonai-triage-agent[bot]andMervin Praison 0b569f8427 fix: remove orphaned legacy AutoGen tool-wrapper dead code (fixes #3582)
Delete create_autogen_tool_function() and the 17 generated autogen_<Tool>
symbols plus their __all__ from inbuilt_tools/autogen_tools.py. Their only
consumer, ToolRegistry.register_builtin_autogen_adapters(), was removed in
the #1590 fix; the canonical AutoGen path is framework_adapters/autogen_adapter.py.

Keeps the praisonai_tools availability probe that feeds PRAISONAI_TOOLS_AVAILABLE
and the release `import *` smoke test.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-01 16:16:56 +00:00
praisonai-triage-agent[bot]andMervin Praison 816d259d35 fix: wire webhook channel into gateway; fail-loud dispatch; durable ingress id
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]>
2026-08-01 10:32:50 +00:00
praisonai-triage-agent[bot]andMervinPraison f05d3bcbd3 feat: add generic declarative webhook-trigger channel (fixes #3580)
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]>
2026-08-01 10:31:52 +00:00
praisonai-triage-agent[bot] 2485e65fea Merge pull request #3579 from MervinPraison/claude/issue-3578-20260801-0914
fix: launch registered plugin channels via registry seam
2026-08-01 10:30:19 +00:00
praisonai-triage-agent[bot]andMervinPraison 92da64a23d fix: launch registered plugin channels via registry seam (fixes #3578)
Route WebSocketGateway._create_bot through the shared platform registry
(resolve_adapter) instead of a hardcoded if/elif over the seven built-ins.
Any channel the registry resolves — built-in, register_platform(), or a
praisonai.channels entry point — is now instantiated and started, with
ch_cfg passed through generically (mirroring probe_channels). An
unresolvable or unconstructable platform is recorded as a degraded channel
(visible in health/doctor) rather than a silent skip. Per-platform token
env-var fallbacks (email/AgentMail/Linear) are preserved.

Co-authored-by: MervinPraison <[email protected]>
2026-08-01 09:19:57 +00:00
praisonai-triage-agent[bot] e27d128f21 Merge pull request #3577 from MervinPraison/claude/issue-3576-20260801-0816
fix: close silent data/correctness gaps in praisonai wrapper
2026-08-01 09:13:41 +00:00
praisonai-triage-agent[bot]andMervin Praison 5a8e9ccbd6 fix: prevent agent_config leak on shared agents; warn on legacy chroma metric
- agent_invoke: _apply_agent_config now rejects overrides on non-isolatable
  (shared registry) agents instead of mutating a shared object, preventing
  config from leaking across concurrent/subsequent requests. Drop the
  never-settable `temperature` key from the whitelist so the surface is honest.
- vector_stores: warn (once per collection) when an existing/legacy Chroma
  collection uses a non-cosine distance metric, surfacing incomparable scores
  without a destructive auto-migration of stored vectors.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-01 08:28:24 +00:00
praisonai-triage-agent[bot]andMervin Praison 13844802ad fix: close silent data/correctness gaps in praisonai wrapper (fixes #3576)
- ChromaVectorStore: centralise namespace->collection resolution so ad-hoc
  namespaces keep the cosine metric; return the stored embedding (not the
  query vector) from query(); scope delete_all to the caller's namespace.
- agent_invoke: honour agent_config overrides against the isolated clone
  via a whitelist, rejecting unknown/unsettable keys with 400 (route) or a
  clean error dict (standalone) instead of silently ignoring them.
- a2u_server.publish_sync: use get_running_loop, track the scheduled task
  (WeakSet) so it can't be GC'd and surface its exceptions; expose
  last_publish_task() for the real delivered count.
- push/client + acp/server: replace deprecated asyncio.get_event_loop()
  with get_running_loop()/to_thread inside coroutines.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-01 08:21:00 +00:00
MervinPraison 1fb210296b docs: auto-update documentation parity trackers [skip ci] 2026-08-01 08:12:49 +00:00
praisonai-triage-agent[bot] dd9b819190 Merge pull request #3574 from MervinPraison/claude/issue-3573-20260801-0715
fix: guardrail fail-closed, failover locking, tool-output run isolation
2026-08-01 08:12:18 +00:00
Mervin Praison 45b19a39d4 Merge pull request #3575 from MervinPraison/ci/scope-release-app-token
ci(release): scope the release app token to this repository
2026-08-01 08:35:45 +01:00
MervinPraisonandClaude Opus 5 138d2fcfce ci(release): scope the release app token to this repository
Both create-github-app-token steps omitted `repositories:`, so the run log
read "creating token for all repositories for given owner MervinPraison" --
the token carried write access to every repo under the account while the
job only ever touches PraisonAI.

Verified single-repo before narrowing: the workflow makes no cross-repo
call, and bump_and_release.py's only gh invocation is `gh release create`
against this repo, with all git operations on origin.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-01 08:35:25 +01:00
praisonai-triage-agent[bot]andMervin Praison 12e7304586 fix: run failover callbacks outside lock; fix second Gap-3 run-id typo
- failover.py: snapshot next_profile + callbacks under the RLock, then
  invoke callbacks after releasing it. User callbacks may block or take
  external locks; running them under the shared credential-pool lock could
  stall every concurrent agent and risk lock-ordering deadlocks (Greptile).
- chat_mixin.py: _run_id -> _current_run_id at the second tool-output
  truncation call site the PR missed; previously passed None into
  get_tool_output_store, defeating the run isolation Gap 3 restores.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-01 07:25:36 +00:00
praisonai-triage-agent[bot]andMervinPraison f56c25bd78 fix: guardrail fail-closed, failover locking, tool-output run isolation (fixes #3573)
- LLMGuardrail.__call__ now fails closed on ambiguous LLM replies, matching
  _llm_validate() and the class's documented fail-closed contract.
- FailoverManager gains a threading.RLock guarding all _profiles reads/mutations
  and AuthProfile status transitions, since it is a shared credential pool by design.
- Fix _run_id -> _current_run_id typo that silently disabled tool-output
  run-scoped isolation; harden get_tool_output_store so a None run_id no longer
  adopts the first run's process-wide store.
- Add regression tests asserting both guardrail entry points fail closed.

Co-authored-by: MervinPraison <[email protected]>
2026-08-01 07:20:02 +00:00
Mervin Praison d8225b2285 Merge pull request #3572 from MervinPraison/fix/release-single-commit-bot-identity
ci(release): single commit per release, authored by praisonai-triage-agent[bot]
2026-08-01 07:34:32 +01:00
MervinPraison 466995f50d Release v4.6.158 2026-08-01 05:40:26 +00:00
MervinPraison 474d31a7e6 Bump praisonai-deploy to 0.0.2 2026-08-01 05:40:21 +00:00
MervinPraison d6ace26571 Bump praisonai-sandbox to 0.0.8 2026-08-01 05:40:15 +00:00
MervinPraison df27278f21 Bump praisonai-mcp to 0.0.10 2026-08-01 05:40:09 +00:00
MervinPraison 64b91aea2a Bump praisonai-browser to 0.0.10 2026-08-01 05:40:04 +00:00
MervinPraison d23124b0b0 Bump praisonai-train to 0.0.14 2026-08-01 05:39:58 +00:00
MervinPraison d44576cb1a Bump praisonai-bot to 0.0.43 2026-08-01 05:39:52 +00:00
MervinPraison ccd1e4d13a Bump praisonai-code to 0.0.58 2026-08-01 05:39:45 +00:00
MervinPraison 257c7ea615 Bump praisonaiagents to 1.6.162 2026-08-01 05:39:07 +00:00
praisonai-triage-agent[bot]andMervin Praison 146a0ed980 fix(deploy): harden compose Postgres defaults and fix Railway starter
- docker-compose: require POSTGRES_PASSWORD (no known fallback) and bind
  Postgres to loopback by default (POSTGRES_BIND) — resolves GitGuardian
  finding and the default-credentials exposure P1.
- railway starter: ship a Dockerfile so railway.json's DOCKERFILE build
  and start command reference real files (fixes missing-file P1).

Co-authored-by: Mervin Praison <[email protected]>
2026-08-01 05:33:31 +00:00
MervinPraisonandClaude Opus 5 327f5c3d42 ci(release): single commit per release, authored by the triage bot
A full pypi-release run landed 9 separate commits and 9 pushes on main --
one per package -- all authored as MervinPraison.

Collapse to one commit: delete the 8 per-package "Commit <pkg> version
bump" steps. They were redundant, since the wrapper step already calls
bump_and_release.py --no-add-all whose release_files list is a superset
of every file those steps staged, so all bumps now land in the script's
single "Release v<x.y.z>" commit. That needs --force, because the tree is
now dirty when the script runs and its pre-flight guard exits 1 under
--no-add-all; --no-add-all still restricts staging to the explicit list.

A trailing "Persist any uncommitted version bumps" step covers what the
single commit cannot: a package failing midway (those versions are
already live on PyPI, so the bumps must not be lost), skip_wrapper, or a
wrapper failure. It is a no-op on a clean run, and uses `git add -u` so
untracked build output stays out.

Attribute the release to praisonai-triage-agent[bot] by minting an
installation token from CLAUDE_APP_ID/CLAUDE_APP_PRIVATE_KEY -- the app
already behind that bot in claude.yml, claude-merge-gate.yml and
pipeline-status-sync.yml. GH_TOKEN is exported via $GITHUB_ENV, so the
commit, the tag push and gh release create are all the bot's;
secrets.GH_TOKEN is no longer referenced.

Two details this depends on: app tokens expire after 1h and this job can
exceed that (9 packages x up to a 600s PyPI wait each), so the token is
re-minted immediately before the wrapper release. And checkout sets
persist-credentials: false -- otherwise its http.extraheader would
outrank the remote URL and pin the first, expiring token.

Requires the App to be able to push to main (contents: write, plus a
ruleset bypass entry if main requires PRs).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-01 05:32:11 +00:00
Mervin Praison 27b85d1e86 Merge pull request #3563 from MervinPraison/feat/deploy-infra-relocation
Relocate deploy infra and expand praisonai-deploy surfaces
2026-08-01 06:30:48 +01:00
MervinPraison d0ed1a808a docs: auto-update feature parity trackers [skip ci] 2026-08-01 05:30:36 +00:00
Mervin Praison 888db30ea6 Merge pull request #3570 from MervinPraison/claude/issue-3566-20260731-1718
perf: guard BEFORE_LLM/AFTER_LLM hook payloads behind has_hooks() (hot-path)
2026-08-01 06:30:09 +01:00
Mervin Praison 89eb38dc65 Merge pull request #3571 from MervinPraison/claude/issue-3568-20260731-1719
fix: extract shared tool-argument hashing helper (#3568)
2026-08-01 06:29:53 +01:00
MervinPraison 1e30e9b06b docs: auto-update documentation parity trackers [skip ci] 2026-07-31 18:23:02 +00:00
praisonai-triage-agent[bot] 8074ce1d99 Merge pull request #3569 from MervinPraison/claude/issue-3567-20260731-1718
fix: dedupe rag/budget context-window table via canonical budgeter (fixes #3567)
2026-07-31 18:21:57 +00:00
praisonai-triage-agent[bot]andMervin Praison d3c15e9969 test: add parity regression tests for shared hash_tool_args (#3568)
Locks the tool-call identity contract shared by ApprovalRegistry and
DoomLoopDetector: sorted-key canonicalisation, default=str, 16-char
digest, None/empty equivalence, and the 'unhashable' fallback.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-31 17:29:08 +00:00
praisonai-triage-agent[bot]andMervin Praison 15f58fb7a5 fix: guard rag/budget extras against silently shadowing canonical table
Add the same overlap-guard assertion used in eval/tokens.py so RAG-specific
extras can never silently shadow canonical MODEL_LIMITS entries and re-introduce
the drift that #3567 fixed.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-31 17:27:55 +00:00
praisonai-triage-agent[bot]andMervinPraison eb37789745 fix: extract shared tool-argument hashing helper (fixes #3568)
Deduplicate the canonical tool-argument identity scheme
(json.dumps(sort_keys=True, default=str) -> sha256[:16]) that was
independently re-implemented in the approval registry and doom-loop
detector. Both safety subsystems now delegate to a single
hash_tool_args() helper in approval/utils.py, removing the drift risk
between approval de-duplication and doom-loop detection. Behaviour is
byte-for-byte identical (verified against the original scheme).

Co-authored-by: MervinPraison <[email protected]>
2026-07-31 17:22:59 +00:00
praisonai-triage-agent[bot]andMervinPraison f89c9472d1 perf: build BEFORE_LLM/AFTER_LLM hook payloads only when a hook is registered (fixes #3566)
Guard the BEFORE_LLM and AFTER_LLM payload construction with the existing
registry.has_hooks() primitive, matching the idiom already used at the
AFTER_TOOL/BEFORE_TOOL/AFTER_AGENT/BEFORE_AGENT sites. Also skip the
str(result) stringification when the trace emitter is a disabled NoOp by
guarding on _trace_emitter.enabled. Behaviour is identical whenever a hook
or emitter is registered; zero cost on the hot path otherwise.

Co-authored-by: MervinPraison <[email protected]>
2026-07-31 17:21:31 +00:00
praisonai-triage-agent[bot]andMervinPraison 07767fea08 fix: source rag/budget context windows from canonical budgeter table (fixes #3567)
Deduplicate the model->context-window data by sourcing exact values from
context/budgeter.MODEL_LIMITS (the canonical table) and layering only
RAG-specific extras underneath, mirroring eval/tokens.py. This removes the
drift where gpt-4.1/gemini-1.5-pro resolved to different windows in RAG vs
the rest of the SDK. The get_model_context_window wrapper (None guard,
normalisation, prefix/heuristic fallbacks) is unchanged.

Co-authored-by: MervinPraison <[email protected]>
2026-07-31 17:21:22 +00:00
praisonai-triage-agent[bot] bc00ad8092 Merge pull request #3565 from MervinPraison/claude/issue-3564-20260731-1616
refactor: consolidate duplicated argv flag/value walk in praisonai CLI router
2026-07-31 17:01:48 +00:00
praisonai-triage-agent[bot]andMervinPraison 38c8dda69c refactor: consolidate duplicated argv flag/value walk in praisonai CLI router (fixes #3564)
Extract a single _iter_argv_tokens classifier as the one source of truth for
flag/value-consumption semantics. _find_first_command, _flag_names and
_build_run_argv now all consume it, so routing and rewrite can no longer
diverge. _find_first_command accepts the shared value_opts (defaulting to the
historical narrow static set) rather than a separate hardcoded VALUE_FLAGS.
Behaviour-preserving internal DRY refactor; no CLI surface change.

Co-authored-by: MervinPraison <[email protected]>
2026-07-31 16:19:17 +00:00
praisonai-triage-agent[bot]andMervin Praison 93ad8c5aca fix(deploy): resolve reviewer findings in C14 deploy surfaces
CLI (cli/features/deploy.py):
- extract compose up|down action before generic parse so `compose up`
  without --file no longer sets file="up"
- drop duplicate `-r` key (F601) so --region short flag works again
- route `doctor --provider fly|railway|render` via provider registry
- wrap the four new handlers in the class try/except convention
- thread --force into create scaffolding

compose.py:
- _ensure_postgres_password now resolves the *effective* (last) value,
  handling quoted-empty and inline-comment cases
- restrict generated project .env to 0600 on POSIX
- report the effective host API port (runtime env → .env → default)
- add 600s timeouts to docker compose up/down

helm.py: add 600s timeout to helm upgrade

starters.py: refuse to overwrite existing files unless force=True

Helm agents-api deployment.yaml:
- pass agents/port/serverFile via env vars (no Python-source injection)
- include POSTGRES_PASSWORD in DATABASE_URL via k8s $(VAR) interpolation

starters: railway.json/render.yaml use the prebuilt GHCR image instead of
a nonexistent Dockerfile/api_server.py; fix compose+helm README manifest links.

Adds regression tests (160 passed).

Co-authored-by: Mervin Praison <[email protected]>
2026-07-31 13:07:56 +00:00
praisonai-triage-agent[bot]andMervin Praison 1c14207af9 fix(deploy): resolve GitGuardian false-positives on secret-like test literals
Split the "POSTGRES_PASSWORD:-praisonai" and "change-me" string literals in
the compose security regression tests so GitGuardian no longer flags them as
hardcoded secrets. The assertions still guard the exact same behaviour (no
default password baked into compose; generated password differs from the old
placeholder). Also replace the static "change-me" placeholder in the
agents-api Helm README with an openssl-generated example.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-31 12:21:49 +00:00
praisonai-triage-agent[bot]andMervin Praison 7aa1b9f18e fix(deploy): remove hardcoded compose DB password and stop exposing Postgres
- docker-compose.yml: drop `:-praisonai` default password (GitGuardian);
  require POSTGRES_PASSWORD via `:?` and bind Postgres to 127.0.0.1 by default
- .env.example: ship empty POSTGRES_PASSWORD (no repo-known credential) + POSTGRES_BIND
- compose.py: auto-generate a strong POSTGRES_PASSWORD into project .env when empty
- README + tests: document security defaults and guard against regressions

Co-authored-by: Mervin Praison <[email protected]>
2026-07-31 12:18:42 +00:00
MervinPraisonandCursor b536e4fc37 Relocate deploy infra under package trees and expand C14 deploy surfaces.
Move gateway Helm to praisonai-bot/infra, compose/starters/agents-api Helm to
praisonai-deploy/infra (not PyPI), add central path resolver, compose/helm/create
CLI, optional Fly/Railway/Render providers, and CI helm gates with 145 deploy tests.

Co-authored-by: Cursor <[email protected]>
2026-07-31 13:11:02 +01:00
Mervin Praison ce3fd23f4a Merge pull request #3515 from MervinPraison/claude/issue-3514-20260730-0716
fix: three silent-failure bugs in praisonaiagents core
2026-07-31 12:36:00 +01:00
Mervin Praison 1aa8c34d46 Merge pull request #3538 from MervinPraison/claude/issue-3537-20260730-1713
fix: remove dead/redundant imports in tools and agent modules
2026-07-31 12:35:26 +01:00
Mervin Praison cd6b8db527 Merge pull request #3539 from MervinPraison/claude/issue-3536-20260730-1712
fix: share workflow step-loop between execute and aexecute
2026-07-31 12:35:10 +01:00
MervinPraison 9963eaa9fc docs: auto-update documentation parity trackers [skip ci] 2026-07-31 11:33:33 +00:00
Mervin Praison 0c5f550c2f Merge pull request #3551 from MervinPraison/claude/issue-3550-20260731-0854
fix: unify permission presets onto one PermissionMode model
2026-07-31 12:32:59 +01:00
praisonai-triage-agent[bot] 6b5a5803b3 Merge pull request #3562 from MervinPraison/claude/issue-3559-20260731-0946
fix: keep tool_call/result pairs intact during context compaction
2026-07-31 11:22:18 +00:00
MervinPraison 6b4c136aca docs: auto-update documentation parity trackers [skip ci] 2026-07-31 11:12:36 +00:00
praisonai-triage-agent[bot] 36dace2c0e Merge pull request #3561 from MervinPraison/claude/issue-3560-20260731-0947
fix: serve last-good tool availability on transient probe failure
2026-07-31 11:12:03 +00:00
MervinPraison c7bdada813 docs: auto-update documentation parity trackers [skip ci] 2026-07-31 10:55:57 +00:00
praisonai-triage-agent[bot] 29550ed32a Merge pull request #3556 from MervinPraison/claude/issue-3554-20260731-0918
fix: add gateway doctor --fix to mint a strong auth token and re-validate
2026-07-31 10:55:25 +00:00
praisonai-triage-agent[bot] ffcfe8c36d Merge pull request #3555 from MervinPraison/claude/issue-3553-20260731-0917
fix: fail fast on unresolved tools at gateway start
2026-07-31 10:49:50 +00:00
praisonai-triage-agent[bot] 9887d14842 Merge pull request #3552 from MervinPraison/claude/issue-3549-20260731-0854
fix: wire subtree AGENTS.md context hook into praisonai run
2026-07-31 10:43:50 +00:00
praisonai-triage-agent[bot] 36fa8b1c39 Merge pull request #3548 from MervinPraison/claude/issue-3547-20260731-0818
fix: AgentOS /chat session isolation + drop call.py import-time dotenv side effect
2026-07-31 10:38:10 +00:00
praisonai-triage-agent[bot]andMervin Praison f303babc15 fix: keep tool pairs intact in TRUNCATE compaction (fixes #3559)
TRUNCATE is the default compaction strategy but did not preserve
tool_calls/result pairs: the recent-window slice could orphan a tool
result, and the token-budget pop loop could drop an assistant
tool_calls message while leaving its tool result behind. Snap the
recent boundary and drop leading tool pairs atomically so strict
providers never receive an orphaned tool message.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-31 09:58:10 +00:00
praisonai-triage-agent[bot]andMervin Praison c79740e894 fix: evict stale availability state on tool replacement (#3560)
When register(..., overwrite=True) replaces a tool under the same name,
evict the previous tool's availability cache and last-success timestamp so
a broken replacement can't be served as available on its first failing
probe via the inherited grace window.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-31 09:56:39 +00:00
praisonai-triage-agent[bot]andMervinPraison 893b7128cc fix: keep tool_call/result pairs intact during context compaction (fixes #3559)
Compaction strategies chose their recent/older boundary purely by token or
message count, which could split an assistant tool_calls message from its
matching tool result. The resulting orphaned tool message made strict
providers (OpenAI/Anthropic/Azure) reject the transcript with a 400,
wedging long-lived gateway/bot sessions on every subsequent turn.

Add ContextCompactor._snap_to_pair_boundary() which moves a chosen boundary
outward so a tool pair is never separated, and apply it in _sliding_window,
_summarize, _prune, _llm_summarize and _llm_summarize_async. Behaviour-only,
backward compatible, no new knobs.

Co-authored-by: MervinPraison <[email protected]>
2026-07-31 09:52:03 +00:00
praisonai-triage-agent[bot]andMervinPraison 7524cbd211 fix: serve last-good tool availability on transient probe failure (fixes #3560)
Track last-success timestamp per tool in ToolRegistry. On a probe
exception within a grace window of a recent success, serve the last-good
True and skip caching a durable negative, so a flaky availability check
no longer strips a healthy tool from a long-lived agent. Sustained
failures beyond the grace window still mark the tool unavailable.

Co-authored-by: MervinPraison <[email protected]>
2026-07-31 09:49:55 +00:00
praisonai-triage-agent[bot]andMervin Praison 319452c1a7 fix: stop AgentOS chat tests leaking a global achat monkeypatch
The new TestAgentOSChatSessionIsolation tests replaced Agent.achat at the
class level (`type(template).achat = _fake_achat`) and never restored it,
so the stub leaked into test_async_gemini_fix.py where the real achat is
called with a `tools=` kwarg the stub did not accept.

Use pytest's monkeypatch fixture (auto-restore) and accept *args/**kwargs
in the stub so it matches the real achat signature.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-31 09:30:58 +00:00
praisonai-triage-agent[bot]andMervin Praison 2a12f78c5b fix: load persisted env in tool preflight; skip self-suggest typos
Addresses reviewer feedback on #3555:
- Greptile P1: _preflight_tools now loads ~/.praisonai/.env before resolving
  so a local tools.py enabled via PRAISONAI_ALLOW_LOCAL_TOOLS in that file is
  not falsely rejected in strict mode (mirrors GatewayHandler.start).
- CodeRabbit: describe_unresolved excludes the exact name from difflib typo
  candidates so a mapped-but-unloadable tool yields an install/generic hint
  instead of a useless "Did you mean '<same name>'?".
- Add regression tests for both.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-31 09:29:59 +00:00
praisonai-triage-agent[bot]andMervin Praison 7f8a2d0802 fix(gateway): rewrite explicit weak YAML auth_token during doctor --fix
An explicit (non-${ENV}) weak gateway.auth_token in gateway.yaml wins over
GATEWAY_AUTH_TOKEN at both startup (GatewayConfig.auth_token) and doctor
re-validation, so the env-only repair left the weak YAML value active and
falsely reported success. The repair now also rewrites the YAML token in
place when it holds an explicit weak value, while preserving ${ENV}
references. Fixes Greptile P1 on #3556.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-31 09:29:39 +00:00
MervinPraison 89adc132f9 docs: auto-update api.md [skip ci] 2026-07-31 09:23:52 +00:00
praisonai-triage-agent[bot]andMervinPraison d081519054 fix: add gateway doctor --fix to mint a strong auth token and re-validate (fixes #3554)
The degraded registry's retry_hint advertised `praisonai gateway doctor --fix`,
but no such flag existed — a dead-end on the gateway's primary recovery path.
Add `--fix`/`--dry-run` to `gateway doctor` that mints a strong GATEWAY_AUTH_TOKEN
when weak/missing, persists it to ~/.praisonai/.env (same store as onboard), and
re-validates the finding cleared. Align the core retry_hint docstring so the
advertised command is guaranteed to exist.

Co-authored-by: MervinPraison <[email protected]>
2026-07-31 09:23:24 +00:00
Mervin Praison 09c85f1bd1 Merge pull request #3546 from MervinPraison/feat/c14-praisonai-deploy
feat(c14): extract praisonai-deploy as tier-2 package
2026-07-31 10:23:19 +01:00
praisonai-triage-agent[bot]andMervinPraison c974f212da fix: fail fast on unresolved tools at gateway start (fixes #3553)
Add a start-time tool pre-flight gate so a mistyped, uninstalled, or
gated tool named in gateway.yaml is surfaced with a per-name reason and
fix hint instead of being silently skipped. Strict by default (mirrors
the credential pre-flight); --no-strict-tools / strict_tools: false
preserve today's skip-and-continue.

- tool_resolver.py: add describe_unresolved() (difflib "did you mean" +
  local-tools/dependency hints) and a module-level wrapper.
- gateway start: add --strict-tools/--no-strict-tools and a
  _preflight_tools() gate reusing the existing validate_yaml_tools.

Co-authored-by: MervinPraison <[email protected]>
2026-07-31 09:23:06 +00:00
praisonai-triage-agent[bot]andMervin Praison 30017afee5 fix(c14-deploy): correct CLI/YAML merge, azure update, scheduler robustness
- cli/features/deploy.py: API & Cloud shortcut commands now honor deploy.api
  and deploy.cloud YAML when CLI flags are unset (defaults changed to None
  sentinels; explicit CLI still overrides). Fixes YAML being silently ignored.
- providers/azure.py: create->update fallback now builds a proper
  `az containerapp update` command (previous `cmd[1]='update'` produced the
  invalid `az update containerapp`); env vars passed under a single --env-vars
  flag so later pairs no longer override earlier ones.
- scheduler/deployment.py: replace buggy nested DeployArgs (dead self-ref,
  missing attrs) with SimpleNamespace carrying all fields handle_deploy reads;
  derive success from SystemExit code instead of always True; interruptible
  retry wait via _stop_event so stop() works; drop unused time import.
- main.py: replace `assert docker_cfg` with explicit ValueError (assert is
  stripped under python -O).
- _wrapper_bridge.py: optional_wrapper_attr now returns default when attr is
  missing, not just on ImportError.
- docker.py: prepare_docker_build_context validates agents file before writing
  api_server.py; docker_container_name sanitizes invalid chars.
- tests: add regression tests for API and Cloud YAML merge precedence.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-07-31 09:11:58 +00:00
praisonai-triage-agent[bot]andMervin Praison 2f44441a31 fix: C7-compliant subtree hook import + propagate --no-rules everywhere
- Route the optional subtree-context helper through praisonai_bot (canonical
  home) with a praisonai wrapper-shim fallback, removing the module-/function-
  level ``from praisonai`` import that tripped the C7 hot-path import gate
  (fixes smoke: test_c7_import_gate_script_passes). Feature now also works on
  installs shipping praisonai_bot without the full wrapper.
- Propagate --no-rules to the custom-agent and profiled prompt paths
  (_run_custom_agent, _run_prompt_profiled) so the opt-out is honoured on every
  non-interactive Agent construction site, not just _run_prompt.
- Add an execution-path test asserting all three paths accept/forward no_rules.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-31 09:10:04 +00:00
praisonai-triage-agent[bot]andMervin Praison 8e23806604 fix(permissions): make ACCEPT_EDITS auto-approve edit tools; correct resolver docstring
- ACCEPT_EDITS previously set _permission_mode but the decision resolver
  deferred it (no-op), so advertised auto-accept-edits behaviour never
  applied. Add an ACCEPT_EDITS branch that auto-approves edit-class tools
  (write/edit/append/create/save/patch/mkdir) via a new _is_edit_tool
  heuristic, deferring destructive/exec/external tools to normal flow.
- Remove the misleading `read_only`-as-mode line from PermissionMode.resolve
  docstring (read_only is a deny-set preset and resolves to None).
- Tests: auto-approve edit tools, defer non-edit tools, auto_edit alias.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-31 09:06:30 +00:00
praisonai-triage-agent[bot]andMervinPraison bbe4fdd25a fix: wire subtree AGENTS.md context hook into praisonai run (fixes #3549)
The interactive REPL lazily attaches a subdirectory's AGENTS.md/CLAUDE.md the
first time the agent reads/edits a file under it, but the scriptable
`praisonai run` path built Agent(**agent_config) without it. In a monorepo,
`praisonai run "refactor packages/foo/bar.py"` therefore never picked up
packages/foo/AGENTS.md while `praisonai chat` in the same repo did.

Reuse the existing subtree hook (praisonai.integration.context_files) and the
existing Agent(hooks=...) seam via a small `_wire_subtree_context_hook` helper,
registered on a fresh session-scoped registry at all three run.py Agent sites.
Gated by the existing --no-rules / PRAISON_NO_RULES off-switch and honouring the
shared PRAISON_CONTEXT_BUDGET character budget. No new Agent params, no new core
module, no agent.py changes — keeping the surface area unchanged.

Co-authored-by: MervinPraison <[email protected]>
2026-07-31 09:00:51 +00:00
praisonai-triage-agent[bot]andMervinPraison 53089306b6 fix: unify permission presets onto one PermissionMode model (fixes #3550)
Add PermissionMode.resolve() as the single mapper from historical mode
spellings (AutonomyMode/ApprovalMode/CLI-flag aliases) onto the canonical
PermissionMode enum, and route the existing Agent(approval=<preset>) string
branch through it. Agent(approval='plan'|'bypass'|'accept_edits'|'dont_ask')
(plus aliases like yolo/auto_edit/reject/full_auto) now resolve to one
coherent model. Deny-set presets (safe/read_only/full/off/default) keep
their exact behaviour. No new Agent params or exports.

Co-authored-by: MervinPraison <[email protected]>
2026-07-31 08:59:39 +00:00
praisonai-triage-agent[bot]andMervin Praison 21adfa4702 fix: refresh call.py env globals after dotenv + guard AgentOS clone when handoffs configured
- call.py: module globals (OPENAI_API_KEY, CALL_SERVER_TOKEN, NGROK_AUTH_TOKEN,
  rate limits) were frozen at import, before main()'s explicit load_dotenv(),
  so a .env-only key was silently ignored. Add _refresh_env_globals() invoked
  after load_dotenv() (also refreshes the n8n router's CALL_SERVER_TOKEN).
- agentos.py: clone_for_channel intentionally drops handoffs; agents with
  handoffs now stay on the shared template so delegation is never silently lost.
- test: cover the handoffs-not-cloned path (9 passed).

Co-authored-by: Mervin Praison <[email protected]>
2026-07-31 08:58:43 +00:00
MervinPraison e3e1134387 docs: auto-update feature parity trackers [skip ci] 2026-07-31 08:29:08 +00:00
praisonai-triage-agent[bot] 9a0950cd1a Merge pull request #3545 from MervinPraison/claude/issue-3544-20260731-0711
fix: permission scope bypass, guardrail fail-open, and fake delegator cancellation
2026-07-31 08:28:36 +00:00
praisonai-triage-agent[bot]andMervinPraison 6f3b872b18 fix: AgentOS /chat session isolation + drop call.py import-time dotenv side effect (fixes #3547)
Gap 1: /chat now clones a per-request/session agent (reusing the existing
_supports_session_isolation / _clone_agent helpers from api/agent_invoke.py)
so concurrent callers no longer share mutable chat_history, and session_id is
bound to the clone instead of merely echoed. Plain mocks fall back to the
shared instance for backward compat.

Gap 2 (minimal): api/call.py no longer calls load_dotenv() unconditionally at
import time; it is gated behind PRAISONAI_CALL_LOAD_DOTENV and loaded
explicitly in main() so the CLI keeps working. Importing the module no longer
mutates os.environ.

Gap 3 rejected as scope creep (breaking public-API churn with a deprecation
window; deferred per the issue).

Co-authored-by: MervinPraison <[email protected]>
2026-07-31 08:23:22 +00:00
praisonai-triage-agent[bot]andMervin Praison 3a780fcb0e fix(c14-deploy): harden generated API server + align GHSA codegen test
- Docker deploy no longer disables auth by default; falls back to the
  secure APIConfig() default (auth_enabled=True, token auto-generated)
  instead of exposing an unauthenticated /chat on 0.0.0.0 (Greptile P1).
- Write the generated API server into a private per-invocation mkdtemp
  (mode 0700) instead of a predictable shared tmp path, preventing
  local replacement before launch (Greptile P1).
- Update pre-existing GHSA deploy-codegen test to assert the genuine
  security property (repr-escaped path, no raw injection) rather than the
  old implementation-coupled `PraisonAI(agent_file=...)` string; the PR
  intentionally forwards the chat message via the wrapper `run(..., cli_config)`
  entrypoint since `PraisonAI.run()` takes no args.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-07-31 07:54:09 +00:00
praisonai-triage-agent[bot]andMervin Praison 4d9832ff85 fix(guardrail): init logger before LLM init and use is-None check; add scope regression test
- llm_guardrail: assign self.logger before _initialize_llm so unsupported/
  string-fail/dict-fail paths can log and fail closed instead of raising
  AttributeError (CodeRabbit major).
- llm_guardrail: __call__ now checks `self.llm is None` (matches _llm_validate)
  instead of truthiness (CodeRabbit minor).
- tests/permissions: lock the scoped-approval bypass fix — an agent-scoped
  approval must reject an unnamed (agent_name=None) caller.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-31 07:44:47 +00:00
praisonai-triage-agent[bot]andMervin Praison 7928a090b2 fix(delegator): correct cancellation semantics for CI + reviewer feedback
- cancel_task now cancels any non-terminal task (PENDING or RUNNING),
  fixing test_cancel_task regression and Greptile P1: queued/PENDING
  delegations no longer escape cancel_all.
- delegate() re-raises external CancelledError (Greptile P1) and only
  swallows delegator-initiated cancels into a result, so wait_for /
  cooperative shutdown behave correctly.
- _execute_task and the semaphore-guarded path early-return when a cancel
  landed before execution, so a cancelled task never runs.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-31 07:39:32 +00:00
MervinPraisonandCursor c7ad408ba9 feat(c14): extract praisonai-deploy as tier-2 package
Move API, Docker, and cloud deployment from the wrapper into a standalone
praisonai-deploy package with backward-compat shims, CI/publish wiring,
and fixes for chat message forwarding, Docker build context, and CLI YAML merge.

Co-authored-by: Cursor <[email protected]>
2026-07-31 08:37:21 +01:00
praisonai-triage-agent[bot] fff8336c70 Merge pull request #3543 from MervinPraison/claude/issue-3542-20260731-0635
feat: add praisonai skills add <url> command
2026-07-31 07:35:07 +00:00
MervinPraison 82f19e0b36 docs: auto-update documentation parity trackers [skip ci] 2026-07-31 07:29:14 +00:00
praisonai-triage-agent[bot] a04abb943a Merge pull request #3541 from MervinPraison/claude/issue-3540-20260731-0614
fix: unify @tool(approval=...) and launch(protocol="mcp") with SDK conventions
2026-07-31 07:28:40 +00:00
praisonai-triage-agent[bot]andMervinPraison a938e7356c fix: close permission scope bypass, guardrail fail-open, and fake delegator cancellation (fixes #3544)
- permissions/rules.py: PersistentApproval.matches now rejects agent-scoped
  approvals for callers that can't prove identity (including agent_name=None),
  closing the short-circuit bypass while leaving unscoped approvals unaffected.
- guardrails/llm_guardrail.py: LLMGuardrail.__call__ now fails closed on a
  missing/unsupported LLM, matching its docstring and sibling _llm_validate.
- agents/delegator.py: cancel_task/cancel_all now propagate cancellation to the
  real asyncio.Task; _execute_task no longer clobbers a CANCELLED status with a
  late COMPLETED assignment.

Co-authored-by: MervinPraison <[email protected]>
2026-07-31 07:16:37 +00:00
praisonai-triage-agent[bot]andMervinPraison cc50461158 feat: add praisonai skills add <url> alias (fixes #3542)
Add a `skills add` subcommand as a thin alias over the existing
`skills install` path so `praisonai skills add https://...` works,
mirroring the familiar `npx skills add <url>` verb. Reuses existing
install/remote-fetch machinery; no new deps, modules, or core params.

Co-authored-by: MervinPraison <[email protected]>
2026-07-31 06:38:46 +00:00
praisonai-triage-agent[bot]andMervin Praison 860d332994 fix: warn on explicit requires_approval=False and handle late MCP transport ImportError
- @tool(requires_approval=False) now emits DeprecationWarning (any use of the
  deprecated spelling nudges migration); use a sentinel to tell "omitted" from
  explicit False. approval= still wins for the resolved value.
- Agent/PraisonAIAgents launch(protocol="mcp") now keep the serve_agents() call
  inside the ImportError guard, so a praisonai-mcp installed without its
  transport extras yields one actionable install message instead of an uncaught
  traceback.
- Tests: explicit-False-warns, omitted-no-warn, both-set-warns, and
  missing-transport-extras for single- and multi-agent launch.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-31 06:28:41 +00:00
praisonai-triage-agent[bot]andMervinPraison f31ca0d01e fix: unify @tool(approval=...) and launch(protocol="mcp") with SDK conventions (fixes #3540)
- @tool now accepts canonical approval= (mirrors Agent(approval=...));
  requires_approval= kept as a deprecated alias with a DeprecationWarning,
  approval= wins when both set. Both funnel to the same ApprovalRegistry.
- PraisonAIAgents.launch(protocol="mcp") and Agent.launch(protocol="mcp")
  now both delegate to praisonai_mcp.serve_agents (lazy import, clear
  "pip install praisonai-mcp" error). Replaces the bespoke single-agent SSE block.
- Add praisonai_mcp naming constants list_sessions / awaiting_approval.
- http launch behaviour untouched.

Co-authored-by: MervinPraison <[email protected]>
2026-07-31 06:21:07 +00:00
praisonai-triage-agent[bot]andMervin Praison 4bdba3757a fix: halt workflow on loop stop-on-error and checkpoint completed loop steps
A stop-on-error failure inside a loop_over step now aborts the whole
workflow instead of only the inner item iteration, and a completed
loop_over step now persists a checkpoint so resume does not re-run every
loop item. Both fixes live in the shared _run_workflow_loop generator so
they apply to sync execute() and async aexecute() alike.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-30 17:25:42 +00:00
praisonai-triage-agent[bot] 2ebbafb0c6 Merge pull request #3535 from MervinPraison/claude/issue-3533-20260730-1618
fix: wire praisonai unified to bundled ui_dashboard preset
2026-07-30 17:24:11 +00:00
praisonai-triage-agent[bot]andMervinPraison a744836735 fix: share workflow step-loop between execute and aexecute (fixes #3536)
Extract the duplicated step-loop from execute() and aexecute() into a single
sans-io generator (_run_workflow_loop) that owns all orchestration:
condition-skip, loop_over iteration, checkpoint save/resume, branch_condition/
next_steps routing, early-stop and on_error handling.

execute() drives it with the sync _execute_single_step; aexecute() drives the
same generator with a new async _aexecute_single_step that awaits async
executors / agent.achat / async handlers (true-async preserved).

This restores loop_over, branch, and checkpoint/resume parity to the async
path and adds checkpoint/resume params to aexecute. Public signatures and
return shapes are unchanged.

Co-authored-by: MervinPraison <[email protected]>
2026-07-30 17:19:13 +00:00
praisonai-triage-agent[bot] 73fcbdcf2c Merge pull request #3534 from MervinPraison/claude/issue-3532-20260730-1617
fix: remove dead adapter-loader functions in registry.py
2026-07-30 17:19:04 +00:00
praisonai-triage-agent[bot]andMervinPraison 1d709e6773 fix: remove dead/redundant imports in tools and agent modules (fixes #3537)
- tools/base.py: drop unused annotation_to_json_schema, get_parameter_requirements
- tools/decorator.py: drop unused annotation_to_json_schema, get_parameter_requirements
- agent/handoff.py: remove duplicate import contextvars

Co-authored-by: MervinPraison <[email protected]>
2026-07-30 17:14:19 +00:00
praisonai-triage-agent[bot] 624da31af3 Merge pull request #3531 from MervinPraison/claude/issue-3528-20260730-1539
feat: first-class serve_agents() MCP path + memory/agent adapter fixes
2026-07-30 16:45:30 +00:00
MervinPraison 11d2c9fbd6 docs: auto-update documentation parity trackers [skip ci] 2026-07-30 16:40:04 +00:00
praisonai-triage-agent[bot] 45c6a71647 Merge pull request #3530 from MervinPraison/claude/issue-3529-20260730-1540
feat: add @tool(requires_approval=...) declaration
2026-07-30 16:39:29 +00:00
praisonai-triage-agent[bot]andMervin Praison 2912b1f08c fix: route bundled aiui preset load failures to host_app fallback
Catch import errors in _load_bundled_dashboard_app so that when the
bundled ui_dashboard preset is unavailable, the loader returns None and
the existing build_host_app fallback runs, instead of the exception
escaping to the outer handler and dropping aiui to the standard dashboard.

Addresses Greptile (P1) and CodeRabbit (Major) review findings on #3535.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-30 16:25:13 +00:00
praisonai-triage-agent[bot]andMervinPraison 811df19f7d fix: wire praisonai unified to bundled ui_dashboard preset (fixes #3533)
Route _run_aiui_dashboard through the bundled ui_dashboard/default_app.py
preset so the documented 9-page unified dashboard actually ships, making
the preset the single source of truth instead of a divergent hardcoded
5-page list. Falls back to the previous build_host_app defaults if the
preset cannot be loaded. Adds the missing ui_dashboard/__init__.py for
consistency with the other bundled UI presets.

Co-authored-by: MervinPraison <[email protected]>
2026-07-30 16:20:29 +00:00
praisonai-triage-agent[bot]andMervinPraison 5b37214dcb fix: remove five unreferenced adapter-loader functions in registry.py (fixes #3532)
Co-authored-by: MervinPraison <[email protected]>
2026-07-30 16:18:13 +00:00
praisonai-triage-agent[bot]andMervin Praison c55ae27e86 fix: isolate agent sessions, prevent tool-name collisions & bad transport
- Namespace per-session transcripts by tool name so reusing a client
  session_id across different ask_* agents no longer leaks history.
- Serialize each session's load->run->save under a per-session lock to
  stop concurrent turns clobbering each other's completed turn.
- Disambiguate agents whose names normalize to the same tool suffix so a
  later registration never silently shadows an earlier agent.
- Raise ValueError on unknown serve_agents() transport instead of
  silently falling back to http-stream.
- Add regression tests for all four cases.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-30 15:51:37 +00:00
praisonai-triage-agent[bot]andMervin Praison 3e19b1e73a fix: fail closed and validate risk level in @tool(requires_approval)
Address reviewer P1 security findings on PR #3530:

- Fail closed: if registering the approval requirement raises, the
  decorator now re-raises RuntimeError instead of logging and handing
  back an executable but ungated tool.
- Validate risk-level strings against ("critical","high","medium","low")
  so a typo like requires_approval="critial" is rejected up front rather
  than silently treated as non-critical.

Adds regression tests for both cases.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-30 15:50:02 +00:00
praisonai-triage-agent[bot]andMervinPraison 15ebe6951e feat: add serve_agents() MCP path + fix memory adapter & throwaway agent proxy (fixes #3528)
- praisonai-mcp: new serve_agents() + agent_adapter publishing ask_{name}
  per agent and list_agents, with per-call session_id minting and in-adapter
  per-session transcript continuity, running over the existing spec-compliant
  http-stream transport. Server-side agents only; no client-set instructions.
- Fix Defect C: rebind memory adapter to the real core API
  (store_short_term/search/get_all_memories/reset_all); the old bindings
  (get_session/get_all/clear_session/clear_all/list_sessions/add) never existed.
- Drop client-controllable `instructions` from praisonai.agent.chat/run so the
  connecting client can no longer rewrite the persona.
- Tests for per-agent tools, session continuity, no-client-instructions, and
  memory adapter against the real API.

Co-authored-by: MervinPraison <[email protected]>
2026-07-30 15:44:21 +00:00
praisonai-triage-agent[bot]andMervinPraison 6dc871c75a feat: add @tool(requires_approval=...) declaration (fixes #3529)
Adds a one-place approval declaration to the @tool decorator that
registers the tool with the existing global ApprovalRegistry at
definition time. `requires_approval=True` maps to the "high" risk
level; a string sets the risk level explicitly. No behaviour change
when unset. Backward compatible.

Co-authored-by: MervinPraison <[email protected]>
2026-07-30 15:42:45 +00:00
praisonai-triage-agent[bot] 688d76a18f Merge pull request #3525 from MervinPraison/claude/issue-3522-20260730-0933
feat: add minimal praisonai-gateway Helm chart
2026-07-30 10:47:27 +00:00
MervinPraison ed0f420c26 docs: auto-update documentation parity trackers [skip ci] 2026-07-30 10:43:01 +00:00
praisonai-triage-agent[bot] 411c63341f Merge pull request #3527 from MervinPraison/claude/issue-3526-20260730-0946
fix: honour wall-clock cron in standalone schedulers
2026-07-30 10:42:29 +00:00
praisonai-triage-agent[bot] a64c3c86a3 Merge pull request #3521 from MervinPraison/claude/issue-3519-20260730-0919
fix: age-gate durable-queue dead-lettering to survive transient outages
2026-07-30 10:42:20 +00:00
MervinPraison 6007e13749 docs: auto-update feature parity trackers [skip ci] 2026-07-30 10:38:30 +00:00
praisonai-triage-agent[bot] e14c411575 Merge pull request #3520 from MervinPraison/claude/issue-3518-20260730-0918
fix: add unified degraded-capability registry for gateway
2026-07-30 10:38:05 +00:00
praisonai-triage-agent[bot]andMervin Praison 0022a4d81d fix: persist current cron slot & warn when croniter missing (#3526 review)
Addresses two valid reviewer findings on PR #3527:

- Stale anchor: the sync & async loops advanced `_last_run_at` *after*
  `_execute_with_retry` persisted daemon state, so a restart restored the
  prior slot and replayed an already-completed run/delivery. Mark the
  wall-clock anchor *before* executing so the state write records this slot.
- Silent degrade: without the optional `croniter` engine a `cron:` schedule
  reverted to the pre-#3526 process-relative interval with no signal. Emit a
  one-time actionable warning (matching core `scheduler.due`) instead.

Tests: new default-install warning + state-persistence cases (run without
croniter); ticker cron tests gated per-class so they no longer skip the
whole module. Scheduler suite: 259 passed (3 pre-existing dotenv env
failures unrelated).

Co-authored-by: Mervin Praison <[email protected]>
2026-07-30 10:06:09 +00:00
praisonai-triage-agent[bot]andMervinPraison fdaf0e2772 fix: honour wall-clock cron in standalone schedulers (fixes #3526)
The AgentScheduler / AsyncAgentScheduler wrapper loops collapsed cron
expressions to a coarse integer interval anchored to process start, so a
"daily 09:00 brief" fired at whatever o'clock the daemon launched, re-phased
on restart, and silently dropped runs across downtime/dormancy.

Add a lightweight, wrapper-local ScheduleTicker that reuses core's croniter
engine to sleep until the next real wall-clock occurrence, catch up a missed
slot exactly once on resume, and persist last_run_at in the existing daemon
state file so restarts don't re-phase. Plain intervals (hourly, */30m, raw
seconds) keep identical behaviour. No new core surface, exports, or params.

Co-authored-by: MervinPraison <[email protected]>
2026-07-30 09:56:06 +00:00
praisonai-triage-agent[bot]andMervin Praison e45cdfbaf5 fix: correct gateway auth secret wiring in Helm chart
- Always inject the env var as GATEWAY_AUTH_TOKEN (the only name the gateway
  reads); auth.secretKey now only selects the Secret data key.
- validateAuth now fails fast whenever auth.enabled is true without a token
  source, not only when ingress is also enabled — the default install
  previously referenced a Secret that was never created.
- Update README security note to match the stronger validation.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-30 09:42:38 +00:00
praisonai-triage-agent[bot] 85b1e0765a Merge pull request #3517 from MervinPraison/claude/issue-3516-20260730-0832
fix: repair approval config crash, async-loop safety, dropped agent flags
2026-07-30 09:40:33 +00:00
praisonai-triage-agent[bot]andMervin Praison 9adcc9a749 fix: keep age-gated dead-lettering on older core via local fallback policy
The bot's dependency floor (praisonaiagents>=1.6.152) admits core releases
that predate AttemptAndAgeDeadLetterPolicy (first shipped 1.6.161). On those
installs the import guard previously bound None and both durable queues
silently reverted to attempt-only dead-lettering, re-introducing the #3519
transient message loss. Add a dependency-free LocalDeadLetterPolicy in
_resilience (mirroring core semantics: attempt+age gate, permanent-error
short-circuit, min_age=0 legacy) and bind it as the import fallback so the
age gate holds on any allowed core version. Adds older-core coverage tests.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-30 09:39:14 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy dfa0035dab feat: add minimal praisonai-gateway Helm chart (fixes #3522)
Ship a single, self-contained Helm chart under deploy/helm/praisonai-gateway
that reuses the existing GHCR image (no new build pipeline, no core code
change). Grounded in real gateway behaviour: port 8765, /health probes, and
GATEWAY_AUTH_TOKEN via a Kubernetes Secret. Includes optional ingress with
WebSocket-friendly annotations and an optional CPU HPA. Chart refuses to render
if the gateway would be exposed via ingress without an auth token.

Co-authored-by: Dhivya-Bharathy <[email protected]>
2026-07-30 09:37:08 +00:00
praisonai-triage-agent[bot]andMervin Praison 1d05e81e7a fix: enforce degraded vocabularies + wire live producer (PR #3520)
- Enforce closed OWNER_KINDS/DEGRADED_STATES in DegradedOwner.__post_init__
  so unsupported values can never reach operator-facing records (Greptile P2).
- Route the existing channel-credential degradation path through new
  gateway helpers _mark_degraded_owner/_clear_degraded_owner so the shared
  _degraded_registry has a genuine live producer feeding degraded_owners,
  not just an empty registry read by health() (Greptile P1).
- Drop unused dataclasses.field import; add vocabulary-rejection tests.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-30 09:30:34 +00:00
praisonai-triage-agent[bot]andMervinPraison c5a2809281 fix: age-gate durable-queue dead-lettering to survive transient outages (fixes #3519)
Dead-lettering decided on attempt-count alone burned the default five
attempts in ~45s of channel outage, permanently dropping deliverable
inbound/outbound messages during routine transient incidents.

Add a core DeadLetterPolicyProtocol + default AttemptAndAgeDeadLetterPolicy
in praisonaiagents/gateway that requires BOTH attempt-exhaustion AND a
minimum wall-clock age (default 6h) before dead-lettering, while known
permanent errors (credential/permanent_target) still short-circuit. The
bot durable queues (OutboundQueue.drain, InboundJournal redelivery/replay)
consume the policy where they previously tested attempts >= max_attempts,
gaining a dead_letter_min_age knob. min_age=0 restores legacy behaviour.

Co-authored-by: MervinPraison <[email protected]>
2026-07-30 09:28:56 +00:00
praisonai-triage-agent[bot]andMervinPraison f616d86970 fix: add unified degraded-capability registry for gateway (fixes #3518)
Adds a small, process-local DegradedCapabilityRegistry + DegradedOwner
contract in core (praisonaiagents/gateway/degraded_state.py), mirroring the
existing gateway policy-protocol + default-impl pattern. Any owner (channel,
provider, capability, route, gateway) records degradation with a consistent,
redacted, actionable shape.

Wires the bot gateway health() as a live consumer: it aggregates existing
channel degradation facts (plus any provider/capability/route degradation
recorded into a shared registry) into a single degraded_owners surface, so a
degraded provider or unresolved secret-backed capability is no longer
classified-but-invisible. The channel-only surface stays for backward compat.

Co-authored-by: MervinPraison <[email protected]>
2026-07-30 09:23:15 +00:00
praisonai-triage-agent[bot]andMervin Praison ac6079c03a fix: translate wrapper int autonomy level to core preset (fixes #3516)
The wrapper YAML schema types `autonomy` as an int 0-10 but core
Agent only accepts bool/str/dict/AutonomyConfig; forwarding the raw
int hit core's disable branch and silently ignored a configured
level. Map the numeric level onto core's string presets (0 => off,
1-3 => suggest, 4-7 => auto_edit, 8-10 => full_auto) and pass
bool/str/dict through unchanged.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-30 08:48:04 +00:00
praisonai-triage-agent[bot]andMervinPraison 0e72285fb4 fix: repair approval config crash, async-loop safety, dropped agent flags (fixes #3516)
- praisonai_adapter._resolve_agent_approval: map the wrapper approval dict
  onto the real ApprovalConfig fields (approve_all_tools->all_tools, resolve
  string backend) instead of passing extra keys into the dataclass, which
  raised TypeError on every --trust/--approve-* run against the native adapter.
- praisonai_adapter.run + persistence sync stores + async TUI: route through
  the existing run_sync_or_offload helper instead of run_sync, so the flagship
  sync praisonai.run(...) API and sync persistence hooks no longer crash from
  inside a running loop (FastAPI, async test, notebook).
- praisonai_adapter._build_agents_and_tasks: forward agent-level fields the
  core Agent already accepts (planning, reflection, guardrails, web, autonomy,
  skills) instead of silently dropping them.

Co-authored-by: MervinPraison <[email protected]>
2026-07-30 08:40:03 +00:00
praisonai-triage-agent[bot]andMervin Praison 7f4de3d4bd fix: restore class-level Handoff._semaphore_lock contract
The per-instance async semaphore fix (correctly) enforces each Handoff's
own max_concurrent, but moved _semaphore_lock to instance level, breaking
the architectural test that asserts Handoff._semaphore_lock is a single
shared class attribute. Restore it as a class-level threading.Lock guarding
lazy semaphore creation while keeping the semaphore itself per-instance.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-30 07:33:53 +00:00
praisonai-triage-agent[bot]andMervinPraison 167908928c fix: three silent-failure bugs in praisonaiagents core (fixes #3514)
- llm.py: add LLMResponseError and re-raise instead of swallowing a
  mid-tool-loop error and returning "" as if the call succeeded, so the
  existing try/except in chat_mixin.py can surface the real failure.
- memory.py: make adapter search/reset/delete gates symmetric with the
  adapter-agnostic store path (any configured memory_adapter, not just
  the hardcoded sqlite/in_memory providers). Also make store_long_term
  adapter-agnostic to match store_short_term so adapter-stored memories
  (e.g. "dakera") remain findable.
- handoff.py: make the concurrency semaphore instance-level (created once
  per Handoff, rebound if the running event loop changes) instead of a
  class-level singleton, so per-handoff max_concurrent limits no longer
  override each other and multiple asyncio.run() lifecycles don't crash.

Co-authored-by: MervinPraison <[email protected]>
2026-07-30 07:25:12 +00:00
praisonai-triage-agent[bot] 266a1c9fec Merge pull request #3513 from MervinPraison/claude/issue-3512-20260729-1624
fix: add missing code-side feature bridges for eval/persistence/background/ollama
2026-07-30 04:26:12 +00:00
praisonai-triage-agent[bot]andMervinPraison 0ee0b108f7 fix: add missing code-side feature bridges for eval/persistence/background/ollama (fixes #3512)
Co-authored-by: MervinPraison <[email protected]>
2026-07-29 16:26:05 +00:00
Mervin Praison 2e5632c202 Merge pull request #3511 from Hore01/fix/windows-file-locking
fix: preserve Windows file lock length during save
2026-07-29 14:06:37 +01:00
praisonai-triage-agent[bot]andMervin Praison 61de56b83f fix: propagate Windows lock length through load() release
load() acquired the exclusive lock over max(file_size, 1) bytes on
Windows but discarded the returned length, so _release_exclusive_lock
fell back to unlocking only 1 byte. For session files larger than one
byte this left the remaining range locked until file close, blocking
concurrent access. Pass the acquired lock_length to the release call
to unlock the full range, mirroring save().

Co-authored-by: Mervin Praison <[email protected]>
2026-07-29 12:06:38 +00:00
MervinPraison 230a222c85 docs: auto-update documentation parity trackers [skip ci] 2026-07-29 11:58:47 +00:00
Mervin Praison 77185b2bc1 Merge pull request #3471 from MervinPraison/claude/issue-3469-20260728-0914
fix: enrich gateway WS stream relay + accepted/final ack
2026-07-29 12:58:10 +01:00
Mervin Praison ea49535cb4 Merge pull request #3510 from Hore01/fix/workspace-path-serialization
fix: serialize workspace paths consistently across platforms
2026-07-29 12:57:42 +01:00
Hore01 30e473063d fix: preserve Windows file lock length during save 2026-07-29 12:01:35 +01:00
Hore01 fbc120e318 fix: serialize workspace paths consistently across platforms 2026-07-29 11:48:51 +01:00
Mervin Praison 4717e8eab6 Merge pull request #3398 from MervinPraison/claude/issue-3397-20260726-0715
fix: session_id collisions, concurrent handoff corruption, async retry hook crash
2026-07-29 11:42:53 +01:00
Mervin Praison 206b7212b1 Merge pull request #3437 from MervinPraison/claude/issue-3427-20260727-0902
fix: publish agents.yaml JSON Schema + wire editor autocomplete/validation
2026-07-29 11:42:17 +01:00
Mervin Praison ca8f8be99b Merge pull request #3441 from MervinPraison/claude/issue-3440-20260727-0930
fix: make gateway reload-scope classification canonical in core (#3440)
2026-07-29 11:41:50 +01:00
Mervin Praison 2cccd773f5 Merge pull request #3442 from MervinPraison/claude/issue-3438-20260727-0929
fix: make gateway reliability safe by default (#3438)
2026-07-29 11:41:22 +01:00
praisonai-triage-agent[bot]andMervin Praison 2b6bf40455 fix: persist new stream frames, terminate missing-agent turns, order final after stream
- Persist reasoning/tool-progress/stream-error frames for reconnect replay (CodeRabbit)
- Missing agent now resolves terminally (final+error) instead of a dangling accepted ack (Greptile P1)
- Drain relay futures before final frame so final never overtakes trailing stream events (Greptile P1)

Co-authored-by: Mervin Praison <[email protected]>
2026-07-29 10:41:20 +00:00
praisonai-triage-agent[bot]andMervinPraison f7ac3e1cc9 fix: enrich gateway WS stream relay + accepted/final ack (fixes #3469)
Forward a closed set of structured progress events (reasoning, tool
progress, stream errors) to WS clients instead of dropping everything
but text/tool-call/end, and disambiguate the overloaded response frame
with status: "accepted" (provisional) vs status: "final" (with a
structured outcome).

Co-authored-by: MervinPraison <[email protected]>
2026-07-29 10:41:20 +00:00
Mervin Praison bfdc58974b Merge pull request #3452 from Hore01/fix/conftest-pytest-plugins-error
fix: Remove pytest_plugins from conftest to resolve pytest error
2026-07-29 11:40:44 +01:00
Mervin Praison ec1dfb36a9 Merge pull request #3456 from MervinPraison/claude/issue-3455-20260727-1716
fix: remove duplicate BEFORE_TOOL/AFTER_TOOL hook firing in legacy async path
2026-07-29 11:40:17 +01:00
Mervin Praison 441ce71da7 Merge pull request #3458 from MervinPraison/claude/issue-3457-20260728-0714
fix: MCP per-call timeout, wire workflow context into prompt, fix parallel loop idx
2026-07-29 11:40:03 +01:00
Mervin Praison be0ff63ec6 Merge pull request #3472 from MervinPraison/claude/issue-3467-20260728-0912
fix: cancel/time-bound in-flight gateway agent turns
2026-07-29 11:39:18 +01:00
Mervin Praison 21b15f0384 Merge pull request #3489 from MervinPraison/claude/issue-3483-20260728-1714
fix: route MCP URL transport selection through shared get_transport_type
2026-07-29 11:38:44 +01:00
Mervin Praison e25ab00722 Merge pull request #3507 from MervinPraison/claude/issue-3505-20260729-0943
fix: scope scheduler automations & suggestions per identity
2026-07-29 11:37:59 +01:00
Mervin Praison 566ce4b4a0 Merge pull request #3497 from MervinPraison/claude/issue-3494-20260729-0852
fix: route auxiliary LLM calls to configured small_model
2026-07-29 11:37:26 +01:00
MervinPraison d7e3858386 docs: auto-update documentation parity trackers [skip ci] 2026-07-29 10:36:32 +00:00
Mervin Praison 02b69bd07f Merge pull request #3502 from MervinPraison/claude/issue-3498-20260729-0914
fix: add durable SQLite backend for gateway push delivery store
2026-07-29 11:35:55 +01:00
Mervin Praison 9c0dfe1076 Merge pull request #3508 from MervinPraison/claude/issue-3506-20260729-0943
fix: consume markdown_dialect capability with core dialect converter
2026-07-29 11:35:47 +01:00
praisonai-triage-agent[bot] c40b9118a9 Merge pull request #3503 from MervinPraison/claude/issue-3500-20260729-0921
fix: sandbox USERPROFILE in rules path-safety test for Windows
2026-07-29 10:31:24 +00:00
praisonai-triage-agent[bot] ee67a9d826 Merge pull request #3504 from MervinPraison/claude/issue-3501-20260729-0921
fix: instruct bot shell agent to report command stdout
2026-07-29 10:29:59 +00:00
MervinPraison ed84d68863 docs: auto-update documentation parity trackers [skip ci] 2026-07-29 10:22:40 +00:00
praisonai-triage-agent[bot] e34a9fac13 Merge pull request #3496 from MervinPraison/claude/issue-3495-20260729-0852
fix: declarative remote skill sources that stay synced
2026-07-29 10:21:59 +00:00
praisonai-triage-agent[bot]andMervin Praison 1134398e17 fix: extend principal ownership boundary to name-based mutations & suggestion admission
Closes the two P1 gaps flagged in review: name-based schedule lookups/removals
had no principal check (a gateway user could read/delete another's automation by
guessing its name), and suggestion admission caps/dedup were measured globally
(one tenant could starve another's suggestions).

- store.py / config_store.py: get_by_name/remove_by_name gain optional principal
  filter; a cross-owner match is skipped. principal=None stays global.
- protocols.py: ScheduleStoreProtocol get_by_name/remove_by_name updated.
- suggestion_store.py: add() scopes the pending cap & dedup window to the
  suggestion's principal; principal=None keeps the global pool.
- tests: +6 cases for name-based mutation ownership and per-principal caps/dedup.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-29 09:54:56 +00:00
praisonai-triage-agent[bot]andMervin Praison d88af8a81a fix: consume markdown_dialect in core send seam & fix plain-text fallback
Wire BasePlatformAdapter.format_message() (the existing deliver() seam) to
call format_for_dialect(text, caps.markdown_dialect) so the capability is
actually consumed in core — Telegram replies are MarkdownV2-escaped and Slack
gets mrkdwn. Default "markdown" dialect keeps existing adapters unaffected.

Fix strip_markdown to only unwrap *paired* emphasis/code spans so literal
identifiers (svc_1), globs (*.py) and arithmetic (a*b) are no longer corrupted.

Adds regression tests for both fixes.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-29 09:54:55 +00:00
praisonai-triage-agent[bot]andMervinPraison e495c5d850 fix: consume markdown_dialect capability with core dialect converter (fixes #3506)
Add a pure, stdlib-only format_for_dialect() in praisonaiagents/bots/format.py
that finally consumes the declared markdown_dialect capability, returning
(rendered_text, parse_mode) so adapters can escape/convert agent replies to the
target platform's flavour (Telegram MarkdownV2, Slack mrkdwn, Discord
passthrough, safe plain-text fallback) — preventing dropped Telegram messages
from unescaped specials.

Co-authored-by: MervinPraison <[email protected]>
2026-07-29 09:46:43 +00:00
praisonai-triage-agent[bot]andMervinPraison af9f314fb0 fix: scope scheduler automations & suggestions per identity (fixes #3505)
Add an optional `principal` owner key to the core scheduler data model so
a multi-user gateway isolates each user's automations and suggestions
instead of sharing one global, single-tenant store.

- Suggestion.principal + SuggestionStore.list_pending/accept/dismiss gain
  an optional principal filter that rejects cross-owner access.
- ScheduleJob.principal round-trips through to_dict/from_dict; FileScheduleStore
  and ConfigYamlScheduleStore .list() gain an optional principal filter.
- ScheduleStoreProtocol.list signature updated.

principal=None preserves the current global behaviour, so single-user
deployments are unaffected (fully backward compatible).

Co-authored-by: MervinPraison <[email protected]>
2026-07-29 09:46:15 +00:00
praisonai-triage-agent[bot]andMervin Praison 6711bf72fd fix: inject shell stdout directive even when prompt already names execute_command
The bare-tool-name guard skipped the stdout-reporting instruction for
preconfigured agents whose own system prompt mentions execute_command,
leaving the "no output" bug reachable. Guard on a stable marker phrase
instead, keeping injection idempotent. Strengthen assertions and add
regression coverage for the preconfigured-prompt and duplicate paths.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-29 09:30:30 +00:00
praisonai-triage-agent[bot]andMervin Praison 55a22cdf80 fix: persist push-delivery recipient so restart replay actually redelivers
Addresses the durable at-least-once guarantee gaps flagged in review:
- Persist the recipient client_id alongside each pending event and
  reconstruct _pending_acks on startup, so replayed events are scheduled
  for retry/reconnect redelivery instead of being silently dropped.
- Evict acknowledged events from the SQLite store (and in-memory cache)
  so acked rows are not resurrected after a restart.
- Backfill client_id column for pre-existing stores; strengthen tests to
  assert redeliverability after restart and ack-eviction.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-29 09:28:09 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy 9d828e5529 fix: instruct bot shell agent to report command stdout (fixes #3501)
Strengthen the shell-tool instruction in enable_shell_tools so the
gateway agent actually invokes execute_command and returns its stdout
verbatim, instead of intermittently replying "there was no output".
This addresses the flaky E2E sandbox marker check (E2E_SANDBOX_OK).

Co-authored-by: Dhivya-Bharathy <[email protected]>
2026-07-29 09:24:39 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy 5a2cd2b20e fix: sandbox USERPROFILE so rules path-safety test passes on Windows (fixes #3500)
Path.home() resolves via USERPROFILE on Windows, not HOME. The fixture
only set HOME, so on Windows the tool wrote to the real user profile and
the positive test asserted a file under tmp_path that was never created.
Set USERPROFILE alongside HOME so the fake home is honored on all
platforms and no writes escape the sandbox.

Co-authored-by: Dhivya-Bharathy <[email protected]>
2026-07-29 09:23:58 +00:00
praisonai-triage-agent[bot] 763500259b Merge pull request #3493 from MervinPraison/claude/issue-3492-20260729-0822
fix: close three cross-cutting wrapper-layer gaps (#3492)
2026-07-29 09:21:48 +00:00
praisonai-triage-agent[bot]andMervinPraison d3e5ba0996 fix: add durable SQLite backend for gateway push delivery store (fixes #3498)
Make the gateway's at-least-once push delivery guarantee durable by default
without an external Redis server. DeliveryConfig.store_backend now accepts
"sqlite" (new default), "redis" and "memory", validated in core. The wrapper
gains a SqlitePushStore mirroring the existing OutboundQueue pattern (WAL,
stdlib sqlite3, TTL eviction) at ~/.praisonai/state/push_delivery.sqlite, so
pending un-acked events survive a restart and replay on reconnect. "memory"
stays an explicit ephemeral opt-out; "redis" stays for multi-process fan-out.

Co-authored-by: MervinPraison <[email protected]>
2026-07-29 09:20:47 +00:00
praisonai-triage-agent[bot]andMervin Praison 14a30e55a9 fix: wire declarative remote skills into discovery, fix root-skill double-count and project config
- discovery.py: auto-include synced remote-skill cache (`current` aliases) in
  default skill dirs so agents pick up `skills sync` output without install;
  scan now treats a returned dir that is itself a skill (root-level SKILL.md)
  as a single skill.
- remote.py: return the fetched dir as-is (never its parent) so a root-level
  skill is not double-counted alongside its cache `current` alias.
- skills.py (CLI): `sync` reuses discover_skills (no re-implemented scan / no
  double-count); no-URL sync now reads project praisonai.yaml/.yml as well as
  the user-global config.
- test_remote.py: regression test for root-level SKILL.md repo discovered once.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-29 09:05:44 +00:00
praisonai-triage-agent[bot]andMervin Praison 9fa0f9999e fix: Task guardrail must prefer configured llm_instance over bare model string
Task._setup_guardrail selected `agent.llm or agent.llm_instance`; since
agent.llm is always a non-empty model-name string it always won, discarding
the configured LLM instance (base_url/api_key/auth) and authenticating against
the wrong backend. Reorder to `llm_instance or llm`, matching
Agent._setup_guardrail. small_model routing still applies only to the bare
string case. Adds Task guardrail routing/instance-preference tests.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-29 09:05:19 +00:00
praisonai-triage-agent[bot]andMervinPraison 8d8dc36685 fix: route auxiliary LLM calls to configured small_model (fixes #3494)
Internal, non-user-facing LLM calls (context compaction/summarisation and
LLM guardrail validation) always ran on the primary agent model. Thread the
existing get_small_model resolver through these live auxiliary call sites so
a configured small_model is used, while leaving it unset preserves today's
behaviour (unset -> primary model).

- agent._create_llm_summarize_fn: resolve the summariser model via
  get_small_model(primary_model=<agent model>) instead of hardcoding self.llm.
- Agent/Task LLM guardrail setup: when the guardrail LLM would default to the
  bare primary model-name string, route it through get_small_model.

Change is strictly additive and backward-compatible: an explicit LLM instance
or per-call model always wins.

Co-authored-by: MervinPraison <[email protected]>
2026-07-29 08:57:07 +00:00
praisonai-triage-agent[bot]andMervinPraison 5128bfda8c fix: add declarative remote skill sources that stay synced (fixes #3495)
Introduce an opt-in RemoteSkillSourceProtocol and a default git-backed
implementation that syncs skills into a versioned local cache with atomic
swaps and offline-safe fallback to the last-good cache. Wired into
discover_skills via a new sources= param (backward compatible, zero
import-time cost) and surfaced through a `praisonai skills sync` CLI
command reading skills.urls / skills.sources from config.

Co-authored-by: MervinPraison <[email protected]>
2026-07-29 08:57:02 +00:00
praisonai-triage-agent[bot]andMervin Praison 3141a24c61 fix: isolate capability cache per-registry & scope AgentOps teardown (#3492)
Addresses two P1 reviewer findings on the wrapper-layer PR:

- registry.py: move the capability cache off a process-global dict onto the
  FrameworkAdapterRegistry instance so two registries that register different
  adapters under the same name no longer read each other's flags.
- observability/hooks.py: record which AgentOps teardown a run owns
  (per-session vs legacy singleton). A run whose start_session returned no
  usable handle now ends nothing instead of falling back to the package-global
  end_session and cross-finalizing a concurrent run.
- agents_generator.py: keep the "cannot verify" refusal for genuinely unknown
  frameworks while treating known built-ins (DEFAULT_PRIORITY / registered)
  whose adapter can't be constructed as unsupported, preserving the historical
  "not supported for framework=..." message.

Adds regression tests for all three behaviours.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-29 08:42:09 +00:00
praisonai-triage-agent[bot]andMervinPraison 63b9e1e8f4 fix: close three cross-cutting wrapper-layer gaps (fixes #3492)
Gap 1: scope AgentOps to each ObservabilityRun (per-run start_session
handle + lock) instead of the process-global singleton so concurrent
runs don't cross-contaminate tags or cross-finalize sessions.

Gap 2: add run_sync_or_offload() to _async_bridge as the single
sync-or-offload driver; delete the duplicated copies in auto.py, use it
in PersistenceOrchestrator._sync (was silently raising inside a running
loop), and replace deprecated get_event_loop().run_in_executor with
asyncio.to_thread in api/agent_invoke.py.

Gap 3: add adapter_capability() and route SUPPORTS_RUNTIME_FEATURES /
SUPPORTS_WORKFLOW decisions through it, removing the
str(framework)=="praisonai" fallbacks so third-party adapters aren't
silently demoted on transient resolution failures.

Co-authored-by: MervinPraison <[email protected]>
2026-07-29 08:31:40 +00:00
MervinPraison e22dc0d170 docs: auto-update feature parity trackers [skip ci] 2026-07-29 08:13:19 +00:00
praisonai-triage-agent[bot] dd69b5ea95 Merge pull request #3491 from MervinPraison/claude/issue-3490-20260729-0715
fix: undo file resurrection, skill-approval race, and once-scope permission expiry
2026-07-29 08:12:48 +00:00
praisonai-triage-agent[bot]andMervin Praison 7e0d9939a4 fix: preserve ignored files (.env) on snapshot restore-all
The restore-all prune loop deleted every project file absent from the
target commit, silently destroying ignored/excluded files (.env, venv,
node_modules) that the snapshot never tracked. Mirror _sync_files()'s
ignore rules in restore so only would-be-tracked files are pruned.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-29 07:26:13 +00:00
praisonai-triage-agent[bot]andMervinPraison 8ac5a3bbec fix: mirror deletions in undo, lock skill-approval store, consume once-scope permissions (fixes #3490)
- snapshot: prune shadow files removed from the project in _sync_files() and
  restrict restore() "restore all" to the target commit's tree (removing files
  not in it), so undo() cannot resurrect deleted files or leak files from a
  later snapshot.
- skills: guard the pending-store load-mutate-save cycle in _stage_pending/
  approve/reject with a threading.Lock so concurrent proposals no longer
  silently overwrite each other.
- permissions: consume a scope="once" approval/denial on first match in
  _check_flat so it no longer becomes permanent for the process lifetime.

Co-authored-by: MervinPraison <[email protected]>
2026-07-29 07:19:33 +00:00
Hore01 37cea31ea9 fix: resolve pytest_plugins error with pytest 9 2026-07-28 18:32:08 +01:00
praisonai-triage-agent[bot]andMervinPraison 6863b13474 fix: route MCP URL transport selection through shared get_transport_type (fixes #3483)
Delegate mcp.py's inline SSE-vs-HTTP URL check to the existing
mcp_transport.get_transport_type() helper so there is a single source of
truth for URL->transport classification. No behaviour change: /sse URLs
still use SSE and all other HTTP URLs use Streamable HTTP.

Co-authored-by: MervinPraison <[email protected]>
2026-07-28 17:16:33 +00:00
praisonai-triage-agent[bot]andMervin Praison aa379152e8 fix: reach + isolate + settle gateway per-turn timeout/abort (#3467)
Address three P1 gaps in the gateway cancellation/timeout work:

- Timeout was unreachable: add per_turn_timeout to the bot GatewayServerSchema
  (extra="forbid" was rejecting it) and stamp it onto self.config in
  start_with_config so the documented YAML knob actually takes effect.
- Turn isolation: pass the InterruptController per-turn as the entry point's
  cancel_token instead of mutating the shared agent.interrupt_controller, so
  overlapping sessions on one Agent instance no longer interrupt each other.
  Legacy agents without a cancel_token kwarg fall back to the shared attribute.
- Cancellation honesty: interrupt cooperatively first and wait a bounded grace
  window (_ABORT_GRACE_SECONDS) for the turn — including a sync agent.chat in a
  worker thread that cannot be force-killed — to unwind before the serial
  session queue advances; hard task.cancel() only as a fallback.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-07-28 09:31:28 +00:00
praisonai-triage-agent[bot]andMervinPraison 8cbc4f4f19 fix: cancel/time-bound in-flight gateway agent turns (fixes #3467)
Wire per-turn abort + timeout into the gateway so a runaway turn can no
longer wedge the serial per-session queue:

- Advertise the `abort` method (+ `message_abort` event) in the hello
  capability handshake instead of the "abort not implemented" placeholder.
- Handle `abort`/`message_abort` WS frames and a portable `/stop` chat
  command; both drive the existing InterruptController and cancel the
  driving asyncio task via a new `_active_turns` registry.
- Run each turn under an optional `gateway.per_turn_timeout` (YAML/config
  knob, 0 = disabled) via `_drive_turn`, normalising cancelled/timed-out
  turns to a typed terminal message instead of leaving them hanging.

Reuses core's InterruptController (already checked by the agent run loop)
and `agent.interrupt_controller`; no new agent params.

Co-authored-by: MervinPraison <[email protected]>
2026-07-28 09:20:12 +00:00
praisonai-triage-agent[bot]andMervin Praison de5a95d549 fix: preserve workflow _execution_context across task retries
Do not clear task._execution_context inside _build_execution_context.
The run_task/arun_task retry loops re-enter this helper on guardrail /
completion failures; clearing stripped the upstream output and validation
feedback the retry needs (Greptile P1). The Process engine already owns
this field's lifecycle (re-set before each yield, reset to None before
selecting the next task), so no cross-task leak.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-28 07:24:29 +00:00
praisonai-triage-agent[bot]andMervinPraison 36da0007e6 fix: MCP per-call timeout, wire workflow context into prompt, fix parallel loop idx (fixes #3457)
- mcp.py: wrap session.call_tool/read_resource/get_prompt in asyncio.wait_for
  so a hung server fails one request without wedging the runner loop forever
- agents.py: fold task._execution_context (inter-task context + validation
  feedback built by Process._build_task_context) into the prompt builder, and
  clear it after consumption so it doesn't leak into later runs
- workflows.py: track future->idx map in parallel loop so error attribution
  survives exceptions instead of using an unbound/stale idx

Co-authored-by: MervinPraison <[email protected]>
2026-07-28 07:18:39 +00:00
praisonai-triage-agent[bot]andMervin Praison a900a5cc1d fix: remove duplicate BEFORE_TOOL/AFTER_TOOL hook firing in legacy async path (fixes #3455)
The legacy async tool loop in _achat_completion fired BEFORE_TOOL/AFTER_TOOL
inline and then again inside execute_tool_async, double-firing hooks when
registered and building hook inputs (os.getcwd/time.time) with no consumer
when none are. Rely on the single guarded dispatch in execute_tool_async,
which already handles blocking, arg mutation and after-context aggregation.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 17:18:44 +00:00
Hore01 02c1c87d58 fix: Remove pytest_plugins from conftest to resolve pytest error 2026-07-27 14:30:07 +01:00
praisonai-triage-agent[bot]andMervin Praison becf0f30fd fix: recognize all loopback bind forms in reliability default (#3438)
Classify loopback numerically via ipaddress so 127.0.0.2,
127.255.255.255, and expanded IPv6 ::1 keep the snappy loopback drain
instead of the 15s production window. Addresses Greptile review feedback.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 09:45:18 +00:00
praisonai-triage-agent[bot]andMervin Praison 785f361ded fix: harden gateway reload classifier for empty channel name + core version skew (#3440)
- classify_reload now rejects empty channel names (channels., channels..enabled)
  and falls through to the fail-safe FULL restart instead of restarting channel ""
- wrapper _build_reload_plan gracefully degrades to full restart if an older
  supported praisonaiagents lacks classify_reload, instead of crashing the reload
- add regression tests for malformed channel paths

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 09:45:07 +00:00
praisonai-triage-agent[bot]andMervinPraison 03b0d931dc fix: make gateway reliability safe by default (fixes #3438)
The unset (None) reliability posture now resolves to a backpressured
deployment — bounded admission ceiling + fair wait queue + graceful drain —
instead of the old no-admission one. It is bind-aware: a non-loopback bind
(a real deployment) resolves to the full production window, loopback keeps a
snappy 5s drain. reliability="off" opts back into today's immediate-teardown
behaviour; explicit reliability="default" keeps the legacy no-ceiling posture.

Co-authored-by: MervinPraison <[email protected]>
2026-07-27 09:35:53 +00:00
praisonai-triage-agent[bot]andMervinPraison 57dc4b115f fix: make gateway reload-scope classification canonical in core (fixes #3440)
Add a pure, side-effect-free ReloadScope/classify_reload registry to the
core SDK so hot-appliable vs channel-scoped vs agents vs full-restart is
classified identically by every runtime, instead of being duplicated
ad-hoc in the wrapper. The wrapper's _build_reload_plan now delegates to
core, keeping the existing scoped per-channel restart behaviour while the
contract "grows" in the authoritative layer. Backward-compatible.

Co-authored-by: MervinPraison <[email protected]>
2026-07-27 09:35:12 +00:00
praisonai-triage-agent[bot]andMervin Praison ab13eea127 fix: align published agents schema with runtime contract + log header failures
Addresses reviewer feedback on PR #3437:

- Greptile P1: the published editor schema was stricter than the runtime.
  generate_agents_schema() now relaxes AgentConfig (drops required
  role/goal/backstory, which the runtime auto-fills or maps from
  'instructions') and allows list-form roles/agents (runtime _list_to_dict),
  so editors no longer flag valid, executable YAML. The strict YAMLConfig used
  by ConfigValidator is unchanged.
- Greptile P2: replace the silent `except Exception: pass` around the
  yaml-language-server header write with a debug log so a dropped directive is
  diagnosable.
- Regenerate agents.schema.json; add a runtime-parity guard test.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 09:32:02 +00:00
praisonai-triage-agent[bot]andMervinPraison fda4d2424d fix: publish agents.yaml JSON Schema + wire editor autocomplete (fixes #3427)
Derive a JSON Schema from YAMLConfig (generate_agents_schema), publish it as
a committed agents.schema.json artefact, expose it via `praisonai validate
schema --agents/--output`, and prepend a `# yaml-language-server: $schema=`
header when auto.py scaffolds agents.yaml so editors give autocomplete /
inline validation out of the box. Authoring-time only; runtime parsing and
execution are unchanged (a leading YAML comment is ignored by safe_load).

Co-authored-by: MervinPraison <[email protected]>
2026-07-27 09:08:39 +00:00
praisonai-triage-agent[bot]andMervin Praison b3349e1ce1 fix(handoff): isolate handoff chain per parallel task (Greptile P1)
parallel_handoffs runs children via asyncio.gather, which copies the
context per task. A copied ContextVar binding still references the same
parent list object, so when parallel_handoffs runs inside a non-empty
parent handoff chain, sibling tasks mutated a shared list and corrupted
cycle/depth tracking. Rebind each task's chain to a fresh per-task copy
at _run_one entry so siblings are fully isolated.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 08:42:34 +00:00
praisonai-triage-agent[bot]andMervinPraison c79d640e82 fix: session_id collisions, concurrent handoff corruption, async retry hook crash (fixes #3397)
- memory_mixin: append per-instance uuid suffix to default session_id so
  same-named agents can no longer collide and bleed chat history
- handoff: replace threading.local() with contextvars.ContextVar so handoff
  chain/depth is isolated per asyncio.Task under parallel_handoffs
- hooks/runner: add HookRunner.execute_async alias so async retry-with-backoff
  no longer crashes with AttributeError

Co-authored-by: MervinPraison <[email protected]>
2026-07-27 08:42:34 +00:00
531 changed files with 48516 additions and 5589 deletions

No files matched your search

@@ -1,5 +1,5 @@
name: Install PraisonAI monorepo packages
description: Install praisonai-agents, praisonai-code, praisonai-bot, praisonai-train, praisonai-browser, praisonai-mcp, praisonai-sandbox, and praisonai wrapper from local paths (mirrors PyPI eight-package install order).
description: Install praisonai-agents, praisonai-code, praisonai-bot, praisonai-train, praisonai-browser, praisonai-mcp, praisonai-sandbox, praisonai-deploy, and praisonai wrapper from local paths (mirrors PyPI nine-package install order).
inputs:
repo-root:
@@ -40,6 +40,7 @@ runs:
BROWSER_PKG="${BASE}src/praisonai-browser"
MCP_PKG="${BASE}src/praisonai-mcp"
SANDBOX_PKG="${BASE}src/praisonai-sandbox"
DEPLOY_PKG="${BASE}src/praisonai-deploy"
WRAPPER_PKG="${BASE}src/praisonai"
normalise_extras() {
@@ -69,6 +70,8 @@ runs:
uv pip install --system -e .
cd "$SANDBOX_PKG"
uv pip install --system -e .
cd "$DEPLOY_PKG"
uv pip install --system -e .
cd "$WRAPPER_PKG"
uv pip install --system "${WRAPPER_SUFFIX}"
else
@@ -86,6 +89,8 @@ runs:
pip install -e .
cd "$SANDBOX_PKG"
pip install -e .
cd "$DEPLOY_PKG"
pip install -e .
cd "$WRAPPER_PKG"
pip install -e "${WRAPPER_SUFFIX}"
fi
+77 -36
View File
@@ -11,71 +11,112 @@ assert.ok(versions.currentCode);
assert.ok(versions.currentWrapper);
assert.ok(versions.targetCode);
assert.ok(rg.PACKAGE_PATHS.includes('src/praisonai-code'));
// Every released package must make the gate's path-change check eligible.
for (const p of [
'src/praisonai', 'src/praisonai-agents', 'src/praisonai-code',
'src/praisonai-bot', 'src/praisonai-train', 'src/praisonai-browser',
'src/praisonai-mcp', 'src/praisonai-sandbox', 'src/praisonai-deploy',
]) {
assert.ok(rg.PACKAGE_PATHS.includes(p), `PACKAGE_PATHS missing ${p}`);
}
const noonUtc = new Date('2026-07-08T12:00:00Z');
const dayStart = rg.utcDayStart(noonUtc);
assert.strictEqual(dayStart.toISOString(), '2026-07-08T00:00:00.000Z');
(async () => {
assert.strictEqual(await rg.pypiVersionExists('praisonaiagents', '0.0.0'), false);
const mockGithub = {
function mockGithub({ runs = [], releases = [] } = {}) {
return {
rest: {
actions: {
listWorkflowRuns: async () => ({
data: {
workflow_runs: [
{
conclusion: 'success',
created_at: '2026-07-08T09:00:00Z',
status: 'completed',
},
],
},
}),
listWorkflowRuns: async () => ({ data: { workflow_runs: runs } }),
},
repos: {
listReleases: async () => ({ data: releases }),
},
},
};
}
(async () => {
assert.strictEqual(await rg.pypiVersionExists('praisonaiagents', '0.0.0'), false);
assert.strictEqual(rg.PATCH_RELEASE_INTERVAL_DAYS, 3);
// Dedupe is release-based: a v* release inside the window blocks…
const recentRelease = mockGithub({
releases: [{ tag_name: 'v4.6.159', published_at: '2026-07-08T09:00:00Z' }],
});
assert.strictEqual(
await rg.hasSuccessfulReleaseWithinDays(mockGithub, 'o', 'r', noonUtc),
await rg.hasSuccessfulReleaseWithinDays(recentRelease, 'o', 'r', noonUtc),
true
);
const result = await rg.evaluateReleasePreflight(
mockGithub, 'o', 'r',
recentRelease, 'o', 'r',
{ headSha: 'abc', isCiTrigger: false, bump: 'patch', now: noonUtc },
null
);
assert.ok(result.reasons.some((r) => r.includes('every 3 days')));
const oldReleaseGithub = {
rest: {
actions: {
listWorkflowRuns: async () => ({
data: {
workflow_runs: [
{
conclusion: 'success',
created_at: '2026-07-04T09:00:00Z',
status: 'completed',
},
],
},
}),
},
},
};
// …an old release does not…
const oldRelease = mockGithub({
releases: [{ tag_name: 'v4.6.150', published_at: '2026-07-04T09:00:00Z' }],
});
assert.strictEqual(
await rg.hasSuccessfulReleaseWithinDays(oldReleaseGithub, 'o', 'r', noonUtc),
await rg.hasSuccessfulReleaseWithinDays(oldRelease, 'o', 'r', noonUtc),
false
);
// …and a dry-run "success" (workflow run, no release) does not consume the
// window: only real releases count.
const dryRunOnly = mockGithub({
runs: [{ conclusion: 'success', created_at: '2026-07-08T09:00:00Z', status: 'completed' }],
releases: [],
});
assert.strictEqual(
await rg.hasSuccessfulReleaseWithinDays(dryRunOnly, 'o', 'r', noonUtc),
false
);
// Active-run blocking: fresh waiting run blocks…
const freshWaiting = mockGithub({
runs: [{ status: 'waiting', conclusion: null, created_at: '2026-07-08T11:00:00Z', html_url: 'x' }],
});
assert.strictEqual(
await rg.hasActiveReleaseRun(freshWaiting, 'o', 'r', noonUtc, null),
true
);
// …a waiting run stuck longer than STALE_WAITING_HOURS no longer blocks…
const staleWaiting = mockGithub({
runs: [{ status: 'waiting', conclusion: null, created_at: '2026-07-08T01:00:00Z', html_url: 'x' }],
});
const warnings = [];
const fakeCore = { warning: (m) => warnings.push(m), info: () => {} };
assert.strictEqual(rg.STALE_WAITING_HOURS, 6);
assert.strictEqual(
await rg.hasActiveReleaseRun(staleWaiting, 'o', 'r', noonUtc, fakeCore),
false
);
assert.ok(warnings.length === 1 && warnings[0].includes('approve or cancel'));
// …but an in_progress run always blocks regardless of age.
const oldInProgress = mockGithub({
runs: [{ status: 'in_progress', conclusion: null, created_at: '2026-07-07T01:00:00Z', html_url: 'x' }],
});
assert.strictEqual(
await rg.hasActiveReleaseRun(oldInProgress, 'o', 'r', noonUtc, null),
true
);
console.log('ok: bumpPatch');
console.log('ok: readVersionsFromTree');
console.log('ok: PACKAGE_PATHS covers all 9 released packages');
console.log('ok: pypiVersionExists missing version');
console.log('ok: utcDayStart');
console.log('ok: hasSuccessfulReleaseWithinDays');
console.log('ok: release-based dedupe (recent blocks, old does not)');
console.log('ok: dry-run success does not consume the release window');
console.log('ok: 3-day dedupe blocks second release');
console.log('ok: fresh waiting run blocks; stale waiting run ignored with warning');
console.log('ok: in_progress always blocks');
})();
+78 -15
View File
@@ -4,12 +4,31 @@
const https = require('https');
const PACKAGE_PATHS = ['src/praisonai', 'src/praisonai-agents', 'src/praisonai-code'];
/** Every directory whose changes should make a release eligible — all nine
* published packages (mirrors the skip_* inputs in pypi-release.yml). */
const PACKAGE_PATHS = [
'src/praisonai',
'src/praisonai-agents',
'src/praisonai-code',
'src/praisonai-bot',
'src/praisonai-train',
'src/praisonai-browser',
'src/praisonai-mcp',
'src/praisonai-sandbox',
'src/praisonai-deploy',
];
/** Minimum days between successful patch auto-releases. */
const PATCH_RELEASE_INTERVAL_DAYS = 3;
const ACTIVE_RELEASE_STATUSES = new Set([
'queued', 'in_progress', 'waiting', 'pending', 'requested',
]);
/** A run stuck in `waiting` (environment approval) longer than this no longer
* blocks the gate. GitHub only auto-fails unapproved deployments after 30
* days, so without a cutoff one forgotten manual dispatch stalls all
* auto-releases for up to a month. The run is warned about, never cancelled —
* if later approved, the pypi-release concurrency group serializes it and its
* pypi_exists checks no-op anything already published. */
const STALE_WAITING_HOURS = 6;
function bumpPatch(version) {
const parts = version.split('.');
@@ -65,16 +84,27 @@ function pypiVersionExists(packageName, version) {
});
}
async function hasActiveReleaseRun(github, owner, repo) {
async function hasActiveReleaseRun(github, owner, repo, now = new Date(), core = null) {
const runs = await github.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: 'pypi-release.yml',
per_page: 20,
});
return runs.data.workflow_runs.some(
(r) => ACTIVE_RELEASE_STATUSES.has(r.status) && !r.conclusion
);
const staleCutoff = now.getTime() - STALE_WAITING_HOURS * 60 * 60 * 1000;
return runs.data.workflow_runs.some((r) => {
if (!ACTIVE_RELEASE_STATUSES.has(r.status) || r.conclusion) return false;
if (r.status === 'waiting' && new Date(r.created_at).getTime() < staleCutoff) {
if (core) {
core.warning(
`Ignoring release run waiting >${STALE_WAITING_HOURS}h for environment approval: `
+ `${r.html_url} — approve or cancel it. It no longer blocks auto-releases.`
);
}
return false;
}
return true;
});
}
function utcDayStart(now = new Date()) {
@@ -93,15 +123,18 @@ async function hasSuccessfulReleaseWithinDays(
intervalDays = PATCH_RELEASE_INTERVAL_DAYS,
) {
const windowStart = releaseIntervalStart(now, intervalDays);
const runs = await github.rest.actions.listWorkflowRuns({
// Dedupe on GitHub releases (v* tags) rather than run conclusions: a
// dry_run=true dispatch concludes 'success' without publishing anything and
// must not consume the release window. Only a real full release creates a
// v* GitHub release (bump_and_release.py's `gh release create`).
const releases = await github.rest.repos.listReleases({
owner,
repo,
workflow_id: 'pypi-release.yml',
status: 'completed',
per_page: 30,
per_page: 10,
});
return runs.data.workflow_runs.some(
(r) => r.conclusion === 'success' && new Date(r.created_at) >= windowStart
return releases.data.some(
(r) => r.tag_name && r.tag_name.startsWith('v')
&& new Date(r.published_at || r.created_at) >= windowStart
);
}
@@ -146,12 +179,13 @@ async function evaluateReleasePreflight(github, owner, repo, options, core) {
return out;
}
if (await hasActiveReleaseRun(github, owner, repo)) {
const referenceTime = options.now instanceof Date ? options.now : new Date();
if (await hasActiveReleaseRun(github, owner, repo, referenceTime, core)) {
reasons.push('PyPI Release already in progress or awaiting approval');
return out;
}
const referenceTime = options.now instanceof Date ? options.now : new Date();
if (await hasSuccessfulReleaseWithinDays(github, owner, repo, referenceTime)) {
reasons.push(
`already released within last ${PATCH_RELEASE_INTERVAL_DAYS} days; `
@@ -226,8 +260,35 @@ async function evaluateReleasePreflight(github, owner, repo, options, core) {
out.headSha = evalSha;
const greenSha = await lastGreenCoreTestsSha(github, owner, repo);
if (greenSha !== evalSha) {
reasons.push(`CI not green on HEAD (last green: ${greenSha ? greenSha.slice(0, 7) : 'none'})`);
return out;
// Core Tests has path filters and honors [skip ci] (the release
// safety-net commit uses it), so the tip of main may legitimately have
// no Core Tests run of its own — e.g. a docs-only commit, or the
// "chore(release): … [skip ci]" commit right after a release. Accept a
// green ancestor when nothing under the released package paths changed
// after it; otherwise the cron path stalls until the next src/** push.
let greenAncestorOk = false;
if (greenSha && /^[0-9a-f]{40}$/.test(greenSha)) {
try {
execSync(`git merge-base --is-ancestor ${greenSha} ${evalSha}`);
const delta = execSync(
`git diff --name-only ${greenSha} ${evalSha} -- ${PACKAGE_PATHS.join(' ')}`,
{ encoding: 'utf8' }
).trim();
greenAncestorOk = delta === '';
} catch {
greenAncestorOk = false;
}
}
if (!greenAncestorOk) {
reasons.push(`CI not green on HEAD (last green: ${greenSha ? greenSha.slice(0, 7) : 'none'})`);
return out;
}
if (core) {
core.info(
`HEAD ${evalSha.slice(0, 7)} has no Core Tests run; accepting green ancestor `
+ `${greenSha.slice(0, 7)} (no package-path changes since).`
);
}
}
}
@@ -242,7 +303,9 @@ module.exports = {
readVersionsFromTree,
pypiVersionExists,
PATCH_RELEASE_INTERVAL_DAYS,
STALE_WAITING_HOURS,
releaseIntervalStart,
hasActiveReleaseRun,
hasSuccessfulReleaseWithinDays,
hasSuccessfulReleaseToday,
utcDayStart,
+5 -1
View File
@@ -41,7 +41,7 @@ jobs:
github.event.workflow_run.head_branch == 'main' &&
github.event.workflow_run.event == 'push' &&
!startsWith(github.event.workflow_run.head_commit.message, 'Release v') &&
!startsWith(github.event.workflow_run.head_commit.message, 'Bump praisonai')
!startsWith(github.event.workflow_run.head_commit.message, 'chore(release):')
)
runs-on: ubuntu-latest
env:
@@ -69,6 +69,10 @@ jobs:
- name: Fetch main and tags
run: git fetch --tags origin main
# Guards the gate logic itself — no workflow ran these selftests before.
- name: Selftest release-gate script
run: node .github/scripts/release-gate-selftest.js
- name: Preflight — path changes, CI SHA, dedupe
id: preflight
uses: actions/github-script@v7
+424 -169
View File
@@ -61,6 +61,29 @@ on:
required: false
default: false
type: boolean
skip_deploy:
description: 'Skip praisonai-deploy publish (recovery if already on PyPI)'
required: false
default: false
type: boolean
# NOTE: GitHub caps workflow_dispatch at 25 top-level inputs; this
# workflow has 23. Adding a 10th package (skip_* + *_version) hits 25.
only:
description: 'Release ONLY this package (forces skip on all others); all = no restriction'
required: false
default: all
type: choice
options:
- all
- agents
- code
- bot
- train
- browser
- mcp
- sandbox
- deploy
- wrapper
agents_version:
description: 'Override agents version (empty = auto bump from pyproject.toml)'
required: false
@@ -101,6 +124,11 @@ on:
required: false
default: ''
type: string
deploy_version:
description: 'Override praisonai-deploy version (empty = auto bump from pyproject.toml)'
required: false
default: ''
type: string
source:
description: 'Trigger source (audit trail)'
required: false
@@ -137,14 +165,32 @@ jobs:
# PyPI API token auth: username __token__, password = pypi-… token (see pypi.org/help/#invalid-auth)
UV_PUBLISH_USERNAME: __token__
UV_PUBLISH_PASSWORD: ${{ secrets.PYPI_TOKEN || secrets.PYPI_API_TOKEN }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
# GH_TOKEN is NOT set here: it is a GitHub App installation token minted in
# "Generate GitHub App token" and exported via $GITHUB_ENV, so releases are
# authored by praisonai-triage-agent[bot] rather than MervinPraison.
steps:
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.CLAUDE_APP_ID }}
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
# Scope to this repo only. Without it the token is minted for every
# repository the owner has; this job never leaves PraisonAI (checkout,
# push to origin, and gh release create all target it).
repositories: ${{ github.event.repository.name }}
- name: Checkout main
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: main
fetch-depth: 0
token: ${{ secrets.GH_TOKEN }}
token: ${{ steps.app-token.outputs.token }}
# Do not bake the token into .git/config: an http.extraheader would
# outrank the remote URL set in "Configure git" and pin this first
# (1h) token, defeating the refresh before the wrapper release.
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
@@ -155,10 +201,19 @@ jobs:
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4
- name: Configure git
env:
APP_TOKEN: ${{ steps.app-token.outputs.token }}
REPO: ${{ github.repository }}
run: |
git config user.name "MervinPraison"
git config user.email "[email protected]"
gh auth setup-git
set -euo pipefail
git config user.name "praisonai-triage-agent[bot]"
git config user.email "272766704+praisonai-triage-agent[bot]@users.noreply.github.com"
# persist-credentials:false left the remote unauthenticated; push auth
# comes from the app token embedded here (masked in logs, and the
# token dies with the runner).
git remote set-url origin "https://x-access-token:${APP_TOKEN}@github.com/${REPO}.git"
echo "GH_TOKEN=${APP_TOKEN}" >> "$GITHUB_ENV"
GH_TOKEN="${APP_TOKEN}" gh auth setup-git
- name: Release summary
run: |
@@ -168,6 +223,36 @@ jobs:
echo "Dry run: ${{ inputs.dry_run }}"
echo "Environment: ${{ (inputs.source == 'ci' || inputs.source == 'nightly') && inputs.bump == 'patch' && 'pypi-auto' || 'pypi' }}"
# Single source of truth for which packages this run releases. `only`
# inverts into the skip flags here; everything downstream (version
# computation, PyPI detection, publish conditions) consumes these
# outputs, so a dedicated one-package release is one input instead of
# eight skip flags.
- name: Resolve effective skips
id: flags
env:
ONLY: ${{ inputs.only }}
SKIP_AGENTS: ${{ inputs.skip_agents }}
SKIP_CODE: ${{ inputs.skip_code }}
SKIP_BOT: ${{ inputs.skip_bot }}
SKIP_TRAIN: ${{ inputs.skip_train }}
SKIP_BROWSER: ${{ inputs.skip_browser }}
SKIP_MCP: ${{ inputs.skip_mcp }}
SKIP_SANDBOX: ${{ inputs.skip_sandbox }}
SKIP_DEPLOY: ${{ inputs.skip_deploy }}
SKIP_WRAPPER: ${{ inputs.skip_wrapper }}
run: |
set -euo pipefail
for pkg in agents code bot train browser mcp sandbox deploy wrapper; do
var="SKIP_$(echo "$pkg" | tr '[:lower:]' '[:upper:]')"
skip="${!var}"
if [ -n "${ONLY}" ] && [ "${ONLY}" != "all" ] && [ "${ONLY}" != "$pkg" ]; then
skip=true
fi
echo "skip_${pkg}=${skip}" >> "$GITHUB_OUTPUT"
echo "skip_${pkg}=${skip}"
done
- name: Compute release versions
id: versions
env:
@@ -180,13 +265,15 @@ jobs:
BROWSER_OVERRIDE: ${{ inputs.browser_version }}
MCP_OVERRIDE: ${{ inputs.mcp_version }}
SANDBOX_OVERRIDE: ${{ inputs.sandbox_version }}
SKIP_AGENTS: ${{ inputs.skip_agents }}
SKIP_CODE: ${{ inputs.skip_code }}
SKIP_BOT: ${{ inputs.skip_bot }}
SKIP_TRAIN: ${{ inputs.skip_train }}
SKIP_BROWSER: ${{ inputs.skip_browser }}
SKIP_MCP: ${{ inputs.skip_mcp }}
SKIP_SANDBOX: ${{ inputs.skip_sandbox }}
DEPLOY_OVERRIDE: ${{ inputs.deploy_version }}
SKIP_AGENTS: ${{ steps.flags.outputs.skip_agents }}
SKIP_CODE: ${{ steps.flags.outputs.skip_code }}
SKIP_BOT: ${{ steps.flags.outputs.skip_bot }}
SKIP_TRAIN: ${{ steps.flags.outputs.skip_train }}
SKIP_BROWSER: ${{ steps.flags.outputs.skip_browser }}
SKIP_MCP: ${{ steps.flags.outputs.skip_mcp }}
SKIP_SANDBOX: ${{ steps.flags.outputs.skip_sandbox }}
SKIP_DEPLOY: ${{ steps.flags.outputs.skip_deploy }}
run: |
python <<'PY'
import os
@@ -223,6 +310,7 @@ jobs:
browser_override = os.environ.get("BROWSER_OVERRIDE", "").strip()
mcp_override = os.environ.get("MCP_OVERRIDE", "").strip()
sandbox_override = os.environ.get("SANDBOX_OVERRIDE", "").strip()
deploy_override = os.environ.get("DEPLOY_OVERRIDE", "").strip()
validate_version("agents_version", agents_override)
validate_version("wrapper_version", wrapper_override)
validate_version("code_version", code_override)
@@ -231,6 +319,7 @@ jobs:
validate_version("browser_version", browser_override)
validate_version("mcp_version", mcp_override)
validate_version("sandbox_version", sandbox_override)
validate_version("deploy_version", deploy_override)
agents_pyproject = root / "src/praisonai-agents/pyproject.toml"
agents_content = agents_pyproject.read_text()
@@ -288,6 +377,14 @@ jobs:
sys.exit(1)
current_sandbox = sandbox_match.group(1)
deploy_pyproject = root / "src/praisonai-deploy/pyproject.toml"
deploy_content = deploy_pyproject.read_text()
deploy_match = re.search(r'^version\s*=\s*"([^"]+)"', deploy_content, re.MULTILINE)
if not deploy_match:
print("Could not read praisonai-deploy version from pyproject.toml", file=sys.stderr)
sys.exit(1)
current_deploy = deploy_match.group(1)
wrapper_version_py = root / "src/praisonai/praisonai/version.py"
wrapper_content = wrapper_version_py.read_text()
wrapper_match = re.search(r'__version__ = "([^"]+)"', wrapper_content)
@@ -304,6 +401,7 @@ jobs:
("browser", current_browser),
("mcp", current_mcp),
("sandbox", current_sandbox),
("deploy", current_deploy),
("wrapper", current_wrapper),
):
if not SEMVER.match(value):
@@ -317,6 +415,7 @@ jobs:
skip_browser = os.environ.get("SKIP_BROWSER", "false").lower() == "true"
skip_mcp = os.environ.get("SKIP_MCP", "false").lower() == "true"
skip_sandbox = os.environ.get("SKIP_SANDBOX", "false").lower() == "true"
skip_deploy = os.environ.get("SKIP_DEPLOY", "false").lower() == "true"
if agents_override:
agents_version = agents_override
@@ -367,6 +466,13 @@ jobs:
else:
sandbox_version = bump_version(current_sandbox)
if deploy_override:
deploy_version = deploy_override
elif skip_deploy:
deploy_version = current_deploy
else:
deploy_version = bump_version(current_deploy)
if wrapper_override:
wrapper_version = wrapper_override
else:
@@ -381,6 +487,7 @@ jobs:
fh.write(f"browser_version={browser_version}\n")
fh.write(f"mcp_version={mcp_version}\n")
fh.write(f"sandbox_version={sandbox_version}\n")
fh.write(f"deploy_version={deploy_version}\n")
fh.write(f"wrapper_version={wrapper_version}\n")
fh.write(f"current_agents={current_agents}\n")
fh.write(f"current_code={current_code}\n")
@@ -389,6 +496,7 @@ jobs:
fh.write(f"current_browser={current_browser}\n")
fh.write(f"current_mcp={current_mcp}\n")
fh.write(f"current_sandbox={current_sandbox}\n")
fh.write(f"current_deploy={current_deploy}\n")
fh.write(f"current_wrapper={current_wrapper}\n")
print(f"Agents: {current_agents} -> {agents_version}")
@@ -398,6 +506,7 @@ jobs:
print(f"Browser: {current_browser} -> {browser_version}")
print(f"MCP: {current_mcp} -> {mcp_version}")
print(f"Sandbox: {current_sandbox} -> {sandbox_version}")
print(f"Deploy: {current_deploy} -> {deploy_version}")
print(f"Wrapper: {current_wrapper} -> {wrapper_version}")
PY
@@ -412,15 +521,17 @@ jobs:
BROWSER_VERSION: ${{ steps.versions.outputs.browser_version }}
MCP_VERSION: ${{ steps.versions.outputs.mcp_version }}
SANDBOX_VERSION: ${{ steps.versions.outputs.sandbox_version }}
DEPLOY_VERSION: ${{ steps.versions.outputs.deploy_version }}
WRAPPER_VERSION: ${{ steps.versions.outputs.wrapper_version }}
INPUT_SKIP_AGENTS: ${{ inputs.skip_agents }}
INPUT_SKIP_CODE: ${{ inputs.skip_code }}
INPUT_SKIP_BOT: ${{ inputs.skip_bot }}
INPUT_SKIP_TRAIN: ${{ inputs.skip_train }}
INPUT_SKIP_BROWSER: ${{ inputs.skip_browser }}
INPUT_SKIP_MCP: ${{ inputs.skip_mcp }}
INPUT_SKIP_SANDBOX: ${{ inputs.skip_sandbox }}
INPUT_SKIP_WRAPPER: ${{ inputs.skip_wrapper }}
INPUT_SKIP_AGENTS: ${{ steps.flags.outputs.skip_agents }}
INPUT_SKIP_CODE: ${{ steps.flags.outputs.skip_code }}
INPUT_SKIP_BOT: ${{ steps.flags.outputs.skip_bot }}
INPUT_SKIP_TRAIN: ${{ steps.flags.outputs.skip_train }}
INPUT_SKIP_BROWSER: ${{ steps.flags.outputs.skip_browser }}
INPUT_SKIP_MCP: ${{ steps.flags.outputs.skip_mcp }}
INPUT_SKIP_SANDBOX: ${{ steps.flags.outputs.skip_sandbox }}
INPUT_SKIP_DEPLOY: ${{ steps.flags.outputs.skip_deploy }}
INPUT_SKIP_WRAPPER: ${{ steps.flags.outputs.skip_wrapper }}
run: |
set -euo pipefail
skip_agents="${INPUT_SKIP_AGENTS}"
@@ -430,30 +541,53 @@ jobs:
skip_browser="${INPUT_SKIP_BROWSER}"
skip_mcp="${INPUT_SKIP_MCP}"
skip_sandbox="${INPUT_SKIP_SANDBOX}"
skip_deploy="${INPUT_SKIP_DEPLOY}"
skip_wrapper="${INPUT_SKIP_WRAPPER}"
# Returns 0 if published, 1 if definitively absent (404). Any other
# HTTP status (PyPI outage, 5xx, rate limit) FAILS the run: guessing
# "not published" would lead to a duplicate-upload failure midway
# through the release, which is strictly worse than stopping here
# before anything has been published.
pypi_published() { # $1=package $2=version
local code attempt
for attempt in 1 2 3; do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 30 \
"https://pypi.org/pypi/$1/$2/json" || echo "000")
case "$code" in
200) return 0 ;;
404) return 1 ;;
*) echo "PyPI check $1==$2 returned HTTP ${code} (attempt ${attempt}/3)"; sleep 5 ;;
esac
done
echo "❌ Cannot determine PyPI state for $1==$2; refusing to guess." >&2
exit 2
}
if [ "$skip_agents" != "true" ]; then
curl -fsSL "https://pypi.org/pypi/praisonaiagents/${AGENTS_VERSION}/json" >/dev/null && skip_agents=true || true
pypi_published praisonaiagents "${AGENTS_VERSION}" && skip_agents=true || true
fi
if [ "$skip_code" != "true" ]; then
curl -fsSL "https://pypi.org/pypi/praisonai-code/${CODE_VERSION}/json" >/dev/null && skip_code=true || true
pypi_published praisonai-code "${CODE_VERSION}" && skip_code=true || true
fi
if [ "$skip_bot" != "true" ]; then
curl -fsSL "https://pypi.org/pypi/praisonai-bot/${BOT_VERSION}/json" >/dev/null && skip_bot=true || true
pypi_published praisonai-bot "${BOT_VERSION}" && skip_bot=true || true
fi
if [ "$skip_train" != "true" ]; then
curl -fsSL "https://pypi.org/pypi/praisonai-train/${TRAIN_VERSION}/json" >/dev/null && skip_train=true || true
pypi_published praisonai-train "${TRAIN_VERSION}" && skip_train=true || true
fi
if [ "$skip_browser" != "true" ]; then
curl -fsSL "https://pypi.org/pypi/praisonai-browser/${BROWSER_VERSION}/json" >/dev/null && skip_browser=true || true
pypi_published praisonai-browser "${BROWSER_VERSION}" && skip_browser=true || true
fi
if [ "$skip_mcp" != "true" ]; then
curl -fsSL "https://pypi.org/pypi/praisonai-mcp/${MCP_VERSION}/json" >/dev/null && skip_mcp=true || true
pypi_published praisonai-mcp "${MCP_VERSION}" && skip_mcp=true || true
fi
if [ "$skip_sandbox" != "true" ]; then
curl -fsSL "https://pypi.org/pypi/praisonai-sandbox/${SANDBOX_VERSION}/json" >/dev/null && skip_sandbox=true || true
pypi_published praisonai-sandbox "${SANDBOX_VERSION}" && skip_sandbox=true || true
fi
if [ "$skip_deploy" != "true" ]; then
pypi_published praisonai-deploy "${DEPLOY_VERSION}" && skip_deploy=true || true
fi
if [ "$skip_wrapper" != "true" ]; then
curl -fsSL "https://pypi.org/pypi/praisonai/${WRAPPER_VERSION}/json" >/dev/null && skip_wrapper=true || true
pypi_published praisonai "${WRAPPER_VERSION}" && skip_wrapper=true || true
fi
echo "skip_agents=${skip_agents}" >> "$GITHUB_OUTPUT"
echo "skip_code=${skip_code}" >> "$GITHUB_OUTPUT"
@@ -462,8 +596,9 @@ jobs:
echo "skip_browser=${skip_browser}" >> "$GITHUB_OUTPUT"
echo "skip_mcp=${skip_mcp}" >> "$GITHUB_OUTPUT"
echo "skip_sandbox=${skip_sandbox}" >> "$GITHUB_OUTPUT"
echo "skip_deploy=${skip_deploy}" >> "$GITHUB_OUTPUT"
echo "skip_wrapper=${skip_wrapper}" >> "$GITHUB_OUTPUT"
if [ "$skip_agents" = "true" ] && [ "$skip_code" = "true" ] && [ "$skip_bot" = "true" ] && [ "$skip_train" = "true" ] && [ "$skip_browser" = "true" ] && [ "$skip_mcp" = "true" ] && [ "$skip_sandbox" = "true" ] && [ "$skip_wrapper" = "true" ]; then
if [ "$skip_agents" = "true" ] && [ "$skip_code" = "true" ] && [ "$skip_bot" = "true" ] && [ "$skip_train" = "true" ] && [ "$skip_browser" = "true" ] && [ "$skip_mcp" = "true" ] && [ "$skip_sandbox" = "true" ] && [ "$skip_deploy" = "true" ] && [ "$skip_wrapper" = "true" ]; then
echo "All target versions already on PyPI — nothing to publish."
fi
@@ -477,6 +612,7 @@ jobs:
steps.pypi_exists.outputs.skip_browser == 'true' &&
steps.pypi_exists.outputs.skip_mcp == 'true' &&
steps.pypi_exists.outputs.skip_sandbox == 'true' &&
steps.pypi_exists.outputs.skip_deploy == 'true' &&
steps.pypi_exists.outputs.skip_wrapper == 'true'
run: echo "Idempotent skip — target versions already on PyPI."
@@ -484,34 +620,43 @@ jobs:
if: inputs.dry_run
run: |
echo "Dry run — no publish, commit, or push."
echo "Only: ${{ inputs.only }}"
echo "Agents version: ${{ steps.versions.outputs.agents_version }}"
echo "Code version: ${{ steps.versions.outputs.code_version }}"
echo "Bot version: ${{ steps.versions.outputs.bot_version }}"
echo "Train version: ${{ steps.versions.outputs.train_version }}"
echo "Browser version: ${{ steps.versions.outputs.browser_version }}"
echo "MCP version: ${{ steps.versions.outputs.mcp_version }}"
echo "Sandbox version: ${{ steps.versions.outputs.sandbox_version }}"
echo "Deploy version: ${{ steps.versions.outputs.deploy_version }}"
echo "Wrapper version: ${{ steps.versions.outputs.wrapper_version }}"
echo ""
if [ "${{ inputs.skip_agents }}" != "true" ]; then
if [ "${{ steps.flags.outputs.skip_agents }}" != "true" ]; then
echo "Would publish praisonaiagents ${{ steps.versions.outputs.agents_version }}"
fi
if [ "${{ inputs.skip_code }}" != "true" ]; then
if [ "${{ steps.flags.outputs.skip_code }}" != "true" ]; then
echo "Would publish praisonai-code ${{ steps.versions.outputs.code_version }}"
fi
if [ "${{ inputs.skip_bot }}" != "true" ]; then
if [ "${{ steps.flags.outputs.skip_bot }}" != "true" ]; then
echo "Would publish praisonai-bot ${{ steps.versions.outputs.bot_version }}"
fi
if [ "${{ inputs.skip_train }}" != "true" ]; then
if [ "${{ steps.flags.outputs.skip_train }}" != "true" ]; then
echo "Would publish praisonai-train ${{ steps.versions.outputs.train_version }}"
fi
if [ "${{ inputs.skip_browser }}" != "true" ]; then
if [ "${{ steps.flags.outputs.skip_browser }}" != "true" ]; then
echo "Would publish praisonai-browser ${{ steps.versions.outputs.browser_version }}"
fi
if [ "${{ inputs.skip_mcp }}" != "true" ]; then
if [ "${{ steps.flags.outputs.skip_mcp }}" != "true" ]; then
echo "Would publish praisonai-mcp ${{ steps.versions.outputs.mcp_version }}"
fi
if [ "${{ inputs.skip_wrapper }}" != "true" ]; then
echo "Would run bump_and_release.py ${{ steps.versions.outputs.wrapper_version }} --agents ${{ steps.versions.outputs.agents_version }} --code-pin ${{ steps.versions.outputs.code_version }} --bot-pin ${{ steps.versions.outputs.bot_version }} --train-pin ${{ steps.versions.outputs.train_version }} --browser-pin ${{ steps.versions.outputs.browser_version }} --mcp-pin ${{ steps.versions.outputs.mcp_version }}"
if [ "${{ steps.flags.outputs.skip_sandbox }}" != "true" ]; then
echo "Would publish praisonai-sandbox ${{ steps.versions.outputs.sandbox_version }}"
fi
if [ "${{ steps.flags.outputs.skip_deploy }}" != "true" ]; then
echo "Would publish praisonai-deploy ${{ steps.versions.outputs.deploy_version }}"
fi
if [ "${{ steps.flags.outputs.skip_wrapper }}" != "true" ]; then
echo "Would run bump_and_release.py ${{ steps.versions.outputs.wrapper_version }} --agents ${{ steps.versions.outputs.agents_version }} --code-pin ${{ steps.versions.outputs.code_version }} --bot-pin ${{ steps.versions.outputs.bot_version }} --train-pin ${{ steps.versions.outputs.train_version }} --browser-pin ${{ steps.versions.outputs.browser_version }} --mcp-pin ${{ steps.versions.outputs.mcp_version }} --sandbox-pin ${{ steps.versions.outputs.sandbox_version }} --deploy-pin ${{ steps.versions.outputs.deploy_version }}"
echo "Would uv publish praisonai ${{ steps.versions.outputs.wrapper_version }}"
fi
@@ -539,7 +684,9 @@ jobs:
rm -rf dist
uv lock
uv build
uv publish
# --check-url makes re-uploads idempotent: files already on PyPI are
# skipped instead of failing the publish (partial-upload recovery).
uv publish --check-url https://pypi.org/simple/
- name: Wait for praisonaiagents on PyPI
if: >-
@@ -568,19 +715,6 @@ jobs:
sleep "$INTERVAL"
done
- name: Commit agents version bump
if: >-
inputs.dry_run != true &&
inputs.skip_agents != true &&
steps.pypi_exists.outputs.skip_agents != 'true'
run: |
set -euo pipefail
git add src/praisonai-agents/pyproject.toml src/praisonai-agents/uv.lock
git diff --cached --quiet && { echo "No agents files to commit"; exit 0; }
git commit -m "Bump praisonaiagents to ${{ steps.versions.outputs.agents_version }}"
git pull --rebase origin main
git push origin main
- name: Publish praisonai-code
if: >-
inputs.dry_run != true &&
@@ -609,7 +743,9 @@ jobs:
rm -rf dist
uv lock
uv build
uv publish
# --check-url makes re-uploads idempotent: files already on PyPI are
# skipped instead of failing the publish (partial-upload recovery).
uv publish --check-url https://pypi.org/simple/
- name: Wait for praisonai-code on PyPI
if: >-
@@ -638,19 +774,6 @@ jobs:
sleep "$INTERVAL"
done
- name: Commit praisonai-code version bump
if: >-
inputs.dry_run != true &&
inputs.skip_code != true &&
steps.pypi_exists.outputs.skip_code != 'true'
run: |
set -euo pipefail
git add src/praisonai-code/pyproject.toml src/praisonai-code/uv.lock src/praisonai-code/praisonai_code/__init__.py
git diff --cached --quiet && { echo "No code files to commit"; exit 0; }
git commit -m "Bump praisonai-code to ${{ steps.versions.outputs.code_version }}"
git pull --rebase origin main
git push origin main
- name: Publish praisonai-bot
if: >-
inputs.dry_run != true &&
@@ -679,7 +802,9 @@ jobs:
rm -rf dist
uv lock
uv build
uv publish
# --check-url makes re-uploads idempotent: files already on PyPI are
# skipped instead of failing the publish (partial-upload recovery).
uv publish --check-url https://pypi.org/simple/
- name: Wait for praisonai-bot on PyPI
if: >-
@@ -708,19 +833,6 @@ jobs:
sleep "$INTERVAL"
done
- name: Commit praisonai-bot version bump
if: >-
inputs.dry_run != true &&
inputs.skip_bot != true &&
steps.pypi_exists.outputs.skip_bot != 'true'
run: |
set -euo pipefail
git add src/praisonai-bot/pyproject.toml src/praisonai-bot/uv.lock src/praisonai-bot/praisonai_bot/_version.py
git diff --cached --quiet && { echo "No bot files to commit"; exit 0; }
git commit -m "Bump praisonai-bot to ${{ steps.versions.outputs.bot_version }}"
git pull --rebase origin main
git push origin main
- name: Publish praisonai-train
if: >-
inputs.dry_run != true &&
@@ -749,7 +861,9 @@ jobs:
rm -rf dist
uv lock
uv build
uv publish
# --check-url makes re-uploads idempotent: files already on PyPI are
# skipped instead of failing the publish (partial-upload recovery).
uv publish --check-url https://pypi.org/simple/
- name: Wait for praisonai-train on PyPI
if: >-
@@ -778,19 +892,6 @@ jobs:
sleep "$INTERVAL"
done
- name: Commit praisonai-train version bump
if: >-
inputs.dry_run != true &&
inputs.skip_train != true &&
steps.pypi_exists.outputs.skip_train != 'true'
run: |
set -euo pipefail
git add src/praisonai-train/pyproject.toml src/praisonai-train/uv.lock src/praisonai-train/praisonai_train/_version.py
git diff --cached --quiet && { echo "No train files to commit"; exit 0; }
git commit -m "Bump praisonai-train to ${{ steps.versions.outputs.train_version }}"
git pull --rebase origin main
git push origin main
- name: Publish praisonai-browser
if: >-
inputs.dry_run != true &&
@@ -819,7 +920,9 @@ jobs:
rm -rf dist
uv lock
uv build
uv publish
# --check-url makes re-uploads idempotent: files already on PyPI are
# skipped instead of failing the publish (partial-upload recovery).
uv publish --check-url https://pypi.org/simple/
- name: Wait for praisonai-browser on PyPI
if: >-
@@ -848,19 +951,6 @@ jobs:
sleep "$INTERVAL"
done
- name: Commit praisonai-browser version bump
if: >-
inputs.dry_run != true &&
inputs.skip_browser != true &&
steps.pypi_exists.outputs.skip_browser != 'true'
run: |
set -euo pipefail
git add src/praisonai-browser/pyproject.toml src/praisonai-browser/uv.lock src/praisonai-browser/praisonai_browser/_version.py
git diff --cached --quiet && { echo "No browser files to commit"; exit 0; }
git commit -m "Bump praisonai-browser to ${{ steps.versions.outputs.browser_version }}"
git pull --rebase origin main
git push origin main
- name: Publish praisonai-mcp
if: >-
inputs.dry_run != true &&
@@ -889,7 +979,9 @@ jobs:
rm -rf dist
uv lock
uv build
uv publish
# --check-url makes re-uploads idempotent: files already on PyPI are
# skipped instead of failing the publish (partial-upload recovery).
uv publish --check-url https://pypi.org/simple/
- name: Wait for praisonai-mcp on PyPI
if: >-
@@ -918,19 +1010,6 @@ jobs:
sleep "$INTERVAL"
done
- name: Commit praisonai-mcp version bump
if: >-
inputs.dry_run != true &&
inputs.skip_mcp != true &&
steps.pypi_exists.outputs.skip_mcp != 'true'
run: |
set -euo pipefail
git add src/praisonai-mcp/pyproject.toml src/praisonai-mcp/uv.lock src/praisonai-mcp/praisonai_mcp/_version.py
git diff --cached --quiet && { echo "No mcp files to commit"; exit 0; }
git commit -m "Bump praisonai-mcp to ${{ steps.versions.outputs.mcp_version }}"
git pull --rebase origin main
git push origin main
- name: Publish praisonai-sandbox
if: >-
inputs.dry_run != true &&
@@ -959,7 +1038,9 @@ jobs:
rm -rf dist
uv lock
uv build
uv publish
# --check-url makes re-uploads idempotent: files already on PyPI are
# skipped instead of failing the publish (partial-upload recovery).
uv publish --check-url https://pypi.org/simple/
- name: Wait for praisonai-sandbox on PyPI
if: >-
@@ -988,20 +1069,101 @@ jobs:
sleep "$INTERVAL"
done
- name: Commit praisonai-sandbox version bump
- name: Publish praisonai-deploy
if: >-
inputs.dry_run != true &&
inputs.skip_sandbox != true &&
steps.pypi_exists.outputs.skip_sandbox != 'true'
inputs.skip_deploy != true &&
steps.pypi_exists.outputs.skip_deploy != 'true'
working-directory: src/praisonai-deploy
env:
NEW_VERSION: ${{ steps.versions.outputs.deploy_version }}
CURRENT: ${{ steps.versions.outputs.current_deploy }}
run: |
set -euo pipefail
git add src/praisonai-sandbox/pyproject.toml src/praisonai-sandbox/uv.lock src/praisonai-sandbox/praisonai_sandbox/_version.py
git diff --cached --quiet && { echo "No sandbox files to commit"; exit 0; }
git commit -m "Bump praisonai-sandbox to ${{ steps.versions.outputs.sandbox_version }}"
git pull --rebase origin main
git push origin main
python <<'PY'
import os
import re
from pathlib import Path
new = os.environ["NEW_VERSION"]
current = os.environ["CURRENT"]
pyproject = Path("pyproject.toml")
content = pyproject.read_text()
pyproject.write_text(content.replace(f'version = "{current}"', f'version = "{new}"', 1))
version_py = Path("praisonai_deploy/_version.py")
version_content = version_py.read_text()
version_py.write_text(re.sub(r'__version__ = "[^"]+"', f'__version__ = "{new}"', version_content, count=1))
PY
rm -rf dist
uv lock
uv build
# --check-url makes re-uploads idempotent: files already on PyPI are
# skipped instead of failing the publish (partial-upload recovery).
uv publish --check-url https://pypi.org/simple/
- name: Wait for praisonai-deploy on PyPI
if: >-
inputs.dry_run != true &&
inputs.skip_deploy != true &&
steps.pypi_exists.outputs.skip_deploy != 'true'
run: |
set -euo pipefail
PACKAGE="praisonai-deploy"
VERSION="${{ steps.versions.outputs.deploy_version }}"
MAX_WAIT=600
INTERVAL=30
START=$(date +%s)
while true; do
if curl -fsSL "https://pypi.org/pypi/${PACKAGE}/${VERSION}/json" >/dev/null; then
echo "✅ ${PACKAGE}==${VERSION} is available on PyPI"
exit 0
fi
ELAPSED=$(( $(date +%s) - START ))
if [ "$ELAPSED" -ge "$MAX_WAIT" ]; then
echo "❌ Timeout waiting for ${PACKAGE}==${VERSION} on PyPI"
exit 1
fi
echo "⏳ Waiting for ${PACKAGE}==${VERSION} ($(( MAX_WAIT - ELAPSED ))s remaining...)"
sleep "$INTERVAL"
done
# All per-package version bumps are still uncommitted at this point: the
# wrapper release below stages every one of them (bump_and_release.py's
# release_files list) into a SINGLE "Release v…" commit.
# App tokens expire after 1h and this job can exceed that (9 packages ×
# up to a 600s PyPI wait). Re-mint so the final commit/tag/push/release
# never runs on an expired token.
- name: Refresh GitHub App token
id: app-token-final
if: ${{ !cancelled() && inputs.dry_run != true }}
# A failed re-mint must not fail the release: the original token may
# still be valid, and the next step only swaps tokens on success.
continue-on-error: true
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.CLAUDE_APP_ID }}
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: ${{ github.event.repository.name }}
# Guarded on the refresh actually succeeding: if the re-mint failed
# (API blip), the ORIGINAL token — still wired into the remote URL and
# GH_TOKEN — may well be valid, and overwriting it with an empty string
# would turn a recoverable situation into a guaranteed auth failure.
- name: Use refreshed GitHub App token
if: ${{ !cancelled() && inputs.dry_run != true && steps.app-token-final.outcome == 'success' }}
env:
APP_TOKEN: ${{ steps.app-token-final.outputs.token }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
git remote set-url origin "https://x-access-token:${APP_TOKEN}@github.com/${REPO}.git"
echo "GH_TOKEN=${APP_TOKEN}" >> "$GITHUB_ENV"
- name: Bump and release wrapper
id: wrapper
if: >-
inputs.dry_run != true &&
inputs.skip_wrapper != true &&
@@ -1016,7 +1178,8 @@ jobs:
--browser-pin "${{ steps.versions.outputs.browser_version }}" \
--mcp-pin "${{ steps.versions.outputs.mcp_version }}" \
--sandbox-pin "${{ steps.versions.outputs.sandbox_version }}" \
--wait --max-wait 600 --no-add-all
--deploy-pin "${{ steps.versions.outputs.deploy_version }}" \
--wait --max-wait 600 --no-add-all --force
- name: Publish praisonai wrapper to PyPI
if: >-
@@ -1024,7 +1187,7 @@ jobs:
inputs.skip_wrapper != true &&
steps.pypi_exists.outputs.skip_wrapper != 'true'
working-directory: src/praisonai
run: uv publish
run: uv publish --check-url https://pypi.org/simple/
- name: Wait for praisonai on PyPI
if: >-
@@ -1053,47 +1216,139 @@ jobs:
sleep "$INTERVAL"
done
# Actually probes PyPI — previously this step only echoed checkmarks
# from the input flags, so it "verified" publishes that never happened.
- name: Verify PyPI versions
if: inputs.dry_run != true
run: |
set -euo pipefail
AGENTS="${{ steps.versions.outputs.agents_version }}"
CODE="${{ steps.versions.outputs.code_version }}"
BOT="${{ steps.versions.outputs.bot_version }}"
TRAIN="${{ steps.versions.outputs.train_version }}"
BROWSER="${{ steps.versions.outputs.browser_version }}"
MCP="${{ steps.versions.outputs.mcp_version }}"
SANDBOX="${{ steps.versions.outputs.sandbox_version }}"
WRAPPER="${{ steps.versions.outputs.wrapper_version }}"
verify() { # $1=pypi-name $2=version $3=effective-skip
if [ "$3" = "true" ]; then
echo "⏭️ $1 skipped"
return 0
fi
if curl -fsSL --max-time 30 "https://pypi.org/pypi/$1/$2/json" >/dev/null; then
echo "✅ $1==$2"
else
echo "❌ $1==$2 NOT found on PyPI"
exit 1
fi
}
verify praisonaiagents "${{ steps.versions.outputs.agents_version }}" "${{ steps.flags.outputs.skip_agents }}"
verify praisonai-code "${{ steps.versions.outputs.code_version }}" "${{ steps.flags.outputs.skip_code }}"
verify praisonai-bot "${{ steps.versions.outputs.bot_version }}" "${{ steps.flags.outputs.skip_bot }}"
verify praisonai-train "${{ steps.versions.outputs.train_version }}" "${{ steps.flags.outputs.skip_train }}"
verify praisonai-browser "${{ steps.versions.outputs.browser_version }}" "${{ steps.flags.outputs.skip_browser }}"
verify praisonai-mcp "${{ steps.versions.outputs.mcp_version }}" "${{ steps.flags.outputs.skip_mcp }}"
verify praisonai-sandbox "${{ steps.versions.outputs.sandbox_version }}" "${{ steps.flags.outputs.skip_sandbox }}"
verify praisonai-deploy "${{ steps.versions.outputs.deploy_version }}" "${{ steps.flags.outputs.skip_deploy }}"
verify praisonai "${{ steps.versions.outputs.wrapper_version }}" "${{ steps.flags.outputs.skip_wrapper }}"
if [ "${{ inputs.skip_agents }}" != "true" ]; then
echo "✅ praisonaiagents==${AGENTS}"
# Safety net. On a clean run the wrapper's "Release v…" commit already
# carries every bump and this is a no-op. It only fires when the single
# commit could not happen: a package publish failed midway (those versions
# are already live on PyPI, so the bumps must not be lost), skip_wrapper
# was set, or the wrapper itself failed.
- name: Persist any uncommitted version bumps
if: ${{ !cancelled() && inputs.dry_run != true }}
run: |
set -euo pipefail
# If the wrapper release did not complete, drop any uncommitted
# wrapper-side rewrites (version.py, Dockerfiles, pins): committing
# them would put a wrapper version on main that never reached PyPI,
# and the next auto-bump would skip past it forever. Committed
# changes are untouched (checkout only reverts the working tree),
# and if the wrapper was skipped these files were never modified.
if [ "${{ steps.wrapper.outcome }}" != "success" ]; then
git checkout -- \
src/praisonai/praisonai/version.py \
src/praisonai/pyproject.toml \
src/praisonai/uv.lock \
src/praisonai/README.md \
src/praisonai/praisonai.rb \
src/praisonai-deploy/praisonai_deploy/docker.py \
docker/ 2>/dev/null || true
fi
# -u = tracked modifications only; uv build output under dist/ is
# untracked and stays out of the commit.
git add -u
git diff --cached --quiet && { echo "Nothing left to commit"; exit 0; }
# Name the packages actually staged, so a single-package release
# (every other skip_* set) reads "bump praisonai-mcp" rather than a
# generic message. Falls back when nothing under src/ is staged.
PKGS="$(git diff --cached --name-only \
| sed -n 's#^src/\([^/]*\)/.*#\1#p' | sort -u | tr '\n' ' ' | sed 's/ *$//')"
if [ -n "$PKGS" ]; then
MSG="chore(release): bump ${PKGS} [skip ci]"
else
MSG="chore(release): persist version bumps [skip ci]"
fi
echo "Commit message: ${MSG}"
git commit -m "$MSG"
# This step fires exactly when the release went sideways — i.e. when
# main is most likely to have moved — so the rebase+push retries.
# Losing this commit deadlocks future auto-releases: the packages
# are on PyPI at N+1 while the repo says N, so every later run
# detects "already published" and skips both publish and bump.
for attempt in 1 2 3; do
git fetch origin main
if ! git rebase origin/main; then
git rebase --abort || true
# During rebase, "theirs" is the commit being replayed — the
# version bumps — so they win any conflict.
if ! git rebase -X theirs origin/main; then
git rebase --abort || true
echo "Rebase attempt ${attempt}/3 failed"
sleep 5
continue
fi
fi
if git push origin main; then
echo "Pushed version bumps (attempt ${attempt}/3)"
exit 0
fi
echo "Push attempt ${attempt}/3 rejected; retrying..."
sleep 5
done
echo "❌ Could not push version bumps after 3 attempts."
echo " Repo is now BEHIND PyPI; commit the version files on main manually"
echo " or future auto-releases will no-op ('already published')."
exit 1
if [ "${{ inputs.skip_code }}" != "true" ]; then
echo "✅ praisonai-code==${CODE}"
fi
if [ "${{ inputs.skip_bot }}" != "true" ]; then
echo "✅ praisonai-bot==${BOT}"
fi
if [ "${{ inputs.skip_train }}" != "true" ]; then
echo "✅ praisonai-train==${TRAIN}"
fi
if [ "${{ inputs.skip_browser }}" != "true" ]; then
echo "✅ praisonai-browser==${BROWSER}"
fi
if [ "${{ inputs.skip_mcp }}" != "true" ]; then
echo "✅ praisonai-mcp==${MCP}"
fi
if [ "${{ inputs.skip_sandbox }}" != "true" ]; then
echo "✅ praisonai-sandbox==${SANDBOX}"
fi
if [ "${{ inputs.skip_wrapper }}" != "true" ]; then
echo "✅ praisonai==${WRAPPER}"
fi
# Dedicated releases (skip_wrapper) create no "Release v…" tag — the
# wrapper drives that — so tag each published package individually for
# provenance. Full releases keep their single v… tag unchanged.
- name: Tag dedicated package releases
if: >-
${{ !cancelled() && inputs.dry_run != true &&
steps.flags.outputs.skip_wrapper == 'true' &&
steps.versions.outcome == 'success' }}
run: |
set -euo pipefail
tag_pkg() { # $1=effective-skip $2=pypi-name $3=version
if [ "$1" = "true" ]; then
return 0
fi
local tag="$2-v$3"
# Only tag what is confirmed on PyPI — a package whose publish
# failed must not get a tag claiming it shipped.
if ! curl -fsSL --max-time 30 "https://pypi.org/pypi/$2/$3/json" >/dev/null; then
echo "⏭️ $2==$3 not on PyPI; not tagging"
return 0
fi
if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then
echo "tag ${tag} already exists"
return 0
fi
git tag "${tag}"
git push origin "refs/tags/${tag}"
echo "🏷️ ${tag}"
}
tag_pkg "${{ steps.flags.outputs.skip_agents }}" praisonaiagents "${{ steps.versions.outputs.agents_version }}"
tag_pkg "${{ steps.flags.outputs.skip_code }}" praisonai-code "${{ steps.versions.outputs.code_version }}"
tag_pkg "${{ steps.flags.outputs.skip_bot }}" praisonai-bot "${{ steps.versions.outputs.bot_version }}"
tag_pkg "${{ steps.flags.outputs.skip_train }}" praisonai-train "${{ steps.versions.outputs.train_version }}"
tag_pkg "${{ steps.flags.outputs.skip_browser }}" praisonai-browser "${{ steps.versions.outputs.browser_version }}"
tag_pkg "${{ steps.flags.outputs.skip_mcp }}" praisonai-mcp "${{ steps.versions.outputs.mcp_version }}"
tag_pkg "${{ steps.flags.outputs.skip_sandbox }}" praisonai-sandbox "${{ steps.versions.outputs.sandbox_version }}"
tag_pkg "${{ steps.flags.outputs.skip_deploy }}" praisonai-deploy "${{ steps.versions.outputs.deploy_version }}"
+38 -4
View File
@@ -5,6 +5,9 @@ on:
branches: [ main, develop ]
paths:
- 'src/**'
- 'src/praisonai-deploy/infra/**'
- 'src/praisonai-bot/infra/**'
- 'scripts/check_helm_charts.sh'
- 'pyproject.toml'
- '.github/workflows/test-core.yml'
- '.github/actions/install-monorepo-packages/**'
@@ -12,6 +15,9 @@ on:
branches: [ main, develop ]
paths:
- 'src/**'
- 'src/praisonai-deploy/infra/**'
- 'src/praisonai-bot/infra/**'
- 'scripts/check_helm_charts.sh'
- 'pyproject.toml'
- '.github/workflows/test-core.yml'
- '.github/actions/install-monorepo-packages/**'
@@ -114,11 +120,17 @@ jobs:
extra_ignore: ""
pythonpath: >-
${{ github.workspace }}/src/praisonai-agents
- shard: deploy
workdir: src/praisonai-deploy
paths: >-
tests/
extra_ignore: ""
pythonpath: >-
${{ github.workspace }}/src/praisonai-agents
- shard: subdirs
paths: >-
tests/unit/scheduler/
tests/unit/integrations/
tests/unit/deploy/
tests/unit/mcp/
tests/unit/knowledge/
tests/unit/llm/
@@ -183,6 +195,9 @@ jobs:
if [ "$WORKDIR" = "src/praisonai-sandbox" ]; then
bash "$GITHUB_WORKSPACE/scripts/check_c13_sandbox_imports.sh"
fi
if [ "$WORKDIR" = "src/praisonai-deploy" ]; then
bash "$GITHUB_WORKSPACE/scripts/check_c14_deploy_imports.sh"
fi
if [ "$WORKDIR" = "src/praisonai-mcp" ]; then
bash "$GITHUB_WORKSPACE/scripts/check_c12_mcp_imports.sh"
fi
@@ -268,9 +283,27 @@ jobs:
--ignore=tests/integration/test_tracker_complex_tasks.py \
--ignore=tests/integration/test_serve_integration.py
helm-charts:
name: helm-charts
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Setup Helm
uses: azure/setup-helm@v3
with:
version: v3.14.4
- name: Lint and template Helm charts
shell: bash
run: |
set -euo pipefail
bash "$GITHUB_WORKSPACE/scripts/check_helm_charts.sh"
test-core:
name: test-core
needs: [test-core-collect, test-core-unit, test-core-integration]
needs: [test-core-collect, test-core-unit, test-core-integration, helm-charts]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
@@ -282,8 +315,9 @@ jobs:
collect='${{ needs.test-core-collect.result }}'
unit='${{ needs.test-core-unit.result }}'
integration='${{ needs.test-core-integration.result }}'
echo "collect=$collect unit=$unit integration=$integration"
if [ "$collect" != "success" ] || [ "$unit" != "success" ] || [ "$integration" != "success" ]; then
helm='${{ needs.helm-charts.result }}'
echo "collect=$collect unit=$unit integration=$integration helm=$helm"
if [ "$collect" != "success" ] || [ "$unit" != "success" ] || [ "$integration" != "success" ] || [ "$helm" != "success" ]; then
echo "Core Tests failed"
exit 1
fi
+18 -1
View File
@@ -86,6 +86,7 @@ jobs:
tests/unit/test_c11_browser_backward_compat.py \
tests/unit/test_c12_mcp_backward_compat.py \
tests/unit/test_c13_sandbox_backward_compat.py \
tests/unit/test_c14_deploy_backward_compat.py \
-q --tb=line --timeout=30 --maxfail=5
python -m pytest tests/unit/cli/ \
../praisonai-bot/tests/unit/cli/test_onboard_command.py \
@@ -269,10 +270,26 @@ jobs:
bash "$GITHUB_WORKSPACE/scripts/check_c13_sandbox_imports.sh"
python -m pytest src/praisonai-sandbox/tests -q -m "not network"
- name: Standalone praisonai-deploy smoke (C14)
run: |
set -euo pipefail
python -m venv /tmp/praisonai-deploy-standalone
/tmp/praisonai-deploy-standalone/bin/pip install -q -e src/praisonai-agents -e src/praisonai-deploy
/tmp/praisonai-deploy-standalone/bin/python -c "
from praisonai_deploy._version import __version__ as pkg_version
import praisonai_deploy
assert praisonai_deploy.__version__ == pkg_version
assert 'main' not in __import__('sys').modules
print('deploy standalone ok', praisonai_deploy.__version__)
"
/tmp/praisonai-deploy-standalone/bin/praisonai-deploy --help
bash "$GITHUB_WORKSPACE/scripts/check_c14_deploy_imports.sh"
python -m pytest src/praisonai-deploy/tests -q -m "not network"
- name: Audit hybrid module imports (C8 full stack)
run: |
set -euo pipefail
pip install -q -e src/praisonai-agents -e src/praisonai-code -e src/praisonai-bot -e src/praisonai-train -e src/praisonai-browser -e src/praisonai-mcp -e src/praisonai-sandbox -e src/praisonai
pip install -q -e src/praisonai-agents -e src/praisonai-code -e src/praisonai-bot -e src/praisonai-train -e src/praisonai-browser -e src/praisonai-mcp -e src/praisonai-sandbox -e src/praisonai-deploy -e src/praisonai
python scripts/audit_hybrid_modules.py
- name: Smoke Test Summary
+2 -2
View File
@@ -7,9 +7,9 @@ You are working on the PraisonAI project.
- Be concise and helpful in responses
- Test implementation thoroughly
- Ensure backward compatibility with existing APIs
- Follow protocol-driven design across the eight packages: core protocols in `praisonaiagents/`, agentic terminal CLI in `praisonai-code/`, bots/gateway in `praisonai-bot/`, LLM fine-tuning + agent training in `praisonai-train/`, browser automation in `praisonai-browser/`, MCP server host in `praisonai-mcp/`, sandbox backends in `praisonai-sandbox/`, integrations/serve/dashboard in the `praisonai/` wrapper
- Follow protocol-driven design across the nine packages: core protocols in `praisonaiagents/`, agentic terminal CLI in `praisonai-code/`, bots/gateway in `praisonai-bot/`, LLM fine-tuning + agent training in `praisonai-train/`, browser automation in `praisonai-browser/`, MCP server host in `praisonai-mcp/`, sandbox backends in `praisonai-sandbox/`, deployment in `praisonai-deploy/`, integrations/serve/dashboard in the `praisonai/` wrapper
- Preserve old `praisonai.*` import paths via shims when moving code between packages (see §2.3 in `src/praisonai-agents/AGENTS.md`; shim helpers in `src/praisonai/praisonai/cli/_shim.py`)
- Package boundaries and dependency rules: `ARCHITECTURE.md` §2 (Tier 2 packages must never PyPI-depend on the wrapper; cross-tier access goes through lazy `_*_bridge` modules)
- Boundary manifests: `src/praisonai/tests/PRAISONAI_BOT_MANIFEST.md` (C9), `src/praisonai/tests/PRAISONAI_TRAIN_MANIFEST.md` (C10), `src/praisonai/tests/PRAISONAI_BROWSER_MANIFEST.md` (C11), `src/praisonai/tests/PRAISONAI_MCP_MANIFEST.md` (C12), `src/praisonai/tests/PRAISONAI_SANDBOX_MANIFEST.md` (C13)
- Boundary manifests: `src/praisonai/tests/PRAISONAI_BOT_MANIFEST.md` (C9), `src/praisonai/tests/PRAISONAI_TRAIN_MANIFEST.md` (C10), `src/praisonai/tests/PRAISONAI_BROWSER_MANIFEST.md` (C11), `src/praisonai/tests/PRAISONAI_MCP_MANIFEST.md` (C12), `src/praisonai/tests/PRAISONAI_SANDBOX_MANIFEST.md` (C13), `src/praisonai/tests/PRAISONAI_DEPLOY_MANIFEST.md` (C14)
- When reviewing a PR or an issue, evaluate whether the change addresses a framework concern or a user goal, and design its surface (params, naming, defaults) accordingly
- The aim of this package is to stay **lightweight and powerful**. Do a critical review at each stage — when triaging an issue, when planning a fix, and when reviewing/implementing a PR. Reject scope creep for the sake of adding features: if a capability already exists (e.g. via existing Agent params like `instructions`/`backstory`/`tools`/`hooks`/`memory`), prefer it over a new API surface. A change must genuinely strengthen the SDK (simpler, more robust, more user-friendly) — do not add knobs, params, modules, or exports that have no live consumer or that merely duplicate existing behaviour.
+29 -10
View File
@@ -1,6 +1,6 @@
# PraisonAI Architecture
> **Last updated:** 2026-07-17 (C13 — eight-package tiered model)
> **Last updated:** 2026-07-31 (C14 — nine-package tiered model)
>
> Strategic architecture document for PraisonAI — a multi-agent AI framework.
> Covers **Python tiered package model (C7.1 + C9 + C10)**, system design, runtime
@@ -11,7 +11,7 @@
## Table of Contents
1. [Executive Summary](#1-executive-summary)
2. [Python Tiered Package Model (C7.1 + C9 + C10 + C11 + C12 + C13)](#2-python-tiered-package-model-c71--c9--c10--c11--c12--c13)
2. [Python Tiered Package Model (C7.1 + C9 + C10 + C11 + C12 + C13 + C14)](#2-python-tiered-package-model-c71--c9--c10--c11--c12--c13--c14)
3. [System Overview](#3-system-overview)
4. [Layered Architecture](#4-layered-architecture)
5. [Core Data Contracts](#5-core-data-contracts)
@@ -51,17 +51,18 @@ Orchestration + Observability** core to unlock adoption and trust:
---
## 2. Python Tiered Package Model (C7.1 + C9 + C10 + C11 + C12 + C13)
## 2. Python Tiered Package Model (C7.1 + C9 + C10 + C11 + C12 + C13 + C14)
**Release:** v4.6.110+ · `praisonaiagents` · `praisonai-code` · `praisonai-bot` · `praisonai-train` · `praisonai-browser` · `praisonai-mcp` · `praisonai-sandbox` · `praisonai`
**Release:** v4.6.110+ · `praisonaiagents` · `praisonai-code` · `praisonai-bot` · `praisonai-train` · `praisonai-browser` · `praisonai-mcp` · `praisonai-sandbox` · `praisonai-deploy` · `praisonai`
The Python monorepo publishes eight packages in three tiers with strict dependency
The Python monorepo publishes nine packages in three tiers with strict dependency
direction. C7 delivered a standalone agentic hot path; C7.1 formalised
code/wrapper ownership; C9 extracted bots, gateway, and channel CLI into
`praisonai-bot`; C10 extracted LLM fine-tuning and agent training into
`praisonai-train`; C11 extracted browser automation into `praisonai-browser`;
C12 extracted the heavy MCP host into `praisonai-mcp`.
C13 extracted sandbox backends into `praisonai-sandbox`.
C14 extracted deployment (API, Docker, cloud) into `praisonai-deploy`.
```mermaid
flowchart TB
@@ -69,13 +70,14 @@ flowchart TB
Agents["praisonaiagents<br/>Agent, tools, memory, hooks, protocols"]
end
subgraph tier2 [Tier 2 — Terminal + Bot + Train + Browser + MCP + Sandbox]
subgraph tier2 [Tier 2 — Terminal + Bot + Train + Browser + MCP + Sandbox + Deploy]
Code["praisonai-code<br/>run, chat, code, Typer, runtime, LLM"]
Bot["praisonai-bot<br/>bots, gateway, channel CLI, OS daemon"]
Train["praisonai-train<br/>LLM fine-tuning, agent training"]
Browser["praisonai-browser<br/>extension bridge, CDP, Playwright"]
MCP["praisonai-mcp<br/>MCP server host, auth, capability adapters"]
Sandbox["praisonai-sandbox<br/>Docker, E2B, Modal, Sandlock backends"]
Deploy["praisonai-deploy<br/>API, Docker, AWS/Azure/GCP deploy"]
end
subgraph tier3 [Tier 3 — Wrapper]
@@ -88,36 +90,53 @@ flowchart TB
Agents --> Browser
Agents --> MCP
Agents --> Sandbox
Agents --> Deploy
Agents --> Wrapper
Code -.->|"lazy _bot_bridge"| Bot
Code -.->|"lazy _train_bridge"| Train
Code -.->|"lazy _browser_bridge"| Browser
Code -.->|"lazy _mcp_bridge"| MCP
Code -.->|"lazy _sandbox_bridge"| Sandbox
Code -.->|"lazy _deploy_bridge"| Deploy
Code -.->|"lazy _wrapper_bridge"| Wrapper
Bot -.->|"lazy _code_bridge / _wrapper_bridge"| Code
Bot -.->|"lazy _wrapper_bridge"| Wrapper
Train -.->|"lazy _code_bridge / _wrapper_bridge"| Code
```
**PyPI publish order:** `praisonaiagents` → `praisonai-code` + `praisonai-bot` + `praisonai-train` + `praisonai-browser` + `praisonai-mcp` + `praisonai-sandbox` → `praisonai`
**PyPI publish order:** `praisonaiagents` → `praisonai-code` + `praisonai-bot` + `praisonai-train` + `praisonai-browser` + `praisonai-mcp` + `praisonai-sandbox` + `praisonai-deploy` → `praisonai`
**Backward compatibility:** `praisonai.bots`, `praisonai.gateway`, `praisonai.train`,
`praisonai.browser`, `praisonai.mcp_server`, `praisonai.sandbox`, and related CLI paths remain as
`praisonai.browser`, `praisonai.mcp_server`, `praisonai.sandbox`, `praisonai.deploy`, and related CLI paths remain as
`alias_package` shims to `praisonai_bot.*` / `praisonai_train.*` /
`praisonai_browser.*` / `praisonai_mcp.*` / `praisonai_sandbox.*`.
`praisonai_browser.*` / `praisonai_mcp.*` / `praisonai_sandbox.*` / `praisonai_deploy.*`.
| Tier | Package | Owns | Must not depend on |
|------|---------|------|-------------------|
| 1 | `src/praisonai-agents/` | Agent, tools, memory, hooks, `frameworks/` protocols, sandbox protocols | `praisonai`, `praisonai-code`, `praisonai-bot`, `praisonai-train`, `praisonai-browser`, `praisonai-mcp`, `praisonai-sandbox` |
| 1 | `src/praisonai-agents/` | Agent, tools, memory, hooks, `frameworks/` protocols, sandbox protocols | `praisonai`, `praisonai-code`, `praisonai-bot`, `praisonai-train`, `praisonai-browser`, `praisonai-mcp`, `praisonai-sandbox`, `praisonai-deploy` |
| 2a | `src/praisonai-code/` | `run`/`chat`/`code`, Typer, runtime, LLM, tool resolution | **`praisonai` as a PyPI dependency** (optional lazy imports via `_wrapper_bridge` only) |
| 2b | `src/praisonai-bot/` | Bots, gateway, channel CLI, OS daemon, gateway scheduler tick | **`praisonai` as a PyPI dependency** (optional lazy `_wrapper_bridge` for jobs/UI) |
| 2c | `src/praisonai-train/` | LLM fine-tuning (Unsloth), agent training, `train` CLI, conda env setup | **`praisonai` as a PyPI dependency** (lazy `_code_bridge` for legacy dispatch) |
| 2d | `src/praisonai-browser/` | Extension bridge, CDP/hybrid automation, `browser` CLI | **`praisonai` as a PyPI dependency** (none required; depends on `praisonaiagents` only) |
| 2e | `src/praisonai-mcp/` | MCP server host, auth, transports, `mcp` CLI | **`praisonai` as a PyPI dependency** (lazy `_wrapper_bridge` for full capability registry) |
| 2f | `src/praisonai-sandbox/` | Sandbox backends (Docker, E2B, Modal, Sandlock, SSH), `SandboxRegistry` | **`praisonai` as a PyPI dependency** (lazy `_code_bridge` for `PluginRegistry`) |
| 2g | `src/praisonai-deploy/` | Deploy API/Docker/cloud, `deploy` CLI, scheduler integration | **`praisonai` as a PyPI dependency** (lazy `_plugin_registry` via `_code_bridge`) |
| 3 | `src/praisonai/` | `framework_adapters/`, serve, dashboard, async jobs API | — |
### Repo infra (not PyPI)
Deployment packaging that stays in the git checkout, not in any tier-2 wheel:
| Path | Runtime owner | Orchestration |
|------|---------------|---------------|
| `src/praisonai-bot/infra/helm/praisonai-gateway/` | `praisonai-bot` (gateway) | C14 `deploy helm` wrapper |
| `src/praisonai-deploy/infra/helm/praisonai-agents-api/` | Generated API from C14 | C14 cross-link |
| `src/praisonai-deploy/infra/compose/agents-stack/` | Docker Compose | `praisonai deploy compose up/down` |
| `src/praisonai-deploy/infra/starters/` | Starter scaffolds | `praisonai deploy create --template` |
| `docker/` | Mixed (wrapper/bot dev stacks) | Not C14 — see bot manifest for `docker/bots/` |
Same boundary as C14: Helm/K8s manifests are **repo infra**, not `pip install praisonai-deploy` content.
**Config kernel:** Phase 0 `praisonai/common/` was skipped; shared config lives in
`praisonai_code/cli/configuration/` and is reached by the bot tier via lazy
`_code_bridge` (see `src/praisonai/tests/CONFIG_KERNEL.md`).
+11 -8
View File
@@ -25,7 +25,7 @@ Methods:
Types:
```python
from praisonaiagents import Agent, AutoAgents, AutoRagAgent, ContextAgent, DeepResearchAgent, ImageAgent, PlanningAgent, PromptExpanderAgent, QueryRewriterAgent, create_context_agent
from praisonaiagents import Agent, AutoAgents, AutoRagAgent, ContextAgent, DeepResearchAgent, ImageAgent, PlanningAgent, PraisonAIAgents, PromptExpanderAgent, QueryRewriterAgent, create_context_agent
```
Methods:
@@ -36,13 +36,9 @@ Methods:
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">analyze_prompt</a>(prompt: str) -> set</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">auto_memory</a>() -> Optional[bool]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">auto_memory</a>(value: Optional[bool]) -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">background</a>() -> Optional[bool]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">background</a>(value: Optional[bool]) -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">chat_history</a>()</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">chat_history</a>(value)</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">chat_with_context</a>(message: str, context: 'ContextPack', **kwargs) -> str</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">checkpoints</a>() -> Optional[bool]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">checkpoints</a>(value: Optional[bool]) -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">clone_for_channel</a>() -> 'Agent'</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">close</a>() -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">console</a>() -> Optional[Any]</code>
@@ -180,14 +176,15 @@ Methods:
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">get_schema</a>() -> Dict[str, Any]</code>
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">run</a>(**kwargs) -> Any</code>
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">safe_run</a>(**kwargs) -> ToolResult</code>
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">to_model_output</a>(result: Any) -> Optional[Any]</code>
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">validate</a>() -> bool</code>
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">validate_class</a>() -> bool</code>
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">validate_schema_roundtrip</a>() -> bool</code>
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">__call__</a>(*args, **kwargs) -> Any</code>
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">check_availability</a>() -> tuple[bool, str]</code>
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">get_schema</a>() -> Dict[str, Any]</code>
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">injected_params</a>() -> Dict[str, Any]</code>
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">run</a>(**kwargs) -> Any</code>
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">to_model_output</a>(result: Any) -> Optional[Any]</code>
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">clear</a>() -> None</code>
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">discover_plugins</a>() -> int</code>
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">discover_single_file_plugins</a>() -> int</code>
@@ -253,9 +250,12 @@ Methods:
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">delete_memory</a>(memory_id: str, memory_type: Optional[str] = None) -> bool</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">delete_short_term</a>(memory_id: str) -> bool</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">finalize_task_output</a>(content: str, agent_name: str, quality_score: float, threshold: float = 0.7, metrics: Dict[str, Any] = None, task_id: str = None)</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">forget</a>(**kwargs) -> int</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">get_all_memories</a>() -> List[Dict[str, Any]]</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">get_learn_context</a>() -> str</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">learn</a>()</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">recall</a>(query: str, **kwargs) -> List[Dict[str, Any]]</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">remember</a>(content: str, **kwargs) -> str</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">reset_all</a>()</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">reset_entity_only</a>()</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">reset_long_term</a>()</code>
@@ -510,6 +510,7 @@ Methods:
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">patch</a>(name: str, old_string: str, new_string: str, file_path: Optional[str] = None, replace_all: bool = False, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">patch_skill</a>(name: str, old_string: str, new_string: str, file_path: str = None, replace_all: bool = False, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">reject</a>(identifier: str) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">reload</a>() -> Dict[str, List[str]]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">remove_file</a>(name: str, file_path: str, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">remove_skill_file</a>(name: str, file_path: str, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">resolve</a>(selectors: List[str]) -> List[str]</code>
@@ -767,6 +768,7 @@ Methods:
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">arun_task</a>(task_id)</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">aspawn_sub_agent</a>(agent: Agent, task: Any, completion_callback: Optional[Callable[[SubAgentCompletionEvent], Any]] = None, metadata: Optional[Dict[str, Any]] = None) -> SpawnedSubAgent</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">astart</a>(content = None, return_dict = False, **kwargs)</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">astart_for_each</a>(inputs, **kwargs)</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">await_for_completions</a>(timeout: Optional[float] = None, agent_ids: Optional[List[str]] = None) -> List[SubAgentCompletionEvent]</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">clean_json_output</a>(output: str) -> str</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">clear_state</a>() -> None</code>
@@ -797,10 +799,11 @@ Methods:
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">run_all_tasks</a>()</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">run_task</a>(task_id)</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">save_output_to_file</a>(task, task_output)</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">save_session_state</a>(session_id: str, include_memory: bool = True) -> None</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">save_session_state</a>(session_id: str, include_memory: bool = True) -> bool</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">set_state</a>(key: str, value: Any) -> None</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">spawn_sub_agent</a>(agent: Agent, task: Any, completion_callback: Optional[Callable[[SubAgentCompletionEvent], Any]] = None, metadata: Optional[Dict[str, Any]] = None) -> SpawnedSubAgent</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">start</a>(content = None, return_dict = False, output = None, **kwargs)</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">start_for_each</a>(inputs, **kwargs)</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">stream_emitter</a>()</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">todo_list</a>()</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">update_plan_step_status</a>(step_id: str, status: str) -> bool</code>
@@ -819,7 +822,7 @@ Methods:
Types:
```python
from praisonai import Agent, AgentApp, AgentOS, AnthropicManagedAgent, CloudProvider, DB, Deploy, DeployConfig, DeployType, HostedAgent, HostedAgentConfig, LocalAgent, LocalAgentConfig, LocalManagedAgent, LocalManagedConfig, ManagedAgent, ManagedConfig, __version__, arun, run
from praisonai import Agent, AgentApp, AgentOS, AnthropicManagedAgent, DB, HostedAgent, HostedAgentConfig, LocalAgent, LocalAgentConfig, LocalManagedAgent, LocalManagedConfig, ManagedAgent, ManagedConfig, __version__, arun, run
```
# CLI
+10
View File
@@ -0,0 +1,10 @@
# Deployment infra has moved
Deployment assets are no longer at the monorepo root.
| Asset | New location |
|-------|----------------|
| Gateway Helm chart | [`src/praisonai-bot/infra/helm/praisonai-gateway/`](../src/praisonai-bot/infra/helm/praisonai-gateway/) |
| Agents API Helm, Compose, Starters | [`src/praisonai-deploy/infra/`](../src/praisonai-deploy/infra/) |
See [`src/praisonai-deploy/infra/README.md`](../src/praisonai-deploy/infra/README.md).
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.157" \
"praisonai>=4.6.162" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.157" \
"praisonai>=4.6.162" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.157" \
"praisonai>=4.6.162" \
"praisonai[ui]" \
"praisonai[crewai]"
@@ -36,6 +36,12 @@ if __name__ == "__main__":
print(f"Custom agent max_rpm: {agent.max_rpm}")
print(f"Custom agent max_execution_time: {agent.max_execution_time}")
print(f"Custom agent max_retry_limit: {agent.max_retry_limit}")
# max_rpm auto-creates a live RateLimiter (not just a stored number)
assert agent_rate_limited.max_rpm == 10
assert agent_rate_limited._rate_limiter is not None
assert agent_rate_limited._rate_limiter.requests_per_minute == 10
print(f"Rate-limited agent limiter: {agent_rate_limited._rate_limiter}")
# Test custom agent
print("\n--- Custom Execution ---")
@@ -0,0 +1,111 @@
"""
Human Review Between Sequential AgentTeam Tasks
================================================
Demonstrates a human-in-the-loop review gate between sequential tasks using the
existing ``on_task_complete`` hook — no new API required.
IMPORTANT — two different ``on_task_complete`` hooks exist, with DIFFERENT
signatures. Do not confuse them:
AgentTeam(hooks=MultiAgentHooksConfig(on_task_complete=fn))
-> fn(task, task_output) # team-level; gets the Task and its output
Task(on_task_complete=fn)
-> fn(task_output) # 1 param: just the TaskOutput
-> fn(task_output, metadata) # 2+ params: TaskOutput + metadata dict
# NOTE: NOT (task, task_output)
This example uses the TEAM-level hook so we get ``(task, task_output)`` and can
filter which tasks require review by ``task.name``.
Approve / reject / edit:
- approve -> return, workflow continues
- reject -> raise; see "stopping the workflow" note below
- edit -> mutate ``task_output.raw``; downstream tasks that read this task via
``context=[...]`` pick up the edited text
Stopping the workflow on reject
-------------------------------
The TEAM-level hook logs and swallows exceptions, so a bare ``raise`` inside it
does NOT stop the sequential run today. To hard-stop on reject, use the PER-TASK
callback path with the 1-argument signature and ``fail_on_callback_error=True``
(see ``review_output`` / ``t1_strict`` at the bottom of this file).
"""
from praisonaiagents import Agent, Task, AgentTeam
from praisonaiagents.config.feature_configs import MultiAgentHooksConfig
# Only these task names trigger a human review gate.
REVIEW_TASKS = {"research"}
# --- Team-level review gate: signature is (task, task_output) ---------------
def review_gate(task, task_output):
if task.name not in REVIEW_TASKS:
return
print(f"\n--- Review: {task.name} ---\n{task_output.raw}\n")
choice = input("Approve (y), reject (n), edit (e)? ").strip().lower()
if choice == "n":
# NOTE: team-level hook swallows this; see per-task strict path below
# for a version that actually halts the workflow.
raise RuntimeError(f"Human rejected task '{task.name}'")
if choice == "e":
# task.result is the same object as task_output, so mutating .raw here
# propagates to downstream tasks that use context=[this_task].
task_output.raw = input("Edited output: ").strip()
researcher = Agent(name="Researcher", role="Researcher", goal="Research", backstory="Expert")
writer = Agent(name="Writer", role="Writer", goal="Write", backstory="Writer")
t1 = Task(
name="research",
description="List 5 facts about {{topic}}", # {{...}} is the substitution syntax
expected_output="5 bullets",
agent=researcher,
)
t2 = Task(
name="summary",
description="Write 3-sentence summary",
expected_output="3 sentences",
agent=writer,
context=[t1], # reads the (possibly edited) output of t1
)
team = AgentTeam(
agents=[researcher, writer],
tasks=[t1, t2],
process="sequential",
variables={"topic": "REST APIs"}, # substituted into {{topic}} in task descriptions
hooks=MultiAgentHooksConfig(on_task_complete=review_gate),
)
# --- Alternative: per-task strict gate that HALTS on reject -----------------
# Per-task Task.on_task_complete uses the 1-argument signature (task_output).
# With fail_on_callback_error=True a raised exception re-raises and stops the run.
def review_output(task_output):
print(f"\n--- Review output ---\n{task_output.raw}\n")
if input("Approve? (y/n) ").strip().lower() == "n":
raise RuntimeError("Human rejected output")
t1_strict = Task(
name="research",
description="List 5 facts about {{topic}}",
expected_output="5 bullets",
agent=researcher,
variables={"topic": "REST APIs"},
on_task_complete=review_output, # signature: (task_output)
fail_on_callback_error=True, # required so reject halts the workflow
)
if __name__ == "__main__":
# NOTE: template values come from AgentTeam(variables=...), NOT start(inputs=...).
# start() forwards **kwargs but does not consume an ``inputs`` argument.
team.start()
@@ -86,7 +86,7 @@ def method_1_simple_loop():
agents=[loop_agent],
tasks=[loop_task],
process="workflow",
max_iter=10 # Maximum iterations to prevent infinite loops
execution="balanced" # max_iter=10 preset to prevent infinite loops
)
print(f"🚀 Starting loop processing of {csv_file}")
@@ -64,7 +64,7 @@ def main():
agents=[url_agent],
tasks=[url_task],
process="workflow",
max_iter=10 # Adjust based on how many URLs you have
execution="balanced" # max_iter=10 preset; adjust for more URLs
)
print(f"🚀 Processing URLs from {csv_file}")
@@ -141,7 +141,7 @@ class DynamicAgentSystem:
agents=agents,
tasks=tasks,
process=process,
verbose=inputs['depth'] == 'detailed'
output="verbose" if inputs['depth'] == 'detailed' else "silent"
)
result = praison_agents.start()
@@ -66,7 +66,7 @@ research_task1 = Task(
agents_session1 = AgentTeam(
agents=[research_agent],
tasks=[research_task1],
session=session1, output="verbose"
output="verbose"
)
# Execute first session
@@ -105,7 +105,7 @@ analysis_task = Task(
agents_session2 = AgentTeam(
agents=[analysis_agent],
tasks=[analysis_task],
session=session2, output="verbose"
output="verbose"
)
print("Starting analysis session with previous context...")
@@ -155,7 +155,7 @@ synthesis_task = Task(
agents_recovery = AgentTeam(
agents=[synthesis_agent],
tasks=[synthesis_task],
session=recovery_session, output="verbose"
output="verbose"
)
print("Starting recovery session with full context from previous sessions...")
@@ -205,7 +205,7 @@ review_task = Task(
agents_reviewer = AgentTeam(
agents=[review_agent],
tasks=[review_task],
session=reviewer_session, output="verbose"
output="verbose"
)
print("Starting peer review session by different user...")
@@ -0,0 +1,64 @@
# Simple Sequential Team (YAML)
A minimal 2-agent sequential team: a **researcher** gathers facts, then a
**writer** summarizes them. This is the copy-paste starting point for the most
common multi-agent pattern (2–5 agents, run in order).
```
research_task -> summary_task
(researcher) (writer)
```
## Files
| File | Purpose |
|------|---------|
| `agents.yaml` | Roles (who) + their tasks (what); tasks run in declaration order |
> **Which loader applies?** This example uses the canonical single-file format
> consumed by `AgentsGenerator.generate_crew_and_kickoff()`
> (`src/praisonai/praisonai/agents_generator.py`). Agents live under `roles:`,
> each with nested `tasks:`. **Tasks run in the order they are declared** — the
> researcher's task first, then the writer's. (An optional top-level
> `dependencies:` block is included to document intent and mirror the canonical
> fixture, but the roles-file loader sequences by declaration order, so keep
> tasks in the order you want them to run.) No new loader module is required —
> this is the existing YAML path.
## Run it
```bash
export OPENAI_API_KEY=sk-...
praisonai examples/yaml/teams/research-writer/agents.yaml
```
This positional-file form is the canonical roles-file entry point. From Python
the equivalent is `praisonai.run("agents.yaml")` (see
`src/praisonai/praisonai/_entrypoint.py`), which dispatches to the same
`AgentsGenerator`.
Change the subject by editing the `topic:` line in `agents.yaml` (interpolated
into every `{topic}` placeholder).
## When to use this vs a workflow YAML
| You need… | Use | Example |
|-----------|-----|---------|
| 2–5 agents, sequential/hierarchical | **This** (`roles:` + `tasks:`) | `agents.yaml` here |
| Routing by classifier output | workflow YAML | `../../workflows/routing_workflow.yaml` |
| Parallel branches | workflow YAML | `../../workflows/parallel_workflow.yaml` |
| Loops / planning / memory | workflow YAML | `../../workflows/complete_workflow.yaml` |
## Python equivalent
```python
from praisonaiagents import Agent, Task, PraisonAIAgents
researcher = Agent(role="Senior Researcher", goal="Find accurate information")
writer = Agent(role="Report Writer", goal="Turn research into summaries")
t1 = Task(description="Research renewable energy and list 5 key facts", agent=researcher)
t2 = Task(description="Write a 3-sentence summary from the research", agent=writer, context=[t1])
PraisonAIAgents(agents=[researcher, writer], tasks=[t1, t2]).start()
```
@@ -0,0 +1,34 @@
# yaml-language-server: $schema=https://docs.praison.ai/schemas/agents.schema.json
# Minimal sequential team: researcher -> writer
# Run: praisonai examples/yaml/teams/research-writer/agents.yaml
framework: "praisonai"
topic: "renewable energy"
roles:
researcher:
role: "Senior Researcher"
goal: "Find accurate information about {topic}"
backstory: "Expert at concise, factual research."
tasks:
research_task:
description: "Research {topic} and list 5 key facts."
expected_output: "A bullet list of 5 facts."
writer:
role: "Report Writer"
goal: "Turn research into readable summaries about {topic}"
backstory: "Clear technical writer."
tasks:
summary_task:
description: "Write a 3-sentence executive summary from the research."
expected_output: "3 sentences, plain text."
# Ordering is by task declaration order above (researcher's task runs first,
# then the writer's), which is how the roles-file loader sequences tasks.
# The optional `dependencies:` block below mirrors the canonical fixture
# (src/praisonai/tests/agents.yaml) and documents intent; the roles-file
# loader does not consume it, so keep tasks in the order you want them run.
dependencies:
- task: summary_task
depends_on: research_task
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# C14 import-direction gate for praisonai-deploy.
set -euo pipefail
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CODE_ROOT="${C14_CODE_ROOT:-src/praisonai-code/praisonai_code}"
DEPLOY_ROOT="${C14_DEPLOY_ROOT:-src/praisonai-deploy/praisonai_deploy}"
ANY_WRAPPER_RE='(^[[:space:]]*from praisonai([[:space:]]|\.)|^[[:space:]]*import praisonai($|\.))'
ANY_CODE_RE='(^from praisonai_code([[:space:]]|\.)|^import praisonai_code($|\.))'
ANY_DEPLOY_RE='(^[[:space:]]*from praisonai_deploy([[:space:]]|\.)|^[[:space:]]*import praisonai_deploy($|\.))'
echo "== C14 praisonai_deploy wrapper import gate (must be zero outside bridges) =="
if command -v rg >/dev/null 2>&1; then
MATCHES="$(rg -n "$ANY_WRAPPER_RE" "$DEPLOY_ROOT" --glob '*.py' 2>/dev/null | grep -v '_wrapper_bridge.py' | grep -v '_bootstrap.py' | grep -v '_code_bridge.py' || true)"
else
MATCHES="$(grep -rEn --include='*.py' "$ANY_WRAPPER_RE" "$DEPLOY_ROOT" 2>/dev/null | grep -v '_wrapper_bridge.py' | grep -v '_bootstrap.py' | grep -v '_code_bridge.py' || true)"
fi
if [ -n "$MATCHES" ]; then
echo "$MATCHES"
echo "FAIL: praisonai_deploy imports the praisonai wrapper"
exit 1
fi
echo "deploy wrapper import gate ok"
echo "== C14 praisonai_deploy module-level praisonai_code import gate =="
if command -v rg >/dev/null 2>&1; then
MATCHES="$(rg -n "$ANY_CODE_RE" "$DEPLOY_ROOT" --glob '*.py' 2>/dev/null | grep -v '_code_bridge.py' | grep -v '_plugin_registry.py' || true)"
else
MATCHES="$(grep -rEn --include='*.py' "$ANY_CODE_RE" "$DEPLOY_ROOT" 2>/dev/null | grep -v '_code_bridge.py' | grep -v '_plugin_registry.py' || true)"
fi
if [ -n "$MATCHES" ]; then
echo "$MATCHES"
echo "FAIL: module-level praisonai_code import in praisonai_deploy"
exit 1
fi
echo "deploy code import gate ok"
echo "== C14 praisonai-code hot-path gate (no praisonai_deploy at module level) =="
for f in \
"$CODE_ROOT/cli/main.py" \
"$CODE_ROOT/cli/app.py" \
"$CODE_ROOT/cli/commands/run.py" \
"$CODE_ROOT/cli/commands/chat.py" \
"$CODE_ROOT/cli/commands/code.py"
do
if [ -f "$f" ] && head -n 80 "$f" | grep -E "$ANY_DEPLOY_RE" 2>/dev/null; then
echo "FAIL: module-level praisonai_deploy import in code hot path $f"
exit 1
fi
done
echo "code hot-path gate ok"
echo "C14 import gates passed"
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Lint and template-render all Helm charts under package infra/ trees.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if ! command -v helm >/dev/null 2>&1; then
echo "FAIL: helm CLI not found (install via https://helm.sh/docs/intro/install/)"
exit 1
fi
HELM_ROOTS=(
"$ROOT/src/praisonai-bot/infra/helm"
"$ROOT/src/praisonai-deploy/infra/helm"
)
charts=()
for helm_root in "${HELM_ROOTS[@]}"; do
if [ ! -d "$helm_root" ]; then
continue
fi
shopt -s nullglob
for chart in "$helm_root"/*/; do
if [ -f "${chart}Chart.yaml" ]; then
charts+=("$chart")
fi
done
shopt -u nullglob
done
if [ ${#charts[@]} -eq 0 ]; then
echo "No charts found under package infra/helm trees"
exit 1
fi
for chart in "${charts[@]}"; do
name="$(basename "$chart")"
echo "== helm lint: $name =="
helm lint "$chart"
echo "== helm template: $name =="
case "$name" in
praisonai-gateway)
helm template "ci-smoke-$name" "$chart" \
--set auth.existingSecret=ci-smoke-auth \
>/dev/null
;;
praisonai-agents-api)
helm template "ci-smoke-$name" "$chart" \
--set auth.existingSecret=ci-smoke-auth \
--set postgres.auth.existingSecret=ci-smoke-postgres \
>/dev/null
;;
*)
helm template "ci-smoke-$name" "$chart" >/dev/null
;;
esac
done
echo "Helm chart gates passed (${#charts[@]} chart(s))"
+1 -1
View File
@@ -140,7 +140,7 @@ The **agentic terminal CLI** (`run`, `chat`, `code`, warm runtime, CLI backends,
**Monorepo bootstrap:** `praisonai._bootstrap.ensure_praisonai_code()` adds sibling `src/praisonai-code` to `sys.path` so `PYTHONPATH=src/praisonai-agents:src/praisonai` works without an explicit code entry.
**Install order (CI and local dev):** `praisonai-agents` → `praisonai-code` → `praisonai` (see `.github/actions/install-monorepo-packages`).
**Install order (CI and local dev):** `praisonai-agents` → `praisonai-code` → `praisonai-bot` → `praisonai-train` → `praisonai-browser` → `praisonai-mcp` → `praisonai-sandbox` → `praisonai-deploy` → `praisonai` (see `.github/actions/install-monorepo-packages`).
**Invocation paths that must remain valid:**
+35 -35
View File
@@ -18,76 +18,76 @@ This report compares **Python SDK feature categories** against **Python document
| Category | Features | Docs | Lines |
|----------|----------|------|-------|
| ✅ AGUI | 1 | 2 | 420 |
| ✅ Agent | 21 | 46 | 14663 |
| ✅ Agent | 22 | 49 | 15473 |
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2965 |
| ✅ Agent-to-User (A2U) | 1 | 3 | 585 |
| ✅ Approval | 1 | 5 | 2312 |
| ✅ Agent-to-User (A2U) | 1 | 3 | 684 |
| ✅ Approval | 1 | 5 | 2500 |
| ✅ Audio | 2 | 12 | 1000 |
| ✅ Auto Generation | 5 | 10 | 2880 |
| ✅ Autonomy | 3 | 7 | 2707 |
| ✅ Bots | 7 | 31 | 12353 |
| ✅ Auto Generation | 5 | 10 | 3005 |
| ✅ Autonomy | 3 | 7 | 2736 |
| ✅ Bots | 7 | 31 | 13274 |
| ✅ Budget | 1 | 1 | 287 |
| ✅ CLI | 5 | 119 | 34537 |
| ✅ CLI | 5 | 121 | 35658 |
| ✅ Chunking | 2 | 2 | 385 |
| ✅ Citations | 2 | 1 | 202 |
| ✅ Code Execution | 2 | 13 | 4031 |
| ✅ Code Execution | 2 | 14 | 4548 |
| ✅ Conditions | 1 | 3 | 1179 |
| ✅ Configuration | 3 | 7 | 2773 |
| ✅ Context Management | 16 | 34 | 13206 |
| ✅ Configuration | 3 | 8 | 3047 |
| ✅ Context Management | 16 | 34 | 13388 |
| ✅ Deep Research | 8 | 2 | 587 |
| ✅ Display | 6 | 3 | 741 |
| ✅ Embeddings | 6 | 23 | 2049 |
| ✅ Evaluation | 1 | 7 | 2905 |
| ✅ Events | 1 | 2 | 749 |
| ✅ Execution | 3 | 4 | 1560 |
| ✅ Failover | 2 | 1 | 400 |
| ✅ Files | 2 | 6 | 2066 |
| ✅ Flow | 1 | 3 | 777 |
| ✅ Gateway | 7 | 59 | 21584 |
| ✅ Guardrails | 4 | 4 | 1673 |
| ✅ Handoffs | 11 | 6 | 2454 |
| ✅ Hooks | 2 | 9 | 4128 |
| ✅ Failover | 2 | 1 | 432 |
| ✅ Files | 2 | 7 | 2273 |
| ✅ Flow | 1 | 3 | 788 |
| ✅ Gateway | 7 | 66 | 24288 |
| ✅ Guardrails | 4 | 4 | 1833 |
| ✅ Handoffs | 11 | 6 | 2563 |
| ✅ Hooks | 2 | 9 | 4281 |
| ✅ Image | 1 | 11 | 1255 |
| ✅ Knowledge | 4 | 13 | 4160 |
| ✅ LLM | 3 | 14 | 4864 |
| ✅ Knowledge | 4 | 13 | 4233 |
| ✅ LLM | 3 | 14 | 4955 |
| ✅ Loops | 4 | 5 | 1422 |
| ✅ MCP | 1 | 58 | 13322 |
| ✅ Memory | 6 | 17 | 6635 |
| ✅ MCP | 1 | 60 | 13724 |
| ✅ Memory | 6 | 17 | 6766 |
| ✅ OCR | 2 | 1 | 237 |
| ✅ Observability | 2 | 23 | 2779 |
| ✅ Observability | 2 | 23 | 2800 |
| ✅ Optimizer | 1 | 2 | 792 |
| ✅ Output | 3 | 5 | 1158 |
| ✅ Parallel Execution | 3 | 2 | 497 |
| ✅ Planning | 6 | 6 | 1545 |
| ✅ Plugins | 8 | 3 | 1900 |
| ✅ Planning | 6 | 6 | 1549 |
| ✅ Plugins | 8 | 4 | 2703 |
| ✅ Prompts | 2 | 9 | 1965 |
| ✅ Providers | 1 | 55 | 7502 |
| ✅ Providers | 1 | 56 | 7723 |
| ✅ Query | 1 | 2 | 736 |
| ✅ RAG | 5 | 15 | 2936 |
| ✅ Realtime | 2 | 5 | 668 |
| ✅ Reflection | 3 | 3 | 680 |
| ✅ Retrieval | 2 | 5 | 1044 |
| ✅ Routing | 1 | 2 | 430 |
| ✅ Sandbox | 5 | 6 | 2766 |
| ✅ Security | 1 | 3 | 2970 |
| ✅ Sessions | 4 | 13 | 5302 |
| ✅ Skills | 6 | 13 | 4998 |
| ✅ Tasks | 2 | 6 | 2809 |
| ✅ Sandbox | 5 | 6 | 2806 |
| ✅ Security | 1 | 3 | 3135 |
| ✅ Sessions | 4 | 17 | 6960 |
| ✅ Skills | 6 | 15 | 5638 |
| ✅ Tasks | 2 | 6 | 2819 |
| ✅ Telemetry | 1 | 2 | 604 |
| ✅ Templates | 1 | 8 | 1663 |
| ✅ Tools | 12 | 133 | 38149 |
| ✅ Tools | 12 | 138 | 40206 |
| ✅ Tracing | 3 | 2 | 139 |
| ✅ Vector Store | 1 | 12 | 1193 |
| ✅ Vector Store | 1 | 12 | 1207 |
| ✅ Video | 2 | 6 | 649 |
| ✅ Vision | 2 | 1 | 329 |
| ✅ Web | 3 | 8 | 2055 |
| ✅ Workflows | 5 | 16 | 6546 |
| ✅ Web | 3 | 9 | 2467 |
| ✅ Workflows | 5 | 16 | 6589 |
## Documentation Without Features
These docs exist but don't match any implemented feature category:
- ℹ️ Database (41 docs, 6478 lines)
- ℹ️ Database (42 docs, 6703 lines)
- ℹ️ Documents (1 docs, 787 lines)
---
@@ -304,9 +304,10 @@ _LAZY_IMPORTS = {
'CodeAgent': ('praisonaiagents.agent.code_agent', 'CodeAgent'),
'CodeConfig': ('praisonaiagents.agent.code_agent', 'CodeConfig'),
# AgentTeam (primary) / AgentManager (alias)
# AgentTeam (primary) / AgentManager, PraisonAIAgents (aliases)
'AgentTeam': ('praisonaiagents.agents.agents', 'AgentTeam'),
'AgentManager': ('praisonaiagents.agents.agents', 'AgentManager'), # Silent alias
'PraisonAIAgents': ('praisonaiagents.agents.agents', 'PraisonAIAgents'), # Silent alias (pre-1.0 name)
# Note: 'Agents' is handled by _custom_handler for deprecation warning
'Task': ('praisonaiagents.task.task', 'Task'),
'AutoAgents': ('praisonaiagents.agents.autoagents', 'AutoAgents'),
@@ -843,6 +844,7 @@ __all__ = [
'RetryBackoffConfig',
'AgentTeam', # Primary class for multi-agent coordination (v1.0+)
'AgentManager', # Silent alias for AgentTeam
'PraisonAIAgents', # Silent alias for AgentTeam (pre-1.0 name)
'Agents', # Deprecated alias for AgentTeam (emits warning)
'Task',
@@ -859,8 +859,6 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
planning_tools = None
planning_reasoning = False
policy = None
background = None
checkpoints = None
output_style = None
thinking_budget = None
skills_dirs = None
@@ -1655,7 +1653,14 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
else:
llm = model # model= takes precedence
# Store rate limiter (optional, zero overhead when None)
# Store rate limiter (optional, zero overhead when None).
# Auto-build a RateLimiter from max_rpm when no explicit limiter is
# provided so ExecutionConfig(max_rpm=N) actually throttles requests.
if max_rpm is not None and max_rpm <= 0:
raise ValueError(f"max_rpm must be a positive int, got {max_rpm!r}")
if rate_limiter is None and max_rpm is not None:
from praisonaiagents.llm.rate_limiter import RateLimiter
rate_limiter = RateLimiter(requests_per_minute=max_rpm)
self._rate_limiter = rate_limiter
# Store OpenAI client parameters for lazy initialization (kept separate)
@@ -1977,21 +1982,27 @@ Your Goal: {self.goal}
# None = normal rule flow. Consulted at tool-call approval time.
self._permission_mode = None
if isinstance(approval, str) and approval not in ('True', 'False'):
# Permission preset: "safe", "read_only", "full"
# One string entry point for "how much the agent may do":
# • deny-set presets — "safe"/"read_only"/"full"/"off"/"default"
# • PermissionMode presets — "plan"/"bypass"/"accept_edits"/
# "dont_ask" (+ aliases like "yolo", "auto_edit", "reject").
# Deny-set presets are matched first so their exact behaviour is
# unchanged; anything else falls through to the canonical
# PermissionMode resolver, so all spellings share one model.
from ..approval.registry import PERMISSION_PRESETS
preset_deny = PERMISSION_PRESETS.get(approval)
preset_deny = PERMISSION_PRESETS.get(approval.strip().lower())
self._approval_backend = None
self._approve_all_tools = False
self._approval_timeout = 0
self._approval_permissions = None
if preset_deny is not None:
self._perm_deny = preset_deny
self._approval_backend = None
self._approve_all_tools = False
self._approval_timeout = 0
self._approval_permissions = None
else:
# Unknown string — treat as no approval
self._approval_backend = None
self._approve_all_tools = False
self._approval_timeout = 0
self._approval_permissions = None
# Not a deny-set preset — try the canonical PermissionMode
# presets (plan/bypass/accept_edits/dont_ask + aliases).
from ..permissions.rules import PermissionMode
self._permission_mode = PermissionMode.resolve(approval)
# Unknown string with no mode → treat as no approval.
elif approval is True:
from ..approval.backends import AutoApproveBackend
self._approval_backend = AutoApproveBackend()
@@ -2257,8 +2268,6 @@ Your Goal: {self.goal}
# Agent-centric feature instances (lazy loaded for zero performance impact)
self._auto_memory = auto_memory
self._policy = policy
self._background = background
self._checkpoints = checkpoints
self._output_style = output_style
self._thinking_budget = thinking_budget
@@ -2532,24 +2541,6 @@ Your Goal: {self.goal}
def policy(self, value: Optional[Any]) -> None:
self._policy = value
@property
def background(self) -> Optional[bool]:
"""BackgroundRunner instance for async task execution."""
return self._background
@background.setter
def background(self, value: Optional[bool]) -> None:
self._background = value
@property
def checkpoints(self) -> Optional[bool]:
"""CheckpointService instance for file-level undo/restore."""
return self._checkpoints
@checkpoints.setter
def checkpoints(self, value: Optional[bool]) -> None:
self._checkpoints = value
@property
def output_style(self) -> Optional[str]:
"""OutputStyle instance for response formatting."""
@@ -2712,16 +2703,7 @@ Your Goal: {self.goal}
from ..context.models import ContextConfig as _ContextConfig
if isinstance(self._context_param, _ContextConfig):
# Build ManagerConfig from ContextConfig fields
manager_config = ManagerConfig(
auto_compact=self._context_param.auto_compact,
compact_threshold=self._context_param.compact_threshold,
strategy=self._context_param.strategy,
output_reserve=self._context_param.output_reserve,
default_tool_output_max=self._context_param.tool_output_max, # Map field name
protected_tools=list(self._context_param.protected_tools),
keep_recent_turns=self._context_param.keep_recent_turns,
monitor_enabled=self._context_param.monitor.enabled if self._context_param.monitor else False,
)
manager_config = self._manager_config_from_context_config(self._context_param)
# Check if llm_summarize is enabled in ContextConfig
llm_summarize_enabled = getattr(self._context_param, 'llm_summarize', False)
if llm_summarize_enabled:
@@ -2750,16 +2732,7 @@ Your Goal: {self.goal}
try:
from ..context.models import ContextConfig as _ContextConfig
context_config = _ContextConfig(**preset_config)
manager_config = ManagerConfig(
auto_compact=context_config.auto_compact,
compact_threshold=context_config.compact_threshold,
strategy=context_config.strategy,
output_reserve=context_config.output_reserve,
default_tool_output_max=context_config.tool_output_max,
protected_tools=list(context_config.protected_tools),
keep_recent_turns=context_config.keep_recent_turns,
monitor_enabled=context_config.monitor.enabled if context_config.monitor else False,
)
manager_config = self._manager_config_from_context_config(context_config)
self._context_manager = ContextManager(
model=self.llm if isinstance(self.llm, str) else "gpt-4o-mini",
config=manager_config,
@@ -2778,16 +2751,7 @@ Your Goal: {self.goal}
try:
from ..context.models import ContextConfig as _ContextConfig
context_config = _ContextConfig(**self._context_param)
manager_config = ManagerConfig(
auto_compact=context_config.auto_compact,
compact_threshold=context_config.compact_threshold,
strategy=context_config.strategy,
output_reserve=context_config.output_reserve,
default_tool_output_max=context_config.tool_output_max,
protected_tools=list(context_config.protected_tools),
keep_recent_turns=context_config.keep_recent_turns,
monitor_enabled=context_config.monitor.enabled if context_config.monitor else False,
)
manager_config = self._manager_config_from_context_config(context_config)
llm_summarize_enabled = self._context_param.get('llm_summarize', False)
self._context_manager = ContextManager(
model=self.llm if isinstance(self.llm, str) else "gpt-4o-mini",
@@ -2812,6 +2776,20 @@ Your Goal: {self.goal}
self._context_manager = value
self._context_manager_initialized = True
def _manager_config_from_context_config(self, cc: Any) -> Any:
"""Build a ManagerConfig from a ContextConfig (single source of truth)."""
from ..context import ManagerConfig
return ManagerConfig(
auto_compact=cc.auto_compact,
compact_threshold=cc.compact_threshold,
strategy=cc.strategy,
output_reserve=cc.output_reserve,
default_tool_output_max=cc.tool_output_max, # Map field name
protected_tools=list(cc.protected_tools),
keep_recent_turns=cc.keep_recent_turns,
monitor_enabled=cc.monitor.enabled if cc.monitor else False,
)
def _create_llm_summarize_fn(self) -> Optional[Callable]:
"""
Create an LLM summarization function using the agent's LLM.
@@ -2839,9 +2817,19 @@ Conversation:
Summary:"""
# Use agent's LLM to generate summary
# Use agent's LLM to generate summary. Route this internal,
# non-user-facing call through the configured auxiliary
# ``small_model`` when set; otherwise fall back to the primary
# model (unchanged behaviour).
client = _get_llm_functions()['get_openai_client'](self.llm, self.base_url, self.api_key)
model_name = self.llm if isinstance(self.llm, str) else "gpt-4o-mini"
primary_model = self.llm if isinstance(self.llm, str) else None
try:
from ..config.loader import get_small_model
model_name = get_small_model(
primary_model=primary_model, fallback="gpt-4o-mini"
) or "gpt-4o-mini"
except Exception:
model_name = primary_model or "gpt-4o-mini"
response = client.chat.completions.create(
model=model_name,
@@ -4969,8 +4957,20 @@ Summary:"""
self._memory_instance = None
return
# Determine user_id
mem_user_id = user_id or getattr(self, 'user_id', None) or "default"
# Determine user_id. A shared constant default (e.g. "praison") would
# silently merge different sessions'/users' private memory onto the same
# on-disk path. When memory is enabled and no explicit user_id was given,
# fall back to a per-instance, non-shared id so agents are isolated by
# default. Pass user_id=... explicitly to persist/share memory across runs.
mem_user_id = user_id
if not mem_user_id:
import uuid
mem_user_id = f"agent-{uuid.uuid4().hex[:12]}"
logging.warning(
"Agent memory enabled without an explicit user_id; using an "
"auto-generated, non-shared id (%s). Pass user_id=... explicitly "
"to persist/share memory across runs.", mem_user_id,
)
if memory is True or memory == "file":
# Use FileMemory (zero dependencies)
@@ -5612,6 +5612,16 @@ Answer:"""
# Prefer the configured LLM instance (with api_key/base_url/client
# overrides) over the bare model-name string in self.llm.
llm = getattr(self, 'llm_instance', None) or getattr(self, 'llm', None)
# Guardrail validation is an internal, non-user-facing LLM call.
# When it would fall back to the bare primary model-name string
# (no explicit LLM instance), route through the auxiliary
# ``small_model`` (unset -> primary model, i.e. unchanged behaviour).
if isinstance(llm, str):
try:
from ..config.loader import get_small_model
llm = get_small_model(primary_model=llm, fallback=llm) or llm
except Exception:
pass
self._guardrail_fn = LLMGuardrail(description=self.guardrail, llm=llm)
else:
raise ValueError("Agent guardrail must be either a callable or a string description")
@@ -6416,10 +6426,14 @@ Answer:"""
"""Clean up global server registry entries for this agent."""
if getattr(self, '_agent_id', None) is None:
return # No ID generated, nothing registered
try:
_get_default_server_registry().cleanup_agent_registrations(self._agent_id)
# Tear down the routes launch() actually registered (module-level
# state in execution_mixin.py). ServerRegistry above is a separate,
# unpopulated structure and cleaning it up is a no-op.
from .execution_mixin import cleanup_launch_registration
cleanup_launch_registration(self._agent_id)
except Exception as e:
import sys
if sys.meta_path is not None:
@@ -1305,6 +1305,104 @@ Your Goal: {self.goal}"""
return None
return self.fallback_models[fallback_index]
def _emit_model_fallback(self, from_model, to_model, reason_category, fallback_index,
stream_callback=None):
"""Surface a mid-turn model fallback as an observable state transition.
Fired at the exact point the recovery loop swaps the primary model for
the next entry in ``fallback_models``. Emits a ``MODEL_FALLBACK`` hook
(so plugins/gateways can react — notice, alert, metrics) and, when a
stream callback is active, a ``StreamEventType.MODEL_FALLBACK`` event so
the streaming/draft layer can mark the switch. Only the failure class is
exposed; provider internals stay redacted. Fully guarded and a cheap
no-op when nothing is subscribed — preserving today's behaviour plus the
existing log line (Issue #3820).
"""
try:
from ..hooks.types import HookEvent
runner = getattr(self, '_hook_runner', None)
if runner is not None and runner.registry.has_hooks(HookEvent.MODEL_FALLBACK):
runner.execute_sync(
HookEvent.MODEL_FALLBACK,
self._build_model_fallback_input(
from_model, to_model, reason_category, fallback_index, HookEvent
),
)
except Exception: # observability must never break the fallback path
logging.debug("MODEL_FALLBACK hook failed", exc_info=True)
self._emit_model_fallback_stream(
from_model, to_model, reason_category, fallback_index, stream_callback
)
async def _aemit_model_fallback(self, from_model, to_model, reason_category, fallback_index,
stream_callback=None):
"""Async variant of :meth:`_emit_model_fallback` for the async recovery path.
Awaits the hook runner in the active event loop (``execute_sync`` would
raise inside a running loop, silently dropping ``MODEL_FALLBACK`` hooks).
Same guarantees: cheap no-op when nothing is subscribed and never raises
into the fallback path.
"""
try:
from ..hooks.types import HookEvent
runner = getattr(self, '_hook_runner', None)
if runner is not None and runner.registry.has_hooks(HookEvent.MODEL_FALLBACK):
await runner.execute(
HookEvent.MODEL_FALLBACK,
self._build_model_fallback_input(
from_model, to_model, reason_category, fallback_index, HookEvent
),
)
except Exception: # observability must never break the fallback path
logging.debug("MODEL_FALLBACK hook failed", exc_info=True)
self._emit_model_fallback_stream(
from_model, to_model, reason_category, fallback_index, stream_callback
)
def _build_model_fallback_input(self, from_model, to_model, reason_category,
fallback_index, HookEvent):
"""Build the typed ``ModelFallbackInput`` (only the failure class is exposed)."""
from ..hooks.events import ModelFallbackInput
return ModelFallbackInput(
session_id=getattr(self, '_session_id', 'default'),
cwd=os.getcwd(),
event_name=HookEvent.MODEL_FALLBACK.value,
timestamp=str(time.time()),
agent_name=getattr(self, 'name', None),
from_model=str(from_model),
to_model=str(to_model),
reason_category=str(reason_category or ""),
fallback_index=fallback_index,
)
def _emit_model_fallback_stream(self, from_model, to_model, reason_category,
fallback_index, stream_callback):
"""Emit the ``StreamEventType.MODEL_FALLBACK`` event when a callback is active."""
if stream_callback is None:
return
try:
from ..streaming.events import StreamEvent, StreamEventType
stream_callback(StreamEvent(
type=StreamEventType.MODEL_FALLBACK,
metadata={
"from_model": str(from_model),
"to_model": str(to_model),
"reason_category": str(reason_category or ""),
"fallback_index": fallback_index,
},
agent_id=getattr(self, 'name', None),
session_id=getattr(self, '_session_id', None),
run_id=getattr(self, '_current_run_id', None),
))
except Exception:
logging.debug("MODEL_FALLBACK stream event failed", exc_info=True)
def _max_retry_depth(self) -> int:
"""Maximum LLM retry depth honoured by the recovery loop.
@@ -1345,34 +1443,36 @@ Your Goal: {self.goal}"""
self._apply_context_compaction(messages, _HookEvent)
# Trigger BEFORE_LLM hook
# Only build the input if a BEFORE_LLM hook is actually registered
from ..hooks import HookEvent, BeforeLLMInput
before_llm_input = BeforeLLMInput(
session_id=getattr(self, '_session_id', 'default'),
cwd=os.getcwd(),
event_name=HookEvent.BEFORE_LLM,
timestamp=str(time.time()),
agent_name=self.name,
messages=messages,
model=self.llm if isinstance(self.llm, str) else str(self.llm),
temperature=temperature
)
_before_llm_results = self._hook_runner.execute_sync(HookEvent.BEFORE_LLM, before_llm_input)
# Honour a blocking BEFORE_LLM hook/plugin (POLICY/GUARDRAIL) by
# refusing to dispatch the request, mirroring how BEFORE_TOOL/BEFORE_AGENT
# enforce blocks. Without this, a plugin that returns PluginDecision.deny()
# (or raises GuardrailBlocked) would fail open and still hit the model.
if self._hook_runner.is_blocked(_before_llm_results):
_block_reason = next(
(getattr(r.output, "reason", None) for r in _before_llm_results
if r.output and getattr(r.output, "is_denied", lambda: False)()),
None,
) or "Blocked by hook"
logging.warning(f"Agent {self.name} LLM request blocked by BEFORE_LLM hook: {_block_reason}")
return f"[LLM request blocked by hook: {_block_reason}]"
# C7 - honour any BEFORE_LLM hook that mutated the message stream
# (e.g. PII redactor). The runner applies modified_input in-place on
# before_llm_input.messages; adopt that value for the actual LLM call.
messages = before_llm_input.messages
if self._hook_runner.registry.has_hooks(HookEvent.BEFORE_LLM):
before_llm_input = BeforeLLMInput(
session_id=getattr(self, '_session_id', 'default'),
cwd=os.getcwd(),
event_name=HookEvent.BEFORE_LLM,
timestamp=str(time.time()),
agent_name=self.name,
messages=messages,
model=self.llm if isinstance(self.llm, str) else str(self.llm),
temperature=temperature
)
_before_llm_results = self._hook_runner.execute_sync(HookEvent.BEFORE_LLM, before_llm_input)
# Honour a blocking BEFORE_LLM hook/plugin (POLICY/GUARDRAIL) by
# refusing to dispatch the request, mirroring how BEFORE_TOOL/BEFORE_AGENT
# enforce blocks. Without this, a plugin that returns PluginDecision.deny()
# (or raises GuardrailBlocked) would fail open and still hit the model.
if self._hook_runner.is_blocked(_before_llm_results):
_block_reason = next(
(getattr(r.output, "reason", None) for r in _before_llm_results
if r.output and getattr(r.output, "is_denied", lambda: False)()),
None,
) or "Blocked by hook"
logging.warning(f"Agent {self.name} LLM request blocked by BEFORE_LLM hook: {_block_reason}")
return f"[LLM request blocked by hook: {_block_reason}]"
# C7 - honour any BEFORE_LLM hook that mutated the message stream
# (e.g. PII redactor). The runner applies modified_input in-place on
# before_llm_input.messages; adopt that value for the actual LLM call.
messages = before_llm_input.messages
# Pre-call budget guard (zero overhead when _max_budget is None).
# Estimate this call's minimum cost from the known input size plus the
@@ -1526,19 +1626,21 @@ Your Goal: {self.goal}"""
self._on_budget_exceeded(current_cost, self._max_budget)
# Trigger AFTER_LLM hook
# Only build the input if an AFTER_LLM hook is actually registered
from ..hooks import HookEvent, AfterLLMInput
after_llm_input = AfterLLMInput(
session_id=getattr(self, '_session_id', 'default'),
cwd=os.getcwd(),
event_name=HookEvent.AFTER_LLM,
timestamp=str(time.time()),
agent_name=self.name,
messages=messages,
response=str(final_response),
model=self.llm if isinstance(self.llm, str) else str(self.llm),
latency_ms=(time.time() - start_time) * 1000
)
self._hook_runner.execute_sync(HookEvent.AFTER_LLM, after_llm_input)
if self._hook_runner.registry.has_hooks(HookEvent.AFTER_LLM):
after_llm_input = AfterLLMInput(
session_id=getattr(self, '_session_id', 'default'),
cwd=os.getcwd(),
event_name=HookEvent.AFTER_LLM,
timestamp=str(time.time()),
agent_name=self.name,
messages=messages,
response=str(final_response),
model=self.llm if isinstance(self.llm, str) else str(self.llm),
latency_ms=(time.time() - start_time) * 1000
)
self._hook_runner.execute_sync(HookEvent.AFTER_LLM, after_llm_input)
return final_response
@@ -1624,7 +1726,22 @@ Your Goal: {self.goal}"""
if next_model:
current_model = self.llm if isinstance(self.llm, str) else str(self.llm)
logging.info(f"[{self.name}] {current_model} unavailable — falling back to {next_model}")
# Surface the otherwise-silent switch as an observable event.
# Forward the active stream callback so streaming consumers
# receive the MODEL_FALLBACK event when this follows a
# streaming request.
_sync_stream_callback = (
self.stream_emitter.emit if hasattr(self, 'stream_emitter') else None
)
self._emit_model_fallback(
from_model=current_model,
to_model=next_model,
reason_category=classification.error_category,
fallback_index=_fallback_index,
stream_callback=_sync_stream_callback,
)
# Apply backoff if suggested
if classification.backoff_seconds and classification.backoff_seconds > 0:
time.sleep(classification.backoff_seconds)
@@ -1787,7 +1904,20 @@ Your Goal: {self.goal}"""
if next_model:
current_model = self.llm if isinstance(self.llm, str) else str(self.llm)
logging.info(f"[{self.name}] {current_model} unavailable — falling back to {next_model}")
# Surface the otherwise-silent switch as an observable event.
# Await the async emitter so MODEL_FALLBACK hooks run in the
# active event loop, and honour emit_events so internal calls
# (emit_events=False) don't leak a stream event into the public
# stream.
await self._aemit_model_fallback(
from_model=current_model,
to_model=next_model,
reason_category=classification.error_category,
fallback_index=_fallback_index,
stream_callback=stream_callback if emit_events else None,
)
# Apply backoff if suggested
if classification.backoff_seconds and classification.backoff_seconds > 0:
await asyncio.sleep(classification.backoff_seconds)
@@ -2327,7 +2457,7 @@ Your Goal: {self.goal}"""
return output
try:
run_id = getattr(self, '_run_id', None)
run_id = getattr(self, '_current_run_id', None)
return self.context_manager.truncate_tool_output(tool_name, output, tool_call_id, run_id)
except Exception as e:
logging.warning(f"Tool output truncation error: {e}")
@@ -3749,56 +3879,13 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
_get_display_functions()['display_error'](f"Tool {function_name} not found")
continue
# --- BEFORE_TOOL hook ---
try:
from ..hooks import HookEvent, BeforeToolInput
_before_tool_input = BeforeToolInput(
session_id=getattr(self, '_session_id', 'default'),
cwd=os.getcwd(),
event_name=HookEvent.BEFORE_TOOL,
timestamp=str(time.time()),
agent_name=self.name,
tool_name=function_name,
tool_input=arguments,
)
_before_results = await self._hook_runner.execute(HookEvent.BEFORE_TOOL, _before_tool_input)
_tool_blocked = self._hook_runner.is_blocked(_before_results)
except Exception as _hook_err:
logging.debug(f"BEFORE_TOOL hook error (non-fatal): {_hook_err}")
_tool_blocked = False
if _tool_blocked:
# Reason extraction must not be able to re-open a block:
# use attributes present on both HookResult (plugin
# bridge) and HookOutput, defaulting safely.
_block_reason = next(
(getattr(r.output, "reason", None) for r in _before_results
if r.output and getattr(r.output, "is_denied", lambda: False)()),
None,
) or "Blocked by hook"
results.append(f"[Tool blocked by hook: {_block_reason}]")
continue
# Route through safety pipeline instead of direct execution
# Route through safety pipeline instead of direct execution.
# BEFORE_TOOL/AFTER_TOOL hooks (blocking, arg mutation and
# after-context aggregation) are fired once, guarded, inside
# execute_tool_async — no inline duplicate dispatch here.
# Pass the tools list to honor task-scoped tools
result = await self.execute_tool_async(function_name, arguments, tools_override=tools)
# --- AFTER_TOOL hook ---
try:
from ..hooks import AfterToolInput
_after_tool_input = AfterToolInput(
session_id=getattr(self, '_session_id', 'default'),
cwd=os.getcwd(),
event_name=HookEvent.AFTER_TOOL,
timestamp=str(time.time()),
agent_name=self.name,
tool_name=function_name,
tool_input=arguments,
tool_output=result,
)
await self._hook_runner.execute(HookEvent.AFTER_TOOL, _after_tool_input)
except Exception as _hook_err:
logging.debug(f"AFTER_TOOL hook error (non-fatal): {_hook_err}")
results.append(result)
except Exception as e:
_get_display_functions()['display_error'](f"Error executing tool {function_name}: {e}")
@@ -3887,8 +3974,9 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Force streaming, no display by default (app-friendly)
kwargs['stream'] = True
# Use the internal streaming generator
# Use the internal streaming generator (guardrail-bypass warning is
# emitted inside _start_stream so it covers start(stream=True) too).
for chunk in self._start_stream(prompt, **kwargs):
yield chunk
@@ -3897,6 +3985,17 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
def _start_stream(self, prompt: str, **kwargs) -> Generator[str, None, None]:
"""Stream generator for real-time response chunks."""
# Warn if an output guardrail is configured: token-level streaming
# yields chunks before a full response exists to validate, so the
# guardrail cannot be applied without breaking the streaming contract.
# This lives here (the single common streaming path) so both
# iter_stream() and start(stream=True) surface the bypass loudly.
if getattr(self, 'guardrail', None) is not None:
logging.warning(
f"Agent {getattr(self, 'name', '')}: output guardrail is not "
"applied to streamed responses (iter_stream / stream=True). "
"Use chat() for guardrail-validated output."
)
try:
# Reset the final display flag for each new conversation
self._final_display_shown = False
@@ -28,6 +28,44 @@ _server_started: Dict[int, bool] = {}
_registered_agents: Dict[int, Dict[str, str]] = {}
_shared_apps: Dict[int, Any] = {}
def cleanup_launch_registration(agent_id: str) -> None:
"""Remove launch() endpoints registered for ``agent_id`` from the shared
HTTP state that ``Agent.launch()`` actually populates.
``Agent.launch()`` writes routes into the module-level ``_registered_agents``
and ``_shared_apps`` dicts above. This tears those routes back down so a
closed agent's endpoint stops accepting requests and the request handler
closure no longer keeps the Agent object graph alive.
"""
with _server_lock:
for port, paths in list(_registered_agents.items()):
stale_paths = [p for p, aid in paths.items() if aid == agent_id]
for p in stale_paths:
del paths[p]
app = _shared_apps.get(port)
if app is not None:
try:
# Only drop the agent-owned POST route. launch() always
# registers the handler via ``.post(path)``, so filtering
# on both path AND method preserves unrelated routes that
# share the path with a different verb (e.g. the built-in
# GET /health and GET /).
app.router.routes = [
r for r in app.router.routes
if not (
getattr(r, "path", None) == p
and "POST" in (getattr(r, "methods", None) or set())
)
]
# Invalidate cached OpenAPI schema so removed routes
# disappear from /openapi.json and /docs.
app.openapi_schema = None
except Exception:
pass
if not paths:
_registered_agents.pop(port, None)
if TYPE_CHECKING:
pass
@@ -1481,20 +1519,40 @@ Write the complete compiled report:"""
logging.error(f"Function {function_name} not found in tools")
return {"error": f"Function {function_name} not found in tools"}
# Activate the tool-progress channel so tools running under the async
# path can stream incremental output (emit_tool_progress) and the
# built-in todo tool can publish live updates (emit_todo_update),
# mirroring the sync execute_tool path. Zero overhead when no stream
# callbacks are registered — the sink stays None and the contextvar
# is set to None (a no-op for emitters).
_progress_sink = None
_stream_emitter = self._get_existing_stream_emitter() if hasattr(self, "_get_existing_stream_emitter") else None
if _stream_emitter is not None and _stream_emitter.has_callbacks:
def _progress_sink(_event, _emitter=_stream_emitter): # noqa: ANN001 — StreamEvent forwarder
_event.tool_call = {"name": function_name, "id": tool_call_id}
_event.agent_id = self.name
_emitter.emit(_event)
from ..streaming.events import tool_progress_channel
try:
# BaseTool instances (plugin system, e.g. BrowserBaseTool) are not
# directly callable — dispatch to their .run() method like the sync path.
call_target = func.run if isinstance(func, BaseTool) else func
if inspect.iscoroutinefunction(call_target):
logging.debug(f"Executing async function: {function_name}")
result = await call_target(**arguments)
with tool_progress_channel(_progress_sink):
result = await call_target(**arguments)
else:
logging.debug(f"Executing sync function in executor: {function_name}")
loop = asyncio.get_running_loop()
from ..trace.context_events import copy_context_to_callable
result = await loop.run_in_executor(
None, copy_context_to_callable(lambda: call_target(**arguments))
)
# Set the channel BEFORE copy_context_to_callable so the sink
# propagates into the executor thread via contextvars.
with tool_progress_channel(_progress_sink):
result = await loop.run_in_executor(
None, copy_context_to_callable(lambda: call_target(**arguments))
)
# Ensure result is JSON serializable
logging.debug(f"Raw result from tool: {result}")
@@ -1740,75 +1798,31 @@ Write the complete compiled report:"""
def _launch_mcp_server(self, path: str, port: int, host: str, debug: bool):
"""
Launch MCP server (internal implementation).
NOTE: This implementation will be moved to wrapper layer in future version.
For now, it maintains backward compatibility while following lazy import patterns.
Launch this single agent as an MCP server.
Delegates to the ``praisonai-mcp`` agent adapter via ``serve_agents([self])``
so that ``Agent.launch(protocol="mcp")`` and
``PraisonAIAgents.launch(protocol="mcp")`` share one code path and one
vocabulary (publishing ``ask_{agent_name}`` + ``list_agents``). The mcp
package is an optional dependency imported lazily here so core keeps no
hard dependency on it.
"""
# For now, delegate to the existing MCP implementation
# This will be extracted to a proper adapter in the future
try:
import uvicorn
from mcp.server.fastmcp import FastMCP
from mcp.server.sse import SseServerTransport
from starlette.applications import Starlette
from starlette.routing import Mount
import threading
import time
import asyncio
mcp_server_instance_name = f"{self.name}_mcp_server" if self.name else "agent_mcp_server"
mcp = FastMCP(mcp_server_instance_name)
from praisonai_mcp import serve_agents
# Determine the MCP tool name based on self.name
actual_mcp_tool_name = f"execute_{self.name.lower().replace(' ', '_').replace('-', '_')}_task" if self.name else "execute_task"
@mcp.tool(name=actual_mcp_tool_name)
async def execute_agent_task(prompt: str) -> str:
"""Executes the agent's primary task with the given prompt."""
try:
if hasattr(self, 'achat') and asyncio.iscoroutinefunction(self.achat):
response = await self.achat(prompt, tools=self.tools, task_name=None, task_description=None, task_id=None)
elif hasattr(self, 'chat'):
from ..trace.context_events import copy_context_to_callable
loop = asyncio.get_event_loop()
response = await loop.run_in_executor(None, copy_context_to_callable(lambda p=prompt: self.chat(p, tools=self.tools)))
else:
return f"Error: Agent {self.name} misconfigured for MCP."
return response if response is not None else "Agent returned no response."
except Exception as e:
return f"Error executing task: {str(e)}"
# Create and run MCP server
base_path = (path or "/mcp").rstrip("/") or "/mcp"
transport = SseServerTransport(f"{base_path}/sse")
starlette_app = Starlette(
routes=[Mount(base_path, mcp.sse_app())]
# Keep the call inside the ImportError guard: serve_agents() lazily
# imports its transport backend, so a package that is installed
# without its optional transport extras surfaces the missing
# dependency here rather than at the import line above. Catching it
# in the same place yields one actionable install message instead of
# an uncaught traceback.
return serve_agents([self], host=host, port=port)
except ImportError:
_get_display_functions()['display_error'](
"MCP serving requires the 'praisonai-mcp' package."
)
def run_mcp_server():
try:
uvicorn.run(starlette_app, host=host, port=port, log_level="debug" if debug else "info")
except Exception as e:
logging.error(f"Error starting MCP server: {str(e)}", exc_info=True)
server_thread = threading.Thread(target=run_mcp_server, daemon=True)
server_thread.start()
self._safe_sleep(0.5)
try:
print("\nKeeping MCP server alive. Press Ctrl+C to stop.")
while True:
self._safe_sleep(1)
except KeyboardInterrupt:
print("\nMCP Server stopped")
print("\nTo add MCP capabilities, install: pip install praisonai-mcp")
return None
except ImportError as e:
missing_module = str(e).split("No module named '")[-1].rstrip("'")
_get_display_functions()['display_error'](f"Missing dependency: {missing_module}. Required for MCP mode.")
print(f"\nTo add MCP capabilities, install: pip install {missing_module}")
return None
async def _emit_retry_hook_async(self, tool_name, attempt, delay_ms, error, max_attempts, error_type):
"""Emit ON_RETRY hook event (async version).
@@ -170,7 +170,9 @@ from ..errors import (
# across every coroutine running on the same event-loop thread. Concurrent
# async handoffs (``asyncio.gather``, a server handling parallel requests, or
# any ``max_concurrent`` workflow) would otherwise push/pop into the same list
# and corrupt each other's cycle/depth state.
# and corrupt each other's cycle/depth state. Combined with the copy-on-write
# push/pop below, this fully isolates sibling handoff tasks (e.g. those spawned
# by ``parallel_handoffs`` via ``asyncio.gather``).
_handoff_chain_var: "contextvars.ContextVar[Optional[List[str]]]" = contextvars.ContextVar(
"handoff_chain", default=None
)
@@ -306,12 +308,14 @@ class Handoff:
- Cycle detection and depth limiting
- Configurable context policies
"""
# Class-level semaphore for concurrency control
_semaphore: Optional[asyncio.Semaphore] = None
_sync_semaphore: Optional[threading.Semaphore] = None
_semaphore_lock: threading.Lock = threading.Lock() # Lock for semaphore initialization
# Class-level lock guarding lazy creation of each instance's async
# semaphore. The semaphore itself is per-instance (so every Handoff
# enforces its own max_concurrent), but the brief init critical section is
# only ever touched under this shared threading.Lock, which keeps
# double-checked lazy creation thread-safe without a per-instance lock.
_semaphore_lock: threading.Lock = threading.Lock()
def __init__(
self,
agent: 'Agent',
@@ -342,10 +346,40 @@ class Handoff:
self.input_type = input_type
self.input_filter = input_filter
self.config = config or HandoffConfig()
# Instance-level concurrency control. Each Handoff enforces its own
# max_concurrent instead of sharing one process-wide semaphore, so
# different handoffs don't silently override each other's limits based
# on execution order. The async semaphore is created lazily (and
# recreated if bound to a stale event loop) since it binds to the
# running loop on first use.
self._semaphore: Optional[asyncio.Semaphore] = None
self._semaphore_loop: Optional[asyncio.AbstractEventLoop] = None
# Override config callback if on_handoff provided directly
if on_handoff and not self.config.on_handoff:
self.config.on_handoff = on_handoff
def _get_semaphore(self) -> Optional[asyncio.Semaphore]:
"""Return this handoff's async semaphore, bound to the current loop.
Returns None when concurrency limiting is disabled (max_concurrent <= 0).
The semaphore is created lazily and recreated if the running event loop
differs from the one it was originally bound to, so a process that calls
asyncio.run(...) more than once doesn't crash with a "bound to a
different event loop" RuntimeError.
"""
if self.config.max_concurrent <= 0:
return None
try:
current_loop = asyncio.get_event_loop()
except RuntimeError:
current_loop = None
with self._semaphore_lock:
if self._semaphore is None or self._semaphore_loop is not current_loop:
self._semaphore = asyncio.Semaphore(self.config.max_concurrent)
self._semaphore_loop = current_loop
return self._semaphore
@property
def tool_name(self) -> str:
@@ -614,13 +648,15 @@ class Handoff:
"""
start_time = time.time()
kwargs = context or {}
pushed = False
try:
# Safety checks
# Safety checks (may raise before anything is pushed for this call)
self._check_safety(source_agent)
# Track handoff chain
_push_handoff(source_agent.name)
pushed = True
# Execute on_handoff callback
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
@@ -696,7 +732,8 @@ class Handoff:
logger.error(f"Handoff error: {e}")
return result
finally:
_pop_handoff()
if pushed:
_pop_handoff()
async def execute_async(
self,
@@ -715,20 +752,19 @@ class Handoff:
Returns:
HandoffResult with response or error
"""
# Initialize semaphore if needed (thread-safe)
if self.config.max_concurrent > 0 and Handoff._semaphore is None:
with Handoff._semaphore_lock: # Thread-safe initialization
if Handoff._semaphore is None: # Double-check after acquiring lock
Handoff._semaphore = asyncio.Semaphore(self.config.max_concurrent)
# Per-instance semaphore, bound to the current event loop
semaphore = self._get_semaphore()
start_time = time.time()
kwargs = context or {}
async def _execute():
pushed = False
try:
# Safety checks
# Safety checks (may raise before anything is pushed for this call)
self._check_safety(source_agent)
_push_handoff(source_agent.name)
pushed = True
# Execute callback
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
@@ -777,11 +813,12 @@ class Handoff:
return result
finally:
_pop_handoff()
if pushed:
_pop_handoff()
try:
if self.config.max_concurrent > 0 and Handoff._semaphore:
async with Handoff._semaphore:
if semaphore is not None:
async with semaphore:
if self.config.timeout_seconds > 0:
result = await asyncio.wait_for(
_execute(),
@@ -845,13 +882,15 @@ class Handoff:
def handoff_tool(**kwargs):
"""Execute the handoff to the target agent."""
start_time = time.time()
pushed = False
try:
# Safety checks
# Safety checks (may raise before anything is pushed for this call)
self._check_safety(source_agent)
# Track handoff chain
_push_handoff(source_agent.name)
pushed = True
# Execute on_handoff callback
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
@@ -937,7 +976,8 @@ class Handoff:
self._execute_callback(self.config.on_error, source_agent, kwargs, result)
return f"Error during handoff to {self.agent.name}: {str(e)}"
finally:
_pop_handoff()
if pushed:
_pop_handoff()
# Set function metadata for tool definition generation
handoff_tool.__name__ = self.tool_name
@@ -1188,6 +1228,13 @@ async def parallel_handoffs(
semaphore = asyncio.Semaphore(effective_max_concurrent) if effective_max_concurrent > 0 else None
async def _run_one(agent, prompt):
# Each gathered task shares a copied context, but a copied ContextVar
# binding still points at the *same* parent list object. Rebind to a
# fresh, per-task copy so sibling handoffs cannot corrupt each other's
# cycle/depth tracking when parallel_handoffs runs inside a non-empty
# parent handoff chain.
_handoff_chain_var.set(list(_handoff_chain_var.get() or []))
async def _do_handoff():
try:
return await source.handoff_to_async(agent, prompt, config=config)
@@ -1408,13 +1455,15 @@ class TypedHandoff(Handoff, Generic[T]):
start_time = time.time()
kwargs = context or {}
pushed = False
try:
# Safety checks
# Safety checks (may raise before anything is pushed for this call)
self._check_safety(source_agent)
# Track handoff chain
_push_handoff(source_agent.name)
pushed = True
# Execute on_handoff callback
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
@@ -1487,7 +1536,8 @@ class TypedHandoff(Handoff, Generic[T]):
logger.error(f"Typed handoff error: {e}")
return result
finally:
_pop_handoff()
if pushed:
_pop_handoff()
async def execute_async(
self,
@@ -1514,20 +1564,19 @@ class TypedHandoff(Handoff, Generic[T]):
if isinstance(payload, str):
return await super().execute_async(source_agent, payload, context)
# Initialize semaphore if needed (thread-safe)
if self.config.max_concurrent > 0 and Handoff._semaphore is None:
with Handoff._semaphore_lock:
if Handoff._semaphore is None:
Handoff._semaphore = asyncio.Semaphore(self.config.max_concurrent)
# Per-instance semaphore, bound to the current event loop
semaphore = self._get_semaphore()
start_time = time.time()
kwargs = context or {}
async def _execute():
pushed = False
try:
# Safety checks
# Safety checks (may raise before anything is pushed for this call)
self._check_safety(source_agent)
_push_handoff(source_agent.name)
pushed = True
# Execute callback
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
@@ -1575,11 +1624,12 @@ class TypedHandoff(Handoff, Generic[T]):
handoff_depth=_get_handoff_depth(),
)
finally:
_pop_handoff()
if pushed:
_pop_handoff()
try:
if self.config.max_concurrent > 0 and Handoff._semaphore:
async with Handoff._semaphore:
if semaphore is not None:
async with semaphore:
if self.config.timeout_seconds > 0:
result = await asyncio.wait_for(
_execute(),
@@ -240,21 +240,25 @@ class MemoryMixin:
if hasattr(self._history_lock, 'is_async_context') and self._history_lock.is_async_context():
# Cannot use asyncio.Lock in sync context - use thread lock as fallback
import hashlib
import uuid
from datetime import datetime, timezone
with self._history_lock._lock._thread_lock:
if self._session_id is None: # Double-check after acquiring lock
hour_str = datetime.now(timezone.utc).strftime("%Y%m%d%H")
agent_hash = hashlib.sha256((self.name or "agent").encode()).hexdigest()[:6]
self._session_id = f"{hour_str}-{agent_hash}"
# per-instance suffix so same-named agents can never collide
self._session_id = f"{hour_str}-{agent_hash}-{uuid.uuid4().hex[:8]}"
else:
# Use sync lock directly
import hashlib
import uuid
from datetime import datetime, timezone
with self._history_lock.lock():
if self._session_id is None: # Double-check after acquiring lock
hour_str = datetime.now(timezone.utc).strftime("%Y%m%d%H")
agent_hash = hashlib.sha256((self.name or "agent").encode()).hexdigest()[:6]
self._session_id = f"{hour_str}-{agent_hash}"
# per-instance suffix so same-named agents can never collide
self._session_id = f"{hour_str}-{agent_hash}-{uuid.uuid4().hex[:8]}"
# Call db adapter's on_agent_start to get previous messages
try:
@@ -872,12 +872,34 @@ class ToolExecutionMixin:
# model-visible image parts via format_tool_result_messages().
_is_multimodal_result = _normalize_multimodal_result(result) is not None
# Context economy: if the tool declared a compact, model-facing view
# (ToolResult.model_output, or a to_model_output(result) hook on the
# @tool/BaseTool), resolve it now while the FULL result is still
# intact for tracing/hooks/display below. The compact view is only
# swapped in at the very end, right before the string returned to
# the model is built, so downstream consumers keep the full payload.
_model_facing_override = None
if not _is_multimodal_result:
_model_facing_override = self._resolve_model_facing_result(
function_name, result
)
# Apply prompt injection protection for external tools
# Zero-cost for trusted tools, wraps external content in security markers
if not _is_multimodal_result:
try:
from ..tools.trust import wrap_if_external
result = wrap_if_external(function_name, result)
# The compact, model-facing view is untrusted external
# content too when the producing tool is external, so it
# MUST pass through the same prompt-injection fence before
# it can reach the model. Resolving it above (pre-fence)
# kept the full payload intact for hooks/tracing; fence the
# override here so it never bypasses the security markers.
if _model_facing_override is not None:
_model_facing_override = wrap_if_external(
function_name, _model_facing_override
)
except Exception:
# Trust module unavailable (partial/broken install) must not
# abort tool execution; fall through with the raw result.
@@ -970,8 +992,12 @@ class ToolExecutionMixin:
logging.debug(f"Tool truncation skipped: {e}")
# Emit tool call end event (truncation handled by context_events.py)
# Only stringify the result if the emitter is actually enabled; the
# default emitter is a disabled NoOp, so str(result) would otherwise
# re-serialise the whole structure for nothing on every tool call.
_duration_ms = (_time.time() - _tool_start_time) * 1000
_trace_emitter.tool_call_end(self.name, function_name, str(result) if result else None, _duration_ms)
if _trace_emitter.enabled:
_trace_emitter.tool_call_end(self.name, function_name, str(result) if result else None, _duration_ms)
# Emit TOOL_CALL_RESULT to stream_emitter (for AIUI/AG-UI consumers)
# Zero overhead when no callbacks registered
@@ -1013,7 +1039,18 @@ class ToolExecutionMixin:
tool_error = self._last_normalized_result.error_message
# Clean up temporary attribute
delattr(self, '_last_normalized_result')
# Context economy: ``result`` stays the FULL output through the
# AFTER_TOOL hook, result-aware loop detection, and the loop guard
# (below), so those channels keep seeing real, changing outcomes.
# A tool-declared compact model-facing view (if any) is only swapped
# in at the very end, right before ``return``, purely to shrink what
# the LLM sees. No override => behaviour is unchanged. Model-facing
# annotations added below (AFTER_TOOL additional_context, loop-guard
# notices) are collected here so they can be re-applied to the
# compact view and therefore always reach the model.
_model_facing_annotations = []
# Only build the input if an AFTER_TOOL hook is actually registered
if self._hook_runner.registry.has_hooks(HookEvent.AFTER_TOOL):
after_tool_input = AfterToolInput(
@@ -1042,6 +1079,9 @@ class ToolExecutionMixin:
result.setdefault("_additional_context", extra_context)
else:
result = {"value": result, "_additional_context": extra_context}
# Mirror any model-facing annotation onto the compact view so
# it survives the context-economy swap at the end.
_model_facing_annotations.append(extra_context)
# Back-fill the result hash so the result-aware detector can tell a
# genuine stall (identical output) from legitimate polling (changing
@@ -1085,10 +1125,28 @@ class ToolExecutionMixin:
else:
# Wrap non-string/dict/list results to preserve original data plus warning
result = {"value": result, "_loop_guard": {"message": decision.message, "action": decision.action.value}}
# Ensure the loop-guard notice also reaches the model when a
# compact model-facing view replaces ``result`` below.
_model_facing_annotations.append(f"[loop-guard] {decision.message}")
# Increment per-turn tool count for no-tool-call detection
self._autonomy_turn_tool_count = getattr(self, '_autonomy_turn_tool_count', 0) + 1
# Context economy: now that tracing, hooks, loop detection and the
# loop guard have all observed the FULL result, swap in the compact,
# tool-declared model-facing view for the LLM. Re-apply any
# model-facing annotations so they are never lost. No override =>
# the full result is returned exactly as before.
if _model_facing_override is not None:
result = _model_facing_override
for _annotation in _model_facing_annotations:
if isinstance(result, str):
result = f"{result}\n\n{_annotation}"
elif isinstance(result, dict):
result.setdefault("_additional_context", _annotation)
else:
result = {"value": result, "_additional_context": _annotation}
return result
except Exception as e:
# Emit tool call end with error for exceptions that escape the retry loop
@@ -1357,7 +1415,7 @@ class ToolExecutionMixin:
try:
from ..runtime.tool_output_store import get_tool_output_store
from uuid import uuid4
store = get_tool_output_store(getattr(self, '_run_id', None))
store = get_tool_output_store(getattr(self, '_current_run_id', None))
# Add unique suffix to prevent collisions with repeated keys
unique_suffix = uuid4().hex[:8]
field_call_id = f"{tool_call_id}_{key}_{unique_suffix}" if tool_call_id else f"{tool_name}_{key}_{unique_suffix}"
@@ -1565,7 +1623,9 @@ class ToolExecutionMixin:
- ``PLAN``: read-only exploration → deny any tool not tagged read-only.
- ``DONT_ASK``: auto-deny anything that would otherwise prompt for input
(an explicit ``ask`` rule or a known dangerous tool).
- ``DEFAULT``/``ACCEPT_EDITS``/unset: defer (return ``None``).
- ``ACCEPT_EDITS``: auto-approve file edit/write tools; defer the rest so
non-edit prompts (shell exec, deletes, external tools) still apply.
- ``DEFAULT``/unset: defer (return ``None``).
"""
mode = getattr(self, "_permission_mode", None)
if mode is None:
@@ -1623,6 +1683,17 @@ class ToolExecutionMixin:
))
return None
if mode == PermissionMode.ACCEPT_EDITS:
# Auto-approve file edit/write tools so edits flow without prompting,
# while deferring everything else (shell exec, deletes, external
# tools) to the normal approval flow so they still gate as usual.
if self._is_edit_tool(function_name):
return _wrap(ApprovalDecision(
approved=True,
reason=f"PermissionMode.ACCEPT_EDITS: auto-approved edit tool '{function_name}'",
))
return None
return None
def _tool_would_prompt(self, function_name) -> bool:
@@ -1669,6 +1740,23 @@ class ToolExecutionMixin:
)
return not any(marker in name for marker in write_markers)
@staticmethod
def _is_edit_tool(function_name) -> bool:
"""Best-effort heuristic for whether a tool edits/writes a file.
Used by ``PermissionMode.ACCEPT_EDITS`` to auto-approve edit-class tools
(write/edit/append/create/save/patch/mkdir) while leaving destructive or
execution tools (delete/exec/shell/…) to the normal approval flow.
"""
if not function_name:
return False
name = str(function_name).lower()
edit_markers = (
"write", "edit", "append", "create", "mkdir", "save",
"insert", "patch", "apply_patch",
)
return any(marker in name for marker in edit_markers)
def _check_permission_manager_deny(self, function_name):
"""Return an error dict if the PermissionManager hard-denies the tool.
@@ -1734,12 +1822,14 @@ class ToolExecutionMixin:
logging.warning(error_msg)
return {"error": error_msg, "approval_denied": True}
from ..approval import get_approval_registry
get_approval_registry().mark_approved(function_name)
if decision.modified_args:
arguments = decision.modified_args
logging.info(f"Using modified arguments: {arguments}")
from ..approval import get_approval_registry
get_approval_registry().mark_approved(
function_name, arguments, agent_name=getattr(self, "name", None)
)
return None, arguments
async def _check_tool_approval_async(self, function_name, arguments):
@@ -1765,12 +1855,14 @@ class ToolExecutionMixin:
logging.warning(error_msg)
return {"error": error_msg, "approval_denied": True}
from ..approval import get_approval_registry
get_approval_registry().mark_approved(function_name)
if decision.modified_args:
arguments = decision.modified_args
logging.info(f"Using modified arguments: {arguments}")
from ..approval import get_approval_registry
get_approval_registry().mark_approved(
function_name, arguments, agent_name=getattr(self, "name", None)
)
return None, arguments
def _execute_tool_with_circuit_breaker(self, function_name, arguments):
@@ -2268,6 +2360,49 @@ class ToolExecutionMixin:
return getattr(tool, '__name__', None)
return None
def _resolve_model_facing_result(self, function_name, result):
"""Return a tool's compact, model-facing view for ``result``, or ``None``.
Resolution order (first hit wins):
1. ``result.model_output`` — a ToolResult carrying its own compact view.
2. The producing tool's ``to_model_output(output)`` hook — set via
``@tool(to_model_output=fn)`` or a ``BaseTool`` override.
The hook is invoked with the tool's raw output **value**, matching the
single ``BaseTool.to_model_output`` contract: if ``result`` is a
``ToolResult`` the enclosed ``output`` is passed, otherwise ``result``
itself. ``None`` means the tool opted out, so the caller keeps today's
full stringification (fully backward-compatible).
"""
try:
from ..tools.base import resolve_model_output
compact = resolve_model_output(result)
if compact is not None:
return compact
except Exception:
pass
# Unwrap to the raw output value so the hook receives the same payload
# BaseTool.safe_run passes (the ``output`` channel), keeping one contract.
hook_input = getattr(result, 'output', result)
try:
for name, tool in self._iter_active_named_tools():
if name != function_name:
continue
hook = getattr(tool, 'to_model_output', None)
if callable(hook):
try:
return hook(hook_input)
except Exception as e:
logging.debug(
f"to_model_output hook failed for '{function_name}': {e}"
)
return None
except Exception:
pass
return None
def _iter_active_named_tools(self):
"""Yield ``(name, tool)`` for every active tool.
@@ -388,6 +388,18 @@ def _build_execution_context(agents_instance, task_id, skip_memory_init=False):
# Build context first to include in task prompt
context_text = ""
# Inter-task context / validation feedback assembled by the workflow process
# engine (Process._build_task_context) is stored on the task; fold it in so
# downstream/retried tasks actually see upstream output and rejection reasons.
extra_context = getattr(task, '_execution_context', None)
if extra_context:
context_text = extra_context
# NOTE: We intentionally do NOT clear _execution_context here. Task
# retries (guardrail/completion failures) re-enter this helper via the
# run_task/arun_task retry loops, and clearing would strip the upstream
# output + validation feedback the retry needs. The Process engine owns
# this field's lifecycle: it re-sets it before each task yield and resets
# every task's _execution_context to None before selecting the next task.
if task.context:
context_results = [] # Collect contexts then de-duplicate
for context_item in task.context:
@@ -404,7 +416,11 @@ def _build_execution_context(agents_instance, task_id, skip_memory_init=False):
for i, ctx in enumerate(unique_contexts):
logger.debug(f"Context {i+1}: {ctx[:100]}...")
context_separator = '\n\n'
context_text = context_separator.join(unique_contexts)
joined_contexts = context_separator.join(unique_contexts)
if context_text and joined_contexts:
context_text = context_text + context_separator + joined_contexts
elif joined_contexts:
context_text = joined_contexts
# Build task prompt using DRY helper
task_prompt = _prepare_task_prompt(task, task_description, context_text)
@@ -473,26 +489,59 @@ def _process_task_result(agents_instance, context, agent_output):
except Exception as e:
logger.warning(f"Warning: Could not clean output of task {task_id}: {e}")
json_parse_error = None
pydantic_parse_error = None
if task.output_json:
try:
parsed = json.loads(cleaned)
task_output.json_dict = parsed
task_output.output_format = "JSON"
except Exception:
logger.warning(f"Warning: Could not parse output of task {task_id} as JSON")
except Exception as e:
json_parse_error = e
logger.warning(f"Warning: Could not parse output of task {task_id} as JSON: {e}")
logger.debug(f"Output that failed JSON parsing: {agent_output}")
if task.output_pydantic:
try:
parsed = json.loads(cleaned)
pyd_obj = task.output_pydantic(**parsed)
if hasattr(task.output_pydantic, "model_validate"):
pyd_obj = task.output_pydantic.model_validate(parsed)
else:
pyd_obj = task.output_pydantic(**parsed)
task_output.pydantic = pyd_obj
task_output.output_format = "Pydantic"
except Exception:
logger.warning(f"Warning: Could not parse output of task {task_id} as Pydantic Model")
except Exception as e:
pydantic_parse_error = e
schema_name = getattr(task.output_pydantic, "__name__", str(task.output_pydantic))
logger.warning(
f"Warning: Could not parse output of task {task_id} as Pydantic Model "
f"({schema_name}): {e}"
)
logger.debug(f"Output that failed Pydantic parsing: {agent_output}")
task.result = task_output
# Fail-closed: when structured output was requested but not produced,
# the task did not fulfil its contract. Surface it as a failed result so
# callers relying on TaskResult.success (and the retry loop, which uses
# completion_checker) do not treat freeform prose as a success.
if task.output_pydantic and task_output.pydantic is None:
schema_name = getattr(task.output_pydantic, "__name__", str(task.output_pydantic))
detail = f" ({pydantic_parse_error})" if pydantic_parse_error is not None else ""
return TaskResult(
task_output=task_output,
success=False,
error=f"Structured output validation failed for {schema_name}: could not parse agent output as the requested Pydantic model.{detail}",
)
if task.output_json and task_output.json_dict is None:
detail = f" ({json_parse_error})" if json_parse_error is not None else ""
return TaskResult(
task_output=task_output,
success=False,
error=f"Structured output validation failed: could not parse agent output as JSON.{detail}",
)
return TaskResult(task_output=task_output, success=True)
else:
task.status = "failed"
@@ -823,6 +872,9 @@ class AgentTeam(SpawnAnnounceProtocol):
_max_retries = 3
self.completion_checker = _completion_checker if _completion_checker else self.default_completion_checker
self.task_id_counter = 0
# Last user-supplied task id, tracked for hierarchical runs so the
# return path never surfaces the synthetic manager_task's output.
self._last_real_task_id = None
self._task_id_lock = threading.Lock() # Thread-safe task ID assignment
self._state_lock = threading.Lock() # Thread-safe state mutations
self.verbose = _verbose
@@ -1039,6 +1091,26 @@ class AgentTeam(SpawnAnnounceProtocol):
self.task_id_counter += 1
return task_id
def _last_hierarchical_task_id(self):
"""Return the last user-supplied task id before hierarchical injection.
The hierarchical process injects a synthetic ``manager_task`` that always
lands last in the insertion-ordered ``self.tasks`` dict. Snapshotting the
real task ids up front lets the return path surface the final delegated
task's result instead of the Manager's own generic answer.
A ``manager_task`` from a prior run of the same team can still be present
in ``self.tasks`` (it is not removed after a run), so it is explicitly
skipped here; otherwise a second ``.start()`` would return the previous
run's Manager output instead of the final user-task result.
"""
for task_id in reversed(self.tasks):
task = self.tasks.get(task_id)
if getattr(task, "name", None) == "manager_task":
continue
return task_id
return None
def clean_json_output(self, output: str) -> str:
# NOTE: This method is duplicated in chat_mixin.ChatMixin.clean_json_output.
# Keep both implementations in sync when modifying either.
@@ -1097,10 +1169,14 @@ class AgentTeam(SpawnAnnounceProtocol):
return self._context_manager
def default_completion_checker(self, task, agent_output):
if task.output_json and task.result and task.result.json_dict:
return True
if task.output_pydantic and task.result and task.result.pydantic:
return True
# Fail-closed for structured output: if a structured model was requested
# but not produced, the task is NOT complete. Returning False here drives
# the existing retry loop and, once retries are exhausted, leaves the task
# in a "failed" state instead of silently succeeding with freeform prose.
if task.output_json:
return task.result is not None and task.result.json_dict is not None
if task.output_pydantic:
return task.result is not None and task.result.pydantic is not None
return len(agent_output.strip()) > 0
async def aexecute_task(self, task_id):
@@ -1332,6 +1408,20 @@ class AgentTeam(SpawnAnnounceProtocol):
task.status = "failed" # Set failed status to match sync behavior
logger.info(f"Task {task_id} failed after {task_max} retries.")
@staticmethod
async def _gather_with_isolation(coros):
"""Gather async tasks with exception isolation.
Uses return_exceptions=True so a single failure does not orphan its
siblings (leaving them running in the background to mutate shared state).
The first exception is re-raised after all siblings have settled.
"""
results = await asyncio.gather(*coros, return_exceptions=True)
for result in results:
if isinstance(result, BaseException):
raise result
return results
async def arun_all_tasks(self):
"""Async version of run_all_tasks method"""
process = Process(
@@ -1350,7 +1440,7 @@ class AgentTeam(SpawnAnnounceProtocol):
else:
# If we encounter a sync task, we must wait for the previous async tasks to finish.
if tasks_to_run:
await asyncio.gather(*tasks_to_run)
await self._gather_with_isolation(tasks_to_run)
tasks_to_run = []
# Run sync task in an executor to avoid blocking the event loop
@@ -1360,7 +1450,7 @@ class AgentTeam(SpawnAnnounceProtocol):
await loop.run_in_executor(None, copy_context_to_callable(lambda tid=task_id: self.run_task(tid)))
if tasks_to_run:
await asyncio.gather(*tasks_to_run)
await self._gather_with_isolation(tasks_to_run)
elif self.process == "sequential":
async_tasks_to_run = []
@@ -1369,7 +1459,7 @@ class AgentTeam(SpawnAnnounceProtocol):
"""Execute all pending async tasks"""
nonlocal async_tasks_to_run
if async_tasks_to_run:
await asyncio.gather(*async_tasks_to_run)
await self._gather_with_isolation(async_tasks_to_run)
async_tasks_to_run = []
async for task_id in process.asequential():
@@ -1388,6 +1478,12 @@ class AgentTeam(SpawnAnnounceProtocol):
# Execute any remaining async tasks at the end
await flush_async_tasks()
elif self.process == "hierarchical":
# Snapshot the real (user-supplied) task ids before the hierarchical
# generator injects its synthetic manager_task. The manager_task is
# always added last (highest id), so relying on dict-insertion order
# in the return path would surface the Manager's own generic answer
# instead of the final delegated task's result.
self._last_real_task_id = self._last_hierarchical_task_id()
async for task_id in process.ahierarchical():
if isinstance(task_id, Task):
task_id = self.add_task(task_id)
@@ -1430,10 +1526,14 @@ class AgentTeam(SpawnAnnounceProtocol):
# By default, return only the final agent's response
if not return_dict:
# Get the last task (assuming sequential processing)
task_ids = list(self.tasks.keys())
if task_ids:
last_task_id = task_ids[-1]
# Prefer the tracked last real task id (set by hierarchical runs so
# the synthetic manager_task never masks the real final result);
# otherwise fall back to the last task in insertion order.
last_task_id = getattr(self, "_last_real_task_id", None)
if last_task_id is None:
task_ids = list(self.tasks.keys())
last_task_id = task_ids[-1] if task_ids else None
if last_task_id is not None:
last_result = self.get_task_result(last_task_id)
if last_result:
return last_result.raw
@@ -1581,6 +1681,10 @@ class AgentTeam(SpawnAnnounceProtocol):
for task_id in process.sequential():
self.run_task(task_id)
elif self.process == "hierarchical":
# See arun_all_tasks: capture the last real task id before the
# synthetic manager_task is injected so the return path doesn't
# surface the Manager's own generic output.
self._last_real_task_id = self._last_hierarchical_task_id()
for task_id in process.hierarchical():
if isinstance(task_id, Task):
task_id = self.add_task(task_id)
@@ -1817,9 +1921,14 @@ class AgentTeam(SpawnAnnounceProtocol):
# By default, return only the final agent's response
if not return_dict:
task_ids = list(self.tasks.keys())
if task_ids:
last_task_id = task_ids[-1]
# Prefer the tracked last real task id (set by hierarchical runs so
# the synthetic manager_task never masks the real final result);
# otherwise fall back to the last task in insertion order.
last_task_id = getattr(self, "_last_real_task_id", None)
if last_task_id is None:
task_ids = list(self.tasks.keys())
last_task_id = task_ids[-1] if task_ids else None
if last_task_id is not None:
last_result = self.get_task_result(last_task_id)
if last_result:
return last_result.raw
@@ -1840,6 +1949,170 @@ class AgentTeam(SpawnAnnounceProtocol):
# Always run silently - no verbose output
return self.start(content=content, return_dict=return_dict, output="silent", **kwargs)
def _snapshot_task_state(self):
"""Snapshot per-task run state so a batch can restore it afterwards.
Captures the fields that ``run_task``/``arun_task`` mutate during a run
(``status``, ``result``, ``retry_count``) plus caller-defined
``variables``, keeping the public Task API backward-compatible.
"""
snapshot = {}
for task_id, task in self.tasks.items():
snapshot[task_id] = {
"status": getattr(task, "status", None),
"result": getattr(task, "result", None),
"retry_count": getattr(task, "retry_count", None),
"variables": getattr(task, "variables", None),
}
return snapshot
def _reset_task_state_for_item(self):
"""Reset per-task run state before each batch item executes.
Without this, ``run_task``/``arun_task`` see ``status == "completed"``
from the previous item and skip execution, returning stale results.
Clearing ``variables`` lets the current item's global ``variables``
re-propagate (see ``_run_task_start_hook``).
"""
for task in self.tasks.values():
task.status = "not started"
task.result = None
if hasattr(task, "retry_count"):
task.retry_count = 0
task.variables = {}
def _restore_task_state(self, snapshot):
"""Restore per-task run state captured by ``_snapshot_task_state``."""
for task_id, state in snapshot.items():
task = self.tasks.get(task_id)
if task is None:
continue
task.status = state["status"]
task.result = state["result"]
if state["retry_count"] is not None:
task.retry_count = state["retry_count"]
task.variables = state["variables"]
def _build_batch_result(self, batch_id, items):
"""Aggregate per-item results into a batch summary dict.
``token_usage_total`` is the session-level summary from the shared token
collector (cumulative, not batch-scoped) — kept lightweight; use
per-item outputs for finer accounting.
"""
succeeded = sum(1 for item in items if item["success"])
return {
"batch_id": batch_id,
"items": items,
"outputs": [item["output"] for item in items],
"succeeded": succeeded,
"failed": len(items) - succeeded,
"total": len(items),
"token_usage_total": self.get_token_usage_summary(),
}
@staticmethod
def _validate_batch_input(item_input, index):
"""Coerce/validate a single batch input into a dict of variables."""
if item_input is None:
return {}
if not isinstance(item_input, dict):
raise ValueError(
f"inputs[{index}] must be a dict of variables, got "
f"{type(item_input).__name__}"
)
return item_input
def start_for_each(
self,
inputs,
*,
on_error="continue",
output="silent",
**kwargs,
):
"""Run this team once per input dict, interpolating ``{{key}}`` placeholders.
Each input dict is applied as per-run ``variables`` (existing
``{{placeholder}}`` interpolation in task descriptions), so the original
task templates are never permanently mutated.
Args:
inputs: List of dicts; each runs the team once with those variables.
on_error: "continue" (default) collects errors per item and keeps
going; "fail_fast" re-raises on the first failing item.
output: Output preset forwarded to ``start`` (default "silent").
**kwargs: Additional arguments forwarded to ``start``.
Returns:
dict with ``batch_id``, ``items`` (per-item ``index``/``input``/
``success``/``output``/``error``), ``outputs``, ``succeeded``,
``failed``, ``total`` and ``token_usage_total``.
"""
if on_error not in ("continue", "fail_fast"):
raise ValueError("on_error must be 'continue' or 'fail_fast'")
batch_id = f"batch_{uuid.uuid4().hex[:12]}"
saved_variables = self.variables
task_snapshot = self._snapshot_task_state()
items = []
try:
for index, item_input in enumerate(inputs):
result = {"index": index, "input": item_input, "success": False,
"output": None, "error": None}
try:
item_vars = self._validate_batch_input(item_input, index)
self.variables = {**saved_variables, **item_vars}
self._reset_task_state_for_item()
result["output"] = self.start(output=output, **kwargs)
result["success"] = True
except Exception as exc: # noqa: BLE001
if on_error == "fail_fast":
raise
result["error"] = str(exc)
items.append(result)
finally:
self.variables = saved_variables
self._restore_task_state(task_snapshot)
return self._build_batch_result(batch_id, items)
async def astart_for_each(
self,
inputs,
*,
on_error="continue",
**kwargs,
):
"""Async twin of :meth:`start_for_each` (sequential per-item execution)."""
if on_error not in ("continue", "fail_fast"):
raise ValueError("on_error must be 'continue' or 'fail_fast'")
batch_id = f"batch_{uuid.uuid4().hex[:12]}"
saved_variables = self.variables
task_snapshot = self._snapshot_task_state()
items = []
try:
for index, item_input in enumerate(inputs):
result = {"index": index, "input": item_input, "success": False,
"output": None, "error": None}
try:
item_vars = self._validate_batch_input(item_input, index)
self.variables = {**saved_variables, **item_vars}
self._reset_task_state_for_item()
result["output"] = await self.astart(**kwargs)
result["success"] = True
except Exception as exc: # noqa: BLE001
if on_error == "fail_fast":
raise
result["error"] = str(exc)
items.append(result)
finally:
self.variables = saved_variables
self._restore_task_state(task_snapshot)
return self._build_batch_result(batch_id, items)
def set_state(self, key: str, value: Any) -> None:
"""Set a state value"""
with self._state_lock:
@@ -1916,38 +2189,100 @@ class AgentTeam(SpawnAnnounceProtocol):
return self._state[key]
def save_session_state(self, session_id: str, include_memory: bool = True) -> None:
"""Save current state to memory for session persistence"""
if self.shared_memory and include_memory:
state_data = {
"session_id": session_id,
"user_id": self.user_id,
"run_id": self.run_id,
"state": self._state,
"agents": [agent.display_name for agent in self.agents],
"process": self.process
}
self.shared_memory.store_short_term(
text=f"Session state for {session_id}",
metadata={
"type": "session_state",
"session_id": session_id,
"user_id": self.user_id,
"state_data": state_data
}
def _team_state_payload(self, session_id: str) -> Dict[str, Any]:
"""Build the serialisable team-state payload for durable persistence."""
with self._state_lock:
state_copy = dict(self._state)
return {
"session_id": session_id,
"user_id": self.user_id,
"run_id": self.run_id,
"state": state_copy,
"agents": [agent.display_name for agent in self.agents],
"process": self.process,
}
def save_session_state(self, session_id: str, include_memory: bool = True) -> bool:
"""Persist team session state for deterministic resume (Issue #3635).
Writes the shared team ``_state`` (plus run bookkeeping) to the durable,
project-scoped ``SessionStore`` — the same store single ``Agent`` resume
uses — keyed deterministically by ``session_id``. This gives multi-agent
runs the same locked, atomic, memory-independent continuity as a single
agent: it works even with ``memory=False``.
``shared_memory`` is kept as an *optional enrichment* (not a gate) so
legacy semantic-recall lookups still find the state when memory is on.
Returns ``True`` when the durable write succeeded, ``False`` otherwise,
so callers can tell whether a resumable state actually reached disk.
"""
state_data = self._team_state_payload(session_id)
# Primary, durable path: SessionStore (locked, atomic, memory-independent).
durable_saved = False
try:
from ..session.store import get_default_session_store
durable_saved = bool(
get_default_session_store().update_session_metadata(
session_id, team_session_state=state_data
)
)
if not durable_saved:
logger.warning(
f"Durable team session save reported no write for {session_id}"
)
except Exception as e: # never fail a completed run on save
logger.warning(f"Durable team session save failed for {session_id}: {e}")
# Optional enrichment: mirror into shared memory for semantic recall.
if self.shared_memory and include_memory:
try:
self.shared_memory.store_short_term(
text=f"Session state for {session_id}",
metadata={
"type": "session_state",
"session_id": session_id,
"user_id": self.user_id,
"state_data": state_data,
},
)
except Exception as e:
logger.debug(f"Shared-memory session enrichment failed: {e}")
return durable_saved
def restore_session_state(self, session_id: str) -> bool:
"""Restore state from memory for session persistence. Returns True if restored."""
"""Restore team session state for deterministic resume. Returns True if restored.
Reads first from the durable ``SessionStore`` keyed by ``session_id``
(no fuzzy search, no memory requirement). Falls back to the legacy
``shared_memory`` semantic lookup only for sessions written before this
durable path existed, preserving backward compatibility.
"""
# Primary, durable path: SessionStore lookup by exact session_id.
try:
from ..session.store import get_default_session_store
store = get_default_session_store()
if store.session_exists(session_id):
metadata = store.get_session(session_id).metadata or {}
state_data = metadata.get("team_session_state")
if isinstance(state_data, dict) and "state" in state_data:
with self._state_lock:
self._state.update(state_data["state"])
return True
except Exception as e:
logger.debug(f"Durable team session restore failed for {session_id}: {e}")
# Legacy fallback: shared-memory semantic lookup (pre-#3635 sessions).
if not self.shared_memory:
return False
# Use metadata-based search for better SQLite compatibility
results = self.shared_memory.search_short_term(
query=f"type:session_state",
query="type:session_state",
limit=10 # Get more results to filter by session_id
)
# Filter results by session_id in metadata
for result in results:
metadata = result.get("metadata", {})
@@ -2038,14 +2373,17 @@ class AgentTeam(SpawnAnnounceProtocol):
"""
Launch all agents as a single API endpoint (HTTP) or an MCP server.
In HTTP mode, the endpoint accepts a query and processes it through all agents in sequence.
In MCP mode, an MCP server is started, exposing a tool to run the agent workflow.
In MCP mode, serving is delegated to the ``praisonai-mcp`` agent adapter
(``serve_agents``), publishing ``ask_{agent_name}`` per agent plus
``list_agents`` — the same path used by ``Agent.launch(protocol="mcp")``.
Args:
path: API endpoint path (default: '/agents') for HTTP, or base path for MCP.
path: API endpoint path (default: '/agents') for HTTP. Ignored in MCP mode.
port: Server port (default: 8000)
host: Server host (default: '0.0.0.0')
debug: Enable debug mode for uvicorn (default: False)
protocol: "http" to launch as FastAPI, "mcp" to launch as MCP server.
protocol: "http" to launch as FastAPI, "mcp" to serve over MCP via
``praisonai-mcp`` (install with ``pip install praisonai-mcp``).
Returns:
None
@@ -2308,144 +2646,26 @@ class AgentTeam(SpawnAnnounceProtocol):
logging.warning("No agents to launch for MCP mode. Add agents to the Agents instance first.")
return
# Delegate to the praisonai-mcp agent adapter so multi-agent MCP
# serving shares one blessed entry point with the single-agent
# Agent.launch(protocol="mcp") path (both go through serve_agents).
# praisonai-mcp is an optional dependency imported lazily so core
# keeps no hard dependency on it.
try:
import uvicorn
from mcp.server.fastmcp import FastMCP
from mcp.server.sse import SseServerTransport
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.routing import Mount, Route
# from mcp.server import Server as MCPServer # Not directly needed if using FastMCP's server
import threading
import time
import inspect
import asyncio
# logging is already imported at the module level
except ImportError as e:
missing_module = str(e).split("No module named '")[-1].rstrip("'")
display_error(f"Missing dependency: {missing_module}. Required for launch() method with MCP mode.")
logging.error(f"Missing dependency: {missing_module}. Required for launch() method with MCP mode.")
print(f"\nTo add MCP capabilities, install the required dependencies:")
print(f"pip install {missing_module} mcp praison-mcp starlette uvicorn")
print("\nOr install all MCP dependencies with relevant packages.")
from praisonai_mcp import serve_agents
# Keep the call inside the ImportError guard: serve_agents()
# lazily imports its transport backend, so a praisonai-mcp
# installed without its optional transport extras surfaces the
# missing dependency here rather than at the import line above.
# Handling it in one place yields a single actionable install
# message instead of an uncaught traceback.
return serve_agents(self.agents, host=host, port=port)
except ImportError:
display_error("MCP serving requires the 'praisonai-mcp' package.")
logging.error("MCP serving requires the 'praisonai-mcp' package.")
print("\nTo add MCP capabilities, install: pip install praisonai-mcp")
return None
mcp_instance = FastMCP("praisonai_workflow_mcp_server")
# Determine the MCP tool name for the workflow based on self.name
actual_mcp_tool_name = (f"execute_{self.name.lower().replace(' ', '_').replace('-', '_')}_workflow" if self.name
else "execute_workflow")
@mcp_instance.tool(name=actual_mcp_tool_name)
async def execute_workflow_tool(query: str) -> str: # Renamed for clarity
"""Executes the defined agent workflow with the given query."""
logging.info(f"MCP tool '{actual_mcp_tool_name}' called with query: {query}")
current_input = query
final_response = "No agents in workflow or workflow did not produce a final response."
for agent_instance in self.agents:
try:
logging.debug(f"Processing with agent: {agent_instance.display_name}")
if hasattr(agent_instance, 'achat') and asyncio.iscoroutinefunction(agent_instance.achat):
response = await agent_instance.achat(current_input, tools=agent_instance.tools, task_name=None, task_description=None, task_id=None)
elif hasattr(agent_instance, 'chat'): # Fallback to sync chat if achat not suitable
# Use copy_context_to_callable to propagate contextvars (needed for trace emission)
from ..trace.context_events import copy_context_to_callable
loop = asyncio.get_running_loop()
response = await loop.run_in_executor(None, copy_context_to_callable(lambda ci=current_input: agent_instance.chat(ci, tools=agent_instance.tools)))
else:
logging.warning(f"Agent {agent_instance.display_name} has no suitable chat or achat method.")
response = f"Error: Agent {agent_instance.display_name} has no callable chat method."
current_input = response if response is not None else "Agent returned no response."
final_response = current_input # Keep track of the last valid response
logging.debug(f"Agent {agent_instance.display_name} responded. Current intermediate output: {current_input}")
except Exception as e:
logging.error(f"Error during agent {agent_instance.display_name} execution in MCP workflow: {str(e)}", exc_info=True)
current_input = f"Error from agent {agent_instance.display_name}: {str(e)}"
final_response = current_input # Update final response to show error
# Optionally break or continue based on desired error handling for the workflow
# For now, we continue, and the error is passed to the next agent or returned.
logging.info(f"MCP tool '{actual_mcp_tool_name}' completed. Final response: {final_response}")
return final_response
base_mcp_path = path.rstrip('/')
sse_mcp_path = f"{base_mcp_path}/sse"
messages_mcp_path_prefix = f"{base_mcp_path}/messages"
if not messages_mcp_path_prefix.endswith('/'):
messages_mcp_path_prefix += '/'
sse_transport_mcp = SseServerTransport(messages_mcp_path_prefix)
async def handle_mcp_sse_connection(request: Request) -> None:
logging.debug(f"MCP SSE connection request from {request.client} for path {request.url.path}")
async with sse_transport_mcp.connect_sse(
request.scope, request.receive, request._send,
) as (read_stream, write_stream):
await mcp_instance._mcp_server.run(
read_stream, write_stream, mcp_instance._mcp_server.create_initialization_options(),
)
starlette_mcp_app = Starlette(
debug=debug,
routes=[
Route(sse_mcp_path, endpoint=handle_mcp_sse_connection),
Mount(messages_mcp_path_prefix, app=sse_transport_mcp.handle_post_message),
],
)
print(f"🚀 Agents MCP Workflow server starting on http://{host}:{port}")
print(f"📡 MCP SSE endpoint available at {sse_mcp_path}")
print(f"📢 MCP messages post to {messages_mcp_path_prefix}")
# Instead of trying to extract tool names, hardcode the known tool name
mcp_tool_names = [actual_mcp_tool_name] # Use the determined dynamic tool name
print(f"🛠️ Available MCP tools: {', '.join(mcp_tool_names)}")
agent_names_in_workflow = ", ".join([a.display_name for a in self.agents])
print(f"🔄 Agents in MCP workflow: {agent_names_in_workflow}")
def run_praison_mcp_server():
try:
uvicorn.run(starlette_mcp_app, host=host, port=port, log_level="debug" if debug else "info")
except Exception as e:
logging.error(f"Error starting Agents MCP server: {str(e)}", exc_info=True)
print(f"❌ Error starting Agents MCP server: {str(e)}")
mcp_server_thread = threading.Thread(target=run_praison_mcp_server, daemon=True)
mcp_server_thread.start()
time.sleep(0.5)
import inspect
stack = inspect.stack()
if len(stack) > 1 and stack[1].filename.endswith('.py'):
caller_frame = stack[1]
caller_line = caller_frame.lineno
try:
with open(caller_frame.filename, 'r') as f:
lines = f.readlines()
has_more_launches = False
for line_content in lines[caller_line:]:
if '.launch(' in line_content and not line_content.strip().startswith('#'):
has_more_launches = True
break
if not has_more_launches:
try:
print("\nAgents MCP server running. Press Ctrl+C to stop.")
while True:
time.sleep(1)
except KeyboardInterrupt:
print("\nAgents MCP Server stopped")
except Exception as e:
logging.error(f"Error in Agents MCP launch detection: {e}")
try:
print("\nKeeping Agents MCP server alive. Press Ctrl+C to stop.")
while True:
time.sleep(1)
except KeyboardInterrupt:
print("\nAgents MCP Server stopped")
return None
else:
display_error(f"Invalid protocol: {protocol}. Choose 'http' or 'mcp'.")
return None
@@ -140,6 +140,7 @@ class SubagentDelegator:
# State
self._tasks: Dict[str, DelegationTask] = {}
self._running_asyncio_tasks: Dict[str, "asyncio.Task"] = {}
self._running_count: int = 0
self._total_count: int = 0
self._task_counter: int = 0
@@ -220,9 +221,37 @@ class SubagentDelegator:
self._tasks[task_id] = task
self._total_count += 1
# Execute with concurrency control
# Execute with concurrency control. Wrap in a real asyncio.Task so
# cancel_task/cancel_all can actually propagate cancellation to the
# awaited subagent coroutine instead of only flipping a status enum.
async with self._get_semaphore():
return await self._execute_task(task)
# If cancelled while queued on the semaphore, don't start the work.
if task.status == DelegationStatus.CANCELLED:
return DelegationResult(
task_id=task.task_id,
agent_name=task.agent_name,
success=False,
error="Task was cancelled",
)
exec_task = asyncio.ensure_future(self._execute_task(task))
self._running_asyncio_tasks[task_id] = exec_task
try:
return await exec_task
except asyncio.CancelledError:
# Distinguish a delegator-initiated cancel (via cancel_task/cancel_all,
# which sets status=CANCELLED) from an external/cooperative cancel of
# the caller. Only the former is swallowed into a result; an external
# cancel must propagate so wait_for/shutdown behave correctly.
if task.status == DelegationStatus.CANCELLED:
return DelegationResult(
task_id=task.task_id,
agent_name=task.agent_name,
success=False,
error="Task was cancelled",
)
raise
finally:
self._running_asyncio_tasks.pop(task_id, None)
async def delegate_parallel(
self,
@@ -282,14 +311,25 @@ class SubagentDelegator:
True if task was cancelled
"""
task = self._tasks.get(task_id)
if not task:
# Only terminal tasks are un-cancellable; PENDING/RUNNING can be cancelled.
_terminal = (
DelegationStatus.COMPLETED,
DelegationStatus.FAILED,
DelegationStatus.CANCELLED,
DelegationStatus.TIMEOUT,
)
if not task or task.status in _terminal:
return False
if task.status == DelegationStatus.RUNNING:
task.status = DelegationStatus.CANCELLED
return True
# Mark cancelled first so a PENDING task (still waiting on the semaphore)
# or a RUNNING coroutine both observe CANCELLED and refuse to complete.
task.status = DelegationStatus.CANCELLED
return False
# Propagate cancellation to the actual coroutine if it is already running.
asyncio_task = self._running_asyncio_tasks.get(task_id)
if asyncio_task is not None:
asyncio_task.cancel()
return True
async def cancel_all(self) -> int:
"""
@@ -299,9 +339,8 @@ class SubagentDelegator:
Number of tasks cancelled
"""
cancelled = 0
for task in self._tasks.values():
if task.status == DelegationStatus.RUNNING:
task.status = DelegationStatus.CANCELLED
for task_id in list(self._tasks.keys()):
if await self.cancel_task(task_id):
cancelled += 1
return cancelled
@@ -337,6 +376,15 @@ class SubagentDelegator:
import time
start_time = time.time()
# A cancel may have landed between scheduling and execution; honour it.
if task.status == DelegationStatus.CANCELLED:
return DelegationResult(
task_id=task.task_id,
agent_name=task.agent_name,
success=False,
error="Task was cancelled",
)
task.status = DelegationStatus.RUNNING
self._running_count += 1
@@ -356,7 +404,9 @@ class SubagentDelegator:
timeout=task.timeout_seconds,
)
task.result = result
task.status = DelegationStatus.COMPLETED
# Don't clobber a cancellation that landed while we were running.
if task.status != DelegationStatus.CANCELLED:
task.status = DelegationStatus.COMPLETED
except asyncio.TimeoutError:
task.status = DelegationStatus.TIMEOUT
@@ -31,6 +31,7 @@ from __future__ import annotations
import asyncio
import contextvars
import inspect
import json
import logging
from praisonaiagents._logging import get_logger
@@ -96,11 +97,19 @@ def get_approval_callback() -> Optional[Callable]:
"""Get the current approval callback function (legacy API)."""
return approval_callback
def mark_approved(tool_name: str) -> None:
get_approval_registry().mark_approved(tool_name)
def mark_approved(
tool_name: str,
arguments: Optional[Dict] = None,
agent_name: Optional[str] = None,
) -> None:
get_approval_registry().mark_approved(tool_name, arguments, agent_name)
def is_already_approved(tool_name: str) -> bool:
return get_approval_registry().is_already_approved(tool_name)
def is_already_approved(
tool_name: str,
arguments: Optional[Dict] = None,
agent_name: Optional[str] = None,
) -> bool:
return get_approval_registry().is_already_approved(tool_name, arguments, agent_name)
def is_yaml_approved(tool_name: str) -> bool:
return get_approval_registry().is_yaml_approved(tool_name)
@@ -166,6 +175,29 @@ async def request_approval(function_name: str, arguments: Dict) -> ApprovalDecis
RiskLevel = Literal["critical", "high", "medium", "low"]
def _bind_call_args(func: Callable, args: tuple, kwargs: Dict) -> Dict:
"""Normalise a call's positional + keyword args into a single dict.
The approval cache is keyed by the tool's arguments, so a directly-invoked
``@require_approval`` tool must reduce positional args to the same named
form the cache uses — otherwise ``read_file("/a")`` and ``read_file("/b")``
would share the empty-argument key and one approval would unlock the other.
Falls back to a shallow merge of ``kwargs`` plus an ``__args__`` tuple when
the signature cannot be bound (e.g. C-implemented callables), so distinct
positional calls still produce distinct keys instead of collapsing.
"""
try:
bound = inspect.signature(func).bind_partial(*args, **kwargs)
return dict(bound.arguments)
except (TypeError, ValueError):
merged = dict(kwargs)
if args:
merged["__args__"] = args
return merged
def require_approval(risk_level: RiskLevel = "high"):
"""Decorator to mark a tool as requiring human approval."""
def decorator(func):
@@ -177,13 +209,18 @@ def require_approval(risk_level: RiskLevel = "high"):
@wraps(func)
def wrapper(*args, **kwargs):
if is_already_approved(tool_name):
# Bind positional args into a normalized dict so the approval key
# reflects the *actual* call. Without this, positional-only calls
# (e.g. read_file("/etc/passwd")) would all collapse to the same
# empty-argument key, letting one approval unlock any later value.
approval_args = _bind_call_args(func, args, kwargs)
if is_already_approved(tool_name, approval_args):
return func(*args, **kwargs)
if is_yaml_approved(tool_name):
mark_approved(tool_name)
mark_approved(tool_name, approval_args)
return func(*args, **kwargs)
if is_env_auto_approve():
mark_approved(tool_name)
mark_approved(tool_name, approval_args)
return func(*args, **kwargs)
try:
from ..utils.async_bridge import is_async_context, run_coroutine_from_any_context
@@ -196,32 +233,35 @@ def require_approval(risk_level: RiskLevel = "high"):
)
else:
# Safe to run async approval using registry
decision = run_coroutine_from_any_context(request_approval(tool_name, kwargs))
decision = run_coroutine_from_any_context(request_approval(tool_name, approval_args))
except Exception as e:
logging.warning(f"Approval request failed: {e}", exc_info=True)
# Fail closed - do not fall back to console
raise PermissionError(f"Approval request failed for {tool_name}: {e}") from e
if not decision.approved:
raise PermissionError(f"Execution of {tool_name} denied: {decision.reason}")
mark_approved(tool_name)
kwargs.update(decision.modified_args)
approval_args.update(decision.modified_args)
mark_approved(tool_name, approval_args)
return func(*args, **kwargs)
@wraps(func)
async def async_wrapper(*args, **kwargs):
if is_already_approved(tool_name):
approval_args = _bind_call_args(func, args, kwargs)
if is_already_approved(tool_name, approval_args):
return await func(*args, **kwargs)
if is_yaml_approved(tool_name):
mark_approved(tool_name)
mark_approved(tool_name, approval_args)
return await func(*args, **kwargs)
if is_env_auto_approve():
mark_approved(tool_name)
mark_approved(tool_name, approval_args)
return await func(*args, **kwargs)
decision = await request_approval(tool_name, kwargs)
decision = await request_approval(tool_name, approval_args)
if not decision.approved:
raise PermissionError(f"Execution of {tool_name} denied: {decision.reason}")
mark_approved(tool_name)
kwargs.update(decision.modified_args)
approval_args.update(decision.modified_args)
mark_approved(tool_name, approval_args)
return await func(*args, **kwargs)
if asyncio.iscoroutinefunction(func):
@@ -18,8 +18,6 @@ from __future__ import annotations
import asyncio
import contextvars
import hashlib
import json
from praisonaiagents._logging import get_logger
import os
from typing import Dict, List, Optional, Set
@@ -225,20 +223,35 @@ class ApprovalRegistry:
# ── Context helpers ──────────────────────────────────────────────────
@staticmethod
def _approval_cache_key(tool_name: str, arguments: Dict) -> str:
payload = json.dumps(arguments or {}, sort_keys=True, default=str)
digest = hashlib.sha256(payload.encode()).hexdigest()[:16]
return f"{tool_name}:{digest}"
def _approval_cache_key(
tool_name: str, arguments: Dict, agent_name: Optional[str] = None
) -> str:
# Scope the key to the requesting agent so one agent's approval never
# silently pre-authorizes an identical call from a different, stricter
# agent in the same context. ``*`` is the sentinel for calls made
# outside any Agent (e.g. bare module-level tool calls).
from .utils import hash_tool_args
return f"{agent_name or '*'}:{tool_name}:{hash_tool_args(arguments)}"
def mark_approved(self, tool_name: str, arguments: Optional[Dict] = None) -> None:
def mark_approved(
self,
tool_name: str,
arguments: Optional[Dict] = None,
agent_name: Optional[str] = None,
) -> None:
approved = self._approved_context.get(set())
approved.add(self._approval_cache_key(tool_name, arguments or {}))
approved.add(self._approval_cache_key(tool_name, arguments or {}, agent_name))
self._approved_context.set(approved)
def is_already_approved(self, tool_name: str, arguments: Optional[Dict] = None) -> bool:
def is_already_approved(
self,
tool_name: str,
arguments: Optional[Dict] = None,
agent_name: Optional[str] = None,
) -> bool:
# Honour an explicit mark_approved() from the agent approval path even
# for critical tools (e.g. execute_command after AutoApproveBackend).
if self._approval_cache_key(tool_name, arguments or {}) in self._approved_context.get(set()):
if self._approval_cache_key(tool_name, arguments or {}, agent_name) in self._approved_context.get(set()):
return True
return False
@@ -401,27 +414,27 @@ class ApprovalRegistry:
return ApprovalDecision(approved=True, reason="No approval required")
# Already approved in this context
if self.is_already_approved(tool_name, arguments):
if self.is_already_approved(tool_name, arguments, agent_name):
return ApprovalDecision(approved=True, reason="Already approved in context")
# "This session" scoped grant covers matching calls for the run
if self._is_session_scoped(agent_name, tool_name, arguments):
self.mark_approved(tool_name, arguments)
self.mark_approved(tool_name, arguments, agent_name)
return ApprovalDecision(approved=True, reason="Approved (session)", approver="session")
# Check per-tool auto-approval (G-A fix)
if self.is_auto_approved(tool_name, agent_name):
self.mark_approved(tool_name, arguments)
self.mark_approved(tool_name, arguments, agent_name)
return ApprovalDecision(approved=True, reason="Auto-approved (skill)", approver="skill")
# Env auto-approve
if self.is_env_auto_approve():
self.mark_approved(tool_name, arguments)
self.mark_approved(tool_name, arguments, agent_name)
return ApprovalDecision(approved=True, reason="Auto-approved (env)", approver="env")
# YAML auto-approve
if self.is_yaml_approved(tool_name):
self.mark_approved(tool_name, arguments)
self.mark_approved(tool_name, arguments, agent_name)
return ApprovalDecision(approved=True, reason="Auto-approved (yaml)", approver="yaml")
# Delegate to backend
@@ -445,7 +458,7 @@ class ApprovalRegistry:
)
if decision.approved:
self.mark_approved(tool_name, arguments)
self.mark_approved(tool_name, arguments, agent_name)
self._persist_scoped_decision(agent_name, tool_name, arguments, decision)
return decision
@@ -465,25 +478,25 @@ class ApprovalRegistry:
if not force and not self.is_required(tool_name, agent_name):
return ApprovalDecision(approved=True, reason="No approval required")
if self.is_already_approved(tool_name, arguments):
if self.is_already_approved(tool_name, arguments, agent_name):
return ApprovalDecision(approved=True, reason="Already approved in context")
# "This session" scoped grant covers matching calls for the run
if self._is_session_scoped(agent_name, tool_name, arguments):
self.mark_approved(tool_name, arguments)
self.mark_approved(tool_name, arguments, agent_name)
return ApprovalDecision(approved=True, reason="Approved (session)", approver="session")
# Check per-tool auto-approval (G-A fix)
if self.is_auto_approved(tool_name, agent_name):
self.mark_approved(tool_name, arguments)
self.mark_approved(tool_name, arguments, agent_name)
return ApprovalDecision(approved=True, reason="Auto-approved (skill)", approver="skill")
if self.is_env_auto_approve():
self.mark_approved(tool_name, arguments)
self.mark_approved(tool_name, arguments, agent_name)
return ApprovalDecision(approved=True, reason="Auto-approved (env)", approver="env")
if self.is_yaml_approved(tool_name):
self.mark_approved(tool_name, arguments)
self.mark_approved(tool_name, arguments, agent_name)
return ApprovalDecision(approved=True, reason="Auto-approved (yaml)", approver="yaml")
backend = self.get_backend(agent_name)
@@ -503,6 +516,6 @@ class ApprovalRegistry:
decision = ApprovalDecision(approved=False, reason="Approval timed out")
if decision.approved:
self.mark_approved(tool_name, arguments)
self.mark_approved(tool_name, arguments, agent_name)
self._persist_scoped_decision(agent_name, tool_name, arguments, decision)
return decision
@@ -7,11 +7,31 @@ across the approval system.
import asyncio
import concurrent.futures
import hashlib
import json
from typing import Any, Awaitable, Callable, Dict, Optional, TypeVar
T = TypeVar('T')
def hash_tool_args(arguments: Optional[Dict[str, Any]]) -> str:
"""Return the canonical 16-char identity hash of a tool call's arguments.
Produces a 16-character SHA-256 digest over the canonical JSON encoding
(``sort_keys=True, default=str``) of the arguments. This is the single
source of truth for the tool-call identity key shared by approval
de-duplication (``ApprovalRegistry``) and doom-loop detection, so the two
safety subsystems cannot silently diverge.
Falls back to ``"unhashable"`` if the arguments cannot be serialised.
"""
try:
payload = json.dumps(arguments or {}, sort_keys=True, default=str)
return hashlib.sha256(payload.encode()).hexdigest()[:16]
except (TypeError, ValueError):
return "unhashable"
# Tool names that map to a shell-command permission target (``bash:<command>``)
# so the reusable command-prefix machinery in ``PermissionManager`` applies.
_SHELL_TOOLS = frozenset({
@@ -56,6 +76,14 @@ def build_permission_target(
* file tools -> ``<edit|write|delete|…>:<path>``
* everything else -> ``tool:<tool_name>``
The command identity is preserved verbatim so command-specific rules
(e.g. ``deny: bash:rm *``) still match. The out-of-workspace boundary is
enforced downstream by :class:`~praisonaiagents.permissions.PermissionManager`
(its ``external_dir:`` gate), which decomposes the ``bash:<command>`` target
and gates any escaping path — so a broad ``bash:*`` / "allow shell" /
session grant cannot silently authorise out-of-workspace access while a
command-specific ``deny`` still fires.
Falls back to ``tool:<tool_name>`` whenever the expected argument is missing
so a target is always produced.
@@ -40,6 +40,8 @@ __all__ = [
"TaskStatus",
# Configuration
"BackgroundConfig",
# Shared runner accessor
"get_background_runner",
]
@@ -61,4 +63,8 @@ def __getattr__(name: str):
from .config import BackgroundConfig
return BackgroundConfig
if name == "get_background_runner":
from .runner import get_background_runner
return get_background_runner
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -362,6 +362,24 @@ class BackgroundRunner:
# Block briefly to get the BackgroundTask object (not the task result).
return future.result(timeout=5)
def cancel_task_sync(self, task_id: str) -> bool:
"""Cancel a task from synchronous code (or an unrelated event loop).
Schedules :meth:`cancel_task` on the shared background loop where the
task's future lives, so the underlying execution is actually stopped
(not merely marked cancelled). Safe to call from within a running
event loop because it hops threads via ``run_coroutine_threadsafe``.
Args:
task_id: ID of task to cancel
Returns:
True if cancelled, False if not found or already completed
"""
loop = _get_bg_loop()
future = asyncio.run_coroutine_threadsafe(self.cancel_task(task_id), loop)
return future.result(timeout=5)
def submit_agent_sync(
self,
agent: Any,
@@ -451,3 +469,25 @@ def _shutdown_bg_loop():
# Register cleanup on process exit
import atexit
atexit.register(_shutdown_bg_loop)
# ── Shared process-wide runner ──────────────────────────────────────────
_shared_runner: Optional["BackgroundRunner"] = None
_shared_runner_lock = _threading.Lock()
def get_background_runner() -> "BackgroundRunner":
"""Return the process-wide shared :class:`BackgroundRunner`.
Interactive surfaces (REPL/TUI) and bots submit and inspect background
tasks through this single instance so ``/tasks`` sees the same tasks
regardless of which surface submitted them. Created lazily on first use.
"""
global _shared_runner
if _shared_runner is not None:
return _shared_runner
with _shared_runner_lock:
if _shared_runner is None:
_shared_runner = BackgroundRunner()
return _shared_runner
@@ -27,6 +27,7 @@ from .protocols import (
EmailProtocol,
EmailInbox,
SupportsPresentation,
PresentationRendererProtocol,
PlatformCapabilities,
ChannelField,
ChannelDescriptor,
@@ -52,6 +53,12 @@ from .presentation import (
ButtonStyle,
BlockType,
adapt_presentation,
adapt_presentation_with_report,
DegradedDelivery,
table_to_markdown,
chart_to_text,
register_presentation_renderer,
get_presentation_renderer,
)
from .interactive import (
InteractiveContext,
@@ -74,13 +81,33 @@ from .agent_reply import (
extract_completion,
append_completion_note,
)
from .format import (
format_for_dialect,
escape_markdown_v2,
markdown_to_slack,
strip_markdown,
)
from .webhook_filter import (
WebhookFilter,
evaluate_webhook_filter,
resolve_field,
)
from .config import BotConfig, BotOSConfig, DisplayPolicy, resolve_display_policy
from .silence import (
SILENT_REPLY_TOKEN,
is_intentional_silence_response,
classify_final,
BotLoopPolicy,
BotLoopGuard,
)
from .failure import (
FailureReply,
render_failure_reply,
)
from .admission import (
IngressDecision,
resolve_ingress_admission,
)
from .run_status import (
RunPhase,
RunStatusController,
@@ -119,10 +146,20 @@ __all__ = [
"ButtonStyle",
"BlockType",
"adapt_presentation",
"adapt_presentation_with_report",
"DegradedDelivery",
"table_to_markdown",
"chart_to_text",
"PresentationRendererProtocol",
"register_presentation_renderer",
"get_presentation_renderer",
"PlatformCapabilities",
"ChannelField",
"ChannelDescriptor",
"WebhookVerifierProtocol",
"WebhookFilter",
"evaluate_webhook_filter",
"resolve_field",
"CallbackPayloadStoreProtocol",
"InMemoryCallbackPayloadStore",
"GatewayRuntimeSeams",
@@ -147,10 +184,19 @@ __all__ = [
"extract_presentation",
"extract_completion",
"append_completion_note",
"format_for_dialect",
"escape_markdown_v2",
"markdown_to_slack",
"strip_markdown",
"SILENT_REPLY_TOKEN",
"is_intentional_silence_response",
"classify_final",
"BotLoopPolicy",
"BotLoopGuard",
"FailureReply",
"render_failure_reply",
"IngressDecision",
"resolve_ingress_admission",
"RunPhase",
"RunStatusController",
"StallState",
@@ -0,0 +1,220 @@
"""
Inbound admission primitive for bot gateways.
Provides a single, pure, dependency-free decision for "should the bot act on
this inbound message?" — channel/user allowlist, block-list, group policy
(``respond_all`` / ``mention_only`` / ``command_only`` / ``observe``) and
pairing for unknown users — returning a typed verdict with a machine-readable
reason code an operator can inspect.
Like :func:`praisonaiagents.bots.protocols.evaluate_channel_health` and
:class:`praisonaiagents.bots.silence.BotLoopGuard`, this is a zero-dependency
decision primitive: every channel adapter (built-in or plugin) feeds it native
message facts and acts on the verdict, so the admission decision cannot drift
across transports and a drop is no longer a silent ``logger.debug`` line but a
recorded ``reason_code``.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterable, Optional
# Machine-readable reason codes. Kept as module constants so callers can record
# and compare them without re-typing string literals (which is exactly how the
# per-channel logic drifted).
REASON_ALLOWED = "allowed"
REASON_NOT_IN_ALLOWLIST = "not_in_allowlist"
REASON_BLOCKED = "blocked"
REASON_GROUP_MENTION_ONLY = "group_mention_only"
REASON_OBSERVE = "observe"
REASON_PAIRING_REQUIRED = "pairing_required"
REASON_COMMAND_ONLY = "command_only"
# Gate names — which stage of the ladder produced the verdict.
GATE_BLOCKLIST = "blocklist"
GATE_ALLOWLIST = "allowlist"
GATE_PAIRING = "pairing"
GATE_GROUP_POLICY = "group_policy"
GATE_DIRECT = "direct"
# Group policies, matching the existing YAML ``group_policy`` values.
_GROUP_POLICIES = frozenset(
{"respond_all", "mention_only", "command_only", "observe"}
)
# Chat types treated as one-to-one (no group policy applies).
_DIRECT_CHAT_TYPES = frozenset({"dm", "private", "direct", "im"})
@dataclass(frozen=True)
class IngressDecision:
"""Typed verdict for an inbound admission decision.
Attributes:
admit: Whether the bot should run the agent on this message. ``False``
means the message is dropped (or only passively observed).
reason_code: A machine-readable reason (one of the ``REASON_*``
constants) an operator can inspect to answer "why didn't the bot
reply to that message?".
gate: Which gate decided (one of the ``GATE_*`` constants), for
diagnostics.
observe: When ``True`` the message was not admitted for a run but SHOULD
be recorded into the session transcript as passive context (the
``observe`` group policy). Adapters record it and skip the run.
"""
admit: bool
reason_code: str
gate: str
observe: bool = False
def _contains(collection: Optional[Iterable[str]], value: Optional[str]) -> bool:
"""Membership test tolerant of ``None`` collection/value.
An empty or ``None`` collection means "no restriction configured" and is
handled by the caller — this only answers whether ``value`` is present.
"""
if not collection or value is None:
return False
return value in collection
def resolve_ingress_admission(
*,
chat_type: Optional[str],
sender_id: Optional[str],
is_mention: bool = False,
is_command: bool = False,
allowlist: Optional[Iterable[str]] = None,
blocklist: Optional[Iterable[str]] = None,
group_policy: Optional[str] = None,
paired: bool = True,
) -> IngressDecision:
"""Decide whether the bot should act on an inbound message.
Pure, deterministic and zero-dependency: the same inputs always yield the
same verdict, so the decision cannot drift across transports or plugin
channels. Every adapter replaces its bespoke ladder with one call and
records ``decision.reason_code``.
The ladder (first matching gate wins):
1. **Block-list** — an explicitly blocked sender is always dropped
(``blocked``), taking precedence over any allowlist entry.
2. **Allow-list** — when an ``allowlist`` is configured and the sender is
not in it, drop (``not_in_allowlist``). An empty/``None`` allowlist means
"no allowlist restriction".
3. **Pairing** — an unknown but not-yet-``paired`` sender that passed the
allowlist gate requires pairing (``pairing_required``).
4. **Group policy** — for group/channel chats only:
- ``command_only``: admit commands, else drop (``command_only``);
- ``mention_only``: admit mentions/commands, else drop
(``group_mention_only``);
- ``observe``: admit mentions/commands, else *observe* (recorded, no
run) with reason ``observe``;
- ``respond_all``: admit everything;
- unset/unknown: treated as ``mention_only`` (the live
``BotConfig.group_policy`` default), so an adapter forwarding an
unset policy does not silently reply to all group traffic.
Direct (DM/private) chats skip the group-policy gate entirely.
Args:
chat_type: Native chat type (e.g. ``"dm"``, ``"private"``, ``"group"``,
``"channel"``, ``"supergroup"``). Anything not in the direct set is
treated as a group for policy purposes.
sender_id: Stable sender identifier used for allow/block-list checks.
is_mention: Whether the bot was mentioned/@-addressed in the message.
is_command: Whether the message is a bot command (always allowed under
``mention_only`` / ``command_only`` / ``observe``).
allowlist: Optional collection of allowed sender ids to *enforce*.
Empty/``None`` disables the allowlist gate (no restriction). This is
the already-resolved list — an adapter that wants an empty allowlist
to mean "deny unknown" (``BotConfig.is_explicitly_allowed`` +
``unknown_user_policy``) resolves that upstream into the ``paired``
flag rather than passing an empty ``allowlist`` here.
blocklist: Optional collection of blocked sender ids.
group_policy: One of ``respond_all`` / ``mention_only`` /
``command_only`` / ``observe``. ``None`` or unknown values behave as
``mention_only`` — the live ``BotConfig.group_policy`` default — so
an unset policy fails safe (mention-gated) rather than open.
paired: Whether the sender is already paired/known. ``True`` (the
default) means pairing is not required.
Returns:
An :class:`IngressDecision` with the verdict, a machine-readable
``reason_code``, the deciding ``gate`` and the ``observe`` flag.
"""
# 1. Block-list wins over everything: an explicitly blocked sender never
# reaches an agent run regardless of allowlist membership.
if _contains(blocklist, sender_id):
return IngressDecision(
admit=False, reason_code=REASON_BLOCKED, gate=GATE_BLOCKLIST
)
# 2. Allow-list: only enforced when configured. Absent/empty => no restriction.
if allowlist and not _contains(allowlist, sender_id):
return IngressDecision(
admit=False,
reason_code=REASON_NOT_IN_ALLOWLIST,
gate=GATE_ALLOWLIST,
)
# 3. Pairing: an allowed-but-unknown sender must complete pairing first.
if not paired:
return IngressDecision(
admit=False,
reason_code=REASON_PAIRING_REQUIRED,
gate=GATE_PAIRING,
)
# Direct chats bypass group policy entirely.
is_direct = (chat_type or "").lower() in _DIRECT_CHAT_TYPES
if is_direct:
return IngressDecision(
admit=True, reason_code=REASON_ALLOWED, gate=GATE_DIRECT
)
# 4. Group policy for group/channel chats. Unknown/None => mention_only,
# matching the live ``BotConfig.group_policy`` default so an adapter that
# forwards an unset policy does not silently start replying to all group
# traffic (a security-relevant regression).
policy = (group_policy or "mention_only").lower()
if policy == "command_only":
if is_command:
return IngressDecision(
admit=True, reason_code=REASON_ALLOWED, gate=GATE_GROUP_POLICY
)
return IngressDecision(
admit=False,
reason_code=REASON_COMMAND_ONLY,
gate=GATE_GROUP_POLICY,
)
if policy in ("mention_only", "observe"):
if is_mention or is_command:
return IngressDecision(
admit=True, reason_code=REASON_ALLOWED, gate=GATE_GROUP_POLICY
)
if policy == "observe":
# Recorded as passive context (no run) so the bot has memory of the
# conversation when next addressed.
return IngressDecision(
admit=False,
reason_code=REASON_OBSERVE,
gate=GATE_GROUP_POLICY,
observe=True,
)
return IngressDecision(
admit=False,
reason_code=REASON_GROUP_MENTION_ONLY,
gate=GATE_GROUP_POLICY,
)
# respond_all (or any unknown policy): admit everything.
return IngressDecision(
admit=True, reason_code=REASON_ALLOWED, gate=GATE_GROUP_POLICY
)
@@ -278,8 +278,26 @@ class BasePlatformAdapter(ABC):
# ------------------------------------------------------------------ #
def format_message(self, text: str) -> str:
"""Apply per-platform formatting. Default: identity."""
return text
"""Render *text* for the platform's declared ``markdown_dialect``.
This is the seam that finally *consumes* the ``markdown_dialect``
capability every adapter advertises: the default keys off
``capabilities.markdown_dialect`` and returns text rendered in that
flavour (e.g. MarkdownV2-escaped for Telegram, Slack ``mrkdwn`` for
Slack) so replies render correctly and are never dropped by a transport
that rejects unescaped specials. The default dialect ``"markdown"``
yields a safe plain-text reduction, so existing adapters are unaffected.
Adapters that need the transport ``parse_mode`` (e.g. Telegram's
``"MarkdownV2"``) can call :func:`~praisonaiagents.bots.format_for_dialect`
directly in their send path; override this method to change formatting.
"""
from .format import format_for_dialect
rendered, _parse_mode = format_for_dialect(
text, self._cap("markdown_dialect", "markdown")
)
return rendered
def chunk(self, text: str) -> List[str]:
"""Split *text* to respect the platform max length.
@@ -0,0 +1,286 @@
"""
Visible failure-outcome primitive for bot gateways.
:func:`classify_final` (``bots/silence.py``) guarantees a visible outcome for a
*blank* turn, but says nothing about a turn that **failed** (expired auth,
missing key, rate limit, timeout, budget/doom-loop) — where the user today sees
a generic ``Error: <msg>`` or a silent downgrade. A rich error taxonomy already
exists in :mod:`praisonaiagents.errors` and :mod:`praisonaiagents.run_outcome`,
but nothing maps it to a *user-facing, actionable* reply, so every channel
adapter re-decides it ad hoc (the drift this closes).
This module is the failure-path counterpart of ``classify_final`` /
``resolve_ingress_admission``: a single, pure, dependency-free decision that maps
an :class:`~praisonaiagents.run_outcome.AgentRunOutcome` or a
:class:`~praisonaiagents.errors.PraisonAIError` to a typed :class:`FailureReply`
with next-step copy keyed by failure class, reusing the existing
``remediation_hint``. Every adapter renders ``reply.text`` and records
``reply.reason_code`` instead of hand-rolling ``Error: …`` strings.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Optional
if TYPE_CHECKING: # pragma: no cover - typing only, avoids import cost/cycles
from ..errors import PraisonAIError
from ..run_outcome import AgentRunOutcome
# Machine-readable failure reason codes. Module constants (like the ``REASON_*``
# codes in ``admission.py``) so callers record/compare without re-typing string
# literals — exactly the drift that produced the per-adapter ``Error: …`` copy.
REASON_AUTH_EXPIRED = "auth_expired"
REASON_AUTH_PERMANENT = "auth_permanent"
REASON_MISSING_KEY = "missing_key"
REASON_RATE_LIMIT = "rate_limit"
REASON_OVERLOADED = "overloaded"
REASON_TIMEOUT = "timeout"
REASON_BUDGET_EXHAUSTED = "budget_exhausted"
REASON_DOOM_LOOP = "doom_loop"
REASON_NEEDS_HELP = "needs_help"
REASON_CANCELLED = "cancelled"
REASON_CONTEXT_OVERFLOW = "context_overflow"
REASON_MODEL_NOT_FOUND = "model_not_found"
REASON_FORMAT_ERROR = "format_error"
REASON_UNKNOWN = "unknown"
# Static, user-facing next-step copy keyed by failure class. Kept deterministic
# and channel-agnostic so the same guidance appears on every transport. A
# concrete ``remediation_hint`` (e.g. from ``PraisonAIConfigError``) always wins
# over these defaults when present.
_REASON_TEXT = {
REASON_AUTH_EXPIRED: (
"I couldn't complete that - the model credential has expired. "
"Run `praisonai onboard` to re-authenticate, then resend."
),
REASON_AUTH_PERMANENT: (
"I couldn't complete that - the model credential was rejected. "
"Check the API key/permissions, then run `praisonai onboard` and resend."
),
REASON_MISSING_KEY: (
"I couldn't complete that - a required credential is missing. "
"Run `praisonai onboard` (or `praisonai doctor`) to set it up, then resend."
),
REASON_RATE_LIMIT: (
"I couldn't complete that - the provider is rate-limiting requests. "
"Please wait a moment and resend."
),
REASON_OVERLOADED: (
"I couldn't complete that - the provider is temporarily overloaded. "
"Please wait a moment and resend."
),
REASON_TIMEOUT: (
"I couldn't complete that in time - the request timed out. "
"Please resend, or try a shorter request."
),
REASON_BUDGET_EXHAUSTED: (
"I stopped before finishing - this turn hit its budget limit. "
"Raise the budget or narrow the task, then resend."
),
REASON_DOOM_LOOP: (
"I stopped before finishing - I detected a repeating loop and halted to "
"avoid wasting effort. Try rephrasing the request, then resend."
),
REASON_NEEDS_HELP: (
"I need a bit more to continue - please clarify or provide the missing "
"detail, then resend."
),
REASON_CANCELLED: "That request was cancelled before it finished.",
REASON_CONTEXT_OVERFLOW: (
"I couldn't complete that - the conversation is too long for the model. "
"Start a fresh thread or shorten the request, then resend."
),
REASON_MODEL_NOT_FOUND: (
"I couldn't complete that - the configured model is unavailable. "
"Check the model name in your config, then resend."
),
REASON_FORMAT_ERROR: (
"I couldn't complete that - the request or configuration was invalid. "
"Please check the input and resend."
),
REASON_UNKNOWN: "I couldn't complete that due to an unexpected error. Please resend.",
}
# Which failure classes are worth retrying without user intervention. Mirrors the
# taxonomy in ``errors.AgentErrorKind`` / ``AgentRunOutcome.is_retryable`` so the
# adapter can decide whether to offer a "retry" affordance.
_RETRYABLE_REASONS = frozenset(
{REASON_RATE_LIMIT, REASON_OVERLOADED, REASON_TIMEOUT}
)
# Map the closed ``AgentErrorKind`` taxonomy to a user-facing reason code. Auth
# is split into expired vs permanent so re-authentication guidance is precise.
_ERROR_KIND_TO_REASON = {
"auth": REASON_AUTH_EXPIRED,
"auth_permanent": REASON_AUTH_PERMANENT,
"rate_limit": REASON_RATE_LIMIT,
"overloaded": REASON_OVERLOADED,
"context_overflow": REASON_CONTEXT_OVERFLOW,
"idle_timeout": REASON_TIMEOUT,
"billing": REASON_BUDGET_EXHAUSTED,
"model_not_found": REASON_MODEL_NOT_FOUND,
"format_error": REASON_FORMAT_ERROR,
"validation": REASON_FORMAT_ERROR,
"unknown": REASON_UNKNOWN,
}
# Map an ``AgentRunOutcome`` termination context / status to a reason code.
_TERMINATION_TO_REASON = {
"budget_exhausted": REASON_BUDGET_EXHAUSTED,
"doom_loop": REASON_DOOM_LOOP,
"needs_help": REASON_NEEDS_HELP,
"timeout": REASON_TIMEOUT,
"cancelled": REASON_CANCELLED,
"interrupted": REASON_CANCELLED,
}
@dataclass(frozen=True)
class FailureReply:
"""A typed, user-facing reply for a failed turn.
Attributes:
text: The visible, actionable message to deliver to the user. Includes
next-step copy keyed by the failure class (how to re-authenticate,
run onboarding, wait out a rate limit, etc.).
reason_code: A machine-readable failure class (one of the ``REASON_*``
constants) an operator/model can inspect and record.
retryable: Whether the failure is worth retrying without user
intervention (rate limit, overload, transient timeout).
"""
text: str
reason_code: str
retryable: bool
def _reason_from_error(error: "PraisonAIError") -> str:
"""Derive a reason code from a :class:`PraisonAIError`.
A missing/blank config key surfaces as ``missing_key`` (distinct from an
expired credential) so the onboarding vs re-auth guidance stays precise.
"""
category = getattr(error, "error_category", None) or "unknown"
# A config error carrying a config_key is a *missing* credential/setting,
# not a rejected one — steer the user to onboarding rather than re-auth.
if getattr(error, "config_key", None) and category == "format_error":
return REASON_MISSING_KEY
return _ERROR_KIND_TO_REASON.get(category, REASON_UNKNOWN)
def _reason_from_outcome(outcome: "AgentRunOutcome") -> str:
"""Derive a reason code from an :class:`AgentRunOutcome`.
Prefers a specific ``termination_reason`` recorded in the outcome context
(budget/doom-loop/needs-help) before falling back to the error category and
finally the coarse status.
"""
context = getattr(outcome, "context", None) or {}
termination = context.get("termination_reason") or context.get("termination")
if termination is not None:
key = getattr(termination, "value", termination)
mapped = _TERMINATION_TO_REASON.get(str(key))
if mapped is not None:
return mapped
category = getattr(outcome, "error_category", None)
if category:
mapped = _ERROR_KIND_TO_REASON.get(category)
if mapped is not None:
return mapped
status = getattr(outcome, "status", None)
if status == "timeout":
return REASON_TIMEOUT
if status == "cancelled":
return REASON_CANCELLED
if status == "invalid_output":
return REASON_FORMAT_ERROR
return REASON_UNKNOWN
def render_failure_reply(outcome: Any) -> FailureReply:
"""Map a run outcome or error to a visible, actionable :class:`FailureReply`.
This is the failure-path counterpart of
:func:`praisonaiagents.bots.silence.classify_final`: a single decision point
so no adapter re-decides how a failure looks. It accepts either an
:class:`~praisonaiagents.run_outcome.AgentRunOutcome` or a
:class:`~praisonaiagents.errors.PraisonAIError` (duck-typed to avoid an
import dependency), reuses the existing ``remediation_hint`` when present,
and otherwise selects deterministic next-step copy keyed by the failure
class.
Args:
outcome: An ``AgentRunOutcome``, a ``PraisonAIError`` (or subclass), or
any object exposing ``error_category`` / ``status``. A plain string
or unknown object degrades to a generic (but still visible)
``unknown`` reply rather than raising.
Returns:
A :class:`FailureReply` with actionable ``text``, a machine-readable
``reason_code`` and a ``retryable`` flag.
"""
# Duck-type the two supported inputs without importing them (keeps this a
# zero-dependency leaf like the other bot primitives).
is_error = isinstance(outcome, BaseException)
# Preserve an affirmative retryability signal from the source outcome (e.g.
# AgentRunOutcome.is_retryable() is True for invalid_output) so a
# reason-code that is not in the static ``_RETRYABLE_REASONS`` default does
# not silently drop the retry affordance the outcome already promised.
source_retryable = False
if is_error:
reason = _reason_from_error(outcome) # type: ignore[arg-type]
remediation = getattr(outcome, "remediation_hint", None)
elif hasattr(outcome, "status") or hasattr(outcome, "error_category"):
reason = _reason_from_outcome(outcome)
context = getattr(outcome, "context", None) or {}
remediation = context.get("remediation_hint")
is_retryable = getattr(outcome, "is_retryable", None)
if callable(is_retryable):
try:
source_retryable = bool(is_retryable())
except Exception:
source_retryable = False
else:
reason = REASON_UNKNOWN
remediation = None
text: Optional[str] = None
if isinstance(remediation, str) and remediation.strip():
# A concrete remediation hint (e.g. from PraisonAIConfigError) is the
# most precise guidance available — prefer it over the static default.
text = f"I couldn't complete that. {remediation.strip()}"
if text is None:
text = _REASON_TEXT.get(reason, _REASON_TEXT[REASON_UNKNOWN])
return FailureReply(
text=text,
reason_code=reason,
retryable=source_retryable or reason in _RETRYABLE_REASONS,
)
__all__ = [
"FailureReply",
"render_failure_reply",
"REASON_AUTH_EXPIRED",
"REASON_AUTH_PERMANENT",
"REASON_MISSING_KEY",
"REASON_RATE_LIMIT",
"REASON_OVERLOADED",
"REASON_TIMEOUT",
"REASON_BUDGET_EXHAUSTED",
"REASON_DOOM_LOOP",
"REASON_NEEDS_HELP",
"REASON_CANCELLED",
"REASON_CONTEXT_OVERFLOW",
"REASON_MODEL_NOT_FOUND",
"REASON_FORMAT_ERROR",
"REASON_UNKNOWN",
]
@@ -0,0 +1,137 @@
"""
Markdown dialect conversion for agent replies.
A pure, dependency-free (stdlib-only) default that finally *consumes* the
``markdown_dialect`` capability every platform adapter already declares
(:class:`~praisonaiagents.bots.protocols.PlatformCapabilities`). It turns an
agent's ordinary markdown reply into the flavour the target platform speaks so
formatting renders correctly and a reply is never dropped by a transport that
rejects unescaped special characters (e.g. Telegram's ``400 can't parse
entities``).
The single entry point is :func:`format_for_dialect`, which returns the
``(rendered_text, parse_mode)`` pair a send path needs::
text, mode = format_for_dialect(agent_text, caps.markdown_dialect)
await bot.send_message(chat_id, text, parse_mode=mode)
Supported dialects:
* ``"telegram_markdown_v2"`` -> MarkdownV2-escaped text, ``parse_mode="MarkdownV2"``
* ``"slack"`` -> Slack mrkdwn text, ``parse_mode=None``
* ``"discord_markdown"`` -> passthrough (Discord speaks CommonMark),
``parse_mode=None``
* ``"markdown"``/unknown -> safe plain text, ``parse_mode=None``
This lives in core because it is the light default for a core protocol seam:
pure string work, no heavy imports, and driven by the capability contract each
adapter already advertises. Adapters call it from their send paths.
"""
from __future__ import annotations
import re
from typing import Optional, Tuple
__all__ = [
"format_for_dialect",
"escape_markdown_v2",
"markdown_to_slack",
"strip_markdown",
]
# Characters Telegram MarkdownV2 requires escaping outside entities.
# See https://core.telegram.org/bots/api#markdownv2-style
_MDV2_SPECIAL = r"_*[]()~`>#+-=|{}.!\\"
_MDV2_ESCAPE_RE = re.compile("([" + re.escape(_MDV2_SPECIAL) + "])")
def escape_markdown_v2(text: str) -> str:
"""Escape every Telegram MarkdownV2 special character in ``text``.
This is the conservative, always-safe escape: it treats the input as plain
text and backslash-escapes each reserved character so Telegram accepts the
message verbatim without a ``can't parse entities`` error. Existing markup
(``**bold**``, ``[links](url)``) is shown literally rather than reinterpreted;
correctness (never dropping a reply) is preferred over best-effort styling.
"""
if not text:
return ""
return _MDV2_ESCAPE_RE.sub(r"\\\1", text)
# Inline markdown -> Slack mrkdwn conversions (order matters: bold before italic).
_MD_BOLD_RE = re.compile(r"\*\*(.+?)\*\*", re.DOTALL)
_MD_LINK_RE = re.compile(r"\[([^\]]+)\]\((https?://[^\s)]+)\)")
_MD_HEADING_RE = re.compile(r"^\s{0,3}#{1,6}\s+(.*)$", re.MULTILINE)
def markdown_to_slack(text: str) -> str:
"""Convert common markdown to Slack ``mrkdwn``.
Slack uses ``*bold*`` (single asterisk), ``_italic_``, ``<url|label>`` links
and has no ``#`` headings. This maps the frequent cases and otherwise leaves
text untouched, so a plain reply passes through unchanged.
"""
if not text:
return ""
# Links: [label](url) -> <url|label>
text = _MD_LINK_RE.sub(lambda m: f"<{m.group(2)}|{m.group(1)}>", text)
# Bold: **x** -> *x* (do before single-asterisk handling is unnecessary here)
text = _MD_BOLD_RE.sub(lambda m: f"*{m.group(1)}*", text)
# Headings: "# Title" -> "*Title*" (Slack has no headings)
text = _MD_HEADING_RE.sub(lambda m: f"*{m.group(1).strip()}*", text)
return text
# Markdown constructs to drop when falling back to plain text.
_STRIP_LINK_RE = re.compile(r"\[([^\]]+)\]\((?:https?://[^\s)]+)\)")
_STRIP_HEADING_RE = re.compile(r"^\s{0,3}#{1,6}\s+", re.MULTILINE)
# Only unwrap *paired* emphasis/code delimiters so literal characters
# (e.g. ``svc_1``, ``*.py``, ``a*b``) are preserved rather than deleted.
_STRIP_BOLD_RE = re.compile(r"(\*\*|__)(?=\S)(.+?)(?<=\S)\1", re.DOTALL)
_STRIP_ITALIC_RE = re.compile(r"(?<![*_\w])([*_])(?=\S)(.+?)(?<=\S)\1(?![*_\w])")
_STRIP_CODE_RE = re.compile(r"`([^`]+)`")
def strip_markdown(text: str) -> str:
"""Reduce markdown to readable plain text (safe fallback).
Unwraps *paired* emphasis/code spans and heading hashes and unwraps links
to their label, so a reply reads cleanly on a platform whose dialect we
don't specifically target. Unpaired or literal delimiters (``svc_1``,
``*.py``, ``a*b``) are left untouched so identifiers and code are never
corrupted.
"""
if not text:
return ""
text = _STRIP_LINK_RE.sub(r"\1", text)
text = _STRIP_HEADING_RE.sub("", text)
text = _STRIP_CODE_RE.sub(r"\1", text)
text = _STRIP_BOLD_RE.sub(r"\2", text)
text = _STRIP_ITALIC_RE.sub(r"\2", text)
return text
def format_for_dialect(text: str, dialect: str) -> Tuple[str, Optional[str]]:
"""Render ``text`` for a platform's declared ``markdown_dialect``.
Args:
text: The agent's reply text, authored in ordinary markdown.
dialect: The platform's ``markdown_dialect`` capability value.
Returns:
``(rendered_text, parse_mode)`` — ``parse_mode`` is the value the
transport expects (e.g. ``"MarkdownV2"`` for Telegram) or ``None`` when
the text is already in the platform's native form / plain text.
"""
if text is None:
return ("", None)
if dialect == "telegram_markdown_v2":
return (escape_markdown_v2(text), "MarkdownV2")
if dialect == "slack":
return (markdown_to_slack(text), None)
if dialect == "discord_markdown":
return (text, None)
# "markdown" / unknown: safe plain-text fallback.
return (strip_markdown(text), None)
@@ -15,7 +15,16 @@ import hashlib
import time
from dataclasses import dataclass, field
from enum import Enum
from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union
from typing import (
TYPE_CHECKING,
Any,
Dict,
List,
Literal,
Optional,
Tuple,
Union,
)
if TYPE_CHECKING:
from .protocols import CallbackPayloadStoreProtocol
@@ -76,6 +85,8 @@ class BlockType(str, Enum):
SELECT = "select" # Dropdown/select menu
DIVIDER = "divider" # Visual separator
CONTEXT = "context" # Contextual info (smaller text)
TABLE = "table" # Tabular data (columns + rows)
CHART = "chart" # Chart/visualisation (kind + series)
@dataclass
@@ -245,6 +256,12 @@ class PresentationBlock:
options: Optional[List[SelectOption]] = None
placeholder: Optional[str] = None
action_id: Optional[str] = None
# Tabular data (TABLE blocks)
columns: Optional[List[str]] = None
rows: Optional[List[List[str]]] = None
# Chart data (CHART blocks)
chart_kind: Optional[str] = None
series: Optional[List[Dict[str, Any]]] = None
@staticmethod
def make_text(content: str, markdown: bool = True) -> "PresentationBlock":
@@ -280,6 +297,47 @@ class PresentationBlock:
"""Create a context block (smaller text)."""
return PresentationBlock(type=BlockType.CONTEXT, text=content)
@staticmethod
def make_table(
columns: List[str],
rows: List[List[str]],
) -> "PresentationBlock":
"""Create a table block from *columns* and *rows*.
Describe tabular data once; channels with a native table widget render
it directly, and everywhere else it degrades to a deterministic
markdown table (see :func:`adapt_presentation`).
"""
return PresentationBlock(
type=BlockType.TABLE,
columns=[str(c) for c in columns],
rows=[[str(c) for c in row] for row in rows],
)
@staticmethod
def make_chart(
chart_kind: str,
series: List[Dict[str, Any]],
text: Optional[str] = None,
) -> "PresentationBlock":
"""Create a chart block.
Args:
chart_kind: One of ``"bar"``, ``"line"``, ``"pie"``, ``"area"``.
series: A list of ``{"label": str, "points": list[float]}`` dicts.
text: Optional caption/title for the chart.
Channels with native visualisation render the series directly; elsewhere
the chart degrades to a compact text summary (see
:func:`adapt_presentation`).
"""
return PresentationBlock(
type=BlockType.CHART,
chart_kind=chart_kind,
series=series,
text=text,
)
@staticmethod
def quick_replies(
choices: List[Any],
@@ -320,6 +378,14 @@ class PresentationBlock:
data["placeholder"] = self.placeholder
if self.action_id is not None:
data["action_id"] = self.action_id
if self.columns is not None:
data["columns"] = self.columns
if self.rows is not None:
data["rows"] = self.rows
if self.chart_kind is not None:
data["chart_kind"] = self.chart_kind
if self.series is not None:
data["series"] = self.series
return data
@classmethod
@@ -338,6 +404,10 @@ class PresentationBlock:
),
placeholder=data.get("placeholder"),
action_id=data.get("action_id"),
columns=data.get("columns"),
rows=data.get("rows"),
chart_kind=data.get("chart_kind"),
series=data.get("series"),
)
@@ -484,6 +554,10 @@ class PresentationLimits:
supports_markdown: Whether channel supports markdown
supports_select: Whether channel supports select menus
supports_web_apps: Whether channel supports web apps
supports_tables: Whether channel has a native table widget
supports_charts: Whether channel has native chart/visualisation
max_table_rows: Maximum rows in a table block
max_table_cols: Maximum columns in a table block
"""
max_buttons: int = 10
@@ -495,6 +569,10 @@ class PresentationLimits:
supports_markdown: bool = True
supports_select: bool = True
supports_web_apps: bool = False
supports_tables: bool = False
supports_charts: bool = False
max_table_rows: int = 50
max_table_cols: int = 10
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
@@ -508,6 +586,10 @@ class PresentationLimits:
"supports_markdown": self.supports_markdown,
"supports_select": self.supports_select,
"supports_web_apps": self.supports_web_apps,
"supports_tables": self.supports_tables,
"supports_charts": self.supports_charts,
"max_table_rows": self.max_table_rows,
"max_table_cols": self.max_table_cols,
}
@classmethod
@@ -523,6 +605,10 @@ class PresentationLimits:
supports_markdown=data.get("supports_markdown", True),
supports_select=data.get("supports_select", True),
supports_web_apps=data.get("supports_web_apps", False),
supports_tables=data.get("supports_tables", False),
supports_charts=data.get("supports_charts", False),
max_table_rows=data.get("max_table_rows", 50),
max_table_cols=data.get("max_table_cols", 10),
)
@staticmethod
@@ -758,6 +844,80 @@ def _select_to_buttons(
return PresentationBlock(type=BlockType.BUTTONS, buttons=buttons)
def _clamp_table(
block: PresentationBlock,
limits: PresentationLimits,
) -> PresentationBlock:
"""Return a copy of a TABLE block clamped to the channel's row/column caps."""
columns = list(block.columns or [])
rows = [list(r) for r in (block.rows or [])]
if limits.max_table_cols and len(columns) > limits.max_table_cols:
columns = columns[: limits.max_table_cols]
rows = [r[: limits.max_table_cols] for r in rows]
if limits.max_table_rows and len(rows) > limits.max_table_rows:
rows = rows[: limits.max_table_rows]
return PresentationBlock(type=BlockType.TABLE, columns=columns, rows=rows)
def table_to_markdown(columns: List[str], rows: List[List[str]]) -> str:
"""Render a table as a deterministic GitHub-flavoured markdown table.
Cells are coerced to strings and pipes escaped so the table stays valid.
Short rows are padded and long rows trimmed to the header width.
"""
def _cell(value: Any) -> str:
return str(value).replace("|", "\\|").replace("\n", " ")
header = [_cell(c) for c in columns]
ncols = len(header)
lines = ["| " + " | ".join(header) + " |"]
lines.append("| " + " | ".join(["---"] * ncols) + " |")
for row in rows:
cells = [_cell(c) for c in row][:ncols]
cells += [""] * (ncols - len(cells))
lines.append("| " + " | ".join(cells) + " |")
return "\n".join(lines)
def chart_to_text(
chart_kind: Optional[str],
series: List[Dict[str, Any]],
caption: Optional[str] = None,
) -> str:
"""Render a chart as a compact, deterministic text summary.
Produces a caption/kind header followed by one line per series listing its
label and points, so the data is never silently dropped on channels without
a native visualisation.
"""
lines: List[str] = []
kind = chart_kind or "chart"
header = caption or f"{kind.capitalize()} chart"
lines.append(header)
for entry in series or []:
label = str(entry.get("label", "series"))
points = entry.get("points", []) or []
rendered = ", ".join(str(p) for p in points)
lines.append(f"{label}: {rendered}")
return "\n".join(lines)
def _table_to_text_block(block: PresentationBlock) -> PresentationBlock:
"""Degrade a TABLE block to a markdown-table TEXT block."""
return PresentationBlock(
type=BlockType.TEXT,
text=table_to_markdown(block.columns or [], block.rows or []),
)
def _chart_to_text_block(block: PresentationBlock) -> PresentationBlock:
"""Degrade a CHART block to a text-summary TEXT block."""
return PresentationBlock(
type=BlockType.TEXT,
text=chart_to_text(block.chart_kind, block.series or [], block.text),
)
def adapt_presentation(
presentation: MessagePresentation,
limits: PresentationLimits,
@@ -868,6 +1028,43 @@ def adapt_presentation(
)
continue
if block_type == BlockType.TABLE.value:
clamped = _clamp_table(block, limits)
if limits.supports_tables:
adapted_blocks.append(clamped)
else:
# Degrade to a deterministic markdown table (then clamp text).
text_block = _table_to_text_block(clamped)
if (
limits.max_text_length
and text_block.text
and len(text_block.text) > limits.max_text_length
):
text_block = PresentationBlock(
type=BlockType.TEXT,
text=text_block.text[: limits.max_text_length],
)
adapted_blocks.append(text_block)
continue
if block_type == BlockType.CHART.value:
if limits.supports_charts:
adapted_blocks.append(block)
else:
# Degrade to a compact text summary (then clamp text).
text_block = _chart_to_text_block(block)
if (
limits.max_text_length
and text_block.text
and len(text_block.text) > limits.max_text_length
):
text_block = PresentationBlock(
type=BlockType.TEXT,
text=text_block.text[: limits.max_text_length],
)
adapted_blocks.append(text_block)
continue
adapted_blocks.append(block)
return MessagePresentation(
@@ -878,3 +1075,257 @@ def adapt_presentation(
)
# Machine-readable degradation reason codes, mirroring the ``REASON_*`` style in
# ``admission.py``/``failure.py`` so a dropped control is recorded, not silent.
DEGRADE_SELECT_UNSUPPORTED = "select_unsupported"
DEGRADE_WEB_APP_UNAVAILABLE = "web_app_unavailable"
DEGRADE_BUTTONS_TRUNCATED = "buttons_truncated"
DEGRADE_OPTIONS_TRUNCATED = "options_truncated"
DEGRADE_TABLE_AS_TEXT = "table_rendered_as_text"
DEGRADE_CHART_AS_TEXT = "chart_rendered_as_text"
DEGRADE_CALLBACK_DATA_TOO_LONG = "callback_data_too_long"
@dataclass(frozen=True)
class DegradedDelivery:
"""A record of controls a channel could not render natively.
``adapt_presentation`` already downgrades charts/tables/buttons/selects to
deterministic text/callbacks, but returns *no record* of what it dropped, so
the downgrade is silent — the user (and the model) never learns a button
vanished or a chart became text. This is the typed report of that
degradation, the presentation-path counterpart of
:class:`~praisonaiagents.bots.failure.FailureReply`: an adapter appends
:attr:`fallback_text` so the loss is *visible*, and records
:attr:`reasons` so it is *machine-readable*.
Attributes:
dropped: Human-readable descriptions of each degraded/dropped control
(e.g. ``"1 button rendered as text"``).
reasons: Machine-readable reason codes (the ``DEGRADE_*`` constants),
aligned by intent with ``dropped``.
fallback_text: A short, user-facing note the adapter can append so the
degradation is never silent (e.g. ``"(Delivered 1 button as text -
Telegram callback data exceeded 64 bytes.)"``). Empty when nothing
degraded.
"""
dropped: Tuple[str, ...]
reasons: Tuple[str, ...]
fallback_text: str
def _callback_is_lossy(value: Optional[str]) -> bool:
"""True when an *adapted* callback value carries the lossy hash marker.
:func:`_encode_reply_callback` / :func:`_encode_select_callback` emit a
``#<digest>`` payload only when the original value overflowed the channel
byte-cap *and* no store was available to preserve it losslessly. Detecting
that marker on the already-adapted value is the single source of truth for
"callback data too long" — so the report never disagrees with the
adaptation (e.g. no false positive when a store round-trips the value, and
no miss for a degraded select option).
"""
if not value:
return False
if value.startswith(f"{REPLY_CALLBACK_PREFIX}{REPLY_HASH_MARKER}"):
return True
# Degraded select options: ``select:...:`` with no ``@<ref>`` store marker
# means the value was hashed (lossy). A stored ref carries CALLBACK_REF_MARKER.
if value.startswith("select:"):
tail = value.rsplit(":", 1)[-1]
return not tail.startswith(CALLBACK_REF_MARKER)
return False
def _presentation_degradation(
presentation: MessagePresentation,
limits: PresentationLimits,
callback_store: Optional["CallbackPayloadStoreProtocol"] = None,
) -> Optional[DegradedDelivery]:
"""Compute the :class:`DegradedDelivery` report for adapting to ``limits``.
Derives the report from the *same* conversion and selection decisions as
:func:`adapt_presentation` (select->buttons, priority/cap button truncation,
web_app->url, option truncation, table/chart->text) — inspecting only the
controls actually retained and reporting callback shortening only when the
adapter genuinely produced a lossy payload. Returns ``None`` when nothing
degrades.
"""
dropped: List[str] = []
reasons: List[str] = []
for block in presentation.blocks:
block_type = block.type.value if isinstance(block.type, BlockType) else block.type
if block_type == BlockType.SELECT.value and not limits.supports_select:
# Follow the adapter: select -> buttons (encoding option callbacks
# exactly as adapt_presentation does), then treat as a buttons block.
n = len(block.options or [])
dropped.append(f"select menu ({n} options) rendered as buttons")
reasons.append(DEGRADE_SELECT_UNSUPPORTED)
block = _select_to_buttons(block, callback_store)
block_type = BlockType.BUTTONS.value
if block_type == BlockType.BUTTONS.value and block.buttons:
buttons = list(block.buttons)
rows = limits.max_button_rows if limits.max_button_rows else 1
total_cap = limits.max_buttons * rows if limits.max_buttons else len(buttons)
if total_cap <= 0:
total_cap = len(buttons)
if len(buttons) > total_cap:
n = len(buttons) - total_cap
dropped.append(f"{n} button(s) dropped (over channel cap)")
reasons.append(DEGRADE_BUTTONS_TRUNCATED)
# Only the *retained* buttons are actually rendered; mirror the
# priority-aware selection so we don't report a dropped
# button's web_app/callback degradation.
indexed = list(enumerate(buttons))
kept = sorted(
indexed, key=lambda iv: (iv[1].priority, -iv[0]), reverse=True
)[:total_cap]
kept.sort(key=lambda iv: iv[0])
buttons = [b for _, b in kept]
for btn in buttons:
if btn.action is None:
continue
# Compare against the adapter's actual output for this button.
adapted_btn = _adapt_button(btn, limits, callback_store)
a_type = (
btn.action.type.value
if isinstance(btn.action.type, ActionType)
else btn.action.type
)
if (
not limits.supports_web_apps
and a_type == ActionType.WEB_APP.value
and btn.action.web_app_url
):
dropped.append("web-app button rendered as URL")
reasons.append(DEGRADE_WEB_APP_UNAVAILABLE)
elif adapted_btn.action is not None and _callback_is_lossy(
adapted_btn.action.value
):
dropped.append("button callback shortened (data exceeded byte cap)")
reasons.append(DEGRADE_CALLBACK_DATA_TOO_LONG)
elif block_type == BlockType.SELECT.value and block.options:
if limits.max_options and len(block.options) > limits.max_options:
n = len(block.options) - limits.max_options
dropped.append(f"{n} select option(s) dropped (over channel cap)")
reasons.append(DEGRADE_OPTIONS_TRUNCATED)
elif block_type == BlockType.TABLE.value and not limits.supports_tables:
dropped.append("table rendered as text")
reasons.append(DEGRADE_TABLE_AS_TEXT)
elif block_type == BlockType.CHART.value and not limits.supports_charts:
dropped.append("chart rendered as text")
reasons.append(DEGRADE_CHART_AS_TEXT)
if not dropped:
return None
fallback_text = "(" + "; ".join(dropped) + ".)"
return DegradedDelivery(
dropped=tuple(dropped),
reasons=tuple(reasons),
fallback_text=fallback_text,
)
def adapt_presentation_with_report(
presentation: MessagePresentation,
limits: PresentationLimits,
*,
callback_store: Optional["CallbackPayloadStoreProtocol"] = None,
) -> "tuple[MessagePresentation, Optional[DegradedDelivery]]":
"""Adapt a presentation *and* report what degraded.
Identical to :func:`adapt_presentation` for the returned presentation, but
additionally returns a typed :class:`DegradedDelivery` (or ``None``) so the
adapter can append a readable text fallback and record machine-readable
reasons instead of downgrading silently. ``adapt_presentation`` is retained
unchanged for callers that do not need the report.
Returns:
``(adapted_presentation, degraded_or_none)``.
"""
adapted = adapt_presentation(presentation, limits, callback_store=callback_store)
report = _presentation_degradation(presentation, limits, callback_store)
return adapted, report
# The renderer contract lives in ``protocols.py`` (alongside the other bot
# extension-point protocols). Re-exported here for backward-compatible imports
# (``from praisonaiagents.bots.presentation import PresentationRendererProtocol``).
from .protocols import PresentationRendererProtocol # noqa: E402,F401
# Registry keyed by a normalized (lowercased, stripped) platform id. Both
# built-in (registered by the wrapper at import time) and plugin renderers
# register here identically, so no channel is second-class for interactive UX.
# Consumers resolve with ``get_presentation_renderer`` and fall back to plain
# text only when genuinely no renderer exists for a platform.
_PRESENTATION_RENDERERS: Dict[str, type] = {}
def _normalize_platform(platform: str) -> str:
"""Normalize a platform id the same way the channel registry does.
Channel identifiers are matched case-insensitively elsewhere, so a
mixed-case plugin id (``"Matrix"``) must resolve to the same renderer slot
as ``"matrix"``. Without this, a channel could register/resolve as a channel
but silently miss its renderer and degrade to plain text.
"""
return platform.strip().lower()
def register_presentation_renderer(platform: str, renderer: type) -> None:
"""Register *renderer* as the presentation renderer for *platform*.
Any channel — built-in or a pip-installed plugin — calls this (e.g. from its
``setup`` hook or entry point) so its interactive presentations render
natively instead of degrading to plain text. Re-registering a platform
overrides the previous renderer, letting a plugin intentionally supersede a
built-in.
Args:
platform: The channel/platform id (e.g. ``"telegram"``, ``"matrix"``);
matched case-insensitively.
renderer: A class satisfying :class:`PresentationRendererProtocol`
(exposing ``get_limits`` and ``render``).
Raises:
ValueError: If *platform* is empty/blank.
TypeError: If *renderer* does not expose callable ``get_limits`` and
``render`` — so a misconfigured renderer fails loudly at
registration rather than later inside :func:`render_for`.
"""
key = _normalize_platform(platform) if isinstance(platform, str) else ""
if not key:
raise ValueError(
"register_presentation_renderer: platform id must be a non-empty "
"string (e.g. 'telegram', 'matrix')."
)
if not (callable(getattr(renderer, "get_limits", None))
and callable(getattr(renderer, "render", None))):
raise TypeError(
"register_presentation_renderer: renderer for "
f"'{platform}' must satisfy PresentationRendererProtocol — expose "
"static/callable 'get_limits()' and 'render(presentation)'."
)
_PRESENTATION_RENDERERS[key] = renderer
def get_presentation_renderer(platform: str) -> Optional[type]:
"""Return the registered renderer class for *platform*, or ``None``.
Lookup is case-insensitive to mirror registration.
"""
if not isinstance(platform, str):
return None
return _PRESENTATION_RENDERERS.get(_normalize_platform(platform))
@@ -1185,6 +1185,34 @@ class SupportsPresentation(Protocol):
...
@runtime_checkable
class PresentationRendererProtocol(Protocol):
"""Contract a channel presentation renderer implements.
A renderer converts a portable :class:`MessagePresentation` into a native,
platform-specific payload (Telegram inline keyboard, Slack blocks, …). It
should run ``adapt_presentation`` against its own :meth:`get_limits` first
so capability-driven degradation is applied uniformly.
This protocol is the core seam that lets *any* channel — built-in or a
pip-installed plugin — register a native renderer via
``register_presentation_renderer``, mirroring how a channel already
contributes config via :class:`ChannelDescriptor`. Heavy renderer
implementations still live in ``praisonai-bot``; only the contract and the
registry live in core.
"""
@staticmethod
def get_limits() -> "PresentationLimits":
"""Return this channel's capability limits."""
...
@staticmethod
def render(presentation: "MessagePresentation") -> Dict[str, Any]:
"""Render *presentation* into a native, platform-specific payload."""
...
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# EmailProtocol — email-specific bot capabilities
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -66,6 +66,39 @@ def is_intentional_silence_response(text: str | None) -> bool:
return False
def classify_final(text: str | None) -> str:
"""Classify an agent's final reply for the visible-outcome guarantee.
Every inbound turn must end in a visible outcome or a recorded, intentional
non-outcome. This is the single decision point the bot/gateway layer uses to
tell those apart, so no adapter re-decides "blank" differently:
- ``"silence"``: a deliberate no-reply (an exact ``NO_REPLY``/``[SILENT]``
marker). The gateway suppresses the send on purpose.
- ``"empty"``: blank, whitespace-only, or the machine ``[tool_calls: …]``
placeholder — NOT deliberate silence. The gateway must substitute a
recorded, user-facing fallback rather than drop it or leak the token.
- ``"text"``: real user-facing content to deliver as-is.
Examples:
>>> classify_final("NO_REPLY")
'silence'
>>> classify_final("")
'empty'
>>> classify_final(" ")
'empty'
>>> classify_final("[tool_calls: search_web]")
'empty'
>>> classify_final("Here is your answer.")
'text'
"""
if is_intentional_silence_response(text):
return "silence"
if not text or not text.strip() or text.strip().startswith("[tool_calls:"):
return "empty"
return "text"
@dataclass
class BotLoopPolicy:
"""Sliding-window pair budget for bot-to-bot loop protection.
@@ -0,0 +1,197 @@
"""Declarative webhook filter — a tiny, import-light matcher for HTTP events.
A generic webhook channel needs to decide, purely from configuration, whether
an inbound HTTP event should trigger an agent. This module provides that
decision as a small, side-effect-free predicate tree so the heavy HTTP-serving
ingress (in ``praisonai-bot``) — and any third-party channel — can reuse one
canonical filter semantics instead of re-implementing payload matching.
The filter is expressed as plain dict/JSON so it round-trips through YAML:
when:
all:
- { field: headers.X-GitHub-Event, equals: issues }
- { field: payload.action, in: [opened, reopened] }
Grammar
-------
A node is either a *combinator* or a *leaf*.
Combinators (compose sub-nodes)::
{"all": [<node>, ...]} # AND — every child must match (empty = True)
{"any": [<node>, ...]} # OR — at least one child matches (empty = False)
{"not": <node>} # negation
Leaf (a single field test)::
{"field": "payload.action", "<op>": <value>}
where ``<op>`` is one of:
- ``exists`` — truthy check the field is present (value: bool, default True)
- ``equals`` — equality (string-insensitive for headers is *not* implied;
compares the resolved value to ``value``)
- ``contains``— substring / membership (``value in resolved``)
- ``in`` — resolved value is one of ``value`` (a list)
- ``regex`` — ``re.search(value, str(resolved))``
``field`` is a dotted path into the event mapping, e.g. ``payload.issue.number``
or ``headers.X-GitHub-Event``. Header lookup is case-insensitive. A missing path
resolves to ``None`` and simply fails the leaf (fail-safe), it never raises.
This module has no third-party imports and does not perform any I/O, so it is
safe to keep in the core SDK; the wrapper composes it with HMAC verification and
the ingress journal.
"""
from __future__ import annotations
import re
from typing import Any, Mapping, Optional
__all__ = ["WebhookFilter", "evaluate_webhook_filter", "resolve_field"]
def resolve_field(event: Mapping[str, Any], path: str) -> Any:
"""Resolve a dotted ``path`` into ``event``, case-insensitively for headers.
Args:
event: The normalised event mapping, typically
``{"payload": ..., "headers": ..., "query": ...}``.
path: A dotted path like ``payload.issue.number`` or
``headers.X-GitHub-Event``.
Returns:
The resolved value, or ``None`` if any segment is missing. Never raises.
"""
if not path:
return None
parts = path.split(".")
current: Any = event
header_section = parts[0] == "headers"
for i, part in enumerate(parts):
if isinstance(current, Mapping):
if part in current:
current = current[part]
continue
# Headers are case-insensitive: fall back to a lowered-key match for
# the segment directly under ``headers``.
if header_section and i == 1:
lowered = {str(k).lower(): v for k, v in current.items()}
if part.lower() in lowered:
current = lowered[part.lower()]
continue
return None
return None
return current
def _match_leaf(event: Mapping[str, Any], node: Mapping[str, Any]) -> bool:
"""Evaluate a single ``{"field": ..., "<op>": value}`` leaf. Fail-safe."""
field = node.get("field")
if not isinstance(field, str):
return False
resolved = resolve_field(event, field)
if "exists" in node:
want = node["exists"]
present = resolved is not None
return present if bool(want) else (not present)
if "equals" in node:
return resolved == node["equals"]
if "contains" in node:
needle = node["contains"]
try:
return needle in resolved # type: ignore[operator]
except TypeError:
return False
if "in" in node:
options = node["in"]
if isinstance(options, (list, tuple, set)):
return resolved in options
return False
if "regex" in node:
pattern = node["regex"]
if not isinstance(pattern, str) or resolved is None:
return False
try:
return re.search(pattern, str(resolved)) is not None
except re.error:
return False
# A leaf with only ``field`` and no operator is treated as an existence
# check — matches when the field is present.
return resolved is not None
def evaluate_webhook_filter(
event: Mapping[str, Any], node: Optional[Any]
) -> bool:
"""Evaluate a declarative filter ``node`` against a normalised ``event``.
Args:
event: Normalised event, e.g. ``{"payload", "headers", "query"}``.
node: The filter tree (dict). ``None`` or ``{}`` matches everything so a
route with no ``when`` is an unconditional catch-all.
Returns:
True if the event matches. Never raises on malformed input — an
unrecognised node fails closed to ``False`` (except the empty/None
catch-all above).
"""
if node is None or node == {}:
return True
if not isinstance(node, Mapping):
return False
if "all" in node:
children = node["all"] or []
return all(evaluate_webhook_filter(event, c) for c in children)
if "any" in node:
children = node["any"] or []
return any(evaluate_webhook_filter(event, c) for c in children)
if "not" in node:
return not evaluate_webhook_filter(event, node["not"])
if "field" in node:
return _match_leaf(event, node)
return False
class WebhookFilter:
"""A reusable, config-driven predicate over a normalised webhook event.
Wraps a declarative filter tree so callers can validate it once and match
many events::
f = WebhookFilter({"all": [
{"field": "headers.X-GitHub-Event", "equals": "issues"},
{"field": "payload.action", "in": ["opened", "reopened"]},
]})
if f.matches({"headers": {...}, "payload": {...}}):
...
A ``None``/empty tree is an unconditional match (catch-all route).
"""
__slots__ = ("_tree",)
def __init__(self, tree: Optional[Any] = None) -> None:
self._tree = tree
@property
def tree(self) -> Optional[Any]:
return self._tree
def matches(self, event: Mapping[str, Any]) -> bool:
"""Return whether ``event`` satisfies the filter tree."""
return evaluate_webhook_filter(event, self._tree)
def __repr__(self) -> str: # pragma: no cover - trivial
return f"WebhookFilter({self._tree!r})"
@@ -37,6 +37,8 @@ __all__ = [
"ToolResultPrunerProtocol",
"MessageFormatterProtocol",
"SummaryBuilderProtocol",
# Read-only recap
"build_recap",
]
@@ -70,4 +72,8 @@ def __getattr__(name: str):
from .protocols import SummaryBuilderProtocol
return SummaryBuilderProtocol
if name == "build_recap":
from .recap import build_recap
return build_recap
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -459,6 +459,58 @@ class ContextCompactor:
return self.tool_pruner.prune(messages, self.config.max_tool_result_size)
return messages, 0
def _snap_to_pair_boundary(
self,
messages: List[Dict[str, Any]],
cut_index: int,
) -> int:
"""Move ``cut_index`` so it never splits an assistant ``tool_calls``
message from its matching ``tool`` result.
``messages[:cut_index]`` is the older segment that gets dropped or
summarised; ``messages[cut_index:]`` is the recent segment that is
kept verbatim. If the boundary would leave a ``tool`` result at the
head of the kept segment whose originating assistant ``tool_calls``
message sits in the older segment (or vice versa), strict providers
reject the transcript with a 400. This snaps the boundary *outward*
(to a lower index) so the whole pair is kept together on the recent
side, which is the safe direction for the provider contract.
Returns a boundary index in ``[0, len(messages)]``.
"""
if cut_index <= 0 or cut_index >= len(messages):
return max(0, min(cut_index, len(messages)))
# Collect the tool_call ids produced by assistant messages that fall
# in the older (dropped/summarised) segment. Any tool result in the
# kept segment referencing one of these would be orphaned.
def _call_ids(msg: Dict[str, Any]) -> set:
ids = set()
for tc in (msg.get("tool_calls") or []):
if isinstance(tc, dict) and tc.get("id"):
ids.add(tc["id"])
return ids
# Walk the boundary outward while the first kept message is an orphaned
# tool result, i.e. its tool_call_id was emitted before the cut.
older_call_ids = set()
for msg in messages[:cut_index]:
older_call_ids |= _call_ids(msg)
while cut_index > 0:
head = messages[cut_index]
head_response_id = head.get("tool_call_id")
if head.get("role") == "tool" and head_response_id in older_call_ids:
# Pull the boundary back to include the preceding message; keep
# going until the emitting assistant tool_calls message is on
# the kept side too.
cut_index -= 1
older_call_ids -= _call_ids(messages[cut_index])
else:
break
return cut_index
def _truncate(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Truncate oldest messages."""
result = []
@@ -476,25 +528,49 @@ class ContextCompactor:
# Always keep system messages
result.extend(system_msgs)
# Keep recent messages
recent = other_msgs[-self.preserve_recent:] if other_msgs else []
# Keep recent messages. Snap the boundary outward so a tool result at
# the head of the kept window is never orphaned from its assistant
# tool_calls message (strict providers 400 on an orphaned tool result).
if other_msgs and len(other_msgs) > self.preserve_recent:
cut = self._snap_to_pair_boundary(
other_msgs, len(other_msgs) - self.preserve_recent
)
else:
cut = 0
recent = other_msgs[cut:]
# Add recent messages
result.extend(recent)
# If still over limit, truncate more
# If still over limit, truncate more. Drop the whole leading tool pair
# together (an assistant tool_calls message plus its tool results) so we
# never leave an orphaned tool result at the head of the kept window.
while self.count_total_tokens(result) > self.target_tokens and len(result) > 1:
# Remove oldest message (respecting preserve_system setting)
removed = False
# Find the oldest droppable (non-system when preserved) message.
start = None
for i, msg in enumerate(result):
# Skip system messages only if preserve_system is True
if not (self.preserve_system and msg.get("role") == "system"):
result.pop(i)
removed = True
start = i
break
if not removed:
if start is None:
# Only system messages remain but still over budget — stop to avoid infinite loop
break
# If the oldest droppable message emits tool_calls, drop it together
# with all of its immediately-following tool results.
drop_ids = set()
for tc in (result[start].get("tool_calls") or []):
if isinstance(tc, dict) and tc.get("id"):
drop_ids.add(tc["id"])
end = start + 1
while (
drop_ids
and end < len(result)
and result[end].get("role") == "tool"
and result[end].get("tool_call_id") in drop_ids
):
end += 1
del result[start:end]
return result
@@ -507,20 +583,25 @@ class ContextCompactor:
if self.preserve_system and msg.get("role") == "system":
result.append(msg)
# Add messages from end until we hit target
# Add messages from end until we hit target. Track the boundary index
# into non_system so we can snap it to keep tool pairs together.
non_system = [m for m in messages if m.get("role") != "system"]
kept = 0
window: List[Dict[str, Any]] = []
for msg in reversed(non_system):
if self.count_total_tokens(result + [msg]) <= self.target_tokens:
result.insert(len([m for m in result if m.get("role") == "system"]), msg)
if self.count_total_tokens(result + [msg] + window) <= self.target_tokens:
window.insert(0, msg)
kept += 1
else:
break
# Ensure messages are in order
system_msgs = [m for m in result if m.get("role") == "system"]
other_msgs = [m for m in result if m.get("role") != "system"]
# Snap the boundary outward so a tool result kept at the window head is
# not orphaned from its assistant tool_calls left outside the window.
cut = self._snap_to_pair_boundary(non_system, len(non_system) - kept)
window = non_system[cut:]
return system_msgs + other_msgs
return result + window
def _summarize(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Summarize old messages (simplified version)."""
@@ -532,11 +613,16 @@ class ContextCompactor:
result.extend(system_msgs)
# Keep recent messages
recent = other_msgs[-self.preserve_recent:]
# Summarize older messages
older = other_msgs[:-self.preserve_recent] if len(other_msgs) > self.preserve_recent else []
# Choose the recent/older boundary by count, then snap it so a
# tool_calls message and its result are never split across it.
if len(other_msgs) > self.preserve_recent:
cut = self._snap_to_pair_boundary(
other_msgs, len(other_msgs) - self.preserve_recent
)
else:
cut = 0
recent = other_msgs[cut:]
older = other_msgs[:cut]
if older:
# Create a simple summary
@@ -580,9 +666,17 @@ class ContextCompactor:
result.extend(system_msgs)
# Keep recent messages intact
recent = other_msgs[-self.preserve_recent:]
older = other_msgs[:-self.preserve_recent] if len(other_msgs) > self.preserve_recent else []
# Keep recent messages intact. Snap the boundary so a tool_calls
# message and its result are pruned/kept together (prune only rewrites
# tool-result *content*, but a snapped boundary keeps intent consistent).
if len(other_msgs) > self.preserve_recent:
cut = self._snap_to_pair_boundary(
other_msgs, len(other_msgs) - self.preserve_recent
)
else:
cut = 0
recent = other_msgs[cut:]
older = other_msgs[:cut]
# Prune older messages
for msg in older:
@@ -665,8 +759,15 @@ class ContextCompactor:
result.extend(system_msgs)
# Keep recent messages
recent = other_msgs[-self.preserve_recent:]
# Keep recent messages, snapping the boundary so tool_calls/result
# pairs are never split across the recent/older divide.
if len(other_msgs) > self.preserve_recent:
recent_cut = self._snap_to_pair_boundary(
other_msgs, len(other_msgs) - self.preserve_recent
)
else:
recent_cut = 0
recent = other_msgs[recent_cut:]
# Determine what to summarize
total_original_messages = len(messages)
@@ -678,13 +779,14 @@ class ContextCompactor:
messages_since_summary = total_original_messages - self._previous_summary_global_idx
new_older_messages = max(0, messages_since_summary - self.preserve_recent)
if new_older_messages > 0:
to_summarize = other_msgs[-messages_since_summary:-self.preserve_recent]
to_summarize = other_msgs[-messages_since_summary:recent_cut]
else:
to_summarize = []
self._used_previous_summary = True
else:
# Fresh summary: summarize all older messages
to_summarize = other_msgs[:-self.preserve_recent] if len(other_msgs) > self.preserve_recent else []
# Fresh summary: summarize all older messages (kept in sync with the
# snapped recent boundary so a tool pair is never split)
to_summarize = other_msgs[:recent_cut]
self._used_previous_summary = False
if to_summarize or self._previous_summary:
@@ -775,8 +877,15 @@ class ContextCompactor:
result.extend(system_msgs)
# Keep recent messages
recent = other_msgs[-self.preserve_recent:]
# Keep recent messages, snapping the boundary so tool_calls/result
# pairs are never split across the recent/older divide.
if len(other_msgs) > self.preserve_recent:
recent_cut = self._snap_to_pair_boundary(
other_msgs, len(other_msgs) - self.preserve_recent
)
else:
recent_cut = 0
recent = other_msgs[recent_cut:]
# Determine what to summarize based on iterative settings
total_original_messages = len(messages)
@@ -787,13 +896,14 @@ class ContextCompactor:
messages_since_summary = total_original_messages - self._previous_summary_global_idx
new_older_messages = max(0, messages_since_summary - self.preserve_recent)
if new_older_messages > 0:
older = other_msgs[-messages_since_summary:-self.preserve_recent]
older = other_msgs[-messages_since_summary:recent_cut]
else:
older = []
self._used_previous_summary = True
else:
# Fresh summary: summarize all older messages
older = other_msgs[:-self.preserve_recent] if len(other_msgs) > self.preserve_recent else []
# Fresh summary: summarize all older messages (kept in sync with the
# snapped recent boundary so a tool pair is never split)
older = other_msgs[:recent_cut]
self._used_previous_summary = False
if older and self.llm_summarize_fn:
@@ -0,0 +1,135 @@
"""Read-only session recap built on the existing summariser.
The compaction machinery in this package normally runs to *shrink* context.
``build_recap`` reuses that same summariser purely to *inform* the user: it
renders a short "where were we" block from a transcript **without mutating the
conversation or triggering compaction**. This is the shared core primitive
behind the ``/recap`` bot command and ``praisonai session show --recap``.
"""
from __future__ import annotations
from typing import Any, Dict, List
RECAP_HEADER = "Recap — where we were:"
# A recap is a *glanceable* re-entry aid, not a full transcript. Bound the
# rendered block so a large persisted/compaction summary can never produce an
# output that overflows a delivery channel (e.g. Telegram's 4096-char message
# limit) or scrolls a terminal. Kept comfortably under that ceiling so channel
# framing/markdown never tips it over.
RECAP_MAX_CHARS = 3500
def _persisted_summary(history: List[Dict[str, Any]]) -> str:
"""Return an already-distilled summary embedded in the transcript, if any.
Prefers the most recent compaction checkpoint (a system message tagged
``_compacted``) or the naive ``[Previous conversation summary]`` line, so a
recap can start cheaply from what the model already carries (Issue #3062).
"""
found = ""
for msg in history:
content = msg.get("content")
if msg.get("_compacted") and isinstance(content, str):
found = content
elif (
msg.get("role") == "system"
and isinstance(content, str)
and content.startswith("[Previous conversation summary]")
):
found = content
return found
def _naive_summary(history: List[Dict[str, Any]]) -> str:
"""Distil older turns with the compactor's offline summarise pass.
Operates on a shallow copy so the caller's transcript is never modified and
no compaction event is emitted. Returns ``""`` on any failure so recap can
never crash a chat.
"""
try:
from .compactor import ContextCompactor
from .strategy import CompactionStrategy
compactor = ContextCompactor(strategy=CompactionStrategy.SUMMARIZE)
summarised = compactor._summarize([dict(m) for m in history])
for msg in summarised:
content = msg.get("content")
if (
msg.get("role") == "system"
and isinstance(content, str)
and content.startswith("[Previous conversation summary]")
):
return content
except Exception: # noqa: BLE001 - recap must never raise
return ""
return ""
def build_recap(
history: List[Dict[str, Any]],
tail: int = 4,
max_chars: int = RECAP_MAX_CHARS,
) -> str:
"""Render a read-only "where were we" summary of a transcript.
Reuses the existing summariser (a persisted compaction summary when one is
embedded, otherwise the naive summarise pass) and always tops up with the
most recent ``tail`` messages so the recap reflects the latest activity.
The input ``history`` is never modified and no compaction is triggered.
The rendered block is bounded to ``max_chars`` so a large persisted summary
can never overflow a delivery channel (e.g. Telegram's 4096-char message
limit) or flood a terminal; the recent tail is always preserved and the
(older) summary is what gets trimmed, keeping the most relevant context.
Args:
history: The conversation messages (not modified).
tail: How many recent messages to append verbatim.
max_chars: Upper bound on the rendered recap length. ``0`` or negative
disables the cap.
Returns:
A short, human-readable recap block, or a "nothing yet" note.
"""
if not history:
return "Nothing to recap yet — this session has no messages."
summary_text = _persisted_summary(history) or _naive_summary(history)
tail_lines: List[str] = []
for msg in history[-tail:] if tail > 0 else []:
role = msg.get("role", "?")
content = msg.get("content", "")
if isinstance(content, str) and content.strip():
snippet = content.strip().replace("\n", " ")
if len(snippet) > 160:
snippet = snippet[:160] + "…"
tail_lines.append(f"• {role}: {snippet}")
header = f"📌 {RECAP_HEADER}"
if max_chars and max_chars > 0 and summary_text:
# Reserve room for the header, the recent tail, and joining newlines so
# the *most recent* activity is never dropped in favour of the older
# summary. Only the summary is trimmed to fit the budget.
tail_block = ""
if tail_lines:
tail_block = "\n".join(["Recent:", *tail_lines])
# +2 accounts for the newlines that will join header/summary/tail.
reserved = len(header) + len(tail_block) + 2
budget = max_chars - reserved
if budget <= 0:
summary_text = ""
elif len(summary_text) > budget:
summary_text = summary_text[: max(0, budget - 1)].rstrip() + "…"
parts: List[str] = [header]
if summary_text:
parts.append(summary_text)
if tail_lines:
parts.append("Recent:")
parts.extend(tail_lines)
return "\n".join(parts)
@@ -778,6 +778,9 @@ class ExecutionConfig:
max_steps: Optional[int] = None
# Rate limiting
# When set (positive int) and ``rate_limiter`` is omitted, the Agent
# auto-creates ``RateLimiter(requests_per_minute=max_rpm)``. Provide
# ``rate_limiter`` explicitly for burst/TPM control (it takes precedence).
max_rpm: Optional[int] = None
# Time limits
@@ -41,6 +41,7 @@ VALID_PLUGINS_KEYS = {
"enabled": (bool, list, str), # bool, list of plugin names, or "true"/"false"
"auto_discover": (bool,),
"directories": (list,),
"allow_project_plugins": (bool,), # opt-in trust gate for ./.praisonai/plugins/*.py
}
VALID_DEFAULTS_KEYS = {
@@ -110,6 +111,10 @@ class PluginsConfig:
"""Configuration for the plugin system."""
enabled: Union[bool, List[str]] = False # True, False, or list of plugin names
auto_discover: bool = True
# Opt-in trust gate for executing project-local single-file plugins
# (./.praisonai/plugins/*.py). Default False so a cloned repo cannot run
# arbitrary plugin code until the user explicitly authorises it.
allow_project_plugins: bool = False
directories: List[str] = field(default_factory=lambda: [
str(_default_project_plugins_dir()),
str(_default_global_plugins_dir())
@@ -124,6 +129,7 @@ class PluginsConfig:
result = {
"enabled": self.enabled,
"auto_discover": self.auto_discover,
"allow_project_plugins": self.allow_project_plugins,
"directories": self.directories,
}
# Flatten per-plugin option maps back to top level for round-tripping.
@@ -315,7 +321,7 @@ def _dict_to_plugins_config(data: Dict[str, Any]) -> PluginsConfig:
system; any other key whose value is a mapping is treated as a per-plugin
option map delivered to that plugin's ``on_config`` hook.
"""
reserved = {"enabled", "auto_discover", "directories"}
reserved = {"enabled", "auto_discover", "directories", "allow_project_plugins"}
options = {
name: value
for name, value in data.items()
@@ -324,6 +330,7 @@ def _dict_to_plugins_config(data: Dict[str, Any]) -> PluginsConfig:
return PluginsConfig(
enabled=data.get("enabled", False),
auto_discover=data.get("auto_discover", True),
allow_project_plugins=data.get("allow_project_plugins", False),
directories=data.get("directories", [
str(_default_project_plugins_dir()),
str(_default_global_plugins_dir())
@@ -583,6 +590,113 @@ def get_plugin_options() -> Dict[str, Dict[str, Any]]:
return dict(get_plugins_config().options)
def _config_write_target() -> Path:
"""Resolve the config file the CLI should write plugin enable/disable to.
Returns the existing config file if one is already present (so the CLI
mutates the *same* file the runtime reads), otherwise the project-local
``.praisonai/config.yaml`` — the unified surface — as the default target.
This closes the config split-brain where the CLI wrote a JSON file the
runtime never read.
"""
existing = _find_config_file()
if existing is not None:
return existing
return get_project_data_dir() / "config.yaml"
def _dump_config_file(path: Path, data: Dict[str, Any]) -> None:
"""Write ``data`` to ``path`` as YAML or TOML based on its suffix."""
path.parent.mkdir(parents=True, exist_ok=True)
if path.suffix.lower() in (".yaml", ".yml"):
import yaml
with open(path, "w", encoding="utf-8") as f:
yaml.safe_dump(data, f, sort_keys=False, default_flow_style=False)
else:
try:
import tomli_w
except ImportError:
# Do NOT write a YAML sidecar: the runtime keeps reading the
# original .toml (higher precedence), so the write would be
# silently lost and re-introduce the config split-brain. Fail
# fast with a remediation hint instead.
raise RuntimeError(
f"Cannot write TOML config {path}: no TOML writer available. "
"Install tomli-w (pip install tomli-w), or convert the config "
"to .praisonai/config.yaml."
) from None
with open(path, "wb") as f:
tomli_w.dump(data, f)
def set_plugin_enabled(name: str, enabled: bool) -> Path:
"""Persist a plugin's enabled state to the config the runtime reads.
Mutates the ``plugins.enabled`` allow-list in the project's config file
(the same file ``get_enabled_plugins`` reads), creating
``.praisonai/config.yaml`` if no config exists yet. This is the single
source of truth the CLI and runtime share — no separate JSON file.
Args:
name: Plugin name to enable/disable.
enabled: True to add to the allow-list, False to remove it.
Returns:
The path of the config file that was written.
"""
target = _config_write_target()
raw = _load_config() if _find_config_file() is not None else {}
if not isinstance(raw, dict):
raw = {}
plugins = raw.get("plugins")
if not isinstance(plugins, dict):
plugins = {}
raw["plugins"] = plugins
has_enabled_key = "enabled" in plugins
current = plugins.get("enabled")
# Normalise the enabled flag into a list of names we can mutate.
if isinstance(current, list):
names = [str(n) for n in current]
elif isinstance(current, str) and current.strip():
names = [current.strip()]
elif current is True:
# Explicit "all plugins enabled" mode. Collapsing this to an allow-list
# would silently disable every other plugin, so we only touch this
# state when the requested change is well-defined:
# - enable: already enabled, no-op (leave "all enabled" intact)
# - disable: cannot be expressed as an allow-list without naming the
# full set, so refuse rather than disable everything.
if enabled:
clear_config_cache()
return target
raise ValueError(
f"Cannot disable '{name}': plugins.enabled is currently set to "
"'all' (true). Set plugins.enabled to an explicit list of plugin "
"names first, then disable individual plugins."
)
elif current is None and not has_enabled_key:
# No allow-list configured yet (fresh config): start an empty list so
# the first enable produces [name] and the first disable is a no-op.
names = []
else:
# Falsy explicit value (e.g. enabled: false) — start from empty.
names = []
if enabled:
if name not in names:
names.append(name)
else:
names = [n for n in names if n != name]
plugins["enabled"] = names
_dump_config_file(target, raw)
clear_config_cache()
return target
def _defaults_has_any_values() -> bool:
"""Return True if config file defines any defaults (cached after first check)."""
global _defaults_has_values
@@ -60,16 +60,54 @@ OUTPUT_RESERVES: Dict[str, int] = {
}
def _litellm_model_info(model: str) -> Optional[Dict[str, Any]]:
"""
Look up a model's info from litellm's ``model_cost`` registry.
Lazy-imported so there is zero overhead (and no hard dependency) when
litellm is not installed. Returns ``None`` when litellm is absent or the
model is unknown.
"""
try:
from litellm import model_cost
except Exception:
return None
try:
model_lower = model.lower()
if model_lower in model_cost:
return model_cost[model_lower]
# Try with provider prefix removed (e.g. "openai/gpt-4o" -> "gpt-4o")
if "/" in model:
base_model = model.split("/")[-1].lower()
if base_model in model_cost:
return model_cost[base_model]
except Exception:
return None
return None
def get_model_limit(model: str) -> int:
"""
Get context window limit for a model.
Resolves against litellm's ``model_cost`` registry first (``max_input_tokens``),
so any model the underlying LLM layer knows gets the correct window — then
falls back to the static ``MODEL_LIMITS`` table for offline use.
Args:
model: Model name or identifier
Returns:
Context window size in tokens
"""
# Prefer data-driven lookup via litellm (context window = max_input_tokens)
info = _litellm_model_info(model)
if info:
limit = info.get("max_input_tokens") or info.get("max_tokens")
if limit:
return limit
# Exact match
if model in MODEL_LIMITS:
return MODEL_LIMITS[model]
@@ -86,13 +124,24 @@ def get_model_limit(model: str) -> int:
def get_output_reserve(model: str) -> int:
"""
Get recommended output token reserve for a model.
Resolves against litellm's ``model_cost`` registry first
(``max_output_tokens``), then falls back to the static ``OUTPUT_RESERVES``
table for offline use.
Args:
model: Model name
Returns:
Output reserve in tokens
"""
# Prefer data-driven lookup via litellm (output reserve = max_output_tokens)
info = _litellm_model_info(model)
if info:
reserve = info.get("max_output_tokens")
if reserve:
return reserve
model_lower = model.lower()
for key, reserve in OUTPUT_RESERVES.items():
@@ -39,10 +39,21 @@ from .protocols import (
# Home channel and delivery protocols
HomeChannelRegistryProtocol,
DeliveryResolverProtocol,
# Creation-time delivery-target pre-flight (Issue #3800)
DeliveryPreflightProtocol,
DeliveryValidation,
ScheduleTargetError,
# Agent-facing outbound messaging
OutboundMessengerProtocol,
DeliveryResult,
TargetInfo,
# Agent-callable cross-conversation request/reply (Issue #3689)
ConversationReply,
ConversationReplyStatus,
ConversationRequestProtocol,
# Agent-facing live status/health (Issue #3688)
GatewayStatusProtocol,
GatewayStatus,
# Inbound route binding (Issue #2225)
RouteBinding,
RouteFacts,
@@ -74,11 +85,20 @@ from .protocols import (
ResourceSample,
ResourcePressurePolicyProtocol,
MemoryPressurePolicy,
# Gateway memory-pressure cache eviction (Issue #3804)
WarmSession,
MemoryPressureProtocol,
plan_pressure_evictions,
# Gateway rate-limit admission (Issue #2532)
RateLimitDecision,
RateLimitPolicyProtocol,
RateLimitPolicy, # backward-compat alias
SlidingWindowRateLimitPolicy,
# Durable-queue dead-letter decision (Issue #3519)
PERMANENT_ERROR_CLASSES,
DeadLetterDecision,
DeadLetterPolicyProtocol,
AttemptAndAgeDeadLetterPolicy,
# Port-less, restart-safe external drain trigger (Issue #2390)
current_epoch,
DrainMarkerPolicy,
@@ -96,6 +116,8 @@ from .protocols import (
FatalConfigError,
classify_exit_reason,
RestartLoopGuard,
# Fleet-level crash-loop breaker for channel supervision (Issue #3840)
FleetSupervisionPolicy,
# Protocol version negotiation
PROTOCOL_VERSION,
MIN_PROTOCOL_VERSION,
@@ -122,6 +144,10 @@ from .protocols import (
LivenessDecision,
LivenessPolicyProtocol,
LivenessPolicy,
# Cluster-wide per-turn serialisation (Issue #3643)
TurnLeaseToken,
TurnLockProtocol,
LocalTurnLock,
# Schema-validated inbound frame codec (Issue #2831)
HelloParams,
HelloResult,
@@ -146,6 +172,18 @@ from .liveness import (
LoopWatchdogPolicy,
LoopWatchdog,
)
from .degraded_state import (
# Unified degraded-capability registry (Issue #3518)
DegradedOwner,
DegradedCapabilityProtocol,
DegradedCapabilityRegistry,
OWNER_KINDS,
DEGRADED_STATES,
# Fail-closed read of the degraded-owner contract (Issue #3640)
DegradedCapabilityLookupProtocol,
OwnerUnavailable,
assert_owner_available,
)
from .hooks import (
HookAction,
HookConfig,
@@ -160,6 +198,13 @@ from .config import (
ApiConfig,
ChannelRouteConfig,
MultiChannelGatewayConfig,
# Config version stamp + doctor-driven migration (Issue #3841)
GATEWAY_CONFIG_VERSION,
ConfigVersionError,
LegacyConfigRule,
GATEWAY_CONFIG_RULES,
is_config_current,
migrate_config_with_doctor,
# Push config
PushConfig,
RedisConfig,
@@ -167,10 +212,14 @@ from .config import (
DeliveryConfig,
PollingConfig,
LivenessConfig,
TurnLockConfig,
# Hot-reload registry (Issue #3378)
HOT_APPLIABLE_KEYS,
SupportsHotReload,
is_hot_appliable,
# Reload scope classification (Issue #3440)
ReloadScope,
classify_reload,
)
# Lazy loading cache
@@ -251,10 +300,20 @@ __all__ = [
# Home channel and delivery protocols
"HomeChannelRegistryProtocol",
"DeliveryResolverProtocol",
"DeliveryPreflightProtocol",
"DeliveryValidation",
"ScheduleTargetError",
# Agent-facing outbound messaging
"OutboundMessengerProtocol",
"DeliveryResult",
"TargetInfo",
# Agent-callable cross-conversation request/reply (Issue #3689)
"ConversationReply",
"ConversationReplyStatus",
"ConversationRequestProtocol",
# Agent-facing live status/health (Issue #3688)
"GatewayStatusProtocol",
"GatewayStatus",
# Inbound route binding (Issue #2225)
"RouteBinding",
"RouteFacts",
@@ -285,11 +344,20 @@ __all__ = [
"ResourceSample",
"ResourcePressurePolicyProtocol",
"MemoryPressurePolicy",
# Gateway memory-pressure cache eviction (Issue #3804)
"WarmSession",
"MemoryPressureProtocol",
"plan_pressure_evictions",
# Gateway rate-limit admission (Issue #2532)
"RateLimitDecision",
"RateLimitPolicyProtocol",
"RateLimitPolicy",
"SlidingWindowRateLimitPolicy",
# Durable-queue dead-letter decision (Issue #3519)
"PERMANENT_ERROR_CLASSES",
"DeadLetterDecision",
"DeadLetterPolicyProtocol",
"AttemptAndAgeDeadLetterPolicy",
# Port-less, restart-safe external drain trigger (Issue #2390)
"current_epoch",
"DrainMarkerPolicy",
@@ -307,6 +375,8 @@ __all__ = [
"FatalConfigError",
"classify_exit_reason",
"RestartLoopGuard",
# Fleet-level crash-loop breaker for channel supervision (Issue #3840)
"FleetSupervisionPolicy",
# Protocol version negotiation
"PROTOCOL_VERSION",
"MIN_PROTOCOL_VERSION",
@@ -333,6 +403,10 @@ __all__ = [
"LivenessDecision",
"LivenessPolicyProtocol",
"LivenessPolicy",
# Cluster-wide per-turn serialisation (Issue #3643)
"TurnLeaseToken",
"TurnLockProtocol",
"LocalTurnLock",
# Schema-validated inbound frame codec (Issue #2831)
"HelloParams",
"HelloResult",
@@ -354,6 +428,16 @@ __all__ = [
# Event-loop liveness watchdog (Issue #3385)
"LoopWatchdogPolicy",
"LoopWatchdog",
# Unified degraded-capability registry (Issue #3518)
"DegradedOwner",
"DegradedCapabilityProtocol",
"DegradedCapabilityRegistry",
"OWNER_KINDS",
"DEGRADED_STATES",
# Fail-closed read of the degraded-owner contract (Issue #3640)
"DegradedCapabilityLookupProtocol",
"OwnerUnavailable",
"assert_owner_available",
# Inbound trigger / webhook contract (Issue #2281)
"HookAction",
"HookConfig",
@@ -367,16 +451,26 @@ __all__ = [
"ApiConfig",
"ChannelRouteConfig",
"MultiChannelGatewayConfig",
# Config version stamp + doctor-driven migration (Issue #3841)
"GATEWAY_CONFIG_VERSION",
"ConfigVersionError",
"LegacyConfigRule",
"GATEWAY_CONFIG_RULES",
"is_config_current",
"migrate_config_with_doctor",
"PushConfig",
"RedisConfig",
"PresenceConfig",
"DeliveryConfig",
"PollingConfig",
"LivenessConfig",
"TurnLockConfig",
# Hot-reload registry (Issue #3378)
"HOT_APPLIABLE_KEYS",
"SupportsHotReload",
"is_hot_appliable",
"ReloadScope",
"classify_reload",
# Implementations (lazy loaded from praisonai wrapper)
"WebSocketGateway",
"GatewaySession",
@@ -5,7 +5,187 @@ Provides configuration dataclasses for gateway and session settings.
"""
from dataclasses import dataclass, field
from typing import Any, Dict, List, Mapping, Optional, Protocol, Set, runtime_checkable
from typing import (
Any,
Callable,
Dict,
List,
Mapping,
Optional,
Protocol,
Set,
Tuple,
runtime_checkable,
)
# ---------------------------------------------------------------------------
# Canonical config version + doctor-driven migration (Issue #3841)
# ---------------------------------------------------------------------------
#
# The gateway config carries a ``config_version`` stamp so the runtime, the
# operator, and ``gateway doctor --fix`` can all tell whether a config predates
# a breaking change. Migration is expressed as an ordered list of *declarative
# rules* (a detect predicate + a fix mutation as one unit) that ``doctor --fix``
# applies once to move an out-of-date config forward, then stamps the current
# version. This keeps migration a one-time repair rather than a permanent
# load-time heuristic, and gives the canonical config shape a single owner.
GATEWAY_CONFIG_VERSION = 1
class ConfigVersionError(ValueError):
"""Raised when a config carries a ``config_version`` this build can't handle.
Two cases, both operator-actionable rather than silently corrupting data:
* the stamp is a version *newer* than :data:`GATEWAY_CONFIG_VERSION` — an
older binary must not downgrade / migrate a config written by a newer
one (that would drop keys the newer schema added), so migration refuses;
* the stamp is present but not a non-boolean integer — a malformed stamp
(``true``, ``"1"``, ``1.0``) is a mistake, not "current", so it is
rejected instead of being treated as version 1 via ``True == 1``.
"""
@dataclass
class LegacyConfigRule:
"""A single declarative config-migration rule.
``detect`` returns True when the (old) shape this rule fixes is present in
the raw config mapping; ``fix`` returns the mutated mapping moving it toward
the current version; ``reason`` is an operator-facing description rendered by
``gateway doctor --fix``. Rules are pure functions of the raw mapping so the
same set can be reasoned about, tested, and applied identically everywhere.
"""
detect: Callable[[Dict[str, Any]], bool]
fix: Callable[[Dict[str, Any]], Dict[str, Any]]
reason: str
def _detect_allowed_users_csv(raw: Dict[str, Any]) -> bool:
channels = raw.get("channels")
if not isinstance(channels, dict):
return False
return any(
isinstance(ch, dict) and isinstance(ch.get("allowed_users"), str)
for ch in channels.values()
)
def _fix_allowed_users_csv(raw: Dict[str, Any]) -> Dict[str, Any]:
for ch in raw.get("channels", {}).values():
if isinstance(ch, dict) and isinstance(ch.get("allowed_users"), str):
value = ch["allowed_users"]
ch["allowed_users"] = (
[u.strip() for u in value.split(",") if u.strip()] if value else []
)
return raw
def _detect_missing_group_policy(raw: Dict[str, Any]) -> bool:
channels = raw.get("channels")
if not isinstance(channels, dict):
return False
return any(
isinstance(ch, dict) and "group_policy" not in ch
for ch in channels.values()
)
def _fix_missing_group_policy(raw: Dict[str, Any]) -> Dict[str, Any]:
for ch in raw.get("channels", {}).values():
if isinstance(ch, dict) and "group_policy" not in ch:
ch["group_policy"] = "mention_only"
return raw
# Ordered set of migration rules. Each moves an older config shape toward the
# current canonical form; ``migrate_config_with_doctor`` applies them once and
# stamps ``config_version``. New breaking renames/retirements append a rule here
# and bump ``GATEWAY_CONFIG_VERSION`` — the canonical shape has one owner.
GATEWAY_CONFIG_RULES: "List[LegacyConfigRule]" = [
LegacyConfigRule(
detect=_detect_allowed_users_csv,
fix=_fix_allowed_users_csv,
reason="migrating allowed_users (string) -> list [rule: allowed_users_csv_to_list]",
),
LegacyConfigRule(
detect=_detect_missing_group_policy,
fix=_fix_missing_group_policy,
reason="setting group_policy secure default 'mention_only' [rule: group_policy_default]",
),
]
def _parse_config_version(raw: Mapping[str, Any]) -> Optional[int]:
"""Return the config's ``config_version`` as an int, or None if unstamped.
Rejects a malformed stamp so ``config_version: true`` cannot masquerade as
version 1 (``True == 1``) and a string/float stamp cannot slip through.
"""
if "config_version" not in raw:
return None
value = raw["config_version"]
if isinstance(value, bool) or not isinstance(value, int):
raise ConfigVersionError(
f"Invalid gateway config_version {value!r}: expected an integer "
f"(current is {GATEWAY_CONFIG_VERSION}). Fix or remove the stamp."
)
return value
def is_config_current(raw: Mapping[str, Any]) -> bool:
"""Return whether ``raw`` already carries the current ``config_version``.
A malformed stamp (non-integer / boolean) is not "current" — it raises
:class:`ConfigVersionError` so operators fix it rather than having it
silently coerced (``True == 1``).
"""
return _parse_config_version(raw) == GATEWAY_CONFIG_VERSION
def migrate_config_with_doctor(
raw: Dict[str, Any],
) -> "Tuple[Dict[str, Any], List[str]]":
"""Apply the declarative migration rules once and stamp ``config_version``.
Returns ``(migrated, applied_reasons)``. The input is copied shallowly (and
per-channel dicts copied) so the caller's mapping is not mutated in place.
Only rules whose ``detect`` fires contribute a reason, so a config already
at the current shape migrates cleanly with an empty reason list while still
receiving the version stamp. This is the single migration executor behind
``gateway doctor --fix``.
Raises :class:`ConfigVersionError` when the config was written by a *newer*
build (its stamp exceeds :data:`GATEWAY_CONFIG_VERSION`) or the stamp is
malformed — an older binary must never downgrade a newer config or drop
keys it does not understand.
"""
source_version = _parse_config_version(raw)
if source_version is not None and source_version > GATEWAY_CONFIG_VERSION:
raise ConfigVersionError(
f"gateway config_version {source_version} is newer than this "
f"build supports ({GATEWAY_CONFIG_VERSION}). Upgrade praisonai / "
"praisonai-bot to a version that understands this config instead "
"of migrating it with an older one."
)
migrated: Dict[str, Any] = dict(raw)
channels = migrated.get("channels")
if isinstance(channels, dict):
migrated["channels"] = {
name: (dict(ch) if isinstance(ch, dict) else ch)
for name, ch in channels.items()
}
applied: List[str] = []
for rule in GATEWAY_CONFIG_RULES:
if rule.detect(migrated):
migrated = rule.fix(migrated)
applied.append(rule.reason)
migrated["config_version"] = GATEWAY_CONFIG_VERSION
return migrated, applied
# ---------------------------------------------------------------------------
@@ -39,6 +219,65 @@ def is_hot_appliable(path: str) -> bool:
return any(path.startswith(key + ".") for key in HOT_APPLIABLE_KEYS)
# ---------------------------------------------------------------------------
# Reload scope classification (Issue #3440)
# ---------------------------------------------------------------------------
#
# The wrapper/bot gateway builds a concrete reload plan (which channels to
# bounce, whether to recreate agents, whether to full-restart). The *rules*
# for that plan — hot-appliable vs channel-scoped vs full — must stay
# canonical in core so every runtime reloads identically, rather than being
# duplicated ad-hoc per runtime. This is a pure string classification with no
# heavy imports; the wrapper consumes it and only implements the effects.
class ReloadScope:
"""Canonical classification of a changed config ``path``'s reload scope.
Values are plain strings so wrapper/runtime code can compare without
importing this class. ``FULL`` is the fail-safe default for unknown or
structural changes.
- ``HOT``: apply in place, no restart (see :func:`is_hot_appliable`).
- ``CHANNEL``: a change under ``channels.<name>`` — restart only that one
channel; other channels keep their connections and in-flight turns.
- ``AGENTS``: an agent/provider/guardrails change — recreate agents only,
without bouncing channels.
- ``FULL``: unknown or structural change — full restart (fail-safe).
"""
HOT = "hot"
CHANNEL = "channel"
AGENTS = "agents"
FULL = "full"
def classify_reload(path: str) -> str:
"""Classify a changed dotted config ``path`` into a :class:`ReloadScope`.
Canonical, side-effect-free classification shared by every runtime so a
hot-reload plan is built identically regardless of who loads the config.
Anything not explicitly recognised falls through to ``ReloadScope.FULL``,
keeping full restart the fail-safe default for structural changes.
"""
if is_hot_appliable(path):
return ReloadScope.HOT
parts = path.split(".")
head = parts[0] if parts else ""
# A change scoped to a single channel (``channels.<name>...``) only needs
# that channel restarted. The bare ``channels`` section (no name) — and a
# malformed empty name like ``channels.`` — is a structural change and
# stays a full restart (fail-safe).
if head == "channels" and len(parts) >= 2 and parts[1]:
return ReloadScope.CHANNEL
# Agent-affecting changes recreate agents without bouncing channels.
if head in ("agents", "provider", "guardrails"):
return ReloadScope.AGENTS
return ReloadScope.FULL
@runtime_checkable
class SupportsHotReload(Protocol):
"""Protocol a gateway implements to apply hot-reloadable config in place.
@@ -61,7 +300,11 @@ class SessionConfig:
Attributes:
timeout: Session timeout in seconds (0 = no timeout)
max_messages: Maximum messages to keep in history (0 = unlimited)
persist: Whether to persist session state
persist: Whether to persist session state. Defaults to True so a
gateway started from the out-of-box path remembers conversations
across restarts/redeploys via the SQLite transcript store. Set
``persist: false`` in the config to opt into ephemeral, in-memory
sessions.
persist_path: Path for session persistence
store: Persistence backend when ``persist`` is set — ``"sqlite"``
(default: transcripts in a WAL SQLite DB with concurrent readers
@@ -74,7 +317,7 @@ class SessionConfig:
timeout: int = 3600 # 1 hour default
max_messages: int = 1000
persist: bool = False
persist: bool = True # durable by default; set persist=False for ephemeral
persist_path: Optional[str] = None
store: str = "sqlite" # "sqlite" (concurrent, indexed) | "file" (legacy JSON)
resume_window: int = 86400 # 24 hours default
@@ -189,7 +432,11 @@ class DeliveryConfig:
max_retries: Maximum retry attempts
retry_backoff: Exponential backoff multiplier
message_ttl: How long to retain unacknowledged messages (seconds)
store_backend: Message store backend ("memory" or "redis")
store_backend: Message store backend — ``"sqlite"`` (default,
zero-dependency durable store so the at-least-once guarantee
survives a gateway restart/redeploy), ``"redis"`` (durable +
multi-process horizontal fan-out) or ``"memory"`` (explicit,
ephemeral opt-out for testing/single-process throwaway use).
"""
enabled: bool = True
@@ -197,7 +444,15 @@ class DeliveryConfig:
max_retries: int = 3
retry_backoff: float = 2.0
message_ttl: int = 86400
store_backend: str = "memory"
store_backend: str = "sqlite"
def __post_init__(self) -> None:
"""Validate configuration values."""
if self.store_backend not in ("sqlite", "redis", "memory"):
raise ValueError(
f"Invalid delivery store_backend {self.store_backend!r}; "
"expected 'sqlite', 'redis' or 'memory'"
)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
@@ -341,6 +596,73 @@ class LivenessConfig:
)
@dataclass
class TurnLockConfig:
"""Configuration for cluster-wide per-turn serialisation (Issue #3643).
Selects the backend for the gateway's per-turn lock — the guarantee that
only one turn runs against a given resolved session at a time. The default
``"local"`` backend reproduces today's in-process ``asyncio.Lock`` /
``LockMap`` behaviour exactly (zero cost, no new dependency), so
single-replica deployments are byte-for-byte unchanged. Selecting
``"redis"`` extends serialisation across every replica so a
horizontally-scaled gateway (``replicas > 1``) no longer runs concurrent
turns on one session — the concrete distributed lock lives in the
wrapper/bot package and reuses the existing ``RedisConfig`` connection and
the scheduler's proven owner+TTL lease pattern.
This maps onto the pure core
:class:`~praisonaiagents.gateway.protocols.TurnLockProtocol`.
Attributes:
backend: ``"local"`` (default, in-process ``asyncio.Lock``) or
``"redis"`` (distributed lease, cluster-wide serialisation).
ttl: Lease time-to-live in seconds for a distributed backend. Bounds
how long a crashed holder's lease survives before it is reclaimable,
so a dead replica cannot wedge a healthy session (fail-open /
self-healing, as the scheduler already does). Inert for ``"local"``.
url: Optional Redis URL for the ``"redis"`` backend. When omitted the
distributed lock reuses the gateway's configured ``RedisConfig``.
"""
backend: str = "local"
ttl: float = 60.0
url: Optional[str] = None
def __post_init__(self) -> None:
if self.backend not in ("local", "redis"):
raise ValueError(
f"Invalid turn_lock backend {self.backend!r}; "
"expected 'local' or 'redis'"
)
if not self.ttl > 0:
raise ValueError("turn_lock ttl must be > 0")
@property
def enabled(self) -> bool:
"""Whether a distributed (cross-replica) turn lock is selected."""
return self.backend != "local"
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary (hides sensitive URL)."""
return {
"backend": self.backend,
"ttl": self.ttl,
"url": "***" if self.url else None,
}
@classmethod
def from_dict(cls, data: Optional[Dict[str, Any]]) -> "TurnLockConfig":
"""Create from a parsed ``gateway.turn_lock`` mapping (tolerant of None)."""
if not isinstance(data, dict):
return cls()
return cls(
backend=str(data.get("backend") or "local"),
ttl=float(data.get("ttl") or 60.0),
url=data.get("url"),
)
@dataclass
class ApiConfig:
"""Configuration for additive protocol surfaces on the gateway app.
@@ -419,6 +741,12 @@ class GatewayConfig:
session_config: SessionConfig = field(default_factory=SessionConfig)
heartbeat_interval: int = 30
reconnect_timeout: int = 60
# Issue #3467: per-turn wall-clock ceiling. When > 0, a single agent turn
# that runs longer than this many seconds is cancelled (cooperatively via
# the agent's interrupt controller and by cancelling the driving task) so a
# runaway turn cannot wedge the serial per-session queue. 0 = no timeout
# (today's behaviour: a turn runs to completion).
per_turn_timeout: float = 0.0
ssl_cert: Optional[str] = None
ssl_key: Optional[str] = None
max_buffered_bytes: int = 1024 * 1024 # 1MB default
@@ -445,6 +773,11 @@ class GatewayConfig:
# Issue #2798: application-level connection liveness (ping/pong heartbeat +
# half-open reaper). Opt-in; disabled by default so behaviour is unchanged.
liveness: LivenessConfig = field(default_factory=LivenessConfig)
# Issue #3643: cluster-wide per-turn serialisation. Default "local" backend
# keeps today's in-process asyncio.Lock behaviour (single-replica); "redis"
# serialises turns across replicas so a horizontally-scaled gateway does not
# run concurrent turns on one session.
turn_lock: "TurnLockConfig" = field(default_factory=lambda: TurnLockConfig())
def __post_init__(self) -> None:
"""Post-initialization to set bind_host from host if not specified and validate values."""
@@ -464,6 +797,10 @@ class GatewayConfig:
raise ValueError("heartbeat_interval must be >= 0")
if self.reconnect_timeout < 0:
raise ValueError("reconnect_timeout must be >= 0")
if self.per_turn_timeout < 0:
raise ValueError(
"per_turn_timeout must be >= 0 (use 0 to disable the per-turn timeout)"
)
if self.max_concurrent_runs < 0:
raise ValueError(
"max_concurrent_runs must be >= 0 (use 0 to disable admission control)"
@@ -549,6 +886,7 @@ class GatewayConfig:
"session_config": self.session_config.to_dict(),
"heartbeat_interval": self.heartbeat_interval,
"reconnect_timeout": self.reconnect_timeout,
"per_turn_timeout": self.per_turn_timeout,
"ssl_enabled": bool(self.ssl_cert and self.ssl_key),
"max_buffered_bytes": self.max_buffered_bytes,
"max_queued_frames": self.max_queued_frames,
@@ -561,6 +899,7 @@ class GatewayConfig:
"scope_policy_enabled": self.has_scope_policy,
"api": self.api.to_dict(),
"liveness": self.liveness.to_dict(),
"turn_lock": self.turn_lock.to_dict(),
}
@property
@@ -692,7 +1031,7 @@ class MultiChannelGatewayConfig:
session_config = SessionConfig(
timeout=sc_data.get("timeout", 3600),
max_messages=sc_data.get("max_messages", 1000),
persist=sc_data.get("persist", False),
persist=sc_data.get("persist", True),
persist_path=sc_data.get("persist_path"),
store=sc_data.get("store", "sqlite"),
resume_window=sc_data.get("resume_window", 86400),
@@ -734,6 +1073,7 @@ class MultiChannelGatewayConfig:
session_config=session_config,
heartbeat_interval=gw_data.get("heartbeat_interval", 30),
reconnect_timeout=gw_data.get("reconnect_timeout", 60),
per_turn_timeout=float(gw_data.get("per_turn_timeout", 0.0) or 0.0),
ssl_cert=gw_data.get("ssl_cert"),
ssl_key=gw_data.get("ssl_key"),
max_buffered_bytes=int(gw_data.get("max_buffered_bytes", 1024 * 1024)),
@@ -750,6 +1090,7 @@ class MultiChannelGatewayConfig:
auth_scopes=auth_scopes,
api=ApiConfig.from_dict(gw_data.get("api")),
liveness=LivenessConfig.from_dict(gw_data.get("liveness")),
turn_lock=TurnLockConfig.from_dict(gw_data.get("turn_lock")),
)
# Parse agents section (pass through as dicts)
@@ -0,0 +1,237 @@
"""
Unified degraded-capability registry for the gateway (Issue #3518).
The gateway records degradation in several disconnected places but historically
exposed only *degraded channels* through ``health()``. Provider/model credential
failures, unresolved ``SecretRef`` capabilities, and route-level failures were
classified-but-not-surfaced or not surfaced at all — a partial degraded state
where some failures are effectively invisible until a message silently goes
nowhere.
This module provides one small, process-local contract that any owner records
into at the boundary that owns it, so ``gateway status`` / ``gateway doctor`` /
``health()`` can list *every* degraded owner with a consistent, redacted shape
and an actionable next step. It is a generic protocol + default in-process impl
with no heavy third-party imports, mirroring the existing gateway
policy-protocol + default-impl pattern in ``protocols.py``.
Owners call :meth:`DegradedCapabilityRegistry.mark` when they enter a degraded
state and :meth:`DegradedCapabilityRegistry.clear` on recovery; readers call
:meth:`DegradedCapabilityRegistry.list_degraded`.
"""
from __future__ import annotations
import threading
from dataclasses import dataclass
from typing import Dict, List, Optional, Protocol, Tuple, runtime_checkable
# Closed vocabularies kept as module constants so owners and readers agree on
# the shape without importing an enum-heavy dependency.
OWNER_KINDS: Tuple[str, ...] = ("channel", "provider", "capability", "route", "gateway")
DEGRADED_STATES: Tuple[str, ...] = ("cold", "stale")
class OwnerUnavailable(Exception):
"""Typed, redacted outcome raised when work targets a degraded owner.
Issue #3640: the fail-closed read of the degraded-owner contract. When a
request targets an owner already recorded as degraded, the dispatch path
calls :meth:`DegradedCapabilityRegistry.assert_owner_available` (or the
module-level :func:`assert_owner_available`) and this is raised instead of
letting the work proceed and fail silently. It carries only the redacted,
operator-safe fields already on :class:`DegradedOwner` — never token/secret
material — so the loop can turn it into a visible "unavailable, here is the
next action" outcome.
"""
def __init__(self, owner: "DegradedOwner") -> None:
self.owner = owner
self.owner_kind = owner.owner_kind
self.owner_id = owner.owner_id
self.state = owner.state
self.reason = owner.reason
self.retry_hint = owner.retry_hint
super().__init__(
f"{owner.owner_kind} {owner.owner_id!r} unavailable ({owner.state}): "
f"{owner.reason}"
+ (f" — {owner.retry_hint}" if owner.retry_hint else "")
)
def to_dict(self) -> Dict[str, str]:
"""Render the redacted outcome for a JSON/operator surface."""
return self.owner.to_dict()
@dataclass(frozen=True)
class DegradedOwner:
"""A single degraded owner, redacted and operator-safe.
Attributes:
owner_kind: One of ``channel | provider | capability | route | gateway``.
owner_id: Stable identity, e.g. ``telegram:main``, ``openai``, ``mcp:notion``.
state: ``cold`` (no last-known-good) or ``stale`` (reusing last-known-good).
reason: Redacted, operator-safe explanation — never leaks token/secret material.
retry_hint: The exact next action, and it MUST name a command that
exists — e.g. ``praisonai gateway doctor --fix`` (implemented in the
wrapper CLI as a detect → repair → re-validate loop). Never point an
operator at a non-existent command.
"""
owner_kind: str
owner_id: str
state: str
reason: str
retry_hint: str = ""
def __post_init__(self) -> None:
# Enforce the closed vocabularies at construction so an unsupported
# owner_kind/state can never reach an operator-facing record. Frozen
# dataclass validation belongs here rather than being duplicated by
# every writer and reader (Issue #3518).
if self.owner_kind not in OWNER_KINDS:
raise ValueError(
f"owner_kind {self.owner_kind!r} not in {OWNER_KINDS!r}"
)
if self.state not in DEGRADED_STATES:
raise ValueError(
f"state {self.state!r} not in {DEGRADED_STATES!r}"
)
def to_dict(self) -> Dict[str, str]:
return {
"owner_kind": self.owner_kind,
"owner_id": self.owner_id,
"state": self.state,
"reason": self.reason,
"retry_hint": self.retry_hint,
}
@runtime_checkable
class DegradedCapabilityProtocol(Protocol):
"""Contract every degraded-capability registry implements.
Owners write facts where they happen (``mark``/``clear``); readers
(``health()``, ``gateway status``/``doctor``) read them where needed
(``list_degraded``).
Note (Issue #3640): the fail-closed point read ``find`` is deliberately
*not* on this base contract. Adding it here would break structural
``isinstance`` conformance for any existing external registry that only
implemented ``mark``/``clear``/``list_degraded``. The point read lives on
the extended :class:`DegradedCapabilityLookupProtocol`, and the module
guard degrades gracefully to ``list_degraded()`` when ``find`` is absent.
"""
def mark(self, owner: DegradedOwner) -> None: ...
def clear(self, owner_kind: str, owner_id: str) -> None: ...
def list_degraded(self) -> List[DegradedOwner]: ...
@runtime_checkable
class DegradedCapabilityLookupProtocol(DegradedCapabilityProtocol, Protocol):
"""Extended contract for registries that support the point read (Issue #3640).
Kept separate from :class:`DegradedCapabilityProtocol` so upgrading does not
silently break structural conformance for pre-existing external registries.
"""
def find(self, owner_kind: str, owner_id: str) -> Optional[DegradedOwner]: ...
class DegradedCapabilityRegistry:
"""Default in-process, thread-safe degraded-capability registry.
Keyed on ``(owner_kind, owner_id)`` so re-marking an already-degraded owner
updates its record in place rather than duplicating it. Reads return a
stable, sorted snapshot so operator output is deterministic.
"""
def __init__(self) -> None:
self._lock = threading.Lock()
self._owners: Dict[Tuple[str, str], DegradedOwner] = {}
def mark(self, owner: DegradedOwner) -> None:
"""Record (or update) a degraded owner. Last write wins per key."""
with self._lock:
self._owners[(owner.owner_kind, owner.owner_id)] = owner
def clear(self, owner_kind: str, owner_id: str) -> None:
"""Remove a degraded owner on recovery. Idempotent."""
with self._lock:
self._owners.pop((owner_kind, owner_id), None)
def list_degraded(self) -> List[DegradedOwner]:
"""Return every currently-degraded owner as a stable, sorted list."""
with self._lock:
values = list(self._owners.values())
return sorted(values, key=lambda o: (o.owner_kind, o.owner_id))
def find(self, owner_kind: str, owner_id: str) -> Optional[DegradedOwner]:
"""Return the degraded record for one owner, or ``None`` if healthy.
Issue #3640: the point read the fail-closed dispatch path uses to ask
"is *this* owner currently degraded?" without scanning the whole list.
"""
with self._lock:
return self._owners.get((owner_kind, owner_id))
def assert_owner_available(self, owner_kind: str, owner_id: str) -> None:
"""Fail-closed guard: raise :class:`OwnerUnavailable` if degraded.
Issue #3640: the read that turns a recorded degradation into a fact the
request path consults. A dispatch path calls this before doing work for
an owner; if that owner is degraded the request short-circuits to a
typed, redacted "unavailable — next action" outcome instead of
proceeding and failing silently. No-op when the owner is healthy.
"""
owner = self.find(owner_kind, owner_id)
if owner is not None:
raise OwnerUnavailable(owner)
def to_list(self) -> List[Dict[str, str]]:
"""Convenience: ``list_degraded()`` rendered as plain dicts for JSON."""
return [owner.to_dict() for owner in self.list_degraded()]
def assert_owner_available(
registry: Optional[DegradedCapabilityProtocol],
owner_kind: str,
owner_id: str,
) -> None:
"""Fail-closed guard for a possibly-absent registry (Issue #3640).
The dispatch path holds an *optional* shared registry (a gateway may run
without one). This wrapper is a no-op when ``registry`` is ``None`` and
otherwise delegates to :meth:`DegradedCapabilityRegistry.assert_owner_available`,
raising :class:`OwnerUnavailable` if the owner is degraded — so a caller can
guard a request without repeating the ``None`` check at every call site.
"""
if registry is None:
return
guard = getattr(registry, "assert_owner_available", None)
if callable(guard):
guard(owner_kind, owner_id)
return
# Backward compatibility (Issue #3640): a pre-existing registry may implement
# only ``mark``/``clear``/``list_degraded`` and have neither the new guard nor
# ``find``. Prefer the point read when present, otherwise derive the owner
# from ``list_degraded()`` so a legacy registry still fails-closed rather than
# raising ``AttributeError``.
find = getattr(registry, "find", None)
if callable(find):
owner = find(owner_kind, owner_id)
else:
owner = next(
(
o
for o in registry.list_degraded()
if o.owner_kind == owner_kind and o.owner_id == owner_id
),
None,
)
if owner is not None:
raise OwnerUnavailable(owner)
@@ -11,9 +11,14 @@ The ``praisonai`` wrapper provides backward-compatible shims.
from __future__ import annotations
import math
import time
import uuid
from contextlib import AbstractContextManager, contextmanager
from contextlib import (
AbstractAsyncContextManager,
AbstractContextManager,
contextmanager,
)
from dataclasses import dataclass, field
from enum import Enum
from typing import (
@@ -27,6 +32,7 @@ from typing import (
Literal,
Optional,
Protocol,
Sequence,
Set,
Tuple,
TypedDict,
@@ -39,6 +45,7 @@ GATEWAY_PROTOCOL_VERSION = 1
MIN_CLIENT_PROTOCOL_VERSION = 1
if TYPE_CHECKING:
import asyncio
from praisonai.gateway.pairing import PairedChannel
from ..agent import Agent
from ..bots.presentation import MessagePresentation
@@ -187,6 +194,9 @@ class EventType(str, Enum):
# Streaming events (relayed from agent's StreamEventEmitter)
TOKEN_STREAM = "token_stream"
TOOL_CALL_STREAM = "tool_call_stream"
REASONING_STREAM = "reasoning_stream"
TOOL_PROGRESS_STREAM = "tool_progress_stream"
STREAM_ERROR = "stream_error"
STREAM_END = "stream_end"
# System events
@@ -2255,6 +2265,51 @@ class HomeChannelRegistryProtocol(Protocol):
...
@dataclass(frozen=True)
class DeliveryValidation:
"""Result of a creation-time delivery-target pre-flight (Issue #3800).
A scheduled job or agent-initiated proactive message carries a
``DeliveryTarget`` whose reachability is otherwise only discovered when the
job *fires* — potentially hours later, where an unroutable target is
silently dropped or dead-target self-healed. This closed shape lets a
resolver answer "will this route?" the moment the send is created, so the
creator gets an immediate, actionable error instead of a late invisible
drop.
Attributes:
ok: Whether the target resolves to a reachable channel/route.
reason: On failure, a human-readable explanation of why it is
unroutable (empty when ``ok``).
hint: On failure, an actionable next step (e.g. the configured
channels, or a command to list them); empty when ``ok``.
preview: A dry-run preview of the destination (e.g.
``"telegram:@alice (session main)"``) suitable for surfacing to the
creator before commit.
"""
ok: bool
reason: str = ""
hint: str = ""
preview: str = ""
class ScheduleTargetError(ValueError):
"""Raised when a scheduled/agent-initiated send has an unroutable target.
Carries the structured :class:`DeliveryValidation` reason/hint so the
scheduler/CLI can fail fast at *creation* time with an actionable message
(``channel 'telegramm' is not configured. Configured: telegram, slack.``)
rather than accepting a target that is only discovered dead at fire time.
"""
def __init__(self, reason: str, hint: str = ""):
self.reason = reason
self.hint = hint
message = f"{reason} {hint}".strip() if hint else reason
super().__init__(message)
@runtime_checkable
class DeliveryResolverProtocol(Protocol):
"""Protocol for resolving delivery routing tokens.
@@ -2287,6 +2342,46 @@ class DeliveryResolverProtocol(Protocol):
...
@runtime_checkable
class DeliveryPreflightProtocol(Protocol):
"""Optional creation-time pre-flight extension for delivery resolvers.
Kept separate from :class:`DeliveryResolverProtocol` so the base contract
stays ``resolve()``-only: an existing resolver that implements just
``resolve`` still satisfies ``DeliveryResolverProtocol`` under
``isinstance``/``runtime_checkable``. A resolver that can additionally
pre-flight or preview a target against its live registry advertises that by
also satisfying this protocol; callers duck-type on it and fall back to a
structural, registry-free check (:meth:`DeliveryTarget.preview`) otherwise.
"""
def validate_target(
self, target: "DeliveryTarget"
) -> "DeliveryValidation":
"""Pre-flight ``target`` against the live channel/route registry.
Called at *creation* time (when a scheduled/agent-initiated send is
registered) so an unroutable target is rejected or warned on with an
actionable message, instead of being silently dropped when the job
fires.
Returns:
A :class:`DeliveryValidation` (``ok`` / ``reason`` / ``hint`` /
``preview``).
"""
...
def preview_target(
self, target: "DeliveryTarget"
) -> str:
"""Return a dry-run preview of where ``target`` will deliver.
A short, display-only string (e.g. ``"telegram:@alice (session
main)"``) so the creator sees the destination before commit.
"""
...
# ---------------------------------------------------------------------------
# Agent-facing outbound messaging (Issue #2183)
# ---------------------------------------------------------------------------
@@ -2387,6 +2482,192 @@ class OutboundMessengerProtocol(Protocol):
...
# ---------------------------------------------------------------------------
# Agent-callable cross-conversation request/reply (Issue #3689)
#
# ``send_message`` is fire-and-deliver: it returns a delivery receipt, not the
# target's answer. This adds the missing *ask another conversation and await
# the reply* capability — an agent can route a question to a symbolic target
# and get the next correlated inbound reply back into its own turn, bounded by
# a timeout. It reuses ``send_message``'s target resolution and the outbound
# send-policy guard; the only new surface is a one-shot reply correlation.
#
# Core owns only the *shape*: the typed outcome (:class:`ConversationReply`),
# the protocol seam (:class:`ConversationRequestProtocol`), the context-var
# registration slot (in ``session.context``), and the built-in
# ``ask_conversation`` tool. The correlation-aware reply source is bound by the
# running gateway/bot exactly as ``register_outbound_messenger`` binds the
# outbound side — no heavy import lives in core. Every path ends in a recorded
# outcome (reply | timeout | undelivered | no_route) — never a silent hang.
# ---------------------------------------------------------------------------
ConversationReplyStatus = Literal["reply", "timeout", "undelivered", "no_route"]
"""Closed set of outcomes for an :func:`ask_conversation` request.
* ``reply`` — the target replied within the timeout; ``text`` carries it.
* ``timeout`` — the prompt was delivered but no reply arrived in time.
* ``undelivered`` — the prompt could not be delivered to the target.
* ``no_route`` — the target could not be resolved to a reachable channel.
"""
@dataclass
class ConversationReply:
"""Outcome of an agent-initiated cross-conversation request (Issue #3689).
Every request resolves to exactly one of the :data:`ConversationReplyStatus`
outcomes, so the agent always gets a typed answer back into its turn rather
than a silent hang.
Attributes:
status: The outcome (``reply`` / ``timeout`` / ``undelivered`` /
``no_route``).
target: The resolved target the prompt was routed to.
text: The reply text, populated only when ``status == "reply"``.
detail: Optional extra information (error text, message id, etc.).
"""
status: ConversationReplyStatus
target: str = ""
text: str = ""
detail: Optional[str] = None
def as_dict(self) -> Dict[str, Any]:
"""Convert to a serializable dictionary for the tool return value."""
data: Dict[str, Any] = {"status": self.status}
if self.target:
data["from"] = self.target
if self.status == "reply":
data["text"] = self.text
if self.detail:
data["detail"] = self.detail
return data
@runtime_checkable
class ConversationRequestProtocol(Protocol):
"""Protocol for agent-facing cross-conversation request/reply.
A concrete implementation is provided by the running gateway/bot (in the
praisonai wrapper) and registered into the per-turn context so the built-in
``ask_conversation`` tool can resolve it. It sends the prompt via the same
delivery stack ``send_message`` uses, then correlates the *next inbound
reply* from that target (via the existing ``correlation_id``) with a bounded
timeout, returning a typed :class:`ConversationReply`.
Example usage (implementation in praisonai_bot.gateway)::
requester = BotConversationRequester(router, origin=origin)
token = register_conversation_requester(requester)
try:
... # agent runs; ask_conversation tool resolves the requester
finally:
clear_conversation_requester(token)
"""
async def ask(
self,
target: str,
text: str,
*,
timeout_s: float = 120.0,
) -> "ConversationReply":
"""Send ``text`` to ``target`` and await the next correlated reply.
Args:
target: Symbolic target token ("origin", "<platform>",
"<platform>:<chat_id>[:<thread_id>]", or a friendly alias).
text: The prompt to send.
timeout_s: Maximum seconds to wait for a reply before returning a
``timeout`` outcome.
Returns:
A :class:`ConversationReply` describing the outcome.
"""
...
# ---------------------------------------------------------------------------
# Agent-callable live status/health (Issue #3688)
#
# The gateway already computes rich live state (per-turn run status, active
# sessions, delivery/DLQ backlog, degraded owners) but only humans/CLI/HTTP can
# read it. This read-only protocol lets the running gateway bind a live source
# into the per-turn context so the built-in ``gateway_status`` tool can report
# it — mirroring how ``OutboundMessengerProtocol`` backs ``send_message``. Core
# ships only the protocol + snapshot shape; the concrete binding (reading
# ``health()`` / ``metrics_snapshot()`` / the session registry) lives in the
# praisonai-bot wrapper. It is strictly read-only, redaction-aware and
# visibility-scoped (no secrets, no cross-tenant leakage).
# ---------------------------------------------------------------------------
@dataclass
class GatewayStatus:
"""Read-only snapshot of the gateway's live self-state (Issue #3688).
A neutral, serializable shape the agent can reason about and report. All
fields default to empty so a partial/minimal binding is valid and the tool
never dead-ends. The concrete binding populates only the visibility-scoped
facts it can safely expose.
Attributes:
run: Current turn/run status (e.g. "idle", "busy", "queued").
queued: Number of turns queued behind the current one.
active_sessions: Count of active sessions (visibility-scoped).
sessions_by_channel: Active-session counts keyed by channel/platform.
delivery: Delivery-health facts (e.g. outbox_depth, dlq, dead_targets).
degraded: Degraded owners as ``{"owner": ..., "reason": ...}`` entries
(channels/capabilities/routes flagged configured-unavailable).
detail: Optional free-form extra context for the model.
"""
run: str = "idle"
queued: int = 0
active_sessions: int = 0
sessions_by_channel: Dict[str, int] = field(default_factory=dict)
delivery: Dict[str, Any] = field(default_factory=dict)
degraded: List[Dict[str, Any]] = field(default_factory=list)
detail: str = ""
def as_dict(self) -> Dict[str, Any]:
"""Convert to a serializable dictionary."""
return {
"run": self.run,
"queued": self.queued,
"active_sessions": self.active_sessions,
"sessions_by_channel": dict(self.sessions_by_channel),
"delivery": dict(self.delivery),
"degraded": list(self.degraded),
"detail": self.detail,
}
@runtime_checkable
class GatewayStatusProtocol(Protocol):
"""Protocol for agent-facing, read-only live status/health reporting.
A concrete implementation is provided by the running gateway/bot (in the
praisonai wrapper) and registered into the per-turn context so the built-in
``gateway_status`` tool can resolve it. It reads the same live objects the
HTTP endpoints already serve (``health()`` / ``metrics_snapshot()`` / the
session registry) and returns a redaction-aware, visibility-scoped
:class:`GatewayStatus`.
Example usage (implementation in praisonai_bot.gateway)::
status = BotGatewayStatus(gateway)
token = register_gateway_status(status)
try:
... # agent runs; gateway_status tool resolves the source
finally:
clear_gateway_status(token)
"""
def snapshot(self) -> "GatewayStatus":
"""Return a read-only snapshot of the gateway's live self-state."""
...
# ---------------------------------------------------------------------------
# Outbound send-policy guard (Issue #2226)
#
@@ -3057,6 +3338,136 @@ class MemoryPressurePolicy:
return AdmissionDecision.ADMIT
# ---------------------------------------------------------------------------
# Gateway memory-pressure cache eviction (Issue #3804)
#
# ``MemoryPressurePolicy`` above sheds *new* inbound turns under RSS pressure,
# but it never reclaims the memory already held by *idle* warm per-session
# agent caches. On a memory-limited host (a "$5 VPS", a Fly machine, a k8s pod
# with a cgroup limit) a busy gateway accumulates dozens of warm caches and,
# absent eviction, keeps climbing until the kernel OOM-kills the whole process
# — dropping *every* live session at once. This adds a pure, import-free
# planner that, given a memory *budget* and the LRU order of warm sessions,
# names the coldest rebuildable caches to soft-evict *before* the OOM killer
# fires. Each victim is transparently rebuilt from the persisted session store
# on its next turn, so eviction is cheap and lossless — as long as we never
# evict a session with an unflushed transcript or an in-flight turn.
#
# The *decision* lives here (provable in isolation, no event loop, no heavy
# imports), mirroring :class:`MemoryPressurePolicy`; the *mechanism* — the warm
# registry, the LRU order and the flushed/in-flight signals, plus reading the
# cgroup limit and anon RSS — lives in the running gateway (the wrapper).
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class WarmSession:
"""A warm per-session agent cache eligible for memory-pressure eviction.
A pure, import-free fact carried from the running gateway to
:func:`plan_pressure_evictions`. Attributes:
* ``session_id``: the cache key to soft-evict.
* ``last_activity``: monotonic/epoch seconds of the session's last turn;
the planner evicts the coldest (smallest ``last_activity``) first.
* ``in_flight``: ``True`` while a turn is executing — never evicted (would
abort live work).
* ``flushed``: ``True`` when the transcript is durably persisted — only a
flushed cache is rebuildable, so an unflushed one is never evicted
(would lose data).
"""
session_id: str
last_activity: float = 0.0
in_flight: bool = False
flushed: bool = True
@runtime_checkable
class MemoryPressureProtocol(Protocol):
"""Protocol for reading the memory budget and pressure of a gateway host.
Pure contract the gateway implements over its own process: report the
container's memory limit (from the cgroup v1/v2 memory limit) and the
current anonymous (non-reclaimable) RSS, so the eviction budget tracks the
*real* container ceiling rather than a hard-coded number. Both return
``None``/``0`` gracefully when the platform can't report them, so the
planner degrades to a no-op instead of crashing the gateway it protects.
"""
def cgroup_limit_mb(self) -> Optional[float]:
"""Return the container memory limit in MiB, or ``None`` if unknown."""
...
def anon_rss_mb(self) -> float:
"""Return current anonymous (non-reclaimable) RSS in MiB."""
...
def plan_pressure_evictions(
budget_mb: Optional[float],
rss_mb: float,
warm_sessions: Sequence[WarmSession],
*,
headroom_ratio: float = 0.9,
) -> List[str]:
"""Return the ``session_id``s to soft-evict, coldest (LRU) first.
A pure planner: it takes the container memory *budget* (typically the
cgroup limit), the current anonymous ``rss_mb`` and the warm-cache
registry, and names the coldest rebuildable caches to shed until RSS is
back within ``headroom_ratio`` of the budget. It never touches the event
loop or the caches themselves — the gateway enacts the returned plan.
Guards (a victim is *skipped*, never evicted, when):
* ``in_flight`` is ``True`` — an executing turn must not be aborted, or
* ``flushed`` is ``False`` — an unflushed transcript is not yet rebuildable
from the store, so evicting it would lose data.
Returns an empty list when RSS is within budget, the budget is unknown
(``None``/``<= 0``), or nothing evictable remains — so a host that can't
report a cgroup limit simply never soft-evicts (legacy behaviour).
"""
if budget_mb is None:
return []
try:
budget = float(budget_mb)
rss = float(rss_mb)
except (TypeError, ValueError):
return []
# Reject non-finite measurements (NaN/inf): a NaN budget or rss would slip
# past the ``rss <= target`` check (every comparison with NaN is False) and
# spuriously evict *every* warm cache — the opposite of protecting them.
if not math.isfinite(budget) or not math.isfinite(rss) or budget <= 0:
return []
try:
ratio = float(headroom_ratio)
except (TypeError, ValueError):
ratio = 0.9
if not (math.isfinite(ratio) and 0.0 < ratio <= 1.0):
ratio = 0.9
target = budget * ratio
if rss <= target:
return []
# Name the coldest evictable caches, LRU-first. We deliberately do not
# track per-cache bytes (guessing sizes would be scope creep) so we cannot
# remeasure RSS mid-plan; instead the planner returns every evictable cache
# coldest-first and the gateway evicts down that ordered list, re-sampling
# its own RSS as it goes and stopping as soon as it is back within target.
# Over-shedding is thus avoided by the enactor and under-shedding is caught
# on the next pass — keeping this decision pure and byte-agnostic.
evictable = [
s for s in warm_sessions
if not s.in_flight and s.flushed
]
if not evictable:
return []
evictable.sort(key=lambda s: (s.last_activity, s.session_id))
return [s.session_id for s in evictable]
# ---------------------------------------------------------------------------
# Gateway rate-limit admission (Issue #2532)
#
@@ -3264,6 +3675,163 @@ class SlidingWindowRateLimitPolicy:
RateLimitPolicy = RateLimitPolicyProtocol
# ---------------------------------------------------------------------------
# Durable-queue dead-letter decision (Issue #3519)
#
# The gateway's durable inbound journal and outbound queue must decide when a
# repeatedly-failing entry is a genuine *poison message* (dead-letter it) vs a
# victim of a *transient channel outage* (keep retrying). Deciding on the
# attempt counter alone dead-letters deliverable traffic during a routine
# few-minute API incident, because the exponential backoff burns the default
# five attempts in well under a minute — a silent-loss failure the durable
# queue exists to prevent.
#
# The fix is a pure, import-free *decision* contract, symmetric with the other
# gateway policy protocols above (``SendPolicyProtocol``,
# ``RateLimitPolicyProtocol``): a recoverable/transient failure is only
# dead-lettered once it is BOTH attempt-exhausted AND genuinely old, while a
# permanently-classified error (credentials revoked, permanent target) still
# short-circuits immediately. The durable-queue runtime in ``praisonai-bot``
# consumes it where it currently tests ``attempts >= max_attempts``.
# ---------------------------------------------------------------------------
# Error classes that are already *known-permanent* and should dead-letter
# immediately regardless of age — no amount of retrying recovers a revoked
# credential or a permanently-invalid target.
PERMANENT_ERROR_CLASSES: Tuple[str, ...] = ("credential", "permanent_target")
@dataclass(frozen=True)
class DeadLetterDecision:
"""Result of a dead-letter evaluation.
Attributes:
dead_letter: Whether the entry should be routed to the dead-letter
queue / marked ``permanent_failure`` now. When ``False`` the
caller reschedules the entry under its normal capped backoff.
reason: Short machine-readable explanation (``"permanent_error"``,
``"attempts_and_age"``, ``"retry"``) for logging/metrics.
"""
dead_letter: bool
reason: str = ""
@runtime_checkable
class DeadLetterPolicyProtocol(Protocol):
"""Protocol for the durable-queue dead-letter decision.
Pure, import-free decision contract consumed by the outbound queue's
``drain`` and the inbound journal's redelivery/replay paths. The runtime
supplies typed facts about a repeatedly-failing entry (its ``attempts``,
the ``first_seen_epoch`` it was first received, the current ``now_epoch``,
and a coarse ``error_class``) and the policy returns a
:class:`DeadLetterDecision`. Concrete queue state and side effects (SQLite
rows, DLQ enqueue) stay in the implementation; this keeps the *policy*
injectable and testable in isolation, symmetric with
:class:`SendPolicyProtocol` / :class:`RateLimitPolicyProtocol`.
A config-driven default (:class:`AttemptAndAgeDeadLetterPolicy`) is
provided for the common "poison vs transient" case.
"""
def should_dead_letter(
self,
*,
attempts: int,
first_seen_epoch: float,
now_epoch: float,
error_class: str = "",
) -> DeadLetterDecision:
"""Return a :class:`DeadLetterDecision` for the supplied facts."""
...
class AttemptAndAgeDeadLetterPolicy:
"""Default dead-letter policy: require BOTH attempt-exhaustion AND age.
Distinguishes a *poison message* (fails repeatedly over a long time) from
a *transient outage* (fails a few times quickly, then recovers). A
recoverable/transient failure is dead-lettered only once it satisfies
**both**:
1. ``attempts >= max_attempts``, and
2. ``age >= min_age_seconds`` (wall-clock age since first receipt).
Until an entry is genuinely old it keeps retrying under capped backoff
rather than being discarded, so a brief channel incident results in
delayed-but-delivered messages rather than a DLQ full of manual-replay
work. A truly poisoned entry still dead-letters — it keeps failing past
both thresholds.
An error whose ``error_class`` is known-permanent (see
:data:`PERMANENT_ERROR_CLASSES` — a revoked credential or a permanently
invalid target) short-circuits to dead-letter immediately regardless of
age, since retrying can never recover it.
``min_age_seconds=0`` restores the legacy attempt-count-only behaviour,
keeping the knob fully backward-compatible for callers that opt in.
Example::
AttemptAndAgeDeadLetterPolicy(max_attempts=5, min_age_seconds=6*3600)
"""
#: Default minimum age (6 hours) before a transient failure may dead-letter.
DEFAULT_MIN_AGE_SECONDS: int = 6 * 3600
def __init__(
self,
max_attempts: int = 5,
min_age_seconds: int = DEFAULT_MIN_AGE_SECONDS,
) -> None:
try:
attempts_ceiling = int(max_attempts)
except (TypeError, ValueError) as err:
raise ValueError(
f"max_attempts must be an integer, got {max_attempts!r}"
) from err
if attempts_ceiling < 1:
raise ValueError(
f"max_attempts must be >= 1, got {max_attempts!r}"
)
try:
min_age = float(min_age_seconds)
except (TypeError, ValueError) as err:
raise ValueError(
f"min_age_seconds must be a number, got {min_age_seconds!r}"
) from err
if min_age < 0:
raise ValueError(
f"min_age_seconds must be >= 0, got {min_age_seconds!r}"
)
self.max_attempts = attempts_ceiling
self.min_age_seconds = min_age
def should_dead_letter(
self,
*,
attempts: int,
first_seen_epoch: float,
now_epoch: float,
error_class: str = "",
) -> DeadLetterDecision:
# Known-permanent conditions can never be recovered by retrying.
if error_class in PERMANENT_ERROR_CLASSES:
return DeadLetterDecision(dead_letter=True, reason="permanent_error")
exhausted = attempts >= self.max_attempts
# Guard against a missing/zero first-seen stamp: treat it as "just now"
# so a malformed row is never prematurely dead-lettered on age.
age = now_epoch - first_seen_epoch if first_seen_epoch else 0.0
old_enough = age >= self.min_age_seconds
if exhausted and old_enough:
return DeadLetterDecision(dead_letter=True, reason="attempts_and_age")
return DeadLetterDecision(dead_letter=False, reason="retry")
# ---------------------------------------------------------------------------
# Port-less, restart-safe external drain trigger (Issue #2390)
#
@@ -3782,6 +4350,143 @@ class RestartLoopGuard:
self._events = []
class FleetSupervisionPolicy:
"""Pure fleet-level crash-loop breaker for channel supervision (Issue #3840).
Per-channel restart budgets (``ChannelHealthMonitor`` /
``ChannelRestartHistory`` in ``praisonai-bot``) throttle one misbehaving
channel, but they are blind to a *systemic* fault — a bad shared provider,
a network partition, an org-wide expired token — that makes *every* channel
restart at once. Each channel then independently stays "under budget" while
the fleet as a whole thrashes: a reconnect storm that floods logs, burns
CPU, and risks an upstream rate-limit ban with no single operator-visible
signal.
This is the aggregate breaker that sits *on top of* the per-channel budgets.
Like :class:`RestartLoopGuard` it is intentionally side-effect free (records
timestamps only, no I/O, no heavy deps) so the *decision* lives in core and
is provable in isolation; the wrapper owns the side effects (halting
restarts, recording one ``gateway`` degraded-owner entry).
The breaker trips when *either* aggregate signal crosses its threshold
within the trailing window:
* the fleet restart rate reaches ``fleet_restarts_per_hour`` restarts across
all channels, or
* the fraction of channels in a failing/parked state reaches
``failing_channel_fraction``.
Once tripped it stays tripped for ``breaker_cooldown_s`` so the caller
applies backpressure (stops auto-restarting, backs off) instead of feeding
the storm; after the cooldown it re-arms automatically.
Example::
policy = FleetSupervisionPolicy(fleet_restarts_per_hour=40)
if policy.note_restart(now=time.monotonic()):
# fleet breaker tripped — stop auto-restarting, surface degraded
...
"""
def __init__(
self,
fleet_restarts_per_hour: int = 40,
failing_channel_fraction: float = 0.5,
breaker_cooldown_s: float = 120.0,
):
if fleet_restarts_per_hour < 1:
raise ValueError(
f"fleet_restarts_per_hour must be >= 1, got {fleet_restarts_per_hour!r}"
)
if not 0.0 < failing_channel_fraction <= 1.0:
raise ValueError(
"failing_channel_fraction must be in (0.0, 1.0], got "
f"{failing_channel_fraction!r}"
)
if breaker_cooldown_s < 0:
raise ValueError(
f"breaker_cooldown_s must be >= 0, got {breaker_cooldown_s!r}"
)
self.fleet_restarts_per_hour = int(fleet_restarts_per_hour)
self.failing_channel_fraction = float(failing_channel_fraction)
self.breaker_cooldown_s = float(breaker_cooldown_s)
self._window_seconds = 3600.0 # restart-rate window is per-hour
self._events: "List[float]" = []
self._tripped_until: Optional[float] = None
def _prune(self, now: float) -> None:
cutoff = now - self._window_seconds
self._events = [t for t in self._events if t >= cutoff]
def note_restart(self, now: float) -> bool:
"""Record a fleet restart at ``now`` and return whether the breaker is tripped.
Args:
now: A monotonic timestamp for this restart event.
Returns:
``True`` when the aggregate restart rate has reached
``fleet_restarts_per_hour`` within the trailing hour (or the breaker
is still within its cooldown); ``False`` otherwise.
"""
# While actively cooling down, do NOT record new events: the caller has
# already HELD the restart, so counting held (non-)restarts would keep
# renewing the window and leave the breaker stuck until the full hour
# expires. Just report that we are still tripped.
if self.tripped(now):
return True
self._prune(now)
self._events.append(now)
if len(self._events) >= self.fleet_restarts_per_hour:
self._tripped_until = now + self.breaker_cooldown_s
return True
return False
def note_fleet_state(
self, failing_channels: int, total_channels: int, now: float
) -> bool:
"""Trip the breaker when too large a fraction of the fleet is failing.
A systemic fault often shows up as many channels simultaneously in a
failing/parked state rather than as a raw restart rate. When at least
``failing_channel_fraction`` of the fleet is failing, trip and start the
cooldown.
Args:
failing_channels: Number of channels currently failing/parked.
total_channels: Total number of supervised channels.
now: A monotonic timestamp for this evaluation.
Returns:
``True`` when the breaker is tripped (now or still cooling down).
"""
if total_channels > 0:
fraction = failing_channels / total_channels
if fraction >= self.failing_channel_fraction:
self._tripped_until = now + self.breaker_cooldown_s
return True
return self.tripped(now)
def tripped(self, now: float) -> bool:
"""Return whether the breaker is currently tripped without recording.
When the cooldown has elapsed the breaker re-arms cleanly: the accrued
event window is cleared so the accumulated pre-trip restarts cannot
immediately re-trip on the very next ``note_restart``.
"""
if self._tripped_until is not None:
if now < self._tripped_until:
return True
self._tripped_until = None
self._events = []
return False
def reset(self) -> None:
"""Clear recorded restart history and any active trip (clean recovery)."""
self._events = []
self._tripped_until = None
# ---------------------------------------------------------------------------
# Protocol Version Negotiation (Issue #2130)
# ---------------------------------------------------------------------------
@@ -4523,6 +5228,179 @@ class LivenessPolicy:
return LivenessDecision.KEEP
# ---------------------------------------------------------------------------
# Cluster-wide per-turn serialisation contract (Issue #3643)
# ---------------------------------------------------------------------------
#
# Turn serialisation — "only one turn runs against a given resolved session at
# a time" — is enforced in-process by an ``asyncio.Lock`` (``LockMap``). That
# guarantee evaporates the moment a gateway is scaled to ``replicas > 1`` (the
# sanctioned HA topology behind ``redis_pubsub.py`` + the Helm chart): two
# messages for one session land on two replicas and run concurrent turns with
# no serialisation between them, corrupting the shared transcript, tripping
# provider strict-alternation, duplicating replies and double-billing.
#
# This is the pure, dependency-free contract for a *distributed* turn lock,
# mirroring how every other gateway robustness knob (drain, admission,
# rate-limit, liveness, dead-letter) is a swappable pure protocol in core. The
# heavy Redis/network implementation is an optional-dep runtime concern that
# lives in the wrapper/bot package; core keeps only the protocol, the lease
# token, and a zero-cost in-process default so single-replica behaviour is
# unchanged and no new dependency is introduced.
@dataclass(frozen=True)
class TurnLeaseToken:
"""An opaque handle to a held turn lease (Issue #3643).
Returned by :meth:`TurnLockProtocol.acquire` and passed back to
:meth:`TurnLockProtocol.release`. The ``owner`` token identifies the
replica/process that holds the lease so a distributed backend can make
``release`` **identity-checked and idempotent** — a replica can only
release the lease it actually owns, and a stale/expired lease that has
since been reclaimed by another owner is never clobbered.
Attributes:
key: The resolved session id the lease serialises on.
owner: The holder's opaque owner token (e.g. a per-replica id).
expires_at: Absolute wall-clock expiry (same clock the backend uses).
A dead holder's lease is reclaimable once ``now`` passes this, so a
crashed replica cannot wedge a healthy session forever.
"""
key: str
owner: str
expires_at: float
@runtime_checkable
class TurnLockProtocol(Protocol):
"""Contract for serialising a session's turns — cluster-wide or in-process.
The gateway holds this lock for the *whole* agent turn keyed on the
resolved session id, so only one turn ever runs against one session's
transcript at a time. With the default in-process backend
(:class:`LocalTurnLock`) this reproduces today's ``asyncio.Lock`` behaviour
exactly and adds no dependency. With a distributed backend (a
``RedisTurnLock`` in the wrapper/bot package, reusing the scheduler's proven
``owner``+TTL lease pattern) the same ``async with`` seam serialises turns
across every replica.
Contract:
* :meth:`acquire` blocks until the lease for ``key`` is held, then
returns a :class:`TurnLeaseToken`. ``ttl`` bounds how long the lease
survives without renewal so a crashed holder self-heals.
* :meth:`release` is identity-checked against the token's ``owner`` and
idempotent: releasing an already-expired/reclaimed lease is a no-op,
never an error and never another owner's lease.
* :meth:`hold` is the ergonomic async context manager wrapping
acquire/release, used at the ``async with self._turn_lock.hold(...)``
call site.
A backend outage must fail *open* (degrade to a loud warning rather than
wedging a healthy session), mirroring the fail-safe defaults elsewhere.
"""
async def acquire(self, key: str, *, owner: str, ttl: float) -> TurnLeaseToken:
"""Block until the lease for ``key`` is held; return its token."""
...
async def release(self, token: TurnLeaseToken) -> None:
"""Release ``token``'s lease (identity-checked, idempotent)."""
...
def hold(
self, key: str, *, owner: str, ttl: float
) -> "AbstractAsyncContextManager[TurnLeaseToken]":
"""Return an async context manager holding the lease for the block."""
...
class LocalTurnLock:
"""Default in-process turn lock — today's ``asyncio.Lock`` behaviour.
Zero-cost, dependency-free implementation of :class:`TurnLockProtocol` for
single-replica / no-backend deployments. It serialises turns within one
process (per event loop) exactly as the existing ``LockMap`` does, so
upgrading is byte-for-byte backward compatible. It provides **no** cross-
process guarantee — selecting a distributed backend (e.g. ``redis``) is what
extends serialisation across replicas.
The ``owner``/``ttl`` arguments are accepted for protocol symmetry but are
inert here: an in-process ``asyncio.Lock`` is released deterministically
when the holding task exits, so there is no crashed-holder lease to expire.
"""
def __init__(self) -> None:
self._locks: Dict[str, "asyncio.Lock"] = {}
# Current lease holder per key, so release() is identity-checked: only
# the exact token handed out by the latest acquire() may release, so a
# stale token can never clobber a waiter that took the lock after it.
self._holders: Dict[str, TurnLeaseToken] = {}
def _lock_for(self, key: str) -> "asyncio.Lock":
lock = self._locks.get(key)
if lock is None:
import asyncio
lock = asyncio.Lock()
self._locks[key] = lock
return lock
async def acquire(self, key: str, *, owner: str, ttl: float) -> TurnLeaseToken:
lock = self._lock_for(key)
await lock.acquire()
token = TurnLeaseToken(key=key, owner=owner, expires_at=0.0)
self._holders[key] = token
return token
async def release(self, token: TurnLeaseToken) -> None:
# Identity-checked & idempotent: a stale/duplicate token whose lease has
# since been reclaimed by a waiter is a harmless no-op, never another
# holder's lease. ``is`` (not ``==``) so equal-valued tokens from two
# acquires are not conflated (``TurnLeaseToken`` is frozen/value-equal).
if self._holders.get(token.key) is not token:
return
del self._holders[token.key]
lock = self._locks.get(token.key)
if lock is not None and lock.locked():
lock.release()
def hold(
self, key: str, *, owner: str, ttl: float
) -> "AbstractAsyncContextManager[TurnLeaseToken]":
return _TurnLeaseHold(self, key, owner=owner, ttl=ttl)
class _TurnLeaseHold:
"""Async context manager wrapping acquire/release for any turn lock.
Reusable by any :class:`TurnLockProtocol` implementation so the concrete
lock only needs ``acquire``/``release``; ``hold`` composes them safely
(releasing on every exit path, including exceptions).
"""
def __init__(
self, lock: "TurnLockProtocol", key: str, *, owner: str, ttl: float
) -> None:
self._lock = lock
self._key = key
self._owner = owner
self._ttl = ttl
self._token: Optional[TurnLeaseToken] = None
async def __aenter__(self) -> TurnLeaseToken:
self._token = await self._lock.acquire(
self._key, owner=self._owner, ttl=self._ttl
)
return self._token
async def __aexit__(self, exc_type, exc, tb) -> None:
if self._token is not None:
await self._lock.release(self._token)
self._token = None
# ---------------------------------------------------------------------------
# Declarative method -> required-scope registry (Issue #3206)
# ---------------------------------------------------------------------------
@@ -27,8 +27,8 @@ class LLMGuardrail:
llm: The LLM instance to use for validation (can be string or LLM instance)
"""
self.description = description
self.llm = self._initialize_llm(llm)
self.logger = get_logger(__name__)
self.llm = self._initialize_llm(llm)
def _initialize_llm(self, llm: Any) -> Any:
"""Initialize the LLM instance from string identifier or existing instance.
@@ -88,9 +88,13 @@ class LLMGuardrail:
else:
raw_text = task_output.raw
if not self.llm:
self.logger.warning("No LLM provided for guardrail validation")
return True, task_output
if self.llm is None:
# Fail-closed: without an LLM the guardrail cannot validate, so
# block the output rather than silently rubber-stamping it.
self.logger.error(
"No LLM available for guardrail validation - failing closed"
)
return False, "Guardrail validation unavailable: no LLM configured"
# Create validation prompt
validation_prompt = f"""
@@ -118,8 +122,14 @@ Your response:"""
# For simple callable LLMs
response = self.llm(validation_prompt)
else:
self.logger.error(f"Unsupported LLM type: {type(self.llm)}")
return True, task_output
# Fail-closed: an unusable LLM cannot validate, so block.
self.logger.error(
f"Unsupported LLM type: {type(self.llm)} - failing closed"
)
return False, (
f"Guardrail validation unavailable: "
f"unsupported LLM type {type(self.llm)}"
)
# Parse response
response = str(response).strip()
@@ -131,9 +141,10 @@ Your response:"""
reason = response[5:].strip(": ")
return False, f"Guardrail validation failed: {reason}"
else:
# Unclear response, log and pass through
# Unclear response - fail closed, matching _llm_validate() and the
# class's documented "fail-closed by default" contract.
self.logger.warning(f"Unclear guardrail response: {response}")
return True, task_output
return False, f"Guardrail validation unclear: {response}"
except Exception as e:
self.logger.error(f"Error in LLM guardrail validation: {str(e)}")
@@ -216,7 +227,15 @@ Please respond with either:
Your response:"""
# Get LLM response
if hasattr(self.llm, 'complete'):
if hasattr(self.llm, 'get_response'):
# praisonaiagents.llm.llm.LLM interface
response = self.llm.get_response(
prompt=prompt,
verbose=False,
markdown=False,
stream=False,
)
elif hasattr(self.llm, 'complete'):
response = self.llm.complete(prompt)
elif hasattr(self.llm, 'invoke'):
response = self.llm.invoke(prompt)
@@ -70,6 +70,8 @@ __all__ = [
"AfterLLMInput",
"SessionStartInput",
"SessionEndInput",
"SessionPersistFailedInput",
"ModelFallbackInput",
"OnErrorInput",
"OnRetryInput",
# Message lifecycle event inputs
@@ -140,6 +142,8 @@ _LAZY_GROUPS = {
'AfterLLMInput': ('praisonaiagents.hooks.events', 'AfterLLMInput'),
'SessionStartInput': ('praisonaiagents.hooks.events', 'SessionStartInput'),
'SessionEndInput': ('praisonaiagents.hooks.events', 'SessionEndInput'),
'SessionPersistFailedInput': ('praisonaiagents.hooks.events', 'SessionPersistFailedInput'),
'ModelFallbackInput': ('praisonaiagents.hooks.events', 'ModelFallbackInput'),
'OnErrorInput': ('praisonaiagents.hooks.events', 'OnErrorInput'),
'OnRetryInput': ('praisonaiagents.hooks.events', 'OnRetryInput'),
'MessageReceivedInput': ('praisonaiagents.hooks.events', 'MessageReceivedInput'),
@@ -140,6 +140,38 @@ class SessionEndInput(HookInput):
return base
@dataclass
class SessionPersistFailedInput(HookInput):
"""Input for SESSION_PERSIST_FAILED hooks (a durable session write failed).
Fired by the session store when an ``add_message``/``set_chat_history``
write cannot be persisted (disk-full, SQLite/FTS corruption, permission /
``OSError``). Instead of losing the already-produced turn silently, the
store spills it to an atomic fallback file and fires this hook so operators
can observe/route the failure (metrics, alerting, forensics) — mirroring the
``MESSAGE_UNDELIVERED`` posture on the outbound side (Issue #3597).
``spilled`` reports whether the last-resort fallback write itself
succeeded; ``spill_path`` is the file the turn was salvaged to (when any).
"""
role: str = ""
content: str = ""
error: str = ""
spilled: bool = False
spill_path: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
base = super().to_dict()
base.update({
"role": self.role,
"content": self.content[:500] if self.content else "",
"error": self.error,
"spilled": self.spilled,
"spill_path": self.spill_path,
})
return base
@dataclass
class BeforeLLMInput(HookInput):
"""Input for BeforeLLM hooks."""
@@ -180,6 +212,34 @@ class AfterLLMInput(HookInput):
return base
@dataclass
class ModelFallbackInput(HookInput):
"""Input for MODEL_FALLBACK hooks (primary model unavailable mid-turn).
Fired by the agent's LLM recovery loop at the exact point it swaps the
primary model for the next entry in the configured ``fallback_models``
chain, so an otherwise silent quality/cost degradation becomes an
observable state transition (Issue #3820). Notification only: the turn
continues on ``to_model``. Only the failure class is exposed
(``reason_category``) — provider internals stay redacted, mirroring the
redaction discipline of the other error-path events.
"""
from_model: str = ""
to_model: str = ""
reason_category: str = ""
fallback_index: int = 0
def to_dict(self) -> Dict[str, Any]:
base = super().to_dict()
base.update({
"from_model": self.from_model,
"to_model": self.to_model,
"reason_category": self.reason_category,
"fallback_index": self.fallback_index,
})
return base
@dataclass
class OnErrorInput(HookInput):
"""Input for OnError hooks."""
@@ -124,6 +124,16 @@ class HookRunner:
return results
async def execute_async(
self,
event: HookEvent,
input_data: HookInput,
target: Optional[str] = None,
_hooks: Optional[List["HookDefinition"]] = None
) -> List[HookExecutionResult]:
"""Alias of execute() for call sites expecting an explicit async entry point."""
return await self.execute(event, input_data, target=target, _hooks=_hooks)
def execute_sync(
self,
event: HookEvent,
@@ -32,10 +32,19 @@ class HookEvent(str, Enum):
# Session lifecycle
SESSION_START = "session_start"
SESSION_END = "session_end"
# Fired when a durable session write fails (disk-full/corruption/permission).
# The already-produced turn is spilled to a fallback file and this hook makes
# the otherwise-silent failure observable for metrics/alerting (Issue #3597).
SESSION_PERSIST_FAILED = "session_persist_failed"
# LLM lifecycle
BEFORE_LLM = "before_llm"
AFTER_LLM = "after_llm"
# Primary model became unavailable mid-turn and the runtime transparently
# switched to the next entry in the configured ``fallback_models`` chain.
# Makes the otherwise silent quality/cost degradation observable so a
# gateway/plugin can react (notice, alert, metrics) — Issue #3820.
MODEL_FALLBACK = "model_fallback"
# Prompt-cache stability (advisory): fired when a turn's cached prompt
# prefix (model + tool schemas + system-prompt fingerprint) changes from
@@ -150,6 +150,10 @@ class MongoDBKnowledgeAdapter:
self,
text: str,
metadata: Optional[Dict[str, Any]] = None,
*,
user_id: Optional[str] = None,
agent_id: Optional[str] = None,
run_id: Optional[str] = None,
**kwargs
) -> str:
"""
@@ -158,6 +162,9 @@ class MongoDBKnowledgeAdapter:
Args:
text: Content to add
metadata: Optional metadata dictionary
user_id: Optional user scope for tenant isolation
agent_id: Optional agent scope for tenant isolation
run_id: Optional run scope for tenant isolation
**kwargs: Additional parameters
Returns:
@@ -180,6 +187,9 @@ class MongoDBKnowledgeAdapter:
document = {
"content": text,
"metadata": metadata or {},
"user_id": user_id,
"agent_id": agent_id,
"run_id": run_id,
"timestamp": datetime.utcnow(),
"embedding": embedding
}
@@ -196,6 +206,10 @@ class MongoDBKnowledgeAdapter:
self,
query: str,
limit: int = 5,
*,
user_id: Optional[str] = None,
agent_id: Optional[str] = None,
run_id: Optional[str] = None,
**kwargs
) -> List[Dict[str, Any]]:
"""
@@ -204,6 +218,9 @@ class MongoDBKnowledgeAdapter:
Args:
query: Search query
limit: Maximum number of results
user_id: Optional user scope for tenant isolation
agent_id: Optional agent scope for tenant isolation
run_id: Optional run scope for tenant isolation
**kwargs: Additional search parameters
Returns:
@@ -211,6 +228,15 @@ class MongoDBKnowledgeAdapter:
"""
try:
results = []
# Build tenant/scope filter honored by both vector and text search
scope_filter = {
k: v for k, v in {
"user_id": user_id,
"agent_id": agent_id,
"run_id": run_id,
}.items() if v is not None
}
# Try vector search first if available
if self.use_vector_search and self.embedding_model and self._is_atlas_connection():
@@ -223,16 +249,19 @@ class MongoDBKnowledgeAdapter:
query_embedding = response.data[0].embedding
# Vector search pipeline
vector_search_stage = {
"$vectorSearch": {
"index": "default", # Assumes default vector index name
"path": "embedding",
"queryVector": query_embedding,
"numCandidates": limit * 10,
"limit": limit
}
}
if scope_filter:
vector_search_stage["$vectorSearch"]["filter"] = scope_filter
pipeline = [
{
"$vectorSearch": {
"index": "default", # Assumes default vector index name
"path": "embedding",
"queryVector": query_embedding,
"numCandidates": limit * 10,
"limit": limit
}
},
vector_search_stage,
{
"$project": {
"_id": 1,
@@ -263,8 +292,10 @@ class MongoDBKnowledgeAdapter:
logger.warning(f"Vector search failed, falling back to text search: {e}")
# Fallback to text search
text_query: Dict[str, Any] = {"$text": {"$search": query}}
text_query.update(scope_filter)
text_results = self.collection.find(
{"$text": {"$search": query}},
text_query,
{"score": {"$meta": "textScore"}}
).sort([("score", {"$meta": "textScore"})]).limit(limit)
@@ -34,6 +34,10 @@ def __getattr__(name):
from .llm import LLMContextLengthExceededException
_lazy_cache[name] = LLMContextLengthExceededException
return LLMContextLengthExceededException
elif name == "LLMResponseError":
from .llm import LLMResponseError
_lazy_cache[name] = LLMResponseError
return LLMResponseError
elif name == "OpenAIClient":
from .openai_client import OpenAIClient
@@ -206,6 +210,7 @@ def __getattr__(name):
__all__ = [
"LLM",
"LLMContextLengthExceededException",
"LLMResponseError",
"OpenAIClient",
"get_openai_client",
"ChatCompletionMessage",
@@ -20,6 +20,7 @@ __all__ = [
"classify_error",
"classify_llm_error",
"should_retry",
"is_replay_unsafe",
"get_retry_delay",
"extract_retry_after",
"get_error_context",
@@ -459,6 +460,92 @@ def should_retry(category: ErrorCategory) -> bool:
}
# Signals that a transient failure happened AFTER the request was dispatched, so
# the provider may already have produced output (and any side-effecting tool
# calls in that turn may already have run). Replaying such a call is unsafe: it
# risks double-executing the turn. Read timeouts, connection resets and
# incomplete/streaming reads all occur once bytes have been sent.
_REPLAY_UNSAFE_EXCEPTION_NAMES = frozenset({
"readtimeout", # httpx.ReadTimeout / requests ReadTimeout
"readtimeouterror", # urllib3 ReadTimeoutError
"connectionreseterror", # stdlib socket reset mid-response
"chunkedencodingerror", # response body truncated after dispatch
"incompleteread", # http.client partial read after dispatch
"remoteprotocolerror", # httpx: peer closed connection mid-response
"protocolerror", # urllib3 protocol error mid-response
"apitimeouterror", # provider SDK request timeout (post-send)
})
# Signals that a transient failure happened BEFORE the request was dispatched,
# so the provider provably did not process it — safe to replay automatically.
_REPLAY_SAFE_EXCEPTION_NAMES = frozenset({
"connecttimeout", # TCP connect never completed
"connecttimeouterror", # urllib3 connect timeout
"connectionrefusederror", # nothing accepted the connection
"connecterror", # httpx connect error (pre-dispatch)
"newconnectionerror", # urllib3: connection never established
"gaierror", # DNS resolution failure
"sslerror", # TLS handshake failure (pre-dispatch)
"sslcertverificationerror",
})
_REPLAY_UNSAFE_MESSAGE_PATTERNS = (
r"read.?timed?.?out",
r"read.?timeout",
r"connection.?reset",
r"peer.?closed",
r"incomplete.?read",
r"chunked",
r"response.?ended.?prematurely",
r"server.?disconnected",
)
_REPLAY_SAFE_MESSAGE_PATTERNS = (
r"connect.?timed?.?out",
r"connection.?timed?.?out",
r"connection.?refused",
r"name.?resolution",
r"failed.?to.?resolve",
r"getaddrinfo",
r"ssl",
r"handshake",
r"tls",
)
def is_replay_unsafe(error: Exception) -> bool:
"""Return True if replaying the request could double-execute the turn.
Distinguishes *pre-dispatch* failures (the request provably never reached
the provider — DNS/connect/TLS failures) which are safe to replay, from
*post-dispatch* failures (read timeout, connection reset mid-response) where
the provider may already have produced output and any side-effecting tool
calls may already have run. Post-dispatch failures are replay-unsafe.
Defaults to ``False`` (safe/retryable) for ambiguous transient errors so
existing auto-retry behaviour is preserved unless a post-dispatch signal is
present. Callers should only block the retry when the turn is side-effecting.
"""
# 1. Exception type (walk the MRO to catch provider subclasses) — most stable.
for cls in type(error).__mro__:
name = cls.__name__.lower()
if name in _REPLAY_SAFE_EXCEPTION_NAMES:
return False
if name in _REPLAY_UNSAFE_EXCEPTION_NAMES:
return True
# 2. Message text — pre-dispatch signals win over the generic "timeout".
error_text = f"{type(error).__name__} {error}".lower()
for pattern in _REPLAY_SAFE_MESSAGE_PATTERNS:
if re.search(pattern, error_text):
return False
for pattern in _REPLAY_UNSAFE_MESSAGE_PATTERNS:
if re.search(pattern, error_text):
return True
return False
def get_retry_delay(category: ErrorCategory, attempt: int = 1, base_delay: float = 1.0) -> float:
"""Get the appropriate delay before retrying based on error category.
@@ -11,6 +11,7 @@ Heavy implementations live in the praisonai wrapper.
from __future__ import annotations
from praisonaiagents._logging import get_logger
import threading
import time
from dataclasses import dataclass, field
from enum import Enum
@@ -216,6 +217,12 @@ class FailoverManager:
self._profiles: List[AuthProfile] = []
self._current_index: int = 0
self._on_failover_callbacks: List[Callable[[AuthProfile, AuthProfile], None]] = []
# Re-entrant lock: FailoverManager is a shared credential pool by design
# (one instance across many concurrent Agent/LLM instances), so every
# read/mutation of _profiles and each AuthProfile status transition must
# be synchronized. RLock allows methods like mark_failure to call
# get_next_profile while already holding the lock.
self._lock = threading.RLock()
def add_profile(self, profile: AuthProfile) -> None:
"""Add an auth profile.
@@ -223,8 +230,9 @@ class FailoverManager:
Args:
profile: The profile to add
"""
self._profiles.append(profile)
self._profiles.sort(key=lambda p: p.priority)
with self._lock:
self._profiles.append(profile)
self._profiles.sort(key=lambda p: p.priority)
def remove_profile(self, name: str) -> bool:
"""Remove a profile by name.
@@ -235,11 +243,12 @@ class FailoverManager:
Returns:
True if removed, False if not found
"""
for i, profile in enumerate(self._profiles):
if profile.name == name:
self._profiles.pop(i)
return True
return False
with self._lock:
for i, profile in enumerate(self._profiles):
if profile.name == name:
self._profiles.pop(i)
return True
return False
def get_profile(self, name: str) -> Optional[AuthProfile]:
"""Get a profile by name.
@@ -250,10 +259,11 @@ class FailoverManager:
Returns:
The profile or None if not found
"""
for profile in self._profiles:
if profile.name == name:
return profile
return None
with self._lock:
for profile in self._profiles:
if profile.name == name:
return profile
return None
def list_profiles(self) -> List[AuthProfile]:
"""List all profiles.
@@ -261,7 +271,8 @@ class FailoverManager:
Returns:
List of all profiles
"""
return list(self._profiles)
with self._lock:
return list(self._profiles)
def get_next_profile(self) -> Optional[AuthProfile]:
"""Get the next available profile.
@@ -272,29 +283,30 @@ class FailoverManager:
Returns:
The next available profile, or None if all are unavailable
"""
if not self._profiles:
with self._lock:
if not self._profiles:
return None
# First, check if any cooldowns have expired
current_time = time.time()
for profile in self._profiles:
if profile.cooldown_until and current_time >= profile.cooldown_until:
profile.reset()
# Find first available profile
for profile in self._profiles:
if profile.is_available:
return profile
# If none available, return the one with shortest remaining cooldown
available_profiles = [p for p in self._profiles if p.status != ProviderStatus.DISABLED]
if available_profiles:
return min(
available_profiles,
key=lambda p: p.cooldown_until or 0
)
return None
# First, check if any cooldowns have expired
current_time = time.time()
for profile in self._profiles:
if profile.cooldown_until and current_time >= profile.cooldown_until:
profile.reset()
# Find first available profile
for profile in self._profiles:
if profile.is_available:
return profile
# If none available, return the one with shortest remaining cooldown
available_profiles = [p for p in self._profiles if p.status != ProviderStatus.DISABLED]
if available_profiles:
return min(
available_profiles,
key=lambda p: p.cooldown_until or 0
)
return None
def mark_failure(
self,
@@ -309,27 +321,37 @@ class FailoverManager:
error: Error message
is_rate_limit: Whether this is a rate limit error
"""
if is_rate_limit:
profile.mark_rate_limited(self.config.cooldown_on_rate_limit)
logger.warning(
f"Profile '{profile.name}' rate limited, "
f"cooldown for {self.config.cooldown_on_rate_limit}s"
with self._lock:
if is_rate_limit:
profile.mark_rate_limited(self.config.cooldown_on_rate_limit)
logger.warning(
f"Profile '{profile.name}' rate limited, "
f"cooldown for {self.config.cooldown_on_rate_limit}s"
)
else:
profile.mark_error(error, self.config.cooldown_on_error)
logger.warning(
f"Profile '{profile.name}' error: {error}, "
f"cooldown for {self.config.cooldown_on_error}s"
)
# Resolve the target profile and snapshot callbacks while holding
# the lock, but invoke them AFTER releasing it. User callbacks may
# block or acquire external locks; running them under our shared
# credential-pool lock would stall every concurrent agent and risks
# lock-ordering deadlocks.
next_profile = self.get_next_profile()
callbacks = (
list(self._on_failover_callbacks)
if next_profile and next_profile != profile
else []
)
else:
profile.mark_error(error, self.config.cooldown_on_error)
logger.warning(
f"Profile '{profile.name}' error: {error}, "
f"cooldown for {self.config.cooldown_on_error}s"
)
# Notify callbacks
next_profile = self.get_next_profile()
if next_profile and next_profile != profile:
for callback in self._on_failover_callbacks:
try:
callback(profile, next_profile)
except Exception as e:
logger.error(f"Failover callback error: {e}")
for callback in callbacks:
try:
callback(profile, next_profile)
except Exception as e:
logger.error(f"Failover callback error: {e}")
def mark_success(self, profile: AuthProfile) -> None:
"""Mark a profile as successful.
@@ -337,9 +359,10 @@ class FailoverManager:
Args:
profile: The profile that succeeded
"""
if profile.status != ProviderStatus.AVAILABLE:
profile.reset()
logger.info(f"Profile '{profile.name}' recovered")
with self._lock:
if profile.status != ProviderStatus.AVAILABLE:
profile.reset()
logger.info(f"Profile '{profile.name}' recovered")
def on_failover(
self,
@@ -350,7 +373,8 @@ class FailoverManager:
Args:
callback: Function called with (failed_profile, new_profile)
"""
self._on_failover_callbacks.append(callback)
with self._lock:
self._on_failover_callbacks.append(callback)
def get_retry_delay(self, attempt: int) -> float:
"""Calculate retry delay for an attempt.
@@ -374,18 +398,20 @@ class FailoverManager:
Returns:
Status information
"""
available = sum(1 for p in self._profiles if p.is_available)
return {
"total_profiles": len(self._profiles),
"available_profiles": available,
"profiles": [p.to_dict() for p in self._profiles],
"config": self.config.to_dict(),
}
with self._lock:
available = sum(1 for p in self._profiles if p.is_available)
return {
"total_profiles": len(self._profiles),
"available_profiles": available,
"profiles": [p.to_dict() for p in self._profiles],
"config": self.config.to_dict(),
}
def reset_all(self) -> None:
"""Reset all profiles to available status."""
for profile in self._profiles:
profile.reset()
with self._lock:
for profile in self._profiles:
profile.reset()
@runtime_checkable
+119 -3
View File
@@ -87,6 +87,18 @@ except ImportError:
# NOTE: The custom-LLM path (Agent.chat → get_response) and OpenAI path
# (Agent.chat → _chat_completion) are separate code paths, not duplicate
# API calls per request. This is a DRY/maintenance concern, not a billing issue.
class LLMResponseError(Exception):
"""Raised when the LLM tool-calling loop fails and cannot produce a response.
This ensures a mid-loop failure surfaces as a distinguishable exception to
the caller instead of being silently swallowed and returned as an empty
string.
"""
def __init__(self, message: str):
self.message = message
super().__init__(self.message)
class LLMContextLengthExceededException(Exception):
"""Raised when LLM context length is exceeded"""
def __init__(self, message: str):
@@ -805,6 +817,21 @@ Respond with ONLY a valid JSON tool call in this format:
# Default fallback
return "unknown"
def _is_post_dispatch_failure(self, error: Exception) -> bool:
"""Whether a transient error occurred after the request was dispatched.
A post-dispatch failure (read timeout, connection reset mid-response)
means the provider may already have processed the request — replaying it
is unsafe on a side-effecting turn. A pre-dispatch failure (connect
timeout, connection refused, DNS/TLS failure) provably never reached the
provider and is always safe to replay.
"""
try:
from .error_classifier import is_replay_unsafe
return is_replay_unsafe(error)
except Exception: # noqa: BLE001 - classification must never break retry
return False
def resolve_failover_decision(self, error: Exception, attempt_state: dict) -> FailoverDecision:
"""
Resolve failover decision based on error kind and attempt state.
@@ -838,7 +865,25 @@ Respond with ONLY a valid JSON tool call in this format:
reason=error_kind,
is_retryable=False
)
# Replay-safety gate (applies to ALL retryable error kinds).
# A transient failure that happened AFTER the request was dispatched
# (read timeout, connection reset mid-response, incomplete/chunked read,
# remote protocol error) may already have produced output server-side and
# already run any side-effecting tool calls in that turn. Blindly
# replaying it can double-execute the turn (double billing, re-run of a
# side-effecting tool). These post-dispatch signals are not all captured
# by classify_error_kind (e.g. a bare "connection reset" falls through to
# "unknown"), so the gate is evaluated here — before any retry branch —
# rather than inside a single error-kind branch. Only block when the turn
# is side-effecting (exposes tools); a tool-less turn is safe to replay.
if attempt_state.get("side_effecting") and self._is_post_dispatch_failure(error):
return FailoverDecision(
action="surface_error",
reason="provider_outcome_unknown",
is_retryable=False
)
# Rate limiting - extract retry delay
if error_kind == "rate_limit":
backoff = self._parse_retry_delay(str(error)) # Returns seconds
@@ -861,6 +906,7 @@ Respond with ONLY a valid JSON tool call in this format:
)
# Overloaded/timeout - retry with exponential backoff
# (Post-dispatch replay-safety is already handled by the gate above.)
if error_kind in ["overloaded", "idle_timeout"]:
# For idle timeouts, check circuit breaker
if error_kind == "idle_timeout":
@@ -1009,8 +1055,23 @@ Respond with ONLY a valid JSON tool call in this format:
error_str: String form of the exception.
kwargs: The (possibly updated) keyword arguments.
"""
# A turn is "side-effecting" when it exposes tools the model may call:
# a post-dispatch failure on such a turn could have already run a
# side-effecting tool, so it must not be silently replayed. A turn with
# no tools (pure completion) has no external side effects, so replaying a
# post-dispatch failure is safe (at most re-bills the completion, which
# the provider already dedupes far less than a double tool action).
side_effecting = bool(kwargs.get("tools"))
# Use new typed failover decision instead of old classification
decision = self.resolve_failover_decision(e, {"attempt": attempt + 1, "max_retries": self._max_retries})
decision = self.resolve_failover_decision(
e,
{
"attempt": attempt + 1,
"max_retries": self._max_retries,
"side_effecting": side_effecting,
},
)
error_str = str(e)
@@ -1817,6 +1878,48 @@ Respond with ONLY a valid JSON tool call in this format:
return False, None, iteration_count
def _register_deferred_if_any(self, tool_result: Any) -> None:
"""Register a deferred tool result so its eventual value is not lost.
Closes the deferred loop (Issue #3716): when a tool returns
``defer(handle_id=...)`` the run loop already surfaces the note (carried
in ``tool_result.result``); here we also register the handle on the
global :class:`DeferredResolver` so a later ``resolve_deferred(handle_id,
value)`` (fired on background completion) re-injects the result into the
shared ``messages`` history as a follow-up tool message.
No-op for non-deferred results, preserving existing behaviour. A gateway
that wants to deliver the result to an external channel can register its
own resolver for the same handle first — this only registers when the
handle is not already pending, so it never overrides that.
"""
deferred = getattr(tool_result, "deferred", None)
if deferred is None:
return
try:
from ..tools.call_executor import get_deferred_resolver
resolver = get_deferred_resolver()
tool_call_id = tool_result.tool_call_id
function_name = tool_result.function_name
def _reinject(handle_id: str, value: Any, session_id: Optional[str]) -> None:
# Best-effort: this closure is invoked from a background worker
# thread; appending to the LLM's persistent ``chat_history``
# (the same list follow-up turns replay via _build_messages)
# makes the resolved value available on the next turn.
self.chat_history.append({
"role": "tool",
"tool_call_id": tool_call_id,
"content": f"[deferred:{function_name}] {value}",
})
# Atomic: only register when not already pending, so a gateway that
# registered its own resolver for this handle first is never
# overridden and concurrent turns cannot clobber each other.
resolver.register_if_absent(deferred.handle_id, _reinject)
except Exception as e: # noqa: BLE001 — registration must never break the turn
logging.debug("Deferred registration skipped (non-fatal): %s", e)
def _finalise_on_limit(self, messages: List[Dict], response_text: str,
temperature: float = 0.2, **kwargs) -> str:
"""
@@ -2656,6 +2759,9 @@ Respond with ONLY a valid JSON tool call in this format:
for tool_call_obj, tool_result_obj in zip(tool_calls_batch, tool_results_batch):
if tool_result_obj.error is not None:
raise tool_result_obj.error
# Register any deferred handle so its eventual
# background result is re-injected, not lost.
self._register_deferred_if_any(tool_result_obj)
tool_result = tool_result_obj.result
tool_results.append(tool_result)
accumulated_tool_results.append(tool_result)
@@ -3563,9 +3669,16 @@ Respond with ONLY a valid JSON tool call in this format:
final_response_text = response_text.strip() if response_text else ""
break
except LLMResponseError:
raise
except Exception as e:
logging.error(f"Error in LLM iteration {iteration_count}: {e}")
break
# Don't swallow the failure and return an empty string as if
# the call succeeded. Re-raise as a distinguishable exception
# so the caller (e.g. Agent.chat) can surface the real error.
raise LLMResponseError(
f"LLM tool-calling loop failed at iteration {iteration_count}: {e}"
) from e
# End of while loop - return final response
if final_response_text:
@@ -4138,6 +4251,9 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
)
for tool_result in tool_results:
# Register any deferred handle so its eventual
# background result is re-injected, not lost.
self._register_deferred_if_any(tool_result)
if tool_result.error is None:
# Successful execution
tool_message = self._create_tool_message(
@@ -4,6 +4,12 @@ Model capabilities configuration for different LLM providers.
This module uses LiteLLM's helper functions as the primary source for model capability detection.
LiteLLM is maintained by many contributors and is more accurate and up-to-date.
When LiteLLM is not installed (lean provider-native installs), each ``supports_*`` helper
falls back to a small, conservative static heuristic instead of silently returning ``False``,
so capability gating stays usable without pulling in the optional ``litellm`` dependency.
The fallbacks are pattern-based only (no network, no new dependency); LiteLLM remains
authoritative whenever it is installed.
LiteLLM Helper Functions:
- litellm.supports_web_search(model=) - Check web search support
- litellm.supports_function_calling(model=) - Check function calling support
@@ -19,9 +25,84 @@ Sources:
- https://docs.litellm.ai/docs/completion/prompt_caching
"""
from functools import lru_cache
from ._litellm_loader import get_litellm as _get_litellm
def _base_model_name(model_name: str) -> str:
"""Strip a leading ``provider/`` prefix and lowercase for pattern matching."""
name = model_name.lower()
if "/" in name:
name = name.split("/", 1)[1]
return name
def _fallback_supports_structured_outputs(model_name: str) -> bool:
"""Static heuristic used only when litellm is unavailable.
Keeps capability gating correct for lean (litellm-free) installs instead of
silently returning False. Deliberately conservative and pattern-based; no
network, no new dependency.
"""
name = _base_model_name(model_name)
return any(
p in name
for p in ("gpt-4o", "gpt-4.1", "gpt-5", "o1", "o3", "o4", "gemini", "claude-3", "claude-sonnet", "claude-opus", "claude-haiku")
)
def _fallback_supports_function_calling(model_name: str) -> bool:
"""Static heuristic used only when litellm is unavailable."""
name = _base_model_name(model_name)
if any(x in name for x in ("embedding", "whisper", "tts", "dall-e")):
return False
return any(
p in name
for p in ("gpt-4", "gpt-5", "gpt-3.5", "o1", "o3", "o4", "gemini", "claude", "llama-3", "mistral", "mixtral", "grok")
)
def _fallback_supports_parallel_function_calling(model_name: str) -> bool:
"""Static heuristic used only when litellm is unavailable.
Deliberately narrower than :func:`_fallback_supports_function_calling`:
parallel tool calls are a stricter capability than serial function calling,
so this only reports ``True`` for provider families known to support issuing
multiple tool calls in a single turn. Models that support only serial tool
calls conservatively return ``False``.
"""
if not _fallback_supports_function_calling(model_name):
return False
name = _base_model_name(model_name)
return any(
p in name
for p in ("gpt-4", "gpt-5", "gpt-3.5", "o1", "o3", "o4", "gemini", "claude")
)
def _fallback_supports_web_search(model_name: str) -> bool:
"""Static heuristic used only when litellm is unavailable.
Note: Anthropic Claude models are intentionally excluded here — they use
``web_fetch`` (see :func:`supports_web_fetch`), not native ``web_search``,
mirroring litellm's own reporting.
"""
name = _base_model_name(model_name)
if "perplexity" in name or name.startswith("sonar"):
return True
if "search" in name: # e.g. gpt-4o-search-preview
return True
return any(p in name for p in ("gemini-2", "grok-3"))
def _fallback_supports_prompt_caching(model_name: str) -> bool:
"""Static heuristic used only when litellm is unavailable."""
name = _base_model_name(model_name)
return any(p in name for p in ("claude-3", "claude-sonnet", "claude-opus", "claude-haiku", "gpt-4o", "gpt-4.1", "gpt-5", "deepseek"))
@lru_cache(maxsize=256)
def supports_structured_outputs(model_name: str) -> bool:
"""
Check if a model supports structured outputs (JSON schema).
@@ -37,19 +118,26 @@ def supports_structured_outputs(model_name: str) -> bool:
if not model_name:
return False
litellm = None
try:
litellm = _get_litellm()
if litellm is None:
return False
# litellm genuinely unavailable: use conservative static heuristic.
return _fallback_supports_structured_outputs(model_name)
# Use LiteLLM's built-in check - most accurate and up-to-date
if hasattr(litellm, 'supports_response_schema'):
return litellm.supports_response_schema(model=model_name)
except Exception:
pass
return False
# litellm is installed but the helper is missing or raised: keep litellm
# authoritative (return False) rather than overriding it with the heuristic.
if litellm is not None:
return False
return _fallback_supports_structured_outputs(model_name)
@lru_cache(maxsize=256)
def supports_function_calling(model_name: str) -> bool:
"""
Check if a model supports function calling.
@@ -65,19 +153,23 @@ def supports_function_calling(model_name: str) -> bool:
if not model_name:
return False
litellm = None
try:
litellm = _get_litellm()
if litellm is None:
return False
return _fallback_supports_function_calling(model_name)
# Use LiteLLM's built-in check - most accurate and up-to-date
if hasattr(litellm, 'supports_function_calling'):
return litellm.supports_function_calling(model=model_name)
except Exception:
pass
return False
if litellm is not None:
return False
return _fallback_supports_function_calling(model_name)
@lru_cache(maxsize=256)
def supports_parallel_function_calling(model_name: str) -> bool:
"""
Check if a model supports parallel function calling.
@@ -93,17 +185,20 @@ def supports_parallel_function_calling(model_name: str) -> bool:
if not model_name:
return False
litellm = None
try:
litellm = _get_litellm()
if litellm is None:
return False
return _fallback_supports_parallel_function_calling(model_name)
# Use LiteLLM's built-in check - most accurate and up-to-date
if hasattr(litellm, 'supports_parallel_function_calling'):
return litellm.supports_parallel_function_calling(model=model_name)
except Exception:
pass
return False
if litellm is not None:
return False
return _fallback_supports_parallel_function_calling(model_name)
def supports_streaming_with_tools(model_name: str) -> bool:
@@ -124,6 +219,7 @@ def supports_streaming_with_tools(model_name: str) -> bool:
GEMINI_INTERNAL_TOOLS = {'googleSearch', 'urlContext', 'codeExecution'}
@lru_cache(maxsize=256)
def supports_web_search(model_name: str) -> bool:
"""
Check if a model supports native web search via LiteLLM.
@@ -149,19 +245,23 @@ def supports_web_search(model_name: str) -> bool:
if not model_name:
return False
litellm = None
try:
litellm = _get_litellm()
if litellm is None:
return False
return _fallback_supports_web_search(model_name)
# Use LiteLLM's built-in check - most accurate and up-to-date
if hasattr(litellm, 'supports_web_search'):
return litellm.supports_web_search(model=model_name)
except Exception:
pass
return False
if litellm is not None:
return False
return _fallback_supports_web_search(model_name)
@lru_cache(maxsize=256)
def supports_prompt_caching(model_name: str) -> bool:
"""
Check if a model supports prompt caching via LiteLLM.
@@ -186,16 +286,19 @@ def supports_prompt_caching(model_name: str) -> bool:
if not model_name:
return False
litellm = None
try:
litellm = _get_litellm()
if litellm is None:
return False
return _fallback_supports_prompt_caching(model_name)
if hasattr(litellm, 'utils') and hasattr(litellm.utils, 'supports_prompt_caching'):
return litellm.utils.supports_prompt_caching(model=model_name)
except Exception:
pass
return False
if litellm is not None:
return False
return _fallback_supports_prompt_caching(model_name)
# Models that support web fetch via LiteLLM (Anthropic only)
@@ -219,6 +322,7 @@ MODELS_SUPPORTING_WEB_FETCH = {
}
@lru_cache(maxsize=256)
def supports_web_fetch(model_name: str) -> bool:
"""
Check if a model supports web fetch via LiteLLM.
@@ -26,6 +26,11 @@ from .protocols import (
InstanceInfo,
InstanceStatus,
ManagedRuntimeProtocol,
SupportsCapture,
load_environment_definition,
find_environment_definition,
definition_hash,
capture_key,
)
__all__ = [
@@ -44,6 +49,11 @@ __all__ = [
"InstanceStatus",
"ManagedRuntimeProtocol",
"ManagedBackendProtocol",
"SupportsCapture",
"load_environment_definition",
"find_environment_definition",
"definition_hash",
"capture_key",
]
@@ -56,6 +56,7 @@ class ComputeConfig:
cpu=2,
memory_mb=2048,
packages={"pip": ["pandas", "numpy"]},
setup=["pip install -e ."],
env={"OPENAI_API_KEY": "sk-..."},
auto_shutdown=True,
idle_timeout_s=300,
@@ -66,6 +67,7 @@ class ComputeConfig:
memory_mb: int = 1024
gpu: Optional[str] = None
packages: Dict[str, List[str]] = field(default_factory=dict)
setup: List[str] = field(default_factory=list)
env: Dict[str, str] = field(default_factory=dict)
working_dir: str = "/workspace"
mount_paths: List[str] = field(default_factory=list)
@@ -75,6 +77,262 @@ class ComputeConfig:
metadata: Dict[str, Any] = field(default_factory=dict)
_ENV_FILENAME = "environment.yaml"
_ENV_DIRNAME = ".praisonai"
_ENV_KNOWN_KEYS = {
"image", "packages", "setup", "refresh", "env", "resources",
"network", "backend", "working_dir", "mount_paths", "capture",
}
def find_environment_definition(start: Optional[str] = None) -> Optional[str]:
"""Discover ``.praisonai/environment.yaml`` by walking up from ``start``.
Args:
start: Directory to start searching from (defaults to CWD).
Returns:
Absolute path to the definition file, or ``None`` if not found.
"""
import os
current = os.path.abspath(start or os.getcwd())
while True:
candidate = os.path.join(current, _ENV_DIRNAME, _ENV_FILENAME)
if os.path.isfile(candidate):
return candidate
parent = os.path.dirname(current)
if parent == current:
return None
current = parent
def load_environment_definition(
path: Optional[str] = None,
) -> Optional[ComputeConfig]:
"""Load a repo-committed ``.praisonai/environment.yaml`` into a ComputeConfig.
This is the single resolution path shared by every ``compute=`` consumer:
the file declares image / packages / setup / env / resources / network /
backend, and is mapped onto the existing :class:`ComputeConfig` schema.
Args:
path: Explicit path to a definition file. If ``None``, discovery walks
up from the current directory looking for ``.praisonai/environment.yaml``.
Returns:
A :class:`ComputeConfig`, or ``None`` when no file is found (callers then
fall back to today's defaults — the file is strictly opt-in).
Raises:
ValueError: If the file contains unknown top-level keys or is malformed.
"""
if path is None:
path = find_environment_definition()
if path is None:
return None
import yaml # lazy — stdlib-adjacent, already a dependency
with open(path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
if not isinstance(data, dict):
raise ValueError(
f"{path}: environment definition must be a mapping, got {type(data).__name__}"
)
unknown = set(data) - _ENV_KNOWN_KEYS
if unknown:
raise ValueError(
f"{path}: unknown key(s) {sorted(unknown)}; "
f"allowed keys are {sorted(_ENV_KNOWN_KEYS)}"
)
def _require(key: str, value: Any, expected: type, kind: str) -> None:
if value is not None and not isinstance(value, expected):
raise ValueError(
f"{path}: '{key}' must be {kind}, got {type(value).__name__}"
)
cfg = ComputeConfig()
if "image" in data:
_require("image", data["image"], str, "a string")
cfg.image = data["image"]
if "packages" in data:
pkgs = data["packages"] or {}
_require("packages", pkgs, dict, "a mapping")
cfg.packages = pkgs
if "setup" in data:
setup = data["setup"] or []
if isinstance(setup, str):
setup = [setup]
_require("setup", setup, list, "a string or list of strings")
cfg.setup = list(setup)
if "refresh" in data:
refresh = data["refresh"] or []
if isinstance(refresh, str):
refresh = [refresh]
_require("refresh", refresh, list, "a string or list of strings")
# ``refresh`` runs only when starting from a capture (cheap incremental
# step). Carried in metadata so no new dataclass field is added without
# a typed consumer; the docker capture path reads it.
cfg.metadata["refresh"] = list(refresh)
if "env" in data:
env = data["env"] or {}
_require("env", env, dict, "a mapping")
cfg.env = {str(k): str(v) for k, v in env.items()}
if "working_dir" in data:
_require("working_dir", data["working_dir"], str, "a string")
cfg.working_dir = data["working_dir"]
if "mount_paths" in data:
mounts = data["mount_paths"] or []
_require("mount_paths", mounts, list, "a list")
cfg.mount_paths = list(mounts)
resources = data.get("resources") or {}
_require("resources", resources, dict, "a mapping")
if "cpu" in resources:
cfg.cpu = int(resources["cpu"])
if "memory_mb" in resources:
cfg.memory_mb = int(resources["memory_mb"])
if "gpu" in resources:
cfg.gpu = resources["gpu"]
# Carry network / backend preferences in existing typed fields so no new
# dataclass surface is added without a consumer.
if "network" in data:
cfg.networking = {"type": data["network"]}
if "backend" in data:
cfg.metadata["backend"] = data["backend"]
if "capture" in data:
# Opt-in flag for backends where capture may carry provider cost
# (cloud snapshots). Local docker commit is free and always on.
cfg.metadata["capture"] = bool(data["capture"])
return cfg
def _definition_payload(config: "ComputeConfig") -> Dict[str, Any]:
"""Normalised, order-insensitive view of the fields that change *what gets
built* (image, packages, setup, env variable names, resources, working_dir).
``env`` **values** are deliberately excluded here — see :func:`definition_hash`.
"""
def _norm_packages(pkgs: Dict[str, List[str]]) -> Dict[str, List[str]]:
return {k: sorted(map(str, v or [])) for k, v in sorted((pkgs or {}).items())}
return {
"image": config.image,
"packages": _norm_packages(config.packages),
"setup": list(config.setup or []),
"env_names": sorted((config.env or {}).keys()),
"cpu": config.cpu,
"memory_mb": config.memory_mb,
"gpu": config.gpu,
"working_dir": config.working_dir,
}
def definition_hash(config: "ComputeConfig") -> str:
"""Stable content hash of an environment definition, for display/logging.
Hashes only the fields that change *what gets built* (image, packages,
setup, env variable names, resources, working_dir). Values of ``env`` are
excluded because they are typically secrets/tenant-specific and must not
leak into a hash that may be logged or shown in ``list_captures``. The
result is a short, stable sha256 hex digest: reordering keys/lists that
describe the same environment yields the same hash; changing a package or
setup step yields a new one.
.. note::
This value-free hash is safe to log but is **not** a safe reuse key on
its own: ``setup:`` runs with ``env`` values injected and may bake
env-derived state into the filesystem, so a capture must not be reused
across differing secret values. Use :func:`capture_key` for the actual
reuse/cache key — it additionally binds the ``env`` **values** so
different secrets produce a different capture, while never being logged.
Args:
config: The :class:`ComputeConfig` to fingerprint.
Returns:
A 12-char hex digest.
"""
import hashlib
import json
blob = json.dumps(
_definition_payload(config), sort_keys=True, separators=(",", ":")
)
return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:12]
def capture_key(config: "ComputeConfig") -> str:
"""Secret-aware reuse key for capturing/reusing a provisioned environment.
Extends :func:`definition_hash` by additionally binding the ``env``
**values**. ``setup:`` executes with those values injected and may persist
env-derived state (tokens, tenant config) into the committed filesystem, so
a capture is only safe to reuse when the values match too. Two definitions
that are identical except for their secret values therefore get *different*
capture keys and never share a committed image.
The env values are folded into the digest (never returned in the clear and
never logged), so this key is suitable for an image tag
(``praisonai-env:{key}``) but should not itself be surfaced to users.
Args:
config: The :class:`ComputeConfig` to fingerprint.
Returns:
A 12-char hex digest that changes with any build input *or* secret value.
"""
import hashlib
import json
payload = dict(_definition_payload(config))
payload["env_values"] = {
str(k): str(v) for k, v in sorted((config.env or {}).items())
}
blob = json.dumps(payload, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:12]
@runtime_checkable
class SupportsCapture(Protocol):
"""Optional capability: capture a provisioned instance and reuse it.
Backends that can snapshot post-setup state (docker ``commit``, cloud SDK
pause/snapshot) implement this so a second provision of the same definition
starts *from the capture* — skipping pull + package install + ``setup:``.
Backends that cannot (subprocess/ssh) simply don't implement it; the
provision flow then falls back to today's ephemeral behaviour. Methods
return ``None`` to signal "no capture available", never raising, so a failed
capture degrades to ephemeral rather than blocking the run.
"""
def capture(self, instance_id: str, ref: str) -> Optional[str]:
"""Capture ``instance_id``'s current state under ``ref``.
Args:
instance_id: Instance to capture.
ref: Stable name to record the capture under (e.g. a definition hash).
Returns:
An opaque capture reference (image tag / snapshot id) on success,
or ``None`` if capture was unavailable or failed.
"""
...
def has_capture(self, ref: str) -> bool:
"""Whether a reusable capture exists for ``ref``."""
...
@dataclass
class InstanceInfo:
"""Runtime information about a provisioned compute instance."""
@@ -78,12 +78,14 @@ class MCPToolRunner(threading.Thread):
response_queue, kind, name, arguments = item
try:
if kind == "resource":
result = await session.read_resource(name)
result = await asyncio.wait_for(session.read_resource(name), timeout=self.timeout)
elif kind == "prompt":
result = await session.get_prompt(name, arguments or None)
result = await asyncio.wait_for(session.get_prompt(name, arguments or None), timeout=self.timeout)
else:
result = await session.call_tool(name, arguments)
result = await asyncio.wait_for(session.call_tool(name, arguments), timeout=self.timeout)
response_queue.put((True, result))
except asyncio.TimeoutError:
response_queue.put((False, f"MCP {kind} call timed out after {self.timeout} seconds (server side)"))
except Exception as e:
response_queue.put((False, str(e)))
except queue.Empty:
@@ -385,8 +387,11 @@ class MCP:
# Check if this is an HTTP URL
if isinstance(command_or_string, str) and re.match(r'^https?://', command_or_string):
# Determine transport type based on URL or kwargs
if command_or_string.endswith('/sse') and 'transport_type' not in kwargs:
# Determine transport type based on URL or kwargs. Delegate the
# URL->transport classification to the shared helper so there is a
# single source of truth (see mcp_transport.get_transport_type).
from .mcp_transport import get_transport_type
if get_transport_type(command_or_string) == "sse" and 'transport_type' not in kwargs:
# Legacy SSE URL - use SSE transport for backward compatibility
from .mcp_sse import SSEMCPClient
self.sse_client = SSEMCPClient(command_or_string, debug=debug, timeout=timeout)
@@ -16,6 +16,7 @@ Storage Structure:
└── summaries.json # LLM-generated summaries
"""
import os
import json
import time
import sys
@@ -235,19 +236,40 @@ class FileMemory:
def _write_json(self, filepath: Path, data: Any) -> bool:
"""Write JSON file with file locking (Unix only)."""
"""Write JSON file atomically, with file locking (Unix only).
Writes to a uniquely-named temp file in the same directory and atomically
renames it into place via os.replace so a crash mid-write cannot leave the
memory store truncated (open(path, 'w') truncates before any lock can be
acquired). tempfile.mkstemp guarantees a distinct temp name per write, so
concurrent writers in the same process (multiple threads or FileMemory
instances) cannot clobber each other's temp file (matches the atomic-write
pattern used across the SDK, e.g. storage/base.py and session/store.py).
"""
import tempfile
tmp_fd, tmp_name = tempfile.mkstemp(
dir=str(filepath.parent), prefix=f".{filepath.name}.", suffix=".tmp"
)
tmp_path = Path(tmp_name)
try:
with open(filepath, 'w', encoding='utf-8') as f:
with os.fdopen(tmp_fd, 'w', encoding='utf-8') as f:
if _HAS_FCNTL:
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
try:
json.dump(data, f, indent=2, ensure_ascii=False)
f.flush()
os.fsync(f.fileno())
finally:
if _HAS_FCNTL:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
os.replace(tmp_path, filepath)
return True
except IOError as e:
except (IOError, OSError) as e:
self._log(f"Error writing {filepath}: {e}", logging.ERROR)
try:
tmp_path.unlink()
except OSError:
pass
return False
@@ -329,6 +351,13 @@ class FileMemory:
The generated memory ID
"""
with self._lock:
# Re-read the current on-disk state before mutating so a concurrent
# writer sharing the same files (e.g. another agent with the same
# user_id) doesn't get its entries silently discarded by this save.
self._short_term = [
MemoryItem.from_dict(i)
for i in self._read_json(self.short_term_file, [])
]
item = MemoryItem(
id=self._generate_id(content),
content=content,
@@ -367,6 +396,12 @@ class FileMemory:
def _auto_promote_to_long_term(self):
"""Promote high-importance short-term memories to long-term."""
# Note: This method is called within _lock context from add_short_term
# Re-read long-term from disk before appending so concurrent writers
# sharing the same files aren't overwritten by the promotion save.
self._long_term = [
MemoryItem.from_dict(i)
for i in self._read_json(self.long_term_file, [])
]
threshold = self.config["importance_threshold"]
promoted = []
@@ -404,6 +439,13 @@ class FileMemory:
The generated memory ID
"""
with self._lock:
# Re-read the current on-disk state before mutating so a concurrent
# writer sharing the same files (e.g. another agent with the same
# user_id) doesn't get its entries silently discarded by this save.
self._long_term = [
MemoryItem.from_dict(i)
for i in self._read_json(self.long_term_file, [])
]
item = MemoryItem(
id=self._generate_id(content),
content=content,
@@ -460,6 +502,13 @@ class FileMemory:
The entity ID
"""
with self._lock:
# Re-read the current on-disk state before mutating so a concurrent
# writer sharing the same files (e.g. another agent with the same
# user_id) doesn't get its entries silently discarded by this save.
self._entities = {
k: EntityItem.from_dict(v)
for k, v in self._read_json(self.entities_file, {}).items()
}
entity_id = self._generate_id(f"{name}:{entity_type}")
# Check if entity exists
@@ -142,6 +142,10 @@ class BaseStore(ABC):
Checks for exact content matches to prevent duplicate learnings.
If a duplicate is found, returns the existing entry instead of creating a new one.
"""
# Re-read the current on-disk state before mutating so a concurrent
# writer sharing the same store (e.g. another agent with the same
# user_id) isn't silently overwritten by this save.
self._load()
# Deduplication: Check for exact content match
content_normalized = content.strip().lower()
for existing_entry in self._entries.values():
@@ -98,7 +98,7 @@ class Memory(SearchMixin, MemoryCoreMixin):
vector-based memory for enhanced relationship-aware retrieval.
"""
def __init__(self, config: Dict[str, Any], verbose: int = 0):
def __init__(self, config: Optional[Dict[str, Any]] = None, verbose: int = 0):
self.cfg = config or {}
self.verbose = verbose
@@ -707,11 +707,13 @@ class Memory(SearchMixin, MemoryCoreMixin):
return []
else:
# Delegate to the active adapter when provider falls back to
# sqlite/in_memory. This avoids the legacy short_mem schema mismatch
# where the adapter creates short_term_memory but legacy queries
# short_mem (which is never created by the adapter).
if getattr(self, "provider", None) in ("sqlite", "in_memory") and getattr(self, "memory_adapter", None):
# Delegate to the active adapter whenever one is configured. This
# mirrors the adapter-agnostic store path (store_short_term uses any
# configured memory_adapter regardless of provider name), so data
# stored via a registered adapter (e.g. "dakera") remains findable.
# Otherwise the legacy short_mem query — never written to when the
# adapter store succeeded — would always return [].
if getattr(self, "memory_adapter", None):
try:
adapter_results = self.memory_adapter.search_short_term(query, limit=limit, **kwargs)
if min_quality > 0:
@@ -757,12 +759,11 @@ class Memory(SearchMixin, MemoryCoreMixin):
def reset_short_term(self):
"""Completely clears short-term memory."""
# Delegate to the active adapter when provider falls back to
# sqlite/in_memory, mirroring search_short_term. The adapter creates
# Delegate to the active adapter whenever one is configured, mirroring
# store_short_term/search_short_term. The adapter creates
# short_term_memory; the legacy short_mem table below is never created
# by the adapter, so DELETE FROM short_mem would raise.
if (getattr(self, "provider", None) in ("sqlite", "in_memory")
and getattr(self, "memory_adapter", None)):
if getattr(self, "memory_adapter", None):
if hasattr(self.memory_adapter, "reset_short_term"):
self.memory_adapter.reset_short_term()
return
@@ -817,15 +818,14 @@ class Memory(SearchMixin, MemoryCoreMixin):
ident = str(time.time_ns())
created = time.time()
# Protocol-driven storage: Try adapter first only when the provider has
# fallen back to sqlite/in_memory. This keeps storage consistent with
# search (which delegates to the adapter for the same providers) and
# avoids the legacy long_mem schema mismatch. Other providers (chroma,
# mem0, mongodb) keep their existing dedicated write paths below so the
# embedding model and vector space stay consistent with search.
# Protocol-driven storage: Try adapter first whenever one is configured.
# This mirrors store_short_term (adapter-agnostic) and keeps storage
# symmetric with search_long_term, so data stored via a registered
# adapter (e.g. "dakera") is written to — and later found in — the same
# place. The dedicated chroma/mem0/mongodb write paths below are only
# used when no adapter handled the store.
adapter_success = False
if (getattr(self, "provider", None) in ("sqlite", "in_memory")
and getattr(self, "memory_adapter", None)):
if getattr(self, "memory_adapter", None):
try:
result_id = self.memory_adapter.store_long_term(text, metadata=metadata)
logger.info(f"Successfully stored via memory adapter with ID: {result_id}")
@@ -858,14 +858,13 @@ class Memory(SearchMixin, MemoryCoreMixin):
# Continue to SQLite fallback
# Store in SQLite (with write lock for concurrency safety).
# Skip the legacy long_mem schema for adapter-driven sqlite/in_memory
# providers: those use the adapter's long_term_memory table and the
# legacy long_mem table is never created, which would silently drop
# the write. Surface the adapter failure instead.
if not adapter_success and getattr(self, "provider", None) in ("sqlite", "in_memory") \
and getattr(self, "memory_adapter", None):
# Skip the legacy long_mem schema for adapter-driven providers: those use
# the adapter's long_term_memory table and the legacy long_mem table is
# never created, which would silently drop the write (and search delegates
# to the adapter, not long_mem). Surface the adapter failure instead.
if not adapter_success and getattr(self, "memory_adapter", None):
raise RuntimeError(
"Long-term store failed via adapter for sqlite/in_memory provider; "
"Long-term store failed via memory adapter; "
"the legacy long_mem table is not schema-compatible."
)
@@ -1057,10 +1056,12 @@ class Memory(SearchMixin, MemoryCoreMixin):
except Exception as e:
self._log_verbose(f"Error searching ChromaDB: {e}", logging.ERROR)
# Delegate to the active adapter when provider falls back to
# sqlite/in_memory. This avoids the legacy long_mem schema mismatch
# where the adapter creates long_term_memory but legacy queries long_mem.
if not found and getattr(self, "provider", None) in ("sqlite", "in_memory") and getattr(self, "memory_adapter", None):
# Delegate to the active adapter whenever one is configured. This mirrors
# the adapter-agnostic store path (store_long_term uses any configured
# memory_adapter regardless of provider name), so data stored via a
# registered adapter (e.g. "dakera") remains findable rather than being
# lost to the legacy long_mem query that was never written to.
if not found and getattr(self, "memory_adapter", None):
try:
adapter_results = self.memory_adapter.search_long_term(query, limit=limit, **kwargs)
if min_quality > 0:
@@ -1129,12 +1130,11 @@ class Memory(SearchMixin, MemoryCoreMixin):
def reset_long_term(self):
"""Clear local LTM DB, plus Chroma, MongoDB, or mem0 if in use."""
# Delegate to the active adapter when provider falls back to
# sqlite/in_memory, mirroring search_long_term. The adapter creates
# long_term_memory; the legacy long_mem table below is never created
# by the adapter, so DELETE FROM long_mem would raise.
if (getattr(self, "provider", None) in ("sqlite", "in_memory")
and getattr(self, "memory_adapter", None)):
# Delegate to the active adapter whenever one is configured, mirroring
# store_long_term/search_long_term. The adapter creates long_term_memory;
# the legacy long_mem table below is never created by the adapter, so
# DELETE FROM long_mem would raise.
if getattr(self, "memory_adapter", None):
if hasattr(self.memory_adapter, "reset_long_term"):
self.memory_adapter.reset_long_term()
return
@@ -1186,12 +1186,11 @@ class Memory(SearchMixin, MemoryCoreMixin):
Returns:
True if memory was found and deleted, False otherwise
"""
# Delegate to the active adapter when provider falls back to
# sqlite/in_memory, mirroring search_short_term. Data written through
# the adapter lives in short_term_memory, not the legacy short_mem
# table, so a direct DELETE below would silently match zero rows.
if (getattr(self, "provider", None) in ("sqlite", "in_memory")
and getattr(self, "memory_adapter", None)
# Delegate to the active adapter whenever one is configured, mirroring
# store_short_term/search_short_term. Data written through the adapter
# lives in short_term_memory, not the legacy short_mem table, so a direct
# DELETE below would silently match zero rows.
if (getattr(self, "memory_adapter", None)
and hasattr(self.memory_adapter, "delete_memory")):
return self.memory_adapter.delete_memory(memory_id, tier="short")
@@ -1236,12 +1235,11 @@ class Memory(SearchMixin, MemoryCoreMixin):
Returns:
True if memory was found and deleted, False otherwise
"""
# Delegate to the active adapter when provider falls back to
# sqlite/in_memory, mirroring search_long_term. Data written through
# the adapter lives in long_term_memory, not the legacy long_mem
# table, so a direct DELETE below would silently match zero rows.
if (getattr(self, "provider", None) in ("sqlite", "in_memory")
and getattr(self, "memory_adapter", None)
# Delegate to the active adapter whenever one is configured, mirroring
# store_long_term/search_long_term. Data written through the adapter
# lives in long_term_memory, not the legacy long_mem table, so a direct
# DELETE below would silently match zero rows.
if (getattr(self, "memory_adapter", None)
and hasattr(self.memory_adapter, "delete_memory")):
return self.memory_adapter.delete_memory(memory_id, tier="long")
@@ -1397,6 +1395,62 @@ class Memory(SearchMixin, MemoryCoreMixin):
return deleted
# -------------------------------------------------------------------------
# Convenience API (remember / recall / forget)
# -------------------------------------------------------------------------
# Thin, intuitive aliases over the existing long-term store/search/delete
# methods. They do not replace any backend or add new storage — they simply
# provide a friendlier entry point for standalone scripts and agents.
def remember(
self,
content: str,
*,
metadata: Optional[Dict[str, Any]] = None,
**kwargs,
) -> str:
"""Store a fact in long-term memory; returns the memory id.
Convenience alias over :meth:`store_long_term`.
"""
return self.store_long_term(content, metadata=metadata, **kwargs)
def recall(
self,
query: str,
*,
limit: int = 5,
**kwargs,
) -> List[Dict[str, Any]]:
"""Retrieve matching facts from long-term memory.
Convenience alias over :meth:`search_long_term`.
"""
return self.search_long_term(query, limit=limit, **kwargs)
def forget(
self,
*,
memory_id: Optional[str] = None,
query: Optional[str] = None,
**kwargs,
) -> int:
"""Delete memories by id or matching query; returns count deleted.
Convenience wrapper over :meth:`delete_memory` /
:meth:`delete_memories_matching`. Provide exactly one of
``memory_id`` or ``query``.
"""
if (memory_id is None) == (query is None):
raise ValueError("forget() requires exactly one of 'memory_id' or 'query'")
if memory_id is not None:
return 1 if self.delete_memory(memory_id, **kwargs) else 0
if not query.strip():
raise ValueError("forget() query must be a non-empty string")
# Scope query-based deletion to long-term memory to mirror
# remember()/recall(), which operate on long-term storage only.
kwargs.setdefault("memory_type", "long_term")
return self.delete_memories_matching(query, **kwargs)
# -------------------------------------------------------------------------
# Entity Memory Methods
# -------------------------------------------------------------------------
@@ -117,6 +117,20 @@ def get_sessions_dir() -> Path:
return get_data_dir() / "sessions"
def get_session_spill_dir() -> Path:
"""
Get the session spill directory (last-resort salvage on write failure).
When a durable session write fails (disk-full / corruption / permission),
the already-produced turn is spilled here atomically and re-ingested on the
next load (Issue #3597).
Returns:
Path to ~/.praisonai/state/session_spill/
"""
return get_data_dir() / "state" / "session_spill"
def get_skills_dir() -> Path:
"""
Get user skills directory.
@@ -102,14 +102,8 @@ class DoomLoopDetector:
def _hash_arguments(self, arguments: Dict[str, Any]) -> str:
"""Create a hash of tool arguments."""
import hashlib
import json
try:
arg_str = json.dumps(arguments, sort_keys=True, default=str)
return hashlib.sha256(arg_str.encode()).hexdigest()[:16]
except (TypeError, ValueError):
return "unhashable"
from praisonaiagents.approval.utils import hash_tool_args
return hash_tool_args(arguments)
def _cleanup_old_records(self):
"""Remove records outside the time window."""
@@ -430,14 +430,19 @@ class PermissionManager:
"""Legacy flat matching against approvals and rules for a target."""
with self._lock:
# Check persistent approvals first
for approval in self._approvals:
for approval in list(self._approvals):
if approval.matches(target, agent):
return PermissionResult(
result = PermissionResult(
action=PermissionAction.ALLOW if approval.approved else PermissionAction.DENY,
target=target,
reason=f"Persistent approval: {'approved' if approval.approved else 'denied'}",
approved=approval.approved,
)
# A "once" approval/denial is consumed on first match so it
# does not silently become permanent for the process.
if approval.scope == "once":
self._approvals.remove(approval)
return result
# Check rules
for rule in self._rules:
@@ -39,6 +39,55 @@ class PermissionMode(str, Enum):
BYPASS = "bypass_permissions"
PLAN = "plan"
@classmethod
def resolve(cls, value: Any) -> Optional["PermissionMode"]:
"""Resolve a preset name/alias to a canonical ``PermissionMode``.
This is the single place that maps the many historical spellings for
"how much the agent may do" onto one enum, so CLI, YAML and Python all
agree. Returns ``None`` for values that are not a permission preset
(e.g. the deny-set presets ``safe``/``read_only``/``full``/``off``),
letting callers fall through to their existing handling.
Recognised aliases (case-insensitive, ``-``/``_`` interchangeable):
- ``plan`` … PLAN (read-only)
- ``bypass``, ``bypass_permissions``, ``yolo``, ``full_auto`` … BYPASS
- ``accept_edits``, ``auto_edit`` … ACCEPT_EDITS
- ``dont_ask``, ``reject``, ``no_ask`` … DONT_ASK
- ``default``, ``ask``, ``suggest``, ``prompt`` … DEFAULT
Note: the deny-set presets ``safe``/``read_only``/``full``/``off`` are
deliberately *not* modes and return ``None`` here — they are handled by
their own deny-set machinery in ``Agent``.
"""
if isinstance(value, cls):
return value
if not isinstance(value, str):
return None
key = value.strip().lower().replace("-", "_")
return _MODE_ALIASES.get(key)
# Deprecated/alternate spellings (AutonomyMode / ApprovalMode / CLI flags) that
# resolve onto the single ``PermissionMode``. Kept as thin aliases so the same
# preset name means the same thing across every surface (backward-compatible).
_MODE_ALIASES: Dict[str, PermissionMode] = {
"default": PermissionMode.DEFAULT,
"ask": PermissionMode.DEFAULT,
"suggest": PermissionMode.DEFAULT,
"prompt": PermissionMode.DEFAULT,
"plan": PermissionMode.PLAN,
"accept_edits": PermissionMode.ACCEPT_EDITS,
"auto_edit": PermissionMode.ACCEPT_EDITS,
"dont_ask": PermissionMode.DONT_ASK,
"no_ask": PermissionMode.DONT_ASK,
"reject": PermissionMode.DONT_ASK,
"bypass": PermissionMode.BYPASS,
"bypass_permissions": PermissionMode.BYPASS,
"yolo": PermissionMode.BYPASS,
"full_auto": PermissionMode.BYPASS,
}
@dataclass
class PermissionRule:
@@ -227,7 +276,12 @@ class PersistentApproval:
if not self.is_valid():
return False
if self.agent_name and agent_name and self.agent_name != agent_name:
# An approval scoped to a specific agent must never match a call that
# can't prove it's that agent — including calls with no agent_name at
# all. ``agent_name != self.agent_name`` covers both "different name"
# and "no name" (None != "trusted-agent" is True), closing the bypass
# while leaving unscoped approvals (self.agent_name is None) unaffected.
if self.agent_name is not None and agent_name != self.agent_name:
return False
if fnmatch.fnmatch(target, self.pattern):
@@ -32,7 +32,7 @@ class TodoItem:
Attributes:
id: Unique identifier
description: What needs to be done
status: Current status (pending, in_progress, completed)
status: Current status (pending, in_progress, completed, cancelled)
dependencies: List of item IDs that must complete first
agent: Name of the agent responsible
priority: Priority level (low, medium, high)
@@ -40,7 +40,7 @@ class TodoItem:
"""
description: str
id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
status: Literal["pending", "in_progress", "completed"] = "pending"
status: Literal["pending", "in_progress", "completed", "cancelled"] = "pending"
dependencies: List[str] = field(default_factory=list)
agent: Optional[str] = None
priority: Literal["low", "medium", "high"] = "medium"
@@ -43,6 +43,7 @@ __all__ = [
"maybe_enable_from_config",
"list_plugins",
"is_enabled",
"get_plugin_registry",
# Core
"PluginManager",
"Plugin",
@@ -304,6 +305,119 @@ def is_enabled(name: str = None) -> bool:
return manager.is_enabled(name)
def get_plugin_registry() -> list:
"""Return a truthful, unified view of all discoverable plugins.
Combines three real sources — no hardcoded/fake entries:
- Entry-point plugins (installed pip packages registering in the
``praisonai.plugins`` group), reported with their ``source`` as their
distribution/entry-point provenance.
- Registered ``Plugin`` instances held by the :class:`PluginManager`.
- Project/user single-file plugins discovered on disk (``.praisonai/plugins``
and ``~/.praisonai/plugins``), reported without executing them.
Each entry is a dict with ``name``, ``version``, ``source``
(``entry_point`` | ``registered`` | ``single_file``), ``enabled`` state,
``hooks`` and ``description``. Enabled state reflects both the live manager
and the persisted config allow-list, so a truthful CLI can render it
without a running agent.
Returns:
List of plugin entry dicts.
"""
from .manager import get_plugin_manager
manager = get_plugin_manager()
# Config-driven enabled allow-list (None => all enabled when plugins on).
config_enabled = None
try:
from ..config.loader import get_enabled_plugins
config_enabled = get_enabled_plugins()
except Exception:
config_enabled = None
def _config_says_enabled(name: str) -> bool:
if config_enabled is None:
return manager.is_enabled(name)
return name in config_enabled
entries: list = []
seen: set = set()
# 1. Registered Plugin instances (includes entry-point plugins already
# loaded via discover_entry_points()).
for info in manager.list_plugins():
name = getattr(info, "name", None)
if not name or name in seen:
continue
seen.add(name)
hooks = [
h.value if hasattr(h, "value") else str(h)
for h in (getattr(info, "hooks", None) or [])
]
entries.append({
"name": name,
"version": getattr(info, "version", "1.0.0"),
"description": getattr(info, "description", ""),
"source": "registered",
"enabled": manager.is_enabled(name) or _config_says_enabled(name),
"hooks": hooks,
})
# 2. Entry-point plugins present on the system but not yet loaded, so
# `list` shows provenance even before enable().
try:
import importlib.metadata as _md
try:
eps = _md.entry_points(group="praisonai.plugins")
except TypeError:
eps = _md.entry_points().get("praisonai.plugins", [])
for ep in eps:
if ep.name in seen:
continue
seen.add(ep.name)
dist = getattr(getattr(ep, "dist", None), "name", None)
entries.append({
"name": ep.name,
"version": "-",
"description": "",
"source": f"entry_point:{dist}" if dist else "entry_point",
"enabled": _config_says_enabled(ep.name),
"hooks": [],
})
except Exception as e:
import logging
logging.getLogger(__name__).debug(f"Entry-point plugin scan failed: {e}")
# 3. Single-file plugins discovered on disk (metadata only, no exec).
try:
from .discovery import discover_plugins
for meta in discover_plugins():
name = meta.get("name")
if not name or name in seen:
continue
seen.add(name)
entries.append({
"name": name,
"version": meta.get("version", "1.0.0"),
"description": meta.get("description", ""),
"source": "single_file",
"enabled": manager.is_enabled(name) or _config_says_enabled(name),
"hooks": list(meta.get("hooks", []) or []),
"path": meta.get("path"),
})
except Exception as e:
import logging
logging.getLogger(__name__).debug(f"Single-file plugin discovery failed: {e}")
return entries
def __getattr__(name: str):
"""Lazy load module components."""
# Core classes
@@ -19,6 +19,8 @@ Usage:
"""
import importlib.util
import os
import threading
from praisonaiagents._logging import get_logger
import sys
from pathlib import Path
@@ -29,6 +31,70 @@ from ..paths import get_plugins_dir, get_project_data_dir
logger = get_logger(__name__)
# Maps a loaded plugin's generated module name to the tool names it harvested,
# so unload_plugin can unregister them instead of leaking active tools.
_loaded_plugin_tools: Dict[str, List[str]] = {}
# Guards the read-modify-write of _loaded_plugin_tools so concurrent load and
# unload calls from different threads cannot leave a tool registered with no
# tracking entry.
_loaded_plugin_tools_lock = threading.Lock()
def _project_plugins_allowed() -> bool:
"""Whether executing project-local single-file plugins is authorised.
Single-file plugins in ``./.praisonai/plugins/*.py`` can hook every
lifecycle event and intercept every tool call, so they are the most
privileged extension surface. They therefore share the same opt-in trust
gate that project-local tools use: an explicit environment flag. This
mirrors ``PRAISONAI_ALLOW_LOCAL_TOOLS`` for tools; a cloned repo carrying a
malicious ``.praisonai/plugins/exfil.py`` will not run until the user opts
in.
User-global plugins (``~/.praisonai/plugins/``) are treated as trusted
(the user placed them there themselves), matching entry-point plugins
installed via pip.
"""
env = os.environ.get("PRAISONAI_ALLOW_PROJECT_PLUGINS", "").strip().lower()
if env in ("true", "1", "yes", "on"):
return True
if env in ("false", "0", "no", "off"):
return False
try:
from ..config.loader import get_plugins_config
return bool(getattr(get_plugins_config(), "allow_project_plugins", False))
except Exception:
return False
def _is_project_local(path: Path) -> bool:
"""True when ``path`` is reached via the project-local plugins directory.
The trust gate must fire on *how the file was reached*, not on where a
symlink ultimately points. A repository-controlled symlink at
``.praisonai/plugins/evil.py -> /tmp/evil.py`` is still project-controlled
code, so we compare the file's own (un-resolved) location against the
project plugins directory. We resolve only the parent directories (not the
final component) so a project ``plugins`` symlink is still recognised while
a symlinked plugin *file* inside it cannot slip the gate by resolving
elsewhere.
"""
try:
project_plugins = (get_project_data_dir() / "plugins").resolve()
except Exception:
return False
try:
# Resolve the containing directory (following any symlinked dirs) but
# keep the file's own name un-resolved, so a symlinked plugin file is
# judged by its location under .praisonai/plugins, not its target.
located = path.expanduser()
candidate = located.parent.resolve() / located.name
candidate.relative_to(project_plugins)
return True
except ValueError:
return False
def get_default_plugin_dirs() -> List[Path]:
"""Get default plugin directory locations.
@@ -132,8 +198,12 @@ def load_plugin(filepath: str) -> Optional[Dict[str, Any]]:
Returns:
Plugin metadata dict with 'tools' and 'hooks' lists, or None on error
"""
path = Path(filepath).resolve()
# Keep the caller's location un-resolved for the trust gate so a
# repository-controlled symlink under .praisonai/plugins cannot escape the
# gate by pointing its target elsewhere.
located = Path(filepath).expanduser()
path = located.resolve()
if not path.exists():
logger.error(f"Plugin file not found: {filepath}")
return None
@@ -141,7 +211,20 @@ def load_plugin(filepath: str) -> Optional[Dict[str, Any]]:
if not path.suffix == '.py':
logger.error(f"Plugin must be a Python file: {filepath}")
return None
# Trust gate: project-local single-file plugins are the most privileged
# extension surface (they can hook every lifecycle event and intercept
# every tool call), so refuse to exec them unless explicitly authorised —
# matching the opt-in required for project-local tools.
if _is_project_local(located) and not _project_plugins_allowed():
logger.warning(
"Refusing to load project plugin %s: set "
"PRAISONAI_ALLOW_PROJECT_PLUGINS=true (or plugins.allow_project_plugins "
"in .praisonai/config.yaml) to enable.",
path,
)
return None
try:
# Parse header first
metadata = parse_plugin_header_from_file(str(path))
@@ -166,7 +249,15 @@ def load_plugin(filepath: str) -> Optional[Dict[str, Any]]:
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
# Snapshot the registry before exec: the @tool decorator auto-registers
# on module load, so any tool name present *after* exec but not before
# is one this module contributed and therefore owns.
try:
pre_exec_tools = set(registry.list_tools())
except Exception:
pre_exec_tools = set()
try:
spec.loader.exec_module(module)
except Exception as e:
@@ -178,20 +269,31 @@ def load_plugin(filepath: str) -> Optional[Dict[str, Any]]:
# The @tool decorator creates FunctionTool instances but may not
# register them if the registry wasn't fully initialized
new_tools = []
owned_tools = []
for attr_name in dir(module):
if attr_name.startswith('_'):
continue
attr = getattr(module, attr_name, None)
if isinstance(attr, BaseTool):
tool_name = attr.name
# Register if not already registered
# Register any tool not already present (e.g. if the decorator
# could not auto-register).
if registry.get(tool_name) is None:
registry.register(attr)
# A tool is "owned" (safe to unregister on unload) only if it
# was absent before this module executed — tools already present
# belong to another plugin or the core registry.
if tool_name not in pre_exec_tools:
owned_tools.append(tool_name)
new_tools.append(tool_name)
# Add discovered tools to metadata
metadata["tools"] = new_tools
metadata["module"] = module_name
# Remember only the tools this module actually registered so unload can
# clean them up without removing tools still in use elsewhere.
with _loaded_plugin_tools_lock:
_loaded_plugin_tools[module_name] = owned_tools
logger.info(f"Loaded plugin: {metadata['name']} (tools: {new_tools})")
return metadata
@@ -230,17 +332,33 @@ def discover_and_load_plugins(
return loaded
def unload_plugin(module_name: str) -> bool:
"""Unload a plugin module.
Note: This removes the module from sys.modules but does NOT
unregister tools or hooks that were already registered.
"""Unload a plugin module and unregister the tools it harvested.
Removes the module from ``sys.modules`` and unregisters every tool the
module contributed to the global tool registry, so a disable-by-unload no
longer leaks active tools.
Args:
module_name: The module name (from plugin metadata)
Returns:
True if unloaded, False if not found
True if the module was present and unloaded, False if not found
"""
with _loaded_plugin_tools_lock:
tools = _loaded_plugin_tools.pop(module_name, [])
if tools:
try:
from ..tools.registry import get_registry
registry = get_registry()
for tool_name in tools:
try:
registry.unregister(tool_name)
except Exception as e:
logger.debug(f"Failed to unregister tool '{tool_name}': {e}")
except Exception as e:
logger.debug(f"Failed to access tool registry during unload: {e}")
if module_name in sys.modules:
del sys.modules[module_name]
return True
@@ -21,6 +21,21 @@ if TYPE_CHECKING:
logger = get_logger(__name__)
def _env_plugins_suppressed() -> bool:
"""Return True when external plugins are suppressed via env for this run.
Honours ``PRAISONAI_NO_PLUGINS`` (set by the CLI ``--pure`` / ``--no-plugins``
flag) so a single invocation can skip discovery of external plugins without
mutating any persisted enable/disable state.
"""
import os
return os.environ.get("PRAISONAI_NO_PLUGINS", "").strip().lower() in (
"true", "1", "yes",
)
class PluginManager:
"""
Manages plugin discovery, loading, and execution.
@@ -44,7 +59,7 @@ class PluginManager:
args = await manager.async_execute_hook(PluginHook.BEFORE_TOOL, "bash", {"cmd": "ls"})
"""
def __init__(self):
def __init__(self, disabled: Optional[bool] = None):
self._plugins: Dict[str, Plugin] = {}
self._enabled: Dict[str, bool] = {}
self._single_file_plugins: Dict[str, Dict[str, Any]] = {} # WordPress-style plugins
@@ -52,6 +67,30 @@ class PluginManager:
# delivered to each plugin's on_config hook. {plugin_name: options}.
self._plugin_options: Dict[str, Dict[str, Any]] = {}
self._lock = threading.RLock() # Thread safety for multi-agent environments
# Ephemeral, per-process suppression of external plugin discovery.
# ``None`` (default) defers to the ``PRAISONAI_NO_PLUGINS`` env var so
# the CLI ``--pure`` / ``--no-plugins`` flag works; passing ``True``
# (e.g. Python ``plugins=False`` parity) forces suppression regardless.
self._disabled = disabled
self._suppression_notified = False
def is_discovery_disabled(self) -> bool:
"""Return True when external plugin discovery is suppressed this run.
A constructor ``disabled=True`` wins; otherwise defers to the
``PRAISONAI_NO_PLUGINS`` env var. Never mutates persisted state.
"""
if self._disabled is not None:
return bool(self._disabled)
return _env_plugins_suppressed()
def _notify_suppressed_once(self) -> None:
"""Emit a single 'running without external plugins' notice per manager."""
if not self._suppression_notified:
self._suppression_notified = True
logger.info(
"Running without external plugins (--pure / PRAISONAI_NO_PLUGINS)"
)
def set_plugin_options(
self,
@@ -488,6 +527,10 @@ class PluginManager:
"""
import os
if self.is_discovery_disabled():
self._notify_suppressed_once()
return 0
if os.environ.get("PRAISONAI_ALLOW_PLUGIN_DISCOVERY", "").strip().lower() not in (
"true", "1", "yes",
):
@@ -525,6 +568,10 @@ class PluginManager:
Returns:
Number of plugins loaded successfully.
"""
if self.is_discovery_disabled():
self._notify_suppressed_once()
return 0
try:
import importlib.metadata as _md
except ImportError:
@@ -11,10 +11,19 @@ Format:
Author: Your Name
Hooks: before_tool, after_tool
Dependencies: requests, aiohttp
Channels: telegram, slack
Provides: get_weather, send_email
Config: api_key, timeout
Auto Enable When Configured: TELEGRAM_TOKEN
'''
This is the SIMPLEST possible plugin format - just a Python file
with a docstring header at the top.
The optional ``Channels``/``Provides``/``Config``/``Auto Enable When
Configured`` fields form a static capability manifest: they let discovery,
config validation and capability gating read *what a plugin offers* WITHOUT
importing its runtime (the header is parsed as plain text).
"""
import re
@@ -38,6 +47,11 @@ class PluginMetadata:
hooks: List[str] = field(default_factory=list)
dependencies: List[str] = field(default_factory=list)
path: Optional[str] = None
# Static capability manifest (read without importing the plugin's runtime):
channels: List[str] = field(default_factory=list)
provides: List[str] = field(default_factory=list)
config: List[str] = field(default_factory=list)
auto_enable_when_configured: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
@@ -49,6 +63,10 @@ class PluginMetadata:
"hooks": self.hooks,
"dependencies": self.dependencies,
"path": self.path,
"channels": self.channels,
"provides": self.provides,
"config": self.config,
"auto_enable_when_configured": self.auto_enable_when_configured,
}
@@ -107,7 +125,25 @@ def parse_plugin_header(content: str) -> Dict[str, Any]:
"hooks": "hooks",
"dependencies": "dependencies",
"deps": "dependencies",
# Static capability manifest fields (no import required to read):
"channels": "channels",
"provides": "provides",
"provides_tools": "provides",
"tools": "provides",
"config": "config",
"config_schema": "config",
"auto enable when configured": "auto_enable_when_configured",
"auto_enable_when_configured": "auto_enable_when_configured",
}
list_fields = (
"hooks",
"dependencies",
"channels",
"provides",
"config",
"auto_enable_when_configured",
)
for line in docstring.strip().split('\n'):
line = line.strip()
@@ -123,7 +159,7 @@ def parse_plugin_header(content: str) -> Dict[str, Any]:
field_name = field_mapping.get(key)
if field_name:
# Handle list fields (comma-separated)
if field_name in ("hooks", "dependencies"):
if field_name in list_fields:
if value:
metadata[field_name] = [v.strip() for v in value.split(',') if v.strip()]
else:
@@ -193,4 +229,8 @@ def create_plugin_metadata(data: Dict[str, Any]) -> PluginMetadata:
hooks=data.get("hooks", []),
dependencies=data.get("dependencies", []),
path=data.get("path"),
channels=data.get("channels", []),
provides=data.get("provides", []),
config=data.get("config", []),
auto_enable_when_configured=data.get("auto_enable_when_configured", []),
)
@@ -334,16 +334,61 @@ def create_read_only_policy(name: str = "read_only") -> Policy:
),
PolicyRule(
action=PolicyAction.DENY,
resource="tool:*_write*",
resource="tool:write_*",
reason="Write tools not allowed in read-only mode",
name="deny_write_tools"
),
PolicyRule(
action=PolicyAction.DENY,
resource="tool:*_delete*",
resource="tool:*_write*",
reason="Write tools not allowed in read-only mode",
name="deny_write_tools_suffix"
),
PolicyRule(
action=PolicyAction.DENY,
resource="tool:delete_*",
reason="Delete tools not allowed in read-only mode",
name="deny_delete_tools"
),
PolicyRule(
action=PolicyAction.DENY,
resource="tool:*_delete*",
reason="Delete tools not allowed in read-only mode",
name="deny_delete_tools_suffix"
),
# Other built-in mutating tools shipped by the SDK
# (edit_file, apply_patch, copy_file, move_file, append_file, ...)
# whose names don't contain "write"/"delete".
PolicyRule(
action=PolicyAction.DENY,
resource="tool:edit_*",
reason="Edit tools not allowed in read-only mode",
name="deny_edit_tools"
),
PolicyRule(
action=PolicyAction.DENY,
resource="tool:apply_patch*",
reason="Patch tools not allowed in read-only mode",
name="deny_patch_tools"
),
PolicyRule(
action=PolicyAction.DENY,
resource="tool:copy_*",
reason="Copy tools not allowed in read-only mode",
name="deny_copy_tools"
),
PolicyRule(
action=PolicyAction.DENY,
resource="tool:move_*",
reason="Move tools not allowed in read-only mode",
name="deny_move_tools"
),
PolicyRule(
action=PolicyAction.DENY,
resource="tool:append_*",
reason="Append tools not allowed in read-only mode",
name="deny_append_tools"
),
],
priority=100
)
@@ -0,0 +1,24 @@
"""OpenAI-compatible schema for hierarchical manager delegation."""
from pydantic import BaseModel, ConfigDict, Field
class ManagerInstructions(BaseModel):
"""Instructions emitted by the hierarchical manager each delegation turn.
``extra="forbid"`` makes Pydantic emit ``additionalProperties: false`` in the
generated JSON schema, which OpenAI's strict structured-output API requires.
"""
model_config = ConfigDict(extra="forbid")
task_id: int = Field(
...,
description=(
"Exact task_id integer of the task to run next, chosen from the "
"task_id values shown in the tasks list (these ids are 0-based). "
"Never select manager_task and never invent an id."
),
)
agent_name: str = Field(..., description="Name of the agent assigned to the task")
action: str = Field(..., description="'execute' to run the task or 'stop' to end the workflow")
@@ -9,6 +9,7 @@ from ..agent.agent import Agent
from ..task.task import Task
from ..main import display_error
from ..run_outcome import AgentRunOutcome, RunStatus, validate_decision_string
from .manager_schema import ManagerInstructions
import csv
import os
@@ -971,11 +972,6 @@ Workflow Finished: {self.workflow_finished} # ADDED: Workflow Finished Status
reflection=False
)
class ManagerInstructions(BaseModel):
task_id: int
agent_name: str
action: str
manager_task = Task(
name="manager_task",
description="Decide the order of tasks and which agent executes them",
@@ -997,7 +993,7 @@ Workflow Finished: {self.workflow_finished} # ADDED: Workflow Finished Status
while completed_count < total_tasks:
tasks_summary = []
for tid, tk in self.tasks.items():
if tk.name == "manager_task":
if tid == manager_task_id:
continue
task_info = {
"task_id": tid,
@@ -1069,16 +1065,22 @@ Provide a JSON with the structure:
logging.info("Manager decided to stop task execution")
break
if selected_task_id not in self.tasks:
# Re-prompt the manager with valid task IDs instead of terminating
selected_task = self.tasks.get(selected_task_id)
if selected_task is None or selected_task_id == manager_task_id:
# Reject unknown ids and the synthetic manager_task by its actual id
# (it must never delegate to itself). Matching by id avoids colliding
# with a legitimate user task that happens to be named "manager_task".
# Re-prompt with the delegable IDs only.
invalid_selection_attempts += 1
if invalid_selection_attempts > MAX_INVALID_SELECTIONS:
logging.error(
f"Manager produced {invalid_selection_attempts} invalid task selections; aborting."
)
break
valid_task_ids = list(self.tasks.keys())
valid_task_ids = [
tid for tid in self.tasks if tid != manager_task_id
]
logging.warning(
f"Manager selected invalid task_id={selected_task_id} "
f"(attempt {invalid_selection_attempts}/{MAX_INVALID_SELECTIONS}); valid IDs: {valid_task_ids}"
@@ -1086,7 +1088,8 @@ Provide a JSON with the structure:
# Set error context for next iteration (instead of appending to prompt that gets rebuilt)
error_context = (
f"\n\n[ERROR] Your previous selection of task_id={selected_task_id} was invalid. "
f"Valid task IDs are: {valid_task_ids}. Please select again from the valid options."
f"Valid task IDs are: {valid_task_ids}. Never select manager_task. "
f"Please select again from the valid options."
)
continue # Re-prompt the manager instead of breaking
@@ -1094,10 +1097,10 @@ Provide a JSON with the structure:
invalid_selection_attempts = 0
error_context = ""
original_agent = self.tasks[selected_task_id].agent.name if self.tasks[selected_task_id].agent else "None"
original_agent = selected_task.agent.name if selected_task.agent else "None"
for a in self.agents:
if a.name == selected_agent_name:
self.tasks[selected_task_id].agent = a
selected_task.agent = a
logging.info(f"Changed agent for task {selected_task_id} from {original_agent} to {selected_agent_name}")
break
@@ -1558,11 +1561,6 @@ Workflow Finished: {self.workflow_finished} # ADDED: Workflow Finished Status
reflection=False
)
class ManagerInstructions(BaseModel):
task_id: int
agent_name: str
action: str
manager_task = Task(
name="manager_task",
description="Decide the order of tasks and which agent executes them",
@@ -1584,7 +1582,7 @@ Workflow Finished: {self.workflow_finished} # ADDED: Workflow Finished Status
while completed_count < total_tasks:
tasks_summary = []
for tid, tk in self.tasks.items():
if tk.name == "manager_task":
if tid == manager_task_id:
continue
task_info = {
"task_id": tid,
@@ -1629,16 +1627,22 @@ Provide a JSON with the structure:
logging.info("Manager decided to stop task execution")
break
if selected_task_id not in self.tasks:
# Re-prompt the manager with valid task IDs instead of terminating
selected_task = self.tasks.get(selected_task_id)
if selected_task is None or selected_task_id == manager_task_id:
# Reject unknown ids and the synthetic manager_task by its actual id
# (it must never delegate to itself). Matching by id avoids colliding
# with a legitimate user task that happens to be named "manager_task".
# Re-prompt with the delegable IDs only.
invalid_selection_attempts += 1
if invalid_selection_attempts > MAX_INVALID_SELECTIONS:
logging.error(
f"Manager produced {invalid_selection_attempts} invalid task selections; aborting."
)
break
valid_task_ids = list(self.tasks.keys())
valid_task_ids = [
tid for tid in self.tasks if tid != manager_task_id
]
logging.warning(
f"Manager selected invalid task_id={selected_task_id} "
f"(attempt {invalid_selection_attempts}/{MAX_INVALID_SELECTIONS}); valid IDs: {valid_task_ids}"
@@ -1646,7 +1650,8 @@ Provide a JSON with the structure:
# Set error context for next iteration (instead of appending to prompt that gets rebuilt)
error_context = (
f"\n\n[ERROR] Your previous selection of task_id={selected_task_id} was invalid. "
f"Valid task IDs are: {valid_task_ids}. Please select again from the valid options."
f"Valid task IDs are: {valid_task_ids}. Never select manager_task. "
f"Please select again from the valid options."
)
continue # Re-prompt the manager instead of breaking
@@ -1654,10 +1659,10 @@ Provide a JSON with the structure:
invalid_selection_attempts = 0
error_context = ""
original_agent = self.tasks[selected_task_id].agent.name if self.tasks[selected_task_id].agent else "None"
original_agent = selected_task.agent.name if selected_task.agent else "None"
for a in self.agents:
if a.name == selected_agent_name:
self.tasks[selected_task_id].agent = a
selected_task.agent = a
logging.info(f"Changed agent for task {selected_task_id} from {original_agent} to {selected_agent_name}")
break
@@ -10,54 +10,40 @@ No heavy imports - only stdlib and typing.
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
from ..context.budgeter import MODEL_LIMITS as _CANONICAL_LIMITS
# Model context window mapping (tokens)
# Updated with latest model information
MODEL_CONTEXT_WINDOWS: Dict[str, int] = {
# RAG-specific context windows not present in the canonical budgeter table
# (extra Anthropic/Google aliases, Mistral, Llama, DeepSeek, Cohere, etc.).
# Exact values for models shared with the canonical table are sourced from
# context/budgeter.MODEL_LIMITS below, so a single source of truth is kept.
_RAG_EXTRA_CONTEXT_WINDOWS: Dict[str, int] = {
# OpenAI models
"gpt-4": 8192,
"gpt-4-32k": 32768,
"gpt-4-turbo": 128000,
"gpt-4-turbo-preview": 128000,
"gpt-4o": 128000,
"gpt-4o-mini": 128000,
"gpt-4.1": 1000000,
"gpt-4.1-mini": 1000000,
"gpt-4.1-nano": 1000000,
"gpt-3.5-turbo": 16385,
"gpt-3.5-turbo-16k": 16385,
"o1": 200000,
"o1-mini": 128000,
"o1-preview": 128000,
"o3": 200000,
"o3-mini": 200000,
"o4-mini": 200000,
# Anthropic models
"claude-3-opus": 200000,
"claude-3-sonnet": 200000,
"claude-3-haiku": 200000,
"claude-3.5-sonnet": 200000,
"claude-3.5-haiku": 200000,
"claude-3.7-sonnet": 200000,
"claude-4-sonnet": 200000,
"claude-sonnet-4": 200000,
# Google models
"gemini-pro": 32768,
"gemini-1.0-pro": 32768,
"gemini-1.5-pro": 1000000,
"gemini-1.5-flash": 1000000,
"gemini-2.0-flash": 1000000,
"gemini-2.5-pro": 1000000,
"gemini-2.5-flash": 1000000,
# Mistral models
"mistral-large": 128000,
"mistral-medium": 32768,
"mistral-small": 32768,
"mixtral-8x7b": 32768,
# Llama models
"llama-3.1-405b": 128000,
"llama-3.1-70b": 128000,
@@ -67,17 +53,39 @@ MODEL_CONTEXT_WINDOWS: Dict[str, int] = {
"llama-3.3-70b": 128000,
"llama-4-maverick": 1000000,
"llama-4-scout": 10000000,
# DeepSeek models
"deepseek-chat": 64000,
"deepseek-coder": 64000,
"deepseek-r1": 64000,
# Cohere models
"command-r": 128000,
"command-r-plus": 128000,
}
# Guard the invariant that RAG-only extras never silently shadow canonical
# entries. If a key is later added to the canonical budgeter table, it should be
# removed from _RAG_EXTRA_CONTEXT_WINDOWS so the single source of truth stays
# authoritative (this is exactly the drift that #3567 fixed). Mirrors the same
# assertion in eval/tokens.py.
_overlapping_keys = set(_RAG_EXTRA_CONTEXT_WINDOWS) & {
k for k in _CANONICAL_LIMITS if k != "default"
}
assert not _overlapping_keys, (
"_RAG_EXTRA_CONTEXT_WINDOWS keys overlap with canonical MODEL_LIMITS; remove "
f"the duplicate(s) from the RAG table: {sorted(_overlapping_keys)}"
)
# Single source of truth for exact context-window values: canonical budgeter
# table takes precedence, with RAG-specific extras layered underneath. This
# mirrors how eval/tokens.py merges eval-specific extras onto the canonical
# table and removes the previously drifted duplicate copy.
MODEL_CONTEXT_WINDOWS: Dict[str, int] = {
**_RAG_EXTRA_CONTEXT_WINDOWS,
**{k: v for k, v in _CANONICAL_LIMITS.items() if k != "default"},
}
# Default fallback for unknown models
DEFAULT_CONTEXT_WINDOW = 8192
@@ -177,7 +177,12 @@ def get_tool_output_store(run_id: Optional[str] = None) -> ToolOutputStore:
"""
global _store_instance
with _store_lock:
if _store_instance is None or (run_id and run_id != _store_instance.run_id):
if run_id is None:
# No run context available - don't silently reuse/adopt whichever
# store happens to be live process-wide; scope to a fresh instance
# so unrelated runs never share the first run's store directory.
return ToolOutputStore(None)
if _store_instance is None or run_id != _store_instance.run_id:
_store_instance = ToolOutputStore(run_id)
return _store_instance
@@ -67,18 +67,43 @@ class ConfigYamlScheduleStore:
with self._lock:
return self._jobs.get(job_id)
def get_by_name(self, name: str) -> Optional[ScheduleJob]:
def get_by_name(
self,
name: str,
principal: Optional[str] = None,
) -> Optional[ScheduleJob]:
"""Return a job by name, optionally scoped to ``principal``.
When ``principal`` is given, a job owned by a *different* identity is
treated as not found (returns ``None``) so one gateway user cannot
read another's automation by guessing its name. ``None`` (the default)
preserves the pre-scoping global lookup.
"""
with self._lock:
for job in self._jobs.values():
if job.name == name:
if principal is not None and job.principal != principal:
continue
return job
return None
def list(self, agent_id: Optional[str] = None) -> List[ScheduleJob]:
def list(
self,
agent_id: Optional[str] = None,
principal: Optional[str] = None,
) -> List[ScheduleJob]:
"""List jobs, optionally filtered by owning agent and/or principal.
``principal`` isolates a gateway end-user's automations from
another's. ``None`` (the default) returns everything, preserving
the pre-scoping global behaviour.
"""
with self._lock:
jobs = list(self._jobs.values())
if agent_id is not None:
jobs = [j for j in jobs if j.agent_id == agent_id]
if principal is not None:
jobs = [j for j in jobs if j.principal == principal]
return jobs
def update(self, job: ScheduleJob) -> None:
@@ -104,11 +129,20 @@ class ConfigYamlScheduleStore:
return True
return False
def remove_by_name(self, name: str) -> bool:
def remove_by_name(self, name: str, principal: Optional[str] = None) -> bool:
"""Remove a job by name, optionally scoped to ``principal``.
When ``principal`` is given, a job owned by a *different* identity is
skipped (not removed) so one gateway user cannot delete another's
automation by guessing its name. ``None`` (the default) preserves the
pre-scoping global removal.
"""
with self._lock, self._file_lock():
self._reload_locked()
for jid, job in list(self._jobs.items()):
if job.name == name:
if principal is not None and job.principal != principal:
continue
del self._jobs[jid]
self._save()
return True
@@ -17,6 +17,19 @@ from praisonaiagents._logging import get_logger
logger = get_logger(__name__)
def next_fire_time(cron_expr: str, base: float) -> float:
"""Return the next cron fire time (epoch) strictly after ``base``.
Pure helper owning the "compute next fire from a base" step so wrapper
tickers can share core's cron timing rather than re-implementing it.
Requires the optional ``croniter`` engine; callers guard its import and
the ``(ValueError, KeyError, TypeError)`` raised by malformed expressions.
"""
from croniter import croniter # type: ignore[import-untyped]
return croniter(cron_expr, base).get_next(float)
def is_due(job: Any, now: float) -> bool:
"""Return ``True`` if ``job`` is due to run at epoch ``now``.
@@ -63,8 +76,7 @@ def is_due(job: Any, now: float) -> bool:
return False
base_time = job.last_run_at or job.created_at
try:
cron = croniter(sched.cron_expr, base_time)
next_run = cron.get_next(float)
next_run = next_fire_time(sched.cron_expr, base_time)
except (ValueError, KeyError, TypeError) as e:
# A malformed cron expression must not abort the whole tick loop
# (which iterates all jobs) — treat this job as not-due instead.
@@ -156,6 +156,39 @@ class DeliveryTarget:
# Bare platform name → resolve to its home channel via the router.
return cls(channel=token, deliver=token)
def preview(self, *, session_target: str = "") -> str:
"""Return a human-readable, dry-run preview of where this will deliver.
A pure, dependency-free description of the resolved destination so the
creator — user, agent, or a blueprint accept — sees "where will this
go?" the moment a scheduled / agent-initiated send is created, not only
at fire time. Symbolic tokens (``origin`` / ``all``) are surfaced as
such; concrete targets render as ``platform:channel_id[:thread_id]``.
Args:
session_target: Optional ``"main"`` / ``"isolated"`` session hint to
append (e.g. ``" (session main)"``), when known by the caller.
Returns:
A short, display-only string such as ``"telegram:@alice"`` or
``"telegram:123:789 (session main)"``.
"""
token = (self.deliver or "").strip()
symbolic = token.lower()
if symbolic in ("origin", "all"):
base = symbolic
elif self.channel:
base = self.channel
if self.channel_id:
base = f"{base}:{self.channel_id}"
if self.thread_id:
base = f"{base}:{self.thread_id}"
else:
base = token or "<unrouted>"
if session_target:
base = f"{base} (session {session_target})"
return base
@dataclass
class RunRecord:
@@ -239,6 +272,43 @@ class ScheduleJob:
and surfaced for readability but is NOT itself enforced; the
default :class:`ShellConditionGate` gates on ``pre_run``. A
custom ``condition_resolver`` may interpret it.
principal: Optional resolved canonical identity of the *end-user* who
owns this job (from the gateway's identity resolver).
Distinct from ``agent_id`` (the owning agent) and ``origin``
(where it was created): ``principal`` is the access-control
key used by ``store.list(principal=...)`` to isolate one
gateway user's automations from another's. ``None`` means
global / single-tenant — preserving pre-scoping behaviour.
command: Optional shell command whose stdout is delivered verbatim.
When set, the job runs this command on its schedule and
delivers the output as-is to ``delivery`` — with NO agent
resolved and NO model turn taken. This is a first-class
model-free execution *action* (a cheap, deterministic
watchdog: ``df -h``, ``uptime``, a health-check ``curl``),
distinct from ``pre_run`` which is only a go/no-go *gate*
feeding the model turn. Additive and backward-compatible:
jobs without a ``command`` keep the existing agent path.
command_timeout: Maximum seconds the ``command`` may run before it is
killed (with its process group on POSIX) and the tick is
recorded as ``failed``. Bounds the action so a hung command
cannot stall the ticker. Defaults to 60s.
provider: Optional model *provider* snapshotted when the job was
created (e.g. ``"openai"``). Advisory metadata paired with
``model`` so a drift check can report both. ``None`` means no
snapshot was taken — the job keeps today's follow-the-default
behaviour and no drift is enforced.
model: Optional model identifier snapshotted when the job was created
(e.g. ``"gpt-4o-mini"``). Because unattended runs fire with no
human present, a job created against a cheap/local default
must not silently inherit a later, pricier default. When set
and ``pin_model`` is ``True`` the wrapper executor pins the run
to this model and *fails closed* if the resolved agent has
drifted. ``None`` preserves the pre-snapshot behaviour, so
existing jobs are fully backward-compatible.
pin_model: When ``True`` (default) a ``model`` snapshot is enforced —
the run is pinned and drift fails closed. Set ``False`` to opt
into following whatever the default becomes. Only meaningful
when ``model`` is set; a job with no snapshot never enforces.
"""
name: str = ""
@@ -255,6 +325,12 @@ class ScheduleJob:
origin: Optional[DeliveryTarget] = None
pre_run: Optional[str] = None
condition: Optional[str] = None
principal: Optional[str] = None
command: Optional[str] = None
command_timeout: float = 60.0
provider: Optional[str] = None
model: Optional[str] = None
pin_model: bool = True
# ── serialisation ────────────────────────────────────────────────
@@ -279,6 +355,23 @@ class ScheduleJob:
d["pre_run"] = self.pre_run
if self.condition is not None:
d["condition"] = self.condition
if self.principal is not None:
d["principal"] = self.principal
if self.command is not None:
d["command"] = self.command
# Only persist the timeout when a command is configured and it
# differs from the default, keeping agent-only jobs unchanged.
if self.command_timeout != 60.0:
d["command_timeout"] = self.command_timeout
# Model pin snapshot. Only persist when a snapshot exists so agent-only
# jobs stay byte-for-byte unchanged; ``pin_model`` is likewise persisted
# only when opting out of the default (True), keeping the payload minimal.
if self.provider is not None:
d["provider"] = self.provider
if self.model is not None:
d["model"] = self.model
if not self.pin_model:
d["pin_model"] = False
# Atomic-claim lease metadata (set dynamically by stores that support
# ``claim_due``). Persisted so a lease is visible across processes and
# survives a restart; omitted when no lease is held.
@@ -308,6 +401,12 @@ class ScheduleJob:
origin=DeliveryTarget.from_dict(origin_data) if isinstance(origin_data, dict) else None,
pre_run=d.get("pre_run"),
condition=d.get("condition"),
principal=d.get("principal"),
command=d.get("command"),
command_timeout=d.get("command_timeout", 60.0),
provider=d.get("provider"),
model=d.get("model"),
pin_model=d.get("pin_model", True),
)
# Restore atomic-claim lease metadata if present (see ``to_dict``).
job._lease_until = d.get("lease_until", 0.0) or 0.0
@@ -116,8 +116,17 @@ class ScheduleStoreProtocol(Protocol):
"""Get a job by its unique ID."""
...
def list(self, agent_id: Optional[str] = None) -> List[Any]:
"""List all jobs, optionally filtered by agent_id."""
def list(
self,
agent_id: Optional[str] = None,
principal: Optional[str] = None,
) -> List[Any]:
"""List all jobs, optionally filtered by agent_id and/or principal.
``principal`` is the resolved end-user identity used to isolate one
gateway user's automations from another's. ``None`` returns
everything (global / single-tenant behaviour).
"""
...
def update(self, job: Any) -> None:
@@ -128,12 +137,26 @@ class ScheduleStoreProtocol(Protocol):
"""Remove a job by ID. Returns True if found and removed."""
...
def get_by_name(self, name: str) -> Optional[Any]:
"""Get a job by its human-readable name."""
def get_by_name(
self,
name: str,
principal: Optional[str] = None,
) -> Optional[Any]:
"""Get a job by its human-readable name.
``principal`` scopes the lookup to the resolved end-user identity: a
job owned by a different principal is treated as not found. ``None``
returns any match (global / single-tenant behaviour).
"""
...
def remove_by_name(self, name: str) -> bool:
"""Remove a job by name. Returns True if found and removed."""
def remove_by_name(self, name: str, principal: Optional[str] = None) -> bool:
"""Remove a job by name. Returns True if found and removed.
``principal`` scopes the removal to the resolved end-user identity so
one gateway user cannot delete another's automation by name. ``None``
removes any match (global / single-tenant behaviour).
"""
...
# ── Atomic claim / lease (optional) ──────────────────────────────
@@ -65,18 +65,43 @@ class FileScheduleStore:
with self._lock:
return self._jobs.get(job_id)
def get_by_name(self, name: str) -> Optional[ScheduleJob]:
def get_by_name(
self,
name: str,
principal: Optional[str] = None,
) -> Optional[ScheduleJob]:
"""Return a job by name, optionally scoped to ``principal``.
When ``principal`` is given, a job owned by a *different* identity is
treated as not found (returns ``None``) so one gateway user cannot
read another's automation by guessing its name. ``None`` (the default)
preserves the pre-scoping global lookup.
"""
with self._lock:
for job in self._jobs.values():
if job.name == name:
if principal is not None and job.principal != principal:
continue
return job
return None
def list(self, agent_id: Optional[str] = None) -> List[ScheduleJob]:
def list(
self,
agent_id: Optional[str] = None,
principal: Optional[str] = None,
) -> List[ScheduleJob]:
"""List jobs, optionally filtered by owning agent and/or principal.
``principal`` isolates a gateway end-user's automations from
another's. ``None`` (the default) returns everything, preserving
the pre-scoping global behaviour.
"""
with self._lock:
jobs = list(self._jobs.values())
if agent_id is not None:
jobs = [j for j in jobs if j.agent_id == agent_id]
if principal is not None:
jobs = [j for j in jobs if j.principal == principal]
return jobs
def update(self, job: ScheduleJob) -> None:
@@ -102,11 +127,20 @@ class FileScheduleStore:
return True
return False
def remove_by_name(self, name: str) -> bool:
def remove_by_name(self, name: str, principal: Optional[str] = None) -> bool:
"""Remove a job by name, optionally scoped to ``principal``.
When ``principal`` is given, a job owned by a *different* identity is
skipped (not removed) so one gateway user cannot delete another's
automation by guessing its name. ``None`` (the default) preserves the
pre-scoping global removal.
"""
with self._lock, self._file_lock():
self._reload_locked()
for jid, job in list(self._jobs.items()):
if job.name == name:
if principal is not None and job.principal != principal:
continue
del self._jobs[jid]
self._save()
return True
@@ -91,6 +91,11 @@ class Suggestion:
accepted: bool = False
"""True if the user accepted this suggestion (job has been created)."""
principal: Optional[str] = None
"""Resolved canonical identity of the owner (from the gateway's
identity resolver). ``None`` means the suggestion is global /
single-tenant — preserving pre-scoping behaviour."""
# ── Suggestion store ─────────────────────────────────────────────────────────
@@ -150,19 +155,30 @@ class SuggestionStore:
def add(self, suggestion: Suggestion) -> bool:
"""Add a suggestion to the store.
The pending cap and dedup window are evaluated *within the
suggestion's ``principal``* so one gateway user's pending queue
cannot exhaust the cap or collide with another's. A suggestion with
``principal=None`` is measured against the global pool (pre-scoping
single-tenant behaviour).
Returns:
``True`` if the suggestion was added, ``False`` if it was
rejected due to the pending cap or dedup window.
rejected due to the (per-principal) pending cap or dedup window.
"""
with self._lock:
now = time.time()
# Only count active (non-dismissed, non-accepted, non-expired)
# suggestions toward the cap and dedup window.
# suggestions toward the cap and dedup window. Scope both to the
# incoming suggestion's owner so one tenant's pending queue cannot
# exhaust the cap (or its dedup window collide) for another —
# ``principal is None`` keeps the pre-scoping global pool.
owner = suggestion.principal
active = [
s for s in self._suggestions.values()
if not s.dismissed and not s.accepted
and (s.expires_at == 0 or s.expires_at > now)
and (owner is None or s.principal == owner)
]
if len(active) >= MAX_PENDING_CAP:
return False
@@ -185,40 +201,67 @@ class SuggestionStore:
with self._lock:
return self._suggestions.get(suggestion_id)
def list_pending(self) -> List[Suggestion]:
"""Return all undismissed, unaccepted, non-expired suggestions."""
def list_pending(self, principal: Optional[str] = None) -> List[Suggestion]:
"""Return all undismissed, unaccepted, non-expired suggestions.
Args:
principal: When provided, only suggestions owned by this
resolved identity are returned (multi-user isolation).
``None`` returns everything — the pre-scoping global
behaviour used by single-tenant deployments.
"""
now = time.time()
with self._lock:
return [
s for s in self._suggestions.values()
if not s.dismissed and not s.accepted
and (s.expires_at == 0 or s.expires_at > now)
and (principal is None or s.principal == principal)
]
def accept(self, suggestion_id: str) -> bool:
def accept(self, suggestion_id: str, principal: Optional[str] = None) -> bool:
"""Mark a suggestion as accepted.
Args:
suggestion_id: The suggestion to accept.
principal: When provided, the call is refused (returns
``False``) unless the suggestion is owned by this
identity — preventing one user from accepting another's.
``None`` skips the ownership check (global behaviour).
Returns:
``False`` if the suggestion was not found.
``False`` if the suggestion was not found or is owned by a
different principal.
"""
with self._lock:
s = self._suggestions.get(suggestion_id)
if s is None:
return False
if principal is not None and s.principal != principal:
return False
s.accepted = True
self._save()
return True
def dismiss(self, suggestion_id: str) -> bool:
def dismiss(self, suggestion_id: str, principal: Optional[str] = None) -> bool:
"""Mark a suggestion as dismissed (user declined).
Args:
suggestion_id: The suggestion to dismiss.
principal: When provided, the call is refused (returns
``False``) unless the suggestion is owned by this
identity. ``None`` skips the ownership check.
Returns:
``False`` if the suggestion was not found.
``False`` if the suggestion was not found or is owned by a
different principal.
"""
with self._lock:
s = self._suggestions.get(suggestion_id)
if s is None:
return False
if principal is not None and s.principal != principal:
return False
s.dismissed = True
self._save()
return True
@@ -31,7 +31,7 @@ if TYPE_CHECKING:
from .store import DefaultSessionStore, SessionMessage, SessionData, CompactionCheckpoint
from .sqlite_store import SqliteSessionStore
from .sqlite_transcript_store import SqliteTranscriptStore
from .protocols import SessionStoreProtocol
from .protocols import SessionStoreProtocol, SessionMirrorProtocol
from .hierarchy import HierarchicalSessionStore, SessionSnapshot, ExtendedSessionData
# Lazy loading for zero import overhead
@@ -93,6 +93,11 @@ def __getattr__(name: str):
_module_cache[name] = SearchableSessionStoreProtocol
return SearchableSessionStoreProtocol
if name == "SessionMirrorProtocol":
from .protocols import SessionMirrorProtocol
_module_cache[name] = SessionMirrorProtocol
return SessionMirrorProtocol
if name == "SessionHit":
from .protocols import SessionHit
_module_cache[name] = SessionHit
@@ -179,6 +184,7 @@ __all__ = [
"get_default_session_store",
"SessionStoreProtocol",
"SearchableSessionStoreProtocol",
"SessionMirrorProtocol",
"SessionHit",
"SessionSummary",
"HierarchicalSessionStore",
@@ -37,7 +37,12 @@ from dataclasses import asdict, dataclass
from typing import TYPE_CHECKING, Dict, List, Optional, Any
if TYPE_CHECKING:
from ..gateway.protocols import OutboundMessengerProtocol, SendPolicyProtocol
from ..gateway.protocols import (
ConversationRequestProtocol,
OutboundMessengerProtocol,
SendPolicyProtocol,
GatewayStatusProtocol,
)
def neutralize_untrusted_text(value: object, *, max_chars: int = 240) -> str:
@@ -234,6 +239,40 @@ def clear_outbound_messenger(token: Token) -> None:
_MESSENGER.set(None)
# ---------------------------------------------------------------------------
# Cross-conversation request/reply requester (Issue #3689)
#
# The running gateway/bot registers a concrete ConversationRequestProtocol impl
# into this task-local slot so the built-in ``ask_conversation`` tool can ask
# another conversation something and await the correlated reply mid-turn. When
# unbound the tool reports that no gateway is available (gating, not a hang).
# ---------------------------------------------------------------------------
_CONVERSATION_REQUESTER: ContextVar[Optional["ConversationRequestProtocol"]] = ContextVar(
"praisonai_conversation_requester", default=None
)
def register_conversation_requester(
requester: Optional["ConversationRequestProtocol"],
) -> Token:
"""Register the active conversation requester for this task. Returns a token."""
return _CONVERSATION_REQUESTER.set(requester)
def get_conversation_requester() -> Optional["ConversationRequestProtocol"]:
"""Return the active conversation requester, or ``None`` if no gateway is running."""
return _CONVERSATION_REQUESTER.get()
def clear_conversation_requester(token: Token) -> None:
"""Restore the previous conversation requester using the token from register."""
try:
_CONVERSATION_REQUESTER.reset(token)
except (LookupError, ValueError):
_CONVERSATION_REQUESTER.set(None)
# ---------------------------------------------------------------------------
# Outbound send-policy guard (Issue #2226)
#
@@ -268,6 +307,41 @@ def clear_send_policy(token: Token) -> None:
_SEND_POLICY.set(None)
# ---------------------------------------------------------------------------
# Gateway live status/health source (Issue #3688)
#
# The running gateway/bot registers a concrete GatewayStatusProtocol impl into
# this task-local slot so the built-in ``gateway_status`` tool can report the
# gateway's live self-state (run status, active sessions, delivery/DLQ backlog,
# degraded owners). When nothing is registered (CLI / one-shot runs), the tool
# is simply not offered — a clean gate, never a dead-end failure.
# ---------------------------------------------------------------------------
_GATEWAY_STATUS: ContextVar[Optional["GatewayStatusProtocol"]] = ContextVar(
"praisonai_gateway_status", default=None
)
def register_gateway_status(
source: Optional["GatewayStatusProtocol"],
) -> Token:
"""Register the active gateway status source for this task. Returns a token."""
return _GATEWAY_STATUS.set(source)
def get_gateway_status() -> Optional["GatewayStatusProtocol"]:
"""Return the active gateway status source, or ``None`` if no gateway is running."""
return _GATEWAY_STATUS.get()
def clear_gateway_status(token: Token) -> None:
"""Restore the previous gateway status source using the token from register."""
try:
_GATEWAY_STATUS.reset(token)
except (LookupError, ValueError):
_GATEWAY_STATUS.set(None)
# ---------------------------------------------------------------------------
# Gateway event loop registry (Issue #2183)
#
@@ -309,9 +383,15 @@ __all__ = [
"register_outbound_messenger",
"get_outbound_messenger",
"clear_outbound_messenger",
"register_conversation_requester",
"get_conversation_requester",
"clear_conversation_requester",
"register_send_policy",
"get_send_policy",
"clear_send_policy",
"register_gateway_status",
"get_gateway_status",
"clear_gateway_status",
"register_gateway_loop",
"get_gateway_loop",
"clear_gateway_loop",
@@ -159,7 +159,9 @@ class HierarchicalSessionStore(DefaultSessionStore):
contract so the write-abort protection is honoured here too:
* File does not exist → fresh empty session.
* Malformed JSON → treat as corrupted; start fresh (logged).
* Malformed JSON → quarantine the corrupt file aside and surface the
event before starting fresh, so its raw bytes are not silently
overwritten by the next write (Issue #3715).
* Transient ``OSError`` on an existing file → re-raise so the write
paths (``_modify_session_locked``) abort instead of overwriting real
history with an empty session. Previously this override swallowed
@@ -172,10 +174,19 @@ class HierarchicalSessionStore(DefaultSessionStore):
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
return ExtendedSessionData.from_dict(data)
except json.JSONDecodeError as e:
except (json.JSONDecodeError, UnicodeDecodeError) as e:
# Invalid UTF-8 (``UnicodeDecodeError``) is handled alongside
# malformed JSON so a corrupt binary file is quarantined here too
# rather than propagating and matching the base-store contract.
quarantine_path = self._quarantine_corrupt(filepath)
logger.error(
f"Session file {filepath} contains invalid JSON; starting fresh: {e}"
"Session file %s contains invalid JSON; quarantined to %s and "
"starting fresh: %s",
filepath,
quarantine_path or "<quarantine failed>",
e,
)
self._fire_corruption_hook(session_id, str(e), quarantine_path)
return ExtendedSessionData(session_id=session_id)
except OSError as e:
logger.error(
@@ -325,6 +325,75 @@ class RuntimeStateMirroringProtocol(Protocol):
...
@runtime_checkable
class SessionMirrorProtocol(Protocol):
"""Protocol for mirroring session transcripts to a remote backend (Issue #3646).
A mirror is a *pluggable, local-first* sink: the session store keeps
writing to local disk exactly as today, and — when a mirror is configured
— new append-only records are also handed to the mirror so a session can
be continued on another machine. The contract is deliberately tiny:
- :meth:`append` receives the records produced by a turn (each an already
timestamped, id-tagged dict, so mirroring is conflict-free by
construction — last-writer per record id, no merge logic);
- :meth:`load` returns all mirrored records for a session so a
non-local id can be hydrated on resume;
- :meth:`list_sessions` (optional) enumerates mirrored sessions for a
``session list --remote`` surface.
The mirror must be safe to call off the local write path: an implementation
is expected to be tolerant of transient outages, since the caller queues
records and never lets a mirror failure block or corrupt the local session.
Wrapper backends (postgres/supabase/turso/sqlite, …) adapt the existing
``praisonai.persistence.conversation`` stores onto this protocol; core
ships only the contract (zero new dependencies).
Example::
store: SessionMirrorProtocol = MyRemoteMirror()
store.append("s_abc", [{"id": "m1", "role": "user", "content": "hi",
"timestamp": 1.0}])
records = store.load("s_abc") # full transcript, incl. tool calls
"""
def append(self, session_id: str, records: List[Dict[str, Any]]) -> None:
"""Append append-only transcript records for a session.
Args:
session_id: The session these records belong to.
records: New records to mirror. Each is a JSON-serialisable dict
carrying at least an ``id`` and ``timestamp`` so re-appends are
idempotent (last-writer per record id).
"""
...
def load(self, session_id: str) -> List[Dict[str, Any]]:
"""Load all mirrored records for a session.
Args:
session_id: The session to hydrate.
Returns:
The full ordered list of mirrored records (empty if unknown).
"""
...
def list_sessions(
self, *, user_id: Optional[str] = None
) -> List[Dict[str, Any]]:
"""Enumerate mirrored sessions (optional).
Args:
user_id: Optional filter to a single user's sessions.
Returns:
List of session metadata dicts (at minimum ``session_id``).
"""
...
@runtime_checkable
class CheckpointQueryProtocol(Protocol):
"""Read path for session checkpoints / rollback snapshots."""
Loaded 100 of 531 files, more files were not shown because too many files have changed in this diff. Show more