Compare commits

...
Author SHA1 Message Date
praisonai-triage-agent[bot]andMervin Praison ac4eafc3a0 fix: detect single-backslash, home-relative and bare relative Windows paths
Addresses Gemini and Greptile review feedback: is_path_like now recognizes
\path, ~\path and relative paths containing backslashes (e.g. knowledge\docs)
so equivalent Windows configurations resolve as knowledge sources.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-27 02:07:57 +00:00
codingFeng101 efc07126ae Detect Windows knowledge paths 2026-06-27 09:54:36 +08:00
Mervin Praison 911db9d701 Merge pull request #2343 from MervinPraison/fix/pipeline-status-labels-merge-gate-dispatch
fix: pipeline status labels and reliable merge-gate dispatch
2026-06-26 23:48:04 +01:00
MervinPraison 91af284ccd docs: auto-update documentation parity trackers [skip ci] 2026-06-26 22:47:55 +00:00
Mervin Praison 3ab13e9f70 Merge pull request #2347 from MervinPraison/claude/issue-2344-20260626-1711
fix: remove unreferenced private methods in agent/ mixins
2026-06-26 23:47:24 +01:00
Mervin Praison acce8aab79 Merge pull request #2346 from MervinPraison/claude/issue-2345-20260626-1711
refactor: delegate _apply_tool_truncation_async to sync impl
2026-06-26 23:47:02 +01:00
Mervin Praison d75cf6b73b Merge pull request #2341 from MervinPraison/claude/issue-2340-20260626-1610
fix: remove dead deprecated _process_interactive_prompt_async
2026-06-26 23:46:33 +01:00
Mervin Praison b340b8d07d Merge pull request #2338 from MervinPraison/claude/issue-90-20260626-1428
fix: clear provider guidance on praisonai --init when no LLM key set
2026-06-26 23:46:13 +01:00
MervinPraison ec51edf33c docs: auto-update feature parity trackers [skip ci] 2026-06-26 22:45:59 +00:00
Mervin Praison 3aecf1ad54 Merge pull request #2335 from MervinPraison/claude/issue-1939-20260613-0842
feat: auto runtime selection with plugin harness registry
2026-06-26 23:45:34 +01:00
MervinPraison 63fe1366d2 docs: auto-update api.md [skip ci] 2026-06-26 20:34:46 +00:00
Mervin Praison c79e0f71ae Merge pull request #2342 from MervinPraison/claude/issue-2339-20260626-1610
fix: consolidate duplicate AutoGen config_list builder into shared helper
2026-06-26 21:34:28 +01:00
Mervin Praison 1891dade5d Merge pull request #2337 from MervinPraison/claude/issue-1341-20260409-1606
fix: add comment to shell_tools execute_command
2026-06-26 21:34:27 +01:00
Mervin Praison 7efa8fd458 Merge pull request #2336 from MervinPraison/claude/issue-722-20250705_065525
feat: MCP HTTP-Streaming support
2026-06-26 21:34:25 +01:00
praisonai-triage-agent[bot]andMervin Praison c23b733877 fix: preserve [tool-truncation-async] log tag via log_tag param
Addresses Greptile P2: async truncation was emitting the sync
[tool-truncation] log tag after delegation. Add an optional
log_tag param to the sync helper so the async wrapper can pass
"tool-truncation-async", restoring the diagnostic distinction
without re-introducing sync/async drift.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 17:26:45 +00:00
praisonai-triage-agent[bot]andMervin Praison 2699326ec6 fix: remove unreferenced private methods in agent/ mixins (fixes #2344)
Remove four dead private methods with zero callers across praisonaiagents/
and tests/:
- execution_mixin.py: _safe_sleep_async (sync _safe_sleep is the live path)
- unified_execution_mixin.py: _resolve_cli_backend_at_turn_time (placeholder)
- chat_mixin.py: _finalize_unified_achat_response (never invoked)
- chat_mixin.py: _process_stream_response (only an unrelated OpenAIClient test)

Pure dead-code cleanup; no behaviour or public API change.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 17:16:32 +00:00
praisonai-triage-agent[bot]andMervin Praison d1e26a1c1a fix: address reviewer feedback in merge-gate and pipeline-status scripts
- Reorder resolvePrNumberFromWorkflowRun to check linked PRs before head
  branch (Gemini): more accurate and avoids a pulls.list call
- Drop unreachable base.repo.name fallback in isInternalPullRequestLink
  (CodeRabbit): bare repo name can never match owner/repo
- Paginate labels and tolerate 422 in ensurePipelineLabels (Gemini/CodeRabbit)
- Skip full pagination when maxPrs <= 100 in syncOpenPullRequests (Gemini)

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 17:16:02 +00:00
praisonai-triage-agent[bot]andMervin Praison 4416a2afc7 refactor: delegate _apply_tool_truncation_async to sync impl (fixes #2345)
The async tool-truncation method was a byte-identical copy of the sync
version with no await points. Make it a thin delegate to eliminate ~28
duplicated lines and remove sync/async drift risk.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 17:13:18 +00:00
MervinPraisonandCursor 45b2052afb fix: pipeline status labels and reliable merge-gate dispatch
Filter fork sync PRs in resolvePrNumberFromWorkflowRun, dispatch merge gate
explicitly from claude.yml after PR runs, and sync pipeline/* stage/blocker
labels via auto-pr-comment, merge-gate scan, and 15-min sync workflow.

Co-authored-by: Cursor <[email protected]>
2026-06-26 18:03:33 +01:00
Mervin Praison 078bb69ad4 Merge pull request #2334 from MervinPraison/claude/issue-2251-20260626
fix: reliably kick review chain when bot PRs miss pull_request opened
2026-06-26 17:56:26 +01:00
praisonai-triage-agent[bot]andMervinPraison 5b84a84972 fix: consolidate duplicate AutoGen config_list builder into shared helper (fixes #2339)
Co-authored-by: MervinPraison <[email protected]>
2026-06-26 16:12:45 +00:00
praisonai-triage-agent[bot]andMervinPraison fb713566af fix: remove dead deprecated _process_interactive_prompt_async (fixes #2340)
Co-authored-by: MervinPraison <[email protected]>
2026-06-26 16:11:32 +00:00
praisonai-triage-agent[bot]andMervin Praison 85fa5719fd fix: align --init provider preflight with runtime credential/model resolution
Address Greptile P1 findings: the preflight gate could disagree with what
AutoGenerator actually does at runtime, so a user could pass the guard and
still hit a raw provider auth error.

- Inject stored credentials into env before gating so keys saved via
  `praisonai setup` reach the env-only AutoGenerator resolver.
- Gate on the exact runtime model (MODEL_NAME / OPENAI_MODEL_NAME) so a stale
  OpenAI override with only a non-OpenAI key is caught with guidance instead
  of failing later.
- Add regression tests: stored-credential injection, runtime model override
  gating, and the real PraisonAI.main() --init early-return path asserting the
  generator is never constructed.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 14:53:30 +00:00
claude[bot]andMervin Praison df99d777a3 fix: Address code review issues with minimal changes
- Implement functional HTTP-Streaming transport in Python and TypeScript
- Fix ClientSession initialization with proper read/write parameters
- Fix event loop handling and remove unused imports
- Improve error handling and initialization synchronization
- Both transports now use message queues to prevent runtime errors

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 14:40:42 +00:00
claude[bot]andMervin Praison d85fb91ec3 fix: Address code review issues with minimal changes
- Remove unused imports (json, List, get_session_from_context)
- Fix event loop handling to avoid conflicts with existing loops
- Add TypeScript interface for tool definitions instead of using any
- Update receive methods to return placeholder instead of throwing errors
- Simplify transport detection logic by removing unnecessary elif
- Add finally block to ensure init_done.set() is always called

These changes maintain backward compatibility while fixing the critical
runtime issues identified in the code review.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 14:40:38 +00:00
claude[bot]andMervin Praison 11f43e9fbd feat: Add HTTP-Streaming support for MCP with backward compatibility
- Add HTTP-Streaming transport module for Python MCP implementation
- Update main MCP module to support transport selection (auto, sse, http-streaming)
- Add TypeScript HTTP-Streaming transport implementation
- Create unified MCP class for TypeScript with transport selection
- Maintain full backward compatibility for existing code
- Auto-detect transport based on URL patterns (/sse uses SSE, others use HTTP-streaming)
- Add example demonstrating transport selection usage

Fixes #722

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 14:40:28 +00:00
praisonai-triage-agent[bot]andMervin Praison 7ee4854c19 fix: address critical runtime issues found in review
- Fix TypeError in builtin.py: pop prompt/messages from kwargs to avoid duplicate argument error
- Fix async/sync mismatch: use aexecute() instead of execute() in async context
- Fix Python 3.8/3.9 compatibility: handle TypeError when entry_points() doesn't accept group parameter
- Fix priority docstring: clarify that lower numbers = higher priority with correct example
- Implement lazy loading for entry points: defer module loading until runtime is resolved

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 14:39:27 +00:00
praisonai-triage-agent[bot]andMervinPraison 2001632dd3 feat: implement auto runtime selection with plugin harness registry (fixes #1939)
- Add AgentRuntimeProtocol with supports() and selection_priority() methods
- Implement auto resolution logic in runtime/resolve.py with priority-based selection
- Add runtime registry with entry point discovery for plugin harnesses
- Include built-in praisonai runtime as default fallback
- Add comprehensive unit tests for auto selection behavior
- Export runtime functionality through core SDK __init__.py

Supports:
- Auto mode selects highest-priority supporting runtime
- Explicit runtime selection by ID
- Entry point registration via 'praisonai.runtimes' group
- Tie-breaking by priority and fallback to praisonai default
- Lazy loading of plugin dependencies on selection only

Co-authored-by: MervinPraison <[email protected]>
2026-06-26 14:39:14 +00:00
praisonai-triage-agent[bot]andMervin Praison 4706567535 fix: harden bot-pr-recovery workflow against template injection
- Move pr_number workflow_dispatch input into env var, read via process.env (CodeRabbit Major/Security: template-injection)
- Set persist-credentials: false on checkout in bot-pr-recovery.yml and auto-pr-comment.yml (zizmor artipacked)

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 14:33:34 +00:00
praisonai-triage-agent[bot]andMervin Praison 3184351b8a fix: clear provider guidance on praisonai --init when no LLM key set (fixes #90)
Non-programmer users running `praisonai --init` with no API key (or a
non-OpenAI key for the OpenAI-default model) previously got a raw stack
trace, reinforcing the impression that PraisonAI is OpenAI-only. Add a
pre-flight credential check that emits an actionable message pointing at
the existing interactive `praisonai setup` wizard (no shell `export`
needed) and lists supported providers.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 14:32:53 +00:00
praisonai-triage-agent[bot]andMervin Praison 2625cf9904 fix: harden bot-pr review chain per reviewer feedback
- chainKickPosted now requires BOTH coderabbit + qodo markers; kickReviewChain posts only the missing one (closes partial-failure idempotency gap)
- recoverStalledBotPrs enforces isBotOpenedPr even for manual pr_number dispatch (fixes Greptile P1 security: no longer kicks arbitrary human/fork PRs) and paginates open PRs
- findOpenPrForIssue requires bot-authored PR to avoid kicking human prefix branches
- kickReviewChain accepts pre-fetched comments to avoid redundant API calls (Gemini perf)
- self-test covers new dual-marker + helper exports

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 14:31:19 +00:00
MervinPraisonandCursor 9beda70776 fix: reliably kick review chain when bot PRs miss pull_request opened
Add shared bot-pr-review-chain helper, post-create kick in claude.yml via
GH_TOKEN PAT, idempotent bot-pr-trigger-reviews, and a 15-min recovery scanner.
Fixes #2251

Co-authored-by: Cursor <[email protected]>
2026-06-26 15:18:28 +01:00
MervinPraison b5a45bf26a docs: auto-update feature parity trackers [skip ci] 2026-06-26 14:16:48 +00:00
Mervin Praison 6baffd53a9 Merge pull request #1967 from us/feat/add-fastcrw
feat: add fastCRW tool
2026-06-26 15:16:26 +01:00
Mervin Praison 688a2f2ce6 Merge pull request #1986 from Anil-matcha/feat/muapi-provider-example
feat: add MuAPI image generation provider example
2026-06-26 15:15:04 +01:00
MervinPraisonandCursor d2c897b6ff fix: unblock merge gate after conflict rebase and repair auto-dispatch
Clear the 12h merge-conflict cooldown once Claude posts rebase complete and
FINAL review is current on HEAD. Use repository_dispatch instead of
createWorkflowDispatch so event-driven assess runs reliably with GH_TOKEN.

Co-authored-by: Cursor <[email protected]>
2026-06-26 14:00:31 +01:00
MervinPraison 5eaaeaa4d8 Release v4.6.80 2026-06-26 13:32:14 +01:00
Mervin Praison 392bfa1d6f Merge pull request #2308 from MervinPraison/claude/issue-2300-20260626-0041
feat: agent-emitted interactive UI (buttons/quick-replies/selects) in bot replies
2026-06-26 13:30:11 +01:00
MervinPraison 6369c34786 docs: auto-update api.md [skip ci] 2026-06-26 10:42:05 +00:00
Mervin Praison 26cd5ca461 Merge pull request #2324 from MervinPraison/claude/issue-2321-20260626-0843
feat: managed local agent server — version handshake + live session attach
2026-06-26 11:41:43 +01:00
Mervin Praison 4d5136a4de Merge pull request #2325 from MervinPraison/cursor/critical-bug-investigation-9d70
fix: isolate warm runtime runs and preserve default session auto-save
2026-06-26 11:41:07 +01:00
Mervin Praison bec08ff40c Merge pull request #2329 from MervinPraison/claude/issue-2326-20260626-0915
fix: durable approval store so pending approvals survive restart
2026-06-26 11:40:33 +01:00
Mervin Praison c293635c8b Merge pull request #2310 from MervinPraison/claude/issue-2301-20260626-0041
feat: end-to-end correlation IDs + gateway message-flow metrics
2026-06-26 11:39:47 +01:00
praisonai-triage-agent[bot]andMervin Praison fc748e2908 fix: collision-safe long-reply callbacks (greptile P1)
Over-cap reply values were truncated to a UTF-8 prefix, so two long choices
sharing a prefix collapsed to the same callback payload and the agent received
a value it never authored. Encode over-cap values as a collision-resistant
hash marked with `#` (reply:#<digest>); make_reply_handler detects the marker
and declines to route a lossy value into the next turn instead of feeding a
corrupted/colliding input. Adds collision + handler-decline tests.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 10:39:25 +00:00
praisonai-triage-agent[bot]andMervin Praison 822345e867 fix: byte-safe reply callbacks, empty-string replies, stale-presentation clear
Address reviewer findings on PR #2308 (greptile, CodeRabbit):
- interactive.py: encode_action treats only None (not "") as missing so
  PresentationAction.reply("") keeps the reserved reply: prefix.
- presentation.py: _encode_reply_callback / _encode_select_callback measure
  the callback cap in UTF-8 bytes (not chars); long reply values are truncated
  to a routable value prefix instead of an unrecoverable opaque digest.
- _session.py: chat() always normalises to plain text (never leaks AgentReply)
  and clears any stale presentation on a later plain-text turn.
- tests: byte-cap + non-ASCII + empty-string reply coverage.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 10:33:39 +00:00
praisonai-triage-agent[bot]andMervin Praison 49198943cc fix: reject duplicate pending approval ids; surface stale resolve on rehydrated taps
- _approval_store: refuse to overwrite an active pending row with a
  different request on a reused approval_id (idempotent only when identical);
  switch the insert to plain INSERT since the no-existing-row path is the only
  one that reaches it. Prevents a reused id from hijacking the active prompt's
  timestamp/expiry window.
- _presentation_approval: _record_decision now returns the store's resolve()
  result; handle_approval_command treats a rehydrated approval's durable row as
  the source of truth and returns False (not handled) for a stale tap on an
  already expired/resolved row, leaving state and audit trail intact.

Addresses Greptile P1 findings: "Duplicate pending ids replace" and
"Stale taps look handled".

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 10:33:14 +00:00
praisonai-triage-agent[bot]andMervinPraison 28bf015455 feat: enable agents to emit portable interactive UI in bot replies (fixes #2300)
Adds the core agent-facing seam so an agent (or hook) can return a
MessagePresentation/AgentReply as a normal reply, plus a typed reply
action that routes a button/select click back into the next agent turn.

Core (praisonaiagents.bots):
- ActionType.REPLY + PresentationAction.reply()/.callback()/.command()/.open_url()
- PresentationBlock.quick_replies() shortcut for reply buttons
- reply->callback degradation in adapt_presentation (reply:<value>, length-capped)
  so existing per-channel renderers carry the payload unchanged
- AgentReply contract + extract_presentation() emit seam (str | MessagePresentation
  | AgentReply | dict -> (text, presentation)); fully backward compatible
- make_reply_handler() + REPLY_NAMESPACE for the typed inbound route
- encode_action handles REPLY

Wrapper (praisonai.bots):
- _session.chat() captures an agent-emitted presentation (keeps str return);
  new pop_last_presentation() lets adapters render via existing renderers
- chat_with_run_control surfaces presentation in metadata
- fix latent PresentationBlock.text/.context calls in _presentation_approval

Co-authored-by: MervinPraison <[email protected]>
2026-06-26 10:33:07 +00:00
praisonai-triage-agent[bot]andMervin Praison 8e66c99cc0 fix: daemon start rejects incompatible live runtime (Greptile P1)
After a major version upgrade, an old live runtime could not be reused by
run/attach (compat gate skips it) yet `daemon start` still reported it as
"already running" and exited 0, stranding the user with an orphan they could
not talk to. daemon start now detects a version-mismatched live runtime and
exits with a clear error directing the user to `daemon stop` first, keeping
the read-only descriptor getter free of process-killing side effects.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 10:33:02 +00:00
praisonai-triage-agent[bot]andMervin Praison 1d0790dde1 fix: reset correlation id on duplicate early-return and require auth for /metrics
- bots/_session.py: restore the task-local correlation id before the
  duplicate-message early return so it never leaks into the next turn
  reusing the same task (flagged by CodeRabbit + Greptile).
- gateway/server.py: gate GET /metrics behind _check_auth like /info so
  channel names and message-flow volumes are not exposed to unauthenticated
  clients on externally bound gateways (flagged by Greptile + CodeRabbit).

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 10:32:37 +00:00
praisonai-triage-agent[bot]andMervinPraison a6c8537926 feat: add end-to-end correlation IDs and gateway message-flow metrics (fixes #2301)
Mint a stable correlation id at ingress (adopting the platform message_id
or a fresh uuid) and thread it through ingress -> session -> agent run via a
contextvar so every hop logs one joinable id. Add a dependency-free
GatewayMetrics registry exposed at GET /metrics in Prometheus text format with
inbound/dispatched/outbound counters and live gauges (outbox depth, active
sessions, channel recoveries).

Co-authored-by: MervinPraison <[email protected]>
2026-06-26 10:32:37 +00:00
Cursor AgentandMervin Praison fbcacee465 fix: isolate warm runtime runs and preserve default session auto-save
The warm runtime reused cached Agent instances across /run calls without
clearing chat_history, so prior prompts leaked into subsequent invocations.
Clear in-memory history after each successful run while keeping the warm
agent for client cold-start savings.

Default praisonai run auto-saves sessions, but the warm fast-path returned
before persistence logic ran. Only attach the warm runtime when --no-save is
set, until session wiring is added to the warm path.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 10:31:53 +00:00
Mervin Praison 4671880b59 Merge pull request #2333 from MervinPraison/claude/issue-2332-20260626-0942
feat: gateway idle-dormancy / scale-to-zero (fixes #2332)
2026-06-26 11:30:36 +01:00
Mervin Praison d90b60d1f1 Merge pull request #2331 from MervinPraison/claude/issue-2328-20260626-0916
fix: effectively-once outbound delivery via crash reconciliation
2026-06-26 11:29:49 +01:00
Mervin Praison 04c2875fb3 Merge pull request #2330 from MervinPraison/claude/issue-2327-20260626-0916
fix: gateway broadcast backpressure & slow-consumer eviction
2026-06-26 11:29:19 +01:00
Mervin Praison 345a68617b Merge pull request #2323 from MervinPraison/claude/issue-2320-20260626-0842
fix: wire LSP client into post-edit diagnostics for multi-language self-correction
2026-06-26 11:28:08 +01:00
praisonai-triage-agent[bot]andMervin Praison eb2d145ebd fix: harden warm-runtime attach (session-id encoding, compat gate, disconnect handling)
Addresses reviewer feedback on PR #2324:
- URL-encode session id in RuntimeClient.attach and URL-decode it in the SSE
  server route (+ strip query string) so ids with reserved chars round-trip.
- attach command now requires a compatible runtime (require_compatible=True),
  matching the run path's gate.
- Reject --attach on file/profile/custom-agent/custom-command runs so users
  don't get a silently event-less session.
- Normalize mid-stream disconnects in the attach client as RuntimeUnavailable.
- Add an end-to-end test for a reserved-char session id round-trip.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 10:27:46 +00:00
Mervin Praison 38dad10f53 Merge pull request #2322 from MervinPraison/claude/issue-2319-20260626-0842
fix: anchor CLI config, rules & sessions to project root (walk up from cwd)
2026-06-26 11:27:27 +01:00
praisonai-triage-agent[bot]andMervinPraison 5660e7346f feat: managed local agent server — version handshake + live session attach (fixes #2321)
Assembles the existing warm-runtime building blocks into a managed
local-server lifecycle for the CLI:

- Version-compat handshake: RuntimeDescriptor carries a `version`; the
  server writes it (and advertises it on /healthz), the client/run path
  verifies it via `is_compatible()` / `get_runtime_descriptor(require_compatible=True)`
  so a stale (major-mismatched) runtime is not silently attached to.
- Live session attach: server-side SessionEventHub fans out per-session
  run.start/run.result/run.error events over an SSE endpoint
  (/sessions/{id}/events); RuntimeClient.attach() streams them and a new
  `praisonai attach <session-id>` command renders them in another terminal.
- `praisonai run --attach <id>` tags a warm-runtime run with a session id
  so multiple terminals can observe one active session.
- `daemon status` now reports the runtime version and compatibility.

Strictly opt-in and backward-compatible: default in-process behaviour is
unchanged; older descriptors without a version are treated as incompatible.

Wrapper-layer only (process-lifecycle/transport), no core changes.

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

Co-authored-by: MervinPraison <[email protected]>
2026-06-26 10:27:03 +00:00
Mervin Praison 2920cd68bc Merge pull request #2318 from MervinPraison/claude/issue-2317-20260626-0810
fix: harden praisonai wrapper (thread leaks, YAML corruption, async client race)
2026-06-26 11:26:14 +01:00
praisonai-triage-agent[bot]andMervin Praison 1abf4cf938 fix: harden durable approval store (collisions, audit, stale resolve, send-failure)
Address reviewer findings on PR #2329:
- Use full uuid4 hex for auto-generated approval_id (no 8-char truncation)
- _sync_persist refuses to clobber a resolved/expired row (preserve audit)
- resolve() returns bool and only reports success when a pending row matched
- Add explicit 'expired' terminal state via decision.metadata['terminal'];
  timeouts and undelivered approvals now audited as expired, not denied
- Rename store contract load_pending -> list_pending (SDK naming), keep alias
- request_approval forwards agent_name/session_id into the persisted request
- Send-failure now fails fast and clears the durable row instead of hanging

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 10:25:53 +00:00
praisonai-triage-agent[bot]andMervinPraison 7994eddde0 fix: durable approval store so pending approvals survive restart (fixes #2326)
Add a persistence/re-hydration seam to the approval contract so pending
human-in-the-loop approvals are no longer lost on gateway/bot restart.

Core (praisonaiagents):
- ApprovalRequest gains a stable, auto-generated `approval_id` correlation
  id that survives a restart and matches an inbound channel callback.
- New `ApprovalStoreProtocol` (persist / load_pending / resolve) lets any
  backend and any channel interoperate.

Wrapper (praisonai):
- New SQLite-backed `ApprovalStore` modeled on `_outbox.py` (stdlib sqlite3,
  thread-safe, TTL-bounded, durable audit trail).
- `PresentationApprovalHandler` accepts an optional store: persists pending
  approvals, re-hydrates outstanding ones on startup via `rehydrate()`, and
  records decisions durably. Late "Allow"/"Deny" taps after a restart still
  resolve. In-memory remains the zero-dependency default when no store is set.

Co-authored-by: MervinPraison <[email protected]>
2026-06-26 10:25:53 +00:00
Mervin Praison d7ea9007f8 Merge pull request #2313 from MervinPraison/claude/issue-2312-20260626-0103
fix: add usage telemetry and bounded retention to continuous-learning stores
2026-06-26 11:25:26 +01:00
Mervin Praison 201364939d Merge pull request #2311 from MervinPraison/claude/issue-2304-20260626-0043
fix: wire all configured MCP servers (multiple local + remote) on run path
2026-06-26 11:24:34 +01:00
MervinPraison 7d89504ba5 docs: auto-update api.md [skip ci] 2026-06-26 10:23:32 +00:00
Mervin Praison 88c43464db Merge pull request #2309 from MervinPraison/claude/issue-2302-20260626-0042
feat: wire checkpoint CLI + rewind into praisonai run
2026-06-26 11:23:12 +01:00
MervinPraisonandCursor 7c27af5faf fix: improve merge gate throughput and event-driven dispatch
FIFO oldest-ready scan with MAX_CANDIDATES 5, scope claude.yml blocking per PR,
auto-dispatch after Claude Assistant/Core Tests success, and merge-gate-ci-only
label to exempt workflow-only CI changes from sensitive-path blocks.

Co-authored-by: Cursor <[email protected]>
2026-06-26 11:22:54 +01:00
Mervin Praison 107994cc1f Merge pull request #2307 from MervinPraison/claude/issue-2299-20260626-0040
fix: enforce actor-authorization on interactive callbacks & tool approvals
2026-06-26 11:22:09 +01:00
Mervin Praison b58329269b Merge pull request #2306 from MervinPraison/claude/issue-2303-20260626-0043
feat: opt-in live shell substitution in custom command templates
2026-06-26 11:21:07 +01:00
MervinPraison 2ff15d63e5 docs: auto-update documentation parity trackers [skip ci] 2026-06-26 10:20:04 +00:00
Mervin Praison 138c3d1b34 Merge pull request #2305 from MervinPraison/claude/issue-2298-20260626-0039
fix: per-route, trust-tiered toolset scoping for gateway inbound
2026-06-26 11:19:32 +01:00
Anil Matcha db3e8fb563 fix(muapi): allowlist models, safe dict access, guard module-level side effects
- ALLOWED_MODELS set prevents arbitrary path injection via model param
- .get() on all response dict accesses with explicit error messages
- Agent/Tool instantiation moved inside __main__ guard to avoid
  side effects on import (env var reads, network, PraisonAI init)
2026-06-26 15:41:52 +05:30
praisonai-triage-agent[bot]andMervin Praison 6fdac7d1c0 fix: passively probe session liveness in gateway idle loop (#2332)
Closes the reviewer P1 "stale counters" bug: notify_inbound/turn_started
hooks were defined but not wired into every adapter, so _running_turns and
_last_inbound_ts could stay frozen while users were actively messaging,
letting the idle loop quiesce a busy gateway.

_run_idle_loop now calls _probe_activity(), which passively reads the
session-manager state every adapter already maintains (_active_runs for
in-flight turns, _last_active for last-inbound). No adapter changes needed;
explicit hooks still merge in as an override for non-session adapters.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 10:07:19 +00:00
Mervin Praison e803e0b402 Merge pull request #2316 from MervinPraison/fix/merge-gate-mcp-opus
Merge gate fix — lands MCP + Opus subagent fix on main for scheduled runs.
2026-06-26 11:05:27 +01:00
praisonai-triage-agent[bot]andMervin Praison 2a9ff3a8eb fix: address reviewer feedback on gateway idle-dormancy (#2332)
- Rename GatewayIdlePolicy protocol to GatewayIdlePolicyProtocol per repo
  convention (SendPolicyProtocol/SendPolicy); keep backward-compat alias.
- Block dormancy when any enabled scheduled job exists, not only currently
  due jobs, so post-timeout jobs are never lost via stopped transports.
- Supervise wake-restarted transport tasks and prune stale task handles.
- Document the activity-hook integration contract on notify_inbound.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 10:02:47 +00:00
praisonai-triage-agent[bot]andMervinPraison 771ccc3c5c feat: add gateway idle-dormancy / scale-to-zero policy (fixes #2332)
Add a pure, import-free GatewayIdlePolicy protocol + config-driven
ScaleToZeroPolicy in core (praisonaiagents) so the idle decision is
testable in isolation. Wire an opt-in idle_policy driver into BotOS
that quiesces transports (and an optional compute-host suspend hook)
only when no turn is in flight and no background work is pending, with
a wake() resume path reusing HookAction.WAKE semantics. Default off and
fully backward compatible.

Co-authored-by: MervinPraison <[email protected]>
2026-06-26 09:47:16 +00:00
praisonai-triage-agent[bot]andMervin Praison 5ef7e98c8f fix: address reviewer findings on gateway slow-consumer flow control
- Reject single frames already exceeding max_buffered_bytes (CodeRabbit)
- Mark _ClientConn closed on send failure so dead drain stops accepting frames (Greptile P1)
- Queue per-client snapshots in _send_to_client/broadcast to avoid shared cursor/seq mutation (Greptile P1/P2)
- Clear scope/session state on slow-consumer eviction (Greptile P2)
- Coerce max_buffered_bytes/max_queued_frames to int in load_gateway_config (CodeRabbit)
- Wire buffer ceilings through from_config_file + start_with_config YAML paths (CodeRabbit)
- Add tests for oversized frame rejection, dead-drain rejection, eviction cleanup

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 09:42:12 +00:00
praisonai-triage-agent[bot]andMervin Praison 5f78636527 fix: guard terminal states & stale claim reclaim in outbox reconciliation
Address reviewer findings on PR #2331:
- mark_failed now refuses to overwrite terminal states (sent/permanent_failure),
  preventing a stale drain from re-queueing an already-delivered message
  (Greptile P1: "Sent Rows Become Failed").
- _claim_entry reclaims stale 'sending' rows, matching _get_pending_entries so
  abandoned in-process claims are retried without a restart (CodeRabbit Major).
- drain only counts a reconciled entry as succeeded when mark_sent actually
  commits; otherwise it falls through to a normal send (Greptile P1: "Reconciled
  Claim Can Stay Sending").
- Tests assert failed == 0 (CodeRabbit Minor / Ruff RUF059).
- Document supports_idempotency_token as informational-only.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 09:39:36 +00:00
praisonai-triage-agent[bot]andMervinPraison ae17b289ec fix: reconcile in-flight sends on restart for effectively-once delivery (fixes #2328)
A crash after a message is handed to the channel API but before its terminal
status is recorded left the outbox entry as 'sending'; on restart it was reset
to 'pending' and blindly re-sent, duplicating the channel message.

- core: add reconciles_unknown_send + supports_idempotency_token to
  PlatformCapabilities (with dict round-trip) so adapters can declare whether
  they can confirm a prior send landed.
- wrapper: recovered 'sending' entries now move to a new 'recovered' state on
  restart and are reconciled (via an optional drain reconciler) before
  re-dispatch; if the prior attempt already landed it is marked sent without
  re-sending (effectively-once). Falls back to at-least-once when no reconciler.
- wrapper: DurableDelivery.drain_pending builds a reconciler from the adapter's
  was_delivered() when it declares reconciles_unknown_send.
- fix latent durability bug: status-update writes (mark_sent/mark_failed/claim/
  permanent_failure) now commit so terminal status survives restart.

Co-authored-by: MervinPraison <[email protected]>
2026-06-26 09:24:00 +00:00
praisonai-triage-agent[bot]andMervinPraison c842940b61 fix: add gateway broadcast backpressure & slow-consumer eviction (fixes #2327)
Give each gateway client a bounded outbound send queue drained by its own
task so one slow/stalled consumer can no longer head-of-line block the shared
broadcast loop or grow an unbounded backlog. Slow consumers are evicted with a
typed SLOW_CONSUMER close. Adds max_queued_frames config (CLI/YAML/Python),
advertises both buffer dimensions in the handshake policy, and a
GatewayCloseCode.SLOW_CONSUMER protocol code.

Co-authored-by: MervinPraison <[email protected]>
2026-06-26 09:23:48 +00:00
praisonai-triage-agent[bot]andMervin Praison 20fbc0dc10 fix: LSP diagnostics fall back to checker when server never publishes (#2320)
Address reviewer findings on PR #2323:

- Greptile P1: an installed-but-not-yet-published LSP result no longer
  suppresses the legacy checker. _collect() now returns None (fall back)
  when no publishDiagnostics arrived within the budget, and [] only for a
  genuine clean publish, so a syntax error is never reported as a false
  all-clear in auto mode.
- CodeRabbit perf: a clean file returns promptly instead of sleeping the
  full timeout by breaking once the URI appears in the client's published
  diagnostics store (distinguishing "not reported yet" from "clean").

Adds two regression tests covering the unpublished->fallback and
published-clean->authoritative paths.

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

Co-Authored-By: Mervin Praison <[email protected]>
2026-06-26 09:01:00 +00:00
praisonai-triage-agent[bot]andMervin Praison 6cca735161 fix: harden project-root resolution per reviewer feedback (#2322)
- validate PRAISONAI_PROJECT override (existing dir only); invalid values
  fall through to normal discovery instead of crashing or anchoring writes
- stop no-marker ancestor walk at cwd so unrelated parent .praison configs
  are not picked up with higher precedence than user config
- align config dir with the detected marker (.praison/.praisonai) so a repo
  using .praisonai reads/writes from the same directory it was detected by
- loader: keep explicit project_root pinned, but let the default path use
  the cwd->root ancestor-chain merge in get_config_paths

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 08:58:19 +00:00
praisonai-triage-agent[bot]andMervinPraison 3fa03f2d94 fix: wire LSP client into post-edit diagnostics for multi-language self-correction (fixes #2320)
Wire the existing praisonaiagents/lsp client into the edit tool's post-edit
diagnostics hook. After a successful edit, EditTools now tries the LSP client
first (when a language server is installed for the edited file's language) and
falls back to the existing zero-config per-language checker otherwise.

- Lazy: the LSP client is only imported/spawned when diagnostics are enabled
  and a server command is actually installed, so Python-only/silent runs pay
  no startup cost and the `off` fast-path keeps zero overhead.
- Bounded: reuses the shared diagnostics character budget and timeout, so a
  slow/missing server never blocks a successful edit.
- Backward-compatible: the pyflakes-style checker remains the default fallback.

Co-authored-by: MervinPraison <[email protected]>
2026-06-26 08:46:40 +00:00
praisonai-triage-agent[bot]andMervinPraison 8a71828dc3 fix: anchor CLI config & sessions to project root via upward walk (fixes #2319)
Add find_project_root() in cli/configuration/paths.py that ascends from cwd
to the nearest ancestor containing a project marker (.praison/.praisonai)
or a VCS root (.git), with a PRAISONAI_PROJECT env override. Thread the
resolved root through get_project_config_dir(), get_config_paths() (now
collecting configs along the ancestor chain, nearest-wins) and the
ConfigLoader.project_root default so the CLI behaves identically from any
sub-directory. Backward-compatible: from the project root the resolved root
equals cwd.

Co-authored-by: MervinPraison <[email protected]>
2026-06-26 08:44:48 +00:00
praisonai-triage-agent[bot]andMervin Praison 385437f238 fix: address reviewer findings on wrapper hardening (lock race, env validation, sync client race, file mode)
- agents_generator.py: eagerly init the timeout-executor lock at module scope so
  concurrent first-callers share one lock/executor (no first-use race); validate
  PRAISONAI_TOOL_TIMEOUT_WORKERS and fall back to default 32 on bad/non-positive values.
- auto.py: _get_openai_client() now snapshots the client under the lock (mirrors the
  async path), closing the sync shutdown-during-request race.
- auto.py: _atomic_write_text() preserves the existing file's permission bits so a
  replaced shared/deployment-managed config does not become unreadable.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 08:30:55 +00:00
praisonai-triage-agent[bot]andMervinPraison ba96163809 fix: harden praisonai wrapper against thread leaks, YAML corruption, async client race (fixes #2317)
- agents_generator.py: replace bare daemon threads in sync tool timeout with a
  bounded, named ThreadPoolExecutor; log a warning on timeout and surface
  background_work_may_continue in the payload; correct the misleading docstring.
- auto.py: add _atomic_write_text (temp file + fsync + os.replace) and use it for
  convert_and_save and both _save_workflow write sites to prevent config data loss.
- auto.py: snapshot the async OpenAI client under the lock before await to close
  the aclose()/in-flight-request race; capture-close-then-null in close()/aclose()
  so a failed close can be retried.

Co-authored-by: MervinPraison <[email protected]>
2026-06-26 08:16:38 +00:00
praisonai-triage-agent[bot]andMervin Praison 8012d5e0a4 fix: filter disallowed tools from action allow-list to enforce read-only
Removes any explicitly disallowed tools (Bash/Edit/Write/Replace) from the
default allow-list before building --allowedTools, so the merge gate's
read-only contract no longer relies on undefined CLI precedence between
--allowedTools and --disallowedTools (CodeRabbit major finding).

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 07:24:58 +00:00
MervinPraison c287d7bb5f docs: auto-update feature parity trackers [skip ci] 2026-06-26 07:12:31 +00:00
Mervin Praison 3b51e83393 Merge pull request #2315 from MervinPraison/claude/issue-2314-20260626-0118
fix: pre-call budget guard enforces max_budget as a hard ceiling
2026-06-26 08:12:07 +01:00
MervinPraisonandCursor 91e5699287 fix: restore merge gate GitHub MCP and Opus 4.8 subagents
Use the local claude-code-action (installs GitHub MCP), pass --model to the CLI,
inherit subagent model from Opus, and post a deterministic fallback verdict when
Claude cannot comment.

Co-authored-by: Cursor <[email protected]>
2026-06-26 08:09:05 +01:00
praisonai-triage-agent[bot]andMervin Praison 2b81fd5293 fix: reserve default output cost in pre-call budget estimate (#2314)
Addresses reviewer feedback (Greptile P1): when max_tokens is unset the
pre-call guard previously reserved zero completion tokens, so a small prompt
with a large provider-default response could pass the check and then push
_total_cost over max_budget. _estimate_min_call_cost now falls back to a
conservative DEFAULT_OUTPUT_RESERVATION_TOKENS reservation whenever there is
prompt content but no explicit max_tokens, keeping the estimate a true
minimum. Documented that tool-schema tokens remain uncounted by design (the
reactive post-call accounting is the backstop).

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 01:37:05 +00:00
praisonai-triage-agent[bot]andMervin Praison 20454c554b fix: protect new learnings from eviction and make archival atomic
Address reviewer findings on PR #2313 (Greptile P1, CodeRabbit Major/Minor):
- add() now passes protect_id so a full capped store never evicts the
  just-added entry and returns an unretrievable LearnEntry.
- prune() treats archival as mandatory: if the .archive.json sidecar write
  fails, evicted entries are restored to the active store instead of being
  silently lost.
- _touch() sets _was_updated so persisted telemetry is reflected in the
  public update flag.
- Tests exercise the public retrieval path, add new-entry-retention and
  archive-failure-restore coverage, and verify LearnManager.prune() itself.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 01:23:03 +00:00
praisonai-triage-agent[bot]andMervinPraison 3892ff3256 fix: add pre-call budget guard to enforce max_budget as hard ceiling (fixes #2314)
Estimate a call's minimum cost from known input tokens plus the configured
max_tokens output reservation and refuse to dispatch when it would breach
max_budget (on_budget_exceeded="stop"), preventing overshoot by a full LLM
call. Reactive post-call accounting is unchanged; warn/callable modes and
the unset-budget path keep their existing behaviour.

Co-authored-by: MervinPraison <[email protected]>
2026-06-26 01:22:56 +00:00
praisonai-triage-agent[bot]andMervin Praison 796b0de3f6 fix: scope untrusted Discord/Slack routes via staged tool policy (#2298)
Telegram threaded the per-route ToolPolicy end-to-end, but the
Discord/Slack injected routing handler only called set_agent() and
returned before the adapter's own _session.chat(), so an untrusted
Discord/Slack route still ran with the full toolset (Greptile P1).

BotSessionManager now exposes set_pending_tool_policy(); the injected
routing handler stages the resolved policy (keyed by agent identity)
right before the adapter's chat(), which consumes-and-clears it when no
explicit tool_policy was passed. Fail-closed: an absent explicit arg
inherits the staged scope; the staged policy is always popped so it
applies exactly once and never leaks to a later turn.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 01:10:19 +00:00
MervinPraison cbbf24dd00 Merge branch 'main' of https://github.com/MervinPraison/PraisonAI 2026-06-26 02:09:32 +01:00
MervinPraison dab3b6bfcc Enhance pypi-release workflow with source input and release summary logging. Update instruction documentation for manual and nightly release processes. 2026-06-26 02:09:30 +01:00
praisonai-triage-agent[bot]andMervinPraison b8fb606abc fix: add usage telemetry and bounded retention to continuous-learning stores (fixes #2312)
Adds lifecycle governance to memory/learn stores so they stay healthy over
long-running/gateway-resident agents:
- LearnEntry gains use_count/last_used; search()/list_all() record access
- BaseStore gains max_entries/retention_days bounded retention with
  least-used/oldest eviction and staleness deprecation
- Evicted entries are moved to a recoverable .archive.json sidecar
  (never silent hard-delete)
- LearnConfig gains max_entries/retention_days; LearnManager.prune()
  propagates limits and prunes across all stores
- Backward-compatible: unbounded by default (0 = no limits)

Co-authored-by: MervinPraison <[email protected]>
2026-06-26 01:08:44 +00:00
praisonai-triage-agent[bot]andMervin Praison 6feadc619e fix: enforce shell-substitution byte cap during capture (CodeRabbit)
Stream stdout via Popen with a non-blocking read loop, capping at
SHELL_SUBSTITUTION_MAX_BYTES while reading rather than after subprocess
completes, so a noisy command can no longer buffer unbounded output in
memory. Cap-triggered kills are treated as success-with-truncation.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 01:07:55 +00:00
praisonai-triage-agent[bot]andMervin Praison 72c8eef44b fix: close MCP config inline-exec bypass + drop SSE headers safely (PR #2311 review)
- create_mcp_from_server now enforces the same inline-eval rejection
  (python -c, node -e, deno eval, bun -e) as the --mcp string path via a
  shared _validate_mcp_executable helper, closing a config-driven RCE path.
- warn + omit headers for legacy /sse remote URLs since the SDK SSEMCPClient
  cannot forward them (avoids silent auth-header drop).
- handle_direct_prompt reuses _build_mcp_tools for a single source of truth.
- add tests for inline-exec rejection and SSE header drop.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 01:06:40 +00:00
praisonai-triage-agent[bot]andMervin Praison bb54046fd7 fix: harden custom-command shell substitution per reviewer feedback
- Run !`cmd` on the original template only and inline stdout via opaque
  placeholders so untrusted $ARGUMENTS / @file content can never be
  re-parsed and executed (injection guard).
- Add _coerce_bool so quoted false-like frontmatter values
  (e.g. "false", "0", "no") fail closed instead of enabling shell.
- Read the commands.allow_shell project-config gate from resolved
  config.extra (it is not a first-class field) and register `commands`
  as a known top-level config key.
- Keep command stdout out of the $(...) escape pass so legitimate output
  is no longer mangled.
- Tests: argument/file injection guards, $(...) escaping of arguments,
  shell output containing $(...), quoted-false gating, config gate.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 01:04:02 +00:00
praisonai-triage-agent[bot]andMervin Praison 3dd17b831a fix: resolve checkpoint refs in diff, reject ambiguous prefixes, scope auto-checkpoint to file runs
Addresses reviewer findings on PR #2309:
- checkpoint diff now resolves last/short-id/prefix refs (Greptile P1)
- restore resolvers (CLI + run --restore) prefer exact match and reject
  ambiguous prefixes so a workspace-mutating restore never targets the
  wrong checkpoint (CodeRabbit)
- auto-checkpoint is scoped to YAML-file runs and snapshots the target
  file's directory, avoiding spurious "no changes" noise on plain-prompt
  runs and protecting the correct workspace (Greptile P1/P2)
- auto-checkpoint runs quietly so it never leaks into json/quiet run output
  (CodeRabbit); added quiet flag to CheckpointsHandler.save

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

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 01:02:56 +00:00
praisonai-triage-agent[bot]andMervin Praison 497f53739e fix: fail-closed trust tiers + resolve top-level dict tool names (#2298)
Address reviewer findings on PR #2305:
- RouteBinding.__post_init__ normalises `trust` (strip/lower) and down-tiers
  any unknown value to `untrusted` so config typos can never fail open into a
  full toolset (Greptile P1 / CodeRabbit).
- ToolPolicy._tool_name resolves plain schema dicts `{"name": ...}` so exact
  denies / untrusted substrings remove them instead of matching str(dict)
  (Greptile P2).
- Adds unit coverage for both.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 01:02:55 +00:00
praisonai-triage-agent[bot]andMervin Praison 9258eea28b fix: bound approval replay/audit retention and harden audit_log
Address reviewer feedback (Greptile P2, CodeRabbit) on PR #2307:
- Bound _resolved_ids and _audit_log with FIFO eviction (history_limit)
  so long-running bots don't grow unboundedly.
- audit_log property now returns copied entries to prevent external
  mutation of internal audit state.
- Lint fixes in tests: replace /tmp path (S108), underscore unused var
  (RUF059).
- Add tests for bounded retention and audit-copy immutability.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 01:01:33 +00:00
praisonai-triage-agent[bot]andMervinPraison ca4413c6f8 fix: wire all configured MCP servers (multiple local + remote) on run path (fixes #2304)
The `praisonai run` path collapsed project MCP config into a single
`--mcp` command string, dropping extra local stdio servers (with a
warning) and ignoring remote/URL servers entirely.

Now all enabled MCP servers from project config — multiple local stdio
servers AND remote/URL servers (with headers) — are collected into a
structured list and handed to the existing core MCP client, which
already supports stdio, SSE, HTTP-stream and websocket transports.
The "only the first will be used" warning is removed.

Co-authored-by: MervinPraison <[email protected]>
2026-06-26 00:52:35 +00:00
praisonai-triage-agent[bot]andMervinPraison 147790f29b feat: wire checkpoint CLI + rewind into praisonai run (fixes #2302)
Surface the existing core CheckpointService through a first-class CLI:
- Add `praisonai checkpoint` command group (save/list/restore/diff/delete)
  delegating to the existing CheckpointsHandler; `restore` accepts `last`.
- Register `checkpoint` in the lazy command registry in cli/app.py.
- Auto-checkpoint the workspace before `praisonai run` executes (gated by
  `checkpoints.auto` config, default on; opt out with `--no-checkpoint`),
  tagged with the run id so `--restore last` is unambiguous.
- Add `praisonai run --restore <id|last>` to rewind the workspace and exit.

Co-authored-by: MervinPraison <[email protected]>
2026-06-26 00:48:02 +00:00
praisonai-triage-agent[bot]andMervinPraison a7f44b9bc9 fix: enforce actor-authorization on interactive callbacks & tool approvals (fixes #2299)
Bind interactive presentations (esp. tool approvals) to an authorized
actor set and enforce it in the shared core registry so every channel
inherits the check.

Core (praisonaiagents.bots.interactive):
- InteractiveRegistry.register() accepts an optional `authorize` callback;
  dispatch() rejects unauthorized clicks before the handler runs (fail-closed
  on authorizer errors). Backward compatible when no authorizer is given.
- New InteractiveAuthorizer type, exported from bots package.

Wrapper (praisonai.bots._presentation_approval):
- request_approval() records `allowed_actors`; handle_approval_command()
  takes `actor`, enforces membership, and is single-use + audited.
- Added replay protection (resolved ids), an audit_log, and is_authorized().
- Fixed pre-existing PresentationBlock.text/.context calls to make_text/make_context.

Co-authored-by: MervinPraison <[email protected]>
2026-06-26 00:47:29 +00:00
praisonai-triage-agent[bot]andMervinPraison 9fe5a0a489 feat: opt-in live shell substitution in custom command templates (fixes #2303)
Add a security-bounded, opt-in `!`cmd`` substitution to command templates so
commands can embed live output (git diff, status, etc). Disabled by default and
gated via PRAISONAI_ALLOW_SHELL env, commands.allow_shell config, or per-command
allow_shell frontmatter. Runs with a timeout + output byte cap and raises a clear
ShellSubstitutionError when present-but-disabled. $(...) escaping unchanged.

Co-authored-by: MervinPraison <[email protected]>
2026-06-26 00:46:59 +00:00
praisonai-triage-agent[bot]andMervinPraison 711a06553c fix: add per-route, trust-tiered toolset scoping for gateway inbound (fixes #2298)
Inbound gateway/bot messages previously ran with the agent's full tool
surface regardless of how trusted the route was. This adds a declarative,
per-route toolset scope reusing the scheduler's proven apply/restore
pattern.

Core (praisonaiagents):
- New ToolPolicy dataclass + filter_tools (allow/deny/deny-substring).
- RouteBinding gains optional trust/allow_tools/deny_tools and a
  tool_policy() method; untrusted tier seeds a conservative deny-list
  (shell/file-mutation/delegation/self-scheduling).
- from_dict parses the new YAML fields; ToolPolicy exported.

Wrapper (praisonai):
- BotSessionManager.chat() accepts an optional tool_policy and swaps the
  agent's tools to the allowed subset for the turn, restoring afterwards
  (under the existing per-agent lock; restored even on error).
- Gateway resolves the route's tool_policy and threads it into chat().

Co-authored-by: MervinPraison <[email protected]>
2026-06-26 00:46:56 +00:00
ae160d1ad2 Add LiteLLM fallback support and fix AutoGenerator test issues (#2297)
* Add LiteLLM fallback support for auto generator

* fix: restore lazy loading and execution-level LiteLLM fallback in auto.py

- Remove eager top-level `import litellm` / dead `LITELLM_AVAILABLE` flag
  that defeated the module's documented FULL LAZY LOADING contract.
- Move Pydantic models back inside their lazy factory closures
  (_get_team_models / _get_workflow_models) so pydantic is no longer
  imported at module load; expose model names via PEP 562 module
  __getattr__ so `from praisonai.auto import PatternRecommendation`
  keeps working lazily.
- Wrap LiteLLM completion calls (sync + async) in try/except so runtime
  failures (auth/network/unsupported model/None content) gracefully fall
  through to the OpenAI SDK instead of propagating, honouring the PR's
  stated graceful-fallback goal.
- Tighten test stub: is_available side_effect=lambda x: x == "litellm".

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: Iyanuoluwa007 <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 01:29:55 +01:00
MervinPraison 9b6c796ffe docs: auto-update api.md [skip ci] 2026-06-25 21:46:39 +00:00
praisonai-triage-agent[bot]andMervin Praison 8379f616b2 feat: opt-in warm local runtime so repeated praisonai run skips cold-start (#2279)
* feat: add opt-in warm local runtime so repeated `praisonai run` skips cold-start (fixes #2276)

Adds a wrapper-layer warm runtime that keeps provider clients and the
agent (and any configured MCP connections) hot across `praisonai run`
invocations:

- praisonai/runtime/: loopback HTTP runtime server (stdlib only), a
  lockfile descriptor (loopback host, file-permissioned token, pid
  liveness check) and a thin client used to forward prompts.
- `praisonai daemon start|stop|status`: lifecycle for the warm runtime
  with local-first defaults (loopback bind, auto port, idle auto-shutdown)
  and optional --background.
- `run`: transparent detect-and-attach branch that forwards the plain
  prompt to a running runtime and falls back to in-process execution when
  none is present or it becomes unreachable. Per-invocation tool/approval/
  memory/session overrides and structured output modes stay in-process.

Backward-compatible and opt-in: with no runtime up, `run` behaves exactly
as before.

Co-authored-by: MervinPraison <[email protected]>

* fix: harden warm runtime per reviewer feedback

- descriptor: atomic 0600 token write (close TOCTOU world-readable window)
- server: per-model lock serializes concurrent /run Agent.start calls
- daemon: reject non-loopback --host; verify runtime via ping before SIGTERM
  (PID-reuse safety); terminate orphaned background child on readiness timeout
- run: keep session/continue/fork in-process (warm runtime carries no session)

Co-authored-by: Mervin Praison <[email protected]>

* fix: evict warm agent on failed start() to prevent state bleed

Addresses Greptile P2: when agent.start() raised (LLM error, timeout),
the cached Agent retained partial conversation state (e.g. an unmatched
user turn), corrupting subsequent runs. Now the agent is evicted from
the per-model cache on exception so the next call rebuilds a clean agent.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-06-25 22:46:17 +01:00
51fe7f9cbb fix: close call-server auth bypass and /compress error-path crash (#2280)
- Fail closed when PRAISONAI_CALL_AUTH=disabled but bind host is unset
  or non-localhost; set PRAISONAI_CALL_BIND_HOST at server startup in
  call.py and serve.py (regression from GHSA-2gpf host-header fix)
- Add missing logger in bots/_commands.py so /compress errors are handled
  instead of raising NameError

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-06-25 22:45:36 +01:00
praisonai-triage-agent[bot]andMervin Praison cfb2932700 feat: add inbound webhook/event-trigger surface to gateway (#2282)
* feat: add inbound webhook/event-trigger surface to gateway (fixes #2281)

Adds a declarative POST /hooks/<path> surface so external services can start
agent runs. The gateway authenticates, deduplicates by a payload-derived
idempotency key, resolves a templated session, runs an agent (or wakes a
session with action="wake"), and delivers the reply via a channel bot.

Protocol-only contract (HookConfig, templating, idempotency helpers) lives in
core (praisonaiagents/gateway/hooks.py); HTTP route, auth, idempotency store and
delivery live in the wrapper (gateway/server.py). 3-way surface: Python
(register_hook), YAML (hooks: in gateway.yaml), and CLI (gateway hooks
add/list/remove). No new dependencies.

Co-authored-by: MervinPraison <[email protected]>

* fix: harden inbound gateway hooks per reviewer feedback (#2281)

- hooks.py: render templates in a single pass so payload values containing
  braces are never re-expanded (fixes idempotency-key/message corruption);
  sort __all__ (RUF022)
- server.py: record idempotency key only after a successful run so transient
  failures stay retryable; reject malformed JSON with 400; fail when an
  explicitly configured hook agent is missing instead of silently running
  another; treat configured-delivery failure as a failure; use
  asyncio.get_running_loop(); refresh hooks on config reload
- config.py: use List[Any] instead of invalid List["Any"] forward ref
- cli: guard against non-mapping YAML roots before cfg.get()

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

Co-authored-by: Mervin Praison <[email protected]>

* fix: close hook idempotency race and stop query-param token logging

- Reserve idempotency keys atomically via an in-flight set so concurrent
  identical deliveries are deduplicated across the await (Greptile P1 TOCTOU).
  Reservations are released on failure so transient errors stay retryable.
- Accept the per-hook secret only via Authorization: Bearer, dropping the
  ?token= query-param fallback that leaked the shared secret into access logs.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-06-25 22:45:19 +01:00
MervinPraison 6252e628f6 docs: auto-update documentation parity trackers [skip ci] 2026-06-25 21:45:10 +00:00
praisonai-triage-agent[bot]andMervinPraison b2260c414d fix: consolidate duplicate output.OutputConfig to canonical config.OutputConfig (fixes #2294) (#2295)
Re-point praisonaiagents.output.OutputConfig lazy __getattr__ to the canonical
config.OutputConfig and remove the unused divergent dataclass in
output/config.py. The duplicate had disjoint fields (default_format,
truncate_long_responses, include_metadata) referenced nowhere in code/tests.
The public import path `from praisonaiagents.output import OutputConfig` is
preserved (backward-compatible).

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-06-25 22:44:39 +01:00
praisonai-triage-agent[bot]andMervinPraison a5770f9126 fix: remove dead private methods in core SDK (#2296)
* fix: remove dead private methods in core SDK (fixes #2293)

Remove three unreferenced private methods:
- Agent._create_subagent (agent.py)
- LLM._try_parse_tool_call_json (llm.py)
- LLM._prepare_response_logging (llm.py)

Update the two example files that called _create_subagent to build the
subagent inline via the public get_profile API, preserving behaviour.
No public symbol, signature, or runtime behaviour changes.

Co-authored-by: MervinPraison <[email protected]>

* fix: correct 'a explorer' -> 'an explorer' in example fallbacks

Addresses Greptile P2 review feedback on both autonomy example files.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-06-25 22:44:21 +01:00
Mervin Praison 2f33ba9cab Merge pull request #2291 from MervinPraison/claude/issue-2290-20260625-1610
fix: route serve openai through shared mount helper + api-key middleware
2026-06-25 18:23:29 +01:00
Mervin Praison 5443840122 Merge pull request #2273 from MervinPraison/claude/issue-2272-20260625-0810
fix: remove shadowed state, dead framework loaders, and scope async bridge shutdown
2026-06-25 18:20:12 +01:00
Mervin Praison e565e57321 Merge pull request #2278 from MervinPraison/claude/issue-2275-20260625-0843
fix: provider-aware default model so a no-config first run uses whichever provider credential is present
2026-06-25 18:19:53 +01:00
Mervin Praison 5cbd015bd5 Merge pull request #2286 from MervinPraison/claude/issue-2285-20260625-0944
fix: resolve friendly aliases for proactive/scheduled delivery
2026-06-25 18:19:49 +01:00
Mervin Praison 42d3a8e68b Merge pull request #2287 from MervinPraison/claude/issue-2283-20260625-0943
fix: unify webhook signature verification (protocol + central HMAC helper)
2026-06-25 18:19:26 +01:00
Mervin Praison 5f4723d71f Merge pull request #2292 from MervinPraison/claude/issue-2289-20260625-1610
fix: extract shared SQLite conversation-store base to remove duplication
2026-06-25 18:18:51 +01:00
Mervin Praison ffdc546f54 Merge pull request #2277 from MervinPraison/claude/issue-2274-20260625-0843
fix: first-class deterministic session resume for CLI
2026-06-25 18:18:25 +01:00
MervinPraisonandCursor 59c960b607 Do not block merge gate on issue-only claude.yml runs.
Ignore in-progress claude.yml workflows triggered by issues events so PR merge gate is not held up by unrelated issue triage.

Co-authored-by: Cursor <[email protected]>
2026-06-25 18:15:58 +01:00
praisonai-triage-agent[bot]andMervin Praison 0367bd0377 fix: correct SQLite edge-case bugs in shared conversation-store base
- delete_messages([]) is now a no-op instead of wiping the whole session
- JSON state/metadata/tool_calls round-trip empty {}/[] via is-not-None checks
- list_sessions filters preserve explicit empty-string user_id/agent_id
- enforce PRAGMA foreign_keys = ON on every sqlite.py thread-local connection
  and on the async_sqlite.py connection
- _connection rolls back on exception to avoid stale thread-local transaction state
- mark abstract _connection hook with @abstractmethod (no yield-less contextmanager)

Co-authored-by: Mervin Praison <[email protected]>
2026-06-25 16:29:36 +00:00
praisonai-triage-agent[bot]andMervin Praison 21c1825350 fix: validate JSON bodies and return non-2xx on /v1/models failure
Address reviewer feedback (Greptile, CodeRabbit) on PR #2291:
- /v1/models now returns 400 on provider failure instead of masking
  it behind a 200, matching the other mounted routes.
- Add _read_json_object helper so malformed or non-object JSON bodies
  return an OpenAI-style 400 instead of an unhandled 500.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-25 16:26:55 +00:00
praisonai-triage-agent[bot]andMervin Praison e80745570f fix: prefix-precise provider gate for google/gemini + cover o3/o4 reasoning models
Closes the remaining gate/resolver mismatch found by Greptile:
- google/ now requires GOOGLE_API_KEY only and gemini/ requires GEMINI_API_KEY
  only, matching env._PROVIDER_MAP exactly so the gate never passes with a key
  the resolver won't read.
- OpenAI o3/o4 reasoning families are now provider-scoped like o1/gpt.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-25 16:21:50 +00:00
praisonai-triage-agent[bot]andMervin Praison 4665428009 fix: agent_name metadata fallback in rehydrate_session (#2274)
Resolve the remaining Greptile robustness note: rehydrate_session now
falls back to metadata.get('agent_name') when the SessionData attribute
is unset, mirroring how model is resolved. Defensive for sessions where
agent_name only lives in metadata.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-25 16:21:12 +00:00
praisonai-triage-agent[bot]andMervinPraison 11d882142f fix: extract shared SQLite conversation-store base to remove duplication (fixes #2289)
Both synchronous SQLite ConversationStore backends re-implemented the same
schema + CRUD. Introduce _sqlite_base._SQLiteConversationStoreBase as the
single owner of the SQLite DDL and CRUD logic (mirroring _sql_base.py). The two
sync stores now subclass it and provide only their connection-acquisition
strategy via a _connection() context manager:

- SQLiteConversationStore: shared thread-local connection (check_same_thread)
- SyncSQLiteConversationStore: fresh per-call connection guarded by an RLock

Behaviour, registry names/aliases and factory mode routing are unchanged.
async_sqlite.py, _sql_base.py and the Postgres/MySQL stores are untouched.

Co-authored-by: MervinPraison <[email protected]>
2026-06-25 16:15:00 +00:00
praisonai-triage-agent[bot]andMervinPraison e9f0a80941 fix: route serve openai through shared mount helper + api-key middleware (fixes #2290)
Move the inline FastAPI /v1/* route + SSE wiring out of the CLI command
layer into a reusable mount_provider_routes() helper in endpoints/server.py,
and apply the shared _install_api_key_middleware on that path so
`praisonai serve openai --api-key X` actually gates requests like
cmd_agents/cmd_unified do.

Co-authored-by: MervinPraison <[email protected]>
2026-06-25 16:13:31 +00:00
Mervin Praison ed25de5207 Merge pull request #2288 from MervinPraison/claude/issue-2284-20260625-0944
fix: expose core conversation-control primitives in chat command surface
2026-06-25 17:00:48 +01:00
MervinPraisonandCursor 21b654db58 Fix merge gate verify step after PR branch checkout.
Stash merge-gate.js before checking out the PR branch so verdict verification still loads the helper script.

Co-authored-by: Cursor <[email protected]>
2026-06-25 16:57:52 +01:00
MervinPraisonandCursor f1182025c3 Fix merge gate false blocks and failed scheduled assess.
Ignore merge-gate diagnostic comments in @claude cooldown, use agent mode for schedule/dispatch assess, and always remove claude-merge-gate-active after assess.

Co-authored-by: Cursor <[email protected]>
2026-06-25 16:54:55 +01:00
praisonai-triage-agent[bot]andMervin Praison 8d0192521c fix: harden verify_hmac and type the webhook verifier gate
- verify_hmac now fails closed on unknown/invalid digest algorithms
  (catch ValueError/TypeError from hmac.new) instead of propagating a 500
- enforce_webhook_verification.verifier is typed via a local
  _WebhookVerifier Protocol instead of Optional[object] for static checking
- document the asymmetric prefix handling in verify_hmac's docstring
- add regression test for unknown-digest fail-closed behaviour

Addresses Greptile review findings on PR #2287.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-25 11:03:52 +00:00
praisonai-triage-agent[bot]andMervin Praison a8f3f6da43 fix: remove dead handle_retry_command imports from bot adapters
The retry flow is correctly inlined in each adapter via get_last_user_message
+ session.chat(), so the handle_retry_command import was unused (flagged by
Greptile). The helper remains in _commands.py and is covered by unit tests.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-25 11:02:56 +00:00
MervinPraisonandCursor 188e1e1361 Improve merge gate robustness and operator UX after validation.
Fix stale-FINAL recovery when @claude cooldown would block re-review, add scheduled recovery, skip secret scans in tests, post PR diagnostics on single-PR gate runs, and require successful assess before merge.

Co-authored-by: Cursor <[email protected]>
2026-06-25 11:59:22 +01:00
MervinPraisonandCursor 6a5e83684d Harden merge gate with diff guardrails and stale FINAL recovery.
Block auto-merge on bot CHANGES_REQUESTED, sensitive paths, secrets, oversized PRs, SDK changes without tests or test CI; re-post @claude when FINAL review is stale after new commits.

Co-authored-by: Cursor <[email protected]>
2026-06-25 11:56:24 +01:00
MervinPraisonandCursor 8bb9c1bcbd Add Agent param bloat guardrails to Claude prompts and merge gate.
Block auto-merge when agent.py grows beyond 100 lines or adds new __init__ params, and teach all auto-posted Claude prompts to avoid unnecessary Agent class parameters.

Co-authored-by: Cursor <[email protected]>
2026-06-25 11:53:01 +01:00
MervinPraisonandCursor fa62faf486 Add Claude PR merge gate workflow and tighten claude.yml review triggers.
Automated merge readiness assessment runs separately from the @claude review chain, merges via GH_TOKEN without gh pr review, and skips conflict/active-review windows. claude.yml now requires @claude in PR review bodies to avoid spurious runs from Qodo/Copilot.

Co-authored-by: Cursor <[email protected]>
2026-06-25 11:44:00 +01:00
praisonai-triage-agent[bot]andMervin Praison 54533d25dd fix: address reviewer findings in conversation-control commands
- /sessions: scope to requesting user (no longer leaks other users' storage keys); prefer user-scoped list_sessions(user_id) contract
- /resume: stop claiming success when no resume primitive exists; report honestly that switching is unsupported
- /retry: actually re-dispatch the last user message through session.chat() in telegram/discord/slack instead of echoing it
- /reasoning: add is_reasoning_visible() consultation point and accurate messaging
- add get_last_user_message() helper; update tests (20 passed)

Co-authored-by: Mervin Praison <[email protected]>
2026-06-25 10:06:25 +00:00
praisonai-triage-agent[bot]andMervin Praison 5e8edda5ff fix: address reviewer feedback on webhook verification (#2287)
- Rename WebhookVerifier -> WebhookVerifierProtocol to match SDK protocol
  naming convention (CodeRabbit)
- AgentMail: stop falling back to API token as the webhook signing secret so
  the insecure-webhooks gate works as intended (CodeRabbit security)
- webhook_security: sort __all__ (RUF022) and suppress intentional fail-closed
  broad catch (BLE001)
- Add tests for verify_hmac, HmacWebhookVerifier, enforce_webhook_verification,
  and capability/protocol round-trip

Co-authored-by: Mervin Praison <[email protected]>
2026-06-25 10:03:26 +00:00
praisonai-triage-agent[bot]andMervin Praison e9dc646170 fix: guard malformed alias directory results and correct annotations
Address reviewer feedback on PR #2286:
- Guard non-2-tuple directory results in DeliveryResolver._resolve_alias so
  broken directories stay on the fallback path (CodeRabbit/Qodo)
- Remove stale `# pragma: no cover` on the except block now exercised by tests
  (Greptile)
- Correct _resolve_alias docstring to describe the real fallback behaviour
  (Greptile)
- Add test for malformed directory result fallback

Co-authored-by: Mervin Praison <[email protected]>
2026-06-25 10:02:09 +00:00
praisonai-triage-agent[bot]andMervinPraison 53efb7c92a fix: expose core conversation-control primitives in chat command surface (fixes #2284)
Wire existing core primitives onto the shared bot command registry so chat
users can rewind, list/resume sessions, retry, and toggle reasoning:
- /undo  -> Agent.undo()
- /sessions, /resume <id> -> session listing/resume
- /retry -> re-run last user message
- /reasoning -> toggle extended-thinking visibility

Adds shared handlers in bots/_commands.py and dispatch across Telegram,
Discord, and Slack. Backward-compatible (permissive when no policy set) and
gated by the existing CommandAccessPolicy.

Co-authored-by: MervinPraison <[email protected]>
2026-06-25 09:50:14 +00:00
praisonai-triage-agent[bot]andMervinPraison 0f072951f6 fix: unify webhook signature verification with core protocol + shared HMAC helper (fixes #2283)
Core declares the contract: WebhookVerifier protocol and
accepts_webhooks / verifies_webhook_signature flags on PlatformCapabilities.

Wrapper provides a shared, constant-time verify_hmac() helper, a reusable
HmacWebhookVerifier, and a fail-closed enforce_webhook_verification() gate.
Refactored WhatsApp, Linear, and AgentMail adapters to use the shared helper
instead of duplicating crypto, and made Linear's unset-secret path fail-closed.

Co-authored-by: MervinPraison <[email protected]>
2026-06-25 09:48:42 +00:00
praisonai-triage-agent[bot]andMervinPraison 8477dbf497 fix: resolve friendly aliases for proactive/scheduled delivery (fixes #2285)
Extend the gateway DeliveryResolver to optionally accept a channel
directory so scheduled/agent-initiated deliveries can target multiple
channels per platform by friendly name (e.g. "family", "ops"), falling
back to the existing single home channel for full backward compatibility.

Co-authored-by: MervinPraison <[email protected]>
2026-06-25 09:48:19 +00:00
praisonai-triage-agent[bot]andMervin Praison 16751fd04f fix: align is_configured gate with resolver for stored creds + reviewer nits
- credentials.py: when no model is passed, defer to
  resolve_llm_endpoint_with_credentials() before rejecting so a stored
  non-OpenAI credential satisfies the gate (fixes gate/resolver
  disagreement). Explicit models stay strictly provider-scoped.
- env.py: clarify default_model_for_available_provider docstring to match
  the intentional best-effort (non-dropping) catalogue validation.
- init.py: remove dead _DEFAULT_MODEL import/assignment; derive provider
  credential keys from env._PROVIDER_DEFAULTS (single source of truth).
- tests: make the no-credentials case deterministic and add regression
  coverage for the stored-credential deferral path.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-06-25 09:05:56 +00:00
praisonai-triage-agent[bot]andMervin Praison 3fd37b5bc6 fix: cross-store session resume parity + deterministic metadata (#2274)
Address reviewer findings (Greptile, CodeRabbit):
- run.py: resume now resolves sessions via shared session_exists_anywhere
  (project store then global store), so globally-stored sessions can be
  continued with a prompt instead of hard-exiting.
- project_sessions.py: persist agent_name unconditionally and extract model
  name from object-based LLM configs, keeping resume deterministic.
- session.py: avoid emitting two top-level outputs in --json mode when a
  prompt continuation follows (restore blob only when no prompt).
- tests: cover project-store path, project->global fallback, and
  session_exists_anywhere across both stores.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-06-25 09:05:24 +00:00
praisonai-triage-agent[bot]andMervinPraison 4d89c7bad0 fix: provider-aware default model for no-config first run (fixes #2275)
When no model is specified, the CLI now derives the default from whichever
supported provider credential is actually present instead of always using
the hard-coded OpenAI default. Adds default_model_for_available_provider()
in llm/env.py, wires it into resolve_llm_endpoint's fallback, tightens
is_configured() so the gate agrees with the inferred provider/model, and
makes `praisonai init` scaffold the detected provider's default (with a
placeholder + hint when none is detected).

Co-authored-by: MervinPraison <[email protected]>
2026-06-25 08:51:25 +00:00
praisonai-triage-agent[bot]andMervinPraison 8dd6a1aa52 fix: first-class deterministic session resume for CLI (fixes #2274)
Add a single shared rehydrate_session helper backed by the core
DefaultSessionStore so that `praisonai session resume <id>` restores the
full conversational state (messages, model, agent) instead of printing a
transcript. The old transcript-only behaviour is available via --transcript.
Also persist the model/agent on the run continuity path so resume is
deterministic regardless of flags/config at resume time.

Co-authored-by: MervinPraison <[email protected]>
2026-06-25 08:48:55 +00:00
praisonai-triage-agent[bot]andMervin Praison f88bb9a2f8 fix: enforce true lock-ownership in _spawn_locked and align autogen import guard
Addresses reviewer feedback (CodeRabbit, Greptile) on PR #2273:
- _async_bridge: track lock owner thread so _spawn_locked() detects an
  external holder, not merely a free lock; move `import atexit` to top.
- auto.py: resolvable return type (Any) and explicit Optional[str] for
  recommend_pattern_llm (fixes F821/RUF013).
- cli/main.py: guard the bare `import autogen` with the adapter install
  hint so a v4/AG2-only env surfaces an actionable ImportError.
- tests: drop imports of removed symbols; assert canonical helper identity;
  add foreign-lock-holder regression test.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-25 08:32:52 +00:00
praisonai-triage-agent[bot]andMervinPraison 4e4654600a fix: remove shadowed state, dead loaders, and scope async bridge shutdown (fixes #2272)
Gap 2a: collapse the duplicate `_models_cache`/`_models_lock` declarations in
auto.py to a single authoritative block (the second silently rebound both).

Gap 2b: scope the process-global AsyncBridge so one caller can no longer tear it
down for everyone. The shared default is now a single stable `_BG` instance whose
lifecycle is owned solely by an atexit hook; the public `shutdown()` is removed
from the module surface. `_spawn_locked()` now enforces its caller-holds-lock
contract. Embedders needing explicit lifecycle own their own `AsyncBridge`.

Gap 3: delete the dead, hand-rolled framework loaders in auto.py
(`_get_crewai`/`_get_autogen`/`_get_autogen_v4`/`_get_praisonai`) that bypassed
the FrameworkAdapter protocol, and route cli/main.py's `_get_autogen` through the
canonical adapter registry so it honours entry-point-registered adapters.

Also fixes a pre-existing import-time NameError (`PatternRecommendation` used as a
class-body annotation outside its closure) that prevented auto.py from importing
at all, and updates the related unit tests.

Co-authored-by: MervinPraison <[email protected]>
2026-06-25 08:18:05 +00:00
us 52faf97c7f docs: add JSDoc to crwScrape and crwCrawl public APIs 2026-06-23 01:32:15 +03:00
us 03dd1bf032 feat: add fastCRW tool 2026-06-23 01:32:15 +03:00
praisonai-triage-agent[bot]andMervin Praison d0064a4e70 fix: Remove redundant comment violating PEP 257
- Removed non-idiomatic comment placed between function signature and docstring
- Comment was redundant with the docstring content
- Fixes violation of PEP 257 which requires docstring as first statement

Co-authored-by: Mervin Praison <[email protected]>
2026-06-19 22:51:56 +00:00
Anil Chandra Naidu Matcha 376214d181 feat: add MuAPI image generation provider example 2026-06-17 11:07:33 +05:30
praisonai-triage-agent[bot]andMervinPraison 854eaaf05c feat: add comment to shell_tools.py execute_command method
Add '# shell command execution' comment at the top of the execute_command
method docstring as requested in issue #1341.

Co-authored-by: MervinPraison <[email protected]>
2026-04-09 16:08:45 +00:00
161 changed files with 15863 additions and 1769 deletions

No files matched your search

+36 -2
View File
@@ -227,9 +227,10 @@ runs:
2. Wrapper (praisonai/): CLI, heavy implementations, optional dependencies.
3. Tools (PraisonAI-Tools): Specific integrations, external tools (e.g. SurrealDB, Slack), and community extensions MUST go to the `MervinPraison/PraisonAI-Tools` repository. If the feature is a tool, clone `https://github.com/MervinPraison/PraisonAI-Tools`, implement it there, and create the PR using `gh pr create -R MervinPraison/PraisonAI-Tools`.
- Does it duplicate existing functionality? Check if Agent already supports this via existing params (reflection, planning, tools, hooks, memory).
- Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.
- Does it inherit from Agent properly? New agent types MUST inherit Agent, not wrap it with composition.
- Does it add new dependencies? Only optional deps allowed, must be lazy-imported.
- Will agent.py grow larger? If the change adds >50 lines to agent.py, find a way to extract instead.
- Will agent.py grow larger? If the change adds >50 lines to agent.py, find a way to extract instead. PRs with >100 lines added to agent.py require manual review (merge gate will not auto-merge).
- Is there a name collision with existing exports in __init__.py?
If ANY of these conceptual checks fail (excluding routing), add a comment to the issue explaining why and close it. Do NOT create a PR.
@@ -260,16 +261,24 @@ runs:
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ inputs.claude_code_oauth_token }}
GITHUB_TOKEN: ${{ inputs.github_token }}
INPUT_MODEL: ${{ inputs.model }}
INPUT_MAX_TURNS: ${{ inputs.max_turns }}
INPUT_ALLOWED_TOOLS: ${{ inputs.allowed_tools }}
INPUT_DISALLOWED_TOOLS: ${{ inputs.disallowed_tools }}
INPUT_TIMEOUT_MINUTES: ${{ inputs.timeout_minutes }}
CLAUDE_CODE_SUBAGENT_MODEL: inherit
ANTHROPIC_DEFAULT_SONNET_MODEL: claude-sonnet-4-6
run: |
echo "🚀 Running Claude Code with GitHub context..."
# Build command arguments
CMD_ARGS=("-p" "--verbose" "--output-format" "stream-json")
if [[ -n "$INPUT_MODEL" ]]; then
CMD_ARGS+=("--model" "$INPUT_MODEL")
export ANTHROPIC_MODEL="$INPUT_MODEL"
fi
# Add max turns if specified
if [[ -n "$INPUT_MAX_TURNS" ]]; then
CMD_ARGS+=("--max-turns" "$INPUT_MAX_TURNS")
@@ -280,11 +289,36 @@ runs:
if [[ -n "$INPUT_ALLOWED_TOOLS" ]]; then
TOOLS="$TOOLS,$INPUT_ALLOWED_TOOLS"
fi
# Remove any explicitly disallowed tools from the effective allow-list so
# the read-only contract is enforced deterministically (no reliance on CLI
# precedence between --allowedTools and --disallowedTools).
if [[ -n "$INPUT_DISALLOWED_TOOLS" ]]; then
DISALLOWED_CSV=$(echo "$INPUT_DISALLOWED_TOOLS" | tr ',\n' ' ' | xargs)
FILTERED=""
IFS=',' read -ra TOOL_ARR <<< "$TOOLS"
for tool in "${TOOL_ARR[@]}"; do
tool=$(echo "$tool" | xargs)
[[ -z "$tool" ]] && continue
skip="false"
for bad in $DISALLOWED_CSV; do
if [[ "$tool" == "$bad" ]]; then
skip="true"
break
fi
done
if [[ "$skip" == "false" ]]; then
FILTERED="${FILTERED:+$FILTERED,}$tool"
fi
done
TOOLS="$FILTERED"
fi
CMD_ARGS+=("--allowedTools" "$TOOLS")
# Add disallowed tools
if [[ -n "$INPUT_DISALLOWED_TOOLS" ]]; then
CMD_ARGS+=("--disallowedTools" "$INPUT_DISALLOWED_TOOLS")
DISALLOWED_FLAG=$(echo "$INPUT_DISALLOWED_TOOLS" | tr '\n' ',' | sed 's/,*$//')
CMD_ARGS+=("--disallowedTools" "$DISALLOWED_FLAG")
fi
echo "📝 Executing Claude Code with prompt from file..."
@@ -0,0 +1,34 @@
#!/usr/bin/env node
const chain = require('./bot-pr-review-chain.js');
let failed = 0;
function assert(name, cond) {
if (!cond) {
console.error('FAIL:', name);
failed++;
} else {
console.log('ok:', name);
}
}
assert('chainKickPosted requires both markers', chain.chainKickPosted([
{ user: { login: 'MervinPraison' }, body: '@coderabbitai review' },
{ user: { login: 'MervinPraison' }, body: '/review' },
]));
assert('chainKickPosted false with only coderabbit', !chain.chainKickPosted([
{ user: { login: 'MervinPraison' }, body: '@coderabbitai review' },
]));
assert('coderabbitKickPosted detects coderabbit', chain.coderabbitKickPosted([
{ user: { login: 'MervinPraison' }, body: '@coderabbitai review' },
]));
assert('qodoKickPosted detects qodo', chain.qodoKickPosted([
{ user: { login: 'github-actions[bot]' }, body: '/review' },
]));
assert('ignores coderabbit bot comment', !chain.coderabbitKickPosted([
{ user: { login: 'coderabbitai[bot]' }, body: '@coderabbitai review' },
]));
assert('bot PR author match', chain.isBotOpenedPr({ user: { login: 'praisonai-triage-agent[bot]' } }));
assert('generic bot type match', chain.isBotOpenedPr({ user: { login: 'my-bot', type: 'Bot' } }));
assert('human author excluded', !chain.isBotOpenedPr({ user: { login: 'MervinPraison', type: 'User' } }));
process.exit(failed ? 1 : 0);
+153
View File
@@ -0,0 +1,153 @@
/**
* Idempotent CodeRabbit/Qodo kick for bot-opened PRs.
* @see .github/workflows/auto-pr-comment.yml, claude.yml, bot-pr-recovery.yml
*/
const KICK_AUTHORS = new Set(['MervinPraison', 'github-actions[bot]']);
const BOT_PR_AUTHORS = new Set(['praisonai-triage-agent[bot]', 'github-actions[bot]']);
async function listAllComments(github, owner, repo, issueNumber) {
if (typeof github.paginate === 'function') {
return github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: issueNumber,
per_page: 100,
});
}
const { data } = await github.rest.issues.listComments({
owner,
repo,
issue_number: issueNumber,
per_page: 100,
});
return data;
}
function kickAuthored(comment, marker) {
return (
KICK_AUTHORS.has(comment.user?.login) &&
(comment.body || '').includes(marker)
);
}
function coderabbitKickPosted(comments) {
return comments.some((c) => kickAuthored(c, '@coderabbitai review'));
}
function qodoKickPosted(comments) {
return comments.some((c) => kickAuthored(c, '/review'));
}
function chainKickPosted(comments) {
return coderabbitKickPosted(comments) && qodoKickPosted(comments);
}
function isBotOpenedPr(pr) {
if (BOT_PR_AUTHORS.has(pr.user?.login)) return true;
return pr.user?.type === 'Bot';
}
async function kickReviewChain(github, owner, repo, prNumber, core, preFetchedComments = null) {
const comments = preFetchedComments || await listAllComments(github, owner, repo, prNumber);
const needCoderabbit = !coderabbitKickPosted(comments);
const needQodo = !qodoKickPosted(comments);
if (!needCoderabbit && !needQodo) {
core?.info?.(`Review chain already kicked on PR #${prNumber}`);
return { kicked: false, reason: 'already_kicked' };
}
if (needCoderabbit) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: '@coderabbitai review',
});
}
if (needQodo) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: '/review',
});
}
core?.info?.(`Kicked review chain for PR #${prNumber} (coderabbit=${needCoderabbit}, qodo=${needQodo})`);
return { kicked: true, coderabbit: needCoderabbit, qodo: needQodo };
}
async function findOpenPrForIssue(github, owner, repo, issueNumber) {
const prefix = `claude/issue-${issueNumber}-`;
const { data: prs } = await github.rest.pulls.list({
owner,
repo,
state: 'open',
sort: 'created',
direction: 'desc',
per_page: 30,
});
return (
prs.find(
(p) => (p.head?.ref || '').startsWith(prefix) && isBotOpenedPr(p)
) || null
);
}
async function kickReviewChainForIssue(github, owner, repo, issueNumber, core) {
const pr = await findOpenPrForIssue(github, owner, repo, issueNumber);
if (!pr) {
core?.info?.(`No open PR for issue #${issueNumber}, skipping review kick`);
return { kicked: false, reason: 'no_pr' };
}
const result = await kickReviewChain(github, owner, repo, pr.number, core);
return { ...result, prNumber: pr.number };
}
async function recoverStalledBotPrs(github, owner, repo, options, core) {
const { prNumber = null, minAgeMs = 10 * 60 * 1000, maxRecover = 10 } = options || {};
let prs;
if (prNumber) {
const { data } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
prs = [data];
} else if (typeof github.paginate === 'function') {
prs = await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: 'open',
per_page: 100,
});
} else {
const { data } = await github.rest.pulls.list({ owner, repo, state: 'open', per_page: 100 });
prs = data;
}
const cutoff = Date.now() - minAgeMs;
let recovered = 0;
for (const pr of prs) {
if (recovered >= maxRecover) break;
if (!isBotOpenedPr(pr)) continue;
if (!prNumber && new Date(pr.created_at).getTime() > cutoff) continue;
const comments = await listAllComments(github, owner, repo, pr.number);
if (chainKickPosted(comments)) continue;
await kickReviewChain(github, owner, repo, pr.number, core, comments);
recovered += 1;
}
core?.info?.(`Recovery complete (${recovered} PR(s) kicked)`);
return recovered;
}
module.exports = {
KICK_AUTHORS,
BOT_PR_AUTHORS,
listAllComments,
chainKickPosted,
coderabbitKickPosted,
qodoKickPosted,
isBotOpenedPr,
kickReviewChain,
findOpenPrForIssue,
kickReviewChainForIssue,
recoverStalledBotPrs,
};
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env node
/**
* Local self-test for merge-gate.js heuristics (no GitHub API).
* Run: node .github/scripts/merge-gate-selftest.js
*/
const mg = require('./merge-gate.js');
let failed = 0;
function assert(name, cond) {
if (!cond) {
console.error('FAIL:', name);
failed++;
} else {
console.log('ok:', name);
}
}
// Stale FINAL: push after FINAL, no @claude since head
const finals = [
{ user: { login: 'github-actions[bot]' }, body: '@claude FINAL architecture reviewer', created_at: '2026-06-12T08:00:00Z' },
];
assert('stale when head after final', mg.isStaleFinalAfterPush(finals, '2026-06-12T09:00:00Z'));
const withRecovery = [
...finals,
{ user: { login: 'github-actions[bot]' }, body: '@claude FINAL architecture reviewer', created_at: '2026-06-12T09:30:00Z' },
];
assert('not stale when @claude after head', !mg.isStaleFinalAfterPush(withRecovery, '2026-06-12T09:00:00Z'));
// Bot CHANGES_REQUESTED then APPROVE
const reviews = [
{ user: { login: 'coderabbit[bot]', type: 'Bot' }, state: 'CHANGES_REQUESTED', submitted_at: '2026-06-12T08:00:00Z' },
{ user: { login: 'coderabbit[bot]', type: 'Bot' }, state: 'APPROVED', submitted_at: '2026-06-12T09:00:00Z' },
];
assert('bot approve clears CR', !mg.hasAnyChangesRequested(reviews));
assert('human CR blocks', mg.hasAnyChangesRequested([
{ user: { login: 'MervinPraison', type: 'User' }, state: 'CHANGES_REQUESTED', submitted_at: '2026-06-12T09:00:00Z' },
]));
// Verdict after HEAD
const verdictComments = [
{ body: 'MERGE_GATE_VERDICT: APPROVE', created_at: '2026-06-12T08:00:00Z' },
];
assert('verdict before head rejected', mg.findMergeGateVerdict(verdictComments, null, '2026-06-12T09:00:00Z') === null);
assert('verdict after head accepted', mg.findMergeGateVerdict(
[{ body: 'MERGE_GATE_VERDICT: APPROVE', created_at: '2026-06-12T10:00:00Z' }],
null,
'2026-06-12T09:00:00Z'
) === 'APPROVE');
const noise = [{ user: { login: 'MervinPraison' }, body: '**Merge gate scan** — wait for `@claude`', created_at: new Date().toISOString() }];
assert('diagnostic comment not a trigger', !mg.hasRecentClaudeTrigger(noise, 35));
// Sensitive + secrets
assert('workflow path sensitive', mg.sensitivePathReasons([{ filename: '.github/workflows/foo.yml' }]).length === 1);
assert('ci-only label exempts workflows', mg.sensitivePathReasons(
[{ filename: '.github/workflows/foo.yml' }],
[mg.WORKFLOW_ONLY_LABEL]
).length === 0);
assert('ci-only label not exempt mixed changes', mg.sensitivePathReasons(
[{ filename: '.github/workflows/foo.yml' }, { filename: 'src/foo.py' }],
[mg.WORKFLOW_ONLY_LABEL]
).length === 1);
assert('secret in patch', mg.secretScanReasons([{ filename: 'x.py', patch: '+key = "ghp_abcdefghijklmnopqrstuvwxyz1234567890"' }]).length === 1);
// Claude run scoping — other PR branches must not block
assert('other branch claude does not block', !mg.hasBlockingClaudeRunForPr(
[{ event: 'issue_comment', head_branch: 'other-branch' }],
'my-branch'
));
assert('same branch claude blocks', mg.hasBlockingClaudeRunForPr(
[{ event: 'issue_comment', head_branch: 'my-branch' }],
'my-branch'
));
assert('issues event never blocks', !mg.hasBlockingClaudeRunForPr(
[{ event: 'issues', head_branch: 'my-branch' }],
'my-branch'
));
// Conflict rebase clears after bot completion + FINAL on HEAD
const conflictTrigger = {
user: { login: 'MervinPraison' },
body: '@claude this PR has merge conflicts with main. Please rebase',
created_at: '2026-06-26T10:30:38Z',
};
const rebaseDone = {
user: { login: 'praisonai-triage-agent[bot]' },
body: 'Rebase complete — PR #2308 onto latest main',
created_at: '2026-06-26T10:30:55Z',
};
const finalAfterRebase = {
user: { login: 'MervinPraison' },
body: '@claude You are the FINAL architecture reviewer.',
created_at: '2026-06-26T10:39:43Z',
};
const headAfterRebase = '2026-06-26T10:39:25Z';
const rebaseComments = [conflictTrigger, rebaseDone, finalAfterRebase];
assert('conflict blocks before rebase done', mg.hasRecentConflictComment([conflictTrigger], headAfterRebase));
assert('conflict clears after rebase + FINAL on HEAD', !mg.hasRecentConflictComment(rebaseComments, headAfterRebase));
assert('conflict still blocks without FINAL on HEAD', mg.hasRecentConflictComment(
[conflictTrigger, rebaseDone],
headAfterRebase
));
// Tests heuristic
assert('sdk without tests', mg.missingTestsReason([{ filename: 'src/praisonai-agents/a/b.py', additions: 3 }]) !== null);
assert('sdk with tests ok', mg.missingTestsReason([
{ filename: 'src/praisonai-agents/a/b.py', additions: 3 },
{ filename: 'src/praisonai-agents/tests/test_x.py', additions: 10 },
]) === null);
// PR size
assert('large PR blocked', mg.prSizeReasons([{ additions: 900 }]).length > 0);
assert('internal PR link accepted', mg.isInternalPullRequestLink(
{ base: { repo: { full_name: 'MervinPraison/PraisonAI' } } },
'MervinPraison',
'PraisonAI'
));
assert('fork sync PR link rejected', !mg.isInternalPullRequestLink(
{ number: 21, base: { repo: { full_name: 'Milkmange/PraisonAI' } } },
'MervinPraison',
'PraisonAI'
));
process.exit(failed ? 1 : 0);
+747
View File
@@ -0,0 +1,747 @@
/**
* Shared merge-gate helpers for claude-merge-gate.yml
* @see .github/workflows/claude-merge-gate.yml
*/
const CLAUDE_TRIGGER_LOGINS = ['MervinPraison', 'github-actions[bot]'];
const AUTO_ACTORS = CLAUDE_TRIGGER_LOGINS;
const CONFLICT_COOLDOWN_MS = 12 * 60 * 60 * 1000;
const CLAUDE_ACTIVE_MS = 35 * 60 * 1000;
const POST_PUSH_BUFFER_MS = 5 * 60 * 1000;
const ALLOWED_MERGE_STATES = new Set(['CLEAN', 'UNSTABLE']);
const AGENT_PY_MAX_AUTO_LINES = 100;
const PR_MAX_AUTO_ADDITIONS = 800;
const PR_MAX_AUTO_FILES = 30;
const MANUAL_ONLY_LABELS = new Set(['security', 'breaking-change', 'needs-manual-review', 'release']);
const WORKFLOW_ONLY_LABEL = 'merge-gate-ci-only';
const CI_ONLY_PATH_PREFIXES = ['.github/workflows/', '.github/actions/', '.github/scripts/merge-gate'];
const SDK_PATH_PREFIXES = ['src/praisonai-agents/', 'src/praisonai/'];
const SENSITIVE_PATH_PATTERNS = [
/^\.github\/workflows\//,
/praisonaiagents\/(auth|approval|policy|sandbox)\//,
/^src\/praisonai-agents\/pyproject\.toml$/,
/^src\/praisonai\/pyproject\.toml$/,
/\.env(\.|$)/,
/credentials\.json$/i,
];
const REQUIRED_SDK_CHECK_PATTERNS = [/test/i, /smoke/i, /core/i, /python package/i, /comprehensive/i, /optimized/i];
const SECRET_PATTERNS = [
/sk-[a-zA-Z0-9]{20,}/,
/AKIA[0-9A-Z]{16}/,
/ghp_[a-zA-Z0-9]{36,}/,
/github_pat_/,
/Bearer eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/,
/-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----/,
];
const BLOCK_LABELS = new Set([
'claude-conflict-pending',
'claude-merge-gate-active',
'no-auto-merge',
'auto-merged-by-gate',
]);
const BOT_REVIEWER_PATTERNS = [
'coderabbit',
'qodo',
'gemini',
'copilot',
'greptile',
];
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function isFinalClaudeTriggerComment(c) {
const body = (c.body || '').toLowerCase();
if (!AUTO_ACTORS.includes(c.user.login)) return false;
if (!body.includes('@claude')) return false;
if (body.includes('merge conflict')) return false;
return body.includes('final architecture reviewer') || body.includes('lead engineer');
}
function isClaudeTriggerNoise(c) {
const body = c.body || '';
if (body.includes('Merge gate scan')) return true;
if (body.includes('MERGE_GATE_VERDICT')) return true;
if (body.includes('Merged by **Claude PR merge gate**')) return true;
return false;
}
function hasRecentClaudeTrigger(comments, minutes = 35) {
const cutoff = Date.now() - minutes * 60 * 1000;
return comments.some((c) => {
if (!CLAUDE_TRIGGER_LOGINS.includes(c.user.login)) return false;
if (isClaudeTriggerNoise(c)) return false;
if (!(c.body || '').includes('@claude')) return false;
return new Date(c.created_at).getTime() > cutoff;
});
}
function isConflictRebaseTriggerComment(c) {
if (!AUTO_ACTORS.includes(c.user.login)) return false;
const body = (c.body || '').toLowerCase();
return body.includes('@claude') && body.includes('merge conflict');
}
function isConflictRebaseCompletionComment(c) {
const login = (c.user?.login || '').toLowerCase();
if (!login.includes('praisonai-triage') && !login.includes('github-actions')) return false;
const body = (c.body || '').toLowerCase();
return (
body.includes('rebase complete') ||
body.includes('rebase onto') ||
(body.includes('conflict') && body.includes('resolved'))
);
}
function conflictRebaseQuiescent(comments, headPushedAt) {
const conflictTriggers = comments.filter(isConflictRebaseTriggerComment);
if (conflictTriggers.length === 0) return true;
const latestConflict = conflictTriggers.reduce((a, b) =>
new Date(a.created_at) > new Date(b.created_at) ? a : b
);
const conflictTime = new Date(latestConflict.created_at).getTime();
const rebaseDone = comments.some(
(c) =>
new Date(c.created_at).getTime() > conflictTime && isConflictRebaseCompletionComment(c)
);
if (!rebaseDone) return false;
return finalClaudeCompletedOnSha(comments, headPushedAt);
}
function hasRecentConflictComment(comments, headPushedAt = null) {
const cutoff = Date.now() - CONFLICT_COOLDOWN_MS;
const hasRecentTrigger = comments.some((c) => {
if (!isConflictRebaseTriggerComment(c)) return false;
return new Date(c.created_at).getTime() > cutoff;
});
if (!hasRecentTrigger) return false;
if (headPushedAt && conflictRebaseQuiescent(comments, headPushedAt)) {
return false;
}
return true;
}
function isBotReviewer(login, userType) {
const lower = (login || '').toLowerCase();
if (userType === 'Bot') return true;
if (lower.endsWith('[bot]')) return true;
return ['coderabbit', 'qodo', 'gemini', 'copilot', 'greptile'].some((p) => lower.includes(p));
}
function latestReviewsByUser(reviews) {
const latestByUser = new Map();
for (const r of reviews) {
const login = r.user?.login;
if (!login) continue;
const prev = latestByUser.get(login);
if (!prev || new Date(r.submitted_at) > new Date(prev.submitted_at)) {
latestByUser.set(login, r);
}
}
return latestByUser;
}
function hasHumanChangesRequested(reviews) {
for (const [login, review] of latestReviewsByUser(reviews)) {
if (review.state !== 'CHANGES_REQUESTED') continue;
if (!isBotReviewer(login, review.user?.type)) return true;
}
return false;
}
function hasAnyChangesRequested(reviews) {
for (const [, review] of latestReviewsByUser(reviews)) {
if (review.state === 'CHANGES_REQUESTED') return true;
}
return false;
}
function hasFinalClaudeReviewTrigger(comments) {
return comments.some(isFinalClaudeTriggerComment);
}
function isStaleFinalAfterPush(comments, headPushedAt) {
if (!headPushedAt) return false;
const headTime = new Date(headPushedAt).getTime();
const finals = comments.filter(isFinalClaudeTriggerComment);
if (finals.length === 0) return true;
const latestFinal = finals.reduce((a, b) =>
new Date(a.created_at) > new Date(b.created_at) ? a : b
);
const finalTime = new Date(latestFinal.created_at).getTime();
if (headTime <= finalTime + 60000) return false;
const claudeSinceHead = comments.some((c) => {
if (!CLAUDE_TRIGGER_LOGINS.includes(c.user.login)) return false;
if (isClaudeTriggerNoise(c)) return false;
if (!(c.body || '').includes('@claude')) return false;
return new Date(c.created_at).getTime() >= headTime - 60000;
});
return !claudeSinceHead;
}
function needsStaleFinalRecovery(comments, headPushedAt) {
return (
hasFinalClaudeReviewTrigger(comments) &&
isStaleFinalAfterPush(comments, headPushedAt)
);
}
function shouldSkipFinalRecovery(comments, headPushedAt) {
const isStale = isStaleFinalAfterPush(comments, headPushedAt);
if (isStale) return false;
return hasRecentClaudeTrigger(comments, 35);
}
function finalClaudeCompletedOnSha(comments, headPushedAt) {
if (!hasFinalClaudeReviewTrigger(comments)) return false;
if (isStaleFinalAfterPush(comments, headPushedAt)) return false;
return true;
}
const FINAL_CLAUDE_REVIEW_BODY =
'@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n5. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items';
async function getMergeState(github, owner, repo, prNumber) {
const query = `
query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
mergeStateStatus
maintainerCanModify
isDraft
headRefOid
headRef { repository { nameWithOwner } }
}
}
}
`;
for (let attempt = 0; attempt < 3; attempt++) {
const result = await github.graphql(query, { owner, repo, number: prNumber });
const prGql = result.repository.pullRequest;
const status = (prGql?.mergeStateStatus || '').toUpperCase();
if (status && status !== 'UNKNOWN') {
return {
status,
isDraft: prGql.isDraft,
headRepo: prGql.headRef?.repository?.nameWithOwner,
headSha: prGql.headRefOid,
maintainerCanModify: prGql.maintainerCanModify === true,
};
}
if (attempt < 2) await sleep(10000);
}
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
return {
status: (pr.mergeable_state || '').toUpperCase(),
isDraft: pr.draft,
headRepo: pr.head.repo?.full_name,
headSha: pr.head.sha,
maintainerCanModify: pr.maintainer_can_modify === true,
};
}
async function allChecksGreenOnSha(github, owner, repo, sha, core) {
const { data } = await github.rest.checks.listForRef({
owner,
repo,
ref: sha,
per_page: 100,
});
const runs = (data.check_runs || []).filter((r) => r.head_sha === sha);
if (runs.length === 0) {
core?.info?.(`No check runs on ${sha.slice(0, 7)} — allowing (e.g. docs-only PR)`);
return true;
}
for (const run of runs) {
if (run.status !== 'completed') {
core?.info?.(`Check pending: ${run.name} (${run.status})`);
return false;
}
const ok = ['success', 'neutral', 'skipped'].includes(run.conclusion);
if (!ok) {
core?.info?.(`Check failed: ${run.name} (${run.conclusion})`);
return false;
}
}
return true;
}
function claudeRunBlocksPr(run, headRef) {
if (!run || run.event === 'issues') return false;
if (!headRef) return true;
return run.head_branch === headRef;
}
function hasBlockingClaudeRunForPr(runs, headRef) {
return (runs || []).some((r) => claudeRunBlocksPr(r, headRef));
}
async function hasInProgressClaudeAssistant(github, owner, repo, prNumber = null) {
try {
const { data } = await github.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: 'claude.yml',
status: 'in_progress',
per_page: 20,
});
const runs = (data.workflow_runs || []).filter((r) => r.event !== 'issues');
if (runs.length === 0) return false;
if (prNumber == null) return runs.length > 0;
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
const headRef = pr.head.ref;
return hasBlockingClaudeRunForPr(runs, headRef);
} catch {
return false;
}
}
function isCiOnlyChange(files) {
if (!files.length) return false;
return files.every((f) => CI_ONLY_PATH_PREFIXES.some((p) => f.filename.startsWith(p)));
}
function isInternalPullRequestLink(link, owner, repo) {
if (link?.base?.repo?.full_name === `${owner}/${repo}`) return true;
const baseUrl = link?.base?.repo?.url || '';
return baseUrl.endsWith(`/repos/${owner}/${repo}`);
}
async function resolvePrNumberFromLinkedPullRequests(github, owner, repo, linked) {
const repoFull = `${owner}/${repo}`;
const internal = (linked || []).filter((l) => isInternalPullRequestLink(l, owner, repo));
for (const link of internal) {
if (!link.number) continue;
try {
const { data } = await github.rest.pulls.get({
owner,
repo,
pull_number: link.number,
});
if (data.state === 'open' && data.base?.repo?.full_name === repoFull) {
return data.number;
}
} catch (err) {
if (err.status !== 404) throw err;
}
}
return null;
}
async function resolvePrNumberFromHeadBranch(github, owner, repo, branch) {
if (!branch || branch === 'main' || branch === 'master') return null;
const { data } = await github.rest.pulls.list({
owner,
repo,
state: 'open',
head: `${owner}:${branch}`,
per_page: 1,
});
return data[0]?.number || null;
}
async function resolvePrNumberFromWorkflowRun(github, owner, repo, workflowRun) {
const fromLinked = await resolvePrNumberFromLinkedPullRequests(
github, owner, repo, workflowRun.pull_requests
);
if (fromLinked) return fromLinked;
const fromBranch = await resolvePrNumberFromHeadBranch(
github, owner, repo, workflowRun.head_branch
);
if (fromBranch) return fromBranch;
return null;
}
async function listPullFiles(github, owner, repo, prNumber) {
if (typeof github.paginate === 'function') {
return github.paginate(github.rest.pulls.listFiles, {
owner,
repo,
pull_number: prNumber,
per_page: 100,
});
}
const { data } = await github.rest.pulls.listFiles({
owner,
repo,
pull_number: prNumber,
per_page: 100,
});
return data;
}
function countNewAgentParams(patch) {
if (!patch) return 0;
return patch
.split('\n')
.filter((l) => l.startsWith('+') && !l.startsWith('+++'))
.filter((l) => {
const t = l.slice(1).trim();
if (!t || t.startsWith('#') || t.startsWith('"""') || t.startsWith("'''")) return false;
if (/^(def|class|@|return\b|if\b|elif\b|else\b|for\b|while\b)/.test(t)) return false;
if (/^\w+\s*[:=]/.test(t) && !t.startsWith('self.')) return true;
return false;
}).length;
}
function getAgentPyChangeFromFiles(files) {
const agentFile = files.find((f) => f.filename.endsWith('praisonaiagents/agent/agent.py'));
if (!agentFile) {
return { touched: false, additions: 0, newParams: 0 };
}
return {
touched: true,
additions: agentFile.additions || 0,
newParams: countNewAgentParams(agentFile.patch || ''),
};
}
async function getAgentPyChange(github, owner, repo, prNumber) {
const files = await listPullFiles(github, owner, repo, prNumber);
return getAgentPyChangeFromFiles(files);
}
function touchesSdk(files) {
return files.some((f) => SDK_PATH_PREFIXES.some((p) => f.filename.startsWith(p)));
}
function hasManualOnlyLabel(labels) {
return labels.some((l) => MANUAL_ONLY_LABELS.has(l));
}
function sensitivePathReasons(files, labels = []) {
if (labels.includes(WORKFLOW_ONLY_LABEL) && isCiOnlyChange(files)) {
return [];
}
const reasons = [];
for (const f of files) {
if (SENSITIVE_PATH_PATTERNS.some((p) => p.test(f.filename))) {
reasons.push(`sensitive path: ${f.filename}`);
break;
}
}
return reasons;
}
function prSizeReasons(files) {
const reasons = [];
const totalAdditions = files.reduce((sum, f) => sum + (f.additions || 0), 0);
if (totalAdditions > PR_MAX_AUTO_ADDITIONS) {
reasons.push(`PR +${totalAdditions} lines (>${PR_MAX_AUTO_ADDITIONS}) requires manual review`);
}
if (files.length > PR_MAX_AUTO_FILES) {
reasons.push(`${files.length} files changed (>${PR_MAX_AUTO_FILES}) requires manual review`);
}
return reasons;
}
function missingTestsReason(files) {
const sdkAdds = files.filter(
(f) =>
f.filename.startsWith('src/praisonai-agents/') &&
f.filename.endsWith('.py') &&
!f.filename.endsWith('__init__.py') &&
(f.additions || 0) > 0
);
if (sdkAdds.length === 0) return null;
const hasTestChange = files.some(
(f) => /\/tests?\//.test(f.filename) || /test_.*\.py$/.test(f.filename) || /_test\.py$/.test(f.filename)
);
if (!hasTestChange) return 'SDK code added without test file changes — requires manual review';
return null;
}
function secretScanReasons(files) {
for (const f of files) {
if (/\/tests?\//.test(f.filename) || /test_.*\.py$/.test(f.filename)) continue;
const patch = f.patch || '';
if (!patch) continue;
for (const pattern of SECRET_PATTERNS) {
if (pattern.test(patch)) {
return [`possible secret in diff (${f.filename}) — requires manual review`];
}
}
}
return [];
}
async function sdkTestChecksReason(github, owner, repo, sha, files, core) {
if (!touchesSdk(files)) return null;
const { data } = await github.rest.checks.listForRef({
owner,
repo,
ref: sha,
per_page: 100,
});
const runs = (data.check_runs || []).filter((r) => r.head_sha === sha);
if (runs.length === 0) {
return 'SDK code changed but no CI checks on HEAD';
}
const testRuns = runs.filter((r) => REQUIRED_SDK_CHECK_PATTERNS.some((p) => p.test(r.name || '')));
if (testRuns.length === 0) {
return 'SDK code changed but no test check runs on HEAD';
}
core?.info?.(`SDK test checks on HEAD: ${testRuns.map((r) => r.name).join(', ')}`);
return null;
}
function manualReviewReasonForAgentPy(agentChange) {
if (!agentChange.touched) return null;
if (agentChange.additions > AGENT_PY_MAX_AUTO_LINES) {
return `agent.py +${agentChange.additions} lines (>${AGENT_PY_MAX_AUTO_LINES}) requires manual review`;
}
if (agentChange.newParams > 0) {
return `agent.py adds ${agentChange.newParams} new Agent param(s) — requires manual review`;
}
return null;
}
async function listAllComments(github, owner, repo, issueNumber) {
if (typeof github.paginate === 'function') {
return github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: issueNumber,
per_page: 100,
});
}
const { data } = await github.rest.issues.listComments({
owner,
repo,
issue_number: issueNumber,
per_page: 100,
});
return data;
}
async function getHeadCommitDate(github, owner, repo, prNumber) {
try {
let commits;
if (typeof github.paginate === 'function') {
commits = await github.paginate(github.rest.pulls.listCommits, {
owner,
repo,
pull_number: prNumber,
per_page: 100,
});
} else {
const { data } = await github.rest.pulls.listCommits({
owner,
repo,
pull_number: prNumber,
per_page: 100,
});
commits = data;
}
const last = commits[commits.length - 1];
return last?.commit?.committer?.date || last?.commit?.author?.date || null;
} catch {
return null;
}
}
async function loadPrContext(github, owner, repo, prNumber) {
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
const { data: issue } = await github.rest.issues.get({ owner, repo, issue_number: prNumber });
const comments = await listAllComments(github, owner, repo, prNumber);
const { data: reviews } = await github.rest.pulls.listReviews({
owner,
repo,
pull_number: prNumber,
per_page: 100,
});
const mergeState = await getMergeState(github, owner, repo, prNumber);
const headSha = mergeState.headSha || pr.head.sha;
const headCommitDate = await getHeadCommitDate(github, owner, repo, prNumber);
const headPushedAt = headCommitDate || pr.updated_at;
return {
pr,
issue,
comments,
reviews,
mergeState,
headSha,
headPushedAt,
labels: issue.labels.map((l) => l.name),
baseRepo: `${owner}/${repo}`,
};
}
async function evaluatePipelineQuiescent(github, owner, repo, prNumber, core, options = {}) {
const {
forMergeStep = false,
skipGlobalClaudeRunCheck = false,
skipRecentClaudeCooldown = false,
} = options;
const ctx = await loadPrContext(github, owner, repo, prNumber);
const reasons = [];
if (ctx.pr.draft) reasons.push('draft');
if (ctx.pr.state !== 'open') reasons.push('not open');
if (ctx.labels.includes('auto-merged-by-gate')) reasons.push('already merged by gate');
if (ctx.labels.includes('no-auto-merge')) reasons.push('no-auto-merge label');
if (ctx.labels.includes('claude-conflict-pending')) reasons.push('claude-conflict-pending');
if (!forMergeStep && ctx.labels.includes('claude-merge-gate-active')) {
reasons.push('claude-merge-gate-active');
}
const { status, headRepo, maintainerCanModify } = ctx.mergeState;
if (!ALLOWED_MERGE_STATES.has(status)) reasons.push(`mergeState=${status}`);
if (headRepo && headRepo !== ctx.baseRepo) {
reasons.push('fork PR');
}
if (hasRecentConflictComment(ctx.comments, ctx.headPushedAt)) {
reasons.push('recent merge-conflict @claude');
}
if (!skipRecentClaudeCooldown && hasRecentClaudeTrigger(ctx.comments, 35)) {
reasons.push('recent @claude within 35min');
}
if (!skipGlobalClaudeRunCheck && (await hasInProgressClaudeAssistant(github, owner, repo, prNumber))) {
reasons.push('claude.yml in progress');
}
const headTime = new Date(ctx.headPushedAt).getTime();
if (Date.now() - headTime < POST_PUSH_BUFFER_MS) reasons.push('post-push buffer (<5min)');
if (!finalClaudeCompletedOnSha(ctx.comments, ctx.headPushedAt)) {
if (!hasFinalClaudeReviewTrigger(ctx.comments)) {
reasons.push('no FINAL Claude review trigger');
} else if (isStaleFinalAfterPush(ctx.comments, ctx.headPushedAt)) {
reasons.push('stale FINAL after new commits (needs @claude re-review)');
} else {
reasons.push('FINAL Claude not complete on HEAD');
}
}
if (hasAnyChangesRequested(ctx.reviews)) reasons.push('CHANGES_REQUESTED review');
if (hasManualOnlyLabel(ctx.labels)) {
const manualLabel = ctx.labels.find((l) => MANUAL_ONLY_LABELS.has(l));
reasons.push(`manual-only label: ${manualLabel}`);
}
const pullFiles = await listPullFiles(github, owner, repo, prNumber);
const agentChange = getAgentPyChangeFromFiles(pullFiles);
const agentManual = manualReviewReasonForAgentPy(agentChange);
if (agentManual) reasons.push(agentManual);
reasons.push(...sensitivePathReasons(pullFiles, ctx.labels));
reasons.push(...prSizeReasons(pullFiles));
reasons.push(...secretScanReasons(pullFiles));
const testsReason = missingTestsReason(pullFiles);
if (testsReason) reasons.push(testsReason);
const sdkCheckReason = await sdkTestChecksReason(github, owner, repo, ctx.headSha, pullFiles, core);
if (sdkCheckReason) reasons.push(sdkCheckReason);
const checksOk = await allChecksGreenOnSha(github, owner, repo, ctx.headSha, core);
if (!checksOk) reasons.push('CI not green on HEAD');
if (ctx.pr.mergeable === false) reasons.push('not mergeable');
const ready = reasons.length === 0;
return {
ready,
reasons,
headSha: ctx.headSha,
prNumber,
};
}
async function selectMergeGateCandidates(github, owner, repo, prNumbers, maxCandidates, core) {
const readyList = [];
const skipped = [];
for (const num of prNumbers) {
const result = await evaluatePipelineQuiescent(github, owner, repo, num, core);
if (result.ready) {
readyList.push({ pr_number: num, head_sha: result.headSha });
} else {
skipped.push({ pr: num, reasons: result.reasons });
}
}
readyList.sort((a, b) => a.pr_number - b.pr_number);
return {
candidates: readyList.slice(0, maxCandidates),
skipped,
};
}
function findMergeGateVerdict(comments, minCreatedAt = null, headPushedAt = null) {
const minTime = minCreatedAt ? new Date(minCreatedAt).getTime() : 0;
const headTime = headPushedAt ? new Date(headPushedAt).getTime() - 60000 : 0;
const gateComments = comments
.filter((c) => {
if (!(c.body || '').includes('MERGE_GATE_VERDICT:')) return false;
const created = new Date(c.created_at).getTime();
if (minTime && created < minTime) return false;
if (headTime && created < headTime) return false;
return true;
})
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
if (gateComments.length === 0) return null;
const body = gateComments[0].body || '';
if (body.includes('MERGE_GATE_VERDICT: APPROVE')) return 'APPROVE';
if (body.includes('MERGE_GATE_VERDICT: BLOCK')) return 'BLOCK';
return null;
}
module.exports = {
CLAUDE_TRIGGER_LOGINS,
AUTO_ACTORS,
isFinalClaudeTriggerComment,
isClaudeTriggerNoise,
hasRecentClaudeTrigger,
hasRecentConflictComment,
isConflictRebaseTriggerComment,
isConflictRebaseCompletionComment,
conflictRebaseQuiescent,
hasHumanChangesRequested,
hasAnyChangesRequested,
hasFinalClaudeReviewTrigger,
finalClaudeCompletedOnSha,
getMergeState,
allChecksGreenOnSha,
hasInProgressClaudeAssistant,
claudeRunBlocksPr,
hasBlockingClaudeRunForPr,
isCiOnlyChange,
isInternalPullRequestLink,
resolvePrNumberFromLinkedPullRequests,
resolvePrNumberFromHeadBranch,
resolvePrNumberFromWorkflowRun,
WORKFLOW_ONLY_LABEL,
getAgentPyChange,
getAgentPyChangeFromFiles,
manualReviewReasonForAgentPy,
countNewAgentParams,
listPullFiles,
touchesSdk,
hasManualOnlyLabel,
sensitivePathReasons,
prSizeReasons,
missingTestsReason,
secretScanReasons,
sdkTestChecksReason,
listAllComments,
getHeadCommitDate,
isStaleFinalAfterPush,
needsStaleFinalRecovery,
shouldSkipFinalRecovery,
FINAL_CLAUDE_REVIEW_BODY,
loadPrContext,
evaluatePipelineQuiescent,
selectMergeGateCandidates,
findMergeGateVerdict,
};
@@ -0,0 +1,76 @@
#!/usr/bin/env node
/**
* Run: node .github/scripts/pipeline-status-selftest.js
*/
const ps = require('./pipeline-status.js');
const mg = require('./merge-gate.js');
let failed = 0;
function assert(name, cond) {
if (!cond) {
console.error('FAIL:', name);
failed++;
} else {
console.log('ok:', name);
}
}
const kickComments = [
{ user: { login: 'MervinPraison' }, body: '@coderabbitai review' },
{ user: { login: 'MervinPraison' }, body: '/review' },
];
const finalComment = {
user: { login: 'MervinPraison' },
body: '@claude You are the FINAL architecture reviewer.',
created_at: '2026-06-26T10:00:00Z',
};
assert(
'reviews pending when not kicked',
ps.deriveStage([], { ready: false, reasons: ['no FINAL Claude review trigger'] })
=== 'pipeline/reviews-pending'
);
assert(
'final pending after kick',
ps.deriveStage(kickComments, { ready: false, reasons: ['no FINAL Claude review trigger'] })
=== 'pipeline/final-claude-pending'
);
assert(
'awaiting merge gate after final',
ps.deriveStage(
[...kickComments, finalComment],
{ ready: false, reasons: ['recent @claude within 35min'] }
) === 'pipeline/awaiting-merge-gate'
);
assert(
'merge ready',
ps.deriveStage([...kickComments, finalComment], { ready: true, reasons: [] })
=== 'pipeline/merge-ready'
);
const blockers = ps.deriveBlockerLabels({
ready: false,
reasons: ['CI not green on HEAD', 'SDK code added without test file changes — requires manual review'],
});
assert('maps ci blocker', blockers.includes('pipeline/blocked:ci'));
assert('maps manual blocker', blockers.includes('pipeline/blocked:manual-review'));
assert(
'internal link matches upstream base',
mg.isInternalPullRequestLink(
{ base: { repo: { full_name: 'MervinPraison/PraisonAI' } } },
'MervinPraison',
'PraisonAI'
)
);
assert(
'fork sync link rejected',
!mg.isInternalPullRequestLink(
{ number: 21, base: { repo: { full_name: 'Milkmange/PraisonAI' } } },
'MervinPraison',
'PraisonAI'
)
);
process.exit(failed ? 1 : 0);
+202
View File
@@ -0,0 +1,202 @@
/**
* Sync pipeline stage/blocker labels on open PRs.
* @see .github/workflows/pipeline-status-sync.yml
*/
const mergeGate = require('./merge-gate.js');
const chain = require('./bot-pr-review-chain.js');
const PIPELINE_PREFIX = 'pipeline/';
const STAGE_LABELS = [
'pipeline/reviews-pending',
'pipeline/final-claude-pending',
'pipeline/awaiting-merge-gate',
'pipeline/merge-ready',
'pipeline/merged',
];
const BLOCKER_LABELS = [
'pipeline/blocked:ci',
'pipeline/blocked:conflict',
'pipeline/blocked:manual-review',
'pipeline/blocked:cooldown',
'pipeline/blocked:stale-final',
'pipeline/blocked:no-final',
];
const ALL_PIPELINE_LABELS = [...STAGE_LABELS, ...BLOCKER_LABELS];
const LABEL_SPECS = [
{ name: 'pipeline/reviews-pending', color: 'fbca04', description: 'Waiting for CodeRabbit/Qodo/Copilot reviews' },
{ name: 'pipeline/final-claude-pending', color: 'd4c5f9', description: 'Reviews done; waiting for FINAL @claude' },
{ name: 'pipeline/awaiting-merge-gate', color: 'c5def5', description: 'FINAL done; waiting for merge gate / CI' },
{ name: 'pipeline/merge-ready', color: '0e8a16', description: 'Eligible for merge gate auto-merge' },
{ name: 'pipeline/merged', color: '6f42c1', description: 'Merged via pipeline' },
{ name: 'pipeline/blocked:ci', color: 'd93f0b', description: 'Blocked: CI not green on HEAD' },
{ name: 'pipeline/blocked:conflict', color: 'b60205', description: 'Blocked: merge conflict or rebase pending' },
{ name: 'pipeline/blocked:manual-review', color: 'e99695', description: 'Blocked: requires manual review' },
{ name: 'pipeline/blocked:cooldown', color: 'fef2c0', description: 'Blocked: post-push or @claude cooldown' },
{ name: 'pipeline/blocked:stale-final', color: 'f9d0c4', description: 'Blocked: FINAL stale after new commits' },
{ name: 'pipeline/blocked:no-final', color: 'ededed', description: 'Blocked: no FINAL @claude trigger yet' },
];
function deriveStage(comments, evalResult) {
if (evalResult.ready) return 'pipeline/merge-ready';
if (!chain.chainKickPosted(comments)) return 'pipeline/reviews-pending';
if (!mergeGate.hasFinalClaudeReviewTrigger(comments)) return 'pipeline/final-claude-pending';
return 'pipeline/awaiting-merge-gate';
}
function reasonToBlockerLabel(reason) {
const r = (reason || '').toLowerCase();
if (r.includes('ci not green') || r.includes('sdk code changed but no ci')) return 'pipeline/blocked:ci';
if (
r.includes('conflict') ||
r.includes('mergestate=dirty') ||
r.includes('not mergeable')
) return 'pipeline/blocked:conflict';
if (
r.includes('manual') ||
r.includes('requires manual') ||
r.includes('sensitive path') ||
r.includes('no-auto-merge') ||
r.includes('manual-only label') ||
r.includes('without test') ||
r.includes('files changed') ||
r.includes('possible secret') ||
r.includes('agent.py')
) return 'pipeline/blocked:manual-review';
if (r.includes('recent @claude') || r.includes('post-push buffer')) return 'pipeline/blocked:cooldown';
if (r.includes('stale final')) return 'pipeline/blocked:stale-final';
if (r.includes('no final claude')) return 'pipeline/blocked:no-final';
if (r.includes('final claude not complete')) return 'pipeline/blocked:stale-final';
if (r.includes('claude.yml in progress') || r.includes('claude-merge-gate-active')) {
return null;
}
return null;
}
function deriveBlockerLabels(evalResult) {
if (evalResult.ready) return [];
const labels = new Set();
for (const reason of evalResult.reasons || []) {
const mapped = reasonToBlockerLabel(reason);
if (mapped && mapped.startsWith('pipeline/blocked:')) labels.add(mapped);
}
return [...labels];
}
function computePipelineLabels(comments, evalResult) {
const stage = deriveStage(comments, evalResult);
const blockers = stage === 'pipeline/merge-ready' ? [] : deriveBlockerLabels(evalResult);
return { stage, blockers, all: [stage, ...blockers] };
}
async function ensurePipelineLabels(github, owner, repo, core) {
let existing;
if (typeof github.paginate === 'function') {
existing = await github.paginate(github.rest.issues.listLabelsForRepo, {
owner,
repo,
per_page: 100,
});
} else {
({ data: existing } = await github.rest.issues.listLabelsForRepo({
owner,
repo,
per_page: 100,
}));
}
const names = new Set(existing.map((l) => l.name));
for (const spec of LABEL_SPECS) {
if (names.has(spec.name)) continue;
try {
await github.rest.issues.createLabel({
owner,
repo,
name: spec.name,
color: spec.color,
description: spec.description,
});
core?.info?.(`Created label ${spec.name}`);
} catch (err) {
if (err.status !== 422) throw err;
core?.info?.(`Label ${spec.name} already exists`);
}
}
}
async function syncPipelineLabels(github, owner, repo, prNumber, core) {
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
if (ctx.pr.state !== 'open') return { synced: false, reason: 'not_open' };
const evalResult = await mergeGate.evaluatePipelineQuiescent(
github, owner, repo, prNumber, core
);
const { stage, blockers, all } = computePipelineLabels(ctx.comments, evalResult);
const current = ctx.labels.filter((l) => l.startsWith(PIPELINE_PREFIX));
const desired = new Set(all);
const toRemove = current.filter((l) => !desired.has(l) && l !== 'pipeline/merged');
const toAdd = all.filter((l) => !current.includes(l));
for (const name of toRemove) {
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name });
} catch (err) {
if (err.status !== 404) throw err;
}
}
if (toAdd.length) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: prNumber,
labels: toAdd,
});
}
core?.info?.(`PR #${prNumber}: stage=${stage} blockers=[${blockers.join(', ')}]`);
return { synced: true, stage, blockers, ready: evalResult.ready, reasons: evalResult.reasons };
}
async function syncOpenPullRequests(github, owner, repo, options, core) {
const { maxPrs = 20 } = options || {};
await ensurePipelineLabels(github, owner, repo, core);
let prs;
if (maxPrs <= 100) {
({ data: prs } = await github.rest.pulls.list({
owner,
repo,
state: 'open',
per_page: 100,
}));
} else {
prs = await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: 'open',
per_page: 100,
});
}
let synced = 0;
for (const pr of prs) {
if (synced >= maxPrs) break;
if (pr.draft) continue;
if (pr.head?.repo?.full_name && pr.head.repo.full_name !== `${owner}/${repo}`) continue;
await syncPipelineLabels(github, owner, repo, pr.number, core);
synced += 1;
}
core?.info?.(`Pipeline label sync complete (${synced} PR(s))`);
return synced;
}
module.exports = {
STAGE_LABELS,
BLOCKER_LABELS,
ALL_PIPELINE_LABELS,
deriveStage,
deriveBlockerLabels,
computePipelineLabels,
ensurePipelineLabels,
syncPipelineLabels,
syncOpenPullRequests,
};
+140 -49
View File
@@ -16,6 +16,9 @@ on:
types: [submitted]
pull_request:
types: [opened, synchronize]
schedule:
- cron: '30 3,9,15,21 * * *'
workflow_dispatch:
env:
MAX_POLL_ATTEMPTS: 20
@@ -190,6 +193,7 @@ jobs:
'Review Context: ' + comments.data.length + ' total comments, ' +
(reviews.coderabbit.length + reviews.qodo.length + reviews.gemini.length + reviews.copilot.length + reviews.greptile.length) + ' bot reviews.\n\n' +
'Phase 1: Review per AGENTS.md principles (protocol-driven, backward compatible, no perf impact, SDK value gate — no feature bloat).\n' +
'Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.\n' +
'Phase 2: FIX valid issues found by prior reviewers.\n' +
'Phase 3: Provide final verdict (approve or request changes).';
await github.rest.issues.createComment({issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: claudeBody});
@@ -212,6 +216,10 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Wait for Copilot to respond (poll)
id: poll
uses: actions/github-script@v7
@@ -319,10 +327,23 @@ jobs:
issue_number: pr,
owner: context.repo.owner,
repo: context.repo.repo,
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items'
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n5. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items'
});
console.log(`Claude triggered on PR #${pr}`);
- name: Sync pipeline status labels
uses: actions/github-script@v7
env:
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
with:
github-token: ${{ secrets.GH_TOKEN }}
script: |
const path = require('path');
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
const pr = parseInt(process.env.PR_NUMBER, 10);
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
# ================================================================
# Claude after Qodo fallback path (same polling approach)
# ================================================================
@@ -338,6 +359,10 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Wait for Copilot to respond (poll)
id: poll
uses: actions/github-script@v7
@@ -426,10 +451,23 @@ jobs:
issue_number: pr,
owner: context.repo.owner,
repo: context.repo.repo,
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items'
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n5. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items'
});
console.log(`Claude triggered on PR #${pr}`);
- name: Sync pipeline status labels
uses: actions/github-script@v7
env:
PR_NUMBER: ${{ needs.copilot-after-qodo.outputs.pr_number }}
with:
github-token: ${{ secrets.GH_TOKEN }}
script: |
const path = require('path');
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
const pr = parseInt(process.env.PR_NUMBER, 10);
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
# ================================================================
# Claude after Gemini — restores the trigger lost from the deleted
# chain-claude-after-copilot.yml workflow.
@@ -480,7 +518,7 @@ jobs:
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '@claude You are the Lead Engineer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL analysis and reviews above carefully (Gemini, CodeRabbit, Qodo, Copilot, etc).\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items'
body: '@claude You are the Lead Engineer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL analysis and reviews above carefully (Gemini, CodeRabbit, Qodo, Copilot, etc).\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n5. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items'
});
# ================================================================
@@ -496,30 +534,41 @@ jobs:
issues: write
pull-requests: read
steps:
- name: Post missing FINAL Claude review after rebase
- uses: actions/checkout@v4
- name: Re-post FINAL Claude review when stale or missing
uses: actions/github-script@v7
env:
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
with:
github-token: ${{ secrets.GH_TOKEN }}
script: |
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
const path = require('path');
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
const pr = context.payload.pull_request.number;
const owner = context.repo.owner;
const repo = context.repo.repo;
const { data: comments } = await github.rest.issues.listComments({
issue_number: pr, owner, repo, per_page: 100,
});
const comments = await mergeGate.listAllComments(github, owner, repo, pr);
const headPushedAt = (await mergeGate.getHeadCommitDate(github, owner, repo, pr))
|| context.payload.pull_request.updated_at;
const hasFinalReview = comments.some(c => {
const body = (c.body || '').toLowerCase();
if (!body.includes('@claude') || !triggerLogins.includes(c.user.login)) return false;
if (body.includes('merge conflict')) return false;
return body.includes('final architecture reviewer') || body.includes('lead engineer');
});
if (hasFinalReview) {
core.info('FINAL Claude review already posted, skipping recovery');
const hasFinal = mergeGate.hasFinalClaudeReviewTrigger(comments);
const isStale = mergeGate.isStaleFinalAfterPush(comments, headPushedAt);
if (mergeGate.shouldSkipFinalRecovery(comments, headPushedAt)) {
core.info('Recent @claude within 35min and FINAL not stale — skip recovery');
return;
}
const finalBody = mergeGate.FINAL_CLAUDE_REVIEW_BODY;
if (hasFinal && isStale) {
await github.rest.issues.createComment({ issue_number: pr, owner, repo, body: finalBody });
core.info(`Posted stale-FINAL recovery @claude on PR #${pr}`);
return;
}
if (hasFinal) {
core.info('FINAL Claude review current on HEAD — skip recovery');
return;
}
@@ -533,11 +582,62 @@ jobs:
return;
}
await github.rest.issues.createComment({
issue_number: pr, owner, repo,
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items'
await github.rest.issues.createComment({ issue_number: pr, owner, repo, body: finalBody });
core.info(`Posted missing FINAL Claude review on PR #${pr}`);
- name: Sync pipeline status labels
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GH_TOKEN }}
script: |
const path = require('path');
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
const pr = context.payload.pull_request.number;
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
# Scheduled: re-post @claude when FINAL is stale (Claude pushed after last FINAL trigger)
stale-final-recovery-scheduled:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: read
steps:
- uses: actions/checkout@v4
- name: Re-post stale FINAL @claude on open internal PRs
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GH_TOKEN }}
script: |
const path = require('path');
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
const owner = context.repo.owner;
const repo = context.repo.repo;
const prs = await github.paginate(github.rest.pulls.list, {
owner, repo, state: 'open', per_page: 100,
});
core.info(`Posted recovery FINAL Claude review on PR #${pr}`);
let posted = 0;
for (const pr of prs) {
if (pr.draft) continue;
if (pr.head?.repo?.full_name && pr.head.repo.full_name !== `${owner}/${repo}`) continue;
const comments = await mergeGate.listAllComments(github, owner, repo, pr.number);
const headPushedAt = (await mergeGate.getHeadCommitDate(github, owner, repo, pr.number)) || pr.updated_at;
if (!mergeGate.needsStaleFinalRecovery(comments, headPushedAt)) continue;
if (mergeGate.hasRecentClaudeTrigger(comments, 10)) {
core.info(`PR #${pr.number}: stale FINAL but @claude within 10min — skip`);
continue;
}
await github.rest.issues.createComment({
issue_number: pr.number, owner, repo,
body: mergeGate.FINAL_CLAUDE_REVIEW_BODY,
});
posted++;
core.info(`Posted scheduled stale-FINAL recovery on PR #${pr.number}`);
if (posted >= 5) break;
}
core.info(`Scheduled stale-FINAL recovery complete (${posted} posted)`);
# ================================================================
# BOT PRs: CodeRabbit/Qodo skip bot-authored PRs by default.
@@ -556,38 +656,29 @@ jobs:
runs-on: ubuntu-latest
permissions:
pull-requests: write
issues: write
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Trigger CodeRabbit and Qodo reviews
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GH_TOKEN }}
script: |
const path = require('path');
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/bot-pr-review-chain.js'));
const pr = context.payload.pull_request.number;
const owner = context.repo.owner;
const repo = context.repo.repo;
await chain.kickReviewChain(github, context.repo.owner, context.repo.repo, pr, core);
// 1. Trigger CodeRabbit review
await github.rest.issues.createComment({
issue_number: pr, owner, repo,
body: '@coderabbitai review'
});
console.log(`Triggered CodeRabbit for bot PR #${pr}`);
// 2. Trigger Qodo review
await github.rest.issues.createComment({
issue_number: pr, owner, repo,
body: '/review'
});
console.log(`Triggered Qodo for bot PR #${pr}`);
// 3. Trigger Gemini review (commented out)
// await github.rest.issues.createComment({
// issue_number: pr, owner, repo,
// body: '@gemini review this PR'
// });
// console.log(`Triggered Gemini for bot PR #${pr}`);
// NOTE: @copilot is NOT triggered here.
// It will be triggered by copilot-after-coderabbit or copilot-after-qodo
// once those bots finish, ensuring Copilot always reviews LAST.
console.log('Copilot will be triggered after CodeRabbit/Qodo complete.');
- name: Sync pipeline status labels
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GH_TOKEN }}
script: |
const path = require('path');
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
const pr = context.payload.pull_request.number;
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
+43
View File
@@ -0,0 +1,43 @@
name: Bot PR Recovery
# Belt-and-braces: kick CodeRabbit/Qodo when pull_request:opened never fired for bot PRs.
on:
schedule:
- cron: '*/15 * * * *'
workflow_dispatch:
inputs:
pr_number:
description: 'Specific PR number to kick (optional)'
required: false
type: string
permissions:
pull-requests: write
issues: write
jobs:
recover:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Find and kick stalled bot PRs
uses: actions/github-script@v7
env:
PR_NUMBER_INPUT: ${{ github.event.inputs.pr_number }}
with:
github-token: ${{ secrets.GH_TOKEN }}
script: |
const path = require('path');
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/bot-pr-review-chain.js'));
const owner = context.repo.owner;
const repo = context.repo.repo;
const manual = (process.env.PR_NUMBER_INPUT || '').trim();
const prNumber = manual ? parseInt(manual, 10) : null;
if (manual && Number.isNaN(prNumber)) {
throw new Error(`Invalid pr_number: ${manual}`);
}
await chain.recoverStalledBotPrs(github, owner, repo, { prNumber }, core);
+474
View File
@@ -0,0 +1,474 @@
name: Claude PR Merge Gate
# Read-only Claude assesses merge readiness; GH_TOKEN merges (no @claude, no gh pr review).
# Does not overlap with merge-conflict-claude.yml or auto-pr-comment @claude chains.
on:
schedule:
- cron: '0 2,8,14,20 * * *'
workflow_dispatch:
inputs:
pr_number:
description: 'Optional PR number (scan all open internal PRs if empty)'
required: false
type: string
merge_method:
description: 'Merge method'
required: false
type: choice
default: merge
options:
- merge
- squash
- rebase
workflow_run:
workflows:
- Claude Assistant
- Core Tests
types:
- completed
repository_dispatch:
types:
- claude-merge-gate
concurrency:
group: claude-merge-gate-${{ github.repository }}-${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || github.event.workflow_run.id || 'scan' }}
cancel-in-progress: true
env:
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
MAX_CANDIDATES: 5
DISPATCH_PR_NUMBER: ${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || '' }}
permissions:
contents: read
pull-requests: read
issues: read
actions: read
jobs:
auto-dispatch:
if: github.event_name == 'workflow_run'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
actions: write
steps:
- uses: actions/checkout@v4
- name: Dispatch merge gate when PR pipeline is ready
uses: actions/github-script@v7
env:
PAT: ${{ secrets.GH_TOKEN }}
with:
github-token: ${{ secrets.GH_TOKEN }}
script: |
const path = require('path');
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
const wr = context.payload.workflow_run;
const owner = context.repo.owner;
const repo = context.repo.repo;
if (!process.env.PAT) {
core.setFailed('GH_TOKEN secret is required for merge gate auto-dispatch (needs repo + workflow scope).');
return;
}
if (wr.conclusion !== 'success') {
core.info(`Skip dispatch: ${wr.name} conclusion=${wr.conclusion}`);
return;
}
const allowed = ['Claude Assistant', 'Core Tests'];
if (!allowed.includes(wr.name)) {
core.info(`Skip dispatch: workflow ${wr.name}`);
return;
}
const prNumber = await mergeGate.resolvePrNumberFromWorkflowRun(github, owner, repo, wr);
if (!prNumber) {
core.info('Skip dispatch: could not resolve PR from workflow run');
return;
}
const fromClaude = wr.name === 'Claude Assistant';
const check = await mergeGate.evaluatePipelineQuiescent(
github, owner, repo, prNumber, core, {
skipGlobalClaudeRunCheck: fromClaude,
skipRecentClaudeCooldown: fromClaude,
}
);
if (!check.ready) {
core.info(`PR #${prNumber} not ready: ${check.reasons.join(', ')}`);
return;
}
await github.rest.repos.createDispatchEvent({
owner,
repo,
event_type: 'claude-merge-gate',
client_payload: { pr_number: prNumber },
});
core.info(`Dispatched merge gate for PR #${prNumber} after ${wr.name}`);
scan-candidates:
if: github.event_name != 'workflow_run'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: write
actions: read
outputs:
matrix: ${{ steps.scan.outputs.matrix }}
has_candidates: ${{ steps.scan.outputs.has_candidates }}
steps:
- uses: actions/checkout@v4
- name: Scan open PRs for merge gate eligibility
id: scan
uses: actions/github-script@v7
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const path = require('path');
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
const owner = context.repo.owner;
const repo = context.repo.repo;
const maxCandidates = parseInt(process.env.MAX_CANDIDATES || '5', 10);
const inputPr = String(process.env.DISPATCH_PR_NUMBER || '').trim();
const candidates = [];
const skipped = [];
if (inputPr) {
const n = parseInt(inputPr, 10);
if (Number.isNaN(n)) {
throw new Error(`Invalid pr_number input: ${inputPr}`);
}
const result = await mergeGate.evaluatePipelineQuiescent(github, owner, repo, n, core);
if (result.ready) {
candidates.push({ pr_number: n, head_sha: result.headSha });
} else {
skipped.push({ pr: n, reasons: result.reasons });
}
} else {
const prs = await github.paginate(github.rest.pulls.list, {
owner, repo, state: 'open', per_page: 100, sort: 'created', direction: 'asc',
});
const prNumbers = prs.map((p) => p.number);
const selected = await mergeGate.selectMergeGateCandidates(
github, owner, repo, prNumbers, maxCandidates, core
);
candidates.push(...selected.candidates);
skipped.push(...selected.skipped);
}
core.info('Skipped: ' + JSON.stringify(skipped));
core.setOutput('matrix', JSON.stringify({ include: candidates }));
core.setOutput('has_candidates', candidates.length > 0 ? 'true' : 'false');
core.summary.addRaw('## Merge gate scan\n');
core.summary.addRaw(`Candidates: ${candidates.length}\n`);
core.summary.addRaw('```json\n' + JSON.stringify({ candidates, skipped }, null, 2) + '\n```');
await core.summary.write();
if (inputPr && skipped.length === 1 && skipped[0].pr === parseInt(inputPr, 10)) {
const reasons = skipped[0].reasons;
const body = [
'**Merge gate scan** — not eligible for auto-merge.',
'',
...reasons.map((r) => `- \`${r}\``),
'',
'**Actions:** wait for CI and the Claude review chain, or add label `needs-manual-review` and merge manually.',
'**Opt out:** label `no-auto-merge`.',
].join('\n');
await github.rest.issues.createComment({
owner, repo, issue_number: skipped[0].pr, body,
});
} else if (inputPr && candidates.length === 1) {
await github.rest.issues.createComment({
owner, repo, issue_number: candidates[0].pr_number,
body: '**Merge gate scan** — eligible for assessment. Claude merge gate will assess and may auto-merge if `MERGE_GATE_VERDICT: APPROVE`.',
});
}
await ps.syncOpenPullRequests(github, owner, repo, { maxPrs: 20 }, core);
claude-assess:
needs: scan-candidates
if: needs.scan-candidates.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.scan-candidates.outputs.matrix) }}
permissions:
contents: read
pull-requests: read
issues: write
actions: read
id-token: write
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 }}
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
- name: Mark merge gate active
id: mark_active
uses: actions/github-script@v7
with:
github-token: ${{ env.GH_TOKEN }}
script: |
core.setOutput('started_at', new Date().toISOString());
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: ${{ matrix.pr_number }},
labels: ['claude-merge-gate-active'],
});
- name: Stash merge gate helpers
run: cp .github/scripts/merge-gate.js /tmp/merge-gate.js
- name: Fetch PR branch
env:
PR_NUMBER: ${{ matrix.pr_number }}
run: |
git fetch origin "pull/${PR_NUMBER}/head:pr-${PR_NUMBER}"
git checkout "pr-${PR_NUMBER}"
- name: Claude merge gate assessment (read-only)
id: assess
continue-on-error: true
uses: ./.github/actions/claude-code-action
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
CLAUDE_CODE_SUBAGENT_MODEL: inherit
ANTHROPIC_DEFAULT_SONNET_MODEL: claude-sonnet-4-6
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
github_token: ${{ steps.app-token.outputs.token }}
model: claude-opus-4-8
trigger_phrase: '@claude-merge-gate-internal'
direct_prompt: |
You are the MERGE GATE reviewer for PraisonAI PR #${{ matrix.pr_number }}.
READ-ONLY: do not modify code, commit, push, rebase, or create branches.
CRITICAL OUTPUT RULE:
Post exactly one PR comment via GitHub MCP containing either:
MERGE_GATE_VERDICT: APPROVE
or
MERGE_GATE_VERDICT: BLOCK
followed by a short rationale on the next lines.
NEVER include @claude in any comment. NEVER mention merge conflict resolution.
Review checklist:
1. Read AGENTS.md and the PR diff on the current branch.
2. Read all PR comments and reviews (FINAL architecture reviewer / Lead Engineer must have run).
3. Confirm SDK value gate: change strengthens praisonaiagents (no scope creep).
4. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.
5. If agent.py adds >100 lines or new Agent __init__ params, respond MERGE_GATE_VERDICT: BLOCK (manual review required).
6. BLOCK if labels security/breaking-change/needs-manual-review/release, sensitive paths (.github/workflows, auth, pyproject.toml), secrets in diff, PR >800 lines or >30 files, or SDK changes without tests. Label `merge-gate-ci-only` exempts CI-only changes under `.github/workflows/`, `.github/actions/`, or merge-gate scripts.
7. Confirm no CHANGES_REQUESTED reviews and CI green on HEAD ${{ matrix.head_sha }}.
8. If ready to merge: MERGE_GATE_VERDICT: APPROVE. Otherwise: MERGE_GATE_VERDICT: BLOCK with reasons.
Do not approve or merge via GitHub UI/API — only post the verdict comment.
disallowed_tools: |
Bash
Edit
Write
Replace
allowed_tools: |
View
GlobTool
GrepTool
Read
timeout_minutes: 15
- name: Post fallback verdict if Claude did not comment
id: fallback_verdict
uses: actions/github-script@v7
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const mergeGate = require('/tmp/merge-gate.js');
const owner = context.repo.owner;
const repo = context.repo.repo;
const prNumber = ${{ matrix.pr_number }};
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
const minCreatedAt = new Date(Date.now() - 25 * 60 * 1000).toISOString();
const existing = mergeGate.findMergeGateVerdict(ctx.comments, minCreatedAt, ctx.headPushedAt);
if (existing) {
core.info(`Verdict already posted: ${existing}`);
core.setOutput('verdict', existing);
return;
}
const check = await mergeGate.evaluatePipelineQuiescent(
github, owner, repo, prNumber, core, { forMergeStep: true }
);
const verdict = check.ready ? 'APPROVE' : 'BLOCK';
const lines = [
`MERGE_GATE_VERDICT: ${verdict}`,
'',
'Automated fallback — Claude assess did not post a verdict comment (e.g. GitHub MCP unavailable).',
];
if (check.ready) {
lines.push('All deterministic merge gates passed.');
} else {
lines.push('Blockers:', ...check.reasons.map((r) => `- ${r}`));
}
await github.rest.issues.createComment({
owner, repo, issue_number: prNumber,
body: lines.join('\n'),
});
core.info(`Posted fallback verdict: ${verdict}`);
core.setOutput('verdict', verdict);
- name: Verify verdict posted
id: verdict
uses: actions/github-script@v7
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const mergeGate = require('/tmp/merge-gate.js');
const comments = await mergeGate.listAllComments(
github, context.repo.owner, context.repo.repo, ${{ matrix.pr_number }}
);
const ctx = await mergeGate.loadPrContext(
github, context.repo.owner, context.repo.repo, ${{ matrix.pr_number }}
);
const minCreatedAt = new Date(Date.now() - 25 * 60 * 1000).toISOString();
const verdict = mergeGate.findMergeGateVerdict(comments, minCreatedAt, ctx.headPushedAt);
core.setOutput('verdict', verdict || 'NONE');
if (!verdict) core.warning('No recent MERGE_GATE_VERDICT comment found after assessment');
- name: Remove merge gate active label
if: always()
uses: actions/github-script@v7
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const prNumber = ${{ matrix.pr_number }};
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
name: 'claude-merge-gate-active',
});
core.info(`Removed claude-merge-gate-active from PR #${prNumber}`);
} catch (e) {
core.info(`Label claude-merge-gate-active already removed or missing on PR #${prNumber}`);
}
- name: Fail job if assessment failed
if: steps.assess.outcome == 'failure'
run: exit 1
merge-only:
needs: [scan-candidates, claude-assess]
if: |
always() &&
needs.scan-candidates.outputs.has_candidates == 'true' &&
needs.claude-assess.result == 'success'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.scan-candidates.outputs.matrix) }}
permissions:
contents: write
pull-requests: write
issues: write
actions: read
steps:
- uses: actions/checkout@v4
- name: Re-check gates and merge
uses: actions/github-script@v7
env:
MERGE_METHOD: ${{ github.event.inputs.merge_method || 'merge' }}
SCAN_HEAD_SHA: ${{ matrix.head_sha }}
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const path = require('path');
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
const owner = context.repo.owner;
const repo = context.repo.repo;
const prNumber = ${{ matrix.pr_number }};
const scanHeadSha = process.env.SCAN_HEAD_SHA;
const removeLabels = async () => {
try {
await github.rest.issues.removeLabel({
owner, repo, issue_number: prNumber, name: 'claude-merge-gate-active',
});
} catch (e) {
core.info('Label claude-merge-gate-active already removed or missing');
}
};
try {
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
const comments = ctx.comments;
const verdictMin = new Date(Date.now() - 25 * 60 * 1000).toISOString();
const verdict = mergeGate.findMergeGateVerdict(comments, verdictMin, ctx.headPushedAt);
if (verdict !== 'APPROVE') {
core.info(`Skip merge: verdict=${verdict || 'NONE'}`);
await removeLabels();
return;
}
const check = await mergeGate.evaluatePipelineQuiescent(
github, owner, repo, prNumber, core, { forMergeStep: true }
);
if (!check.ready) {
core.info(`Skip merge: ${check.reasons.join(', ')}`);
await removeLabels();
return;
}
if (check.headSha !== scanHeadSha) {
core.info(`Skip merge: HEAD moved ${scanHeadSha.slice(0, 7)} → ${check.headSha.slice(0, 7)}`);
await removeLabels();
return;
}
const method = process.env.MERGE_METHOD || 'merge';
await github.rest.pulls.merge({
owner, repo, pull_number: prNumber, merge_method: method,
});
await github.rest.issues.addLabels({
owner, repo, issue_number: prNumber, labels: ['auto-merged-by-gate'],
});
await github.rest.issues.createComment({
owner, repo, issue_number: prNumber,
body: [
'Merged by **Claude PR merge gate** (`claude-merge-gate.yml`).',
`Verdict: MERGE_GATE_VERDICT: APPROVE`,
`SHA: \`${check.headSha.slice(0, 7)}\``,
`Method: ${method}`,
].join('\n'),
});
core.info(`Merged PR #${prNumber}`);
} finally {
await removeLabels();
}
+61 -1
View File
@@ -123,6 +123,8 @@ jobs:
github.event.action != 'opened' &&
(github.event.action != 'labeled' || github.event.label.name == 'claude') &&
(github.event_name != 'issue_comment' || contains(github.event.comment.body, '@claude')) &&
(github.event_name != 'pull_request_review' || contains(github.event.review.body, '@claude')) &&
(github.event_name != 'pull_request_review_comment' || contains(github.event.comment.body, '@claude')) &&
(
!contains(github.actor, '[bot]') ||
github.actor == 'github-actions[bot]' ||
@@ -277,9 +279,10 @@ jobs:
5. Plugins (PraisonAI-Plugins): Framework lifecycle extensions replacing systemic behavior (e.g. tracing, logging, metrics, hooks, guardrails).
For items routed to an EXTERNAL repository (Tools, Docs, Plugins), follow STEP 3-ALT below.
- Does it duplicate existing functionality? Check if Agent already supports this via existing params (reflection, planning, tools, hooks, memory).
- Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.
- Does it inherit from Agent properly? New agent types MUST inherit Agent, not wrap it with composition.
- Does it add new dependencies? Only optional deps allowed, must be lazy-imported.
- Will agent.py grow larger? If the change adds >50 lines to agent.py, find a way to extract instead.
- Will agent.py grow larger? If the change adds >50 lines to agent.py, find a way to extract instead. PRs with >100 lines added to agent.py require manual review (merge gate will not auto-merge).
- Is there a name collision with existing exports in __init__.py?
If ANY of these conceptual checks fail (excluding routing), add a comment to the issue explaining why and close it. Do NOT create a PR.
@@ -333,3 +336,60 @@ jobs:
mcp__github__get_issue_comments
mcp__github__update_issue
timeout_minutes: 30
- name: Kick review chain for bot-created PR
if: |
steps.check_fork.outputs.is_pr != 'true' &&
(github.event_name == 'issues' || github.event_name == 'issue_comment') &&
github.event.issue != null
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GH_TOKEN }}
script: |
const path = require('path');
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/bot-pr-review-chain.js'));
const issueNumber = context.payload.issue.number;
await chain.kickReviewChainForIssue(
github, context.repo.owner, context.repo.repo, issueNumber, core
);
- name: Sync pipeline status labels for PR
if: steps.check_fork.outputs.is_pr == 'true'
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GH_TOKEN }}
script: |
const path = require('path');
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
const prNumber = parseInt('${{ steps.check_fork.outputs.pr_number }}', 10);
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, prNumber, core);
- name: Dispatch merge gate when PR pipeline ready
if: steps.check_fork.outputs.is_pr == 'true'
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GH_TOKEN }}
script: |
const path = require('path');
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
const owner = context.repo.owner;
const repo = context.repo.repo;
const prNumber = parseInt('${{ steps.check_fork.outputs.pr_number }}', 10);
const check = await mergeGate.evaluatePipelineQuiescent(
github, owner, repo, prNumber, core, {
skipGlobalClaudeRunCheck: true,
skipRecentClaudeCooldown: true,
}
);
if (!check.ready) {
core.info(`PR #${prNumber} not ready for merge gate: ${check.reasons.join(', ')}`);
return;
}
await github.rest.repos.createDispatchEvent({
owner,
repo,
event_type: 'claude-merge-gate',
client_payload: { pr_number: prNumber },
});
core.info(`Dispatched merge gate for PR #${prNumber}`);
+2 -1
View File
@@ -42,7 +42,8 @@ jobs:
'@claude this PR has merge conflicts with `main`.',
'Please rebase onto latest `main`, resolve conflicts (keep this PR\'s intent, merge in newer main logic),',
'run targeted tests, and force-push with `--force-with-lease`.',
'Comment which files you resolved.'
'Comment which files you resolved.',
'Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.',
].join(' ');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+126
View File
@@ -0,0 +1,126 @@
name: Nightly Release Gate
# Preflight checks before dispatching PyPI Release (patch bump).
# Runs nightly at 00:00 UTC (midnight).
on:
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
inputs:
dry_run:
description: 'Run preflight only; do not dispatch pypi-release.yml'
required: false
default: false
type: boolean
permissions:
contents: read
actions: write
concurrency:
group: nightly-release-gate
cancel-in-progress: false
jobs:
preflight:
if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
steps:
- name: Checkout main
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: main
fetch-depth: 0
- name: Preflight — path changes and CI green
id: preflight
run: |
set -euo pipefail
git fetch --tags origin
LAST_TAG=$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)
if [ -z "$LAST_TAG" ]; then
echo "No v* tag found — skip automated release (first release must be manual)"
echo "should_release=false" >> "$GITHUB_OUTPUT"
echo "reason=no_tag" >> "$GITHUB_OUTPUT"
exit 0
fi
CHANGED=$(git diff --name-only "${LAST_TAG}" HEAD -- \
src/praisonai \
src/praisonai-agents || true)
if [ -z "$CHANGED" ]; then
echo "No changes in src/praisonai or src/praisonai-agents since ${LAST_TAG} — skip"
echo "should_release=false" >> "$GITHUB_OUTPUT"
echo "reason=no_path_changes" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Changes since ${LAST_TAG}:"
echo "$CHANGED"
HEAD_SHA=$(git rev-parse HEAD)
echo "main HEAD: ${HEAD_SHA}"
GREEN_SHA=$(gh run list \
--repo "${GITHUB_REPOSITORY}" \
--workflow "Core Tests" \
--branch main \
--status success \
--json headSha \
--jq '.[0].headSha // empty')
if [ "$GREEN_SHA" != "$HEAD_SHA" ]; then
echo "main@${HEAD_SHA} is not covered by a green Core Tests run (last green: ${GREEN_SHA:-none}) — skip"
echo "should_release=false" >> "$GITHUB_OUTPUT"
echo "reason=ci_not_green" >> "$GITHUB_OUTPUT"
exit 0
fi
IN_PROGRESS=$(gh run list \
--repo "${GITHUB_REPOSITORY}" \
--workflow "Core Tests" \
--branch main \
--json headSha,status \
--jq "[.[] | select(.headSha == \"${HEAD_SHA}\" and .status == \"in_progress\")] | length")
if [ "${IN_PROGRESS:-0}" != "0" ]; then
echo "Core Tests still in progress on ${HEAD_SHA} — skip"
echo "should_release=false" >> "$GITHUB_OUTPUT"
echo "reason=ci_in_progress" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Preflight passed — ready to dispatch patch release"
echo "should_release=true" >> "$GITHUB_OUTPUT"
echo "reason=ready" >> "$GITHUB_OUTPUT"
echo "last_tag=${LAST_TAG}" >> "$GITHUB_OUTPUT"
echo "head_sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT"
- name: Dry run summary
if: inputs.dry_run == true
run: |
echo "Dry run — preflight only, no pypi-release dispatch."
echo "should_release=${{ steps.preflight.outputs.should_release }}"
echo "reason=${{ steps.preflight.outputs.reason }}"
if [ "${{ steps.preflight.outputs.should_release }}" = "true" ]; then
echo "Would dispatch: pypi-release.yml bump=patch source=nightly"
fi
- name: Dispatch PyPI Release (patch)
if: steps.preflight.outputs.should_release == 'true' && inputs.dry_run != true
run: |
set -euo pipefail
gh workflow run pypi-release.yml \
--repo "${GITHUB_REPOSITORY}" \
--ref main \
-f bump=patch \
-f source=nightly \
-f dry_run=false
echo "Dispatched pypi-release.yml (patch, source=nightly)"
echo "Approve the pypi environment when prompted."
@@ -0,0 +1,31 @@
name: Pipeline Status Sync
# Sync pipeline/* stage and blocker labels on open internal PRs.
on:
schedule:
- cron: '*/15 * * * *'
workflow_dispatch:
permissions:
pull-requests: read
issues: write
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Sync pipeline labels on open PRs
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GH_TOKEN }}
script: |
const path = require('path');
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
await ps.syncOpenPullRequests(
github, context.repo.owner, context.repo.repo, { maxPrs: 30 }, core
);
+14
View File
@@ -40,6 +40,14 @@ on:
required: false
default: ''
type: string
source:
description: 'Trigger source (audit trail)'
required: false
default: manual
type: choice
options:
- manual
- nightly
permissions:
contents: write
@@ -78,6 +86,12 @@ jobs:
git config user.email "[email protected]"
gh auth setup-git
- name: Release summary
run: |
echo "Source: ${{ inputs.source }}"
echo "Bump: ${{ inputs.bump }}"
echo "Dry run: ${{ inputs.dry_run }}"
- name: Compute release versions
id: versions
env:
+11 -1
View File
@@ -819,6 +819,7 @@ Methods:
* <code title="cli">praisonai agents info <a href="./src/praisonai/praisonai/cli/commands/agents.py">--help</a></code>
* <code title="cli">praisonai agents list <a href="./src/praisonai/praisonai/cli/commands/agents.py">--help</a></code>
* <code title="cli">praisonai app app <a href="./src/praisonai/praisonai/cli/commands/app.py">--help</a></code>
* <code title="cli">praisonai attach attach-main <a href="./src/praisonai/praisonai/cli/commands/attach.py">--help</a></code>
* <code title="cli">praisonai audit agent-centric <a href="./src/praisonai/praisonai/cli/commands/audit.py">--help</a></code>
* <code title="cli">praisonai auth list <a href="./src/praisonai/praisonai/cli/commands/auth.py">--help</a></code>
* <code title="cli">praisonai auth login <a href="./src/praisonai/praisonai/cli/commands/auth.py">--help</a></code>
@@ -861,6 +862,11 @@ Methods:
* <code title="cli">praisonai call call-main <a href="./src/praisonai/praisonai/cli/commands/call.py">--help</a></code>
* <code title="cli">praisonai chat <a href="./src/praisonai/praisonai/cli/main.py">--help</a></code>
* <code title="cli">praisonai chat chat-main <a href="./src/praisonai/praisonai/cli/commands/chat.py">--help</a></code>
* <code title="cli">praisonai checkpoint delete <a href="./src/praisonai/praisonai/cli/commands/checkpoint.py">--help</a></code>
* <code title="cli">praisonai checkpoint diff <a href="./src/praisonai/praisonai/cli/commands/checkpoint.py">--help</a></code>
* <code title="cli">praisonai checkpoint list <a href="./src/praisonai/praisonai/cli/commands/checkpoint.py">--help</a></code>
* <code title="cli">praisonai checkpoint restore <a href="./src/praisonai/praisonai/cli/commands/checkpoint.py">--help</a></code>
* <code title="cli">praisonai checkpoint save <a href="./src/praisonai/praisonai/cli/commands/checkpoint.py">--help</a></code>
* <code title="cli">praisonai claw claw <a href="./src/praisonai/praisonai/cli/commands/claw.py">--help</a></code>
* <code title="cli">praisonai code <a href="./src/praisonai/praisonai/cli/main.py">--help</a></code>
* <code title="cli">praisonai code code-main <a href="./src/praisonai/praisonai/cli/commands/code.py">--help</a></code>
@@ -890,6 +896,10 @@ Methods:
* <code title="cli">praisonai context show <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
* <code title="cli">praisonai context stats <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
* <code title="cli">praisonai context tail <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
* <code title="cli">praisonai daemon daemon-main <a href="./src/praisonai/praisonai/cli/commands/daemon.py">--help</a></code>
* <code title="cli">praisonai daemon start <a href="./src/praisonai/praisonai/cli/commands/daemon.py">--help</a></code>
* <code title="cli">praisonai daemon status <a href="./src/praisonai/praisonai/cli/commands/daemon.py">--help</a></code>
* <code title="cli">praisonai daemon stop <a href="./src/praisonai/praisonai/cli/commands/daemon.py">--help</a></code>
* <code title="cli">praisonai dashboard unified <a href="./src/praisonai/praisonai/cli/commands/dashboard.py">--help</a></code>
* <code title="cli">praisonai debug acp <a href="./src/praisonai/praisonai/cli/commands/debug.py">--help</a></code>
* <code title="cli">praisonai debug debug-callback <a href="./src/praisonai/praisonai/cli/commands/debug.py">--help</a></code>
@@ -1367,7 +1377,7 @@ export { // Python parity additions
MinimalTelemetry, AgentTelemetry, PerformanceMonitor, TelemetryCollector, TelemetryIntegration, cleanupTelemetry, cleanupTelemetryResources, createAgentTelemetry, createConsoleSink, createHTTPSink, createPerformanceMonitor, createTelemetryIntegration, disablePerformanceMode, disableTelemetry, enablePerformanceMode, enableTelemetry, getMinimalTelemetry, getTelemetry } from "./telemetry";
export { // Subagent Tool (agent-as-tool pattern)
SubagentTool, BaseTool, FunctionTool, ToolRegistry, ToolResult, ToolValidationError, createDelegator, createSubagentTool, createSubagentTools, createTool, getRegistry, getTool, registerTool, tool, validateTool } from "./tools";
export { airweaveSearch, bedrockBrowserClick, bedrockBrowserFill, bedrockBrowserNavigate, bedrockCodeInterpreter, codeExecution, codeMode, createCustomTool, exaSearch, firecrawlCrawl, firecrawlScrape, parallelSearch, perplexitySearch, registerCustomTool, registerLocalTool, registerNpmTool, superagentGuard, superagentRedact, superagentVerify, tavilyCrawl, tavilyExtract, tavilySearch, valyuBioSearch, valyuCompanyResearch, valyuEconomicsSearch, valyuFinanceSearch, valyuPaperSearch, valyuPatentSearch, valyuSecSearch, valyuWebSearch } from "./tools/builtins";
export { airweaveSearch, bedrockBrowserClick, bedrockBrowserFill, bedrockBrowserNavigate, bedrockCodeInterpreter, codeExecution, codeMode, createCustomTool, crwCrawl, crwScrape, exaSearch, firecrawlCrawl, firecrawlScrape, parallelSearch, perplexitySearch, registerCustomTool, registerLocalTool, registerNpmTool, superagentGuard, superagentRedact, superagentVerify, tavilyCrawl, tavilyExtract, tavilySearch, valyuBioSearch, valyuCompanyResearch, valyuEconomicsSearch, valyuFinanceSearch, valyuPaperSearch, valyuPatentSearch, valyuSecSearch, valyuWebSearch } from "./tools/builtins";
export { BudgetExceededError, MissingDependencyError, MissingEnvVarError, ToolsRegistry, composeMiddleware, createLoggingMiddleware, createRateLimitMiddleware, createRedactionMiddleware, createRetryMiddleware, createTimeoutMiddleware, createToolsRegistry, createTracingMiddleware, createValidationMiddleware, getToolsRegistry, get_registry, get_tool, register_tool, resetToolsRegistry, validate_tool } from "./tools/registry";
export type { InstallHints, PraisonTool, RedactionHooks, RegisteredTool, ToolCapabilities, ToolExecutionContext, ToolExecutionResult, ToolFactory, ToolHooks, ToolInstallStatus, ToolLimits, ToolLogger, ToolMetadata, ToolMiddleware, ToolParameterProperty, ToolParameterSchema } from "./tools/registry";
export { registerBuiltinTools, tools } from "./tools/tools";
+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.79" \
"praisonai>=4.6.80" \
"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.79" \
"praisonai>=4.6.80" \
"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.79" \
"praisonai>=4.6.80" \
"praisonai[ui]" \
"praisonai[crewai]"
+7 -2
View File
@@ -214,8 +214,13 @@ def example_subagent_delegation():
assert hasattr(agent, "delegate"), "Agent should have delegate method"
assert callable(agent.delegate), "delegate should be callable"
# Check _create_subagent method
subagent = agent._create_subagent("explorer")
# Create a subagent using the public profile API
from praisonaiagents.agents.profiles import BUILTIN_PROFILES, get_profile
explorer_profile = get_profile("explorer") if "explorer" in BUILTIN_PROFILES else None
subagent = Agent(
name="subagent_explorer",
instructions=explorer_profile.system_prompt if explorer_profile else "You are an explorer assistant.",
)
print(f"Created subagent: {subagent.name}")
print(f"Subagent instructions: {subagent.instructions[:50]}...")
@@ -67,7 +67,11 @@ def main():
# 4. Subagent delegation
print("\n4. Subagent Delegation:")
subagent = agent._create_subagent("explorer")
explorer_profile = get_profile("explorer") if "explorer" in BUILTIN_PROFILES else None
subagent = Agent(
name="subagent_explorer",
instructions=explorer_profile.system_prompt if explorer_profile else "You are an explorer assistant.",
)
print(f" Created subagent: {subagent.name}")
print(f" Profile: explorer (read-only)")
@@ -0,0 +1,124 @@
"""
Example: Using MuAPI with PraisonAI for image generation.
MuAPI (https://muapi.ai) provides unified access to 400+ generative media
models — Flux, Midjourney, GPT-4o Image, Google Imagen, Seedream, Veo3,
Kling, Wan, and more — through a single API.
This example creates a PraisonAI agent that generates images on demand
using the MuAPI image generation API as a custom tool.
Setup:
pip install praisonaiagents requests
export MUAPI_API_KEY=your_key_here
# Get an API key at https://muapi.ai/dashboard/api-keys
"""
import os
import time
import requests
from praisonaiagents import Agent, Tool
MUAPI_BASE_URL = "https://api.muapi.ai/api/v1"
ALLOWED_MODELS = {
"flux-schnell", "flux-dev", "flux-kontext-dev", "flux-kontext-pro",
"midjourney", "gpt4o", "imagen4", "imagen4-fast", "seedream",
"hidream-fast", "hidream-dev", "reve", "ideogram", "hunyuan",
}
def generate_image(prompt: str, model: str = "flux-schnell") -> str:
"""Generate an image using MuAPI and return the output URL.
Args:
prompt: Text description of the image to generate.
model: MuAPI model ID. Options include flux-schnell, flux-dev,
midjourney, gpt4o, imagen4, seedream, hidream-fast, reve.
Returns:
URL of the generated image.
"""
if model not in ALLOWED_MODELS:
raise ValueError(
f"Unsupported model '{model}'. Choose from: {sorted(ALLOWED_MODELS)}"
)
api_key = os.environ.get("MUAPI_API_KEY", "")
if not api_key:
raise ValueError("MUAPI_API_KEY environment variable not set")
headers = {"x-api-key": api_key, "Content-Type": "application/json"}
# Submit the generation request
submit_resp = requests.post(
f"{MUAPI_BASE_URL}/{model}",
headers=headers,
json={"prompt": prompt},
timeout=30,
)
submit_resp.raise_for_status()
submit_data = submit_resp.json()
request_id = submit_data.get("request_id")
if not request_id:
raise RuntimeError(f"MuAPI did not return a request_id: {submit_data}")
# Poll until completion
for _ in range(120):
time.sleep(3)
poll_resp = requests.get(
f"{MUAPI_BASE_URL}/predictions/{request_id}/result",
headers={"x-api-key": api_key},
timeout=15,
)
poll_resp.raise_for_status()
data = poll_resp.json()
status = data.get("status")
if status == "completed":
outputs = data.get("outputs") or []
if not outputs:
raise RuntimeError(f"Generation completed but returned no outputs: {data}")
return outputs[0]
if status in ("failed", "cancelled"):
raise RuntimeError(f"Image generation {status}: {data.get('error', '')}")
raise TimeoutError("Image generation timed out after 6 minutes")
if __name__ == "__main__":
image_tool = Tool(
name="generate_image",
description=(
"Generate an image from a text description using MuAPI's 400+ model library. "
"Supported models: flux-schnell (fast), flux-dev, midjourney, gpt4o, imagen4, "
"seedream, hidream-fast, reve. Returns a URL to the generated image."
),
function=generate_image,
)
agent = Agent(
name="Image Generator",
instructions=(
"You are a creative image generation assistant. "
"When asked to create an image, use the generate_image tool. "
"Pick the best model for the task: flux-schnell for speed, "
"midjourney for artistic quality, gpt4o for photorealism."
),
tools=[image_tool],
)
# Example: generate a product photo
response = agent.start(
"Generate a photorealistic product photo of a sleek black coffee mug "
"on a white marble table with soft morning light. Use the best model for photorealism."
)
print(response)
# Example: generate concept art
response = agent.start(
"Create a cyberpunk cityscape at night with neon reflections on wet streets. "
"Use midjourney for artistic quality."
)
print(response)
@@ -571,6 +571,17 @@ gh pr diff $PR --repo $REPO | less
### 15b. Merge
**Primary:** Trigger or wait for **Claude PR merge gate** (`.github/workflows/claude-merge-gate.yml`):
```bash
gh workflow run claude-merge-gate.yml --repo $REPO -f pr_number=$PR
gh pr view $PR --repo $REPO --json state,mergedAt,labels
```
The gate merges when `MERGE_GATE_VERDICT: APPROVE`, CI is green, and the PR lacks `no-auto-merge`. Do not merge while `claude-conflict-pending` or during active `@claude` review/fix.
**Manual fallback** (opt-out or gate unavailable):
```bash
gh pr merge $PR --repo $REPO --squash --delete-branch
# --squash preserves linear main; --delete-branch keeps the repo clean
@@ -137,6 +137,15 @@ Final rule: conclude only when evidence shows missing = 0.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PUBLISH WORKFLOW (reference)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
GitHub Actions (recommended):
Manual: Actions → PyPI Release → Run workflow on main (bump defaults to patch)
Nightly: `.github/workflows/nightly-release-gate.yml` runs at 00:00 UTC (midnight) when:
- changes exist in src/praisonai or src/praisonai-agents since last v* tag
- Core Tests succeeded on current main HEAD
→ dispatches pypi-release.yml with bump=patch, source=nightly
→ still requires pypi environment approval before publish
Ad-hoc minor/major: manual PyPI Release only (explicit bump selection)
Step 1 — Publish praisonaiagents (Core SDK):
cd /Users/praison/praisonai-package/src/praisonai-agents
praisonai publish pypi
@@ -161,4 +170,4 @@ Step 3 — Publish praisonai-tools (External, if needed):
# Bump version in pyproject.toml, then:
python3.13 -m build && uv run twine upload dist/*
PraisonAI PRs: NEVER merge automatically — user merges manually.
PraisonAI PRs: default merge via **Claude PR merge gate** (`.github/workflows/claude-merge-gate.yml`) when validation passes. Add label `no-auto-merge` to opt out. Manual `gh pr merge` only as fallback when the gate cannot run.
@@ -187,7 +187,7 @@ gh pr create \
**Commit prefixes:** `fix:`, `feat:`, `security:`, `refactor:`, `docs:`, `test:`
> **Do NOT merge automatically** — user merges manually unless explicitly told otherwise.
> **Merge:** Default is the **Claude PR merge gate** (`.github/workflows/claude-merge-gate.yml`). Do not merge manually unless the PR has `no-auto-merge` or you are explicitly told to use `gh pr merge`.
### 5b. Verify PR state
@@ -129,7 +129,9 @@ python -m pytest tests/ -q \
## Step 6 — Approve or Request Changes
**If the PR is good:**
**Primary (automated):** The **Claude PR merge gate** (`.github/workflows/claude-merge-gate.yml`) assesses readiness and merges when `MERGE_GATE_VERDICT: APPROVE`, CI is green, and the PR lacks `no-auto-merge`. It never posts `@claude` and does not use `gh pr review` (avoids re-triggering `claude.yml`).
**Manual fallback — if the PR is good and gate is skipped or opted out:**
```bash
cd /Users/praison/praisonai-package && \
@@ -147,6 +149,14 @@ gh pr review <NUMBER> --request-changes --body "Requesting changes: <specific fe
## Step 7 — Merge
**Primary:** Wait for merge gate or verify `mergedAt` after a gate run:
```bash
cd /Users/praison/praisonai-package && gh pr view <NUMBER> --json state,mergedAt,labels
```
**Manual fallback** (label `no-auto-merge` or gate disabled):
```bash
cd /Users/praison/praisonai-package && \
gh pr merge <NUMBER> --merge --subject "<PR title>"
@@ -50,7 +50,7 @@ The workflow uses the agent configuration at `.github/praisonai-reviewer.yaml` a
- Copilot NEVER triggers before CodeRabbit/Qodo complete.
- Claude NEVER triggers before Copilot completes.
- Duplicate `@copilot` comments are prevented by checking existing comments.
- PraisonAI PRs: NEVER merge automatically — user merges manually.
- Default merge: **Claude PR merge gate** (`.github/workflows/claude-merge-gate.yml`) after review chain + CI green. Opt out with label `no-auto-merge`. Manual merge only as fallback.
## Security & Credentials
+16 -16
View File
@@ -18,19 +18,19 @@ This report compares **Python SDK feature categories** against **Python document
| Category | Features | Docs | Lines |
|----------|----------|------|-------|
| ✅ AGUI | 1 | 2 | 420 |
| ✅ Agent | 20 | 41 | 11111 |
| ✅ Agent | 21 | 42 | 11340 |
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2880 |
| ✅ Agent-to-User (A2U) | 1 | 3 | 575 |
| ✅ Approval | 1 | 3 | 1344 |
| ✅ Audio | 2 | 10 | 649 |
| ✅ Auto Generation | 5 | 6 | 1637 |
| ✅ Autonomy | 3 | 5 | 1919 |
| ✅ Bots | 7 | 23 | 7357 |
| ✅ Bots | 7 | 23 | 7714 |
| ✅ Budget | 1 | 1 | 287 |
| ✅ CLI | 5 | 107 | 26699 |
| ✅ CLI | 5 | 108 | 26975 |
| ✅ Chunking | 2 | 2 | 288 |
| ✅ Citations | 2 | 1 | 202 |
| ✅ Code Execution | 2 | 13 | 3551 |
| ✅ Code Execution | 2 | 13 | 3554 |
| ✅ Conditions | 1 | 3 | 1085 |
| ✅ Configuration | 3 | 7 | 2160 |
| ✅ Context Management | 16 | 33 | 10280 |
@@ -44,12 +44,12 @@ This report compares **Python SDK feature categories** against **Python document
| ✅ Failover | 2 | 1 | 372 |
| ✅ Files | 2 | 6 | 1632 |
| ✅ Flow | 1 | 2 | 435 |
| ✅ Gateway | 7 | 19 | 6767 |
| ✅ Gateway | 7 | 21 | 7477 |
| ✅ Guardrails | 4 | 4 | 1619 |
| ✅ Handoffs | 11 | 6 | 2499 |
| ✅ Hooks | 2 | 8 | 3146 |
| ✅ Hooks | 2 | 8 | 3150 |
| ✅ Image | 1 | 11 | 1096 |
| ✅ Knowledge | 4 | 23 | 4282 |
| ✅ Knowledge | 4 | 24 | 4525 |
| ✅ LLM | 3 | 10 | 3674 |
| ✅ Loops | 4 | 3 | 774 |
| ✅ MCP | 1 | 51 | 10939 |
@@ -64,24 +64,24 @@ This report compares **Python SDK feature categories** against **Python document
| ✅ Prompts | 2 | 7 | 1569 |
| ✅ Providers | 1 | 52 | 6214 |
| ✅ Query | 1 | 2 | 616 |
| ✅ RAG | 5 | 15 | 3190 |
| ✅ RAG | 5 | 15 | 3193 |
| ✅ Realtime | 2 | 5 | 584 |
| ✅ Reflection | 3 | 2 | 477 |
| ✅ Retrieval | 2 | 5 | 944 |
| ✅ Retrieval | 2 | 5 | 945 |
| ✅ Routing | 1 | 2 | 597 |
| ✅ Sandbox | 5 | 6 | 2478 |
| ✅ Security | 1 | 3 | 2550 |
| ✅ Sessions | 4 | 11 | 3445 |
| ✅ Skills | 6 | 10 | 3641 |
| ✅ Sandbox | 5 | 6 | 2512 |
| ✅ Security | 1 | 3 | 2563 |
| ✅ Sessions | 4 | 12 | 3793 |
| ✅ Skills | 6 | 10 | 3663 |
| ✅ Tasks | 2 | 6 | 3064 |
| ✅ Telemetry | 1 | 2 | 560 |
| ✅ Templates | 1 | 7 | 1411 |
| ✅ Tools | 12 | 118 | 25896 |
| ✅ Tools | 12 | 118 | 25895 |
| ✅ Tracing | 3 | 2 | 139 |
| ✅ Video | 2 | 6 | 510 |
| ✅ Vision | 2 | 1 | 283 |
| ✅ Web | 3 | 6 | 1352 |
| ✅ Workflows | 4 | 14 | 5810 |
| ✅ Web | 3 | 7 | 1537 |
| ✅ Workflows | 4 | 14 | 5893 |
## Undocumented Categories (Need Documentation)
@@ -235,6 +235,12 @@ _LAZY_IMPORTS = {
'CliBackendResult': ('praisonaiagents.cli_backend.protocols', 'CliBackendResult'),
'CliBackendDelta': ('praisonaiagents.cli_backend.protocols', 'CliBackendDelta'),
# Runtime Selection (Auto harness selection and registry)
'AgentRuntimeProtocol': ('praisonaiagents.runtime.protocols', 'AgentRuntimeProtocol'),
'resolve_runtime': ('praisonaiagents.runtime.registry', 'resolve_runtime'),
'register_runtime': ('praisonaiagents.runtime.registry', 'register_runtime'),
'list_runtimes': ('praisonaiagents.runtime.registry', 'list_runtimes'),
# Agent classes
'Agent': ('praisonaiagents.agent.agent', 'Agent'),
'RetryBackoffConfig': ('praisonaiagents.agent.retry_utils', 'RetryBackoffConfig'),
@@ -4063,37 +4063,6 @@ Summary:"""
)
return await handoff_obj.execute_async(self, prompt, context)
def _create_subagent(
self,
profile: str,
context: Optional[Dict[str, Any]] = None,
) -> 'Agent':
"""Create a subagent with the specified profile.
Args:
profile: Agent profile name
context: Additional context
Returns:
Configured Agent instance
"""
from ..agents.profiles import get_profile, BUILTIN_PROFILES
# Get profile config
profile_config = get_profile(profile) if profile in BUILTIN_PROFILES else None
if profile_config:
return Agent(
name=f"subagent_{profile}",
instructions=profile_config.system_prompt,
)
else:
# Default subagent
return Agent(
name=f"subagent_{profile}",
instructions=f"You are a {profile} assistant.",
)
# -------------------------------------------------------------------------
# Runtime Capability Management
# -------------------------------------------------------------------------
@@ -614,24 +614,6 @@ Your Goal: {self.goal}"""
# Fallback to str(response) is still fine, but now it's visible
return str(response)
def _process_stream_response(self, messages, temperature, start_time, formatted_tools=None, reasoning_steps=False):
"""Internal helper for streaming response processing with real-time events."""
if self._openai_client is None:
return None
return self._openai_client.process_stream_response(
messages=messages,
model=self.llm,
temperature=temperature,
tools=formatted_tools,
start_time=start_time,
console=self.console,
display_fn=_get_display_functions()['display_generating'] if self.verbose else None,
reasoning_steps=reasoning_steps,
stream_callback=self.stream_emitter.emit,
emit_events=True
)
def _get_compaction_policy(self):
"""
Get the active compaction policy for this agent.
@@ -786,7 +768,7 @@ Your Goal: {self.goal}"""
from ..context.policy import CompactionRoute
return CompactionRoute.COMPACT_NEEDED, compacted_msgs
def _apply_tool_truncation(self, messages, compactor, policy):
def _apply_tool_truncation(self, messages, compactor, policy, log_tag="tool-truncation"):
"""Apply targeted tool output truncation."""
from ..context.policy import CompactionRoute
import logging
@@ -812,7 +794,7 @@ Your Goal: {self.goal}"""
new_tokens = compactor.count_total_tokens(truncated_msgs)
logging.info(
f"[tool-truncation] {self.name}: {original_tokens}→{new_tokens} tokens "
f"[{log_tag}] {self.name}: {original_tokens}→{new_tokens} tokens "
f"(truncated large tool outputs)"
)
@@ -906,36 +888,8 @@ Your Goal: {self.goal}"""
return CompactionRoute.COMPACT_NEEDED, compacted_msgs
async def _apply_tool_truncation_async(self, messages, compactor, policy):
"""Async version of _apply_tool_truncation."""
from ..context.policy import CompactionRoute
import logging
# Create a copy to avoid modifying original
truncated_msgs = []
for msg in messages:
if msg.get("role") == "tool" or msg.get("tool_call_id"):
content = msg.get("content", "")
if isinstance(content, str) and len(content) > 1000:
# Truncate large tool outputs
truncated_msg = msg.copy()
head = content[:300]
tail = content[-200:] if len(content) > 500 else ""
truncated_msg["content"] = f"{head}\n...[truncated {len(content):,} chars for context budget]...\n{tail}"
truncated_msgs.append(truncated_msg)
continue
truncated_msgs.append(msg)
original_tokens = compactor.count_total_tokens(messages)
new_tokens = compactor.count_total_tokens(truncated_msgs)
logging.info(
f"[tool-truncation-async] {self.name}: {original_tokens}→{new_tokens} tokens "
f"(truncated large tool outputs)"
)
return CompactionRoute.TRUNCATE_TOOLS, truncated_msgs
"""Async wrapper around _apply_tool_truncation (no awaitable work involved)."""
return self._apply_tool_truncation(messages, compactor, policy, log_tag="tool-truncation-async")
def _get_next_fallback_model(self, fallback_index):
"""Get the next fallback model from the chain, if available.
@@ -994,6 +948,29 @@ Your Goal: {self.goal}"""
# 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
# configured max_tokens output reservation and refuse to dispatch when
# it would breach the ceiling. This turns max_budget into a genuine hard
# cap instead of one that can be overshot by a whole LLM call.
if self._max_budget and self._on_budget_exceeded == "stop":
_est_min_cost = self._estimate_min_call_cost(
messages, getattr(self, 'max_tokens', None)
)
with self._cost_lock:
_projected_cost = self._total_cost + _est_min_cost
_current_cost = self._total_cost
if _projected_cost >= self._max_budget:
raise BudgetExceededError(
f"Agent '{self.name}' would exceed budget before call: "
f"${_current_cost:.4f} + est ${_est_min_cost:.4f} >= "
f"${self._max_budget:.4f}",
budget_type="cost",
limit=self._max_budget,
used=_current_cost,
agent_id=self.name
)
logging.debug(f"{self.name} sending messages to LLM: {messages}")
# Emit LLM request trace event (zero overhead when not set)
@@ -1672,151 +1649,6 @@ Your Goal: {self.goal}"""
task_name, task_description, task_id, response_format, stream_callback, emit_events
)
async def _finalize_unified_achat_response(
self,
response,
original_prompt,
temperature,
tools,
output_json,
output_pydantic,
reasoning_steps,
stream,
messages,
chat_history_length,
start_time,
task_name=None,
task_description=None,
task_id=None,
):
"""Post-process unified achat completion (parity with sync _chat_impl)."""
if not response:
self._truncate_chat_history(chat_history_length)
return None
if hasattr(response, "choices") and response.choices:
msg = response.choices[0].message
content = getattr(msg, "content", None)
response_text = content.strip() if content else ""
if (
reasoning_steps
and getattr(msg, "reasoning_content", None)
):
response_text = msg.reasoning_content.strip()
else:
extracted = self._extract_llm_response_content(response)
response_text = (extracted or "").strip() if isinstance(extracted, str) else str(extracted or "")
if output_json or output_pydantic:
self._append_to_chat_history({"role": "assistant", "content": response_text})
self._persist_message("assistant", response_text)
try:
validated_response = self._apply_guardrail_with_retry(
response_text, original_prompt, temperature, tools,
task_name, task_description, task_id,
)
self._execute_callback_and_display(
original_prompt, validated_response, time.time() - start_time,
task_name, task_description, task_id,
)
return await self._atrigger_after_agent_hook(
original_prompt, validated_response, start_time,
)
except Exception as e:
logging.error(f"Agent {self.name}: Guardrail validation failed for JSON output: {e}")
self._truncate_chat_history(chat_history_length)
return None
if not self.self_reflect:
self._append_to_chat_history({"role": "assistant", "content": response_text})
self._persist_message("assistant", response_text)
try:
validated_response = self._apply_guardrail_with_retry(
response_text, original_prompt, temperature, tools,
task_name, task_description, task_id,
)
self._execute_callback_and_display(
original_prompt, validated_response, time.time() - start_time,
task_name, task_description, task_id,
)
return await self._atrigger_after_agent_hook(
original_prompt, validated_response, start_time,
)
except Exception as e:
logging.error(f"Agent {self.name}: Guardrail validation failed: {e}")
self._truncate_chat_history(chat_history_length)
return None
# Self-reflection (legacy OpenAI path)
reflection_count = 0
while True:
reflection_prompt = f"""
Reflect on your previous response: '{response_text}'.
{self.reflect_prompt if self.reflect_prompt else "Identify any flaws, improvements, or actions."}
Provide a "satisfactory" status ('yes' or 'no').
Output MUST be JSON with 'reflection' and 'satisfactory'.
"""
reflection_messages = messages + [
{"role": "assistant", "content": response_text},
{"role": "user", "content": reflection_prompt},
]
try:
if self._openai_client is None:
self._append_to_chat_history({"role": "assistant", "content": response_text})
return await self._atrigger_after_agent_hook(
original_prompt, response_text, start_time,
)
reflection_response = await self._openai_client.async_client.beta.chat.completions.parse(
model=self.reflect_llm if self.reflect_llm else self.llm,
messages=reflection_messages,
temperature=temperature,
response_format=_get_display_functions()['ReflectionOutput'],
)
reflection_output = reflection_response.choices[0].message.parsed
if reflection_output.satisfactory == "yes" and reflection_count >= self.min_reflect - 1:
break
if reflection_count >= self.max_reflect - 1:
break
regenerate_messages = reflection_messages + [
{
"role": "assistant",
"content": (
f"Self Reflection: {reflection_output.reflection} "
f"Satisfactory?: {reflection_output.satisfactory}"
),
},
{"role": "user", "content": "Now regenerate your response using the reflection you made"},
]
new_response = await self._openai_client.async_client.chat.completions.create(
model=self.llm,
messages=regenerate_messages,
temperature=temperature,
)
response_text = new_response.choices[0].message.content
reflection_count += 1
except Exception as e:
logging.error("Reflection parsing failed.", exc_info=True)
reflection_count += 1
if reflection_count >= self.max_reflect:
break
try:
validated_response = self._apply_guardrail_with_retry(
response_text, original_prompt, temperature, tools,
task_name, task_description, task_id,
)
self._execute_callback_and_display(
original_prompt, validated_response, time.time() - start_time,
task_name, task_description, task_id,
)
return await self._atrigger_after_agent_hook(
original_prompt, validated_response, start_time,
)
except Exception as e:
logging.error(f"Agent {self.name}: Guardrail validation failed for OpenAI client: {e}")
self._truncate_chat_history(chat_history_length)
return None
def _execute_callback_and_display(self, prompt: str, response: str, generation_time: float, task_name=None, task_description=None, task_id=None):
"""Helper method to execute callbacks and display interaction.
@@ -35,10 +35,6 @@ class ExecutionMixin:
"""Synchronous sleep - safe for sync contexts only."""
time.sleep(duration)
async def _safe_sleep_async(self, duration: float) -> None:
"""Async sleep - use this in async contexts."""
await asyncio.sleep(duration)
def generate_task(self) -> 'Task':
"""Generate a Task object from the agent's instructions"""
from ..task.task import Task
@@ -831,6 +831,56 @@ class ToolExecutionMixin:
response=response,
)
DEFAULT_OUTPUT_RESERVATION_TOKENS = 1024
def _estimate_min_call_cost(self, messages, max_tokens=None) -> float:
"""Estimate the minimum cost of an upcoming LLM call before dispatch.
Uses the known input size (messages) plus an output-token reservation
so the budget can be enforced as a hard pre-call ceiling. When the agent
has no ``max_tokens`` configured, a conservative default reservation
(``DEFAULT_OUTPUT_RESERVATION_TOKENS``) is used so a small prompt with a
large provider-default response cannot trivially bypass the guard and
then overshoot the cap. Input tokens are approximated as ~4 characters
per token, matching the rest of the codebase.
Note: This is intentionally a *minimum* estimate. It counts message
content only and does not include tool-schema tokens, so a tool-heavy
request may still cost more than estimated; the reactive post-call
accounting remains the backstop for that case.
Args:
messages: The messages that will be sent to the LLM.
max_tokens: Optional output-token reservation. Falls back to
``DEFAULT_OUTPUT_RESERVATION_TOKENS`` when not provided.
Returns:
Estimated minimum cost in USD for the call.
"""
prompt_chars = 0
if messages:
for _msg in messages:
_content = _msg.get("content") if isinstance(_msg, dict) else None
if isinstance(_content, str):
prompt_chars += len(_content)
elif isinstance(_content, list):
for _part in _content:
if isinstance(_part, dict):
_text = _part.get("text")
if isinstance(_text, str):
prompt_chars += len(_text)
elif _content is not None:
prompt_chars += len(str(_content))
prompt_tokens = prompt_chars // 4
if max_tokens:
completion_tokens = int(max_tokens)
elif prompt_chars > 0:
completion_tokens = self.DEFAULT_OUTPUT_RESERVATION_TOKENS
else:
completion_tokens = 0
return self._calculate_llm_cost(prompt_tokens, completion_tokens)
def _truncate_dict_fields(self, data: dict, tool_name: str, max_field_chars: int = None, tool_call_id: str = None) -> dict:
"""Truncate large string fields in a dict to prevent context overflow."""
if max_field_chars is None:
@@ -35,22 +35,6 @@ class UnifiedExecutionMixin:
with a single async-first implementation plus sync bridge.
"""
def _resolve_cli_backend_at_turn_time(self):
"""
Resolve CLI backend at turn-time instead of construction time.
This allows for runtime-specific backend selection based on model changes
or other runtime conditions.
Returns:
The resolved CLI backend instance, or current backend if no override needed.
"""
# For now, this is a placeholder for future backend override logic
# The runtime resolution infrastructure is in place, but the policy
# decisions about when to switch backends are not yet implemented
# Always return the current backend until override logic is implemented
return getattr(self, '_cli_backend', None)
async def _unified_chat_impl(
self,
prompt: str,
@@ -42,7 +42,7 @@ from typing import Callable, Dict, List, Literal, Optional, Set
# ── New protocol-driven exports ──────────────────────────────────────────────
from .protocols import ApprovalProtocol, ApprovalRequest, ApprovalConfig # noqa: F401
from .protocols import ApprovalDecision # noqa: F401
from .protocols import ApprovalDecision, ApprovalStoreProtocol # noqa: F401
from .backends import AutoApproveBackend, ConsoleBackend, CallbackBackend, AgentApproval # noqa: F401
from .registry import ApprovalRegistry, DEFAULT_DANGEROUS_TOOLS # noqa: F401
@@ -8,8 +8,14 @@ explicit inheritance (structural subtyping).
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from typing import Any, Dict, Optional, Protocol, runtime_checkable
from typing import Any, Dict, List, Optional, Protocol, Tuple, runtime_checkable
def _new_approval_id() -> str:
"""Generate a stable correlation id for an approval request."""
return uuid.uuid4().hex
@dataclass
@@ -17,12 +23,15 @@ class ApprovalRequest:
"""Immutable request for tool-execution approval.
Attributes:
tool_name: Name of the tool requesting approval.
arguments: Arguments the tool will be called with.
risk_level: Risk classification (critical / high / medium / low).
agent_name: Name of the agent that triggered the call (optional).
session_id: Session identifier for tracking (optional).
context: Arbitrary context dict for backend-specific data.
tool_name: Name of the tool requesting approval.
arguments: Arguments the tool will be called with.
risk_level: Risk classification (critical / high / medium / low).
agent_name: Name of the agent that triggered the call (optional).
session_id: Session identifier for tracking (optional).
context: Arbitrary context dict for backend-specific data.
approval_id: Stable correlation id that survives a process restart
and can be matched to an inbound channel callback.
Auto-generated when not supplied.
"""
tool_name: str
@@ -31,6 +40,7 @@ class ApprovalRequest:
agent_name: Optional[str] = None
session_id: Optional[str] = None
context: Dict[str, Any] = field(default_factory=dict)
approval_id: str = field(default_factory=_new_approval_id)
@dataclass
@@ -114,3 +124,52 @@ class ApprovalProtocol(Protocol):
ApprovalDecision indicating whether execution is approved.
"""
...
@runtime_checkable
class ApprovalStoreProtocol(Protocol):
"""Protocol for durable persistence of pending approvals.
A store lets pending approvals survive a process restart: requests are
persisted with a stable ``approval_id``, outstanding ones are re-hydrated
on startup, and decisions are recorded as a durable audit trail.
Behaviour is unchanged when no store is configured — in-memory remains the
zero-dependency default. Heavy backends (e.g. SQLite) live in the wrapper
package; this contract lives in core so any backend and any channel can
interoperate.
Example::
class MyStore:
async def persist(self, approval_id, request, *, expires_at):
...
async def list_pending(self):
return []
async def resolve(self, approval_id, decision):
...
"""
async def persist(
self,
approval_id: str,
request: ApprovalRequest,
*,
expires_at: float,
) -> None:
"""Durably store a pending approval keyed by ``approval_id``."""
...
async def list_pending(self) -> List[Tuple[str, ApprovalRequest]]:
"""Return outstanding (un-resolved, un-expired) pending approvals."""
...
async def resolve(self, approval_id: str, decision: ApprovalDecision) -> None:
"""Record a final decision for ``approval_id`` as an audit trail.
``decision.metadata['terminal']`` may carry an explicit terminal state
(``"approved"``, ``"denied"`` or ``"expired"``) so the timeout path can
be recorded distinctly from a user denial. When absent the state is
derived from ``decision.approved``.
"""
...
@@ -27,6 +27,7 @@ from .protocols import (
EmailInbox,
SupportsPresentation,
PlatformCapabilities,
WebhookVerifierProtocol,
)
from .presentation import (
MessagePresentation,
@@ -44,12 +45,19 @@ from .interactive import (
InteractiveContext,
InteractiveRegistry,
InteractiveHandler,
InteractiveAuthorizer,
encode_action,
decode_callback,
create_registry,
get_registry,
register_handler,
unregister_handler,
make_reply_handler,
REPLY_NAMESPACE,
)
from .agent_reply import (
AgentReply,
extract_presentation,
)
from .config import BotConfig, BotOSConfig
@@ -83,13 +91,19 @@ __all__ = [
"BlockType",
"adapt_presentation",
"PlatformCapabilities",
"WebhookVerifierProtocol",
"InteractiveContext",
"InteractiveRegistry",
"InteractiveHandler",
"InteractiveAuthorizer",
"encode_action",
"decode_callback",
"create_registry",
"get_registry",
"register_handler",
"unregister_handler",
"make_reply_handler",
"REPLY_NAMESPACE",
"AgentReply",
"extract_presentation",
]
@@ -0,0 +1,128 @@
"""
Agent-authored reply contract for interactive bot UI.
Defines the seam that lets an agent (or a hook) emit a portable
``MessagePresentation`` as part of a normal reply, alongside optional text.
Channels render the presentation via the existing per-platform renderers and
fall back to text where rich UI is unsupported.
This is a core protocol with no heavy implementations: channel rendering and
session wiring live in the wrapper (praisonai). Producers return one of:
* ``str`` -> plain text (unchanged, fully backward compatible)
* ``MessagePresentation`` -> interactive UI (text fallback derived from text blocks)
* ``AgentReply`` -> explicit text + presentation pairing
* a dict ``{"text": ..., "presentation": ...}`` -> serialised form
``extract_presentation`` normalises any of these into ``(text, presentation)``
so the bot session can stay agnostic about which form the agent used.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Optional, Tuple
from .presentation import MessagePresentation, BlockType
@dataclass
class AgentReply:
"""An agent reply that may carry interactive UI alongside text.
Attributes:
text: Plain-text answer (used as the text fallback for channels that
cannot render rich UI, and as the spoken/preview content otherwise).
presentation: Optional portable presentation with buttons/selects.
"""
text: str = ""
presentation: Optional[MessagePresentation] = None
def to_dict(self) -> dict:
"""Serialise to a plain dict."""
data: dict = {"text": self.text}
if self.presentation is not None:
data["presentation"] = self.presentation.to_dict()
return data
@classmethod
def from_dict(cls, data: dict) -> "AgentReply":
"""Create from a plain dict."""
pres = data.get("presentation")
return cls(
text=data.get("text", "") or "",
presentation=(
MessagePresentation.from_dict(pres)
if isinstance(pres, dict)
else pres
),
)
def _text_from_presentation(presentation: MessagePresentation) -> str:
"""Derive a plain-text fallback from a presentation's text/context blocks."""
parts = []
for block in presentation.blocks:
btype = block.type.value if isinstance(block.type, BlockType) else block.type
if btype in (BlockType.TEXT.value, BlockType.CONTEXT.value) and block.text:
parts.append(block.text)
return "\n\n".join(parts)
def extract_presentation(
result: Any,
) -> Tuple[str, Optional[MessagePresentation]]:
"""Normalise an agent result into ``(text, presentation)``.
Accepts the plain ``str`` reply path (returns ``(result, None)``), an
``AgentReply``, a ``MessagePresentation`` (text fallback derived from its
text blocks), a serialised dict, or any object exposing a ``presentation``
attribute. This keeps the bot session backward compatible: existing
string-returning agents are unaffected, while presentation-aware agents get
their UI rendered.
Args:
result: The value returned/emitted by an agent turn.
Returns:
A ``(text, presentation)`` tuple. ``presentation`` is ``None`` when the
result carried no interactive UI.
"""
if result is None:
return ("", None)
if isinstance(result, str):
return (result, None)
if isinstance(result, AgentReply):
text = result.text
if not text and result.presentation is not None:
text = _text_from_presentation(result.presentation)
return (text, result.presentation)
if isinstance(result, MessagePresentation):
return (_text_from_presentation(result), result)
if isinstance(result, dict) and (
"presentation" in result or "blocks" in result
):
if "blocks" in result and "presentation" not in result:
pres = MessagePresentation.from_dict(result)
return (_text_from_presentation(pres), pres)
reply = AgentReply.from_dict(result)
text = reply.text
if not text and reply.presentation is not None:
text = _text_from_presentation(reply.presentation)
return (text, reply.presentation)
# Duck-typed: any object exposing a ``presentation`` attribute.
pres = getattr(result, "presentation", None)
if isinstance(pres, MessagePresentation):
text = getattr(result, "text", None)
if not text:
text = getattr(result, "output", None) or _text_from_presentation(pres)
return (str(text), pres)
# Unknown shape: stringify so the text path stays robust.
return (str(result), None)
@@ -45,6 +45,66 @@ class InteractiveContext:
InteractiveHandler = Callable[[InteractiveContext], Awaitable[Optional[str]]]
# An authorizer decides whether the resolving actor (context.user_id) is
# permitted to act on a given interactive callback. Returning False rejects
# the callback before the handler runs, so unauthorized clicks never resolve
# privileged actions (e.g. tool approvals).
InteractiveAuthorizer = Callable[[InteractiveContext], bool]
REPLY_NAMESPACE = "reply"
# Marker prefixing a hashed (non-routable) reply value. Mirrors
# ``presentation.REPLY_HASH_MARKER``; kept local so the inbound handler does not
# import the (heavier) render module. When an agent reply value is too long for
# the channel callback byte-cap, the encoder emits ``reply:#<digest>`` instead
# of a lossy prefix; the handler detects this marker and declines to route a
# value the agent never authored.
_REPLY_HASH_MARKER = "#"
def make_reply_handler(
continue_turn: Callable[[str, "InteractiveContext"], Awaitable[Optional[str]]],
) -> InteractiveHandler:
"""Build a handler for agent-authored ``reply`` button/select callbacks.
A ``reply`` action (see ``PresentationAction.reply``) carries the value the
user chose. When clicked, the channel dispatches the ``reply:<value>``
callback to this handler, which extracts ``<value>`` and feeds it back into
the agent turn via *continue_turn* — without channels inferring intent from
``/``-prefixed strings.
Args:
continue_turn: Async callable ``(value, context) -> Optional[str]`` that
continues the agent loop with the chosen value as the next input and
returns the agent's response (or ``None``).
Returns:
An ``InteractiveHandler`` to register under the ``reply`` namespace.
"""
async def _handler(context: "InteractiveContext") -> Optional[str]:
payload = context.platform_data.get("decoded_payload") or {}
value = payload.get("value")
if value is None:
# Fall back to parsing the raw callback when dispatch didn't decode.
data = context.callback_data or ""
if data.startswith(f"{REPLY_NAMESPACE}:"):
value = data[len(REPLY_NAMESPACE) + 1:]
if value is None:
return None
if value.startswith(_REPLY_HASH_MARKER):
# The original value was too long for the channel callback byte-cap
# and was hashed for transport. Feeding the digest into the turn
# would route a value the agent never authored, so decline instead.
logger.warning(
"Reply value exceeded the channel callback limit and could not "
"be routed; keep quick-reply values short enough to fit the cap."
)
return None
return await continue_turn(value, context)
return _handler
def encode_action(namespace: str, action: "PresentationAction") -> str:
"""Encode an action with namespace for callback data.
@@ -58,7 +118,15 @@ def encode_action(namespace: str, action: "PresentationAction") -> str:
"""
from .presentation import ActionType
if action.type == ActionType.CALLBACK:
if action.type == ActionType.REPLY:
# Reply actions route the chosen value back into the next agent turn.
# The ``reply:`` namespace is reserved for this; the value is the input.
# Only ``None`` falls back to the caller namespace — an explicit empty
# string is a valid reply value (``make_reply_handler`` accepts it).
if action.value is not None:
return f"reply:{action.value}"
return namespace
elif action.type == ActionType.CALLBACK:
# For callback type, encode namespace with the value
if action.value:
return f"{namespace}:{action.value}"
@@ -111,22 +179,35 @@ class InteractiveRegistry:
def __init__(self):
"""Initialize the registry."""
self._handlers: Dict[str, InteractiveHandler] = {}
self._authorizers: Dict[str, "InteractiveAuthorizer"] = {}
self._fallback_handler: Optional[InteractiveHandler] = None
def register(
self,
namespace: str,
handler: InteractiveHandler
handler: InteractiveHandler,
authorize: Optional["InteractiveAuthorizer"] = None,
) -> None:
"""Register a handler for a namespace.
Args:
namespace: The namespace to handle (e.g., "approval", "menu")
handler: Async function to handle callbacks in this namespace
authorize: Optional callable that receives the InteractiveContext
and returns True if the resolving actor (``context.user_id``)
is permitted to act on this callback. When provided, an
unauthorized click is rejected before the handler runs, so it
never resolves the underlying action. When omitted, behaviour
is unchanged (any clicker is allowed) for backward
compatibility.
"""
if namespace in self._handlers:
logger.warning(f"Overwriting existing handler for namespace: {namespace}")
self._handlers[namespace] = handler
if authorize is not None:
self._authorizers[namespace] = authorize
else:
self._authorizers.pop(namespace, None)
logger.debug(f"Registered handler for namespace: {namespace}")
def unregister(self, namespace: str) -> None:
@@ -137,6 +218,7 @@ class InteractiveRegistry:
"""
if namespace in self._handlers:
del self._handlers[namespace]
self._authorizers.pop(namespace, None)
logger.debug(f"Unregistered handler for namespace: {namespace}")
def set_fallback(self, handler: InteractiveHandler) -> None:
@@ -162,6 +244,28 @@ class InteractiveRegistry:
handler = self._handlers.get(namespace)
if handler:
# Enforce actor authorization before resolving the action.
# This is the security boundary: an unauthorized clicker must
# never resolve a privileged callback (e.g. a tool approval).
authorizer = self._authorizers.get(namespace)
if authorizer is not None:
try:
allowed = authorizer(context)
except Exception as e:
logger.error(
f"Authorizer for namespace '{namespace}' raised; "
f"denying callback: {e}"
)
allowed = False
if not allowed:
logger.warning(
f"Unauthorized interactive callback: actor "
f"'{context.user_id}' rejected for namespace "
f"'{namespace}'"
)
context.platform_data["authorized"] = False
return False
try:
# Add decoded payload to context
context.platform_data["decoded_namespace"] = namespace
@@ -28,6 +28,7 @@ class ActionType(str, Enum):
CALLBACK = "callback" # Opaque callback data for the plugin
URL = "url" # Open a URL
WEB_APP = "web_app" # Open a web app (Telegram mini apps, etc)
REPLY = "reply" # Feed value back into the agent turn as next input
class ButtonStyle(str, Enum):
@@ -92,6 +93,33 @@ class PresentationAction:
web_app_url=data.get("web_app_url"),
)
@classmethod
def reply(cls, value: str) -> "PresentationAction":
"""Create a ``reply`` action that feeds *value* back into the agent turn.
When a user clicks a button (or picks a select option) carrying a reply
action, the chosen *value* is routed back through the interactive
registry as the next agent input — no ``/``-prefixed command parsing by
channels required. See ``ActionType.REPLY`` and the ``reply`` namespace
handler in ``interactive.py``.
"""
return cls(type=ActionType.REPLY, value=value)
@classmethod
def callback(cls, value: str) -> "PresentationAction":
"""Create an opaque ``callback`` action carrying *value*."""
return cls(type=ActionType.CALLBACK, value=value)
@classmethod
def command(cls, command: str) -> "PresentationAction":
"""Create a ``command`` action that runs a slash *command*."""
return cls(type=ActionType.COMMAND, command=command)
@classmethod
def open_url(cls, url: str) -> "PresentationAction":
"""Create a ``url`` action that opens *url*."""
return cls(type=ActionType.URL, url=url)
@dataclass
class PresentationButton:
@@ -224,6 +252,33 @@ class PresentationBlock:
def make_context(content: str) -> "PresentationBlock":
"""Create a context block (smaller text)."""
return PresentationBlock(type=BlockType.CONTEXT, text=content)
@staticmethod
def quick_replies(
choices: List[Any],
priority_base: int = 0,
) -> "PresentationBlock":
"""Create a row of quick-reply buttons from ``(label, value)`` pairs.
Each choice may be a ``(label, value)`` tuple or a plain string (used as
both label and value). Every button carries a ``reply`` action so a
click feeds ``value`` back into the next agent turn. This is the
agent-facing shortcut for the common "pick one" interaction.
"""
items: List["PresentationButton"] = []
for choice in choices:
if isinstance(choice, (tuple, list)) and len(choice) >= 2:
label, value = choice[0], choice[1]
else:
label = value = choice
items.append(
PresentationButton(
label=str(label),
action=PresentationAction.reply(str(value)),
priority=priority_base,
)
)
return PresentationBlock(type=BlockType.BUTTONS, buttons=items)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
@@ -471,9 +526,22 @@ def _adapt_button(button: PresentationButton, limits: PresentationLimits) -> Pre
action = button.action
url = button.url
if action is not None and not limits.supports_web_apps:
if action is not None:
action_type = action.type.value if isinstance(action.type, ActionType) else action.type
if action_type == ActionType.WEB_APP.value and action.web_app_url:
# Degrade reply -> callback so existing per-channel renderers (which only
# map command/callback/url/web_app) can carry the payload. The
# ``reply:`` prefix lets the inbound registry route the chosen value
# back into the next agent turn (see interactive.py).
if action_type == ActionType.REPLY.value and action.value is not None:
action = PresentationAction(
type=ActionType.CALLBACK,
value=_encode_reply_callback(action.value),
)
elif (
not limits.supports_web_apps
and action_type == ActionType.WEB_APP.value
and action.web_app_url
):
# Degrade web_app -> url when unsupported
if url is None:
url = action.web_app_url
@@ -489,6 +557,40 @@ def _adapt_button(button: PresentationButton, limits: PresentationLimits) -> Pre
)
REPLY_CALLBACK_PREFIX = "reply:"
# Marker prefixing a hashed (non-routable) reply payload. A reply value that
# does not fit the channel callback byte-cap is encoded as
# ``reply:#<digest>``. The ``#`` marker lets the inbound reply handler detect a
# lossy payload and refuse to feed a corrupted/colliding value into the next
# agent turn (see ``make_reply_handler``), rather than silently routing a
# truncated prefix that two distinct choices could share.
REPLY_HASH_MARKER = "#"
def _encode_reply_callback(value: str) -> str:
"""Build a channel-safe callback payload for a ``reply`` action.
Produces ``reply:<value>`` so the inbound interactive registry can route
the chosen value back into the next agent turn. The size check is measured
in UTF-8 bytes because channel callback caps (e.g. Telegram's 64-byte cap)
are byte limits, not character limits.
When the raw form exceeds ``_MAX_CALLBACK_LEN`` the value is replaced with a
short, collision-resistant hash marked with ``#`` (``reply:#<digest>``).
Truncating to a prefix was unsafe: two long choices sharing a prefix would
collapse to the same payload, and the agent would receive a value it never
authored. The hash keeps distinct choices distinct; the marker lets the
reply handler recognise that the original value could not be carried inline
and avoid routing a lossy value into the turn.
"""
raw = f"{REPLY_CALLBACK_PREFIX}{value}"
if len(raw.encode("utf-8")) <= _MAX_CALLBACK_LEN:
return raw
digest = hashlib.sha1(value.encode("utf-8")).hexdigest()[:16]
return f"{REPLY_CALLBACK_PREFIX}{REPLY_HASH_MARKER}{digest}"
def _encode_select_callback(action_id: str, value: str) -> str:
"""Build a channel-safe callback payload for a degraded select option.
@@ -499,7 +601,7 @@ def _encode_select_callback(action_id: str, value: str) -> str:
collision-resistant hash so distinct options stay distinct after truncation.
"""
raw = f"select:{action_id}:{value}"
if len(raw) <= _MAX_CALLBACK_LEN:
if len(raw.encode("utf-8")) <= _MAX_CALLBACK_LEN:
return raw
digest = hashlib.sha1(value.encode("utf-8")).hexdigest()[:16]
prefix = f"select:{action_id}:"
@@ -20,6 +20,7 @@ from typing import (
Callable,
Dict,
List,
Mapping,
Optional,
Protocol,
TypedDict,
@@ -85,6 +86,24 @@ class PlatformCapabilities:
max_files_per_message: Maximum number of file attachments per message
max_file_size_mb: Maximum file size in megabytes
supported_file_types: List of supported file extensions/mime types
accepts_webhooks: Whether the platform delivers inbound messages via webhook
verifies_webhook_signature: Whether the adapter verifies inbound webhook
authenticity. Adapters that accept webhooks MUST set this True and
expose a ``webhook_verifier`` so central ingress can enforce it
fail-closed.
reconciles_unknown_send: Whether the adapter can confirm whether a prior
send attempt actually landed (e.g. via a provider message-status
lookup keyed on the idempotency key). When True the durable outbox
reconciles an in-flight (``sending``) entry on restart before
re-dispatch, upgrading delivery from at-least-once to
effectively-once. When False, delivery remains at-least-once and a
crash mid-send may re-send the message.
supports_idempotency_token: Whether the transport accepts a
provider-level idempotency token so the platform itself
de-duplicates a retried send. This is an informational capability
descriptor only; the durable outbox does not forward a token on
resend, so adapters relying on provider-side dedupe should also set
``reconciles_unknown_send`` to get effectively-once delivery.
"""
max_message_length: int = 4096
@@ -97,6 +116,10 @@ class PlatformCapabilities:
max_files_per_message: int = 1
max_file_size_mb: int = 10
supported_file_types: List[str] = field(default_factory=lambda: ["*"])
accepts_webhooks: bool = False
verifies_webhook_signature: bool = False
reconciles_unknown_send: bool = False
supports_idempotency_token: bool = False
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
@@ -111,6 +134,10 @@ class PlatformCapabilities:
"max_files_per_message": self.max_files_per_message,
"max_file_size_mb": self.max_file_size_mb,
"supported_file_types": self.supported_file_types,
"accepts_webhooks": self.accepts_webhooks,
"verifies_webhook_signature": self.verifies_webhook_signature,
"reconciles_unknown_send": self.reconciles_unknown_send,
"supports_idempotency_token": self.supports_idempotency_token,
}
@classmethod
@@ -127,9 +154,46 @@ class PlatformCapabilities:
max_files_per_message=data.get("max_files_per_message", 1),
max_file_size_mb=data.get("max_file_size_mb", 10),
supported_file_types=data.get("supported_file_types", ["*"]),
accepts_webhooks=data.get("accepts_webhooks", False),
verifies_webhook_signature=data.get("verifies_webhook_signature", False),
reconciles_unknown_send=data.get("reconciles_unknown_send", False),
supports_idempotency_token=data.get("supports_idempotency_token", False),
)
@runtime_checkable
class WebhookVerifierProtocol(Protocol):
"""Protocol for verifying inbound webhook authenticity.
Internet-facing bot adapters (Slack, WhatsApp, Linear, AgentMail, …)
receive webhooks that must be authenticated before they become an agent
run. This protocol declares the contract so the wrapper ingress can
enforce verification centrally and fail-closed: an adapter that declares
``PlatformCapabilities.accepts_webhooks`` must expose a verifier and that
verifier must pass before dispatch.
Implementations live in the ``praisonai`` wrapper package and typically
wrap an HMAC comparison over the raw request body.
Example:
class SlackVerifier:
def verify(self, *, headers, raw_body):
...
"""
def verify(self, *, headers: Mapping[str, str], raw_body: bytes) -> bool:
"""Return True if the inbound webhook is authentic.
Args:
headers: Inbound request headers (case-insensitive mapping).
raw_body: The exact raw request body bytes (pre-parse).
Returns:
True if the signature/secret verifies, False otherwise.
"""
...
class MessageType(str, Enum):
"""Types of bot messages."""
@@ -145,6 +145,10 @@ class LearnConfig:
db_url: Optional[str] = None # Database connection URL (for non-file backends)
store_path: Optional[str] = None # Custom storage path (for file backend)
# Retention / lifecycle governance (0 = unbounded; backward-compatible default)
max_entries: int = 0 # Per-store cap; least-used/oldest archived beyond it
retention_days: int = 0 # Archive entries unused for this many days (staleness)
# LLM for learning extraction
llm: Optional[str] = None # LLM model for extracting learnings (defaults to agent's LLM)
@@ -166,6 +170,8 @@ class LearnConfig:
"backend": self.backend.value if isinstance(self.backend, LearnBackend) else self.backend,
"db_url": self.db_url,
"store_path": self.store_path,
"max_entries": self.max_entries,
"retention_days": self.retention_days,
"llm": self.llm,
}
@@ -107,12 +107,26 @@ def is_path_like(value: str) -> bool:
if not isinstance(value, str):
return False
# Check for path indicators
if value.startswith(("./", "../", "/", "~/")):
# Check for path indicators (POSIX and Windows-style prefixes)
if value.startswith(("./", "../", "/", "~/", ".\\", "..\\", "\\\\", "\\", "~\\")):
return True
# Check for Windows drive paths like C:\data or C:/data
if (
len(value) >= 3
and value[1] == ":"
and value[0].isalpha()
and value[2] in ("\\", "/")
):
return True
# Check for directory indicator
if value.endswith("/"):
if value.endswith(("/", "\\")):
return True
# Check for embedded backslash separators (relative Windows path like "knowledge\docs").
# Backslash is not valid in normal config string values, so it is a strong path signal.
if "\\" in value:
return True
# Check for file extension (common ones)
@@ -19,6 +19,7 @@ from .protocols import (
GatewayMessage,
EventType,
OperatorScope,
GatewayCloseCode,
# Push protocols and dataclasses
PushChannelProtocol,
PresenceProtocol,
@@ -38,10 +39,19 @@ from .protocols import (
RouteFacts,
RouteMatch,
resolve_route,
# Per-route, trust-tiered toolset scoping (Issue #2298)
ToolPolicy,
UNTRUSTED_DENY_SUBSTRINGS,
TRUST_TIERS,
# Outbound send-policy guard (Issue #2226)
SendDecision,
SendPolicyProtocol,
SendPolicy,
# Gateway idle-dormancy / scale-to-zero (Issue #2332)
IdleDecision,
GatewayIdlePolicyProtocol,
GatewayIdlePolicy, # backward-compat alias
ScaleToZeroPolicy,
# Protocol version negotiation
PROTOCOL_VERSION,
MIN_PROTOCOL_VERSION,
@@ -51,6 +61,13 @@ from .protocols import (
GapInfo,
ResumeSnapshot,
)
from .hooks import (
HookAction,
HookConfig,
InboundTriggerProtocol,
render_template,
compute_idempotency_key,
)
from .config import (
GatewayConfig,
SessionConfig,
@@ -112,6 +129,7 @@ __all__ = [
"GatewayMessage",
"EventType",
"OperatorScope",
"GatewayCloseCode",
# Push protocols (always available)
"PushChannelProtocol",
"PresenceProtocol",
@@ -131,10 +149,19 @@ __all__ = [
"RouteFacts",
"RouteMatch",
"resolve_route",
# Per-route, trust-tiered toolset scoping (Issue #2298)
"ToolPolicy",
"UNTRUSTED_DENY_SUBSTRINGS",
"TRUST_TIERS",
# Outbound send-policy guard (Issue #2226)
"SendDecision",
"SendPolicyProtocol",
"SendPolicy",
# Gateway idle-dormancy / scale-to-zero (Issue #2332)
"IdleDecision",
"GatewayIdlePolicyProtocol",
"GatewayIdlePolicy",
"ScaleToZeroPolicy",
# Protocol version negotiation
"PROTOCOL_VERSION",
"MIN_PROTOCOL_VERSION",
@@ -143,6 +170,12 @@ __all__ = [
"ProtocolHelloOk",
"GapInfo",
"ResumeSnapshot",
# Inbound trigger / webhook contract (Issue #2281)
"HookAction",
"HookConfig",
"InboundTriggerProtocol",
"render_template",
"compute_idempotency_key",
# Config (always available)
"GatewayConfig",
"SessionConfig",
@@ -229,6 +229,9 @@ class GatewayConfig:
ssl_cert: Path to SSL certificate (for HTTPS/WSS)
ssl_key: Path to SSL key
max_buffered_bytes: Maximum buffered bytes before slow consumer disconnect (default 1MB)
max_queued_frames: Maximum queued outbound frames per client before slow
consumer disconnect (0 = unlimited frame count; byte ceiling still
applies). Default 1000.
push: Push notification service configuration
auth_scopes: Optional operator scope policy mapping token -> list of
scope names (see OperatorScope). When None/empty (default), any
@@ -251,6 +254,7 @@ class GatewayConfig:
ssl_cert: Optional[str] = None
ssl_key: Optional[str] = None
max_buffered_bytes: int = 1024 * 1024 # 1MB default
max_queued_frames: int = 1000 # Per-client outbound frame ceiling
push: PushConfig = field(default_factory=PushConfig)
auth_scopes: Optional[Dict[str, List[str]]] = None
@@ -262,6 +266,10 @@ class GatewayConfig:
raise ValueError(
"max_buffered_bytes must be >= 0 (use 0 to disable slow-consumer checks)"
)
if self.max_queued_frames < 0:
raise ValueError(
"max_queued_frames must be >= 0 (use 0 to disable the frame-count ceiling)"
)
if self.max_connections < 0:
raise ValueError("max_connections must be >= 0")
if self.heartbeat_interval < 0:
@@ -335,6 +343,7 @@ class GatewayConfig:
"reconnect_timeout": self.reconnect_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,
"push": self.push.to_dict(),
"scope_policy_enabled": self.has_scope_policy,
}
@@ -429,6 +438,7 @@ class MultiChannelGatewayConfig:
gateway: GatewayConfig = field(default_factory=GatewayConfig)
agents: Dict[str, Dict[str, Any]] = field(default_factory=dict)
channels: Dict[str, ChannelRouteConfig] = field(default_factory=dict)
hooks: List[Any] = field(default_factory=list)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "MultiChannelGatewayConfig":
@@ -510,7 +520,8 @@ class MultiChannelGatewayConfig:
reconnect_timeout=gw_data.get("reconnect_timeout", 60),
ssl_cert=gw_data.get("ssl_cert"),
ssl_key=gw_data.get("ssl_key"),
max_buffered_bytes=gw_data.get("max_buffered_bytes", 1024 * 1024),
max_buffered_bytes=int(gw_data.get("max_buffered_bytes", 1024 * 1024)),
max_queued_frames=int(gw_data.get("max_queued_frames", 1000)),
auth_scopes=auth_scopes,
)
@@ -533,10 +544,29 @@ class MultiChannelGatewayConfig:
},
)
# Parse inbound trigger hooks (Issue #2281). The hooks live either at
# the top level (``hooks:``) or nested under ``gateway:`` for grouping.
from .hooks import HookConfig
raw_hooks = data.get("hooks")
if raw_hooks is None:
raw_hooks = gw_data.get("hooks")
hooks: List[HookConfig] = []
for entry in raw_hooks or []:
if isinstance(entry, dict) and entry.get("path"):
try:
hooks.append(HookConfig.from_dict(entry))
except (ValueError, TypeError):
import logging
logging.getLogger(__name__).warning(
"Skipping invalid gateway hook entry: %s", entry
)
return cls(
gateway=gateway_config,
agents=agents,
channels=channels,
hooks=hooks,
)
def to_dict(self) -> Dict[str, Any]:
@@ -547,4 +577,5 @@ class MultiChannelGatewayConfig:
"channels": {
name: ch.to_dict() for name, ch in self.channels.items()
},
"hooks": [h.to_dict() for h in self.hooks],
}
@@ -0,0 +1,286 @@
"""
Inbound trigger (webhook) contract for the PraisonAI gateway.
This module is **protocol-only** and carries no heavy dependencies. It defines
the declarative shape of an inbound event trigger — an authenticated HTTP
surface that, on receiving an external payload, runs an agent (or wakes a
session) and delivers the result through a channel.
The HTTP route registration, request authentication, idempotency store, rate
limiting and channel delivery all live in the ``praisonai`` wrapper gateway
(``praisonai.gateway.server``). Here we only describe:
- ``HookConfig``: the declarative trigger definition (path/auth/action/agent/
session/idempotency/delivery/message).
- ``render_template``: a tiny, dependency-free ``{{ payload.x }}`` / ``{x}``
templating helper used to build session keys, idempotency keys and messages
from a JSON payload.
- ``compute_idempotency_key``: derive a stable dedup key from a payload.
- ``InboundTriggerProtocol``: the structural contract a gateway implements to
register hooks.
It sits alongside the existing internal lifecycle hooks (``SCHEDULE_TRIGGER``,
``GATEWAY_START``) as the *inbound* counterpart: those fire from inside the
process; this fires from an external HTTP request.
"""
from __future__ import annotations
import hashlib
import re
from dataclasses import dataclass, field
from typing import Any, Dict, Optional, Protocol, runtime_checkable
__all__ = [
"HookAction",
"HookConfig",
"InboundTriggerProtocol",
"compute_idempotency_key",
"render_template",
]
class HookAction:
"""Allowed hook actions (string constants, kept dependency-free)."""
AGENT = "agent" # Run an agent turn on the templated message.
WAKE = "wake" # Nudge an existing session without a new user message.
@classmethod
def all(cls) -> "list[str]":
return [cls.AGENT, cls.WAKE]
# Matches both ``{{ payload.a.b }}`` (Jinja-ish) and ``{a.b}`` (str.format-ish)
# placeholders so the same template works across the YAML and Python surfaces.
# A single combined pattern is used (double-brace alternative first so it wins)
# so each placeholder is substituted exactly once in one pass — a payload value
# that itself contains ``{...}`` is therefore never re-expanded.
_PLACEHOLDER = re.compile(r"\{\{\s*([^}]+?)\s*\}\}|\{\s*([^{}]+?)\s*\}")
def _lookup(path: str, payload: Dict[str, Any]) -> Any:
"""Resolve a dotted ``path`` against ``payload``.
A leading ``payload.`` prefix is optional, so ``{{ payload.from }}`` and
``{from}`` resolve identically. Missing keys resolve to an empty string so
a template never raises on a partial payload.
"""
key = path.strip()
if key.startswith("payload."):
key = key[len("payload."):]
elif key == "payload":
return payload
current: Any = payload
for part in key.split("."):
part = part.strip()
if not part:
continue
if isinstance(current, dict) and part in current:
current = current[part]
else:
return ""
return current
def render_template(template: Optional[str], payload: Dict[str, Any]) -> str:
"""Render ``template`` against ``payload`` with no external dependencies.
Supports ``{{ payload.x }}`` and ``{x}`` placeholders resolving dotted
paths into the JSON ``payload``. Returns an empty string when ``template``
is falsy. Unknown placeholders render as empty strings rather than raising.
Args:
template: The template string (or None).
payload: The decoded JSON request body.
Returns:
The rendered string.
"""
if not template:
return ""
def _resolve(match: "re.Match[str]") -> str:
# group(1) is the ``{{ ... }}`` body, group(2) the ``{ ... }`` body.
expr = match.group(1) if match.group(1) is not None else match.group(2)
value = _lookup(expr, payload)
return "" if value is None else str(value)
return _PLACEHOLDER.sub(_resolve, template)
def compute_idempotency_key(
template: Optional[str],
payload: Dict[str, Any],
*,
path: str = "",
) -> str:
"""Derive a stable idempotency key for a hook delivery.
When ``template`` is provided it is rendered against ``payload`` and the
result (scoped by ``path``) is hashed. When it is not, the whole payload is
hashed deterministically so identical retried deliveries still dedup.
Args:
template: Optional idempotency-key template (e.g. ``"{message_id}"``).
payload: The decoded JSON request body.
path: The hook path, mixed in so the same id on different hooks does
not collide.
Returns:
A hex digest usable as a dedup key.
"""
if template:
rendered = render_template(template, payload).strip()
if rendered:
basis = f"{path}\x00{rendered}"
return hashlib.sha256(basis.encode("utf-8")).hexdigest()
# Fall back to a deterministic hash of the full payload.
import json
try:
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
except (TypeError, ValueError):
canonical = repr(payload)
basis = f"{path}\x00{canonical}"
return hashlib.sha256(basis.encode("utf-8")).hexdigest()
@dataclass
class HookConfig:
"""Declarative definition of an inbound event trigger.
Exposes ``POST /hooks/<path>`` on the gateway. On a request the gateway
authenticates, deduplicates, resolves a session, runs the agent (or wakes a
session), and delivers the reply through the delivery router.
Attributes:
path: URL path segment, e.g. ``"gmail"`` exposes ``POST /hooks/gmail``.
agent: Agent id to run (``action="agent"``). Defaults to the gateway's
first registered agent when omitted.
action: ``"agent"`` runs a turn, ``"wake"`` nudges a session.
auth: Optional bearer token / shared secret required on the request.
When omitted the gateway's own ``auth_token`` is used.
session_key: Template for the session id, so related events share
context, e.g. ``"hook:gmail:{message_id}"``.
idempotency_key: Template for the dedup key, e.g. ``"{message_id}"``.
When omitted the whole payload is hashed.
deliver_to: ``channel:target`` delivery spec for the agent reply, e.g.
``"telegram:123456789"``. Omit to skip outbound delivery.
message: Template for the message built from the payload.
enabled: Whether the hook is active.
metadata: Free-form extra settings.
"""
path: str
agent: Optional[str] = None
action: str = HookAction.AGENT
auth: Optional[str] = None
session_key: Optional[str] = None
idempotency_key: Optional[str] = None
deliver_to: Optional[str] = None
message: Optional[str] = None
enabled: bool = True
metadata: Dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
self.path = (self.path or "").strip().strip("/")
if not self.path:
raise ValueError("HookConfig.path must be a non-empty path segment")
if self.action not in HookAction.all():
raise ValueError(
f"HookConfig.action must be one of {HookAction.all()}, "
f"got {self.action!r}"
)
@property
def route(self) -> str:
"""The full gateway route this hook is served on."""
return f"/hooks/{self.path}"
def resolve_session_key(self, payload: Dict[str, Any]) -> str:
"""Resolve the session id for ``payload``.
Falls back to a stable per-hook key when no ``session_key`` template is
configured, so a hook without an explicit key still groups its events.
"""
if self.session_key:
rendered = render_template(self.session_key, payload).strip()
if rendered:
return rendered
return f"hook:{self.path}"
def resolve_idempotency_key(self, payload: Dict[str, Any]) -> str:
"""Resolve the dedup key for ``payload``."""
return compute_idempotency_key(
self.idempotency_key, payload, path=self.path
)
def resolve_message(self, payload: Dict[str, Any]) -> str:
"""Render the agent message for ``payload``."""
return render_template(self.message, payload)
def to_dict(self) -> Dict[str, Any]:
"""Convert to a dictionary (hides the auth secret)."""
return {
"path": self.path,
"agent": self.agent,
"action": self.action,
"auth": "***" if self.auth else None,
"session_key": self.session_key,
"idempotency_key": self.idempotency_key,
"deliver_to": self.deliver_to,
"message": self.message,
"enabled": self.enabled,
"metadata": dict(self.metadata),
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "HookConfig":
"""Create a ``HookConfig`` from a parsed YAML/JSON mapping."""
known = {
"path",
"agent",
"action",
"auth",
"session_key",
"idempotency_key",
"deliver_to",
"message",
"enabled",
"metadata",
}
return cls(
path=data.get("path", ""),
agent=data.get("agent"),
action=data.get("action", HookAction.AGENT),
auth=data.get("auth"),
session_key=data.get("session_key"),
idempotency_key=data.get("idempotency_key"),
deliver_to=data.get("deliver_to"),
message=data.get("message"),
enabled=data.get("enabled", True),
metadata={
**(data.get("metadata") or {}),
**{k: v for k, v in data.items() if k not in known},
},
)
@runtime_checkable
class InboundTriggerProtocol(Protocol):
"""Structural contract a gateway implements to host inbound triggers."""
def register_hook(self, hook: "HookConfig | Dict[str, Any]", **kwargs: Any) -> str:
"""Register an inbound trigger, returning its path."""
...
def unregister_hook(self, path: str) -> bool:
"""Remove a registered hook by path."""
...
def list_hooks(self) -> "list[str]":
"""List registered hook paths."""
...
@@ -24,6 +24,7 @@ from typing import (
Literal,
Optional,
Protocol,
Set,
TypedDict,
Union,
runtime_checkable,
@@ -52,6 +53,23 @@ class ConnectErrorCode(str, Enum):
CONFIGURATION_ERROR = "configuration_error"
class GatewayCloseCode(str, Enum):
"""Structured, machine-readable reasons for a server-initiated close.
Distinct from :class:`ConnectErrorCode` (which describes why a *connection*
was rejected at/handshake time), these describe why an already-established
connection is being torn down by the server mid-session.
Codes:
SLOW_CONSUMER: The client's outbound buffer exceeded the gateway's
advertised ``max_buffered_bytes`` policy (a genuinely slow/stalled
consumer). The server evicts it so its backlog cannot grow without
bound or stall delivery to healthy clients.
"""
SLOW_CONSUMER = "slow_consumer"
class ConnectRecoveryStep(str, Enum):
"""Machine-readable recovery hint for a connection rejection.
@@ -1010,6 +1028,105 @@ class OutboundDeliveryProtocol(Protocol):
...
# ---------------------------------------------------------------------------
# Per-route, trust-tiered toolset scoping (Issue #2298)
# ---------------------------------------------------------------------------
# Conservative deny-list applied to ``trust: "untrusted"`` routes. Inbound
# content from strangers / generic webhooks is the framework's largest
# prompt-injection surface, so dangerous tool *families* are never advertised
# to the model on these routes (shell, file mutation, delegation,
# self-scheduling). Names are matched case-insensitively against substrings of
# the tool name so deployments do not have to enumerate every concrete tool.
UNTRUSTED_DENY_SUBSTRINGS: List[str] = [
"shell",
"exec",
"command",
"subprocess",
"write_file",
"edit_file",
"delete_file",
"rm_file",
"delegate",
"handoff",
"cronjob",
"schedule",
]
# Trust tiers, ordered from least to most privileged.
TRUST_TIERS: List[str] = ["untrusted", "standard", "trusted"]
@dataclass
class ToolPolicy:
"""Declarative, per-route scope applied to an agent's tool surface.
Mirrors the scheduler's ``RunPolicy.filter_tools`` contract (wrapper layer)
but lives in core so :class:`RouteBinding` can *declare* the scope without
importing any heavy wrapper code. The wrapper inbound path applies it via a
small apply/restore helper, exactly as the scheduler already does for
unattended runs.
Attributes:
allow_tools: If set, only tools whose name is in this set are kept;
everything else is removed. ``None`` means "allow all except
``deny_tools`` / the trust deny-list".
deny_tools: Exact tool names removed before the run.
deny_substrings: Case-insensitive substrings; a tool whose name
contains any of them is removed (used by the ``untrusted`` tier).
"""
allow_tools: Optional[Set[str]] = None
deny_tools: Set[str] = field(default_factory=set)
deny_substrings: List[str] = field(default_factory=list)
@property
def is_noop(self) -> bool:
"""``True`` when the policy would never remove any tool."""
return (
self.allow_tools is None
and not self.deny_tools
and not self.deny_substrings
)
@staticmethod
def _tool_name(tool: Any) -> str:
"""Best-effort name for a tool (matches Agent's own resolution)."""
name = getattr(tool, "name", None)
if isinstance(name, str) and name:
return name
dunder = getattr(tool, "__name__", None)
if isinstance(dunder, str) and dunder:
return dunder
if isinstance(tool, dict):
fn = tool.get("function")
if isinstance(fn, dict) and isinstance(fn.get("name"), str):
return fn["name"]
top = tool.get("name")
if isinstance(top, str) and top:
return top
return str(tool)
def is_tool_allowed(self, tool: Any) -> bool:
"""Return ``True`` if ``tool`` may be exposed on this route."""
name = self._tool_name(tool)
if name in self.deny_tools:
return False
lowered = name.lower()
for needle in self.deny_substrings:
if needle and needle.lower() in lowered:
return False
if self.allow_tools is not None and name not in self.allow_tools:
return False
return True
def filter_tools(self, tools: Optional[List[Any]]) -> List[Any]:
"""Return a copy of ``tools`` with denied/disallowed tools removed."""
if not tools:
return []
return [tool for tool in tools if self.is_tool_allowed(tool)]
# ---------------------------------------------------------------------------
# Inbound route binding (Issue #2225)
# ---------------------------------------------------------------------------
@@ -1034,6 +1151,13 @@ class RouteBinding:
channel_id: Specific chat/channel id.
account: Receiving bot account (for multi-account channels).
priority: Higher wins; ties are broken by specificity then order.
trust: Optional trust tier ("untrusted" | "standard" | "trusted").
``untrusted`` advertises a conservative, read-only-leaning toolset
to the model so dangerous tools are never offered on third-party /
stranger / generic-webhook routes (Issue #2298). ``None`` /
``standard`` / ``trusted`` apply no tier deny-list.
allow_tools: If set, only these tool names are exposed on this route.
deny_tools: Tool names removed before the run on this route.
"""
agent: str
@@ -1043,6 +1167,9 @@ class RouteBinding:
channel_id: Optional[str] = None
account: Optional[str] = None
priority: int = 0
trust: Optional[str] = None
allow_tools: Optional[List[str]] = None
deny_tools: Optional[List[str]] = None
# Specificity weights — exact peer beats role/channel beats account
# beats chat-type. Higher means more specific.
@@ -1054,6 +1181,24 @@ class RouteBinding:
"chat_type": 2,
}
def __post_init__(self) -> None:
"""Normalise ``trust`` so config typos cannot silently fail open.
Whitespace/case variants of a known tier (e.g. ``" Untrusted "``) are
canonicalised. Any *unknown* non-empty value is treated as the most
restrictive tier (``untrusted``) rather than as "no policy", so a
misconfigured route can never accidentally expose the full toolset.
"""
if self.trust is None:
return
normalized = str(self.trust).strip().lower()
if not normalized:
self.trust = None
elif normalized in TRUST_TIERS:
self.trust = normalized
else:
self.trust = "untrusted"
@property
def specificity(self) -> int:
"""Sum of weights for the conditions this binding constrains on."""
@@ -1079,6 +1224,29 @@ class RouteBinding:
return False
return True
def tool_policy(self) -> Optional["ToolPolicy"]:
"""Build the :class:`ToolPolicy` this binding declares, if any.
Returns ``None`` when the binding does not constrain the toolset, so
callers can cheaply skip the apply/restore dance for trusted routes.
The ``untrusted`` trust tier seeds a conservative substring deny-list;
explicit ``allow_tools`` / ``deny_tools`` layer on top of it.
"""
deny_substrings: List[str] = []
if self.trust == "untrusted":
deny_substrings = list(UNTRUSTED_DENY_SUBSTRINGS)
allow = set(self.allow_tools) if self.allow_tools else None
deny = set(self.deny_tools) if self.deny_tools else set()
if allow is None and not deny and not deny_substrings:
return None
return ToolPolicy(
allow_tools=allow,
deny_tools=deny,
deny_substrings=deny_substrings,
)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "RouteBinding":
"""Create a binding from a YAML/dict mapping.
@@ -1094,6 +1262,9 @@ class RouteBinding:
channel_id=_as_opt_str(data.get("channel_id")),
account=_as_opt_str(data.get("account")),
priority=int(data.get("priority", 0) or 0),
trust=_as_opt_str(data.get("trust")),
allow_tools=_as_opt_str_list(data.get("allow_tools")),
deny_tools=_as_opt_str_list(data.get("deny_tools")),
)
@@ -1138,6 +1309,24 @@ def _as_opt_str(value: Any) -> Optional[str]:
return str(value)
def _as_opt_str_list(value: Any) -> Optional[List[str]]:
"""Coerce a YAML scalar or sequence into ``Optional[List[str]]``.
Accepts a single string (wrapped into a one-element list) or any iterable
of values; returns ``None`` for ``None``/empty so an absent key stays
unconstrained.
"""
if value is None:
return None
if isinstance(value, str):
return [value] if value else None
try:
items = [str(v) for v in value]
except TypeError:
return [str(value)]
return items or None
def resolve_route(
bindings: List[RouteBinding],
facts: RouteFacts,
@@ -1692,6 +1881,156 @@ class SendPolicy:
return SendDecision(allow=True)
# ---------------------------------------------------------------------------
# Gateway idle-dormancy / scale-to-zero (Issue #2332)
# ---------------------------------------------------------------------------
@dataclass
class IdleDecision:
"""Result of an idle/quiesce evaluation.
Attributes:
idle: Whether the gateway is currently quiescent.
reason: Optional human-readable explanation (logged on quiesce).
"""
idle: bool
reason: str = ""
@runtime_checkable
class GatewayIdlePolicyProtocol(Protocol):
"""Protocol for gateway-process idle/scale-to-zero decisions.
Pure, import-free decision contract consumed by the wrapper's
``BotOS`` run-loop. The wrapper supplies live facts (running turns,
last inbound timestamp, background-work flag) and the policy decides
whether the whole gateway may safely stand down. Concrete drivers
(suspend the compute host, stand transports down, register a wake
URL) live in the wrapper; this contract keeps the *decision* testable
in isolation without a live gateway.
A config-driven default (:class:`ScaleToZeroPolicy`) is provided for
the common "idle for N minutes with nothing in flight" case.
"""
def should_arm(
self,
*,
transports_quiescable: bool,
wake_registered: bool,
) -> bool:
"""Return whether dormancy may be armed at all.
Implementations gate on whether transports can be cleanly stood
down and a wake path exists, so a gateway never quiesces into a
state it cannot resume from.
"""
...
def is_idle(
self,
*,
running_turns: int,
last_inbound_ts: float,
has_background_work: bool,
now: float,
) -> IdleDecision:
"""Return an :class:`IdleDecision` for the supplied facts."""
...
class ScaleToZeroPolicy:
"""Config-driven idle policy for safe scale-to-zero.
The default referenced by ``scale_to_zero:`` blocks in ``gateway.yaml``
and the ``BotOS(..., idle_policy=...)`` Python surface. It is
intentionally minimal and dependency-free so the decision lives in
core and is provable in isolation; the wrapper owns the side effects
(suspend host, stand transports down, wake endpoint).
The gateway is considered idle only when *all* guards pass:
* no in-flight agent turn (``running_turns == 0``),
* no live background work (scheduled jobs, delegated subagents,
durable outbox drains — ``has_background_work`` is ``False``),
* no inbound message for at least ``idle_timeout_minutes``.
Example::
ScaleToZeroPolicy(idle_timeout_minutes=5,
wake_url="https://my-bot.fly.dev/_wake")
"""
def __init__(
self,
idle_timeout_minutes: float = 5.0,
wake_url: Optional[str] = None,
enabled: bool = True,
):
if idle_timeout_minutes <= 0:
raise ValueError(
f"idle_timeout_minutes must be > 0, got {idle_timeout_minutes!r}"
)
self.idle_timeout_minutes = float(idle_timeout_minutes)
self.wake_url = wake_url
self.enabled = bool(enabled)
@property
def idle_timeout_seconds(self) -> float:
return self.idle_timeout_minutes * 60.0
def should_arm(
self,
*,
transports_quiescable: bool,
wake_registered: bool,
) -> bool:
if not self.enabled:
return False
# Never arm into a state we cannot resume from.
return bool(transports_quiescable and wake_registered)
def is_idle(
self,
*,
running_turns: int,
last_inbound_ts: float,
has_background_work: bool,
now: float,
) -> IdleDecision:
if not self.enabled:
return IdleDecision(idle=False, reason="scale_to_zero disabled")
if running_turns > 0:
return IdleDecision(
idle=False,
reason=f"{running_turns} agent turn(s) in flight",
)
if has_background_work:
return IdleDecision(
idle=False,
reason="background work in progress",
)
elapsed = now - last_inbound_ts
if elapsed < self.idle_timeout_seconds:
remaining = self.idle_timeout_seconds - elapsed
return IdleDecision(
idle=False,
reason=f"last inbound {elapsed:.0f}s ago; {remaining:.0f}s to idle",
)
return IdleDecision(
idle=True,
reason=f"idle for {elapsed:.0f}s with nothing in flight",
)
# Backward-compatible alias. The canonical name follows the repo's
# ``*Protocol`` suffix convention (e.g. ``SendPolicyProtocol``); the old
# name is retained so existing imports keep working.
GatewayIdlePolicy = GatewayIdlePolicyProtocol
# ---------------------------------------------------------------------------
# Protocol Version Negotiation (Issue #2130)
# ---------------------------------------------------------------------------
@@ -1516,46 +1516,6 @@ Now provide your final answer using this result. Summarize the information natur
return self._is_ollama_provider() and iteration_count == 0
return False
def _try_parse_tool_call_json(self, response_text: str, iteration_count: int) -> tuple:
"""
Try to parse tool call from JSON response text.
Returns:
tuple: (tool_calls list or None, parse_error string or None)
"""
if not response_text or not response_text.strip():
return None, None
try:
response_json = json.loads(response_text.strip())
if isinstance(response_json, dict) and "name" in response_json:
tool_calls = [{
"id": f"tool_{iteration_count}",
"type": "function",
"function": {
"name": response_json["name"],
"arguments": json.dumps(response_json.get("arguments", {}))
}
}]
return tool_calls, None
elif isinstance(response_json, list):
tool_calls = []
for idx, tool_json in enumerate(response_json):
if isinstance(tool_json, dict) and "name" in tool_json:
tool_calls.append({
"id": f"tool_{iteration_count}_{idx}",
"type": "function",
"function": {
"name": tool_json["name"],
"arguments": json.dumps(tool_json.get("arguments", {}))
}
})
return tool_calls if tool_calls else None, None
else:
return None, "Response is not a valid tool call format (missing 'name' field)"
except json.JSONDecodeError as e:
return None, f"JSON parse error: {str(e)}"
def _validate_tool_call(self, tool_call: Dict, formatted_tools: Optional[List]) -> Optional[str]:
"""
Validate a tool call against available tools.
@@ -5574,26 +5534,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
return response_text, tool_calls if tool_calls else None
def _prepare_response_logging(self, temperature: float, stream: bool, verbose: bool, markdown: bool, **kwargs) -> Optional[Dict[str, Any]]:
"""Prepare debug logging information for response methods"""
if get_logger().getEffectiveLevel() == logging.DEBUG:
debug_info = {
"model": self.model,
"timeout": self.timeout,
"temperature": temperature,
"top_p": self.top_p,
"n": self.n,
"max_tokens": self.max_tokens,
"presence_penalty": self.presence_penalty,
"frequency_penalty": self.frequency_penalty,
"stream": stream,
"verbose": verbose,
"markdown": markdown,
"kwargs": str(kwargs)
}
return debug_info
return None
def _process_streaming_chunk(self, chunk) -> Optional[str]:
"""Extract content from a streaming chunk"""
if chunk and chunk.choices and chunk.choices[0].delta.content:
@@ -92,6 +92,12 @@ class LearnManager:
self._stores: Dict[str, Any] = {}
self._custom_stores = custom_stores or {}
# Retention policy (0 = unbounded; backward-compatible default)
retention = {
"max_entries": getattr(self.config, "max_entries", 0),
"retention_days": getattr(self.config, "retention_days", 0),
}
# Built-in stores are added first, custom stores can override them
if self.config.persona and "persona" not in self._custom_stores:
self._stores["persona"] = PersonaStore(
@@ -99,6 +105,7 @@ class LearnManager:
user_id=self.user_id,
scope=scope,
backend=self._backend,
**retention,
)
if self.config.insights and "insights" not in self._custom_stores:
@@ -107,6 +114,7 @@ class LearnManager:
user_id=self.user_id,
scope=scope,
backend=self._backend,
**retention,
)
if self.config.thread and "threads" not in self._custom_stores:
@@ -115,6 +123,7 @@ class LearnManager:
user_id=self.user_id,
scope=scope,
backend=self._backend,
**retention,
)
if self.config.patterns and "patterns" not in self._custom_stores:
@@ -123,6 +132,7 @@ class LearnManager:
user_id=self.user_id,
scope=scope,
backend=self._backend,
**retention,
)
if self.config.decisions and "decisions" not in self._custom_stores:
@@ -131,6 +141,7 @@ class LearnManager:
user_id=self.user_id,
scope=scope,
backend=self._backend,
**retention,
)
if self.config.feedback and "feedback" not in self._custom_stores:
@@ -139,6 +150,7 @@ class LearnManager:
user_id=self.user_id,
scope=scope,
backend=self._backend,
**retention,
)
if self.config.improvements and "improvements" not in self._custom_stores:
@@ -147,6 +159,7 @@ class LearnManager:
user_id=self.user_id,
scope=scope,
backend=self._backend,
**retention,
)
# Add custom stores last (they override built-in stores)
@@ -315,6 +328,27 @@ class LearnManager:
for store_name, store in self._stores.items()
}
def prune(self) -> Dict[str, int]:
"""Archive stale/excess entries across all stores (lifecycle governance).
Deterministic, usage-driven retention: entries unused beyond
``retention_days`` and least-used/oldest entries beyond ``max_entries``
are moved to a recoverable archive. No-op for stores without limits.
Returns:
Dict mapping store name to number of entries archived.
"""
pruned = {}
for store_name, store in self._stores.items():
prune_fn = getattr(store, "prune", None)
if callable(prune_fn):
try:
pruned[store_name] = prune_fn()
except Exception as e:
logging.warning(f"Failed to prune store '{store_name}': {e}")
pruned[store_name] = 0
return pruned
@property
def pending_learnings(self) -> List[LearnEntry]:
"""Get list of pending learnings awaiting approval (PROPOSE mode)."""
@@ -32,6 +32,8 @@ class LearnEntry:
metadata: Dict[str, Any] = field(default_factory=dict)
created_at: str = field(default_factory=lambda: datetime.utcnow().isoformat())
updated_at: str = field(default_factory=lambda: datetime.utcnow().isoformat())
use_count: int = 0
last_used: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
return {
@@ -40,6 +42,8 @@ class LearnEntry:
"metadata": self.metadata,
"created_at": self.created_at,
"updated_at": self.updated_at,
"use_count": self.use_count,
"last_used": self.last_used,
}
@classmethod
@@ -50,6 +54,8 @@ class LearnEntry:
metadata=data.get("metadata", {}),
created_at=data.get("created_at", datetime.utcnow().isoformat()),
updated_at=data.get("updated_at", datetime.utcnow().isoformat()),
use_count=data.get("use_count", 0),
last_used=data.get("last_used"),
)
@@ -67,10 +73,15 @@ class BaseStore(ABC):
user_id: Optional[str] = None,
scope: str = "private",
backend: Optional["StorageBackendProtocol"] = None,
max_entries: int = 0,
retention_days: int = 0,
):
self.user_id = user_id or "default"
self.scope = scope
self._backend = backend
# Retention policy: 0 disables (permissive default — backward-compatible)
self.max_entries = max_entries or 0
self.retention_days = retention_days or 0
self.store_path = store_path or self._default_path()
self._entries: Dict[str, LearnEntry] = {}
self._was_updated: bool = False
@@ -151,10 +162,29 @@ class BaseStore(ABC):
metadata=metadata or {},
)
self._entries[entry_id] = entry
# Bounded retention: prune stale/excess entries on write (no-op if unconfigured).
# Protect the entry we just added so a full store never evicts the new
# learning and returns a value that is no longer retrievable.
self.prune(save=False, protect_id=entry_id)
self._save()
self._was_updated = True
return entry
def _touch(self, entries: List[LearnEntry]) -> None:
"""Record usage telemetry for retrieved entries.
Bumps use_count/last_used so value is observable and retention is
usage-aware. Persisted only when entries were actually touched.
"""
if not entries:
return
now = datetime.utcnow().isoformat()
for entry in entries:
entry.use_count += 1
entry.last_used = now
self._save()
self._was_updated = True
def get(self, entry_id: str) -> Optional[LearnEntry]:
"""Get entry by ID."""
return self._entries.get(entry_id)
@@ -166,13 +196,17 @@ class BaseStore(ABC):
entry for entry in self._entries.values()
if query_lower in entry.content.lower()
]
return results[:limit]
results = results[:limit]
self._touch(results)
return results
def list_all(self, limit: int = 100) -> List[LearnEntry]:
"""List all entries."""
entries = list(self._entries.values())
entries.sort(key=lambda x: x.updated_at, reverse=True)
return entries[:limit]
entries = entries[:limit]
self._touch(entries)
return entries
def update(self, entry_id: str, content: str, metadata: Optional[Dict[str, Any]] = None) -> Optional[LearnEntry]:
"""Update an existing entry."""
@@ -203,6 +237,106 @@ class BaseStore(ABC):
self._save()
self._was_updated = True
return count
def _archive_path(self) -> Path:
"""Path to the recoverable archive file for this store."""
return Path(self.store_path).with_suffix(".archive.json")
def _archive(self, entries: List[LearnEntry]) -> bool:
"""Append evicted entries to a recoverable archive (never hard-delete).
Returns True only when the entries were durably written, so callers can
keep the active store and archive consistent (no silent data loss when
the sidecar is unwritable).
"""
if not entries:
return True
try:
import json
archive_path = self._archive_path()
existing: List[Dict[str, Any]] = []
if archive_path.exists():
with open(archive_path, "r", encoding="utf-8") as f:
existing = json.load(f)
if not isinstance(existing, list):
existing = []
existing.extend(e.to_dict() for e in entries)
archive_path.parent.mkdir(parents=True, exist_ok=True)
with open(archive_path, "w", encoding="utf-8") as f:
json.dump(existing, f, ensure_ascii=False, indent=2)
return True
except Exception as e:
import logging
logging.warning(f"Failed to archive pruned entries: {e}")
return False
def _is_stale(self, entry: LearnEntry, now: datetime) -> bool:
"""Whether an entry exceeds the retention window (usage-driven, deterministic)."""
if self.retention_days <= 0:
return False
reference = entry.last_used or entry.updated_at or entry.created_at
try:
ref_dt = datetime.fromisoformat(reference)
except (ValueError, TypeError):
return False
age_days = (now - ref_dt).total_seconds() / 86400.0
return age_days > self.retention_days
def prune(self, save: bool = True, protect_id: Optional[str] = None) -> int:
"""Archive stale and least-used/oldest excess entries.
Deterministic, usage-driven retention (no LLM judgment):
- Entries unused beyond ``retention_days`` are archived as stale.
- When more than ``max_entries`` remain, the least-used (then oldest)
are archived until the cap is met.
Archival is recoverable (entries moved to a ``.archive.json`` sidecar);
never a silent hard-delete. Returns the number of entries archived.
No-op (returns 0) when neither limit is configured — backward-compatible.
Args:
save: Persist the active store after pruning.
protect_id: Entry id that must never be evicted (e.g. a learning
just added), so a full store cannot discard the new value.
"""
if self.max_entries <= 0 and self.retention_days <= 0:
return 0
now = datetime.utcnow()
evicted: List[LearnEntry] = []
# 1. Staleness: archive entries beyond the retention window
if self.retention_days > 0:
for entry_id, entry in list(self._entries.items()):
if entry_id == protect_id:
continue
if self._is_stale(entry, now):
evicted.append(self._entries.pop(entry_id))
# 2. Cap: archive least-used (then oldest) beyond max_entries
if self.max_entries > 0 and len(self._entries) > self.max_entries:
candidates = [
e for e in self._entries.values() if e.id != protect_id
]
remaining = sorted(
candidates,
key=lambda e: (e.use_count, e.last_used or "", e.created_at),
)
overflow = len(self._entries) - self.max_entries
for entry in remaining[:overflow]:
evicted.append(self._entries.pop(entry.id))
if evicted:
# Recoverable archival is mandatory: if the sidecar write fails,
# restore the evicted entries rather than silently losing them.
if not self._archive(evicted):
for entry in evicted:
self._entries[entry.id] = entry
return 0
self._was_updated = True
if save:
self._save()
return len(evicted)
class PersonaStore(BaseStore):
@@ -76,7 +76,10 @@ def __getattr__(name: str):
return StylePreset
if name == "OutputConfig":
from .config import OutputConfig
# Consolidated to the canonical config.OutputConfig (see issue #2294).
# The former divergent dataclass in output/config.py was unused; this
# import path is preserved for backward compatibility.
from ..config import OutputConfig
return OutputConfig
# Status output (for status preset - no timestamps)
@@ -1,16 +0,0 @@
"""
Output Configuration for PraisonAI Agents.
"""
from dataclasses import dataclass
from typing import Optional
@dataclass
class OutputConfig:
"""Configuration for output formatting."""
enabled: bool = True
default_format: str = "markdown" # markdown, plain, json
max_length: Optional[int] = None
truncate_long_responses: bool = False
include_metadata: bool = False
@@ -189,4 +189,4 @@ class PraisonAIRuntime:
type="error",
content=str(e),
metadata={'runtime': self.runtime_id}
)
)
@@ -25,6 +25,22 @@ _DIAGNOSTICS_MAX_CHARS = 2000
# Seconds before a diagnostics subprocess is abandoned.
_DIAGNOSTICS_TIMEOUT = 10
# File extension -> LSP language id, used to pick a language server for the
# edited file. Mirrors the languages with a default server in ``lsp/config.py``
# (DEFAULT_SERVERS); anything not listed falls back to the per-language checker.
_LSP_LANGUAGE_BY_EXT = {
".py": "python",
".pyi": "python",
".js": "javascript",
".jsx": "javascript",
".mjs": "javascript",
".cjs": "javascript",
".ts": "typescript",
".tsx": "typescript",
".rs": "rust",
".go": "go",
}
class EditTools:
"""Tools for file editing and patching operations."""
@@ -213,15 +229,161 @@ class EditTools:
return tool_name, argv
return None
def _run_diagnostics(self, safe_path: str, display_path: str) -> str:
"""Run a checker on ``safe_path`` and return a bounded diagnostics block.
@staticmethod
def _bound_output(output: str) -> str:
"""Truncate ``output`` to the shared diagnostics character budget."""
if len(output) > _DIAGNOSTICS_MAX_CHARS:
return output[:_DIAGNOSTICS_MAX_CHARS] + "\n... (diagnostics truncated)"
return output
Returns an empty string when diagnostics are disabled, no checker is
available, or (in ``auto`` mode) the checker reports no problems. Any
exception is logged and swallowed so the edit result is never lost.
def _try_lsp_diagnostics(self, safe_path: str, display_path: str) -> Optional[str]:
"""Query the existing LSP client for diagnostics on ``safe_path``.
Returns a formatted, bounded diagnostics block (matching the shape used
by the per-language checker) when a language server is available for the
file's language, ``None`` when no server is configured/installed so the
caller can fall back to the legacy checker. Everything is lazy: the LSP
client is only imported and spawned when a server actually exists, so
Python-only/silent runs pay no startup cost. Any failure returns
``None`` so a missing/slow server never blocks a successful edit.
"""
import shutil
ext = os.path.splitext(safe_path)[1].lower()
language = _LSP_LANGUAGE_BY_EXT.get(ext)
if language is None:
return None
try:
from ..lsp.config import DEFAULT_SERVERS
except Exception:
return None
server = DEFAULT_SERVERS.get(language)
if not server:
return None
# Only spawn a server whose command is actually installed; otherwise
# fall back to the zero-config checker without importing the client.
if not shutil.which(server["command"]):
return None
try:
import asyncio
from ..lsp.client import LSPClient
from ..lsp.types import DiagnosticSeverity
except Exception as e:
logger.debug("LSP diagnostics unavailable for %s: %s", display_path, e)
return None
async def _collect() -> Optional[list]:
client = LSPClient(language=language)
# Keep the whole exchange inside the shared diagnostics budget so a
# slow server cannot stall the edit result.
client.config.timeout = min(client.config.timeout, _DIAGNOSTICS_TIMEOUT)
try:
if not await client.start():
return None
if not await client.open_document(safe_path):
return None
# Diagnostics arrive asynchronously via publishDiagnostics; poll
# briefly within the timeout budget for the server to report.
# ``get_diagnostics`` returns ``[]`` both before any publish and
# after a clean publish, so also watch the client's diagnostics
# store keyed by URI to tell "not reported yet" from "reported,
# no problems" — that lets a clean file return promptly instead
# of sleeping the full timeout, and distinguishes a real
# all-clear (``[]``) from "never published" (``None``) so the
# caller can fall back to the legacy checker only in the latter.
loop = asyncio.get_event_loop()
uri = f"file://{os.path.abspath(safe_path)}"
published = getattr(client, "_diagnostics", None)
deadline = loop.time() + _DIAGNOSTICS_TIMEOUT
diags: Optional[list] = None
while loop.time() < deadline:
diags = await client.get_diagnostics(safe_path)
if diags or (isinstance(published, dict) and uri in published):
break
diags = None
await asyncio.sleep(0.2)
return diags
finally:
try:
await client.close_document(safe_path)
except Exception:
pass
await client.stop()
try:
try:
asyncio.get_running_loop()
except RuntimeError:
running = False
else:
running = True
if running:
# An event loop is already running (e.g. async agent context);
# don't risk re-entrancy — defer to the checker fallback.
return None
diagnostics = asyncio.run(
asyncio.wait_for(_collect(), timeout=_DIAGNOSTICS_TIMEOUT + 5)
)
except Exception as e:
logger.debug("LSP diagnostics error for %s: %s", display_path, e)
return None
if diagnostics is None:
return None
if not diagnostics:
if self._post_edit_diagnostics == "on":
return f"\n\nDiagnostics (lsp:{language}): no problems found"
return ""
_severity_label = {
DiagnosticSeverity.ERROR: "error",
DiagnosticSeverity.WARNING: "warning",
DiagnosticSeverity.INFORMATION: "info",
DiagnosticSeverity.HINT: "hint",
}
lines = []
for d in diagnostics:
try:
line = d.range.start.line + 1
char = d.range.start.character + 1
label = _severity_label.get(d.severity, "error")
message = " ".join((d.message or "").split())
lines.append(f"{display_path}:{line}:{char}: {label}: {message}")
except Exception:
continue
if not lines:
if self._post_edit_diagnostics == "on":
return f"\n\nDiagnostics (lsp:{language}): no problems found"
return ""
output = self._bound_output("\n".join(lines))
return f"\n\nDiagnostics (lsp:{language}):\n{output}"
def _run_diagnostics(self, safe_path: str, display_path: str) -> str:
"""Run diagnostics on ``safe_path`` and return a bounded diagnostics block.
Tries the wired-in LSP client first (for any language the user has a
language server installed for) and falls back to the zero-config
per-language checker when no server is available. Returns an empty
string when diagnostics are disabled, no checker is available, or (in
``auto`` mode) no problems are found. Any exception is logged and
swallowed so the edit result is never lost.
"""
if self._post_edit_diagnostics == "off":
return ""
# Prefer the existing LSP client when a language server is available so
# multi-language edits get language-aware diagnostics; ``None`` means no
# server, so we fall through to the legacy checker below.
try:
lsp_block = self._try_lsp_diagnostics(safe_path, display_path)
if lsp_block is not None:
return lsp_block
except Exception as e: # never let LSP wiring break a successful edit
logger.debug("LSP diagnostics skipped for %s: %s", display_path, e)
try:
resolved = self._diagnostics_command(safe_path)
if resolved is None:
@@ -255,8 +417,7 @@ class EditTools:
if not output:
output = f"check failed (exit code {proc.returncode})"
if len(output) > _DIAGNOSTICS_MAX_CHARS:
output = output[:_DIAGNOSTICS_MAX_CHARS] + "\n... (diagnostics truncated)"
output = self._bound_output(output)
return f"\n\nDiagnostics ({tool_name}):\n{output}"
except Exception as e: # never let diagnostics break a successful edit
logger.debug("Diagnostics error for %s: %s", display_path, e)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.6.79"
version = "1.6.80"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -334,6 +334,29 @@ class TestKnowledgePresets:
)
assert isinstance(result, MockKnowledgeConfig)
assert "docs/" in result.sources
def test_knowledge_windows_path_string(self):
"""Windows-style directory paths should be treated as sources."""
from praisonaiagents.config.param_resolver import resolve
result = resolve(
value="C:\\knowledge",
param_name="knowledge",
config_class=MockKnowledgeConfig,
string_mode="path_as_source",
)
assert isinstance(result, MockKnowledgeConfig)
assert result.sources == ["C:\\knowledge"]
for windows_path in ("\\knowledge", "~\\knowledge", "knowledge\\docs"):
result = resolve(
value=windows_path,
param_name="knowledge",
config_class=MockKnowledgeConfig,
string_mode="path_as_source",
)
assert isinstance(result, MockKnowledgeConfig)
assert result.sources == [windows_path]
def test_knowledge_array_sources(self):
"""Array of paths should be sources list."""
@@ -478,3 +478,176 @@ class TestNoCortexParam:
spec = importlib.util.find_spec("praisonaiagents.cortex")
assert spec is None, "praisonaiagents.cortex module should not exist"
class TestUsageTelemetry:
"""Test use_count/last_used usage telemetry on retrieval."""
def setup_method(self):
self.temp_dir = tempfile.mkdtemp()
self.store_path = os.path.join(self.temp_dir, "persona.json")
def teardown_method(self):
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_new_entry_defaults(self):
"""New entries start with use_count 0 and no last_used."""
store = PersonaStore(store_path=self.store_path, user_id="u")
entry = store.add("User likes Python")
assert entry.use_count == 0
assert entry.last_used is None
def test_search_bumps_use_count(self):
"""search() records access on returned entries."""
store = PersonaStore(store_path=self.store_path, user_id="u")
store.add("User likes Python")
results = store.search("Python")
assert len(results) == 1
assert results[0].use_count == 1
assert results[0].last_used is not None
def test_list_all_bumps_use_count(self):
"""list_all() records access on returned entries."""
store = PersonaStore(store_path=self.store_path, user_id="u")
store.add("User likes Python")
store.list_all()
store.list_all()
entry = list(store._entries.values())[0]
assert entry.use_count == 2
def test_telemetry_persisted(self):
"""Usage telemetry survives reload."""
store = PersonaStore(store_path=self.store_path, user_id="u")
store.add("User likes Python")
store.search("Python")
reloaded = PersonaStore(store_path=self.store_path, user_id="u")
entry = list(reloaded._entries.values())[0]
assert entry.use_count == 1
assert entry.last_used is not None
def test_entry_roundtrip_preserves_telemetry(self):
"""LearnEntry to_dict/from_dict preserves telemetry fields."""
entry = LearnEntry(id="x", content="c", use_count=3, last_used="2025-01-01T00:00:00")
restored = LearnEntry.from_dict(entry.to_dict())
assert restored.use_count == 3
assert restored.last_used == "2025-01-01T00:00:00"
class TestBoundedRetention:
"""Test max_entries / retention_days pruning and archival."""
def setup_method(self):
self.temp_dir = tempfile.mkdtemp()
self.store_path = os.path.join(self.temp_dir, "insights.json")
def teardown_method(self):
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_unbounded_by_default(self):
"""No limits configured -> behaviour unchanged, prune is a no-op."""
store = InsightStore(store_path=self.store_path, user_id="u")
for i in range(20):
store.add(f"insight {i}")
assert len(store._entries) == 20
assert store.prune() == 0
def test_max_entries_evicts_on_add(self):
"""Exceeding max_entries archives least-used/oldest on write."""
store = InsightStore(store_path=self.store_path, user_id="u", max_entries=5)
for i in range(10):
store.add(f"insight {i}")
assert len(store._entries) == 5
def test_max_entries_keeps_most_used(self):
"""Least-used entries are evicted first."""
store = InsightStore(store_path=self.store_path, user_id="u", max_entries=3)
store.add("keep me")
for i in range(2):
store.add(f"filler {i}")
# Bump usage on the entry we want to keep via the public retrieval path
for _ in range(5):
assert store.search("keep me")
for i in range(5):
store.add(f"new {i}")
contents = [e.content for e in store._entries.values()]
assert "keep me" in contents
assert len(store._entries) == 3
def test_new_entry_not_evicted_when_full(self):
"""A freshly added entry is never the one pruned away on write."""
store = InsightStore(store_path=self.store_path, user_id="u", max_entries=3)
for i in range(3):
store.add(f"old {i}")
# Give existing entries usage so the newcomer is the least-used
for _ in range(3):
store.search("old")
new = store.add("brand new")
assert store.get(new.id) is not None
contents = [e.content for e in store._entries.values()]
assert "brand new" in contents
assert len(store._entries) == 3
def test_archive_failure_restores_entries(self):
"""If archival cannot persist, pruned entries stay in the active store."""
store = InsightStore(store_path=self.store_path, user_id="u", max_entries=2)
for i in range(5):
store.add(f"insight {i}")
# Force the archive write to fail; entries must not be lost
store._archive = lambda entries: False
before = dict(store._entries)
store.add("trigger prune")
# No entries dropped beyond the new add (archival failed -> restore)
assert set(before).issubset(set(store._entries))
def test_archive_is_recoverable(self):
"""Evicted entries are written to a recoverable archive file."""
from pathlib import Path
import json
store = InsightStore(store_path=self.store_path, user_id="u", max_entries=2)
for i in range(6):
store.add(f"insight {i}")
archive = Path(self.store_path).with_suffix(".archive.json")
assert archive.exists()
with open(archive) as f:
archived = json.load(f)
assert len(archived) == 4
def test_retention_days_archives_stale(self):
"""Entries unused beyond retention_days are archived by prune()."""
from datetime import datetime, timedelta
store = InsightStore(store_path=self.store_path, user_id="u", retention_days=30)
store.add("fresh")
stale = store.add("stale")
old = (datetime.utcnow() - timedelta(days=60)).isoformat()
stale.last_used = old
stale.updated_at = old
stale.created_at = old
store._save()
pruned = store.prune()
assert pruned == 1
contents = [e.content for e in store._entries.values()]
assert "fresh" in contents
assert "stale" not in contents
def test_manager_prune_propagates_config(self):
"""LearnManager.prune() runs across stores with config-driven limits."""
from datetime import datetime, timedelta
config = LearnConfig(persona=True, insights=False, thread=False, retention_days=30)
manager = LearnManager(config=config, user_id="u", store_path=self.temp_dir)
manager.capture_persona("fresh")
stale = manager.capture_persona("stale")
old = (datetime.utcnow() - timedelta(days=60)).isoformat()
stale.last_used = old
stale.updated_at = old
stale.created_at = old
manager._stores["persona"]._save()
result = manager.prune()
assert result["persona"] == 1
assert manager.get_stats()["persona"] == 1
def test_config_to_dict_has_retention(self):
"""LearnConfig exposes retention fields via to_dict."""
config = LearnConfig(max_entries=100, retention_days=90)
d = config.to_dict()
assert d["max_entries"] == 100
assert d["retention_days"] == 90
@@ -0,0 +1,204 @@
"""Tests for agent-authored interactive replies (presentation emit + reply route).
Covers the core seam added so an agent can emit a portable MessagePresentation
as a normal reply and have a button/select click routed back into the next turn.
"""
import asyncio
from praisonaiagents.bots import (
MessagePresentation,
PresentationBlock,
PresentationButton,
PresentationAction,
PresentationLimits,
ActionType,
BlockType,
AgentReply,
extract_presentation,
adapt_presentation,
encode_action,
decode_callback,
create_registry,
make_reply_handler,
InteractiveContext,
REPLY_NAMESPACE,
)
def test_reply_action_factory():
action = PresentationAction.reply("env=staging")
assert action.type == ActionType.REPLY
assert action.value == "env=staging"
def test_block_make_helpers():
assert PresentationBlock.make_text("hi").type == BlockType.TEXT
assert PresentationBlock.make_context("note").type == BlockType.CONTEXT
assert PresentationBlock.make_buttons([]).type == BlockType.BUTTONS
def test_quick_replies_builds_reply_buttons():
block = PresentationBlock.quick_replies([("Staging", "env=staging"), "Prod"])
assert block.type == BlockType.BUTTONS
assert block.buttons[0].action.type == ActionType.REPLY
assert block.buttons[0].action.value == "env=staging"
# Plain string choice uses itself as both label and value.
assert block.buttons[1].label == "Prod"
assert block.buttons[1].action.value == "Prod"
def test_encode_reply_action():
enc = encode_action("ignored", PresentationAction.reply("pick=a"))
assert enc == "reply:pick=a"
namespace, payload = decode_callback(enc)
assert namespace == REPLY_NAMESPACE
assert payload["value"] == "pick=a"
def test_adapt_degrades_reply_to_callback():
p = MessagePresentation([
PresentationBlock.make_buttons([
PresentationButton(label="Staging", action=PresentationAction.reply("env=staging")),
])
])
adapted = adapt_presentation(p, PresentationLimits.telegram())
btn = adapted.blocks[0].buttons[0]
# Renderers only know command/callback/url/web_app; reply degrades to callback.
assert btn.action.type == ActionType.CALLBACK
assert btn.action.value == "reply:env=staging"
def test_adapt_reply_callback_respects_length_cap():
long_value = "x" * 200
p = MessagePresentation([
PresentationBlock.make_buttons([
PresentationButton(label="L", action=PresentationAction.reply(long_value)),
])
])
adapted = adapt_presentation(p, PresentationLimits.telegram())
val = adapted.blocks[0].buttons[0].action.value
# Cap is a byte limit; an over-cap value becomes a collision-resistant hash
# marked with ``#`` (not a lossy prefix) so distinct choices stay distinct.
assert len(val.encode("utf-8")) <= 64
assert val.startswith("reply:#")
def test_adapt_long_replies_do_not_collide():
# Two distinct long values that share a prefix must NOT collapse to the same
# callback payload (the prefix-truncation bug). Hashing keeps them distinct.
a = "shared-prefix-" + "a" * 200
b = "shared-prefix-" + "b" * 200
p = MessagePresentation([
PresentationBlock.make_buttons([
PresentationButton(label="A", action=PresentationAction.reply(a)),
PresentationButton(label="B", action=PresentationAction.reply(b)),
])
])
adapted = adapt_presentation(p, PresentationLimits.telegram())
va = adapted.blocks[0].buttons[0].action.value
vb = adapted.blocks[0].buttons[1].action.value
assert va != vb
def test_adapt_reply_callback_byte_cap_with_non_ascii():
# Non-ASCII values can stay under 64 *chars* yet exceed 64 *bytes*; the
# cap must be enforced in UTF-8 bytes.
value = "é" * 40 # 40 chars, 80 bytes
p = MessagePresentation([
PresentationBlock.make_buttons([
PresentationButton(label="L", action=PresentationAction.reply(value)),
])
])
adapted = adapt_presentation(p, PresentationLimits.telegram())
val = adapted.blocks[0].buttons[0].action.value
assert len(val.encode("utf-8")) <= 64
# Over-cap -> hashed marker form, which is pure ASCII (no split codepoint).
assert val.startswith("reply:#")
def test_reply_handler_declines_hashed_value():
# A hashed (non-routable) reply value must NOT be fed into the turn — the
# agent never authored the digest. The handler declines instead.
called = {"hit": False}
async def continue_turn(value, context):
called["hit"] = True
return "should not run"
registry = create_registry()
registry.register(REPLY_NAMESPACE, make_reply_handler(continue_turn))
ctx = InteractiveContext(callback_data="reply:#deadbeefdeadbeef", user_id="u1")
handled = asyncio.run(registry.dispatch(ctx))
assert handled is False
assert called["hit"] is False
def test_encode_empty_string_reply_preserved():
# PresentationAction.reply("") is a valid choice; the encoder must keep the
# reserved reply: prefix instead of falling back to the caller namespace.
enc = encode_action("ns", PresentationAction.reply(""))
assert enc == "reply:"
namespace, payload = decode_callback(enc)
assert namespace == REPLY_NAMESPACE
assert payload["value"] == ""
def test_extract_presentation_plain_str():
text, pres = extract_presentation("hello")
assert text == "hello"
assert pres is None
def test_extract_presentation_from_presentation():
p = MessagePresentation([
PresentationBlock.make_text("Which environment?"),
PresentationBlock.quick_replies([("Staging", "staging")]),
])
text, pres = extract_presentation(p)
assert pres is p
assert "Which environment?" in text
def test_extract_presentation_from_agent_reply():
p = MessagePresentation([PresentationBlock.quick_replies([("A", "a")])])
reply = AgentReply(text="Pick one", presentation=p)
text, pres = extract_presentation(reply)
assert text == "Pick one"
assert pres is p
def test_extract_presentation_from_dict():
p = MessagePresentation([PresentationBlock.make_text("hi")])
data = AgentReply(text="t", presentation=p).to_dict()
text, pres = extract_presentation(data)
assert text == "t"
assert isinstance(pres, MessagePresentation)
assert pres.blocks[0].text == "hi"
def test_agent_reply_roundtrip():
p = MessagePresentation([PresentationBlock.quick_replies([("A", "a")])])
reply = AgentReply(text="hi", presentation=p)
restored = AgentReply.from_dict(reply.to_dict())
assert restored.text == "hi"
assert restored.presentation.blocks[0].buttons[0].action.value == "a"
def test_reply_handler_routes_value_back_into_turn():
seen = {}
async def continue_turn(value, context):
seen["value"] = value
seen["user"] = context.user_id
return f"ran with {value}"
registry = create_registry()
registry.register(REPLY_NAMESPACE, make_reply_handler(continue_turn))
ctx = InteractiveContext(callback_data="reply:env=prod", user_id="u1")
handled = asyncio.run(registry.dispatch(ctx))
assert handled is True
assert seen["value"] == "env=prod"
assert seen["user"] == "u1"
@@ -0,0 +1,107 @@
"""Unit tests for gateway idle-dormancy / scale-to-zero (Issue #2332).
Covers the pure, core-side decision predicates of ScaleToZeroPolicy and
conformance with the GatewayIdlePolicy protocol.
"""
import pytest
from praisonaiagents.gateway import (
GatewayIdlePolicy,
GatewayIdlePolicyProtocol,
IdleDecision,
ScaleToZeroPolicy,
)
def test_protocol_conformance():
policy = ScaleToZeroPolicy(idle_timeout_minutes=5)
assert isinstance(policy, GatewayIdlePolicyProtocol)
def test_backward_compat_alias():
# The old name remains importable and points at the canonical protocol.
assert GatewayIdlePolicy is GatewayIdlePolicyProtocol
assert isinstance(ScaleToZeroPolicy(idle_timeout_minutes=5), GatewayIdlePolicy)
def test_invalid_timeout_raises():
with pytest.raises(ValueError):
ScaleToZeroPolicy(idle_timeout_minutes=0)
with pytest.raises(ValueError):
ScaleToZeroPolicy(idle_timeout_minutes=-1)
def test_idle_timeout_seconds():
assert ScaleToZeroPolicy(idle_timeout_minutes=5).idle_timeout_seconds == 300.0
def test_idle_when_quiescent_past_timeout():
policy = ScaleToZeroPolicy(idle_timeout_minutes=5)
decision = policy.is_idle(
running_turns=0,
last_inbound_ts=0.0,
has_background_work=False,
now=301.0,
)
assert isinstance(decision, IdleDecision)
assert decision.idle is True
def test_not_idle_before_timeout():
policy = ScaleToZeroPolicy(idle_timeout_minutes=5)
decision = policy.is_idle(
running_turns=0,
last_inbound_ts=0.0,
has_background_work=False,
now=120.0,
)
assert decision.idle is False
assert "to idle" in decision.reason
def test_in_flight_turn_blocks_dormancy():
policy = ScaleToZeroPolicy(idle_timeout_minutes=5)
decision = policy.is_idle(
running_turns=1,
last_inbound_ts=0.0,
has_background_work=False,
now=10_000.0,
)
assert decision.idle is False
assert "in flight" in decision.reason
def test_background_work_blocks_dormancy():
policy = ScaleToZeroPolicy(idle_timeout_minutes=5)
decision = policy.is_idle(
running_turns=0,
last_inbound_ts=0.0,
has_background_work=True,
now=10_000.0,
)
assert decision.idle is False
assert "background" in decision.reason
def test_disabled_policy_never_idle():
policy = ScaleToZeroPolicy(idle_timeout_minutes=5, enabled=False)
decision = policy.is_idle(
running_turns=0,
last_inbound_ts=0.0,
has_background_work=False,
now=10_000.0,
)
assert decision.idle is False
def test_should_arm_requires_wake_and_quiescable():
policy = ScaleToZeroPolicy(idle_timeout_minutes=5, wake_url="https://x/_wake")
assert policy.should_arm(transports_quiescable=True, wake_registered=True) is True
assert policy.should_arm(transports_quiescable=False, wake_registered=True) is False
assert policy.should_arm(transports_quiescable=True, wake_registered=False) is False
def test_disabled_policy_never_arms():
policy = ScaleToZeroPolicy(idle_timeout_minutes=5, enabled=False)
assert policy.should_arm(transports_quiescable=True, wake_registered=True) is False
@@ -0,0 +1,101 @@
"""Tests for actor-authorization enforcement in the interactive registry."""
import asyncio
import pytest
from praisonaiagents.bots.interactive import (
InteractiveContext,
InteractiveRegistry,
)
def _ctx(user_id: str, callback_data: str = "approval:1a2b3c:allow") -> InteractiveContext:
return InteractiveContext(callback_data=callback_data, user_id=user_id)
def test_dispatch_without_authorizer_allows_any_actor():
registry = InteractiveRegistry()
seen = []
async def handler(ctx):
seen.append(ctx.user_id)
return "ok"
registry.register("approval", handler)
handled = asyncio.run(registry.dispatch(_ctx("999")))
assert handled is True
assert seen == ["999"]
def test_dispatch_rejects_unauthorized_actor():
registry = InteractiveRegistry()
allowed = {"requester", "admin"}
seen = []
async def handler(ctx):
seen.append(ctx.user_id)
return "ok"
registry.register(
"approval", handler, authorize=lambda ctx: ctx.user_id in allowed
)
handled = asyncio.run(registry.dispatch(_ctx("999")))
assert handled is False
assert seen == [] # handler must never run for unauthorized actor
def test_dispatch_allows_authorized_actor():
registry = InteractiveRegistry()
allowed = {"requester", "admin"}
seen = []
async def handler(ctx):
seen.append(ctx.user_id)
return "ok"
registry.register(
"approval", handler, authorize=lambda ctx: ctx.user_id in allowed
)
handled = asyncio.run(registry.dispatch(_ctx("admin")))
assert handled is True
assert seen == ["admin"]
def test_authorizer_exception_denies():
registry = InteractiveRegistry()
seen = []
async def handler(ctx):
seen.append(ctx.user_id)
return "ok"
def boom(ctx):
raise RuntimeError("authorizer failure")
registry.register("approval", handler, authorize=boom)
handled = asyncio.run(registry.dispatch(_ctx("admin")))
assert handled is False
assert seen == []
def test_unregister_clears_authorizer():
registry = InteractiveRegistry()
async def handler(ctx):
return "ok"
registry.register("approval", handler, authorize=lambda ctx: False)
registry.unregister("approval")
# Re-register with no authorizer -> should allow again
registry.register("approval", handler)
handled = asyncio.run(registry.dispatch(_ctx("999")))
assert handled is True
@@ -0,0 +1,193 @@
"""Unit tests for per-route, trust-tiered toolset scoping (Issue #2298).
Covers the core ``ToolPolicy`` filter contract and ``RouteBinding`` declaring a
policy via ``trust`` / ``allow_tools`` / ``deny_tools`` (plus YAML ``from_dict``
parsing), in praisonaiagents.gateway.protocols.
"""
from praisonaiagents.gateway import (
RouteBinding,
ToolPolicy,
UNTRUSTED_DENY_SUBSTRINGS,
TRUST_TIERS,
)
def _named(name):
"""A simple callable tool with a resolvable name."""
def _tool(): # pragma: no cover - never invoked
return None
_tool.__name__ = name
return _tool
class TestToolPolicyFilter:
def test_noop_policy_keeps_everything(self):
p = ToolPolicy()
assert p.is_noop
tools = [_named("shell"), _named("read_file")]
assert p.filter_tools(tools) == tools
def test_deny_tools_exact(self):
p = ToolPolicy(deny_tools={"shell"})
kept = p.filter_tools([_named("shell"), _named("read_file")])
assert [t.__name__ for t in kept] == ["read_file"]
def test_deny_substrings_case_insensitive(self):
p = ToolPolicy(deny_substrings=["shell"])
kept = p.filter_tools([_named("RunShellCommand"), _named("search")])
assert [t.__name__ for t in kept] == ["search"]
def test_allow_tools_is_a_whitelist(self):
p = ToolPolicy(allow_tools={"search"})
kept = p.filter_tools(
[_named("search"), _named("write_file"), _named("delete_file")]
)
assert [t.__name__ for t in kept] == ["search"]
def test_empty_input_returns_empty(self):
assert ToolPolicy(deny_tools={"x"}).filter_tools(None) == []
assert ToolPolicy(deny_tools={"x"}).filter_tools([]) == []
def test_dict_style_tool_name_resolution(self):
p = ToolPolicy(deny_tools={"shell"})
tool = {"type": "function", "function": {"name": "shell"}}
keep = {"type": "function", "function": {"name": "search"}}
kept = p.filter_tools([tool, keep])
assert kept == [keep]
def test_top_level_name_dict_is_resolved(self):
# Plain schema dicts (no OpenAI ``function`` wrapper) must still resolve
# by name so exact denies / untrusted substrings remove them.
p = ToolPolicy(deny_tools={"shell"})
tool = {"name": "shell", "description": "run a command"}
keep = {"name": "search"}
assert p.filter_tools([tool, keep]) == [keep]
def test_top_level_name_dict_untrusted_substring(self):
p = ToolPolicy(deny_substrings=["shell"])
tool = {"name": "RunShellCommand"}
keep = {"name": "search"}
assert p.filter_tools([tool, keep]) == [keep]
class TestRouteBindingToolPolicy:
def test_no_constraints_returns_none(self):
assert RouteBinding(agent="a").tool_policy() is None
def test_trusted_tier_is_noop(self):
assert RouteBinding(agent="a", trust="trusted").tool_policy() is None
def test_standard_tier_is_noop(self):
assert RouteBinding(agent="a", trust="standard").tool_policy() is None
def test_untrusted_tier_denies_dangerous_families(self):
pol = RouteBinding(agent="a", trust="untrusted").tool_policy()
assert pol is not None
kept = pol.filter_tools(
[
_named("run_shell"),
_named("write_file"),
_named("delete_file"),
_named("delegate_task"),
_named("schedule_cronjob"),
_named("web_search"),
_named("read_file"),
]
)
names = [t.__name__ for t in kept]
assert "web_search" in names
assert "read_file" in names
assert "run_shell" not in names
assert "write_file" not in names
assert "delete_file" not in names
assert "delegate_task" not in names
assert "schedule_cronjob" not in names
def test_explicit_allow_tools(self):
pol = RouteBinding(agent="a", allow_tools=["search"]).tool_policy()
assert pol is not None
kept = pol.filter_tools([_named("search"), _named("shell")])
assert [t.__name__ for t in kept] == ["search"]
def test_explicit_deny_tools(self):
pol = RouteBinding(agent="a", deny_tools=["shell"]).tool_policy()
assert pol is not None
kept = pol.filter_tools([_named("search"), _named("shell")])
assert [t.__name__ for t in kept] == ["search"]
def test_untrusted_plus_explicit_deny_layer(self):
pol = RouteBinding(
agent="a", trust="untrusted", deny_tools=["web_search"]
).tool_policy()
kept = pol.filter_tools(
[_named("web_search"), _named("run_shell"), _named("read_file")]
)
assert [t.__name__ for t in kept] == ["read_file"]
class TestRouteBindingTrustNormalization:
def test_case_and_whitespace_variants_canonicalise(self):
assert RouteBinding(agent="a", trust=" Untrusted ").trust == "untrusted"
assert RouteBinding(agent="a", trust="TRUSTED").trust == "trusted"
def test_unknown_trust_fails_closed_to_untrusted(self):
# A typo must NOT silently expose the full toolset — it down-tiers to
# the most restrictive policy instead.
b = RouteBinding(agent="a", trust="untrustd")
assert b.trust == "untrusted"
pol = b.tool_policy()
assert pol is not None
kept = pol.filter_tools([_named("run_shell"), _named("read_file")])
assert [t.__name__ for t in kept] == ["read_file"]
def test_empty_trust_string_is_none(self):
assert RouteBinding(agent="a", trust=" ").trust is None
assert RouteBinding(agent="a", trust=" ").tool_policy() is None
def test_from_dict_unknown_trust_fails_closed(self):
b = RouteBinding.from_dict({"agent": "a", "trust": "Untrusted!"})
assert b.trust == "untrusted"
assert b.tool_policy() is not None
class TestRouteBindingFromDict:
def test_from_dict_parses_trust_and_lists(self):
b = RouteBinding.from_dict(
{
"agent": "assistant",
"chat_type": "dm",
"trust": "untrusted",
"allow_tools": ["search"],
"deny_tools": ["shell", "delete_file"],
}
)
assert b.trust == "untrusted"
assert b.allow_tools == ["search"]
assert b.deny_tools == ["shell", "delete_file"]
def test_from_dict_scalar_tool_is_wrapped(self):
b = RouteBinding.from_dict({"agent": "a", "deny_tools": "shell"})
assert b.deny_tools == ["shell"]
def test_from_dict_absent_keys_stay_none(self):
b = RouteBinding.from_dict({"agent": "a", "chat_type": "dm"})
assert b.trust is None
assert b.allow_tools is None
assert b.deny_tools is None
assert b.tool_policy() is None
def test_from_dict_backward_compatible(self):
b = RouteBinding.from_dict({"agent": "a", "peer": "123", "priority": 5})
assert b.agent == "a"
assert b.peer == "123"
assert b.priority == 5
class TestModuleConstants:
def test_trust_tiers_order(self):
assert TRUST_TIERS == ["untrusted", "standard", "trusted"]
def test_untrusted_deny_substrings_nonempty(self):
assert "shell" in UNTRUSTED_DENY_SUBSTRINGS
assert len(UNTRUSTED_DENY_SUBSTRINGS) > 0
@@ -18,6 +18,112 @@ def test_webhook_insecure_override(monkeypatch):
assert webhooks_require_verification() is False
def test_verify_hmac_valid_and_invalid():
import hashlib
import hmac
from praisonai.bots.webhook_security import verify_hmac
secret = "s3cret"
body = b"payload"
sig = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
assert verify_hmac(secret, body, sig) is True
assert verify_hmac(secret, body, "deadbeef") is False
assert verify_hmac(secret, body, None) is False
assert verify_hmac("", body, sig) is False
def test_verify_hmac_prefix_and_algo():
import hashlib
import hmac
from praisonai.bots.webhook_security import verify_hmac
secret = "s3cret"
body = b"payload"
digest = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
assert verify_hmac(secret, body, f"sha256={digest}", prefix="sha256=") is True
assert verify_hmac(secret, body, f"sha256={digest}") is True
def test_verify_hmac_unknown_digest_fails_closed():
from praisonai.bots.webhook_security import verify_hmac
assert verify_hmac("s3cret", b"payload", "deadbeef", digest="not-a-real-algo") is False
def test_hmac_webhook_verifier_case_insensitive_header():
import hashlib
import hmac
from praisonai.bots.webhook_security import HmacWebhookVerifier
secret = "s3cret"
body = b"payload"
sig = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
verifier = HmacWebhookVerifier(
secret, signature_headers=["X-Hub-Signature-256"], prefix="sha256="
)
assert verifier.verify(
headers={"x-hub-signature-256": sig}, raw_body=body
) is True
assert verifier.verify(headers={"x-hub-signature-256": "bad"}, raw_body=body) is False
def test_enforce_webhook_verification_gate(monkeypatch):
from praisonai.bots.webhook_security import (
HmacWebhookVerifier,
enforce_webhook_verification,
)
monkeypatch.delenv("PRAISONAI_INSECURE_WEBHOOKS", raising=False)
assert enforce_webhook_verification(
accepts_webhooks=False, verifier=None, headers={}, raw_body=b""
) is True
assert enforce_webhook_verification(
accepts_webhooks=True, verifier=None, headers={}, raw_body=b""
) is False
monkeypatch.setenv("PRAISONAI_INSECURE_WEBHOOKS", "true")
assert enforce_webhook_verification(
accepts_webhooks=True, verifier=None, headers={}, raw_body=b""
) is True
monkeypatch.delenv("PRAISONAI_INSECURE_WEBHOOKS", raising=False)
import hashlib
import hmac
secret = "s3cret"
body = b"payload"
sig = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
verifier = HmacWebhookVerifier(secret, signature_headers=["X-Signature"])
assert enforce_webhook_verification(
accepts_webhooks=True,
verifier=verifier,
headers={"X-Signature": sig},
raw_body=body,
) is True
def test_webhook_verifier_protocol_and_capabilities_roundtrip():
from praisonaiagents.bots import (
PlatformCapabilities,
WebhookVerifierProtocol,
)
assert WebhookVerifierProtocol is not None
caps = PlatformCapabilities(accepts_webhooks=True, verifies_webhook_signature=True)
restored = PlatformCapabilities.from_dict(caps.to_dict())
assert restored.accepts_webhooks is True
assert restored.verifies_webhook_signature is True
def test_jobs_agent_root_rejects_escape(tmp_path, monkeypatch):
from praisonai.jobs.path_validation import validate_agent_file_path
@@ -171,3 +171,84 @@ class TestBudgetNoneOverhead:
)
# 0.0 is falsy → budget check is skipped (intentional)
assert not agent._max_budget
class TestPreCallEstimate:
"""Test _estimate_min_call_cost helper used by the pre-call guard."""
def test_estimate_zero_for_empty_messages(self):
from praisonaiagents import Agent
agent = Agent(name="test", instructions="test")
assert agent._estimate_min_call_cost([], None) == 0.0
def test_estimate_scales_with_input(self):
from praisonaiagents import Agent
agent = Agent(name="test", instructions="test", llm="gpt-4o")
small = agent._estimate_min_call_cost(
[{"role": "user", "content": "hi"}], None
)
large = agent._estimate_min_call_cost(
[{"role": "user", "content": "x" * 100_000}], None
)
assert large > small
def test_estimate_includes_output_reservation(self):
from praisonaiagents import Agent
agent = Agent(name="test", instructions="test", llm="gpt-4o")
msgs = [{"role": "user", "content": "hello world"}]
# A larger explicit max_tokens reservation costs more than a smaller one.
small_out = agent._estimate_min_call_cost(msgs, 100)
large_out = agent._estimate_min_call_cost(msgs, 5000)
assert large_out > small_out
def test_estimate_reserves_default_output_when_max_tokens_unset(self):
"""Unset max_tokens must still reserve a non-zero output cost so a small
prompt with a large provider-default response cannot bypass the guard."""
from praisonaiagents import Agent
agent = Agent(name="test", instructions="test", llm="gpt-4o")
msgs = [{"role": "user", "content": "hi"}]
# Input alone (2 chars -> 0 tokens) would otherwise be ~free; the default
# output reservation guarantees a positive estimate.
assert agent._estimate_min_call_cost(msgs, None) > 0.0
def test_estimate_handles_list_content(self):
from praisonaiagents import Agent
agent = Agent(name="test", instructions="test", llm="gpt-4o")
msgs = [{"role": "user", "content": [{"type": "text", "text": "x" * 8000}]}]
assert agent._estimate_min_call_cost(msgs, None) > 0.0
class TestPreCallGuard:
"""Test that the pre-call guard blocks an over-budget call before dispatch."""
def test_guard_blocks_before_dispatch(self):
"""A huge input that breaches the cap raises before the LLM is called."""
from praisonaiagents import Agent, ExecutionConfig, BudgetExceededError
agent = Agent(
name="test", instructions="test", llm="gpt-4o",
execution=ExecutionConfig(max_budget=0.0001),
)
# Make the retry path explode if it is ever reached.
agent._chat_completion_with_retry = MagicMock(
side_effect=AssertionError("LLM dispatched despite budget guard")
)
messages = [{"role": "user", "content": "x" * 200_000}]
with pytest.raises(BudgetExceededError):
agent._chat_completion(messages)
agent._chat_completion_with_retry.assert_not_called()
# No spend recorded for the blocked call.
assert agent._total_cost == 0.0
def test_guard_skips_when_warn_mode(self):
"""warn mode keeps reactive-only behaviour (no pre-call block)."""
from praisonaiagents import Agent, ExecutionConfig
agent = Agent(
name="test", instructions="test", llm="gpt-4o",
execution=ExecutionConfig(max_budget=0.0001, on_budget_exceeded="warn"),
)
est = agent._estimate_min_call_cost(
[{"role": "user", "content": "x" * 200_000}], None
)
# Estimate alone would exceed the cap, but warn mode must not pre-block.
assert est >= agent._max_budget
assert agent._on_budget_exceeded == "warn"
@@ -410,6 +410,190 @@ class TestPostEditDiagnostics:
assert "Diagnostics" in result
class TestLSPDiagnostics:
"""The post-edit diagnostics hook is wired to the existing LSP client."""
def test_off_mode_skips_lsp(self, tmp_path, monkeypatch):
# ``off`` short-circuits before any LSP attempt (zero overhead).
editor = EditTools(post_edit_diagnostics="off")
called = {"n": 0}
def _spy(self, safe_path, display_path):
called["n"] += 1
return None
monkeypatch.setattr(EditTools, "_try_lsp_diagnostics", _spy)
p = tmp_path / "x.py"
_write(p, "x = 1\n")
result = editor.edit_file(str(p), "x = 1", "x = 2")
assert "Diagnostics" not in result
assert called["n"] == 0
def test_no_server_falls_back_to_checker(self, tmp_path):
# With no language server installed for the extension, LSP returns None
# and the legacy per-language checker still surfaces syntax errors.
editor = EditTools(post_edit_diagnostics="auto")
# No server is configured for an unknown extension -> None.
assert editor._try_lsp_diagnostics(str(tmp_path / "f.unknownext"), "f") is None
p = tmp_path / "broken.py"
_write(p, "x = 1\n")
result = editor.edit_file(str(p), "x = 1", "def (:")
assert "Success" in result
assert "Diagnostics" in result
def test_lsp_block_used_when_available(self, tmp_path, monkeypatch):
# When the LSP path returns a formatted block, it is used verbatim and
# the legacy checker is not consulted.
editor = EditTools(post_edit_diagnostics="auto")
def _fake_lsp(self, safe_path, display_path):
return f"\n\nDiagnostics (lsp:typescript):\n{display_path}:1:1: error: oops"
def _boom(self, safe_path):
raise AssertionError("checker fallback should not run when LSP returns a block")
monkeypatch.setattr(EditTools, "_try_lsp_diagnostics", _fake_lsp)
monkeypatch.setattr(EditTools, "_diagnostics_command", staticmethod(_boom))
p = tmp_path / "a.ts"
_write(p, "const x = 1;\n")
result = editor.edit_file(str(p), "const x = 1;", "const y = 2;")
assert "Success" in result
assert "Diagnostics (lsp:typescript)" in result
def test_lsp_no_problems_auto_is_silent(self, tmp_path, monkeypatch):
# An LSP run with no diagnostics in ``auto`` mode appends nothing.
editor = EditTools(post_edit_diagnostics="auto")
monkeypatch.setattr(
EditTools, "_try_lsp_diagnostics",
lambda self, safe_path, display_path: "",
)
p = tmp_path / "a.ts"
_write(p, "const x = 1;\n")
result = editor.edit_file(str(p), "const x = 1;", "const y = 2;")
assert "Success" in result
assert "Diagnostics" not in result
def test_lsp_failure_falls_back_to_checker(self, tmp_path, monkeypatch):
# A raising LSP attempt must not break the edit; the checker fallback
# still runs and surfaces the Python syntax error.
editor = EditTools(post_edit_diagnostics="auto")
def _raise(self, safe_path, display_path):
raise RuntimeError("server crashed")
monkeypatch.setattr(EditTools, "_try_lsp_diagnostics", _raise)
p = tmp_path / "broken.py"
_write(p, "x = 1\n")
result = editor.edit_file(str(p), "x = 1", "def (:")
assert "Success" in result
assert "Diagnostics" in result
def test_lsp_unpublished_falls_back_to_checker(self, tmp_path, monkeypatch):
# Regression: when an installed server starts/opens but never publishes
# diagnostics within the budget (still indexing, debounced, or URI
# mismatch), the LSP path must return None (not "") so the legacy
# checker still runs and surfaces a real syntax error rather than
# reporting a false all-clear.
import asyncio
from praisonaiagents.tools import edit_tools as et
editor = EditTools(post_edit_diagnostics="auto")
class _FakeClient:
def __init__(self, *a, **k):
self.config = type("C", (), {"timeout": 5})()
self._diagnostics = {} # server never publishes
async def start(self):
return True
async def open_document(self, path):
return True
async def get_diagnostics(self, path):
return []
async def close_document(self, path):
return None
async def stop(self):
return None
monkeypatch.setattr(et, "_DIAGNOSTICS_TIMEOUT", 0.3, raising=False)
import shutil
monkeypatch.setattr(shutil, "which",
lambda cmd: "/usr/bin/" + cmd, raising=False)
monkeypatch.setattr(
"praisonaiagents.lsp.config.DEFAULT_SERVERS",
{"python": {"command": "pyright-langserver", "args": []}},
raising=False,
)
monkeypatch.setattr(
"praisonaiagents.lsp.client.LSPClient", _FakeClient, raising=False
)
# No running loop -> the sync path uses asyncio.run; force fresh loop.
try:
asyncio.get_running_loop()
pytest.skip("running loop present; sync LSP path not exercised")
except RuntimeError:
pass
block = editor._try_lsp_diagnostics(str(tmp_path / "broken.py"), "broken.py")
assert block is None # unpublished -> fall back, not a false all-clear
def test_lsp_clean_publish_is_authoritative(self, tmp_path, monkeypatch):
# A genuine clean publish ([]) returns "" in auto mode and does NOT
# redundantly run the subprocess checker.
import asyncio
from praisonaiagents.tools import edit_tools as et
editor = EditTools(post_edit_diagnostics="auto")
uri_holder = {}
class _FakeClient:
def __init__(self, *a, **k):
self.config = type("C", (), {"timeout": 5})()
self._diagnostics = uri_holder
async def start(self):
return True
async def open_document(self, path):
uri_holder[f"file://{os.path.abspath(path)}"] = []
return True
async def get_diagnostics(self, path):
return []
async def close_document(self, path):
return None
async def stop(self):
return None
import shutil
monkeypatch.setattr(shutil, "which",
lambda cmd: "/usr/bin/" + cmd, raising=False)
monkeypatch.setattr(
"praisonaiagents.lsp.config.DEFAULT_SERVERS",
{"python": {"command": "pyright-langserver", "args": []}},
raising=False,
)
monkeypatch.setattr(
"praisonaiagents.lsp.client.LSPClient", _FakeClient, raising=False
)
try:
asyncio.get_running_loop()
pytest.skip("running loop present; sync LSP path not exercised")
except RuntimeError:
pass
block = editor._try_lsp_diagnostics(str(tmp_path / "ok.py"), "ok.py")
assert block == "" # published-and-clean -> authoritative all-clear
class TestAutomaticStaleness:
def test_edit_aborts_when_changed_after_read(self, tools, tmp_path):
p = tmp_path / "a.py"
+1 -1
View File
@@ -3125,7 +3125,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.6.79"
version = "1.6.80"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+44 -4
View File
@@ -1,14 +1,14 @@
{
"version": "1.5.87",
"lastUpdated": "2026-06-25",
"lastUpdated": "2026-06-26",
"generatedBy": "praisonai._dev.parity.generator",
"sourceOfTruth": "Python SDK (praisonaiagents)",
"status": "PARITY_ACHIEVED",
"summary": {
"pythonCoreFeatures": 388,
"pythonCoreFeatures": 392,
"rustFeatures": 667,
"gapCount": 106,
"parityPercentage": 171.9
"gapCount": 110,
"parityPercentage": 170.2
},
"pythonCoreSDK": {
"exports": [
@@ -27,6 +27,7 @@
"AgentOSProtocol",
"AgentPluginProtocol",
"AgentRunOutcome",
"AgentRuntimeProtocol",
"AgentTeam",
"Agents",
"ApprovalCallback",
@@ -350,6 +351,7 @@
"is_path_like",
"is_policy_string",
"list_memory_adapters",
"list_runtimes",
"list_toolsets",
"load_plugin",
"load_skill",
@@ -364,6 +366,7 @@
"register_display_callback",
"register_memory_adapter",
"register_memory_factory",
"register_runtime",
"register_tool",
"register_toolset",
"repeat",
@@ -381,6 +384,7 @@
"resolve_planning",
"resolve_reflection",
"resolve_routing",
"resolve_runtime",
"resolve_skills",
"resolve_toolset",
"resolve_toolsets",
@@ -3089,6 +3093,15 @@
"status": "TODO",
"category": "other"
},
{
"feature": "AgentRuntimeProtocol",
"python": true,
"rust": false,
"priority": "P3",
"effort": "medium",
"status": "TODO",
"category": "other"
},
{
"feature": "AgentTeam",
"python": true,
@@ -4997,6 +5010,15 @@
"status": "TODO",
"category": "other"
},
{
"feature": "list_runtimes",
"python": true,
"rust": false,
"priority": "P3",
"effort": "low",
"status": "TODO",
"category": "other"
},
{
"feature": "list_toolsets",
"python": true,
@@ -5078,6 +5100,15 @@
"status": "TODO",
"category": "other"
},
{
"feature": "register_runtime",
"python": true,
"rust": false,
"priority": "P3",
"effort": "low",
"status": "TODO",
"category": "other"
},
{
"feature": "register_toolset",
"python": true,
@@ -5213,6 +5244,15 @@
"status": "DONE",
"category": "config"
},
{
"feature": "resolve_runtime",
"python": true,
"rust": false,
"priority": "P3",
"effort": "low",
"status": "TODO",
"category": "other"
},
{
"feature": "resolve_skills",
"python": true,
+8 -4
View File
@@ -1,16 +1,16 @@
# Rust Feature Parity Tracker
> **Python Features:** 388 | **Rust Features:** 667 | **Parity:** 74.2%
> **Python Features:** 392 | **Rust Features:** 667 | **Parity:** 73.5%
## Summary
| Metric | Count |
|--------|-------|
| Python Core Features | 388 |
| Python Core Features | 392 |
| Rust Features | 667 |
| **Actual Gap Count** | **100** |
| **Actual Gap Count** | **104** |
| Language Limitations (N/A) | 5 |
| **Parity** | **74.2%** |
| **Parity** | **73.5%** |
## Implemented Features
@@ -698,6 +698,7 @@ These Python features cannot be directly implemented in Rust due to reserved key
- ❌ `AGGRESSIVE_POLICY`
- ❌ `AgentMessageEvent`
- ❌ `AgentRunOutcome`
- ❌ `AgentRuntimeProtocol`
- ❌ `AsyncLearnProtocol`
- ❌ `AutoApproveBackend`
- ❌ `AutoMemory`
@@ -780,13 +781,16 @@ These Python features cannot be directly implemented in Rust due to reserved key
- ❌ `if_`
- ❌ `include`
- ❌ `list_memory_adapters`
- ❌ `list_runtimes`
- ❌ `list_toolsets`
- ❌ `load_skill`
- ❌ `parallel_handoffs`
- ❌ `register_memory_adapter`
- ❌ `register_memory_factory`
- ❌ `register_runtime`
- ❌ `register_tool`
- ❌ `register_toolset`
- ❌ `resolve_runtime`
- ❌ `resolve_toolset`
- ❌ `resolve_toolsets`
- ❌ `unregister_toolset`
+53 -5
View File
@@ -1,17 +1,17 @@
{
"version": "1.5.87",
"lastUpdated": "2026-06-25",
"lastUpdated": "2026-06-26",
"generatedBy": "praisonai._dev.parity.generator",
"sourceOfTruth": "Python SDK (praisonaiagents)",
"summary": {
"pythonCoreFeatures": 388,
"pythonCoreFeatures": 392,
"pythonWrapperFeatures": 100,
"typescriptFeatures": 1188,
"gapCount": 95,
"typescriptFeatures": 1190,
"gapCount": 99,
"priorityP0": 2,
"priorityP1": 5,
"priorityP2": 6,
"priorityP3": 82
"priorityP3": 86
},
"pythonCoreSDK": {
"path": "/home/runner/work/PraisonAI/PraisonAI/src/praisonai-agents/praisonaiagents",
@@ -31,6 +31,7 @@
"AgentOSProtocol",
"AgentPluginProtocol",
"AgentRunOutcome",
"AgentRuntimeProtocol",
"AgentTeam",
"Agents",
"ApprovalCallback",
@@ -354,6 +355,7 @@
"is_path_like",
"is_policy_string",
"list_memory_adapters",
"list_runtimes",
"list_toolsets",
"load_plugin",
"load_skill",
@@ -368,6 +370,7 @@
"register_display_callback",
"register_memory_adapter",
"register_memory_factory",
"register_runtime",
"register_tool",
"register_toolset",
"repeat",
@@ -385,6 +388,7 @@
"resolve_planning",
"resolve_reflection",
"resolve_routing",
"resolve_runtime",
"resolve_skills",
"resolve_toolset",
"resolve_toolsets",
@@ -636,6 +640,7 @@
"AGGRESSIVE_POLICY",
"AgentMessageEvent",
"AgentRunOutcome",
"AgentRuntimeProtocol",
"Agents",
"AsyncLearnProtocol",
"AutoMemory",
@@ -709,11 +714,14 @@
"has_memory_adapter",
"has_toolset",
"list_memory_adapters",
"list_runtimes",
"list_toolsets",
"memory",
"register_memory_adapter",
"register_memory_factory",
"register_runtime",
"register_toolset",
"resolve_runtime",
"resolve_toolset",
"resolve_toolsets",
"tools",
@@ -1462,6 +1470,8 @@
"createWorkflowHooks",
"create_context_agent",
"cronExpressions",
"crwCrawl",
"crwScrape",
"db",
"defineTool",
"detect_url_scheme",
@@ -2898,6 +2908,8 @@
"createToolsRegistry",
"createTracingMiddleware",
"createValidationMiddleware",
"crwCrawl",
"crwScrape",
"exaSearch",
"firecrawlCrawl",
"firecrawlScrape",
@@ -4228,6 +4240,15 @@
"status": "TODO",
"category": "other"
},
{
"feature": "AgentRuntimeProtocol",
"python": true,
"typescript": false,
"priority": "P3",
"effort": "medium",
"status": "TODO",
"category": "other"
},
{
"feature": "AgentTeam",
"python": true,
@@ -6136,6 +6157,15 @@
"status": "TODO",
"category": "other"
},
{
"feature": "list_runtimes",
"python": true,
"typescript": false,
"priority": "P3",
"effort": "low",
"status": "TODO",
"category": "other"
},
{
"feature": "list_toolsets",
"python": true,
@@ -6217,6 +6247,15 @@
"status": "TODO",
"category": "other"
},
{
"feature": "register_runtime",
"python": true,
"typescript": false,
"priority": "P3",
"effort": "low",
"status": "TODO",
"category": "other"
},
{
"feature": "register_toolset",
"python": true,
@@ -6352,6 +6391,15 @@
"status": "DONE",
"category": "config"
},
{
"feature": "resolve_runtime",
"python": true,
"typescript": false,
"priority": "P3",
"effort": "low",
"status": "TODO",
"category": "other"
},
{
"feature": "resolve_skills",
"python": true,
+13 -9
View File
@@ -1,20 +1,20 @@
# Feature Parity Tracker
> **Version:** 1.5.87 | **Last Updated:** 2026-06-25
> **Version:** 1.5.87 | **Last Updated:** 2026-06-26
> **Source of Truth:** Python SDK (praisonaiagents)
## Summary
| Metric | Count |
|--------|-------|
| Python Core Features | 388 |
| Python Core Features | 392 |
| Python Wrapper Features | 100 |
| TypeScript Features | 1188 |
| **Gap Count** | **95** |
| TypeScript Features | 1190 |
| **Gap Count** | **99** |
| P0 (Critical) | 2 |
| P1 (High) | 5 |
| P2 (Medium) | 6 |
| P3 (Low) | 82 |
| P3 (Low) | 86 |
## Gap Matrix
@@ -159,7 +159,7 @@
| `enable\_telemetry` | ✅ | ✅ | low | ✅ DONE |
| `get\_telemetry` | ✅ | ✅ | low | ✅ DONE |
### P3_Advanced (180 done, 82 todo)
### P3_Advanced (180 done, 86 todo)
| Feature | Python | TypeScript | Effort | Status |
|---------|--------|------------|--------|--------|
@@ -167,6 +167,7 @@
| `AGGRESSIVE\_POLICY` | ✅ | ❌ | low | ⏳ TODO |
| `AgentMessageEvent` | ✅ | ❌ | high | ⏳ TODO |
| `AgentRunOutcome` | ✅ | ❌ | high | ⏳ TODO |
| `AgentRuntimeProtocol` | ✅ | ❌ | medium | ⏳ TODO |
| `AsyncLearnProtocol` | ✅ | ❌ | medium | ⏳ TODO |
| `AutoApproveBackend` | ✅ | ❌ | high | ⏳ TODO |
| `BALANCED\_POLICY` | ✅ | ❌ | low | ⏳ TODO |
@@ -237,10 +238,13 @@
| `has\_memory\_adapter` | ✅ | ❌ | low | ⏳ TODO |
| `has\_toolset` | ✅ | ❌ | low | ⏳ TODO |
| `list\_memory\_adapters` | ✅ | ❌ | low | ⏳ TODO |
| `list\_runtimes` | ✅ | ❌ | low | ⏳ TODO |
| `list\_toolsets` | ✅ | ❌ | low | ⏳ TODO |
| `register\_memory\_adapter` | ✅ | ❌ | low | ⏳ TODO |
| `register\_memory\_factory` | ✅ | ❌ | low | ⏳ TODO |
| `register\_runtime` | ✅ | ❌ | low | ⏳ TODO |
| `register\_toolset` | ✅ | ❌ | low | ⏳ TODO |
| `resolve\_runtime` | ✅ | ❌ | low | ⏳ TODO |
| `resolve\_toolset` | ✅ | ❌ | low | ⏳ TODO |
| `resolve\_toolsets` | ✅ | ❌ | low | ⏳ TODO |
| `unregister\_toolset` | ✅ | ❌ | low | ⏳ TODO |
@@ -584,10 +588,10 @@ from praisonaiagents import Memory
</details>
<details>
<summary><strong>other</strong> (87 exports)</summary>
<summary><strong>other</strong> (91 exports)</summary>
```python
from praisonaiagents import AGGRESSIVE_POLICY, AgentMessageEvent, AgentRunOutcome, Agents, AsyncLearnProtocol, AutoMemory, AutonomyConfig, BALANCED_POLICY, BackendNotAvailableError, BudgetExceededError...
from praisonaiagents import AGGRESSIVE_POLICY, AgentMessageEvent, AgentRunOutcome, AgentRuntimeProtocol, Agents, AsyncLearnProtocol, AutoMemory, AutonomyConfig, BALANCED_POLICY, BackendNotAvailableError...
```
</details>
@@ -921,7 +925,7 @@ import { AgentStats, AgentTelemetry, MetricEntry, MinimalTelemetry, PerformanceM
</details>
<details>
<summary><strong>tools</strong> (87 exports)</summary>
<summary><strong>tools</strong> (89 exports)</summary>
```typescript
import { BaseTool, BudgetExceededError, DelegatorConfig, FunctionTool, InstallHints, MissingDependencyError, MissingEnvVarError, PraisonTool, RedactionHooks, RegisteredTool... } from 'praisonai';
@@ -0,0 +1,52 @@
import { Agent, MCP, TransportType } from 'praisonai-ts';
async function main() {
// Example 1: Automatic transport detection (default behavior)
const mcpAuto = new MCP('http://127.0.0.1:8080/sse'); // Will use SSE
await mcpAuto.initialize();
console.log(`Auto-detected transport: ${mcpAuto.transportType}`);
// Example 2: Explicit SSE transport
const mcpSSE = new MCP('http://127.0.0.1:8080/api', 'sse');
await mcpSSE.initialize();
console.log(`Explicit SSE transport: ${mcpSSE.transportType}`);
// Example 3: Explicit HTTP-Streaming transport
const mcpHTTP = new MCP('http://127.0.0.1:8080/stream', 'http-streaming');
await mcpHTTP.initialize();
console.log(`Explicit HTTP-Streaming transport: ${mcpHTTP.transportType}`);
// Example 4: Auto-detection with non-SSE URL
const mcpAutoHTTP = new MCP('http://127.0.0.1:8080/api'); // Will use HTTP-Streaming
await mcpAutoHTTP.initialize();
console.log(`Auto-detected transport for non-SSE URL: ${mcpAutoHTTP.transportType}`);
// Create tool execution functions
const toolFunctions = Object.fromEntries(
[...mcpAuto].map(tool => [
tool.name,
async (args: any) => tool.execute(args)
])
);
// Create agent with MCP tools
const agent = new Agent({
instructions: 'You are a helpful assistant with access to MCP tools.',
name: 'MCPTransportAgent',
tools: mcpAuto.toOpenAITools(),
toolFunctions
});
// Use the agent
const response = await agent.runSync('What tools are available?');
console.log('Agent response:', response);
// Cleanup
await mcpAuto.close();
await mcpSSE.close();
await mcpHTTP.close();
await mcpAutoHTTP.close();
}
// Run the example
main().catch(console.error);
+1
View File
@@ -110,6 +110,7 @@ export {
codeExecution, tavilySearch, tavilyExtract, tavilyCrawl,
exaSearch, perplexitySearch, parallelSearch,
firecrawlScrape, firecrawlCrawl,
crwScrape, crwCrawl,
superagentGuard, superagentRedact, superagentVerify,
valyuWebSearch, valyuFinanceSearch, valyuPaperSearch, valyuBioSearch,
valyuPatentSearch, valyuSecSearch, valyuEconomicsSearch, valyuCompanyResearch,
+221
View File
@@ -0,0 +1,221 @@
/**
* fastCRW Tool
*
* Web scraping, crawling, and content extraction.
* Firecrawl-compatible web scraper; single binary; self-host or cloud.
* Reuses @mendable/firecrawl-js pointed at the fastCRW API.
*/
import type { ToolMetadata, PraisonTool, ToolExecutionContext } from '../registry/types';
import { MissingDependencyError, MissingEnvVarError } from '../registry/types';
/** Default fastCRW cloud base URL. Override via CRW_API_URL for self-host. */
const DEFAULT_CRW_API_URL = 'https://fastcrw.com/api';
export const CRW_METADATA: ToolMetadata = {
id: 'crw',
displayName: 'fastCRW',
description: 'Firecrawl-compatible web scraping, crawling, and content extraction for LLM-ready data',
tags: ['scrape', 'crawl', 'extract', 'web'],
requiredEnv: ['CRW_API_KEY'],
optionalEnv: ['CRW_API_URL'],
install: {
npm: 'npm install @mendable/firecrawl-js',
pnpm: 'pnpm add @mendable/firecrawl-js',
yarn: 'yarn add @mendable/firecrawl-js',
bun: 'bun add @mendable/firecrawl-js',
},
docsSlug: 'tools/crw',
capabilities: {
crawl: true,
extract: true,
},
packageName: '@mendable/firecrawl-js',
};
export interface CrwScrapeConfig {
formats?: ('markdown' | 'html' | 'rawHtml' | 'links' | 'screenshot')[];
onlyMainContent?: boolean;
includeTags?: string[];
excludeTags?: string[];
waitFor?: number;
}
export interface CrwCrawlConfig {
limit?: number;
maxDepth?: number;
includePaths?: string[];
excludePaths?: string[];
allowBackwardLinks?: boolean;
allowExternalLinks?: boolean;
}
export interface CrwScrapeInput {
url: string;
}
export interface CrwCrawlInput {
url: string;
}
export interface CrwScrapeResult {
content: string;
markdown?: string;
html?: string;
links?: string[];
metadata?: Record<string, unknown>;
}
export interface CrwCrawlResult {
pages: Array<{
url: string;
content: string;
markdown?: string;
}>;
}
async function loadCrwPackage() {
if (!process.env.CRW_API_KEY) {
throw new MissingEnvVarError(
CRW_METADATA.id,
'CRW_API_KEY',
CRW_METADATA.docsSlug
);
}
try {
return await import('@mendable/firecrawl-js');
} catch {
throw new MissingDependencyError(
CRW_METADATA.id,
CRW_METADATA.packageName,
CRW_METADATA.install,
CRW_METADATA.requiredEnv,
CRW_METADATA.docsSlug
);
}
}
/**
* Create a fastCRW Scrape tool.
*
* Scrapes a single web page using the Firecrawl-compatible fastCRW API,
* returning LLM-ready content in markdown and optionally HTML, links, and metadata.
*
* @param config - Optional scraping configuration (formats, content filters, wait time).
* @returns A PraisonTool that scrapes a single web page.
*
* @example
* ```typescript
* import { crwScrape } from 'praisonai';
*
* const scraper = crwScrape({ formats: ['markdown', 'links'], onlyMainContent: true });
* const result = await scraper.execute({ url: 'https://example.com' });
* console.log(result.content);
* ```
*/
export function crwScrape(config?: CrwScrapeConfig): PraisonTool<CrwScrapeInput, CrwScrapeResult> {
return {
name: 'crwScrape',
description: 'Scrape a single web page and extract its content in LLM-ready format.',
parameters: {
type: 'object',
properties: {
url: {
type: 'string',
description: 'The URL to scrape',
},
},
required: ['url'],
},
execute: async (input: CrwScrapeInput, context?: ToolExecutionContext): Promise<CrwScrapeResult> => {
const pkg = await loadCrwPackage();
// fastCRW is Firecrawl-compatible: reuse the Firecrawl client with a custom apiUrl
const FirecrawlApp = (pkg as Record<string, unknown>).default || pkg;
if (typeof FirecrawlApp === 'function') {
const app = new (FirecrawlApp as new (options: { apiKey: string; apiUrl: string }) => {
scrapeUrl: (url: string, options?: CrwScrapeConfig) => Promise<{ data?: { markdown?: string; html?: string; links?: string[]; metadata?: Record<string, unknown> } }>;
})({ apiKey: process.env.CRW_API_KEY!, apiUrl: process.env.CRW_API_URL || DEFAULT_CRW_API_URL });
const result = await app.scrapeUrl(input.url, config);
return {
content: result.data?.markdown || '',
markdown: result.data?.markdown,
html: result.data?.html,
links: result.data?.links,
metadata: result.data?.metadata,
};
}
return { content: '' };
},
};
}
/**
* Create a fastCRW Crawl tool.
*
* Crawls a website starting from a URL, following links and extracting content
* from multiple pages using the Firecrawl-compatible fastCRW API.
*
* @param config - Optional crawl configuration (depth limits, path filters, link policies).
* @returns A PraisonTool that crawls a website and extracts content from multiple pages.
*
* @example
* ```typescript
* import { crwCrawl } from 'praisonai';
*
* const crawler = crwCrawl({ limit: 10, maxDepth: 2, includePaths: ['/docs'] });
* const result = await crawler.execute({ url: 'https://example.com' });
* console.log(result.pages.length, 'pages crawled');
* ```
*/
export function crwCrawl(config?: CrwCrawlConfig): PraisonTool<CrwCrawlInput, CrwCrawlResult> {
return {
name: 'crwCrawl',
description: 'Crawl a website and extract content from multiple pages.',
parameters: {
type: 'object',
properties: {
url: {
type: 'string',
description: 'The starting URL to crawl',
},
},
required: ['url'],
},
execute: async (input: CrwCrawlInput, context?: ToolExecutionContext): Promise<CrwCrawlResult> => {
const pkg = await loadCrwPackage();
const FirecrawlApp = (pkg as Record<string, unknown>).default || pkg;
if (typeof FirecrawlApp === 'function') {
const app = new (FirecrawlApp as new (options: { apiKey: string; apiUrl: string }) => {
crawlUrl: (url: string, options?: CrwCrawlConfig) => Promise<{ data?: Array<{ url: string; markdown?: string }> }>;
})({ apiKey: process.env.CRW_API_KEY!, apiUrl: process.env.CRW_API_URL || DEFAULT_CRW_API_URL });
const result = await app.crawlUrl(input.url, config);
return {
pages: (result.data || []).map(page => ({
url: page.url,
content: page.markdown || '',
markdown: page.markdown,
})),
};
}
return { pages: [] };
},
};
}
/**
* Factory functions for registry
*/
export function createCrwScrapeTool(config?: CrwScrapeConfig): PraisonTool<unknown, unknown> {
return crwScrape(config) as PraisonTool<unknown, unknown>;
}
export function createCrwCrawlTool(config?: CrwCrawlConfig): PraisonTool<unknown, unknown> {
return crwCrawl(config) as PraisonTool<unknown, unknown>;
}
@@ -11,6 +11,7 @@ export { EXA_METADATA } from './exa';
export { PERPLEXITY_METADATA } from './perplexity';
export { PARALLEL_METADATA } from './parallel';
export { FIRECRAWL_METADATA } from './firecrawl';
export { CRW_METADATA } from './crw';
export { SUPERAGENT_METADATA } from './superagent';
export { VALYU_METADATA } from './valyu';
export { BEDROCK_AGENTCORE_METADATA } from './bedrock-agentcore';
@@ -41,6 +42,10 @@ export type { ParallelSearchConfig, ParallelSearchInput, ParallelSearchResult }
export { firecrawlScrape, firecrawlCrawl, createFirecrawlScrapeTool, createFirecrawlCrawlTool } from './firecrawl';
export type { FirecrawlScrapeConfig, FirecrawlScrapeInput, FirecrawlScrapeResult, FirecrawlCrawlConfig, FirecrawlCrawlInput, FirecrawlCrawlResult } from './firecrawl';
// fastCRW
export { crwScrape, crwCrawl, createCrwScrapeTool, createCrwCrawlTool } from './crw';
export type { CrwScrapeConfig, CrwScrapeInput, CrwScrapeResult, CrwCrawlConfig, CrwCrawlInput, CrwCrawlResult } from './crw';
// Superagent
export { superagentGuard, superagentRedact, superagentVerify, createSuperagentGuardTool, createSuperagentRedactTool, createSuperagentVerifyTool } from './superagent';
export type { GuardConfig, GuardInput, GuardResult, RedactConfig, RedactInput, RedactResult, VerifyConfig, VerifyInput, VerifyResult } from './superagent';
@@ -90,6 +95,7 @@ export async function getAllBuiltinMetadata() {
const { PERPLEXITY_METADATA } = await import('./perplexity');
const { PARALLEL_METADATA } = await import('./parallel');
const { FIRECRAWL_METADATA } = await import('./firecrawl');
const { CRW_METADATA } = await import('./crw');
const { SUPERAGENT_METADATA } = await import('./superagent');
const { VALYU_METADATA } = await import('./valyu');
const { BEDROCK_AGENTCORE_METADATA } = await import('./bedrock-agentcore');
@@ -103,6 +109,7 @@ export async function getAllBuiltinMetadata() {
PERPLEXITY_METADATA,
PARALLEL_METADATA,
FIRECRAWL_METADATA,
CRW_METADATA,
SUPERAGENT_METADATA,
VALYU_METADATA,
BEDROCK_AGENTCORE_METADATA,
+108
View File
@@ -0,0 +1,108 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { MCPTool, MCPToolInfo, MCP as SSEMCP } from './mcpSse';
import { HTTPStreamingTransport, MCPHttpStreaming } from './mcpHttpStreaming';
export type TransportType = 'auto' | 'sse' | 'http-streaming';
export class MCP implements Iterable<MCPTool> {
tools: MCPTool[] = [];
private client: Client | null = null;
private transport: TransportType;
private actualTransport: 'sse' | 'http-streaming' | null = null;
constructor(
private url: string,
transport: TransportType = 'auto',
private debug = false
) {
this.transport = transport;
// Auto-detect transport type based on URL
if (transport === 'auto') {
this.actualTransport = url.endsWith('/sse') ? 'sse' : 'http-streaming';
} else if (transport === 'sse' || transport === 'http-streaming') {
this.actualTransport = transport;
} else {
throw new Error(`Unknown transport type: ${transport}`);
}
if (debug) {
console.log(`MCP client initialized for URL: ${url} with transport: ${this.actualTransport}`);
}
}
async initialize(): Promise<void> {
if (this.client) {
if (this.debug) console.log('MCP client already initialized');
return;
}
try {
this.client = new Client({ name: 'praisonai-ts-mcp', version: '1.0.0' });
// Create transport based on selection
let transport;
if (this.actualTransport === 'sse') {
transport = new SSEClientTransport(new URL(this.url));
} else if (this.actualTransport === 'http-streaming') {
transport = new HTTPStreamingTransport(new URL(this.url));
} else {
throw new Error(`Invalid transport type: ${this.actualTransport}`);
}
await this.client.connect(transport);
const { tools } = await this.client.listTools();
this.tools = tools.map((t: any) => new MCPTool({
name: t.name,
description: t.description,
inputSchema: t.inputSchema
}, this.client as Client));
if (this.debug) {
console.log(`Initialized MCP with ${this.tools.length} tools using ${this.actualTransport} transport`);
}
} catch (error) {
if (this.client) {
await this.client.close().catch(() => {});
this.client = null;
}
throw new Error(`Failed to initialize MCP client: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
[Symbol.iterator](): Iterator<MCPTool> {
return this.tools[Symbol.iterator]();
}
toOpenAITools() {
return this.tools.map(t => t.toOpenAITool());
}
async close(): Promise<void> {
if (this.client) {
try {
await this.client.close();
} catch (error) {
if (this.debug) {
console.warn('Error closing MCP client:', error);
}
} finally {
this.client = null;
this.tools = [];
}
}
}
get isConnected(): boolean {
return this.client !== null;
}
get transportType(): string {
return this.actualTransport || 'not initialized';
}
}
// Re-export components for backward compatibility
export { MCPTool, MCPToolInfo } from './mcpSse';
export { HTTPStreamingTransport } from './mcpHttpStreaming';
@@ -0,0 +1,150 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import { MCPTool, MCPToolInfo } from './mcpSse';
interface ToolDefinition {
name: string;
description?: string;
inputSchema?: Record<string, any>;
}
export class HTTPStreamingTransport implements Transport {
private url: URL;
private headers: Record<string, string>;
private closed = false;
private reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
private writer: WritableStreamDefaultWriter<Uint8Array> | null = null;
private messageQueue: Array<any> = [];
private initialized = false;
constructor(url: URL, headers: Record<string, string> = {}) {
this.url = url;
this.headers = headers;
}
async start(): Promise<void> {
// Minimal implementation: mark as initialized
this.initialized = true;
}
async close(): Promise<void> {
this.closed = true;
if (this.reader) {
await this.reader.cancel();
this.reader = null;
}
if (this.writer) {
await this.writer.close();
this.writer = null;
}
}
async send(message: any): Promise<void> {
if (this.closed) {
throw new Error('Transport is closed');
}
// Minimal implementation: process message locally
// In a real implementation, this would send via HTTP
if (message.method === 'initialize') {
const response = {
jsonrpc: '2.0',
id: message.id,
result: {
protocolVersion: '0.1.0',
capabilities: {}
}
};
this.messageQueue.push(response);
} else if (message.method === 'tools/list') {
const response = {
jsonrpc: '2.0',
id: message.id,
result: {
tools: []
}
};
this.messageQueue.push(response);
}
}
async receive(): Promise<any> {
if (this.closed) {
throw new Error('Transport is closed');
}
// Minimal implementation: return queued messages
if (this.messageQueue.length > 0) {
return this.messageQueue.shift();
}
// Return empty response if no messages
return new Promise((resolve) => {
setTimeout(() => {
resolve({ jsonrpc: "2.0", id: null, result: {} });
}, 100);
});
}
}
export class MCPHttpStreaming implements Iterable<MCPTool> {
tools: MCPTool[] = [];
private client: Client | null = null;
constructor(private url: string, private debug = false) {
if (debug) {
console.log(`MCP HTTP-Streaming client initialized for URL: ${url}`);
}
}
async initialize(): Promise<void> {
if (this.client) {
if (this.debug) console.log('MCP HTTP-Streaming client already initialized');
return;
}
try {
this.client = new Client({ name: 'praisonai-ts-mcp', version: '1.0.0' });
const transport = new HTTPStreamingTransport(new URL(this.url));
await this.client.connect(transport);
const { tools } = await this.client.listTools();
this.tools = tools.map((t: ToolDefinition) => new MCPTool({
name: t.name,
description: t.description,
inputSchema: t.inputSchema
}, this.client as Client));
if (this.debug) console.log(`Initialized MCP HTTP-Streaming with ${this.tools.length} tools`);
} catch (error) {
if (this.client) {
await this.client.close().catch(() => {});
this.client = null;
}
throw new Error(`Failed to initialize MCP HTTP-Streaming client: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
[Symbol.iterator](): Iterator<MCPTool> {
return this.tools[Symbol.iterator]();
}
toOpenAITools() {
return this.tools.map(t => t.toOpenAITool());
}
async close(): Promise<void> {
if (this.client) {
try {
await this.client.close();
} catch (error) {
if (this.debug) {
console.warn('Error closing MCP HTTP-Streaming client:', error);
}
} finally {
this.client = null;
this.tools = [];
}
}
}
get isConnected(): boolean {
return this.client !== null;
}
}
+11
View File
@@ -16,6 +16,7 @@ import { exaSearch, EXA_METADATA, createExaSearchTool } from './builtins/exa';
import { perplexitySearch, PERPLEXITY_METADATA, createPerplexitySearchTool } from './builtins/perplexity';
import { parallelSearch, PARALLEL_METADATA, createParallelSearchTool } from './builtins/parallel';
import { firecrawlScrape, firecrawlCrawl, FIRECRAWL_METADATA, createFirecrawlScrapeTool, createFirecrawlCrawlTool } from './builtins/firecrawl';
import { crwScrape, crwCrawl, CRW_METADATA, createCrwScrapeTool, createCrwCrawlTool } from './builtins/crw';
import { superagentGuard, superagentRedact, superagentVerify, SUPERAGENT_METADATA, createSuperagentGuardTool, createSuperagentRedactTool, createSuperagentVerifyTool } from './builtins/superagent';
import {
valyuWebSearch, valyuFinanceSearch, valyuPaperSearch, valyuBioSearch,
@@ -42,6 +43,7 @@ import type { ExaSearchConfig } from './builtins/exa';
import type { PerplexitySearchConfig } from './builtins/perplexity';
import type { ParallelSearchConfig } from './builtins/parallel';
import type { FirecrawlScrapeConfig, FirecrawlCrawlConfig } from './builtins/firecrawl';
import type { CrwScrapeConfig, CrwCrawlConfig } from './builtins/crw';
import type { GuardConfig, RedactConfig, VerifyConfig } from './builtins/superagent';
import type { ValyuSearchConfig } from './builtins/valyu';
import type { CodeInterpreterConfig, BrowserConfig } from './builtins/bedrock-agentcore';
@@ -77,6 +79,10 @@ export function registerBuiltinTools(): void {
registry.register({ ...FIRECRAWL_METADATA, id: 'firecrawl-scrape' }, createFirecrawlScrapeTool as ToolFactory);
registry.register({ ...FIRECRAWL_METADATA, id: 'firecrawl-crawl' }, createFirecrawlCrawlTool as ToolFactory);
// fastCRW
registry.register({ ...CRW_METADATA, id: 'crw-scrape' }, createCrwScrapeTool as ToolFactory);
registry.register({ ...CRW_METADATA, id: 'crw-crawl' }, createCrwCrawlTool as ToolFactory);
// Superagent
registry.register({ ...SUPERAGENT_METADATA, id: 'superagent-guard' }, createSuperagentGuardTool as ToolFactory);
registry.register({ ...SUPERAGENT_METADATA, id: 'superagent-redact' }, createSuperagentRedactTool as ToolFactory);
@@ -136,6 +142,11 @@ export const tools = {
firecrawlScrape: (config?: FirecrawlScrapeConfig) => firecrawlScrape(config),
firecrawlCrawl: (config?: FirecrawlCrawlConfig) => firecrawlCrawl(config),
// fastCRW
crw: (config?: CrwScrapeConfig) => crwScrape(config),
crwScrape: (config?: CrwScrapeConfig) => crwScrape(config),
crwCrawl: (config?: CrwCrawlConfig) => crwCrawl(config),
// Superagent (Security)
guard: (config?: GuardConfig) => superagentGuard(config),
redact: (config?: RedactConfig) => superagentRedact(config),
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.79.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.79.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.80.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.80.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
+58 -24
View File
@@ -6,6 +6,7 @@ handling nested event loop scenarios without creating a new event loop
on every call (which is expensive and breaks multi-agent workflows).
"""
import asyncio
import atexit
import os
import threading
import concurrent.futures
@@ -24,6 +25,10 @@ class AsyncBridge:
self._loop: asyncio.AbstractEventLoop | None = None
self._thread: threading.Thread | None = None
self._lock = threading.Lock()
# Identity of the thread currently holding ``self._lock``. Set under the
# lock by ``get()``/``submit()`` so ``_spawn_locked()`` can verify the
# *caller* (not merely *someone*) owns the lock.
self._lock_owner: int | None = None
def _spawn_locked(self) -> asyncio.AbstractEventLoop:
"""Create the loop+thread; caller must hold ``self._lock``.
@@ -31,11 +36,17 @@ class AsyncBridge:
The thread is marked ``daemon=True`` so that short-lived scripts
(e.g. CLI commands, smoke tests) exit cleanly without waiting on
the background loop to be shut down explicitly. Long-running
server processes should call :func:`shutdown` explicitly to cancel
server processes should call :meth:`shutdown` explicitly to cancel
in-flight tasks before process exit.
"""
# Create the loop+thread; caller must hold self._lock.
# The lock contract is now enforced structurally by submit() method.
# Enforce the "caller must hold the lock" contract. We compare the
# recorded lock owner against the current thread so the check detects
# both an unlocked lock *and* a lock held by a different thread (a bare
# non-blocking acquire could only prove the lock was free for *anyone*).
if self._lock_owner != threading.get_ident():
raise AssertionError(
"_spawn_locked() must be called while holding self._lock"
)
if self._loop is None or self._loop.is_closed():
self._loop = asyncio.new_event_loop()
self._thread = threading.Thread(
@@ -48,13 +59,21 @@ class AsyncBridge:
def get(self) -> asyncio.AbstractEventLoop:
with self._lock:
return self._spawn_locked()
self._lock_owner = threading.get_ident()
try:
return self._spawn_locked()
finally:
self._lock_owner = None
def submit(self, coro):
"""Atomically (re)spawn loop if needed and submit coro. Returns concurrent.futures.Future."""
with self._lock:
loop = self._spawn_locked()
return asyncio.run_coroutine_threadsafe(coro, loop)
self._lock_owner = threading.get_ident()
try:
loop = self._spawn_locked()
return asyncio.run_coroutine_threadsafe(coro, loop)
finally:
self._lock_owner = None
def run_sync(self, coro: Awaitable[T], *, timeout: float | None = _DEFAULT_TIMEOUT) -> T:
try:
@@ -112,17 +131,41 @@ class AsyncBridge:
if not loop.is_closed():
loop.close()
# Backwards-compatible module-level shared default:
_DEFAULT_BRIDGE: AsyncBridge | None = None
_DEFAULT_LOCK = threading.Lock()
# Backwards-compatible module-level shared default.
#
# The shared bridge's lifecycle is owned exclusively by the process (via the
# ``atexit`` hook registered on first use). It is intentionally NOT exposed as a
# public ``shutdown()`` on the module surface: in a multi-agent / multi-tenant
# process every ``run_sync()`` caller shares this single loop+thread, so allowing
# any one caller to tear it down would silently cancel in-flight work belonging
# to every other agent/session. Embedders that need explicit lifecycle control
# must construct and own their own :class:`AsyncBridge` instance and pass it via
# dependency injection.
# Single stable shared instance. It is created eagerly (cheap: no loop/thread is
# spawned until the first ``run_sync()`` call) so the reference never has to be
# rebound. Keeping the *same* object means callers that captured ``_BG`` always
# observe its live state, and only the process-exit hook tears it down.
_BG: AsyncBridge = AsyncBridge()
def _default_bridge() -> AsyncBridge:
global _DEFAULT_BRIDGE
if _DEFAULT_BRIDGE is None:
with _DEFAULT_LOCK:
if _DEFAULT_BRIDGE is None:
_DEFAULT_BRIDGE = AsyncBridge()
return _DEFAULT_BRIDGE
return _BG
def _shutdown_default() -> None:
"""Private process-exit hook: tear down the shared default bridge.
Registered with :mod:`atexit`. This is the ONLY sanctioned way the shared
default is shut down; it is deliberately absent from the public module
surface so a single caller cannot terminate async work for the whole
process. Embedders needing explicit lifecycle control own their own
:class:`AsyncBridge` instance.
"""
_BG.shutdown()
atexit.register(_shutdown_default)
def run_sync(coro: Awaitable[T], *, timeout: float | None = _DEFAULT_TIMEOUT) -> T:
@@ -145,12 +188,3 @@ def run_sync(coro: Awaitable[T], *, timeout: float | None = _DEFAULT_TIMEOUT) ->
Any exception raised by the coroutine
"""
return _default_bridge().run_sync(coro, timeout=timeout)
def shutdown() -> None:
"""Shut down ONLY the shared default bridge (not user-owned instances)."""
global _DEFAULT_BRIDGE
with _DEFAULT_LOCK:
if _DEFAULT_BRIDGE is not None:
_DEFAULT_BRIDGE.shutdown()
_DEFAULT_BRIDGE = None
+2 -10
View File
@@ -10,16 +10,8 @@ from typing import Any
def _build_config_list() -> list[dict[str, Any]]:
"""Reuse the same env/keyfile resolution the CLI already performs."""
# Import LLM endpoint resolution from the same path the CLI uses
from praisonai.llm.env import resolve_llm_endpoint
ep = resolve_llm_endpoint()
return [{
'model': ep.model,
'base_url': ep.base_url,
'api_key': ep.api_key,
'api_type': 'openai' # AutoGen expects this field
}]
from praisonai.llm.config import build_config_list
return build_config_list()
def run(agent_file: str,
+79 -34
View File
@@ -4,6 +4,7 @@ import sys
import os
import inspect
import logging
import threading
import re
import keyword
import difflib
@@ -18,6 +19,8 @@ from .version import __version__
from .framework_adapters.base import FrameworkAdapter
from .framework_adapters.registry import FrameworkAdapterRegistry, get_default_registry
logger = logging.getLogger("praisonai.agents_generator")
def _rich_print(*args, **kwargs):
"""Lazy alias for rich.print — pays the import cost only on first use."""
@@ -77,14 +80,72 @@ def __dir__():
_TOOL_TIMEOUT_EXECUTOR = None
# Eagerly initialised so concurrent first-callers always share one lock (and
# therefore one executor); a lazily-created lock would itself race.
_TOOL_TIMEOUT_EXECUTOR_LOCK = threading.Lock()
_DEFAULT_TOOL_TIMEOUT_WORKERS = 32
def _resolve_tool_timeout_workers():
"""Resolve the timeout worker count, tolerating a bad env value.
A typo or non-positive ``PRAISONAI_TOOL_TIMEOUT_WORKERS`` must not crash
every timed tool call; we warn and fall back to the safe default instead.
"""
raw = os.environ.get("PRAISONAI_TOOL_TIMEOUT_WORKERS")
if raw is None:
return _DEFAULT_TOOL_TIMEOUT_WORKERS
try:
workers = int(raw)
except (TypeError, ValueError):
logger.warning(
"Invalid PRAISONAI_TOOL_TIMEOUT_WORKERS=%r; using default of %d.",
raw, _DEFAULT_TOOL_TIMEOUT_WORKERS,
)
return _DEFAULT_TOOL_TIMEOUT_WORKERS
if workers < 1:
logger.warning(
"PRAISONAI_TOOL_TIMEOUT_WORKERS must be >= 1 (got %d); using default of %d.",
workers, _DEFAULT_TOOL_TIMEOUT_WORKERS,
)
return _DEFAULT_TOOL_TIMEOUT_WORKERS
return workers
def _get_tool_timeout_executor():
"""Lazily create a bounded, observable thread pool for sync tool timeouts.
Using a bounded pool (instead of unbounded bare daemon threads) means any
background work left running after a timeout is at least capped and named,
so operators can enumerate it rather than accumulating phantom threads.
"""
global _TOOL_TIMEOUT_EXECUTOR
import concurrent.futures
with _TOOL_TIMEOUT_EXECUTOR_LOCK:
if _TOOL_TIMEOUT_EXECUTOR is None:
_TOOL_TIMEOUT_EXECUTOR = concurrent.futures.ThreadPoolExecutor(
max_workers=_resolve_tool_timeout_workers(),
thread_name_prefix="praisonai-tool-timeout",
)
return _TOOL_TIMEOUT_EXECUTOR
def _wrap_with_timeout(tool, timeout_seconds: float):
"""Enforce per-call timeout on a tool, sync or async, without
leaking the worker thread/task on timeout.
"""Enforce a per-call timeout on a tool, sync or async.
For async tools the underlying task is cancelled on timeout. For sync tools
the call runs in a bounded thread pool; on timeout we best-effort cancel the
future and log a warning. Note: a synchronous call that has already started
cannot be forcibly interrupted, so background work may continue executing —
the returned payload flags this with ``background_work_may_continue``.
"""
if timeout_seconds is None or timeout_seconds <= 0 or not callable(tool):
return tool
import asyncio
import concurrent.futures
import functools
import inspect
import json
@@ -104,43 +165,27 @@ def _wrap_with_timeout(tool, timeout_seconds: float):
@functools.wraps(tool)
def _sync_wrapped(*args, **kwargs):
import queue
import threading
result_queue: queue.Queue[tuple[bool, object]] = queue.Queue(maxsize=1)
def _runner():
try:
result_queue.put((True, tool(*args, **kwargs)))
except BaseException as exc:
result_queue.put((False, exc))
# Daemon thread avoids blocking process shutdown if the tool call hangs.
worker = threading.Thread(target=_runner, daemon=True)
worker.start()
worker.join(timeout_seconds)
if worker.is_alive():
executor = _get_tool_timeout_executor()
future = executor.submit(tool, *args, **kwargs)
try:
return future.result(timeout=timeout_seconds)
except concurrent.futures.TimeoutError:
# Best-effort cancel; only effective if the future has not started.
# A started sync call cannot be interrupted, so warn operators that
# the worker may keep running (and its side effects may still occur).
cancelled = future.cancel()
logger.warning(
"Tool %r exceeded %.1fs (cancel=%s); worker may continue "
"executing in the background.",
getattr(tool, "__name__", repr(tool)),
timeout_seconds, cancelled,
)
return json.dumps({
"error": "tool_timeout",
"tool": getattr(tool, "__name__", repr(tool)),
"timeout_seconds": timeout_seconds,
"background_work_may_continue": not cancelled,
})
try:
success, payload = result_queue.get_nowait()
except queue.Empty:
# Defensive fallback: if the worker exits without publishing a result,
# avoid blocking indefinitely and surface an execution failure.
return json.dumps({
"error": "tool_execution_error",
"tool": getattr(tool, "__name__", repr(tool)),
"detail": "worker_exited_without_result",
})
if success:
return payload
raise payload
return _sync_wrapped
+7 -4
View File
@@ -38,9 +38,9 @@ def _call_auth_disabled() -> bool:
return os.getenv('PRAISONAI_CALL_AUTH', '').lower() == 'disabled'
def _configured_bind_host() -> str:
def _configured_bind_host() -> Optional[str]:
"""Server bind address from env only — never trust client Host headers."""
return os.getenv('PRAISONAI_CALL_BIND_HOST', '127.0.0.1')
return os.getenv('PRAISONAI_CALL_BIND_HOST')
async def verify_token(
@@ -52,10 +52,13 @@ async def verify_token(
return
if _call_auth_disabled():
bind_host = _configured_bind_host()
if bind_host not in _LOCALHOST_HOSTS:
if bind_host is None or bind_host not in _LOCALHOST_HOSTS:
raise HTTPException(
status_code=503,
detail="PRAISONAI_CALL_AUTH=disabled is only permitted for localhost binding",
detail=(
"PRAISONAI_CALL_AUTH=disabled is only permitted for localhost binding; "
"set PRAISONAI_CALL_BIND_HOST to 127.0.0.1 when binding locally"
),
)
warnings.warn(
"PRAISONAI_CALL_AUTH=disabled bypasses authentication; "
+3 -1
View File
@@ -372,7 +372,9 @@ def run_server(port: int, host: str = "127.0.0.1", use_public: bool = False):
if host == "0.0.0.0" and not use_public:
print("WARNING: Server is binding to all network interfaces (0.0.0.0). "
"This exposes the service to your local network.")
os.environ["PRAISONAI_CALL_BIND_HOST"] = host
uvicorn.run(app, host=host, port=port, log_level="warning")
def main(args=None):
+198 -124
View File
@@ -20,10 +20,6 @@ from praisonai._logging import get_logger
# Type variable for Pydantic models - will be bound at runtime
T = TypeVar('T')
# Module-level cache for models (for backward compatibility)
_models_cache: Dict = {}
_models_lock = threading.Lock()
# =============================================================================
# THREAD-SAFE LAZY LOADING INFRASTRUCTURE - All heavy imports are deferred
# =============================================================================
@@ -51,6 +47,42 @@ def _yaml_safe_load(stream):
return yaml.safe_load(stream)
def _atomic_write_text(path, content_writer):
"""Atomically write text to ``path`` via a sibling temp file + rename.
``content_writer(file_obj)`` is called with an open text file in the target
directory, so the rename stays on the same filesystem (required for atomicity).
This prevents zero-byte / half-written config files if the process crashes,
the disk fills, or the user interrupts mid-write.
"""
import tempfile
target_dir = os.path.dirname(os.path.abspath(path)) or "."
fd, tmp_path = tempfile.mkstemp(prefix=".tmp_", suffix=".yaml", dir=target_dir)
try:
# Preserve the existing file's permission bits so a replaced, shared or
# deployment-managed config does not silently become unreadable to the
# process that later loads it (mkstemp creates 0600 by default).
try:
existing_mode = os.stat(path).st_mode
except OSError:
existing_mode = None
with os.fdopen(fd, "w") as f:
content_writer(f)
f.flush()
os.fsync(f.fileno()) # durability before rename
if existing_mode is not None:
try:
os.chmod(tmp_path, existing_mode)
except OSError:
pass # best-effort; do not block the write on a chmod failure
os.replace(tmp_path, path) # atomic on POSIX + Windows
except BaseException:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
# OpenAI client creation removed - create per-call instead of global cache
@@ -63,37 +95,11 @@ from ._framework_availability import is_available
# Redundant wrapper functions removed - use is_available() directly
def _get_crewai():
"""Lazy load crewai classes (thread-safe)."""
return lazy_get("crewai_classes", lambda: (
__import__("crewai", fromlist=["Agent", "Task", "Crew"]).Agent,
__import__("crewai", fromlist=["Agent", "Task", "Crew"]).Task,
__import__("crewai", fromlist=["Agent", "Task", "Crew"]).Crew,
))
def _get_autogen():
"""Lazy load autogen module (thread-safe)."""
return lazy_get("autogen_module", lambda: __import__("autogen"))
def _get_autogen_v4():
"""Lazy load autogen v0.4 classes (thread-safe)."""
def autogen_v4_loader():
from autogen_agentchat.agents import AssistantAgent
from autogen_ext.models.openai import OpenAIChatCompletionClient
return (AssistantAgent, OpenAIChatCompletionClient)
return lazy_get("autogen_v4_classes", autogen_v4_loader)
def _get_praisonai():
"""Lazy load praisonaiagents classes (thread-safe)."""
def praisonai_loader():
from praisonaiagents import Agent as PraisonAgent, Task as PraisonTask, AgentTeam as Agents
return (PraisonAgent, PraisonTask, Agents)
return lazy_get("praisonai_classes", praisonai_loader)
# Framework class loaders removed: framework selection and resolution now go
# exclusively through the canonical FrameworkAdapter protocol + registry
# (see framework_adapters/). The previous hand-rolled _get_crewai/_get_autogen/
# _get_autogen_v4/_get_praisonai loaders were dead code that bypassed the
# adapter system. Use framework_adapters.registry.get_default_registry().
# --- PraisonAI Tools lazy loading ---
@@ -378,59 +384,66 @@ class BaseAutoGenerator:
config_list: Optional LLM configuration list
"""
# Resolve LLM endpoint configuration from environment variables
from praisonai.llm.env import resolve_llm_endpoint
ep = resolve_llm_endpoint()
self.config_list = config_list or [
{
'model': ep.model,
'base_url': ep.base_url,
'api_key': ep.api_key
}
]
from praisonai.llm.config import build_config_list
self.config_list = config_list or build_config_list(include_api_type=False)
self._openai_client = None # lazy, per-instance
self._async_openai_client = None # lazy, per-instance async client
self._client_lock = threading.Lock()
def _get_openai_client(self):
"""Get or create the OpenAI client for this instance."""
if self._openai_client is None:
with self._client_lock:
if self._openai_client is None:
try:
from openai import OpenAI
except ImportError as e:
raise ImportError("Install with: pip install openai") from e
cfg = self.config_list[0]
self._openai_client = OpenAI(
api_key=cfg.get("api_key") or os.environ.get("OPENAI_API_KEY"),
base_url=cfg.get("base_url"),
)
return self._openai_client
"""Get or create the OpenAI client for this instance.
Lazy-init AND snapshot the client under the same lock so the returned
local reference survives a concurrent close() that nulls the attribute,
closing the shutdown-during-request race (mirrors the async path).
"""
with self._client_lock:
client = self._openai_client
if client is None:
try:
from openai import OpenAI
except ImportError as e:
raise ImportError("Install with: pip install openai") from e
cfg = self.config_list[0]
client = self._openai_client = OpenAI(
api_key=cfg.get("api_key") or os.environ.get("OPENAI_API_KEY"),
base_url=cfg.get("base_url"),
)
return client
def close(self):
"""Close the sync OpenAI client if it exists."""
if not hasattr(self, '_client_lock'):
return # Object was never fully initialized
# Snapshot under the lock, close OUTSIDE it, and only null the attribute
# after a successful close so a failed close can be retried.
with self._client_lock:
client = getattr(self, '_openai_client', None)
self._openai_client = None
if client is not None:
client.close()
with self._client_lock:
if getattr(self, '_openai_client', None) is client:
self._openai_client = None
async def aclose(self):
"""Close both sync and async OpenAI clients if they exist."""
if not hasattr(self, '_client_lock'):
return # Object was never fully initialized
# Snapshot under the lock, close OUTSIDE it, and only null each attribute
# after its successful close so a failed close can be retried.
with self._client_lock:
sync_client = getattr(self, '_openai_client', None)
self._openai_client = None
async_client = getattr(self, '_async_openai_client', None)
self._async_openai_client = None
if sync_client is not None:
await asyncio.to_thread(sync_client.close)
with self._client_lock:
if getattr(self, '_openai_client', None) is sync_client:
self._openai_client = None
if async_client is not None:
await async_client.close()
with self._client_lock:
if getattr(self, '_async_openai_client', None) is async_client:
self._async_openai_client = None
def __enter__(self):
return self
@@ -470,15 +483,27 @@ class BaseAutoGenerator:
# Try LiteLLM first (preferred - supports 100+ providers)
if is_available("litellm"):
litellm = _get_litellm()
response = litellm.completion(
model=model_name,
messages=messages,
response_format=response_model,
**kwargs
)
content = response.choices[0].message.content
return response_model.model_validate_json(content)
try:
litellm = _get_litellm()
response = litellm.completion(
model=model_name,
messages=messages,
response_format=response_model,
**kwargs
)
content = response.choices[0].message.content
return response_model.model_validate_json(content)
except Exception as e:
# Graceful execution-level fallback: if LiteLLM fails at runtime
# (auth error, network timeout, unsupported model, or a provider
# that ignores response_format and returns no content), fall
# through to the OpenAI SDK path below instead of propagating.
if not is_available("openai"):
raise
logger.warning(
"LiteLLM structured completion failed (%s); "
"falling back to OpenAI SDK.", e
)
# Fallback to OpenAI SDK (uses beta.chat.completions.parse)
if is_available("openai"):
@@ -520,32 +545,44 @@ class BaseAutoGenerator:
# Try LiteLLM async first (preferred - supports 100+ providers)
if is_available("litellm"):
litellm = _get_litellm()
response = await litellm.acompletion(
model=model_name,
messages=messages,
response_format=response_model,
**kwargs
)
content = response.choices[0].message.content
return response_model.model_validate_json(content)
try:
litellm = _get_litellm()
response = await litellm.acompletion(
model=model_name,
messages=messages,
response_format=response_model,
**kwargs
)
content = response.choices[0].message.content
return response_model.model_validate_json(content)
except Exception as e:
# Graceful execution-level fallback (see _structured_completion).
if not is_available("openai"):
raise
logger.warning(
"LiteLLM async structured completion failed (%s); "
"falling back to OpenAI SDK.", e
)
# Fallback to OpenAI AsyncSDK (uses beta.chat.completions.parse)
if is_available("openai"):
if self._async_openai_client is None:
with self._client_lock:
if self._async_openai_client is None:
try:
from openai import AsyncOpenAI
except ImportError as e:
raise ImportError("Install with: pip install openai") from e
cfg = self.config_list[0]
self._async_openai_client = AsyncOpenAI(
api_key=cfg.get("api_key") or os.environ.get("OPENAI_API_KEY"),
base_url=cfg.get("base_url"),
)
# Lazy-init AND snapshot the client under the same lock. The local
# reference survives a concurrent aclose() that nulls the attribute,
# closing the shutdown-during-request race.
with self._client_lock:
if self._async_openai_client is None:
try:
from openai import AsyncOpenAI
except ImportError as e:
raise ImportError("Install with: pip install openai") from e
cfg = self.config_list[0]
self._async_openai_client = AsyncOpenAI(
api_key=cfg.get("api_key") or os.environ.get("OPENAI_API_KEY"),
base_url=cfg.get("base_url"),
)
client = self._async_openai_client
response = await self._async_openai_client.beta.chat.completions.parse(
response = await client.beta.chat.completions.parse(
model=model_name,
messages=messages,
response_format=response_model,
@@ -648,14 +685,15 @@ class BaseAutoGenerator:
def _get_team_models():
"""Get team structure models, creating them on first use."""
def _create_team_models():
from pydantic import BaseModel
class TaskDetails(BaseModel):
"""Details for a single task."""
description: str
expected_output: str
class RoleDetails(BaseModel):
"""Details for a single role/agent."""
role: str
@@ -663,11 +701,11 @@ def _get_team_models():
backstory: str
tasks: Dict[str, TaskDetails]
tools: List[str]
class TeamStructure(BaseModel):
"""Structure for multi-agent team."""
roles: Dict[str, RoleDetails]
class SingleAgentStructure(BaseModel):
"""Structure for single-agent generation (Anthropic's 'start simple' principle)."""
name: str
@@ -678,29 +716,30 @@ def _get_team_models():
tools: List[str] = []
task_description: str
expected_output: str
class PatternRecommendation(BaseModel):
"""LLM-based pattern recommendation with reasoning."""
pattern: str # sequential, parallel, routing, orchestrator-workers, evaluator-optimizer
reasoning: str # Why this pattern was chosen
confidence: float # 0.0 to 1.0 confidence score
class ValidationGate(BaseModel):
"""Validation gate for prompt chaining workflows."""
criteria: str # What to validate
pass_action: str # Action if validation passes (e.g., "continue", "next_step")
fail_action: str # Action if validation fails (e.g., "retry", "escalate", "abort")
return {
'TaskDetails': TaskDetails,
'RoleDetails': RoleDetails,
'TeamStructure': TeamStructure,
'SingleAgentStructure': SingleAgentStructure,
'PatternRecommendation': PatternRecommendation,
'ValidationGate': ValidationGate
"TaskDetails": TaskDetails,
"RoleDetails": RoleDetails,
"TeamStructure": TeamStructure,
"SingleAgentStructure": SingleAgentStructure,
"PatternRecommendation": PatternRecommendation,
"ValidationGate": ValidationGate,
}
return lazy_get('team_models', _create_team_models)
return lazy_get("team_models", _create_team_models)
class AutoGenerator(BaseAutoGenerator):
"""
@@ -848,9 +887,11 @@ class AutoGenerator(BaseAutoGenerator):
for task_id, task_details in role_details['tasks'].items():
yaml_data['roles'][role_id]['tasks'][task_id] = self._format_task(task_details)
# Save to YAML file, maintaining the order
with open(self.agent_file, 'w') as f:
_yaml_dump(yaml_data, f, allow_unicode=True, sort_keys=False)
# Save to YAML file atomically, maintaining the order
_atomic_write_text(
self.agent_file,
lambda f: _yaml_dump(yaml_data, f, allow_unicode=True, sort_keys=False),
)
def merge_with_existing_agents(self, new_json_data):
"""
@@ -1063,23 +1104,23 @@ def _get_workflow_models():
return _models_cache['workflow_models']
from pydantic import BaseModel
class WorkflowStepDetails(BaseModel):
"""Details for a workflow step."""
agent: str
action: str
expected_output: Optional[str] = None
class WorkflowRouteDetails(BaseModel):
"""Details for a route step."""
name: str
route: Dict[str, List[str]]
class WorkflowParallelDetails(BaseModel):
"""Details for a parallel step."""
name: str
parallel: List[WorkflowStepDetails]
class WorkflowAgentDetails(BaseModel):
"""Details for a workflow agent."""
name: str
@@ -1087,7 +1128,7 @@ def _get_workflow_models():
goal: str
instructions: str
tools: Optional[List[str]] = None
class WorkflowStructure(BaseModel):
"""Structure for auto-generated workflow."""
name: str
@@ -1095,7 +1136,7 @@ def _get_workflow_models():
agents: Dict[str, WorkflowAgentDetails]
steps: List[Dict] # Can be agent steps, route, parallel, etc.
gates: Optional[List[Any]] = None # Optional validation gates, ValidationGate type resolved at runtime
_models_cache['workflow_models'] = {
'WorkflowStepDetails': WorkflowStepDetails,
'WorkflowRouteDetails': WorkflowRouteDetails,
@@ -1106,6 +1147,35 @@ def _get_workflow_models():
return _models_cache['workflow_models']
# Names exposed lazily via module __getattr__ (PEP 562) so that
# `from praisonai.auto import PatternRecommendation` keeps working without
# importing pydantic at module load time. Each maps to its lazy factory.
_LAZY_MODEL_EXPORTS = {
"PatternRecommendation": ("_get_team_models", "PatternRecommendation"),
"SingleAgentStructure": ("_get_team_models", "SingleAgentStructure"),
"ValidationGate": ("_get_team_models", "ValidationGate"),
"TeamStructure": ("_get_team_models", "TeamStructure"),
"WorkflowStructure": ("_get_workflow_models", "WorkflowStructure"),
"WorkflowStepDetails": ("_get_workflow_models", "WorkflowStepDetails"),
"WorkflowRouteDetails": ("_get_workflow_models", "WorkflowRouteDetails"),
"WorkflowParallelDetails": ("_get_workflow_models", "WorkflowParallelDetails"),
"WorkflowAgentDetails": ("_get_workflow_models", "WorkflowAgentDetails"),
}
def __getattr__(name: str):
"""Lazily resolve structured-output Pydantic models (PEP 562).
Preserves the module's FULL LAZY LOADING contract: pydantic is only
imported when one of these models is first accessed.
"""
export = _LAZY_MODEL_EXPORTS.get(name)
if export is not None:
factory_name, key = export
return globals()[factory_name]()[key]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
class WorkflowAutoGenerator(BaseAutoGenerator):
"""
Auto-generates workflow.yaml files from a topic description.
@@ -1140,7 +1210,7 @@ class WorkflowAutoGenerator(BaseAutoGenerator):
self.framework = framework
self.single_agent = single_agent
def recommend_pattern_llm(self, topic: str = None) -> PatternRecommendation:
def recommend_pattern_llm(self, topic: Optional[str] = None) -> Any:
"""
Use LLM to recommend the best workflow pattern with reasoning.
@@ -1459,10 +1529,12 @@ Example structure:
if agent_data.get('tools'):
workflow_yaml['agents'][agent_id]['tools'] = agent_data['tools']
# Write to file
# Write to file atomically
full_path = os.path.abspath(self.workflow_file)
with open(full_path, 'w') as f:
_yaml_dump(workflow_yaml, f, default_flow_style=False, sort_keys=False)
_atomic_write_text(
full_path,
lambda f: _yaml_dump(workflow_yaml, f, default_flow_style=False, sort_keys=False),
)
return full_path
@@ -1662,9 +1734,11 @@ Generate a workflow for: {self.topic}
workflow_yaml['steps'].append(step_dict)
# Write to file
# Write to file atomically
full_path = os.path.abspath(self.workflow_file)
with open(full_path, 'w') as f:
_yaml_dump(workflow_yaml, f, default_flow_style=False, sort_keys=False)
_atomic_write_text(
full_path,
lambda f: _yaml_dump(workflow_yaml, f, default_flow_style=False, sort_keys=False),
)
return full_path
+32
View File
@@ -28,8 +28,16 @@ if TYPE_CHECKING:
from ._http_approval import HTTPApproval
from ._streaming import StreamingConfig, StreamingMode, DraftStreamer
from ._outbox import OutboundQueue, OutboundEntry
from ._approval_store import ApprovalStore
from ._delivery import DurableDelivery, deliver_with_retry
from ._durable_adapter import DurableAdapterMixin
from ._correlation import (
correlation_id_from,
current_correlation_id,
new_correlation_id,
use_correlation_id,
)
from ._metrics import GatewayMetrics
def __getattr__(name: str):
"""Lazy loading of bot components."""
@@ -116,6 +124,9 @@ def __getattr__(name: str):
if name == "OutboundEntry":
from ._outbox import OutboundEntry
return OutboundEntry
if name == "ApprovalStore":
from ._approval_store import ApprovalStore
return ApprovalStore
if name == "DurableDelivery":
from ._delivery import DurableDelivery
return DurableDelivery
@@ -128,6 +139,23 @@ def __getattr__(name: str):
if name == "DurableAdapterMixin":
from ._durable_adapter import DurableAdapterMixin
return DurableAdapterMixin
# End-to-end correlation IDs (inbound -> run -> outbound)
if name == "correlation_id_from":
from ._correlation import correlation_id_from
return correlation_id_from
if name == "current_correlation_id":
from ._correlation import current_correlation_id
return current_correlation_id
if name == "new_correlation_id":
from ._correlation import new_correlation_id
return new_correlation_id
if name == "use_correlation_id":
from ._correlation import use_correlation_id
return use_correlation_id
# Gateway message-flow metrics
if name == "GatewayMetrics":
from ._metrics import GatewayMetrics
return GatewayMetrics
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
@@ -138,8 +166,12 @@ __all__ = [
"InboundDLQ", "DLQEntry",
"InboundJournal", "JournalEntry",
"OutboundQueue", "OutboundEntry",
"ApprovalStore",
"DurableDelivery", "deliver_with_retry", "deliver_chunked",
"DurableAdapterMixin",
# Correlation + metrics (gateway message-flow observability)
"correlation_id_from", "current_correlation_id", "new_correlation_id",
"use_correlation_id", "GatewayMetrics",
"SlackApproval", "TelegramApproval", "DiscordApproval",
"WebhookApproval", "HTTPApproval",
# Streaming
@@ -0,0 +1,402 @@
"""
Durable approval store for PraisonAI human-in-the-loop approvals.
Pending approvals are normally held only in process memory; if the gateway or
bot restarts while an approval is outstanding the in-flight decision is lost.
This module provides a SQLite-backed :class:`ApprovalStore` that persists
pending approvals with a stable correlation id so they survive a restart and a
late "Allow"/"Deny" tap still resolves.
Design constraints (per PraisonAI principles, mirroring ``_outbox.py``):
- Wrapper-only — heavy implementation stays out of core SDK.
- Lazy: sqlite3 is stdlib so no extra dependency.
- Optional: handlers work exactly as before unless a store is provided.
- Bounded: resolved entries are evicted by TTL to bound disk growth.
- Thread-safe: per-instance threading.Lock guards SQLite writes.
Implements :class:`praisonaiagents.approval.ApprovalStoreProtocol`.
Storage schema::
pending_approvals(
approval_id TEXT PRIMARY KEY,
ts REAL,
expires_at REAL,
request TEXT, -- JSON of the ApprovalRequest
status TEXT, -- 'pending', 'approved', 'denied', 'expired'
decision TEXT, -- JSON of the ApprovalDecision (when resolved)
approver TEXT,
resolved_at REAL
)
"""
from __future__ import annotations
import asyncio
import json
import logging
import sqlite3
import threading
import time
from contextlib import closing
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union
from praisonaiagents.approval import ApprovalDecision, ApprovalRequest
logger = logging.getLogger(__name__)
# 7 days default — long enough to audit, short enough not to blow up disk.
_DEFAULT_TTL_SECONDS = 7 * 86400
def _serialize_request(request: ApprovalRequest) -> str:
return json.dumps(
{
"tool_name": request.tool_name,
"arguments": request.arguments,
"risk_level": request.risk_level,
"agent_name": request.agent_name,
"session_id": request.session_id,
"context": request.context,
"approval_id": request.approval_id,
},
default=str,
)
def _deserialize_request(raw: str) -> ApprovalRequest:
data = json.loads(raw)
return ApprovalRequest(
tool_name=data["tool_name"],
arguments=data.get("arguments", {}),
risk_level=data.get("risk_level", "medium"),
agent_name=data.get("agent_name"),
session_id=data.get("session_id"),
context=data.get("context", {}),
approval_id=data.get("approval_id"),
)
def _serialize_decision(decision: ApprovalDecision) -> str:
return json.dumps(
{
"approved": decision.approved,
"reason": decision.reason,
"modified_args": decision.modified_args,
"approver": decision.approver,
"metadata": decision.metadata,
},
default=str,
)
class ApprovalStore:
"""SQLite-backed durable store for pending human-in-the-loop approvals.
Implements the ``ApprovalStoreProtocol`` from praisonaiagents.
Args:
path: SQLite file path. Created if missing; parent dirs created.
ttl_seconds: Resolved/expired entries older than this are evicted.
Example::
from praisonai.bots import ApprovalStore
store = ApprovalStore(path="~/.praisonai/state/approvals.sqlite")
await store.persist(req.approval_id, req, expires_at=time.time() + 60)
# On startup:
for approval_id, request in await store.list_pending():
rebind_channel_callback(approval_id, request)
"""
def __init__(
self,
path: Union[str, Path],
*,
ttl_seconds: int = _DEFAULT_TTL_SECONDS,
) -> None:
self.path = Path(path).expanduser()
self.ttl_seconds = int(ttl_seconds)
self._lock = threading.Lock()
self.path.parent.mkdir(parents=True, exist_ok=True)
self._init_schema()
# ── Schema ──────────────────────────────────────────────────────
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(str(self.path))
try:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
return conn
except Exception:
conn.close()
raise
def _init_schema(self) -> None:
with self._lock, closing(self._connect()) as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS pending_approvals (
approval_id TEXT PRIMARY KEY,
ts REAL NOT NULL,
expires_at REAL NOT NULL,
request TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
decision TEXT,
approver TEXT,
resolved_at REAL
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_appr_status ON pending_approvals(status)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_appr_expires ON pending_approvals(expires_at)"
)
conn.commit()
# ── Protocol API (async) ────────────────────────────────────────
async def persist(
self,
approval_id: str,
request: ApprovalRequest,
*,
expires_at: float,
) -> None:
"""Durably store a pending approval keyed by ``approval_id``."""
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None, self._sync_persist, approval_id, request, expires_at
)
def _sync_persist(
self, approval_id: str, request: ApprovalRequest, expires_at: float
) -> None:
with self._lock, closing(self._connect()) as conn:
self._evict_expired_locked(conn)
existing = conn.execute(
"SELECT status, request FROM pending_approvals WHERE approval_id = ?",
(approval_id,),
).fetchone()
if existing is not None:
if existing[0] != "pending":
# Never clobber a resolved/expired row — that would destroy
# the durable audit trail. A collision on a full UUID is
# effectively impossible; if it ever happens we keep the
# prior decision.
logger.warning(
"Refusing to persist approval %s over resolved status %r",
approval_id,
existing[0],
)
return
# An active pending row already exists for this id. Treat an
# identical re-persist as idempotent (no-op), but refuse to
# overwrite a *different* request — replacing the original
# request/timestamp/expiry would let a reused id hijack the
# active prompt's window. Keep the original pending row intact.
if existing[1] == _serialize_request(request):
return
logger.warning(
"Refusing to overwrite active pending approval %s with a "
"different request (duplicate/reused approval_id)",
approval_id,
)
return
conn.execute(
"""
INSERT INTO pending_approvals
(approval_id, ts, expires_at, request, status)
VALUES (?, ?, ?, ?, 'pending')
""",
(
approval_id,
time.time(),
float(expires_at),
_serialize_request(request),
),
)
conn.commit()
async def list_pending(self) -> List[Tuple[str, ApprovalRequest]]:
"""Return outstanding (un-resolved, un-expired) pending approvals."""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._sync_load_pending)
# Backward-compatible alias (pre-rename callers).
async def load_pending(self) -> List[Tuple[str, ApprovalRequest]]:
"""Deprecated alias for :meth:`list_pending`."""
return await self.list_pending()
def _sync_load_pending(self) -> List[Tuple[str, ApprovalRequest]]:
now = time.time()
with self._lock, closing(self._connect()) as conn:
rows = conn.execute(
"""
SELECT approval_id, request FROM pending_approvals
WHERE status = 'pending' AND expires_at > ?
ORDER BY ts ASC
""",
(now,),
).fetchall()
result: List[Tuple[str, ApprovalRequest]] = []
for approval_id, raw in rows:
try:
result.append((approval_id, _deserialize_request(raw)))
except Exception:
logger.exception("Failed to deserialize approval %s", approval_id)
return result
async def resolve(self, approval_id: str, decision: ApprovalDecision) -> bool:
"""Record a final decision for ``approval_id`` as an audit trail.
Returns ``True`` only when a still-pending row was updated, so a stale
or duplicate resolve (the row already expired/resolved) is reported as
``False`` instead of silently appearing successful.
An explicit terminal state may be supplied via
``decision.metadata['terminal']`` (``"approved"`` / ``"denied"`` /
``"expired"``); otherwise it is derived from ``decision.approved``.
"""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None, self._sync_resolve, approval_id, decision
)
def _sync_resolve(self, approval_id: str, decision: ApprovalDecision) -> bool:
terminal = (decision.metadata or {}).get("terminal")
if terminal in ("approved", "denied", "expired"):
status = terminal
else:
status = "approved" if decision.approved else "denied"
with self._lock, closing(self._connect()) as conn:
cur = conn.execute(
"""
UPDATE pending_approvals
SET status = ?, decision = ?, approver = ?, resolved_at = ?
WHERE approval_id = ? AND status = 'pending'
""",
(
status,
_serialize_decision(decision),
decision.approver,
time.time(),
approval_id,
),
)
conn.commit()
updated = int(cur.rowcount or 0) > 0
if not updated:
logger.warning(
"resolve() matched no pending approval %s (already resolved or "
"expired)",
approval_id,
)
return updated
# ── Maintenance / introspection ─────────────────────────────────
async def expire_overdue(self) -> int:
"""Mark pending approvals past their expiry as 'expired'. Returns count."""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._sync_expire_overdue)
def _sync_expire_overdue(self) -> int:
now = time.time()
with self._lock, closing(self._connect()) as conn:
cur = conn.execute(
"""
UPDATE pending_approvals
SET status = 'expired', resolved_at = ?
WHERE status = 'pending' AND expires_at <= ?
""",
(now, now),
)
conn.commit()
return int(cur.rowcount or 0)
def _evict_expired_locked(self, conn: sqlite3.Connection) -> int:
cutoff = time.time() - self.ttl_seconds
cur = conn.execute(
"""
DELETE FROM pending_approvals
WHERE status != 'pending' AND resolved_at IS NOT NULL AND resolved_at <= ?
""",
(cutoff,),
)
return int(cur.rowcount or 0)
def get(self, approval_id: str) -> Optional[Dict[str, Any]]:
"""Return the stored row for ``approval_id`` (for doctor/audit checks)."""
with self._lock, closing(self._connect()) as conn:
row = conn.execute(
"""
SELECT approval_id, ts, expires_at, request, status,
decision, approver, resolved_at
FROM pending_approvals WHERE approval_id = ?
""",
(approval_id,),
).fetchone()
if not row:
return None
return {
"approval_id": row[0],
"ts": row[1],
"expires_at": row[2],
"request": row[3],
"status": row[4],
"decision": row[5],
"approver": row[6],
"resolved_at": row[7],
}
def pending_count(self) -> int:
"""Count of currently un-resolved, un-expired pending approvals."""
now = time.time()
with self._lock, closing(self._connect()) as conn:
return int(
conn.execute(
"SELECT COUNT(*) FROM pending_approvals "
"WHERE status = 'pending' AND expires_at > ?",
(now,),
).fetchone()[0]
)
def stale_count(self) -> int:
"""Count of stale/orphaned pending approvals past expiry (for doctor)."""
now = time.time()
with self._lock, closing(self._connect()) as conn:
return int(
conn.execute(
"SELECT COUNT(*) FROM pending_approvals "
"WHERE status = 'pending' AND expires_at <= ?",
(now,),
).fetchone()[0]
)
def size(self) -> int:
with self._lock, closing(self._connect()) as conn:
return int(
conn.execute("SELECT COUNT(*) FROM pending_approvals").fetchone()[0]
)
def purge(self) -> int:
"""Delete all entries. Returns count removed."""
with self._lock, closing(self._connect()) as conn:
n = conn.execute("SELECT COUNT(*) FROM pending_approvals").fetchone()[0]
conn.execute("DELETE FROM pending_approvals")
conn.commit()
return int(n)
def __repr__(self) -> str:
return (
f"ApprovalStore(path={str(self.path)!r}, "
f"size={self.size()}, pending={self.pending_count()}, "
f"ttl={self.ttl_seconds}s)"
)
__all__ = ["ApprovalStore"]
+292 -1
View File
@@ -7,13 +7,18 @@ Telegram, Discord, and Slack bots. Keep them in one place.
from __future__ import annotations
import logging
import time
from typing import Callable, Dict, Optional, Set, TYPE_CHECKING, Any
from typing import Callable, Dict, List, Optional, Set, TYPE_CHECKING, Any
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from praisonaiagents import Agent
from ._run_control import SessionRunControl
logger = logging.getLogger(__name__)
class CommandAccessPolicy:
"""Manages per-command access control for bot commands."""
@@ -130,6 +135,12 @@ class CommandRegistry:
self.register("queue", {"description": "Queue a follow-up message", "builtin": True})
# Learn a grounded skill from sources (codebase, docs, PDFs, or this chat)
self.register("learn", {"description": "Learn a reusable skill from sources you describe (e.g. /learn deploy steps from this repo)", "builtin": True})
# Conversation-control primitives surfacing existing core capabilities
self.register("undo", {"description": "Undo the last turn's file changes", "builtin": True})
self.register("sessions", {"description": "List recent saved sessions/checkpoints", "builtin": True})
self.register("resume", {"description": "Resume a saved session: /resume <id>", "builtin": True})
self.register("retry", {"description": "Retry your last message", "builtin": True})
self.register("reasoning", {"description": "Toggle whether extended-thinking output is shown", "builtin": True})
def register(
self,
@@ -775,3 +786,283 @@ def handle_learn_command(
return str(result) if result else "✅ Skill authored."
except Exception as e: # noqa: BLE001 - surface a friendly message
return f"❌ Could not learn skill: {e}"
def handle_undo_command(
agent: Optional["Agent"],
) -> str:
"""Handle /undo to rewind the last turn's file changes.
Surfaces the existing core primitive :meth:`Agent.undo`, which restores
files to the state before the last autonomous iteration. Available only
when the agent tracks changes (``autonomy`` with ``track_changes``); the
handler degrades gracefully otherwise.
Args:
agent: The bot's agent (may be None).
Returns:
Response message indicating whether anything was reverted.
"""
if agent is None:
return "❌ Undo is not available (no agent configured)."
undo_fn = getattr(agent, "undo", None)
if not callable(undo_fn):
return "❌ This agent does not support undo (change tracking not enabled)."
try:
reverted = undo_fn()
except Exception as e: # noqa: BLE001 - surface a friendly message
return f"❌ Could not undo: {e}"
return "✅ Reverted the last turn." if reverted else "ℹ️ Nothing to undo."
def handle_sessions_command(
session_manager,
user_id: str,
) -> str:
"""Handle /sessions to list recent saved sessions.
Lists the conversation sessions the gateway is currently tracking so a
user can pick one to ``/resume``. Falls back gracefully when the session
manager does not expose session keys.
Args:
session_manager: BotSessionManager instance.
user_id: User ID requesting the listing.
Returns:
A formatted list of session identifiers, or guidance.
"""
# Scope every lookup to the requesting user's storage key so one user can
# never enumerate another user's sessions (the in-memory `_histories` map
# is a *shared* dict keyed by per-user storage key).
storage_key = user_id
if hasattr(session_manager, "_storage_key"):
try:
storage_key = session_manager._storage_key(user_id)
except Exception:
storage_key = user_id
keys: List[str] = []
list_fn = getattr(session_manager, "list_sessions", None)
if callable(list_fn):
try:
# Prefer a user-scoped contract when the manager supports it;
# fall back to an unfiltered call only if the signature rejects it.
try:
keys = list(list_fn(user_id))
except TypeError:
keys = list(list_fn())
except Exception:
keys = []
elif hasattr(session_manager, "_histories"):
try:
# Only surface the caller's own session, never other users' keys.
if storage_key in session_manager._histories:
keys = [storage_key]
except Exception:
keys = []
if not keys:
return "No saved sessions yet."
lines = ["📁 Recent sessions:"]
for key in keys[:50]:
lines.append(f"• {key}")
lines.append("\nUse /resume <id> to switch to one.")
return "\n".join(lines)
def handle_resume_command(
session_manager,
user_id: str,
session_id: Optional[str] = None,
) -> str:
"""Handle /resume to switch to a previously saved session.
Args:
session_manager: BotSessionManager instance.
user_id: User ID issuing the command.
session_id: Identifier of the session to resume.
Returns:
Response message confirming the resume, or usage guidance.
"""
if not session_id or not session_id.strip():
return "ℹ️ Usage: /resume <id> (see /sessions for the list)"
session_id = session_id.strip()
resume_fn = getattr(session_manager, "resume_session", None)
if callable(resume_fn):
try:
ok = resume_fn(user_id, session_id)
except Exception as e: # noqa: BLE001
return f"❌ Could not resume session: {e}"
return (
f"✅ Resumed session {session_id}."
if ok
else f"❌ Session {session_id} not found."
)
# No resume_session() primitive on this manager. Be honest: we can confirm
# whether a session exists, but we cannot switch the active conversation to
# it, so we must not claim the resume succeeded.
if hasattr(session_manager, "_histories"):
if session_id in session_manager._histories:
return (
"ℹ️ This bot can see saved sessions, but switching to a "
"different one isn't supported here (no resume primitive)."
)
return f"❌ Session {session_id} not found. Use /sessions to list them."
return "❌ Resuming sessions isn't supported by this bot."
def get_last_user_message(
session_manager,
user_id: str,
) -> Optional[str]:
"""Return the most recent non-empty user message for *user_id*, or None.
Adapters use this to actually re-dispatch the prior turn through the normal
conversation path (``session.chat(...)``) so ``/retry`` re-runs the message
instead of merely echoing it.
Args:
session_manager: BotSessionManager instance.
user_id: User ID issuing the command.
Returns:
The previous user message text, or ``None`` if there is nothing to retry.
"""
storage_key = user_id
if hasattr(session_manager, "_storage_key"):
try:
storage_key = session_manager._storage_key(user_id)
except Exception:
storage_key = user_id
history: List[Dict[str, Any]] = []
if hasattr(session_manager, "_histories"):
history = session_manager._histories.get(storage_key, []) or []
for msg in reversed(history):
if msg.get("role") == "user":
content = msg.get("content")
if isinstance(content, str) and content.strip():
return content
return None
def handle_retry_command(
session_manager,
user_id: str,
) -> str:
"""Handle /retry to re-run the user's last message.
Looks up the most recent user turn so the caller can re-submit it. Adapters
should call :func:`get_last_user_message` and re-dispatch the returned text
through their normal ``session.chat(...)`` path; this helper returns a
user-facing acknowledgement (or guidance when there's nothing to retry).
Args:
session_manager: BotSessionManager instance.
user_id: User ID issuing the command.
Returns:
A short acknowledgement, or guidance when there's nothing to retry.
"""
last_user_msg = get_last_user_message(session_manager, user_id)
if not last_user_msg:
return "ℹ️ Nothing to retry — no previous message found."
return f"🔁 Retrying your last message:\n{last_user_msg}"
# Tracks per-user reasoning-visibility preferences for /reasoning. Stored on
# the session manager when available so it survives alongside other per-user
# state; falls back to this module-level map otherwise.
_reasoning_visibility: Dict[str, bool] = {}
def handle_reasoning_command(
session_manager,
user_id: str,
agent: Optional["Agent"] = None,
) -> str:
"""Handle /reasoning to toggle extended-thinking visibility.
Toggles whether extended-thinking ("reasoning") output is shown to the
user for this conversation. The preference is stored per user on the
session manager when possible so the gateway can consult it when rendering
responses.
Args:
session_manager: BotSessionManager instance.
user_id: User ID issuing the command.
agent: Current agent instance (optional).
Returns:
Response message reflecting the new visibility state.
"""
storage_key = user_id
if hasattr(session_manager, "_storage_key"):
try:
storage_key = session_manager._storage_key(user_id)
except Exception:
storage_key = user_id
store = getattr(session_manager, "_reasoning_visibility", None)
if store is None:
if session_manager is not None and not hasattr(
session_manager, "_reasoning_visibility"
):
try:
session_manager._reasoning_visibility = {}
store = session_manager._reasoning_visibility
except Exception:
store = _reasoning_visibility
else:
store = _reasoning_visibility
current = store.get(storage_key, False)
new_state = not current
store[storage_key] = new_state
return (
"🧠 Reasoning output will be shown for this conversation."
if new_state
else "🙈 Reasoning output will be hidden for this conversation."
)
def is_reasoning_visible(session_manager, user_id: str) -> bool:
"""Return the current /reasoning visibility preference for *user_id*.
Response-rendering code can consult this to decide whether to include
extended-thinking output. Defaults to ``False`` (hidden) when no
preference has been set.
Args:
session_manager: BotSessionManager instance.
user_id: User ID whose preference to read.
Returns:
``True`` if reasoning output should be shown, else ``False``.
"""
storage_key = user_id
if hasattr(session_manager, "_storage_key"):
try:
storage_key = session_manager._storage_key(user_id)
except Exception:
storage_key = user_id
store = getattr(session_manager, "_reasoning_visibility", None)
if not isinstance(store, dict):
store = _reasoning_visibility
return bool(store.get(storage_key, False))
@@ -0,0 +1,131 @@
"""
End-to-end correlation IDs for the PraisonAI gateway message flow.
A single correlation id is minted at inbound (or adopted from the platform
message id) and threaded through ingress -> session -> agent run -> outbound
delivery so operators can answer "what happened to *this* message?" by joining
otherwise-disjoint log lines on one stable id.
Design constraints (per PraisonAI principles):
- Wrapper-only — no core SDK changes required; core ``run_id`` can be seeded
from the correlation id by callers that want it.
- Lazy / zero-dependency: stdlib ``uuid`` + ``contextvars`` only.
- Optional & backward compatible: if no correlation id is set, helpers fall
back to minting one; nothing breaks when the feature is unused.
Usage::
from praisonai.bots import correlation_id_from, use_correlation_id, current_correlation_id
cid = correlation_id_from({"message_id": "55"})
with use_correlation_id(cid):
... # every hop in this block reads the same id
logger.info("dispatch", extra={"correlation_id": current_correlation_id()})
"""
from __future__ import annotations
import contextvars
import uuid
from contextlib import contextmanager
from typing import Any, Dict, Iterator, Optional
# Task-local correlation id. ``None`` means "not in a correlated turn".
_CORRELATION_ID: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
"praisonai_correlation_id", default=None
)
# Keys we probe (in order) when adopting an id from an inbound payload.
_ADOPT_KEYS = ("correlation_id", "message_id", "id")
def new_correlation_id() -> str:
"""Mint a fresh short correlation id (first 8 hex chars of a uuid4)."""
return uuid.uuid4().hex[:8]
def correlation_id_from(inbound: Any = None) -> str:
"""Derive a correlation id from an inbound message, or mint a new one.
Adopts an existing identifier from ``inbound`` when present (a dict with a
``correlation_id``/``message_id``/``id`` key, or any object exposing those
attributes); otherwise mints a new id. Always returns a non-empty string.
"""
if inbound is not None:
for key in _ADOPT_KEYS:
value = None
if isinstance(inbound, dict):
value = inbound.get(key)
else:
value = getattr(inbound, key, None)
if value:
return str(value)
return new_correlation_id()
def current_correlation_id() -> Optional[str]:
"""Return the correlation id for the current task, or ``None`` if unset."""
return _CORRELATION_ID.get()
def ensure_correlation_id() -> str:
"""Return the current correlation id, minting and setting one if absent."""
cid = _CORRELATION_ID.get()
if cid is None:
cid = new_correlation_id()
_CORRELATION_ID.set(cid)
return cid
def set_correlation_id(cid: Optional[str]) -> "contextvars.Token[Optional[str]]":
"""Set the task-local correlation id, returning a reset token."""
return _CORRELATION_ID.set(cid)
def reset_correlation_id(token: "contextvars.Token[Optional[str]]") -> None:
"""Reset the task-local correlation id using a token from ``set_correlation_id``."""
try:
_CORRELATION_ID.reset(token)
except (ValueError, LookupError):
# Token created in a different context; clear defensively.
_CORRELATION_ID.set(None)
@contextmanager
def use_correlation_id(cid: Optional[str] = None) -> Iterator[str]:
"""Bind a correlation id for the duration of a ``with`` block.
If ``cid`` is ``None`` a new id is minted. Yields the effective id and
restores the previous value on exit.
"""
effective = cid or new_correlation_id()
token = _CORRELATION_ID.set(effective)
try:
yield effective
finally:
reset_correlation_id(token)
def correlation_log_fields(extra: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Build a structured-log ``extra`` dict carrying the current correlation id.
Merges any ``extra`` mapping with ``{"correlation_id": <current>}`` so every
hop logs the same id without each call site re-reading the contextvar.
"""
fields: Dict[str, Any] = dict(extra or {})
cid = current_correlation_id()
if cid is not None:
fields.setdefault("correlation_id", cid)
return fields
__all__ = [
"new_correlation_id",
"correlation_id_from",
"current_correlation_id",
"ensure_correlation_id",
"set_correlation_id",
"reset_correlation_id",
"use_correlation_id",
"correlation_log_fields",
]
+38 -1
View File
@@ -517,11 +517,46 @@ class DurableDelivery:
return success
def _build_reconciler(self) -> Optional[Callable[[Any], Awaitable[bool]]]:
"""Build a reconciler for the outbox drain, if the adapter supports it.
Returns an async callable that confirms whether a recovered (mid-send
crash) entry was already delivered, enabling effectively-once delivery.
Returns None when the adapter cannot reconcile, in which case drain
falls back to at-least-once re-send.
An adapter opts in by declaring
``PlatformCapabilities.reconciles_unknown_send`` and exposing an async
``was_delivered(idempotency_key) -> bool`` method.
"""
adapter = self.adapter
if adapter is None:
return None
caps = getattr(adapter, "platform_capabilities", None)
if caps is None or not getattr(caps, "reconciles_unknown_send", False):
return None
was_delivered = getattr(adapter, "was_delivered", None)
if not callable(was_delivered):
return None
async def reconciler(entry: Any) -> bool:
return bool(await was_delivered(entry.idempotency_key))
return reconciler
async def drain_pending(self, limit: Optional[int] = None) -> tuple[int, int]:
"""Process pending messages from the outbox.
Called on startup to retry messages that failed to send.
Recovered entries (those that were in-flight when the process last
crashed) are reconciled before re-dispatch when the adapter declares
``reconciles_unknown_send`` and exposes ``was_delivered`` — this avoids
sending the same channel message twice (effectively-once). Otherwise
delivery remains at-least-once.
Args:
limit: Optional max messages to process
@@ -563,7 +598,9 @@ class DurableDelivery:
return success
return await self.outbox.drain(sender, limit=limit)
return await self.outbox.drain(
sender, limit=limit, reconciler=self._build_reconciler()
)
__all__ = [
+203
View File
@@ -0,0 +1,203 @@
"""
Message-flow metrics for the PraisonAI gateway.
Provides a tiny, dependency-free metrics registry so the gateway can expose
throughput/flow counters and gauges (``messages_inbound_total``,
``outbound_failed_total``, ``outbox_depth``, per-channel error counts, ...)
over a ``/metrics`` endpoint in Prometheus text-exposition format.
Design constraints (per PraisonAI principles):
- Wrapper-only — gateway infrastructure, not a core/agent concern.
- Zero-dependency: stdlib only; no ``prometheus_client`` requirement. If
``prometheus_client`` happens to be installed, callers may still scrape
this surface since the output follows the text format.
- Thread-safe: a single lock guards all mutations so background supervision
threads and the asyncio loop can both update counters safely.
- Optional: nothing here runs unless the gateway constructs a registry.
Usage::
from praisonai.bots import GatewayMetrics
metrics = GatewayMetrics()
metrics.inc("messages_inbound_total")
metrics.inc("outbound_failed_total", labels={"channel": "telegram"})
metrics.set_gauge("outbox_depth", 3)
text = metrics.render_prometheus() # serve at GET /metrics
"""
from __future__ import annotations
import threading
from typing import Callable, Dict, List, Optional, Tuple
# Stable metric names + help text so the exposition output is self-describing.
_COUNTER_HELP: Dict[str, str] = {
"messages_inbound_total": "Total inbound messages received by the gateway.",
"messages_dispatched_total": "Total inbound messages dispatched to an agent run.",
"messages_duplicate_total": "Total inbound messages dropped as duplicates.",
"outbound_sent_total": "Total outbound messages successfully delivered.",
"outbound_failed_total": "Total outbound messages that failed delivery.",
"approval_pending_total": "Total approval requests created.",
"approval_decided_total": "Total approval requests decided (allowed or denied).",
"channel_errors_total": "Total channel errors observed by supervision.",
"channel_restarts_total": "Total channel restarts performed by supervision.",
}
_GAUGE_HELP: Dict[str, str] = {
"outbox_depth": "Current number of messages pending outbound delivery.",
"approval_pending": "Current number of approvals awaiting a decision.",
"active_sessions": "Current number of active gateway sessions.",
"channel_recoveries": "Total supervision recoveries per channel.",
}
# A label set is a sorted tuple of (name, value) pairs so it is hashable and
# renders deterministically.
_LabelKey = Tuple[Tuple[str, str], ...]
def _label_key(labels: Optional[Dict[str, str]]) -> _LabelKey:
if not labels:
return ()
return tuple(sorted((str(k), str(v)) for k, v in labels.items()))
def _render_labels(key: _LabelKey) -> str:
if not key:
return ""
inner = ",".join(f'{name}="{_escape(value)}"' for name, value in key)
return "{" + inner + "}"
def _escape(value: str) -> str:
return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
class GatewayMetrics:
"""Thread-safe, dependency-free message-flow metrics registry."""
def __init__(self) -> None:
self._lock = threading.Lock()
# name -> label_key -> value
self._counters: Dict[str, Dict[_LabelKey, float]] = {}
self._gauges: Dict[str, Dict[_LabelKey, float]] = {}
# name -> callable returning a live value (e.g. outbox_depth probe)
self._gauge_providers: Dict[str, Callable[[], float]] = {}
# ── Counters ────────────────────────────────────────────────────
def inc(
self,
name: str,
amount: float = 1.0,
labels: Optional[Dict[str, str]] = None,
) -> None:
"""Increment a counter (monotonically increasing)."""
key = _label_key(labels)
with self._lock:
series = self._counters.setdefault(name, {})
series[key] = series.get(key, 0.0) + amount
def counter_value(
self, name: str, labels: Optional[Dict[str, str]] = None
) -> float:
key = _label_key(labels)
with self._lock:
return self._counters.get(name, {}).get(key, 0.0)
# ── Gauges ──────────────────────────────────────────────────────
def set_gauge(
self,
name: str,
value: float,
labels: Optional[Dict[str, str]] = None,
) -> None:
"""Set a gauge to an absolute value."""
key = _label_key(labels)
with self._lock:
self._gauges.setdefault(name, {})[key] = float(value)
def register_gauge_provider(
self, name: str, provider: Callable[[], float]
) -> None:
"""Register a callable sampled at render time for a live gauge value.
Useful for values derived from external state (e.g. an ``OutboundQueue``
``pending_count``) so ``/metrics`` always reflects the current depth
without the caller pushing updates.
"""
with self._lock:
self._gauge_providers[name] = provider
def gauge_value(
self, name: str, labels: Optional[Dict[str, str]] = None
) -> float:
key = _label_key(labels)
with self._lock:
if key == () and name in self._gauge_providers:
try:
return float(self._gauge_providers[name]())
except Exception:
pass
return self._gauges.get(name, {}).get(key, 0.0)
# ── Snapshot / rendering ────────────────────────────────────────
def snapshot(self) -> Dict[str, Dict[str, float]]:
"""Return a plain-dict snapshot for JSON callers or tests.
Series with labels are flattened to ``name{a="b"}`` keys; unlabelled
series use the bare ``name``.
"""
out: Dict[str, Dict[str, float]] = {"counters": {}, "gauges": {}}
with self._lock:
for name, series in self._counters.items():
for key, value in series.items():
out["counters"][name + _render_labels(key)] = value
gauges = {
name: dict(series) for name, series in self._gauges.items()
}
providers = dict(self._gauge_providers)
for name, provider in providers.items():
try:
gauges.setdefault(name, {})[()] = float(provider())
except Exception:
continue
for name, series in gauges.items():
for key, value in series.items():
out["gauges"][name + _render_labels(key)] = value
return out
def render_prometheus(self) -> str:
"""Render all metrics in Prometheus text-exposition format."""
lines: List[str] = []
with self._lock:
counters = {
name: dict(series) for name, series in self._counters.items()
}
gauges = {name: dict(series) for name, series in self._gauges.items()}
providers = dict(self._gauge_providers)
# Sample live gauge providers outside the lock.
for name, provider in providers.items():
try:
gauges.setdefault(name, {})[()] = float(provider())
except Exception:
continue
for name in sorted(counters):
help_text = _COUNTER_HELP.get(name, name)
lines.append(f"# HELP {name} {help_text}")
lines.append(f"# TYPE {name} counter")
for key in sorted(counters[name]):
lines.append(f"{name}{_render_labels(key)} {counters[name][key]}")
for name in sorted(gauges):
help_text = _GAUGE_HELP.get(name, name)
lines.append(f"# HELP {name} {help_text}")
lines.append(f"# TYPE {name} gauge")
for key in sorted(gauges[name]):
lines.append(f"{name}{_render_labels(key)} {gauges[name][key]}")
return "\n".join(lines) + "\n"
__all__ = ["GatewayMetrics"]
+77 -10
View File
@@ -12,6 +12,14 @@ Design constraints (per PraisonAI principles):
- Bounded: TTL + max_size prevent unbounded disk growth
- Thread-safe: per-instance threading.Lock guards SQLite writes
- Atomic: claim/complete mechanism ensures at-least-once delivery
- Effectively-once (opt-in): a crash after a message is handed to the channel
API but before its terminal status is recorded leaves the entry in the
``sending`` state. On restart such entries are moved to ``recovered`` rather
than blindly re-sent. If a ``reconciler`` is supplied to ``drain`` (only
adapters that declare ``PlatformCapabilities.reconciles_unknown_send`` can),
it is asked whether the prior attempt actually landed; if so the entry is
marked ``sent`` without re-dispatch. Without a reconciler, ``recovered``
entries fall back to at-least-once re-send (current behaviour).
Storage schema::
@@ -22,7 +30,7 @@ Storage schema::
target TEXT,
payload TEXT,
metadata TEXT,
status TEXT, -- 'pending', 'sending', 'sent', 'failed', 'permanent_failure'
status TEXT, -- 'pending', 'sending', 'recovered', 'sent', 'failed', 'permanent_failure'
attempts INTEGER DEFAULT 0,
last_attempt REAL,
error TEXT,
@@ -34,7 +42,7 @@ Public API:
- enqueue(idempotency_key, target, payload, metadata) -> str
- mark_sent(key)
- mark_failed(key, error, permanent=False)
- drain(sender) -> (succeeded, failed)
- drain(sender, *, reconciler=None) -> (succeeded, failed)
"""
from __future__ import annotations
@@ -171,10 +179,15 @@ class OutboundQueue:
conn.execute("CREATE INDEX IF NOT EXISTS idx_ts ON outbound_queue(ts)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_sent_at ON outbound_queue(sent_at)")
# Reset stale 'sending' claims from previous crashes
# Move in-flight 'sending' claims from a previous crash into the
# 'recovered' state. These were handed to (or about to be handed to)
# the channel API before the process died, so they MUST be
# reconciled before re-dispatch to avoid duplicate delivery. Drain
# treats 'recovered' like a retryable entry but offers it to a
# reconciler first.
conn.execute("""
UPDATE outbound_queue
SET status = 'pending'
SET status = 'recovered'
WHERE status = 'sending'
""")
conn.commit()
@@ -257,8 +270,9 @@ class OutboundQueue:
cur = conn.execute("""
UPDATE outbound_queue
SET status = 'sent', sent_at = ?
WHERE id = ? AND status IN ('pending', 'sending', 'failed')
WHERE id = ? AND status IN ('pending', 'sending', 'recovered', 'failed')
""", (time.time(), entry_id))
conn.commit()
# Release active claim
self._active_claims.pop(key, None)
@@ -281,11 +295,16 @@ class OutboundQueue:
status = 'permanent_failure' if permanent else 'failed'
with self._lock, closing(self._connect()) as conn:
# Never overwrite a terminal state. A concurrent direct send may have
# already recorded 'sent' for this row; clobbering it with 'failed'
# here would re-queue an entry that already reached the channel and
# produce a duplicate. Only in-flight states may transition to failed.
cur = conn.execute("""
UPDATE outbound_queue
SET status = ?, error = ?, last_attempt = ?, attempts = attempts + 1
WHERE id = ?
WHERE id = ? AND status IN ('pending', 'sending', 'recovered', 'failed')
""", (status, error, time.time(), entry_id))
conn.commit()
# Release active claim
self._active_claims.pop(key, None)
@@ -296,12 +315,22 @@ class OutboundQueue:
self,
sender: Callable[[str, Dict[str, Any]], Awaitable[bool]],
limit: Optional[int] = None,
*,
reconciler: Optional[Callable[[OutboundEntry], Awaitable[bool]]] = None,
) -> Tuple[int, int]:
"""Process pending messages.
Args:
sender: Async function that attempts delivery
limit: Optional max messages to process
reconciler: Optional async function that, given a ``recovered`` entry
(one that was in-flight when the process last crashed), returns
True if the prior send actually landed. When it returns True the
entry is marked sent WITHOUT re-dispatch, giving effectively-once
delivery. Only entries recovered from a crash are reconciled;
fresh ``pending``/``failed`` entries are sent normally. If no
reconciler is supplied, recovered entries are re-sent
(at-least-once).
Returns:
Tuple of (succeeded, failed) counts
@@ -330,6 +359,31 @@ class OutboundQueue:
if not self._claim_entry(key, entry.id):
continue
# Effectively-once: an entry recovered from a mid-send crash may have
# already been delivered. Reconcile before re-dispatch so the user
# does not receive a duplicate.
if entry.status == "recovered" and reconciler is not None:
try:
if await reconciler(entry):
# Only treat as delivered if the terminal write actually
# landed. If another worker mutated the row between claim
# and here, mark_sent returns False; fall through to a
# normal send rather than counting a non-terminal row.
if await self.mark_sent(key):
succeeded += 1
logger.info(
f"Reconciled recovered entry {key} as already "
f"delivered; skipped re-send"
)
continue
except Exception as e:
# Reconciliation is best-effort; if it fails, fall through to
# an at-least-once re-send rather than dropping the message.
logger.warning(
f"Reconciliation failed for {key}: {e}; "
f"falling back to re-send"
)
try:
# Attempt delivery
payload = json.loads(entry.payload)
@@ -370,17 +424,29 @@ class OutboundQueue:
# Claim it
self._active_claims[key] = now
stale_claim_cutoff = now - 300 # mirrors _get_pending_entries
with closing(self._connect()) as conn:
# Claim retryable rows, plus stale 'sending' rows whose owning
# process died without recording a terminal status. Without the
# stale clause an abandoned in-process claim is selected by
# _get_pending_entries forever but never re-claimed until restart.
cur = conn.execute("""
UPDATE outbound_queue
SET status = 'sending', last_attempt = ?
WHERE id = ? AND status IN ('pending', 'failed')
""", (now, entry_id))
WHERE id = ? AND (
status IN ('pending', 'recovered', 'failed')
OR (
status = 'sending'
AND (last_attempt IS NULL OR last_attempt <= ?)
)
)
""", (now, entry_id, stale_claim_cutoff))
if cur.rowcount == 0:
# Already being processed
self._active_claims.pop(key, None)
return False
conn.commit()
return True
@@ -392,6 +458,7 @@ class OutboundQueue:
SET status = 'permanent_failure', error = ?, last_attempt = ?
WHERE id = ?
""", (error, time.time(), entry_id))
conn.commit()
def _get_pending_entries(self, limit: Optional[int] = None) -> List[OutboundEntry]:
"""Get pending entries for processing."""
@@ -401,7 +468,7 @@ class OutboundQueue:
SELECT id, ts, idempotency_key, target, payload, metadata,
status, attempts, last_attempt, error, sent_at
FROM outbound_queue
WHERE status IN ('pending', 'failed')
WHERE status IN ('pending', 'recovered', 'failed')
OR (status = 'sending' AND (last_attempt IS NULL OR last_attempt <= ?))
ORDER BY ts ASC
"""
@@ -469,7 +536,7 @@ class OutboundQueue:
"""Get count of pending messages awaiting delivery."""
with self._lock, closing(self._connect()) as conn:
return int(conn.execute(
"SELECT COUNT(*) FROM outbound_queue WHERE status IN ('pending', 'failed')"
"SELECT COUNT(*) FROM outbound_queue WHERE status IN ('pending', 'recovered', 'failed')"
).fetchone()[0])
def size(self) -> int:
@@ -9,23 +9,76 @@ from __future__ import annotations
import asyncio
import logging
import time
import uuid
from typing import Any, Dict, Optional, TYPE_CHECKING
from collections import deque
from typing import Any, Deque, Dict, Iterable, List, Optional, Set, TYPE_CHECKING
if TYPE_CHECKING:
from praisonaiagents.bots.presentation import MessagePresentation
from praisonaiagents.approval import ApprovalStoreProtocol
logger = logging.getLogger(__name__)
# Bound replay/audit retention so long-running bots don't grow unboundedly.
# The newest N resolved ids/audit entries are retained; older ones are evicted.
_DEFAULT_HISTORY_LIMIT = 1000
class PresentationApprovalHandler:
"""Handles tool approvals using interactive presentations."""
def __init__(self):
"""Initialize the approval handler."""
"""Handles tool approvals using interactive presentations.
When an optional :class:`ApprovalStoreProtocol` backend is supplied, pending
approvals are persisted durably so they survive a process restart: a late
"Allow"/"Deny" tap that arrives after the restart still resolves, and every
decision is recorded as an audit trail. Without a store the handler keeps
the original in-memory, zero-dependency behaviour.
"""
def __init__(
self,
store: Optional["ApprovalStoreProtocol"] = None,
history_limit: int = _DEFAULT_HISTORY_LIMIT,
):
"""Initialize the approval handler.
Args:
store: Optional durable store implementing ``ApprovalStoreProtocol``.
When ``None`` (default) pending approvals are in-memory only.
history_limit: Maximum number of resolved approval ids and audit
entries to retain. Bounds memory for long-running bots; the
oldest entries are evicted once the limit is exceeded. Replay
protection still holds for the most recent ``history_limit``
approvals, which far exceeds any realistic in-flight window.
"""
self._pending_approvals: Dict[str, Dict[str, Any]] = {}
self._approval_futures: Dict[str, asyncio.Future] = {}
self._store = store
# Audit trail of resolved approvals for replay protection and
# accountability. Records who resolved each approval, the decision,
# and when. A resolved id is single-use: subsequent callbacks for it
# are treated as no-ops. Both containers are bounded (FIFO eviction)
# to avoid unbounded memory growth in long-running bots.
self._history_limit = max(1, int(history_limit))
self._audit_log: Deque[Dict[str, Any]] = deque(maxlen=self._history_limit)
self._resolved_ids: Set[str] = set()
# Insertion-ordered queue mirroring _resolved_ids for FIFO eviction.
self._resolved_order: Deque[str] = deque()
def _mark_resolved(self, approval_id: str) -> None:
"""Record an approval id as resolved with bounded FIFO retention.
Evicts the oldest resolved id once the configured ``history_limit`` is
exceeded so replay-protection state cannot grow without bound.
"""
if approval_id in self._resolved_ids:
return
self._resolved_ids.add(approval_id)
self._resolved_order.append(approval_id)
while len(self._resolved_order) > self._history_limit:
oldest = self._resolved_order.popleft()
self._resolved_ids.discard(oldest)
async def request_approval(
self,
tool_name: str,
@@ -34,6 +87,10 @@ class PresentationApprovalHandler:
channel_send_func: Optional[Any] = None,
target: Optional[str] = None,
timeout: float = 60.0,
allowed_actors: Optional[Iterable[str]] = None,
approval_id: Optional[str] = None,
agent_name: Optional[str] = None,
session_id: Optional[str] = None,
) -> Dict[str, Any]:
"""Request approval for a tool execution using buttons.
@@ -44,32 +101,62 @@ class PresentationApprovalHandler:
channel_send_func: Function to send the presentation
target: Target chat/channel ID
timeout: Timeout in seconds
allowed_actors: Optional set of actor (user) IDs permitted to
resolve this approval (e.g. the requesting user plus any
configured ``owner_user_id``/``admin_users``). When provided,
only these actors may approve/deny; any other clicker is
rejected. When ``None``, any actor may resolve (legacy
behaviour, backward compatible).
approval_id: Optional stable correlation id (auto-generated when
omitted) used to match an inbound callback after a
restart.
agent_name: Optional agent that triggered the call (persisted for
restart-safe audit).
session_id: Optional session identifier (persisted for restart-safe
audit).
Returns:
Dict with 'approved' (bool), 'reason' (str), and 'modified_args' (dict)
"""
from praisonaiagents.bots.presentation import MessagePresentation
# Generate unique approval ID
approval_id = str(uuid.uuid4())[:8]
# Generate unique approval ID (stable correlation id). Use the full
# UUID hex — truncating risks collisions in the durable store.
if approval_id is None:
approval_id = uuid.uuid4().hex
# Normalize the authorized actor set (str-typed for cross-platform IDs)
normalized_actors: Optional[Set[str]] = (
{str(a) for a in allowed_actors} if allowed_actors is not None else None
)
# Store approval context
self._pending_approvals[approval_id] = {
"tool_name": tool_name,
"arguments": arguments,
"risk_level": risk_level,
"allowed_actors": normalized_actors,
"target": target,
}
# Create approval future
future = asyncio.get_running_loop().create_future()
self._approval_futures[approval_id] = future
# Persist pending approval durably (survives restart) if a store exists
await self._persist_pending(
approval_id, tool_name, arguments, risk_level, target, timeout,
agent_name=agent_name, session_id=session_id,
)
# Create approval presentation
presentation = self._create_approval_presentation(
approval_id, tool_name, arguments, risk_level
)
# Send presentation if channel function provided
# Send presentation if channel function provided. If delivery fails the
# caller can never resolve, so fail fast instead of waiting out the
# timeout on an approval no user ever saw — and clear the durable row.
if channel_send_func and target:
try:
await channel_send_func(target, presentation)
@@ -77,21 +164,159 @@ class PresentationApprovalHandler:
raise
except Exception:
logger.exception("Failed to send approval presentation")
self._pending_approvals.pop(approval_id, None)
self._approval_futures.pop(approval_id, None)
result = {
"approved": False,
"reason": "Failed to deliver approval request",
"modified_args": {},
}
await self._record_decision(
approval_id, result, approver="system", terminal="expired"
)
return result
# Wait for approval decision with timeout
try:
result = await asyncio.wait_for(future, timeout=timeout)
return result
except asyncio.TimeoutError:
# Clean up
# Clean up and record the timeout as a resolution so any late
# callback for this id is treated as a no-op (replay protection).
self._pending_approvals.pop(approval_id, None)
self._approval_futures.pop(approval_id, None)
return {
self._mark_resolved(approval_id)
self._audit_log.append({
"approval_id": approval_id,
"tool_name": tool_name,
"actor": None,
"decision": "timeout",
"approved": False,
"timestamp": time.time(),
})
result = {
"approved": False,
"reason": f"Approval timed out after {timeout} seconds",
"modified_args": {},
}
# Record as an explicit 'expired' terminal state (not 'denied').
await self._record_decision(
approval_id, result, approver="system", terminal="expired"
)
return result
async def _persist_pending(
self,
approval_id: str,
tool_name: str,
arguments: Dict[str, Any],
risk_level: str,
target: Optional[str],
timeout: float,
agent_name: Optional[str] = None,
session_id: Optional[str] = None,
) -> None:
"""Persist a pending approval to the durable store, if configured."""
if self._store is None:
return
try:
from praisonaiagents.approval import ApprovalRequest
request = ApprovalRequest(
tool_name=tool_name,
arguments=arguments,
risk_level=risk_level,
agent_name=agent_name,
session_id=session_id,
context={"target": target} if target else {},
approval_id=approval_id,
)
await self._store.persist(
approval_id, request, expires_at=time.time() + timeout
)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Failed to persist pending approval %s", approval_id)
async def _record_decision(
self,
approval_id: str,
result: Dict[str, Any],
approver: Optional[str] = None,
terminal: Optional[str] = None,
) -> Optional[bool]:
"""Record a resolved decision to the durable store, if configured.
``terminal`` optionally pins the durable status (``"approved"`` /
``"denied"`` / ``"expired"``) so a timeout is audited distinctly from a
user denial.
Returns:
``None`` when no store is configured (nothing to record).
Otherwise the store's ``resolve()`` result: ``True`` when a still
-pending durable row was updated, ``False`` when the row was already
resolved/expired (a stale callback). A persistence error also yields
``False`` so callers can detect that the decision was not durably
recorded.
"""
if self._store is None:
return None
try:
from praisonaiagents.approval import ApprovalDecision
metadata = {"terminal": terminal} if terminal else {}
decision = ApprovalDecision(
approved=bool(result.get("approved")),
reason=result.get("reason", ""),
modified_args=result.get("modified_args", {}),
approver=approver,
metadata=metadata,
)
return bool(await self._store.resolve(approval_id, decision))
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Failed to record approval decision %s", approval_id)
return False
async def rehydrate(self) -> int:
"""Re-hydrate outstanding pending approvals from the durable store.
Called on startup so a late "Allow"/"Deny" tap that arrives after a
restart can still be resolved by :meth:`handle_approval_command`.
Returns:
Number of pending approvals re-hydrated. ``0`` when no store is
configured.
"""
if self._store is None:
return 0
count = 0
try:
lister = getattr(self._store, "list_pending", None) or self._store.load_pending
pending = await lister()
except Exception:
logger.exception("Failed to load pending approvals from store")
return 0
loop = asyncio.get_running_loop()
for approval_id, request in pending:
if approval_id in self._pending_approvals:
continue
self._pending_approvals[approval_id] = {
"tool_name": request.tool_name,
"arguments": request.arguments,
"risk_level": request.risk_level,
"target": (request.context or {}).get("target"),
# Flag so handle_approval_command treats the durable store as
# the source of truth and ignores a stale tap on an already
# expired/resolved row.
"rehydrated": True,
}
self._approval_futures[approval_id] = loop.create_future()
count += 1
return count
def _create_approval_presentation(
self,
@@ -166,16 +391,39 @@ class PresentationApprovalHandler:
# Create presentation
return MessagePresentation(blocks=[
PresentationBlock.text(prompt),
PresentationBlock.make_text(prompt),
PresentationBlock.make_buttons(buttons),
PresentationBlock.context(f"Approval ID: {approval_id}"),
PresentationBlock.make_context(f"Approval ID: {approval_id}"),
])
def is_authorized(self, approval_id: str, actor: Optional[str]) -> bool:
"""Check whether ``actor`` may resolve the given pending approval.
Args:
approval_id: The approval ID
actor: The resolving user's ID (may be ``None`` if the channel
could not determine it)
Returns:
True if the approval has no actor restriction, or ``actor`` is in
the approval's ``allowed_actors`` set. False otherwise (including
when the approval is unknown/already resolved).
"""
context = self._pending_approvals.get(approval_id)
if context is None:
return False
allowed_actors = context.get("allowed_actors")
if allowed_actors is None:
return True
return actor is not None and str(actor) in allowed_actors
async def handle_approval_command(
self,
approval_id: str,
decision: str,
modified_args: Optional[Dict[str, Any]] = None,
actor: Optional[str] = None,
approver: Optional[str] = None,
) -> bool:
"""Handle an approval command from a button click.
@@ -183,40 +431,119 @@ class PresentationApprovalHandler:
approval_id: The approval ID
decision: The decision (allow/deny/always)
modified_args: Optional modified arguments
actor: ID of the user resolving the approval. When the approval
was created with ``allowed_actors``, the actor must be a member
of that set or the callback is rejected (and the pending
approval is left intact for an authorized actor to resolve).
approver: Optional identity of who approved (for the durable audit
trail). Defaults to ``actor`` when omitted.
Returns:
True if handled, False if approval not found
True if handled, False if the approval is unknown, already
resolved (replay), or the actor is not authorized.
"""
# Replay protection: an already-resolved id is single-use.
if approval_id in self._resolved_ids:
logger.warning(
f"Approval {approval_id} already resolved; ignoring duplicate "
f"callback from actor '{actor}'"
)
return False
# Check if approval exists
if approval_id not in self._pending_approvals:
logger.warning(f"Approval {approval_id} not found or already processed")
return False
# Get approval context
context = self._pending_approvals.pop(approval_id)
future = self._approval_futures.pop(approval_id, None)
if not future:
# Enforce actor authorization. Do NOT pop the pending approval on an
# unauthorized attempt, so the legitimate actor can still resolve it.
if not self.is_authorized(approval_id, actor):
logger.warning(
f"Actor '{actor}' not authorized for approval {approval_id}; "
f"rejecting"
)
self._audit_log.append({
"approval_id": approval_id,
"tool_name": self._pending_approvals[approval_id].get("tool_name"),
"actor": str(actor) if actor is not None else None,
"decision": decision,
"approved": False,
"authorized": False,
"timestamp": time.time(),
})
return False
# Peek at context without committing to resolution yet. A rehydrated
# approval may already be expired/resolved in the durable store, in
# which case this is a stale tap and must not look handled.
context = self._pending_approvals[approval_id]
is_rehydrated = self._approval_futures.get(approval_id) is None or context.get(
"rehydrated", False
)
# Create result
result = {
"approved": decision in ("allow", "always"),
"reason": f"User clicked {decision} button",
"modified_args": modified_args or {},
}
# Add always-allow flag if applicable
if decision == "always":
result["always_allow"] = True
result["reason"] = "User granted permanent approval for this tool"
# Complete the future
if not future.done():
# Record the decision durably (audit trail + survives the waiter).
# Default the durable approver to the resolving actor when not given.
recorded = await self._record_decision(
approval_id, result, approver=approver or actor
)
# For a rehydrated approval (no original waiter), the durable store is
# the source of truth. If resolve() matched no pending row (already
# expired/resolved), this is a stale tap: do not mark it handled, and
# leave state untouched so the audit trail stays accurate.
if recorded is False and is_rehydrated:
logger.warning(
"Ignoring stale callback for approval %s from actor '%s' "
"(durable row already resolved or expired)",
approval_id,
actor,
)
return False
# Commit the resolution (single-use from here on).
self._pending_approvals.pop(approval_id, None)
future = self._approval_futures.pop(approval_id, None)
self._mark_resolved(approval_id)
# Record the resolution for audit (who/what/when/decision)
self._audit_log.append({
"approval_id": approval_id,
"tool_name": context.get("tool_name"),
"actor": str(actor) if actor is not None else None,
"decision": decision,
"approved": result["approved"],
"authorized": True,
"timestamp": time.time(),
})
# Complete the future (may be absent after a restart re-hydration)
if future is not None and not future.done():
future.set_result(result)
return True
@property
def audit_log(self) -> List[Dict[str, Any]]:
"""Return the immutable-ish audit trail of resolved approvals.
Each entry records ``approval_id``, ``tool_name``, ``actor``,
``decision``, ``approved``, and ``timestamp``. Entries are copied so
callers cannot mutate the handler's internal audit trail.
"""
return [dict(entry) for entry in self._audit_log]
def parse_approval_callback(self, callback_data: str) -> Optional[Dict[str, str]]:
"""Parse approval callback data from button clicks.
@@ -247,7 +574,12 @@ class PresentationApprovalHandler:
return None
def cleanup(self) -> None:
"""Clean up pending approvals."""
"""Clean up in-memory pending approvals.
In-memory futures are cancelled, but when a durable store is configured
the persisted pending rows remain on disk so a late tap after a restart
is still resolvable via :meth:`rehydrate`.
"""
# Cancel all pending futures
for future in self._approval_futures.values():
if not future.done():
@@ -261,9 +593,18 @@ class PresentationApprovalHandler:
_global_handler = None
def get_approval_handler() -> PresentationApprovalHandler:
"""Get the global approval handler instance."""
def get_approval_handler(
store: Optional["ApprovalStoreProtocol"] = None,
) -> PresentationApprovalHandler:
"""Get the global approval handler instance.
Args:
store: Optional durable store. Applied to the global handler when it is
first created, or attached to an existing handler that has none.
"""
global _global_handler
if _global_handler is None:
_global_handler = PresentationApprovalHandler()
_global_handler = PresentationApprovalHandler(store=store)
elif store is not None and _global_handler._store is None:
_global_handler._store = store
return _global_handler
+195 -1
View File
@@ -115,8 +115,24 @@ class BotSessionManager:
# Agent instance never leaks one user's model to another. Keyed by
# storage_key (same as _histories).
self._model_overrides: Dict[str, Any] = {}
# Per-route toolset scope staged by a routing handler that cannot thread
# ``tool_policy`` through the adapter's own ``chat()`` call (Issue #2298).
# The gateway's injected on_message handler runs synchronously right
# before the adapter's ``_session.chat()`` in the same dispatch, so it
# stages the resolved policy here keyed by agent identity; ``chat()``
# consumes-and-clears it when no explicit ``tool_policy`` was passed.
# Keyed by ``id(agent)`` so a shared session serving multiple agents
# never crosses policies.
self._pending_tool_policies: Dict[int, Any] = {}
# Session reset policy for automatic lifecycle management
self._reset_policy = reset_policy or SessionResetPolicy(mode="none")
# Per-user last agent-emitted presentation. ``chat()`` keeps the return
# type ``str`` for backward compatibility; when an agent (or hook)
# returns a MessagePresentation/AgentReply, the portable presentation is
# captured here so channel adapters can render interactive UI via the
# existing per-channel renderers (text fallback otherwise). Keyed by
# storage_key and consumed via ``pop_last_presentation``.
self._last_presentation: Dict[str, Any] = {}
# Track storage keys we've already fired SESSION_START for, so the
# hook fires exactly once per session lifetime (until reset).
self._seen_sessions: set = set()
@@ -384,6 +400,77 @@ class BotSessionManager:
except Exception as e:
logger.warning("Failed to persist session to store: %s", e)
def set_pending_tool_policy(
self, agent: "Agent", tool_policy: Optional[Any]
) -> None:
"""Stage a per-route toolset scope for ``agent``'s next ``chat()`` turn.
Used by routing handlers (e.g. the gateway's injected Discord/Slack
``on_message``) that resolve a :class:`ToolPolicy` but cannot thread it
through the adapter's own ``_session.chat()`` call (Issue #2298). The
handler runs synchronously right before that ``chat()`` in the same
dispatch, so the staged policy is consumed-and-cleared by the very next
``chat()`` for the same agent. ``None`` clears any prior staging so a
trusted route never inherits an earlier untrusted route's scope.
"""
if agent is None:
return
key = id(agent)
if tool_policy is None:
self._pending_tool_policies.pop(key, None)
else:
self._pending_tool_policies[key] = tool_policy
@staticmethod
def _apply_tool_policy(
agent: "Agent", tool_policy: Optional[Any]
) -> Optional[Any]:
"""Scope ``agent.tools`` per ``tool_policy`` for one turn (Issue #2298).
Returns a zero-arg callable that restores the agent's original tools,
or ``None`` when no scoping was applied (no policy, no tools, or the
policy removed nothing). The apply/restore mirrors the scheduler's
proven ``_apply_toolset_scope`` so attended/trusted uses of the same
shared agent are never affected.
"""
if tool_policy is None:
return None
filter_tools = getattr(tool_policy, "filter_tools", None)
if not callable(filter_tools):
return None
original = getattr(agent, "tools", None)
if not original:
return None
try:
filtered = filter_tools(list(original))
except Exception as e: # pragma: no cover - defensive
logger.warning("Tool policy could not scope agent tools: %s", e)
return None
if len(filtered) == len(original):
return None
try:
agent.tools = filtered
except Exception as e: # pragma: no cover - defensive
logger.warning("Tool policy could not assign scoped tools: %s", e)
return None
removed = len(original) - len(filtered)
logger.info(
"Route tool policy scoped agent tools for inbound turn: "
"%d -> %d (%d removed)",
len(original),
len(filtered),
removed,
)
def _restore() -> None:
try:
agent.tools = original
except Exception as e: # pragma: no cover - defensive
logger.warning("Tool policy could not restore agent tools: %s", e)
return _restore
async def chat(
self,
agent: "Agent",
@@ -395,6 +482,8 @@ class BotSessionManager:
message_id: str = "",
account: str = "",
stream_callback: Optional[Any] = None,
tool_policy: Optional[Any] = None,
correlation_id: str = "",
) -> str:
"""Run ``agent.chat(prompt)`` with *user_id*-scoped history.
@@ -407,6 +496,14 @@ class BotSessionManager:
Args:
stream_callback: Optional async callback for streaming events.
When provided, events are bridged via agent.stream_emitter.
tool_policy: Optional per-route toolset scope (Issue #2298). When
supplied, the agent's tools are filtered to the
policy-allowed subset for the duration of this turn
and restored afterwards, so an untrusted inbound
route never advertises dangerous tools to the model
while attended/trusted uses of the same shared agent
stay unaffected. Anything exposing ``filter_tools``
is accepted (e.g. ``ToolPolicy`` / ``RunPolicy``).
N4 — Inbound DLQ: if a ``dlq`` was passed to ``__init__`` and
``agent.chat()`` raises, the failing message is persisted to
@@ -419,6 +516,35 @@ class BotSessionManager:
and ``message_id`` is provided, the message is journaled with deduplication
and claim/complete semantics for crash-safe, exactly-once processing.
"""
# Consume any per-route toolset scope staged by a routing handler that
# could not thread it through this call directly (Issue #2298). The
# staged policy is always popped (so it applies exactly once and never
# leaks into a later turn — including the dedup early-return below); an
# explicit ``tool_policy`` argument wins, otherwise the staged one is
# used. ``None`` here means "no policy resolved" (full toolset), so an
# absent explicit arg safely inherits a fail-closed staged scope.
staged_policy = self._pending_tool_policies.pop(id(agent), None)
if tool_policy is None:
tool_policy = staged_policy
# Mint/adopt an end-to-end correlation id and bind it for this turn so
# ingress, the agent run, and outbound delivery share one stable id in
# structured logs. Adopts an explicit correlation_id, else the platform
# message_id, else a fresh id. No-op import failure is non-fatal.
cid = None
cid_token = None
try:
from ._correlation import correlation_id_from, set_correlation_id
cid = correlation_id_from(
{"correlation_id": correlation_id, "message_id": message_id}
)
cid_token = set_correlation_id(cid)
logger.debug(
"bot turn start", extra={"correlation_id": cid, "platform": self._platform}
)
except Exception: # pragma: no cover — defensive
cid = correlation_id or message_id or None
# Handle ingress journaling for durable message processing
journal_key = None
if self._ingress_journal is not None and message_id:
@@ -428,6 +554,7 @@ class BotSessionManager:
"chat_id": chat_id,
"thread_id": thread_id,
"user_name": user_name,
"correlation_id": cid,
}
journal_key = self._ingress_journal.receive(
platform=self._platform or "unknown",
@@ -437,7 +564,15 @@ class BotSessionManager:
payload=payload
)
if journal_key is None:
# Duplicate message - return empty response
# Duplicate message - restore the correlation id before the
# early return so the contextvar set for this turn never leaks
# into an unrelated subsequent call sharing the same task.
if cid_token is not None:
try:
from ._correlation import reset_correlation_id
reset_correlation_id(cid_token)
except Exception as e: # pragma: no cover — defensive
logger.debug("Failed to reset correlation id: %s", e)
return ""
user_lock = self._get_lock(user_id)
@@ -531,6 +666,14 @@ class BotSessionManager:
saved_history = agent.chat_history
agent.chat_history = user_history
# Per-route toolset scope (Issue #2298): swap the
# agent's tools to the policy-allowed subset for this
# turn so untrusted inbound routes never advertise
# dangerous tools to the model. Restored in the finally
# below alongside chat_history/model, so a shared Agent
# instance never leaks a scoped toolset to another turn.
_restore_tools = self._apply_tool_policy(agent, tool_policy)
# Apply a per-user model override (set via the /model
# command) for the duration of this turn only. The swap
# happens inside the per-agent lock and is restored in
@@ -647,6 +790,10 @@ class BotSessionManager:
raise
finally:
agent.chat_history = saved_history
# Restore the agent's original toolset if a
# per-route policy scoped it for this turn.
if _restore_tools is not None:
_restore_tools()
# Restore the agent's original model if we applied a
# per-user override for this turn.
if _model_overridden:
@@ -661,6 +808,28 @@ class BotSessionManager:
None, self._save_history, user_id, updated_history
)
# Normalise an agent-emitted presentation (if any) into
# (text, presentation). Agents/hooks may return a plain str
# (unchanged), a MessagePresentation, or an AgentReply. The
# portable presentation is captured per-user so channel
# adapters can render interactive UI; the text is returned as
# before so the str contract and text fallback are preserved.
try:
from praisonaiagents.bots.agent_reply import extract_presentation
storage_key = self._storage_key(user_id)
text, presentation = extract_presentation(response)
# Always normalise to plain text so chat() never leaks a
# non-str (e.g. AgentReply) past its str contract.
response = text
if presentation is not None:
self._last_presentation[storage_key] = presentation
else:
# Clear any stale UI from an earlier turn so a later
# plain-text turn cannot reuse it via run-control.
self._last_presentation.pop(storage_key, None)
except Exception as e: # pragma: no cover — defensive
logger.debug("presentation extraction skipped: %s", e)
# Store response to return after cleanup
result = response
@@ -687,6 +856,14 @@ class BotSessionManager:
_clear_ctx(ctx_token)
except Exception as e:
logger.debug("Failed to clear task-local session context for ctx_token=%r: %s", ctx_token, e)
# Restore the previous correlation id so the contextvar never leaks
# the id of this turn into an unrelated subsequent call.
if cid_token is not None:
try:
from ._correlation import reset_correlation_id
reset_correlation_id(cid_token)
except Exception as e: # pragma: no cover — defensive
logger.debug("Failed to reset correlation id: %s", e)
async def chat_with_run_control(
self,
@@ -831,6 +1008,12 @@ class BotSessionManager:
if pending_processed:
metadata["pending_processed"] = pending_processed
# Surface any agent-emitted presentation captured during the run(s) so
# run-control callers can render interactive UI alongside the text reply.
presentation = self.pop_last_presentation(user_id)
if presentation is not None:
metadata["presentation"] = presentation
return {"response": last_response, "metadata": metadata}
def reap_stale(self, max_age_seconds: int) -> int:
@@ -928,6 +1111,17 @@ class BotSessionManager:
except Exception as e:
logger.warning("Failed to clear session in store: %s", e)
def pop_last_presentation(self, user_id: str) -> Optional[Any]:
"""Return and clear the last agent-emitted presentation for *user_id*.
Channel adapters call this immediately after ``chat()`` to check whether
the agent attached interactive UI to its reply. Returns the portable
``MessagePresentation`` (which the adapter renders via the per-channel
renderer) or ``None`` when the reply was plain text. Consuming clears it
so a later text-only turn never re-renders stale buttons.
"""
return self._last_presentation.pop(self._storage_key(user_id), None)
def reset(self, user_id: str) -> bool:
"""Clear a user's session history. Returns True if it existed."""
storage_key = self._storage_key(user_id)
+14 -15
View File
@@ -490,25 +490,24 @@ class AgentMailBot(ChatCommandMixin, MessageHookMixin):
async def _handle_email_webhook(self, request: Any) -> Any:
"""Handle incoming webhook POST from AgentMail."""
from aiohttp import web
import hashlib
import hmac
from praisonai.bots.webhook_security import (
verify_hmac,
webhooks_require_verification,
)
body_bytes = await request.read()
secret = os.getenv("AGENTMAIL_WEBHOOK_SECRET") or self._token
secret = os.getenv("AGENTMAIL_WEBHOOK_SECRET", "")
signature = (
request.headers.get("X-AgentMail-Signature")
or request.headers.get("X-Webhook-Signature")
or request.headers.get("X-Signature")
)
if secret:
signature = (
request.headers.get("X-AgentMail-Signature")
or request.headers.get("X-Webhook-Signature")
or request.headers.get("X-Signature")
)
expected = hmac.new(
secret.encode(),
body_bytes,
hashlib.sha256,
).hexdigest()
provided = (signature or "").removeprefix("sha256=")
if not provided or not hmac.compare_digest(provided, expected):
if not verify_hmac(secret, body_bytes, signature):
return web.Response(status=401, text="Invalid webhook signature")
elif webhooks_require_verification():
logger.warning("Webhook rejected: AgentMail signing secret not configured")
return web.Response(status=401, text="Webhook verification not configured")
try:
import json
+217
View File
@@ -77,10 +77,18 @@ class BotOS:
identity_resolver: Optional[Any] = None,
health_monitor: Optional[HealthMonitorConfig] = None,
enable_supervision: bool = True,
idle_policy: Optional[Any] = None,
):
self._bots: Dict[str, Bot] = {}
self._is_running = False
self._config = config
# Issue #2332: opt-in idle-dormancy / scale-to-zero. Default off —
# when None the gateway behaves exactly as before (always-on).
self._idle_policy = idle_policy
self._last_inbound_ts: float = time.time()
self._running_turns: int = 0
self._is_dormant: bool = False
self._on_quiesce = None # optional callable(): host-suspend driver
# W1: shared identity resolver applied to every managed bot —
# gives cross-platform unified-user sessions out of the box.
self._identity_resolver = identity_resolver
@@ -219,6 +227,15 @@ class BotOS:
)
self._tasks.append(schedule_task)
# Issue #2332: opt-in idle-dormancy loop. Only scheduled when an
# idle_policy is configured, so always-on gateways pay zero cost.
if self._idle_policy is not None:
idle_task = asyncio.create_task(
self._run_idle_loop(),
name="botos-idle",
)
self._tasks.append(idle_task)
try:
await asyncio.gather(*self._tasks, return_exceptions=True)
except asyncio.CancelledError:
@@ -247,6 +264,206 @@ class BotOS:
except Exception as e:
logger.error(f"BotOS: {platform} failed: {e}")
# ── Idle-dormancy / scale-to-zero (Issue #2332) ─────────────────
def notify_inbound(self) -> None:
"""Record an inbound message for idle tracking.
Resets the process-wide idle timer. Safe to call always — it is a
cheap timestamp write whether or not an idle policy is configured.
Note: the idle loop also passively probes each bot's session
manager (``_active_runs``/``_last_active``) via
:meth:`_probe_activity`, so live traffic is reflected even without
calling this — these hooks are an optional explicit override for
adapters that don't expose a session manager. When no
``idle_policy`` is configured they are no-op-cheap and the gateway
stays always-on as before.
"""
self._last_inbound_ts = time.time()
def turn_started(self) -> None:
"""Mark an agent turn as in-flight (blocks dormancy)."""
self._running_turns += 1
self._last_inbound_ts = time.time()
def turn_finished(self) -> None:
"""Mark an agent turn as complete."""
if self._running_turns > 0:
self._running_turns -= 1
def _probe_activity(self) -> tuple[int, float]:
"""Passively read live liveness facts from bot session managers.
Closes the "stale counters" gap (#2332 reviewer P1): rather than
relying solely on explicit :meth:`notify_inbound`/:meth:`turn_started`
calls (which not every adapter wires), this reads the session
manager state that *every* adapter already maintains:
* ``_active_runs`` — in-flight agent turns per user, populated and
popped around each agent run.
* ``_last_active`` — per-user last-inbound timestamp (monotonic),
stamped on every inbound message.
Returns ``(running_turns, last_inbound_ts)`` where the timestamp is
wall-clock (``time.time``) to match the policy contract. Explicitly
recorded activity (``self._running_turns`` / ``self._last_inbound_ts``)
is merged in, so adapters that do call the hooks still win and any
adapter that exposes neither degrades to the construction-time value.
"""
running = self._running_turns
last_ts = self._last_inbound_ts
now_wall = time.time()
now_mono = time.monotonic()
for bot in self._bots.values():
session = getattr(bot, "_session", None)
if session is None:
adapter = getattr(bot, "_adapter", None)
session = getattr(adapter, "_session", None)
if session is None:
continue
active_runs = getattr(session, "_active_runs", None)
if isinstance(active_runs, dict):
running += len(active_runs)
last_active = getattr(session, "_last_active", None)
if isinstance(last_active, dict) and last_active:
# Stored as monotonic; convert the most-recent to wall-clock.
newest_mono = max(last_active.values())
wall = now_wall - max(0.0, now_mono - newest_mono)
if wall > last_ts:
last_ts = wall
return running, last_ts
def _has_background_work(self) -> bool:
"""Whether live background work should block dormancy.
Conservative: any *enabled* scheduled job keeps the gateway awake.
Checking only currently-due jobs is unsafe — a job scheduled to
fire after the idle timeout would let the gateway quiesce first,
and the schedule loop would later deliver its output through
stopped transports, losing the result. While transports cannot be
revived in-process, an enabled schedule means the gateway must
stay resident to honour it (#2332 reviewer feedback).
"""
try:
from praisonaiagents.tools.schedule_tools import _get_store
from praisonaiagents.scheduler import ScheduleRunner
except ImportError:
return False
try:
runner = ScheduleRunner(_get_store())
if runner.get_due_jobs():
return True
store = _get_store()
jobs = store.list() if hasattr(store, "list") else []
return any(getattr(job, "enabled", False) for job in jobs)
except Exception:
return False
async def wake(self) -> None:
"""Resume the gateway from dormancy and reconnect transports.
Idempotent: a no-op when not dormant. Reuses ``HookAction.WAKE``
semantics — an inbound poke revives the process and reconnects
the messaging transports with session state preserved.
"""
if not self._is_dormant:
return
logger.info("BotOS: waking from dormancy")
self._is_dormant = False
self.notify_inbound()
# Prune finished task handles so repeated wake cycles don't
# accumulate stale entries.
self._tasks = [t for t in self._tasks if not t.done()]
for platform, bot in self._bots.items():
try:
start = getattr(bot, "start", None)
if start is not None:
task = asyncio.create_task(
self._run_bot(platform, bot),
name=f"botos-{platform}",
)
# Supervise: the main start() gather already returned the
# handles it had at launch, so wake-restarted tasks run
# outside it. Attach a callback to surface their failures.
task.add_done_callback(self._on_wake_task_done)
self._tasks.append(task)
except Exception as e:
logger.warning(f"BotOS: error waking {platform}: {e}")
@staticmethod
def _on_wake_task_done(task: "asyncio.Task") -> None:
"""Surface exceptions from wake-restarted transport tasks."""
if task.cancelled():
return
exc = task.exception()
if exc is not None:
logger.error(f"BotOS: wake task {task.get_name()} failed: {exc}")
async def _quiesce(self, reason: str) -> None:
"""Stand transports down and signal the compute host to suspend."""
if self._is_dormant:
return
logger.info(f"BotOS: quiescing (scale-to-zero): {reason}")
self._is_dormant = True
for platform, bot in self._bots.items():
try:
stop = getattr(bot, "stop", None)
if stop is not None:
await bot.stop()
except Exception as e:
logger.warning(f"BotOS: error quiescing {platform}: {e}")
# Drive the optional compute-host suspend (Fly/Modal/Daytona) only
# after transports are cleanly down, so no inbound is dropped.
if self._on_quiesce is not None:
try:
result = self._on_quiesce()
if asyncio.iscoroutine(result):
await result
except Exception as e:
logger.warning(f"BotOS: on_quiesce driver error: {e}")
async def _run_idle_loop(self) -> None:
"""Evaluate the idle policy and quiesce when fully idle.
Only scheduled when an ``idle_policy`` is configured. The policy
decision is a pure, core-side predicate; this loop supplies live
facts and owns the side effects.
"""
policy = self._idle_policy
wake_url = getattr(policy, "wake_url", None)
# Arm gating: never quiesce into a state we cannot resume from.
if hasattr(policy, "should_arm"):
if not policy.should_arm(
transports_quiescable=True,
wake_registered=wake_url is not None,
):
logger.info(
"BotOS: idle policy not armed (no wake path); staying always-on"
)
return
logger.info("BotOS: idle-dormancy armed (scale-to-zero)")
while self._is_running:
await asyncio.sleep(30)
if self._is_dormant:
continue
try:
# Probe live liveness from session managers so the decision
# reflects real traffic even when adapters don't call the
# explicit notify_inbound/turn_* hooks (#2332 reviewer P1).
running_turns, last_inbound_ts = self._probe_activity()
decision = policy.is_idle(
running_turns=running_turns,
last_inbound_ts=last_inbound_ts,
has_background_work=self._has_background_work(),
now=time.time(),
)
except Exception as e:
logger.debug(f"BotOS: idle evaluation error: {e}")
continue
if getattr(decision, "idle", False):
await self._quiesce(getattr(decision, "reason", ""))
async def _run_schedule_loop(self) -> None:
"""Poll for due scheduled jobs and execute them.
+48
View File
@@ -34,6 +34,11 @@ from ._commands import (
handle_compress_command,
handle_queue_command,
handle_learn_command,
handle_undo_command,
handle_sessions_command,
handle_resume_command,
handle_reasoning_command,
get_last_user_message,
build_command_access_policy,
)
from ._session import BotSessionManager
@@ -301,6 +306,49 @@ class DiscordBot(ChatCommandMixin, MessageHookMixin):
response = handle_learn_command(self._agent, request)
await message.reply(response)
return
elif command == "undo":
response = handle_undo_command(self._agent)
await message.reply(response)
return
elif command == "sessions":
user_id = str(message.author.id)
response = handle_sessions_command(self._session, user_id)
await message.reply(response)
return
elif command == "resume":
user_id = str(message.author.id)
parts = bot_message.text.split(maxsplit=1)
session_id = parts[1] if len(parts) > 1 else None
response = handle_resume_command(self._session, user_id, session_id)
await message.reply(response)
return
elif command == "retry":
user_id = str(message.author.id)
last_user_msg = get_last_user_message(self._session, user_id)
if not last_user_msg:
await message.reply(
"ℹ️ Nothing to retry — no previous message found."
)
return
await message.reply("🔁 Retrying your last message…")
try:
response = await self._session.chat(
self._agent, user_id, last_user_msg,
chat_id=str(message.channel.id),
user_name=str(getattr(message.author, "name", "")),
message_id=str(message.id),
account=self._config.get("account", "default"),
)
await message.reply(response)
except Exception as e: # noqa: BLE001 - surface a friendly message
logger.warning("retry failed: %s", e)
await message.reply(f"❌ Retry failed: {e}")
return
elif command == "reasoning":
user_id = str(message.author.id)
response = handle_reasoning_command(self._session, user_id, self._agent)
await message.reply(response)
return
elif command and command in self._command_handlers:
handler = self._command_handlers[command]
try:
+17 -12
View File
@@ -162,7 +162,19 @@ class LinearBot(ChatCommandMixin, MessageHookMixin):
raise ValueError("LINEAR_OAUTH_TOKEN or LINEAR_API_KEY required")
if not self._signing_secret:
logger.warning("LINEAR_WEBHOOK_SECRET not set - webhook signatures will not be verified")
from praisonai.bots.webhook_security import webhooks_require_verification
if webhooks_require_verification():
logger.warning(
"LINEAR_WEBHOOK_SECRET not set - inbound webhooks will be "
"rejected (fail-closed). Set the secret, or set "
"PRAISONAI_INSECURE_WEBHOOKS=true for local dev only."
)
else:
logger.warning(
"LINEAR_WEBHOOK_SECRET not set and PRAISONAI_INSECURE_WEBHOOKS "
"is enabled - webhook signatures will NOT be verified."
)
logger.info(f"Starting Linear bot on port {self._webhook_port}")
@@ -276,17 +288,10 @@ class LinearBot(ChatCommandMixin, MessageHookMixin):
return web.Response(status=500, text="Internal error")
def _verify_signature(self, body: bytes, signature: str) -> bool:
"""Verify Linear webhook signature using HMAC-SHA256."""
if not self._signing_secret or not signature:
return False
expected = hmac.new(
self._signing_secret.encode(),
body,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
"""Verify Linear webhook signature using shared HMAC-SHA256 helper."""
from praisonai.bots.webhook_security import verify_hmac
return verify_hmac(self._signing_secret, body, signature)
async def _process_webhook(self, event_type: str, body: Dict[str, Any]) -> None:
"""Process webhook events."""
+49
View File
@@ -36,6 +36,11 @@ from ._commands import (
handle_compress_command,
handle_queue_command,
handle_learn_command,
handle_undo_command,
handle_sessions_command,
handle_resume_command,
handle_reasoning_command,
get_last_user_message,
build_command_access_policy,
)
from ._session import BotSessionManager
@@ -293,6 +298,50 @@ class SlackBot(ChatCommandMixin, MessageHookMixin):
response = handle_learn_command(self._agent, request)
await say(text=response, thread_ts=event.get("ts"))
return
elif text == "/undo":
response = handle_undo_command(self._agent)
await say(text=response, thread_ts=event.get("ts"))
return
elif text == "/sessions":
user_id = event.get("user", "unknown")
response = handle_sessions_command(self._session, user_id)
await say(text=response, thread_ts=event.get("ts"))
return
elif text.split(maxsplit=1)[:1] == ["/resume"]:
user_id = event.get("user", "unknown")
parts = text.split(maxsplit=1)
session_id = parts[1] if len(parts) > 1 else None
response = handle_resume_command(self._session, user_id, session_id)
await say(text=response, thread_ts=event.get("ts"))
return
elif text == "/retry":
user_id = event.get("user", "unknown")
last_user_msg = get_last_user_message(self._session, user_id)
if not last_user_msg:
await say(
text="ℹ️ Nothing to retry — no previous message found.",
thread_ts=event.get("ts"),
)
return
await say(text="🔁 Retrying your last message…", thread_ts=event.get("ts"))
try:
response = await self._session.chat(
self._agent, user_id, last_user_msg,
chat_id=str(event.get("channel", "")),
thread_id=event.get("thread_ts", "") or "",
message_id=event.get("ts", ""),
account=self._config.get("account", "default"),
)
await say(text=response, thread_ts=event.get("ts"))
except Exception as e: # noqa: BLE001 - surface a friendly message
logger.warning("retry failed: %s", e)
await say(text=f"❌ Retry failed: {e}", thread_ts=event.get("ts"))
return
elif text == "/reasoning":
user_id = event.get("user", "unknown")
response = handle_reasoning_command(self._session, user_id, self._agent)
await say(text=response, thread_ts=event.get("ts"))
return
for handler in self._message_handlers:
try:
+97
View File
@@ -37,6 +37,11 @@ from ._commands import (
handle_compress_command,
handle_queue_command,
handle_learn_command,
handle_undo_command,
handle_sessions_command,
handle_resume_command,
handle_reasoning_command,
get_last_user_message,
build_command_access_policy,
get_command_registry
)
@@ -765,6 +770,93 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
response = handle_learn_command(self._agent, request)
await update.message.reply_text(response)
async def handle_undo(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not update.message:
return
message = await process_inbound_telegram_message(update, self)
if not message:
return
user_id = message.sender.user_id if message.sender else "unknown"
if not self._command_policy.can_run(user_id, "undo"):
await update.message.reply_text("⛔ You are not permitted to run /undo")
return
response = handle_undo_command(self._agent)
await update.message.reply_text(response)
async def handle_sessions(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not update.message:
return
message = await process_inbound_telegram_message(update, self)
if not message:
return
user_id = message.sender.user_id if message.sender else "unknown"
if not self._command_policy.can_run(user_id, "sessions"):
await update.message.reply_text("⛔ You are not permitted to run /sessions")
return
response = handle_sessions_command(self._session, user_id)
await update.message.reply_text(response)
async def handle_resume(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not update.message or not update.message.text:
return
message = await process_inbound_telegram_message(update, self)
if not message:
return
user_id = message.sender.user_id if message.sender else "unknown"
if not self._command_policy.can_run(user_id, "resume"):
await update.message.reply_text("⛔ You are not permitted to run /resume")
return
parts = update.message.text.split(maxsplit=1)
session_id = parts[1] if len(parts) > 1 else None
response = handle_resume_command(self._session, user_id, session_id)
await update.message.reply_text(response)
async def handle_retry(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not update.message:
return
message = await process_inbound_telegram_message(update, self)
if not message:
return
user_id = message.sender.user_id if message.sender else "unknown"
if not self._command_policy.can_run(user_id, "retry"):
await update.message.reply_text("⛔ You are not permitted to run /retry")
return
last_user_msg = get_last_user_message(self._session, user_id)
if not last_user_msg:
await update.message.reply_text(
"ℹ️ Nothing to retry — no previous message found."
)
return
user_name = (
update.message.from_user.username or update.message.from_user.first_name or ""
) if update.message.from_user else ""
await update.message.reply_text("🔁 Retrying your last message…")
try:
response = await self._session.chat(
self._agent, user_id, last_user_msg,
chat_id=str(update.message.chat_id) if update.message.chat_id else "",
user_name=user_name,
message_id=str(update.message.message_id),
account=self.config.get("account", "default"),
)
await update.message.reply_text(response)
except Exception as e: # noqa: BLE001 - surface a friendly message
logger.warning("retry failed: %s", e)
await update.message.reply_text(f"❌ Retry failed: {e}")
async def handle_reasoning(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not update.message:
return
message = await process_inbound_telegram_message(update, self)
if not message:
return
user_id = message.sender.user_id if message.sender else "unknown"
if not self._command_policy.can_run(user_id, "reasoning"):
await update.message.reply_text("⛔ You are not permitted to run /reasoning")
return
response = handle_reasoning_command(self._session, user_id, self._agent)
await update.message.reply_text(response)
self._application.add_handler(CommandHandler("status", handle_status))
self._application.add_handler(CommandHandler("new", handle_new))
self._application.add_handler(CommandHandler("help", handle_help))
@@ -775,6 +867,11 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
self._application.add_handler(CommandHandler("compress", handle_compress))
self._application.add_handler(CommandHandler("queue", handle_queue))
self._application.add_handler(CommandHandler("learn", handle_learn))
self._application.add_handler(CommandHandler("undo", handle_undo))
self._application.add_handler(CommandHandler("sessions", handle_sessions))
self._application.add_handler(CommandHandler("resume", handle_resume))
self._application.add_handler(CommandHandler("retry", handle_retry))
self._application.add_handler(CommandHandler("reasoning", handle_reasoning))
for command in self._command_handlers:
self._application.add_handler(CommandHandler(command, handle_command))
@@ -1,8 +1,44 @@
"""Shared webhook verification helpers for bot integrations."""
"""Shared webhook verification helpers for bot integrations.
Provides a single, fail-closed contract for inbound webhook authenticity so
adapters stop re-implementing HMAC comparison with divergent failure modes.
Public API:
- webhooks_require_verification() -> bool
- verify_hmac(secret, body, signature, *, digest="sha256", prefix=None) -> bool
- HmacWebhookVerifier — a reusable WebhookVerifierProtocol implementation
- enforce_webhook_verification(...) — central, fail-closed gate for ingress
"""
from __future__ import annotations
import hashlib
import hmac
import logging
import os
from typing import Iterable, Mapping, Optional, Protocol, Union, runtime_checkable
logger = logging.getLogger(__name__)
@runtime_checkable
class _WebhookVerifier(Protocol):
"""Structural type mirroring ``praisonaiagents.bots.WebhookVerifierProtocol``.
Declared locally to keep this wrapper module dependency-light while still
giving static type checkers a precise signature for the ``verifier``
argument of :func:`enforce_webhook_verification`.
"""
def verify(self, *, headers: Mapping[str, str], raw_body: bytes) -> bool: ...
__all__ = [
"HmacWebhookVerifier",
"enforce_webhook_verification",
"verify_hmac",
"webhooks_require_verification",
]
def webhooks_require_verification() -> bool:
@@ -12,3 +48,156 @@ def webhooks_require_verification() -> bool:
"1",
"yes",
)
def verify_hmac(
secret: Union[str, bytes],
body: Union[str, bytes],
signature: Optional[str],
*,
digest: str = "sha256",
prefix: Optional[str] = None,
) -> bool:
"""Constant-time HMAC verification of a webhook body against a signature.
Args:
secret: Shared signing secret.
body: Raw request body bytes (or str, encoded as utf-8).
signature: Signature header value provided by the platform.
digest: Hash algorithm name (e.g. "sha256", "sha1").
prefix: Optional signature prefix. When provided, the comparison is
performed against the fully-prefixed computed value (e.g.
``"sha256=<hex>"``), so callers must pass the raw, prefixed header
value unchanged. When omitted, any ``algo=`` style prefix on the
provided signature is auto-stripped before comparison.
Returns:
True only if a non-empty signature matches the computed HMAC using a
constant-time comparison. Fail-closed: a missing secret/signature or an
unknown ``digest`` algorithm returns False (never raises).
"""
if not secret or not signature:
return False
secret_bytes = secret.encode() if isinstance(secret, str) else secret
body_bytes = body.encode() if isinstance(body, str) else body
try:
digestmod = getattr(hashlib, digest)
except AttributeError:
digestmod = lambda d=b"": hashlib.new(digest, d) # noqa: E731
# Fail-closed on an unknown/invalid algorithm rather than letting
# hmac.new() raise ValueError up the call stack (would surface as HTTP 500).
try:
computed = hmac.new(secret_bytes, body_bytes, digestmod).hexdigest()
except (ValueError, TypeError):
return False
provided = signature
if prefix:
expected = f"{prefix}{computed}"
return hmac.compare_digest(expected, provided)
# Tolerate an "algo=" style prefix on the provided signature.
if "=" in provided and provided.split("=", 1)[0].isalnum():
provided = provided.split("=", 1)[1]
return hmac.compare_digest(computed, provided)
def _first_header(headers: Mapping[str, str], names: Iterable[str]) -> Optional[str]:
"""Case-insensitive lookup of the first present header among ``names``."""
lowered = {k.lower(): v for k, v in headers.items()}
for name in names:
value = lowered.get(name.lower())
if value:
return value
return None
class HmacWebhookVerifier:
"""Reusable :class:`WebhookVerifierProtocol` backed by :func:`verify_hmac`.
Adapters can construct this with their secret and the header name(s) that
carry the signature instead of re-implementing crypto.
Example::
verifier = HmacWebhookVerifier(
secret=app_secret,
signature_headers=["X-Hub-Signature-256"],
prefix="sha256=",
)
if verifier.verify(headers=request.headers, raw_body=body):
...
"""
def __init__(
self,
secret: Union[str, bytes],
*,
signature_headers: Iterable[str],
digest: str = "sha256",
prefix: Optional[str] = None,
) -> None:
self._secret = secret
self._signature_headers = list(signature_headers)
self._digest = digest
self._prefix = prefix
def verify(self, *, headers: Mapping[str, str], raw_body: bytes) -> bool:
signature = _first_header(headers, self._signature_headers)
return verify_hmac(
self._secret,
raw_body,
signature,
digest=self._digest,
prefix=self._prefix,
)
def enforce_webhook_verification(
*,
accepts_webhooks: bool,
verifier: Optional[_WebhookVerifier],
headers: Mapping[str, str],
raw_body: bytes,
platform: str = "",
) -> bool:
"""Central, fail-closed gate for inbound webhooks.
Returns True if the request may proceed, False if it must be rejected
(typically with HTTP 401). Verification is required when the adapter
declares it accepts webhooks, unless ``PRAISONAI_INSECURE_WEBHOOKS`` is
explicitly set for local development.
Args:
accepts_webhooks: Whether this channel is webhook-based.
verifier: An object exposing ``verify(*, headers, raw_body)`` or None.
headers: Inbound request headers.
raw_body: Raw request body bytes.
platform: Platform name, for logging only.
"""
if not accepts_webhooks:
return True
if not webhooks_require_verification():
logger.warning(
"PRAISONAI_INSECURE_WEBHOOKS set: skipping webhook verification for %s",
platform or "unknown",
)
return True
if verifier is None:
logger.warning(
"Webhook rejected for %s: no verifier configured (fail-closed)",
platform or "unknown",
)
return False
try:
return bool(verifier.verify(headers=headers, raw_body=raw_body))
except Exception as exc: # noqa: BLE001 # pragma: no cover - intentional fail-closed
logger.warning("Webhook verification error for %s: %s", platform or "unknown", exc)
return False
+4 -9
View File
@@ -629,15 +629,10 @@ class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
return web.Response(status=200, text="OK")
def _verify_signature(self, body: bytes, signature: str) -> bool:
"""Verify webhook signature using app secret."""
if not signature.startswith("sha256="):
return False
expected = hmac.new(
self._app_secret.encode(),
body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(f"sha256={expected}", signature)
"""Verify webhook signature using app secret (shared HMAC helper)."""
from praisonai.bots.webhook_security import verify_hmac
return verify_hmac(self._app_secret, body, signature, prefix="sha256=")
async def _process_webhook_data(self, data: dict) -> None:
"""Process webhook data and route to appropriate handler."""
Loaded 100 of 161 files, more files were not shown because too many files have changed in this diff. Show more